##// END OF EJS Templates
Post URL loading optimizations
neko259 -
r1668:b8867a5d default
parent child Browse files
Show More
@@ -1,414 +1,410 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
11 from boards.models.post.manager import PostManager
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 threads = models.ManyToManyField('Thread', db_index=True,
90 threads = models.ManyToManyField('Thread', db_index=True,
91 related_name='multi_replies')
91 related_name='multi_replies')
92 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
92 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
93
93
94 url = models.TextField()
94 url = models.TextField()
95 uid = models.TextField(db_index=True)
95 uid = models.TextField(db_index=True)
96
96
97 # Global ID with author key. If the message was downloaded from another
97 # Global ID with author key. If the message was downloaded from another
98 # server, this indicates the server.
98 # server, this indicates the server.
99 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
99 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
100 on_delete=models.CASCADE)
100 on_delete=models.CASCADE)
101
101
102 tripcode = models.CharField(max_length=50, blank=True, default='')
102 tripcode = models.CharField(max_length=50, blank=True, default='')
103 opening = models.BooleanField(db_index=True)
103 opening = models.BooleanField(db_index=True)
104 hidden = models.BooleanField(default=False)
104 hidden = models.BooleanField(default=False)
105 version = models.IntegerField(default=1)
105 version = models.IntegerField(default=1)
106
106
107 def __str__(self):
107 def __str__(self):
108 return 'P#{}/{}'.format(self.id, self.get_title())
108 return 'P#{}/{}'.format(self.id, self.get_title())
109
109
110 def get_title(self) -> str:
110 def get_title(self) -> str:
111 return self.title
111 return self.title
112
112
113 def get_title_or_text(self):
113 def get_title_or_text(self):
114 title = self.get_title()
114 title = self.get_title()
115 if not title:
115 if not title:
116 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
116 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
117
117
118 return title
118 return title
119
119
120 def build_refmap(self, excluded_ids=None) -> None:
120 def build_refmap(self, excluded_ids=None) -> None:
121 """
121 """
122 Builds a replies map string from replies list. This is a cache to stop
122 Builds a replies map string from replies list. This is a cache to stop
123 the server from recalculating the map on every post show.
123 the server from recalculating the map on every post show.
124 """
124 """
125
125
126 replies = self.referenced_posts
126 replies = self.referenced_posts
127 if excluded_ids is not None:
127 if excluded_ids is not None:
128 replies = replies.exclude(id__in=excluded_ids)
128 replies = replies.exclude(id__in=excluded_ids)
129 else:
129 else:
130 replies = replies.all()
130 replies = replies.all()
131
131
132 post_urls = [refpost.get_link_view() for refpost in replies]
132 post_urls = [refpost.get_link_view() for refpost in replies]
133
133
134 self.refmap = ', '.join(post_urls)
134 self.refmap = ', '.join(post_urls)
135
135
136 def is_referenced(self) -> bool:
136 def is_referenced(self) -> bool:
137 return self.refmap and len(self.refmap) > 0
137 return self.refmap and len(self.refmap) > 0
138
138
139 def is_opening(self) -> bool:
139 def is_opening(self) -> bool:
140 """
140 """
141 Checks if this is an opening post or just a reply.
141 Checks if this is an opening post or just a reply.
142 """
142 """
143
143
144 return self.opening
144 return self.opening
145
145
146 def get_absolute_url(self, thread=None):
146 def get_absolute_url(self, thread=None):
147 url = None
148
149 if thread is None:
150 thread = self.get_thread()
151
152 # Url is cached only for the "main" thread. When getting url
147 # Url is cached only for the "main" thread. When getting url
153 # for other threads, do it manually.
148 # for other threads, do it manually.
154 if self.url:
155 url = self.url
149 url = self.url
156
150
157 if url is None:
151 if url is None:
152 if thread is None:
153 thread = self.get_thread()
158 opening = self.is_opening()
154 opening = self.is_opening()
159 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()
160 url = reverse('thread', kwargs={'post_id': opening_id})
156 url = reverse('thread', kwargs={'post_id': opening_id})
161 if not opening:
157 if not opening:
162 url += '#' + str(self.id)
158 url += '#' + str(self.id)
163
159
164 return url
160 return url
165
161
166 def get_thread(self):
162 def get_thread(self):
167 return self.thread
163 return self.thread
168
164
169 def get_thread_id(self):
165 def get_thread_id(self):
170 return self.thread_id
166 return self.thread_id
171
167
172 def get_threads(self) -> QuerySet:
168 def get_threads(self) -> QuerySet:
173 """
169 """
174 Gets post's thread.
170 Gets post's thread.
175 """
171 """
176
172
177 return self.threads
173 return self.threads
178
174
179 def _get_cache_key(self):
175 def _get_cache_key(self):
180 return [datetime_to_epoch(self.last_edit_time)]
176 return [datetime_to_epoch(self.last_edit_time)]
181
177
182 def get_view_params(self, *args, **kwargs):
178 def get_view_params(self, *args, **kwargs):
183 """
179 """
184 Gets the parameters required for viewing the post based on the arguments
180 Gets the parameters required for viewing the post based on the arguments
185 given and the post itself.
181 given and the post itself.
186 """
182 """
187 thread = self.get_thread()
183 thread = self.get_thread()
188
184
189 css_classes = [CSS_CLS_POST]
185 css_classes = [CSS_CLS_POST]
190 if thread.is_archived():
186 if thread.is_archived():
191 css_classes.append(CSS_CLS_ARCHIVE_POST)
187 css_classes.append(CSS_CLS_ARCHIVE_POST)
192 elif not thread.can_bump():
188 elif not thread.can_bump():
193 css_classes.append(CSS_CLS_DEAD_POST)
189 css_classes.append(CSS_CLS_DEAD_POST)
194 if self.is_hidden():
190 if self.is_hidden():
195 css_classes.append(CSS_CLS_HIDDEN_POST)
191 css_classes.append(CSS_CLS_HIDDEN_POST)
196 if thread.is_monochrome():
192 if thread.is_monochrome():
197 css_classes.append(CSS_CLS_MONOCHROME)
193 css_classes.append(CSS_CLS_MONOCHROME)
198
194
199 params = dict()
195 params = dict()
200 for param in POST_VIEW_PARAMS:
196 for param in POST_VIEW_PARAMS:
201 if param in kwargs:
197 if param in kwargs:
202 params[param] = kwargs[param]
198 params[param] = kwargs[param]
203
199
204 params.update({
200 params.update({
205 PARAMETER_POST: self,
201 PARAMETER_POST: self,
206 PARAMETER_IS_OPENING: self.is_opening(),
202 PARAMETER_IS_OPENING: self.is_opening(),
207 PARAMETER_THREAD: thread,
203 PARAMETER_THREAD: thread,
208 PARAMETER_CSS_CLASS: ' '.join(css_classes),
204 PARAMETER_CSS_CLASS: ' '.join(css_classes),
209 })
205 })
210
206
211 return params
207 return params
212
208
213 def get_view(self, *args, **kwargs) -> str:
209 def get_view(self, *args, **kwargs) -> str:
214 """
210 """
215 Renders post's HTML view. Some of the post params can be passed over
211 Renders post's HTML view. Some of the post params can be passed over
216 kwargs for the means of caching (if we view the thread, some params
212 kwargs for the means of caching (if we view the thread, some params
217 are same for every post and don't need to be computed over and over.
213 are same for every post and don't need to be computed over and over.
218 """
214 """
219 params = self.get_view_params(*args, **kwargs)
215 params = self.get_view_params(*args, **kwargs)
220
216
221 return render_to_string('boards/post.html', params)
217 return render_to_string('boards/post.html', params)
222
218
223 def get_search_view(self, *args, **kwargs):
219 def get_search_view(self, *args, **kwargs):
224 return self.get_view(need_op_data=True, *args, **kwargs)
220 return self.get_view(need_op_data=True, *args, **kwargs)
225
221
226 def get_first_image(self) -> Attachment:
222 def get_first_image(self) -> Attachment:
227 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE).earliest('id')
223 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE).earliest('id')
228
224
229 def set_global_id(self, key_pair=None):
225 def set_global_id(self, key_pair=None):
230 """
226 """
231 Sets global id based on the given key pair. If no key pair is given,
227 Sets global id based on the given key pair. If no key pair is given,
232 default one is used.
228 default one is used.
233 """
229 """
234
230
235 if key_pair:
231 if key_pair:
236 key = key_pair
232 key = key_pair
237 else:
233 else:
238 try:
234 try:
239 key = KeyPair.objects.get(primary=True)
235 key = KeyPair.objects.get(primary=True)
240 except KeyPair.DoesNotExist:
236 except KeyPair.DoesNotExist:
241 # Do not update the global id because there is no key defined
237 # Do not update the global id because there is no key defined
242 return
238 return
243 global_id = GlobalId(key_type=key.key_type,
239 global_id = GlobalId(key_type=key.key_type,
244 key=key.public_key,
240 key=key.public_key,
245 local_id=self.id)
241 local_id=self.id)
246 global_id.save()
242 global_id.save()
247
243
248 self.global_id = global_id
244 self.global_id = global_id
249
245
250 self.save(update_fields=['global_id'])
246 self.save(update_fields=['global_id'])
251
247
252 def get_pub_time_str(self):
248 def get_pub_time_str(self):
253 return str(self.pub_time)
249 return str(self.pub_time)
254
250
255 def get_replied_ids(self):
251 def get_replied_ids(self):
256 """
252 """
257 Gets ID list of the posts that this post replies.
253 Gets ID list of the posts that this post replies.
258 """
254 """
259
255
260 raw_text = self.get_raw_text()
256 raw_text = self.get_raw_text()
261
257
262 local_replied = REGEX_REPLY.findall(raw_text)
258 local_replied = REGEX_REPLY.findall(raw_text)
263 global_replied = []
259 global_replied = []
264 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
260 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
265 key_type = match[0]
261 key_type = match[0]
266 key = match[1]
262 key = match[1]
267 local_id = match[2]
263 local_id = match[2]
268
264
269 try:
265 try:
270 global_id = GlobalId.objects.get(key_type=key_type,
266 global_id = GlobalId.objects.get(key_type=key_type,
271 key=key, local_id=local_id)
267 key=key, local_id=local_id)
272 for post in Post.objects.filter(global_id=global_id).only('id'):
268 for post in Post.objects.filter(global_id=global_id).only('id'):
273 global_replied.append(post.id)
269 global_replied.append(post.id)
274 except GlobalId.DoesNotExist:
270 except GlobalId.DoesNotExist:
275 pass
271 pass
276 return local_replied + global_replied
272 return local_replied + global_replied
277
273
278 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
274 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
279 include_last_update=False) -> str:
275 include_last_update=False) -> str:
280 """
276 """
281 Gets post HTML or JSON data that can be rendered on a page or used by
277 Gets post HTML or JSON data that can be rendered on a page or used by
282 API.
278 API.
283 """
279 """
284
280
285 return get_exporter(format_type).export(self, request,
281 return get_exporter(format_type).export(self, request,
286 include_last_update)
282 include_last_update)
287
283
288 def notify_clients(self, recursive=True):
284 def notify_clients(self, recursive=True):
289 """
285 """
290 Sends post HTML data to the thread web socket.
286 Sends post HTML data to the thread web socket.
291 """
287 """
292
288
293 if not settings.get_bool('External', 'WebsocketsEnabled'):
289 if not settings.get_bool('External', 'WebsocketsEnabled'):
294 return
290 return
295
291
296 thread_ids = list()
292 thread_ids = list()
297 for thread in self.get_threads().all():
293 for thread in self.get_threads().all():
298 thread_ids.append(thread.id)
294 thread_ids.append(thread.id)
299
295
300 thread.notify_clients()
296 thread.notify_clients()
301
297
302 if recursive:
298 if recursive:
303 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
299 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
304 post_id = reply_number.group(1)
300 post_id = reply_number.group(1)
305
301
306 try:
302 try:
307 ref_post = Post.objects.get(id=post_id)
303 ref_post = Post.objects.get(id=post_id)
308
304
309 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
305 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
310 # If post is in this thread, its thread was already notified.
306 # If post is in this thread, its thread was already notified.
311 # Otherwise, notify its thread separately.
307 # Otherwise, notify its thread separately.
312 ref_post.notify_clients(recursive=False)
308 ref_post.notify_clients(recursive=False)
313 except ObjectDoesNotExist:
309 except ObjectDoesNotExist:
314 pass
310 pass
315
311
316 def build_url(self):
312 def build_url(self):
317 self.url = self.get_absolute_url()
313 self.url = self.get_absolute_url()
318 self.save(update_fields=['url'])
314 self.save(update_fields=['url'])
319
315
320 def save(self, force_insert=False, force_update=False, using=None,
316 def save(self, force_insert=False, force_update=False, using=None,
321 update_fields=None):
317 update_fields=None):
322 new_post = self.id is None
318 new_post = self.id is None
323
319
324 self.uid = str(uuid.uuid4())
320 self.uid = str(uuid.uuid4())
325 if update_fields is not None and 'uid' not in update_fields:
321 if update_fields is not None and 'uid' not in update_fields:
326 update_fields += ['uid']
322 update_fields += ['uid']
327
323
328 if not new_post:
324 if not new_post:
329 for thread in self.get_threads().all():
325 for thread in self.get_threads().all():
330 thread.last_edit_time = self.last_edit_time
326 thread.last_edit_time = self.last_edit_time
331
327
332 thread.save(update_fields=['last_edit_time', 'status'])
328 thread.save(update_fields=['last_edit_time', 'status'])
333
329
334 super().save(force_insert, force_update, using, update_fields)
330 super().save(force_insert, force_update, using, update_fields)
335
331
336 if self.url is None:
332 if self.url is None:
337 self.build_url()
333 self.build_url()
338
334
339 def get_text(self) -> str:
335 def get_text(self) -> str:
340 return self._text_rendered
336 return self._text_rendered
341
337
342 def get_raw_text(self) -> str:
338 def get_raw_text(self) -> str:
343 return self.text
339 return self.text
344
340
345 def get_sync_text(self) -> str:
341 def get_sync_text(self) -> str:
346 """
342 """
347 Returns text applicable for sync. It has absolute post reflinks.
343 Returns text applicable for sync. It has absolute post reflinks.
348 """
344 """
349
345
350 replacements = dict()
346 replacements = dict()
351 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
347 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
352 try:
348 try:
353 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
349 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
354 replacements[post_id] = absolute_post_id
350 replacements[post_id] = absolute_post_id
355 except Post.DoesNotExist:
351 except Post.DoesNotExist:
356 pass
352 pass
357
353
358 text = self.get_raw_text() or ''
354 text = self.get_raw_text() or ''
359 for key in replacements:
355 for key in replacements:
360 text = text.replace('[post]{}[/post]'.format(key),
356 text = text.replace('[post]{}[/post]'.format(key),
361 '[post]{}[/post]'.format(replacements[key]))
357 '[post]{}[/post]'.format(replacements[key]))
362 text = text.replace('\r\n', '\n').replace('\r', '\n')
358 text = text.replace('\r\n', '\n').replace('\r', '\n')
363
359
364 return text
360 return text
365
361
366 def connect_threads(self, opening_posts):
362 def connect_threads(self, opening_posts):
367 for opening_post in opening_posts:
363 for opening_post in opening_posts:
368 threads = opening_post.get_threads().all()
364 threads = opening_post.get_threads().all()
369 for thread in threads:
365 for thread in threads:
370 if thread.can_bump():
366 if thread.can_bump():
371 thread.update_bump_status()
367 thread.update_bump_status()
372
368
373 thread.last_edit_time = self.last_edit_time
369 thread.last_edit_time = self.last_edit_time
374 thread.save(update_fields=['last_edit_time', 'status'])
370 thread.save(update_fields=['last_edit_time', 'status'])
375 self.threads.add(opening_post.get_thread())
371 self.threads.add(opening_post.get_thread())
376
372
377 def get_tripcode(self):
373 def get_tripcode(self):
378 if self.tripcode:
374 if self.tripcode:
379 return Tripcode(self.tripcode)
375 return Tripcode(self.tripcode)
380
376
381 def get_link_view(self):
377 def get_link_view(self):
382 """
378 """
383 Gets view of a reflink to the post.
379 Gets view of a reflink to the post.
384 """
380 """
385 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
381 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
386 self.id)
382 self.id)
387 if self.is_opening():
383 if self.is_opening():
388 result = '<b>{}</b>'.format(result)
384 result = '<b>{}</b>'.format(result)
389
385
390 return result
386 return result
391
387
392 def is_hidden(self) -> bool:
388 def is_hidden(self) -> bool:
393 return self.hidden
389 return self.hidden
394
390
395 def set_hidden(self, hidden):
391 def set_hidden(self, hidden):
396 self.hidden = hidden
392 self.hidden = hidden
397
393
398 def increment_version(self):
394 def increment_version(self):
399 self.version = F('version') + 1
395 self.version = F('version') + 1
400
396
401 def clear_cache(self):
397 def clear_cache(self):
402 """
398 """
403 Clears sync data (content cache, signatures etc).
399 Clears sync data (content cache, signatures etc).
404 """
400 """
405 global_id = self.global_id
401 global_id = self.global_id
406 if global_id is not None and global_id.is_local()\
402 if global_id is not None and global_id.is_local()\
407 and global_id.content is not None:
403 and global_id.content is not None:
408 global_id.clear_cache()
404 global_id.clear_cache()
409
405
410 def get_tags(self):
406 def get_tags(self):
411 return self.get_thread().get_tags()
407 return self.get_thread().get_tags()
412
408
413 def get_ip_color(self):
409 def get_ip_color(self):
414 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
410 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
@@ -1,328 +1,328 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(multi_replies__id__gt=data['last_id']))
71 & Q(multi_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('multi_replies'))
78 new_post_count=Count('multi_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('multi_replies'))\
82 return new_posts.aggregate(total_count=Count('multi_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.multi_replies.order_by('pub_time').prefetch_related(
184 query = self.multi_replies.order_by('pub_time').prefetch_related(
185 'thread', 'attachments')
185 'thread', '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('poster_ip', 'text', 'last_edit_time',
192 return self.get_replies().defer('poster_ip', 'text', 'last_edit_time',
193 'version')
193 'version')
194
194
195 def get_top_level_replies(self) -> QuerySet:
195 def get_top_level_replies(self) -> QuerySet:
196 return self.get_replies().exclude(refposts__threads__in=[self])
196 return self.get_replies().exclude(refposts__threads__in=[self])
197
197
198 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
198 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
199 """
199 """
200 Gets replies that have at least one image attached
200 Gets replies that have at least one image attached
201 """
201 """
202 return self.get_replies(view_fields_only).filter(
202 return self.get_replies(view_fields_only).filter(
203 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
203 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
204 'attachments')).filter(images_count__gt=0)
204 'attachments')).filter(images_count__gt=0)
205
205
206 def get_opening_post(self, only_id=False) -> Post:
206 def get_opening_post(self, only_id=False) -> Post:
207 """
207 """
208 Gets the first post of the thread
208 Gets the first post of the thread
209 """
209 """
210
210
211 query = self.get_replies().filter(opening=True)
211 query = self.get_replies().filter(opening=True)
212 if only_id:
212 if only_id:
213 query = query.only('id')
213 query = query.only('id')
214 opening_post = query.first()
214 opening_post = query.first()
215
215
216 return opening_post
216 return opening_post
217
217
218 @cached_result()
218 @cached_result()
219 def get_opening_post_id(self) -> int:
219 def get_opening_post_id(self) -> int:
220 """
220 """
221 Gets ID of the first thread post.
221 Gets ID of the first thread post.
222 """
222 """
223
223
224 return self.get_opening_post(only_id=True).id
224 return self.get_opening_post(only_id=True).id
225
225
226 def get_pub_time(self):
226 def get_pub_time(self):
227 """
227 """
228 Gets opening post's pub time because thread does not have its own one.
228 Gets opening post's pub time because thread does not have its own one.
229 """
229 """
230
230
231 return self.get_opening_post().pub_time
231 return self.get_opening_post().pub_time
232
232
233 def __str__(self):
233 def __str__(self):
234 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
234 return 'T#{}'.format(self.id)
235
235
236 def get_tag_url_list(self) -> list:
236 def get_tag_url_list(self) -> list:
237 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
237 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
238
238
239 def update_posts_time(self, exclude_posts=None):
239 def update_posts_time(self, exclude_posts=None):
240 last_edit_time = self.last_edit_time
240 last_edit_time = self.last_edit_time
241
241
242 for post in self.multi_replies.all():
242 for post in self.multi_replies.all():
243 if exclude_posts is None or post not in exclude_posts:
243 if exclude_posts is None or post not in exclude_posts:
244 # Manual update is required because uids are generated on save
244 # Manual update is required because uids are generated on save
245 post.last_edit_time = last_edit_time
245 post.last_edit_time = last_edit_time
246 post.save(update_fields=['last_edit_time'])
246 post.save(update_fields=['last_edit_time'])
247
247
248 post.get_threads().update(last_edit_time=last_edit_time)
248 post.get_threads().update(last_edit_time=last_edit_time)
249
249
250 def notify_clients(self):
250 def notify_clients(self):
251 if not settings.get_bool('External', 'WebsocketsEnabled'):
251 if not settings.get_bool('External', 'WebsocketsEnabled'):
252 return
252 return
253
253
254 client = Client()
254 client = Client()
255
255
256 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
256 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
257 client.publish(channel_name, {
257 client.publish(channel_name, {
258 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
258 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
259 })
259 })
260 client.send()
260 client.send()
261
261
262 def get_absolute_url(self):
262 def get_absolute_url(self):
263 return self.get_opening_post().get_absolute_url()
263 return self.get_opening_post().get_absolute_url()
264
264
265 def get_required_tags(self):
265 def get_required_tags(self):
266 return self.get_tags().filter(required=True)
266 return self.get_tags().filter(required=True)
267
267
268 def get_replies_newer(self, post_id):
268 def get_replies_newer(self, post_id):
269 return self.get_replies().filter(id__gt=post_id)
269 return self.get_replies().filter(id__gt=post_id)
270
270
271 def is_archived(self):
271 def is_archived(self):
272 return self.get_status() == STATUS_ARCHIVE
272 return self.get_status() == STATUS_ARCHIVE
273
273
274 def get_status(self):
274 def get_status(self):
275 return self.status
275 return self.status
276
276
277 def is_monochrome(self):
277 def is_monochrome(self):
278 return self.monochrome
278 return self.monochrome
279
279
280 # If tags have parent, add them to the tag list
280 # If tags have parent, add them to the tag list
281 @transaction.atomic
281 @transaction.atomic
282 def refresh_tags(self):
282 def refresh_tags(self):
283 for tag in self.get_tags().all():
283 for tag in self.get_tags().all():
284 parents = tag.get_all_parents()
284 parents = tag.get_all_parents()
285 if len(parents) > 0:
285 if len(parents) > 0:
286 self.tags.add(*parents)
286 self.tags.add(*parents)
287
287
288 def get_reply_tree(self):
288 def get_reply_tree(self):
289 replies = self.get_replies().prefetch_related('refposts')
289 replies = self.get_replies().prefetch_related('refposts')
290 tree = []
290 tree = []
291 for reply in replies:
291 for reply in replies:
292 parents = reply.refposts.all()
292 parents = reply.refposts.all()
293
293
294 found_parent = False
294 found_parent = False
295 searching_for_index = False
295 searching_for_index = False
296
296
297 if len(parents) > 0:
297 if len(parents) > 0:
298 index = 0
298 index = 0
299 parent_depth = 0
299 parent_depth = 0
300
300
301 indexes_to_insert = []
301 indexes_to_insert = []
302
302
303 for depth, element in tree:
303 for depth, element in tree:
304 index += 1
304 index += 1
305
305
306 # If this element is next after parent on the same level,
306 # If this element is next after parent on the same level,
307 # insert child before it
307 # insert child before it
308 if searching_for_index and depth <= parent_depth:
308 if searching_for_index and depth <= parent_depth:
309 indexes_to_insert.append((index - 1, parent_depth))
309 indexes_to_insert.append((index - 1, parent_depth))
310 searching_for_index = False
310 searching_for_index = False
311
311
312 if element in parents:
312 if element in parents:
313 found_parent = True
313 found_parent = True
314 searching_for_index = True
314 searching_for_index = True
315 parent_depth = depth
315 parent_depth = depth
316
316
317 if not found_parent:
317 if not found_parent:
318 tree.append((0, reply))
318 tree.append((0, reply))
319 else:
319 else:
320 if searching_for_index:
320 if searching_for_index:
321 tree.append((parent_depth + 1, reply))
321 tree.append((parent_depth + 1, reply))
322
322
323 offset = 0
323 offset = 0
324 for last_index, parent_depth in indexes_to_insert:
324 for last_index, parent_depth in indexes_to_insert:
325 tree.insert(last_index + offset, (parent_depth + 1, reply))
325 tree.insert(last_index + offset, (parent_depth + 1, reply))
326 offset += 1
326 offset += 1
327
327
328 return tree
328 return tree
General Comments 0
You need to be logged in to leave comments. Login now