##// END OF EJS Templates
Fixed issues related to removal of multithread posts
neko259 -
r1708:a6b599ad default
parent child Browse files
Show More
@@ -1,393 +1,386 b''
1 import uuid
1 import uuid
2 import hashlib
2 import hashlib
3 import re
3 import re
4
4
5 from boards import settings
5 from boards import settings
6 from boards.abstracts.tripcode import Tripcode
6 from boards.abstracts.tripcode import Tripcode
7 from boards.models import Attachment, KeyPair, GlobalId
7 from boards.models import Attachment, KeyPair, GlobalId
8 from boards.models.attachment import FILE_TYPES_IMAGE
8 from boards.models.attachment import FILE_TYPES_IMAGE
9 from boards.models.base import Viewable
9 from boards.models.base import Viewable
10 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
10 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
11 from boards.models.post.manager import PostManager, NO_IP
11 from boards.models.post.manager import PostManager, NO_IP
12 from boards.utils import datetime_to_epoch
12 from boards.utils import datetime_to_epoch
13 from django.core.exceptions import ObjectDoesNotExist
13 from django.core.exceptions import ObjectDoesNotExist
14 from django.core.urlresolvers import reverse
14 from django.core.urlresolvers import reverse
15 from django.db import models
15 from django.db import models
16 from django.db.models import TextField, QuerySet, F
16 from django.db.models import TextField, QuerySet, F
17 from django.template.defaultfilters import truncatewords, striptags
17 from django.template.defaultfilters import truncatewords, striptags
18 from django.template.loader import render_to_string
18 from django.template.loader import render_to_string
19
19
20 CSS_CLS_HIDDEN_POST = 'hidden_post'
20 CSS_CLS_HIDDEN_POST = 'hidden_post'
21 CSS_CLS_DEAD_POST = 'dead_post'
21 CSS_CLS_DEAD_POST = 'dead_post'
22 CSS_CLS_ARCHIVE_POST = 'archive_post'
22 CSS_CLS_ARCHIVE_POST = 'archive_post'
23 CSS_CLS_POST = 'post'
23 CSS_CLS_POST = 'post'
24 CSS_CLS_MONOCHROME = 'monochrome'
24 CSS_CLS_MONOCHROME = 'monochrome'
25
25
26 TITLE_MAX_WORDS = 10
26 TITLE_MAX_WORDS = 10
27
27
28 APP_LABEL_BOARDS = 'boards'
28 APP_LABEL_BOARDS = 'boards'
29
29
30 BAN_REASON_AUTO = 'Auto'
30 BAN_REASON_AUTO = 'Auto'
31
31
32 TITLE_MAX_LENGTH = 200
32 TITLE_MAX_LENGTH = 200
33
33
34 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
34 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
35 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
35 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
36 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
36 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
37 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
37 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
38
38
39 PARAMETER_TRUNCATED = 'truncated'
39 PARAMETER_TRUNCATED = 'truncated'
40 PARAMETER_TAG = 'tag'
40 PARAMETER_TAG = 'tag'
41 PARAMETER_OFFSET = 'offset'
41 PARAMETER_OFFSET = 'offset'
42 PARAMETER_DIFF_TYPE = 'type'
42 PARAMETER_DIFF_TYPE = 'type'
43 PARAMETER_CSS_CLASS = 'css_class'
43 PARAMETER_CSS_CLASS = 'css_class'
44 PARAMETER_THREAD = 'thread'
44 PARAMETER_THREAD = 'thread'
45 PARAMETER_IS_OPENING = 'is_opening'
45 PARAMETER_IS_OPENING = 'is_opening'
46 PARAMETER_POST = 'post'
46 PARAMETER_POST = 'post'
47 PARAMETER_OP_ID = 'opening_post_id'
47 PARAMETER_OP_ID = 'opening_post_id'
48 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
48 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
49 PARAMETER_REPLY_LINK = 'reply_link'
49 PARAMETER_REPLY_LINK = 'reply_link'
50 PARAMETER_NEED_OP_DATA = 'need_op_data'
50 PARAMETER_NEED_OP_DATA = 'need_op_data'
51
51
52 POST_VIEW_PARAMS = (
52 POST_VIEW_PARAMS = (
53 'need_op_data',
53 'need_op_data',
54 'reply_link',
54 'reply_link',
55 'need_open_link',
55 'need_open_link',
56 'truncated',
56 'truncated',
57 'mode_tree',
57 'mode_tree',
58 'perms',
58 'perms',
59 'tree_depth',
59 'tree_depth',
60 )
60 )
61
61
62
62
63 class Post(models.Model, Viewable):
63 class Post(models.Model, Viewable):
64 """A post is a message."""
64 """A post is a message."""
65
65
66 objects = PostManager()
66 objects = PostManager()
67
67
68 class Meta:
68 class Meta:
69 app_label = APP_LABEL_BOARDS
69 app_label = APP_LABEL_BOARDS
70 ordering = ('id',)
70 ordering = ('id',)
71
71
72 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
72 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
73 pub_time = models.DateTimeField(db_index=True)
73 pub_time = models.DateTimeField(db_index=True)
74 text = TextField(blank=True, null=True)
74 text = TextField(blank=True, null=True)
75 _text_rendered = TextField(blank=True, null=True, editable=False)
75 _text_rendered = TextField(blank=True, null=True, editable=False)
76
76
77 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
77 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
78 related_name='attachment_posts')
78 related_name='attachment_posts')
79
79
80 poster_ip = models.GenericIPAddressField()
80 poster_ip = models.GenericIPAddressField()
81
81
82 # Used for cache and threads updating
82 # Used for cache and threads updating
83 last_edit_time = models.DateTimeField()
83 last_edit_time = models.DateTimeField()
84
84
85 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
85 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
86 null=True,
86 null=True,
87 blank=True, related_name='refposts',
87 blank=True, related_name='refposts',
88 db_index=True)
88 db_index=True)
89 refmap = models.TextField(null=True, blank=True)
89 refmap = models.TextField(null=True, blank=True)
90 thread = models.ForeignKey('Thread', db_index=True, related_name='replies')
90 thread = models.ForeignKey('Thread', db_index=True, related_name='replies')
91
91
92 url = models.TextField()
92 url = models.TextField()
93 uid = models.TextField(db_index=True)
93 uid = models.TextField(db_index=True)
94
94
95 # Global ID with author key. If the message was downloaded from another
95 # Global ID with author key. If the message was downloaded from another
96 # server, this indicates the server.
96 # server, this indicates the server.
97 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
97 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
98 on_delete=models.CASCADE)
98 on_delete=models.CASCADE)
99
99
100 tripcode = models.CharField(max_length=50, blank=True, default='')
100 tripcode = models.CharField(max_length=50, blank=True, default='')
101 opening = models.BooleanField(db_index=True)
101 opening = models.BooleanField(db_index=True)
102 hidden = models.BooleanField(default=False)
102 hidden = models.BooleanField(default=False)
103 version = models.IntegerField(default=1)
103 version = models.IntegerField(default=1)
104
104
105 def __str__(self):
105 def __str__(self):
106 return 'P#{}/{}'.format(self.id, self.get_title())
106 return 'P#{}/{}'.format(self.id, self.get_title())
107
107
108 def get_title(self) -> str:
108 def get_title(self) -> str:
109 return self.title
109 return self.title
110
110
111 def get_title_or_text(self):
111 def get_title_or_text(self):
112 title = self.get_title()
112 title = self.get_title()
113 if not title:
113 if not title:
114 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
114 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
115
115
116 return title
116 return title
117
117
118 def build_refmap(self, excluded_ids=None) -> None:
118 def build_refmap(self, excluded_ids=None) -> None:
119 """
119 """
120 Builds a replies map string from replies list. This is a cache to stop
120 Builds a replies map string from replies list. This is a cache to stop
121 the server from recalculating the map on every post show.
121 the server from recalculating the map on every post show.
122 """
122 """
123
123
124 replies = self.referenced_posts
124 replies = self.referenced_posts
125 if excluded_ids is not None:
125 if excluded_ids is not None:
126 replies = replies.exclude(id__in=excluded_ids)
126 replies = replies.exclude(id__in=excluded_ids)
127 else:
127 else:
128 replies = replies.all()
128 replies = replies.all()
129
129
130 post_urls = [refpost.get_link_view() for refpost in replies]
130 post_urls = [refpost.get_link_view() for refpost in replies]
131
131
132 self.refmap = ', '.join(post_urls)
132 self.refmap = ', '.join(post_urls)
133
133
134 def is_referenced(self) -> bool:
134 def is_referenced(self) -> bool:
135 return self.refmap and len(self.refmap) > 0
135 return self.refmap and len(self.refmap) > 0
136
136
137 def is_opening(self) -> bool:
137 def is_opening(self) -> bool:
138 """
138 """
139 Checks if this is an opening post or just a reply.
139 Checks if this is an opening post or just a reply.
140 """
140 """
141
141
142 return self.opening
142 return self.opening
143
143
144 def get_absolute_url(self, thread=None):
144 def get_absolute_url(self, thread=None):
145 # Url is cached only for the "main" thread. When getting url
145 # Url is cached only for the "main" thread. When getting url
146 # for other threads, do it manually.
146 # for other threads, do it manually.
147 return self.url
147 return self.url
148
148
149 def get_thread(self):
149 def get_thread(self):
150 return self.thread
150 return self.thread
151
151
152 def get_thread_id(self):
152 def get_thread_id(self):
153 return self.thread_id
153 return self.thread_id
154
154
155 def get_threads(self) -> QuerySet:
156 """
157 Gets post's thread.
158 """
159
160 return self.threads
161
162 def _get_cache_key(self):
155 def _get_cache_key(self):
163 return [datetime_to_epoch(self.last_edit_time)]
156 return [datetime_to_epoch(self.last_edit_time)]
164
157
165 def get_view_params(self, *args, **kwargs):
158 def get_view_params(self, *args, **kwargs):
166 """
159 """
167 Gets the parameters required for viewing the post based on the arguments
160 Gets the parameters required for viewing the post based on the arguments
168 given and the post itself.
161 given and the post itself.
169 """
162 """
170 thread = kwargs.get('thread') or self.get_thread()
163 thread = kwargs.get('thread') or self.get_thread()
171
164
172 css_classes = [CSS_CLS_POST]
165 css_classes = [CSS_CLS_POST]
173 if thread.is_archived():
166 if thread.is_archived():
174 css_classes.append(CSS_CLS_ARCHIVE_POST)
167 css_classes.append(CSS_CLS_ARCHIVE_POST)
175 elif not thread.can_bump():
168 elif not thread.can_bump():
176 css_classes.append(CSS_CLS_DEAD_POST)
169 css_classes.append(CSS_CLS_DEAD_POST)
177 if self.is_hidden():
170 if self.is_hidden():
178 css_classes.append(CSS_CLS_HIDDEN_POST)
171 css_classes.append(CSS_CLS_HIDDEN_POST)
179 if thread.is_monochrome():
172 if thread.is_monochrome():
180 css_classes.append(CSS_CLS_MONOCHROME)
173 css_classes.append(CSS_CLS_MONOCHROME)
181
174
182 params = dict()
175 params = dict()
183 for param in POST_VIEW_PARAMS:
176 for param in POST_VIEW_PARAMS:
184 if param in kwargs:
177 if param in kwargs:
185 params[param] = kwargs[param]
178 params[param] = kwargs[param]
186
179
187 params.update({
180 params.update({
188 PARAMETER_POST: self,
181 PARAMETER_POST: self,
189 PARAMETER_IS_OPENING: self.is_opening(),
182 PARAMETER_IS_OPENING: self.is_opening(),
190 PARAMETER_THREAD: thread,
183 PARAMETER_THREAD: thread,
191 PARAMETER_CSS_CLASS: ' '.join(css_classes),
184 PARAMETER_CSS_CLASS: ' '.join(css_classes),
192 })
185 })
193
186
194 return params
187 return params
195
188
196 def get_view(self, *args, **kwargs) -> str:
189 def get_view(self, *args, **kwargs) -> str:
197 """
190 """
198 Renders post's HTML view. Some of the post params can be passed over
191 Renders post's HTML view. Some of the post params can be passed over
199 kwargs for the means of caching (if we view the thread, some params
192 kwargs for the means of caching (if we view the thread, some params
200 are same for every post and don't need to be computed over and over.
193 are same for every post and don't need to be computed over and over.
201 """
194 """
202 params = self.get_view_params(*args, **kwargs)
195 params = self.get_view_params(*args, **kwargs)
203
196
204 return render_to_string('boards/post.html', params)
197 return render_to_string('boards/post.html', params)
205
198
206 def get_search_view(self, *args, **kwargs):
199 def get_search_view(self, *args, **kwargs):
207 return self.get_view(need_op_data=True, *args, **kwargs)
200 return self.get_view(need_op_data=True, *args, **kwargs)
208
201
209 def get_first_image(self) -> Attachment:
202 def get_first_image(self) -> Attachment:
210 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE).earliest('id')
203 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE).earliest('id')
211
204
212 def set_global_id(self, key_pair=None):
205 def set_global_id(self, key_pair=None):
213 """
206 """
214 Sets global id based on the given key pair. If no key pair is given,
207 Sets global id based on the given key pair. If no key pair is given,
215 default one is used.
208 default one is used.
216 """
209 """
217
210
218 if key_pair:
211 if key_pair:
219 key = key_pair
212 key = key_pair
220 else:
213 else:
221 try:
214 try:
222 key = KeyPair.objects.get(primary=True)
215 key = KeyPair.objects.get(primary=True)
223 except KeyPair.DoesNotExist:
216 except KeyPair.DoesNotExist:
224 # Do not update the global id because there is no key defined
217 # Do not update the global id because there is no key defined
225 return
218 return
226 global_id = GlobalId(key_type=key.key_type,
219 global_id = GlobalId(key_type=key.key_type,
227 key=key.public_key,
220 key=key.public_key,
228 local_id=self.id)
221 local_id=self.id)
229 global_id.save()
222 global_id.save()
230
223
231 self.global_id = global_id
224 self.global_id = global_id
232
225
233 self.save(update_fields=['global_id'])
226 self.save(update_fields=['global_id'])
234
227
235 def get_pub_time_str(self):
228 def get_pub_time_str(self):
236 return str(self.pub_time)
229 return str(self.pub_time)
237
230
238 def get_replied_ids(self):
231 def get_replied_ids(self):
239 """
232 """
240 Gets ID list of the posts that this post replies.
233 Gets ID list of the posts that this post replies.
241 """
234 """
242
235
243 raw_text = self.get_raw_text()
236 raw_text = self.get_raw_text()
244
237
245 local_replied = REGEX_REPLY.findall(raw_text)
238 local_replied = REGEX_REPLY.findall(raw_text)
246 global_replied = []
239 global_replied = []
247 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
240 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
248 key_type = match[0]
241 key_type = match[0]
249 key = match[1]
242 key = match[1]
250 local_id = match[2]
243 local_id = match[2]
251
244
252 try:
245 try:
253 global_id = GlobalId.objects.get(key_type=key_type,
246 global_id = GlobalId.objects.get(key_type=key_type,
254 key=key, local_id=local_id)
247 key=key, local_id=local_id)
255 for post in Post.objects.filter(global_id=global_id).only('id'):
248 for post in Post.objects.filter(global_id=global_id).only('id'):
256 global_replied.append(post.id)
249 global_replied.append(post.id)
257 except GlobalId.DoesNotExist:
250 except GlobalId.DoesNotExist:
258 pass
251 pass
259 return local_replied + global_replied
252 return local_replied + global_replied
260
253
261 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
254 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
262 include_last_update=False) -> str:
255 include_last_update=False) -> str:
263 """
256 """
264 Gets post HTML or JSON data that can be rendered on a page or used by
257 Gets post HTML or JSON data that can be rendered on a page or used by
265 API.
258 API.
266 """
259 """
267
260
268 return get_exporter(format_type).export(self, request,
261 return get_exporter(format_type).export(self, request,
269 include_last_update)
262 include_last_update)
270
263
271 def notify_clients(self, recursive=True):
264 def notify_clients(self, recursive=True):
272 """
265 """
273 Sends post HTML data to the thread web socket.
266 Sends post HTML data to the thread web socket.
274 """
267 """
275
268
276 if not settings.get_bool('External', 'WebsocketsEnabled'):
269 if not settings.get_bool('External', 'WebsocketsEnabled'):
277 return
270 return
278
271
279 thread_ids = list()
272 thread_ids = list()
280 self.get_thread().notify_clients()
273 self.get_thread().notify_clients()
281
274
282 if recursive:
275 if recursive:
283 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
276 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
284 post_id = reply_number.group(1)
277 post_id = reply_number.group(1)
285
278
286 try:
279 try:
287 ref_post = Post.objects.get(id=post_id)
280 ref_post = Post.objects.get(id=post_id)
288
281
289 if ref_post.get_thread().id not in thread_ids:
282 if ref_post.get_thread().id not in thread_ids:
290 # If post is in this thread, its thread was already notified.
283 # If post is in this thread, its thread was already notified.
291 # Otherwise, notify its thread separately.
284 # Otherwise, notify its thread separately.
292 ref_post.notify_clients(recursive=False)
285 ref_post.notify_clients(recursive=False)
293 except ObjectDoesNotExist:
286 except ObjectDoesNotExist:
294 pass
287 pass
295
288
296 def _build_url(self):
289 def _build_url(self):
297 opening = self.is_opening()
290 opening = self.is_opening()
298 opening_id = self.id if opening else self.get_thread().get_opening_post_id()
291 opening_id = self.id if opening else self.get_thread().get_opening_post_id()
299 url = reverse('thread', kwargs={'post_id': opening_id})
292 url = reverse('thread', kwargs={'post_id': opening_id})
300 if not opening:
293 if not opening:
301 url += '#' + str(self.id)
294 url += '#' + str(self.id)
302
295
303 return url
296 return url
304
297
305 def save(self, force_insert=False, force_update=False, using=None,
298 def save(self, force_insert=False, force_update=False, using=None,
306 update_fields=None):
299 update_fields=None):
307 new_post = self.id is None
300 new_post = self.id is None
308
301
309 self.uid = str(uuid.uuid4())
302 self.uid = str(uuid.uuid4())
310 if update_fields is not None and 'uid' not in update_fields:
303 if update_fields is not None and 'uid' not in update_fields:
311 update_fields += ['uid']
304 update_fields += ['uid']
312
305
313 if not new_post:
306 if not new_post:
314 thread = self.get_thread()
307 thread = self.get_thread()
315 if thread:
308 if thread:
316 thread.last_edit_time = self.last_edit_time
309 thread.last_edit_time = self.last_edit_time
317 thread.save(update_fields=['last_edit_time', 'status'])
310 thread.save(update_fields=['last_edit_time', 'status'])
318
311
319 super().save(force_insert, force_update, using, update_fields)
312 super().save(force_insert, force_update, using, update_fields)
320
313
321 if new_post:
314 if new_post:
322 self.url = self._build_url()
315 self.url = self._build_url()
323 super().save(update_fields=['url'])
316 super().save(update_fields=['url'])
324
317
325 def get_text(self) -> str:
318 def get_text(self) -> str:
326 return self._text_rendered
319 return self._text_rendered
327
320
328 def get_raw_text(self) -> str:
321 def get_raw_text(self) -> str:
329 return self.text
322 return self.text
330
323
331 def get_sync_text(self) -> str:
324 def get_sync_text(self) -> str:
332 """
325 """
333 Returns text applicable for sync. It has absolute post reflinks.
326 Returns text applicable for sync. It has absolute post reflinks.
334 """
327 """
335
328
336 replacements = dict()
329 replacements = dict()
337 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
330 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
338 try:
331 try:
339 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
332 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
340 replacements[post_id] = absolute_post_id
333 replacements[post_id] = absolute_post_id
341 except Post.DoesNotExist:
334 except Post.DoesNotExist:
342 pass
335 pass
343
336
344 text = self.get_raw_text() or ''
337 text = self.get_raw_text() or ''
345 for key in replacements:
338 for key in replacements:
346 text = text.replace('[post]{}[/post]'.format(key),
339 text = text.replace('[post]{}[/post]'.format(key),
347 '[post]{}[/post]'.format(replacements[key]))
340 '[post]{}[/post]'.format(replacements[key]))
348 text = text.replace('\r\n', '\n').replace('\r', '\n')
341 text = text.replace('\r\n', '\n').replace('\r', '\n')
349
342
350 return text
343 return text
351
344
352 def get_tripcode(self):
345 def get_tripcode(self):
353 if self.tripcode:
346 if self.tripcode:
354 return Tripcode(self.tripcode)
347 return Tripcode(self.tripcode)
355
348
356 def get_link_view(self):
349 def get_link_view(self):
357 """
350 """
358 Gets view of a reflink to the post.
351 Gets view of a reflink to the post.
359 """
352 """
360 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
353 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
361 self.id)
354 self.id)
362 if self.is_opening():
355 if self.is_opening():
363 result = '<b>{}</b>'.format(result)
356 result = '<b>{}</b>'.format(result)
364
357
365 return result
358 return result
366
359
367 def is_hidden(self) -> bool:
360 def is_hidden(self) -> bool:
368 return self.hidden
361 return self.hidden
369
362
370 def set_hidden(self, hidden):
363 def set_hidden(self, hidden):
371 self.hidden = hidden
364 self.hidden = hidden
372
365
373 def increment_version(self):
366 def increment_version(self):
374 self.version = F('version') + 1
367 self.version = F('version') + 1
375
368
376 def clear_cache(self):
369 def clear_cache(self):
377 """
370 """
378 Clears sync data (content cache, signatures etc).
371 Clears sync data (content cache, signatures etc).
379 """
372 """
380 global_id = self.global_id
373 global_id = self.global_id
381 if global_id is not None and global_id.is_local()\
374 if global_id is not None and global_id.is_local()\
382 and global_id.content is not None:
375 and global_id.content is not None:
383 global_id.clear_cache()
376 global_id.clear_cache()
384
377
385 def get_tags(self):
378 def get_tags(self):
386 return self.get_thread().get_tags()
379 return self.get_thread().get_tags()
387
380
388 def get_ip_color(self):
381 def get_ip_color(self):
389 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
382 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
390
383
391 def has_ip(self):
384 def has_ip(self):
392 return self.poster_ip != NO_IP
385 return self.poster_ip != NO_IP
393
386
@@ -1,327 +1,325 b''
1 import logging
1 import logging
2 from adjacent import Client
2 from adjacent import Client
3 from datetime import timedelta
3 from datetime import timedelta
4
4
5
5
6 from django.db.models import Count, Sum, QuerySet, Q
6 from django.db.models import Count, Sum, QuerySet, Q
7 from django.utils import timezone
7 from django.utils import timezone
8 from django.db import models, transaction
8 from django.db import models, transaction
9
9
10 from boards.models.attachment import FILE_TYPES_IMAGE
10 from boards.models.attachment import FILE_TYPES_IMAGE
11 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
11 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
12
12
13 from boards import settings
13 from boards import settings
14 import boards
14 import boards
15 from boards.utils import cached_result, datetime_to_epoch
15 from boards.utils import cached_result, datetime_to_epoch
16 from boards.models.post import Post
16 from boards.models.post import Post
17 from boards.models.tag import Tag
17 from boards.models.tag import Tag
18
18
19 FAV_THREAD_NO_UPDATES = -1
19 FAV_THREAD_NO_UPDATES = -1
20
20
21
21
22 __author__ = 'neko259'
22 __author__ = 'neko259'
23
23
24
24
25 logger = logging.getLogger(__name__)
25 logger = logging.getLogger(__name__)
26
26
27
27
28 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
28 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
29 WS_NOTIFICATION_TYPE = 'notification_type'
29 WS_NOTIFICATION_TYPE = 'notification_type'
30
30
31 WS_CHANNEL_THREAD = "thread:"
31 WS_CHANNEL_THREAD = "thread:"
32
32
33 STATUS_CHOICES = (
33 STATUS_CHOICES = (
34 (STATUS_ACTIVE, STATUS_ACTIVE),
34 (STATUS_ACTIVE, STATUS_ACTIVE),
35 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
35 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
36 (STATUS_ARCHIVE, STATUS_ARCHIVE),
36 (STATUS_ARCHIVE, STATUS_ARCHIVE),
37 )
37 )
38
38
39
39
40 class ThreadManager(models.Manager):
40 class ThreadManager(models.Manager):
41 def process_old_threads(self):
41 def process_old_threads(self):
42 """
42 """
43 Preserves maximum thread count. If there are too many threads,
43 Preserves maximum thread count. If there are too many threads,
44 archive or delete the old ones.
44 archive or delete the old ones.
45 """
45 """
46 old_time_delta = settings.get_int('Messages', 'ThreadArchiveDays')
46 old_time_delta = settings.get_int('Messages', 'ThreadArchiveDays')
47 old_time = timezone.now() - timedelta(days=old_time_delta)
47 old_time = timezone.now() - timedelta(days=old_time_delta)
48 old_ops = Post.objects.filter(opening=True, pub_time__lte=old_time).exclude(thread__status=STATUS_ARCHIVE)
48 old_ops = Post.objects.filter(opening=True, pub_time__lte=old_time).exclude(thread__status=STATUS_ARCHIVE)
49
49
50 for op in old_ops:
50 for op in old_ops:
51 thread = op.get_thread()
51 thread = op.get_thread()
52 if settings.get_bool('Storage', 'ArchiveThreads'):
52 if settings.get_bool('Storage', 'ArchiveThreads'):
53 self._archive_thread(thread)
53 self._archive_thread(thread)
54 else:
54 else:
55 thread.delete()
55 thread.delete()
56 logger.info('Processed old thread {}'.format(thread))
56 logger.info('Processed old thread {}'.format(thread))
57
57
58
58
59 def _archive_thread(self, thread):
59 def _archive_thread(self, thread):
60 thread.status = STATUS_ARCHIVE
60 thread.status = STATUS_ARCHIVE
61 thread.last_edit_time = timezone.now()
61 thread.last_edit_time = timezone.now()
62 thread.update_posts_time()
62 thread.update_posts_time()
63 thread.save(update_fields=['last_edit_time', 'status'])
63 thread.save(update_fields=['last_edit_time', 'status'])
64
64
65 def get_new_posts(self, datas):
65 def get_new_posts(self, datas):
66 query = None
66 query = None
67 # TODO Use classes instead of dicts
67 # TODO Use classes instead of dicts
68 for data in datas:
68 for data in datas:
69 if data['last_id'] != FAV_THREAD_NO_UPDATES:
69 if data['last_id'] != FAV_THREAD_NO_UPDATES:
70 q = (Q(id=data['op'].get_thread_id())
70 q = (Q(id=data['op'].get_thread_id())
71 & Q(replies__id__gt=data['last_id']))
71 & Q(replies__id__gt=data['last_id']))
72 if query is None:
72 if query is None:
73 query = q
73 query = q
74 else:
74 else:
75 query = query | q
75 query = query | q
76 if query is not None:
76 if query is not None:
77 return self.filter(query).annotate(
77 return self.filter(query).annotate(
78 new_post_count=Count('replies'))
78 new_post_count=Count('replies'))
79
79
80 def get_new_post_count(self, datas):
80 def get_new_post_count(self, datas):
81 new_posts = self.get_new_posts(datas)
81 new_posts = self.get_new_posts(datas)
82 return new_posts.aggregate(total_count=Count('replies'))\
82 return new_posts.aggregate(total_count=Count('replies'))\
83 ['total_count'] if new_posts else 0
83 ['total_count'] if new_posts else 0
84
84
85
85
86 def get_thread_max_posts():
86 def get_thread_max_posts():
87 return settings.get_int('Messages', 'MaxPostsPerThread')
87 return settings.get_int('Messages', 'MaxPostsPerThread')
88
88
89
89
90 class Thread(models.Model):
90 class Thread(models.Model):
91 objects = ThreadManager()
91 objects = ThreadManager()
92
92
93 class Meta:
93 class Meta:
94 app_label = 'boards'
94 app_label = 'boards'
95
95
96 tags = models.ManyToManyField('Tag', related_name='thread_tags')
96 tags = models.ManyToManyField('Tag', related_name='thread_tags')
97 bump_time = models.DateTimeField(db_index=True)
97 bump_time = models.DateTimeField(db_index=True)
98 last_edit_time = models.DateTimeField()
98 last_edit_time = models.DateTimeField()
99 max_posts = models.IntegerField(default=get_thread_max_posts)
99 max_posts = models.IntegerField(default=get_thread_max_posts)
100 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
100 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
101 choices=STATUS_CHOICES, db_index=True)
101 choices=STATUS_CHOICES, db_index=True)
102 monochrome = models.BooleanField(default=False)
102 monochrome = models.BooleanField(default=False)
103
103
104 def get_tags(self) -> QuerySet:
104 def get_tags(self) -> QuerySet:
105 """
105 """
106 Gets a sorted tag list.
106 Gets a sorted tag list.
107 """
107 """
108
108
109 return self.tags.order_by('name')
109 return self.tags.order_by('name')
110
110
111 def bump(self):
111 def bump(self):
112 """
112 """
113 Bumps (moves to up) thread if possible.
113 Bumps (moves to up) thread if possible.
114 """
114 """
115
115
116 if self.can_bump():
116 if self.can_bump():
117 self.bump_time = self.last_edit_time
117 self.bump_time = self.last_edit_time
118
118
119 self.update_bump_status()
119 self.update_bump_status()
120
120
121 logger.info('Bumped thread %d' % self.id)
121 logger.info('Bumped thread %d' % self.id)
122
122
123 def has_post_limit(self) -> bool:
123 def has_post_limit(self) -> bool:
124 return self.max_posts > 0
124 return self.max_posts > 0
125
125
126 def update_bump_status(self, exclude_posts=None):
126 def update_bump_status(self, exclude_posts=None):
127 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
127 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
128 self.status = STATUS_BUMPLIMIT
128 self.status = STATUS_BUMPLIMIT
129 self.update_posts_time(exclude_posts=exclude_posts)
129 self.update_posts_time(exclude_posts=exclude_posts)
130
130
131 def _get_cache_key(self):
131 def _get_cache_key(self):
132 return [datetime_to_epoch(self.last_edit_time)]
132 return [datetime_to_epoch(self.last_edit_time)]
133
133
134 @cached_result(key_method=_get_cache_key)
134 @cached_result(key_method=_get_cache_key)
135 def get_reply_count(self) -> int:
135 def get_reply_count(self) -> int:
136 return self.get_replies().count()
136 return self.get_replies().count()
137
137
138 @cached_result(key_method=_get_cache_key)
138 @cached_result(key_method=_get_cache_key)
139 def get_images_count(self) -> int:
139 def get_images_count(self) -> int:
140 return self.get_replies().filter(
140 return self.get_replies().filter(
141 attachments__mimetype__in=FILE_TYPES_IMAGE)\
141 attachments__mimetype__in=FILE_TYPES_IMAGE)\
142 .annotate(images_count=Count(
142 .annotate(images_count=Count(
143 'attachments')).aggregate(Sum('images_count'))['images_count__sum'] or 0
143 'attachments')).aggregate(Sum('images_count'))['images_count__sum'] or 0
144
144
145 def can_bump(self) -> bool:
145 def can_bump(self) -> bool:
146 """
146 """
147 Checks if the thread can be bumped by replying to it.
147 Checks if the thread can be bumped by replying to it.
148 """
148 """
149
149
150 return self.get_status() == STATUS_ACTIVE
150 return self.get_status() == STATUS_ACTIVE
151
151
152 def get_last_replies(self) -> QuerySet:
152 def get_last_replies(self) -> QuerySet:
153 """
153 """
154 Gets several last replies, not including opening post
154 Gets several last replies, not including opening post
155 """
155 """
156
156
157 last_replies_count = settings.get_int('View', 'LastRepliesCount')
157 last_replies_count = settings.get_int('View', 'LastRepliesCount')
158
158
159 if last_replies_count > 0:
159 if last_replies_count > 0:
160 reply_count = self.get_reply_count()
160 reply_count = self.get_reply_count()
161
161
162 if reply_count > 0:
162 if reply_count > 0:
163 reply_count_to_show = min(last_replies_count,
163 reply_count_to_show = min(last_replies_count,
164 reply_count - 1)
164 reply_count - 1)
165 replies = self.get_replies()
165 replies = self.get_replies()
166 last_replies = replies[reply_count - reply_count_to_show:]
166 last_replies = replies[reply_count - reply_count_to_show:]
167
167
168 return last_replies
168 return last_replies
169
169
170 def get_skipped_replies_count(self) -> int:
170 def get_skipped_replies_count(self) -> int:
171 """
171 """
172 Gets number of posts between opening post and last replies.
172 Gets number of posts between opening post and last replies.
173 """
173 """
174 reply_count = self.get_reply_count()
174 reply_count = self.get_reply_count()
175 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
175 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
176 reply_count - 1)
176 reply_count - 1)
177 return reply_count - last_replies_count - 1
177 return reply_count - last_replies_count - 1
178
178
179 # TODO Remove argument, it is not used
179 # TODO Remove argument, it is not used
180 def get_replies(self, view_fields_only=True) -> QuerySet:
180 def get_replies(self, view_fields_only=True) -> QuerySet:
181 """
181 """
182 Gets sorted thread posts
182 Gets sorted thread posts
183 """
183 """
184 query = self.replies.order_by('pub_time').prefetch_related(
184 query = self.replies.order_by('pub_time').prefetch_related(
185 'attachments')
185 'attachments')
186 return query
186 return query
187
187
188 def get_viewable_replies(self) -> QuerySet:
188 def get_viewable_replies(self) -> QuerySet:
189 """
189 """
190 Gets replies with only fields that are used for viewing.
190 Gets replies with only fields that are used for viewing.
191 """
191 """
192 return self.get_replies().defer('text', 'last_edit_time', 'version')
192 return self.get_replies().defer('text', 'last_edit_time', 'version')
193
193
194 def get_top_level_replies(self) -> QuerySet:
194 def get_top_level_replies(self) -> QuerySet:
195 return self.get_replies().exclude(refposts__threads__in=[self])
195 return self.get_replies().exclude(refposts__threads__in=[self])
196
196
197 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
197 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
198 """
198 """
199 Gets replies that have at least one image attached
199 Gets replies that have at least one image attached
200 """
200 """
201 return self.get_replies(view_fields_only).filter(
201 return self.get_replies(view_fields_only).filter(
202 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
202 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
203 'attachments')).filter(images_count__gt=0)
203 'attachments')).filter(images_count__gt=0)
204
204
205 def get_opening_post(self, only_id=False) -> Post:
205 def get_opening_post(self, only_id=False) -> Post:
206 """
206 """
207 Gets the first post of the thread
207 Gets the first post of the thread
208 """
208 """
209
209
210 query = self.get_replies().filter(opening=True)
210 query = self.get_replies().filter(opening=True)
211 if only_id:
211 if only_id:
212 query = query.only('id')
212 query = query.only('id')
213 opening_post = query.first()
213 opening_post = query.first()
214
214
215 return opening_post
215 return opening_post
216
216
217 @cached_result()
217 @cached_result()
218 def get_opening_post_id(self) -> int:
218 def get_opening_post_id(self) -> int:
219 """
219 """
220 Gets ID of the first thread post.
220 Gets ID of the first thread post.
221 """
221 """
222
222
223 return self.get_opening_post(only_id=True).id
223 return self.get_opening_post(only_id=True).id
224
224
225 def get_pub_time(self):
225 def get_pub_time(self):
226 """
226 """
227 Gets opening post's pub time because thread does not have its own one.
227 Gets opening post's pub time because thread does not have its own one.
228 """
228 """
229
229
230 return self.get_opening_post().pub_time
230 return self.get_opening_post().pub_time
231
231
232 def __str__(self):
232 def __str__(self):
233 return 'T#{}'.format(self.id)
233 return 'T#{}'.format(self.id)
234
234
235 def get_tag_url_list(self) -> list:
235 def get_tag_url_list(self) -> list:
236 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
236 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
237
237
238 def update_posts_time(self, exclude_posts=None):
238 def update_posts_time(self, exclude_posts=None):
239 last_edit_time = self.last_edit_time
239 last_edit_time = self.last_edit_time
240
240
241 for post in self.multi_replies.all():
241 for post in self.replies.all():
242 if exclude_posts is None or post not in exclude_posts:
242 if exclude_posts is None or post not in exclude_posts:
243 # Manual update is required because uids are generated on save
243 # Manual update is required because uids are generated on save
244 post.last_edit_time = last_edit_time
244 post.last_edit_time = last_edit_time
245 post.save(update_fields=['last_edit_time'])
245 post.save(update_fields=['last_edit_time'])
246
246
247 post.get_threads().update(last_edit_time=last_edit_time)
248
249 def notify_clients(self):
247 def notify_clients(self):
250 if not settings.get_bool('External', 'WebsocketsEnabled'):
248 if not settings.get_bool('External', 'WebsocketsEnabled'):
251 return
249 return
252
250
253 client = Client()
251 client = Client()
254
252
255 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
253 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
256 client.publish(channel_name, {
254 client.publish(channel_name, {
257 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
255 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
258 })
256 })
259 client.send()
257 client.send()
260
258
261 def get_absolute_url(self):
259 def get_absolute_url(self):
262 return self.get_opening_post().get_absolute_url()
260 return self.get_opening_post().get_absolute_url()
263
261
264 def get_required_tags(self):
262 def get_required_tags(self):
265 return self.get_tags().filter(required=True)
263 return self.get_tags().filter(required=True)
266
264
267 def get_replies_newer(self, post_id):
265 def get_replies_newer(self, post_id):
268 return self.get_replies().filter(id__gt=post_id)
266 return self.get_replies().filter(id__gt=post_id)
269
267
270 def is_archived(self):
268 def is_archived(self):
271 return self.get_status() == STATUS_ARCHIVE
269 return self.get_status() == STATUS_ARCHIVE
272
270
273 def get_status(self):
271 def get_status(self):
274 return self.status
272 return self.status
275
273
276 def is_monochrome(self):
274 def is_monochrome(self):
277 return self.monochrome
275 return self.monochrome
278
276
279 # If tags have parent, add them to the tag list
277 # If tags have parent, add them to the tag list
280 @transaction.atomic
278 @transaction.atomic
281 def refresh_tags(self):
279 def refresh_tags(self):
282 for tag in self.get_tags().all():
280 for tag in self.get_tags().all():
283 parents = tag.get_all_parents()
281 parents = tag.get_all_parents()
284 if len(parents) > 0:
282 if len(parents) > 0:
285 self.tags.add(*parents)
283 self.tags.add(*parents)
286
284
287 def get_reply_tree(self):
285 def get_reply_tree(self):
288 replies = self.get_replies().prefetch_related('refposts')
286 replies = self.get_replies().prefetch_related('refposts')
289 tree = []
287 tree = []
290 for reply in replies:
288 for reply in replies:
291 parents = reply.refposts.all()
289 parents = reply.refposts.all()
292
290
293 found_parent = False
291 found_parent = False
294 searching_for_index = False
292 searching_for_index = False
295
293
296 if len(parents) > 0:
294 if len(parents) > 0:
297 index = 0
295 index = 0
298 parent_depth = 0
296 parent_depth = 0
299
297
300 indexes_to_insert = []
298 indexes_to_insert = []
301
299
302 for depth, element in tree:
300 for depth, element in tree:
303 index += 1
301 index += 1
304
302
305 # If this element is next after parent on the same level,
303 # If this element is next after parent on the same level,
306 # insert child before it
304 # insert child before it
307 if searching_for_index and depth <= parent_depth:
305 if searching_for_index and depth <= parent_depth:
308 indexes_to_insert.append((index - 1, parent_depth))
306 indexes_to_insert.append((index - 1, parent_depth))
309 searching_for_index = False
307 searching_for_index = False
310
308
311 if element in parents:
309 if element in parents:
312 found_parent = True
310 found_parent = True
313 searching_for_index = True
311 searching_for_index = True
314 parent_depth = depth
312 parent_depth = depth
315
313
316 if not found_parent:
314 if not found_parent:
317 tree.append((0, reply))
315 tree.append((0, reply))
318 else:
316 else:
319 if searching_for_index:
317 if searching_for_index:
320 tree.append((parent_depth + 1, reply))
318 tree.append((parent_depth + 1, reply))
321
319
322 offset = 0
320 offset = 0
323 for last_index, parent_depth in indexes_to_insert:
321 for last_index, parent_depth in indexes_to_insert:
324 tree.insert(last_index + offset, (parent_depth + 1, reply))
322 tree.insert(last_index + offset, (parent_depth + 1, reply))
325 offset += 1
323 offset += 1
326
324
327 return tree
325 return tree
General Comments 0
You need to be logged in to leave comments. Login now