##// END OF EJS Templates
Do not connect already connected replies
neko259 -
r1542:5966db37 default
parent child Browse files
Show More
@@ -1,367 +1,369
1 import logging
1 import logging
2 import re
2 import re
3 import uuid
3 import uuid
4
4
5 from boards.utils import datetime_to_epoch
5 from boards.utils import datetime_to_epoch
6 from django.core.exceptions import ObjectDoesNotExist
6 from django.core.exceptions import ObjectDoesNotExist
7 from django.core.urlresolvers import reverse
7 from django.core.urlresolvers import reverse
8 from django.db import models
8 from django.db import models
9 from django.db.models import TextField, QuerySet
9 from django.db.models import TextField, QuerySet
10 from django.template.defaultfilters import striptags, truncatewords
10 from django.template.defaultfilters import striptags, truncatewords
11 from django.template.loader import render_to_string
11 from django.template.loader import render_to_string
12 from django.utils import timezone
12 from django.utils import timezone
13 from django.db.models.signals import post_save, pre_save
13 from django.db.models.signals import post_save, pre_save
14 from django.dispatch import receiver
14 from django.dispatch import receiver
15
15
16 from boards import settings
16 from boards import settings
17 from boards.abstracts.tripcode import Tripcode
17 from boards.abstracts.tripcode import Tripcode
18 from boards.mdx_neboard import get_parser
18 from boards.mdx_neboard import get_parser
19 from boards.models import PostImage, Attachment
19 from boards.models import PostImage, Attachment
20 from boards.models.base import Viewable
20 from boards.models.base import Viewable
21 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
21 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
22 from boards.models.post.manager import PostManager
22 from boards.models.post.manager import PostManager
23 from boards.models.user import Notification
23 from boards.models.user import Notification
24
24
25 CSS_CLS_HIDDEN_POST = 'hidden_post'
25 CSS_CLS_HIDDEN_POST = 'hidden_post'
26 CSS_CLS_DEAD_POST = 'dead_post'
26 CSS_CLS_DEAD_POST = 'dead_post'
27 CSS_CLS_ARCHIVE_POST = 'archive_post'
27 CSS_CLS_ARCHIVE_POST = 'archive_post'
28 CSS_CLS_POST = 'post'
28 CSS_CLS_POST = 'post'
29 CSS_CLS_MONOCHROME = 'monochrome'
29 CSS_CLS_MONOCHROME = 'monochrome'
30
30
31 TITLE_MAX_WORDS = 10
31 TITLE_MAX_WORDS = 10
32
32
33 APP_LABEL_BOARDS = 'boards'
33 APP_LABEL_BOARDS = 'boards'
34
34
35 BAN_REASON_AUTO = 'Auto'
35 BAN_REASON_AUTO = 'Auto'
36
36
37 IMAGE_THUMB_SIZE = (200, 150)
37 IMAGE_THUMB_SIZE = (200, 150)
38
38
39 TITLE_MAX_LENGTH = 200
39 TITLE_MAX_LENGTH = 200
40
40
41 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
41 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
42 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
42 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
43
43
44 PARAMETER_TRUNCATED = 'truncated'
44 PARAMETER_TRUNCATED = 'truncated'
45 PARAMETER_TAG = 'tag'
45 PARAMETER_TAG = 'tag'
46 PARAMETER_OFFSET = 'offset'
46 PARAMETER_OFFSET = 'offset'
47 PARAMETER_DIFF_TYPE = 'type'
47 PARAMETER_DIFF_TYPE = 'type'
48 PARAMETER_CSS_CLASS = 'css_class'
48 PARAMETER_CSS_CLASS = 'css_class'
49 PARAMETER_THREAD = 'thread'
49 PARAMETER_THREAD = 'thread'
50 PARAMETER_IS_OPENING = 'is_opening'
50 PARAMETER_IS_OPENING = 'is_opening'
51 PARAMETER_POST = 'post'
51 PARAMETER_POST = 'post'
52 PARAMETER_OP_ID = 'opening_post_id'
52 PARAMETER_OP_ID = 'opening_post_id'
53 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
53 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
54 PARAMETER_REPLY_LINK = 'reply_link'
54 PARAMETER_REPLY_LINK = 'reply_link'
55 PARAMETER_NEED_OP_DATA = 'need_op_data'
55 PARAMETER_NEED_OP_DATA = 'need_op_data'
56
56
57 POST_VIEW_PARAMS = (
57 POST_VIEW_PARAMS = (
58 'need_op_data',
58 'need_op_data',
59 'reply_link',
59 'reply_link',
60 'need_open_link',
60 'need_open_link',
61 'truncated',
61 'truncated',
62 'mode_tree',
62 'mode_tree',
63 'perms',
63 'perms',
64 'tree_depth',
64 'tree_depth',
65 )
65 )
66
66
67
67
68 class Post(models.Model, Viewable):
68 class Post(models.Model, Viewable):
69 """A post is a message."""
69 """A post is a message."""
70
70
71 objects = PostManager()
71 objects = PostManager()
72
72
73 class Meta:
73 class Meta:
74 app_label = APP_LABEL_BOARDS
74 app_label = APP_LABEL_BOARDS
75 ordering = ('id',)
75 ordering = ('id',)
76
76
77 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
77 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
78 pub_time = models.DateTimeField()
78 pub_time = models.DateTimeField()
79 text = TextField(blank=True, null=True)
79 text = TextField(blank=True, null=True)
80 _text_rendered = TextField(blank=True, null=True, editable=False)
80 _text_rendered = TextField(blank=True, null=True, editable=False)
81
81
82 images = models.ManyToManyField(PostImage, null=True, blank=True,
82 images = models.ManyToManyField(PostImage, null=True, blank=True,
83 related_name='post_images', db_index=True)
83 related_name='post_images', db_index=True)
84 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
84 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
85 related_name='attachment_posts')
85 related_name='attachment_posts')
86
86
87 poster_ip = models.GenericIPAddressField()
87 poster_ip = models.GenericIPAddressField()
88
88
89 # TODO This field can be removed cause UID is used for update now
89 # TODO This field can be removed cause UID is used for update now
90 last_edit_time = models.DateTimeField()
90 last_edit_time = models.DateTimeField()
91
91
92 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
92 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
93 null=True,
93 null=True,
94 blank=True, related_name='refposts',
94 blank=True, related_name='refposts',
95 db_index=True)
95 db_index=True)
96 refmap = models.TextField(null=True, blank=True)
96 refmap = models.TextField(null=True, blank=True)
97 threads = models.ManyToManyField('Thread', db_index=True,
97 threads = models.ManyToManyField('Thread', db_index=True,
98 related_name='multi_replies')
98 related_name='multi_replies')
99 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
99 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
100
100
101 url = models.TextField()
101 url = models.TextField()
102 uid = models.TextField(db_index=True)
102 uid = models.TextField(db_index=True)
103
103
104 tripcode = models.CharField(max_length=50, blank=True, default='')
104 tripcode = models.CharField(max_length=50, blank=True, default='')
105 opening = models.BooleanField(db_index=True)
105 opening = models.BooleanField(db_index=True)
106 hidden = models.BooleanField(default=False)
106 hidden = models.BooleanField(default=False)
107
107
108 def __str__(self):
108 def __str__(self):
109 return 'P#{}/{}'.format(self.id, self.get_title())
109 return 'P#{}/{}'.format(self.id, self.get_title())
110
110
111 def get_title(self) -> str:
111 def get_title(self) -> str:
112 return self.title
112 return self.title
113
113
114 def get_title_or_text(self):
114 def get_title_or_text(self):
115 title = self.get_title()
115 title = self.get_title()
116 if not title:
116 if not title:
117 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
117 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
118
118
119 return title
119 return title
120
120
121 def build_refmap(self) -> None:
121 def build_refmap(self) -> None:
122 """
122 """
123 Builds a replies map string from replies list. This is a cache to stop
123 Builds a replies map string from replies list. This is a cache to stop
124 the server from recalculating the map on every post show.
124 the server from recalculating the map on every post show.
125 """
125 """
126
126
127 post_urls = [refpost.get_link_view()
127 post_urls = [refpost.get_link_view()
128 for refpost in self.referenced_posts.all()]
128 for refpost in self.referenced_posts.all()]
129
129
130 self.refmap = ', '.join(post_urls)
130 self.refmap = ', '.join(post_urls)
131
131
132 def is_referenced(self) -> bool:
132 def is_referenced(self) -> bool:
133 return self.refmap and len(self.refmap) > 0
133 return self.refmap and len(self.refmap) > 0
134
134
135 def is_opening(self) -> bool:
135 def is_opening(self) -> bool:
136 """
136 """
137 Checks if this is an opening post or just a reply.
137 Checks if this is an opening post or just a reply.
138 """
138 """
139
139
140 return self.opening
140 return self.opening
141
141
142 def get_absolute_url(self, thread=None):
142 def get_absolute_url(self, thread=None):
143 url = None
143 url = None
144
144
145 if thread is None:
145 if thread is None:
146 thread = self.get_thread()
146 thread = self.get_thread()
147
147
148 # Url is cached only for the "main" thread. When getting url
148 # Url is cached only for the "main" thread. When getting url
149 # for other threads, do it manually.
149 # for other threads, do it manually.
150 if self.url:
150 if self.url:
151 url = self.url
151 url = self.url
152
152
153 if url is None:
153 if url is None:
154 opening = self.is_opening()
154 opening = self.is_opening()
155 opening_id = self.id if opening else thread.get_opening_post_id()
155 opening_id = self.id if opening else thread.get_opening_post_id()
156 url = reverse('thread', kwargs={'post_id': opening_id})
156 url = reverse('thread', kwargs={'post_id': opening_id})
157 if not opening:
157 if not opening:
158 url += '#' + str(self.id)
158 url += '#' + str(self.id)
159
159
160 return url
160 return url
161
161
162 def get_thread(self):
162 def get_thread(self):
163 return self.thread
163 return self.thread
164
164
165 def get_thread_id(self):
165 def get_thread_id(self):
166 return self.thread_id
166 return self.thread_id
167
167
168 def get_threads(self) -> QuerySet:
168 def get_threads(self) -> QuerySet:
169 """
169 """
170 Gets post's thread.
170 Gets post's thread.
171 """
171 """
172
172
173 return self.threads
173 return self.threads
174
174
175 def _get_cache_key(self):
175 def _get_cache_key(self):
176 return [datetime_to_epoch(self.last_edit_time)]
176 return [datetime_to_epoch(self.last_edit_time)]
177
177
178 def get_view(self, *args, **kwargs) -> str:
178 def get_view(self, *args, **kwargs) -> str:
179 """
179 """
180 Renders post's HTML view. Some of the post params can be passed over
180 Renders post's HTML view. Some of the post params can be passed over
181 kwargs for the means of caching (if we view the thread, some params
181 kwargs for the means of caching (if we view the thread, some params
182 are same for every post and don't need to be computed over and over.
182 are same for every post and don't need to be computed over and over.
183 """
183 """
184
184
185 thread = self.get_thread()
185 thread = self.get_thread()
186
186
187 css_classes = [CSS_CLS_POST]
187 css_classes = [CSS_CLS_POST]
188 if thread.is_archived():
188 if thread.is_archived():
189 css_classes.append(CSS_CLS_ARCHIVE_POST)
189 css_classes.append(CSS_CLS_ARCHIVE_POST)
190 elif not thread.can_bump():
190 elif not thread.can_bump():
191 css_classes.append(CSS_CLS_DEAD_POST)
191 css_classes.append(CSS_CLS_DEAD_POST)
192 if self.is_hidden():
192 if self.is_hidden():
193 css_classes.append(CSS_CLS_HIDDEN_POST)
193 css_classes.append(CSS_CLS_HIDDEN_POST)
194 if thread.is_monochrome():
194 if thread.is_monochrome():
195 css_classes.append(CSS_CLS_MONOCHROME)
195 css_classes.append(CSS_CLS_MONOCHROME)
196
196
197 params = dict()
197 params = dict()
198 for param in POST_VIEW_PARAMS:
198 for param in POST_VIEW_PARAMS:
199 if param in kwargs:
199 if param in kwargs:
200 params[param] = kwargs[param]
200 params[param] = kwargs[param]
201
201
202 params.update({
202 params.update({
203 PARAMETER_POST: self,
203 PARAMETER_POST: self,
204 PARAMETER_IS_OPENING: self.is_opening(),
204 PARAMETER_IS_OPENING: self.is_opening(),
205 PARAMETER_THREAD: thread,
205 PARAMETER_THREAD: thread,
206 PARAMETER_CSS_CLASS: ' '.join(css_classes),
206 PARAMETER_CSS_CLASS: ' '.join(css_classes),
207 })
207 })
208
208
209 return render_to_string('boards/post.html', params)
209 return render_to_string('boards/post.html', params)
210
210
211 def get_search_view(self, *args, **kwargs):
211 def get_search_view(self, *args, **kwargs):
212 return self.get_view(need_op_data=True, *args, **kwargs)
212 return self.get_view(need_op_data=True, *args, **kwargs)
213
213
214 def get_first_image(self) -> PostImage:
214 def get_first_image(self) -> PostImage:
215 return self.images.earliest('id')
215 return self.images.earliest('id')
216
216
217 def delete(self, using=None):
217 def delete(self, using=None):
218 """
218 """
219 Deletes all post images and the post itself.
219 Deletes all post images and the post itself.
220 """
220 """
221
221
222 for image in self.images.all():
222 for image in self.images.all():
223 image_refs_count = image.post_images.count()
223 image_refs_count = image.post_images.count()
224 if image_refs_count == 1:
224 if image_refs_count == 1:
225 image.delete()
225 image.delete()
226
226
227 for attachment in self.attachments.all():
227 for attachment in self.attachments.all():
228 attachment_refs_count = attachment.attachment_posts.count()
228 attachment_refs_count = attachment.attachment_posts.count()
229 if attachment_refs_count == 1:
229 if attachment_refs_count == 1:
230 attachment.delete()
230 attachment.delete()
231
231
232 thread = self.get_thread()
232 thread = self.get_thread()
233 thread.last_edit_time = timezone.now()
233 thread.last_edit_time = timezone.now()
234 thread.save()
234 thread.save()
235
235
236 super(Post, self).delete(using)
236 super(Post, self).delete(using)
237
237
238 logging.getLogger('boards.post.delete').info(
238 logging.getLogger('boards.post.delete').info(
239 'Deleted post {}'.format(self))
239 'Deleted post {}'.format(self))
240
240
241 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
241 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
242 include_last_update=False) -> str:
242 include_last_update=False) -> str:
243 """
243 """
244 Gets post HTML or JSON data that can be rendered on a page or used by
244 Gets post HTML or JSON data that can be rendered on a page or used by
245 API.
245 API.
246 """
246 """
247
247
248 return get_exporter(format_type).export(self, request,
248 return get_exporter(format_type).export(self, request,
249 include_last_update)
249 include_last_update)
250
250
251 def notify_clients(self, recursive=True):
251 def notify_clients(self, recursive=True):
252 """
252 """
253 Sends post HTML data to the thread web socket.
253 Sends post HTML data to the thread web socket.
254 """
254 """
255
255
256 if not settings.get_bool('External', 'WebsocketsEnabled'):
256 if not settings.get_bool('External', 'WebsocketsEnabled'):
257 return
257 return
258
258
259 thread_ids = list()
259 thread_ids = list()
260 for thread in self.get_threads().all():
260 for thread in self.get_threads().all():
261 thread_ids.append(thread.id)
261 thread_ids.append(thread.id)
262
262
263 thread.notify_clients()
263 thread.notify_clients()
264
264
265 if recursive:
265 if recursive:
266 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
266 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
267 post_id = reply_number.group(1)
267 post_id = reply_number.group(1)
268
268
269 try:
269 try:
270 ref_post = Post.objects.get(id=post_id)
270 ref_post = Post.objects.get(id=post_id)
271
271
272 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
272 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
273 # If post is in this thread, its thread was already notified.
273 # If post is in this thread, its thread was already notified.
274 # Otherwise, notify its thread separately.
274 # Otherwise, notify its thread separately.
275 ref_post.notify_clients(recursive=False)
275 ref_post.notify_clients(recursive=False)
276 except ObjectDoesNotExist:
276 except ObjectDoesNotExist:
277 pass
277 pass
278
278
279 def build_url(self):
279 def build_url(self):
280 self.url = self.get_absolute_url()
280 self.url = self.get_absolute_url()
281 self.save(update_fields=['url'])
281 self.save(update_fields=['url'])
282
282
283 def save(self, force_insert=False, force_update=False, using=None,
283 def save(self, force_insert=False, force_update=False, using=None,
284 update_fields=None):
284 update_fields=None):
285 new_post = self.id is None
285 new_post = self.id is None
286
286
287 self.uid = str(uuid.uuid4())
287 self.uid = str(uuid.uuid4())
288 if update_fields is not None and 'uid' not in update_fields:
288 if update_fields is not None and 'uid' not in update_fields:
289 update_fields += ['uid']
289 update_fields += ['uid']
290
290
291 if not new_post:
291 if not new_post:
292 for thread in self.get_threads().all():
292 for thread in self.get_threads().all():
293 thread.last_edit_time = self.last_edit_time
293 thread.last_edit_time = self.last_edit_time
294
294
295 thread.save(update_fields=['last_edit_time', 'status'])
295 thread.save(update_fields=['last_edit_time', 'status'])
296
296
297 super().save(force_insert, force_update, using, update_fields)
297 super().save(force_insert, force_update, using, update_fields)
298
298
299 if self.url is None:
299 if self.url is None:
300 self.build_url()
300 self.build_url()
301
301
302 def get_text(self) -> str:
302 def get_text(self) -> str:
303 return self._text_rendered
303 return self._text_rendered
304
304
305 def get_raw_text(self) -> str:
305 def get_raw_text(self) -> str:
306 return self.text
306 return self.text
307
307
308 def connect_threads(self, opening_posts):
308 def connect_threads(self, opening_posts):
309 for opening_post in opening_posts:
309 for opening_post in opening_posts:
310 threads = opening_post.get_threads().all()
310 threads = opening_post.get_threads().all()
311 for thread in threads:
311 for thread in threads:
312 if thread.can_bump():
312 if thread.can_bump():
313 thread.update_bump_status()
313 thread.update_bump_status()
314
314
315 thread.last_edit_time = self.last_edit_time
315 thread.last_edit_time = self.last_edit_time
316 thread.save(update_fields=['last_edit_time', 'status'])
316 thread.save(update_fields=['last_edit_time', 'status'])
317 self.threads.add(opening_post.get_thread())
317 self.threads.add(opening_post.get_thread())
318
318
319 def get_tripcode(self):
319 def get_tripcode(self):
320 if self.tripcode:
320 if self.tripcode:
321 return Tripcode(self.tripcode)
321 return Tripcode(self.tripcode)
322
322
323 def get_link_view(self):
323 def get_link_view(self):
324 """
324 """
325 Gets view of a reflink to the post.
325 Gets view of a reflink to the post.
326 """
326 """
327 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
327 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
328 self.id)
328 self.id)
329 if self.is_opening():
329 if self.is_opening():
330 result = '<b>{}</b>'.format(result)
330 result = '<b>{}</b>'.format(result)
331
331
332 return result
332 return result
333
333
334 def is_hidden(self) -> bool:
334 def is_hidden(self) -> bool:
335 return self.hidden
335 return self.hidden
336
336
337 def set_hidden(self, hidden):
337 def set_hidden(self, hidden):
338 self.hidden = hidden
338 self.hidden = hidden
339
339
340
340
341 # SIGNALS (Maybe move to other module?)
341 # SIGNALS (Maybe move to other module?)
342 @receiver(post_save, sender=Post)
342 @receiver(post_save, sender=Post)
343 def connect_replies(instance, **kwargs):
343 def connect_replies(instance, **kwargs):
344 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
344 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
345 post_id = reply_number.group(1)
345 post_id = reply_number.group(1)
346
346
347 try:
347 try:
348 referenced_post = Post.objects.get(id=post_id)
348 referenced_post = Post.objects.get(id=post_id)
349
349
350 referenced_post.referenced_posts.add(instance)
350 # Connect only to posts that are not connected to already
351 referenced_post.last_edit_time = instance.pub_time
351 if not referenced_post.referenced_posts.filter(id=instance.id).exists():
352 referenced_post.build_refmap()
352 referenced_post.referenced_posts.add(instance)
353 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
353 referenced_post.last_edit_time = instance.pub_time
354 referenced_post.build_refmap()
355 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
354 except ObjectDoesNotExist:
356 except ObjectDoesNotExist:
355 pass
357 pass
356
358
357
359
358 @receiver(post_save, sender=Post)
360 @receiver(post_save, sender=Post)
359 def connect_notifications(instance, **kwargs):
361 def connect_notifications(instance, **kwargs):
360 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
362 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
361 user_name = reply_number.group(1).lower()
363 user_name = reply_number.group(1).lower()
362 Notification.objects.get_or_create(name=user_name, post=instance)
364 Notification.objects.get_or_create(name=user_name, post=instance)
363
365
364
366
365 @receiver(pre_save, sender=Post)
367 @receiver(pre_save, sender=Post)
366 def preparse_text(instance, **kwargs):
368 def preparse_text(instance, **kwargs):
367 instance._text_rendered = get_parser().parse(instance.get_raw_text())
369 instance._text_rendered = get_parser().parse(instance.get_raw_text())
General Comments 0
You need to be logged in to leave comments. Login now