##// END OF EJS Templates
Added ability to cache method result with additional arguments. Cache thread...
neko259 -
r1106:2a693c11 default
parent child Browse files
Show More
@@ -1,425 +1,425 b''
1 from datetime import datetime, timedelta, date
1 from datetime import datetime, timedelta, date
2 from datetime import time as dtime
2 from datetime import time as dtime
3 import logging
3 import logging
4 import re
4 import re
5
5
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, transaction
8 from django.db import models, transaction
9 from django.db.models import TextField
9 from django.db.models import TextField
10 from django.template.loader import render_to_string
10 from django.template.loader import render_to_string
11 from django.utils import timezone
11 from django.utils import timezone
12
12
13 from boards import settings
13 from boards import settings
14 from boards.mdx_neboard import Parser
14 from boards.mdx_neboard import Parser
15 from boards.models import PostImage
15 from boards.models import PostImage
16 from boards.models.base import Viewable
16 from boards.models.base import Viewable
17 from boards.utils import datetime_to_epoch, cached_result
17 from boards.utils import datetime_to_epoch, cached_result
18 from boards.models.user import Notification, Ban
18 from boards.models.user import Notification, Ban
19 import boards.models.thread
19 import boards.models.thread
20
20
21
21
22 APP_LABEL_BOARDS = 'boards'
22 APP_LABEL_BOARDS = 'boards'
23
23
24 POSTS_PER_DAY_RANGE = 7
24 POSTS_PER_DAY_RANGE = 7
25
25
26 BAN_REASON_AUTO = 'Auto'
26 BAN_REASON_AUTO = 'Auto'
27
27
28 IMAGE_THUMB_SIZE = (200, 150)
28 IMAGE_THUMB_SIZE = (200, 150)
29
29
30 TITLE_MAX_LENGTH = 200
30 TITLE_MAX_LENGTH = 200
31
31
32 # TODO This should be removed
32 # TODO This should be removed
33 NO_IP = '0.0.0.0'
33 NO_IP = '0.0.0.0'
34
34
35 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
35 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
36 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
36 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
37
37
38 PARAMETER_TRUNCATED = 'truncated'
38 PARAMETER_TRUNCATED = 'truncated'
39 PARAMETER_TAG = 'tag'
39 PARAMETER_TAG = 'tag'
40 PARAMETER_OFFSET = 'offset'
40 PARAMETER_OFFSET = 'offset'
41 PARAMETER_DIFF_TYPE = 'type'
41 PARAMETER_DIFF_TYPE = 'type'
42 PARAMETER_CSS_CLASS = 'css_class'
42 PARAMETER_CSS_CLASS = 'css_class'
43 PARAMETER_THREAD = 'thread'
43 PARAMETER_THREAD = 'thread'
44 PARAMETER_IS_OPENING = 'is_opening'
44 PARAMETER_IS_OPENING = 'is_opening'
45 PARAMETER_MODERATOR = 'moderator'
45 PARAMETER_MODERATOR = 'moderator'
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
50
51 DIFF_TYPE_HTML = 'html'
51 DIFF_TYPE_HTML = 'html'
52 DIFF_TYPE_JSON = 'json'
52 DIFF_TYPE_JSON = 'json'
53
53
54 REFMAP_STR = '<a href="{}">&gt;&gt;{}</a>'
54 REFMAP_STR = '<a href="{}">&gt;&gt;{}</a>'
55
55
56
56
57 class PostManager(models.Manager):
57 class PostManager(models.Manager):
58 @transaction.atomic
58 @transaction.atomic
59 def create_post(self, title: str, text: str, image=None, thread=None,
59 def create_post(self, title: str, text: str, image=None, thread=None,
60 ip=NO_IP, tags: list=None, threads: list=None):
60 ip=NO_IP, tags: list=None, threads: list=None):
61 """
61 """
62 Creates new post
62 Creates new post
63 """
63 """
64
64
65 is_banned = Ban.objects.filter(ip=ip).exists()
65 is_banned = Ban.objects.filter(ip=ip).exists()
66
66
67 # TODO Raise specific exception and catch it in the views
67 # TODO Raise specific exception and catch it in the views
68 if is_banned:
68 if is_banned:
69 raise Exception("This user is banned")
69 raise Exception("This user is banned")
70
70
71 if not tags:
71 if not tags:
72 tags = []
72 tags = []
73 if not threads:
73 if not threads:
74 threads = []
74 threads = []
75
75
76 posting_time = timezone.now()
76 posting_time = timezone.now()
77 if not thread:
77 if not thread:
78 thread = boards.models.thread.Thread.objects.create(
78 thread = boards.models.thread.Thread.objects.create(
79 bump_time=posting_time, last_edit_time=posting_time)
79 bump_time=posting_time, last_edit_time=posting_time)
80 new_thread = True
80 new_thread = True
81 else:
81 else:
82 new_thread = False
82 new_thread = False
83
83
84 pre_text = Parser().preparse(text)
84 pre_text = Parser().preparse(text)
85
85
86 post = self.create(title=title,
86 post = self.create(title=title,
87 text=pre_text,
87 text=pre_text,
88 pub_time=posting_time,
88 pub_time=posting_time,
89 poster_ip=ip,
89 poster_ip=ip,
90 thread=thread,
90 thread=thread,
91 last_edit_time=posting_time)
91 last_edit_time=posting_time)
92 post.threads.add(thread)
92 post.threads.add(thread)
93
93
94 logger = logging.getLogger('boards.post.create')
94 logger = logging.getLogger('boards.post.create')
95
95
96 logger.info('Created post {} by {}'.format(post, post.poster_ip))
96 logger.info('Created post {} by {}'.format(post, post.poster_ip))
97
97
98 if image:
98 if image:
99 post.images.add(PostImage.objects.create_with_hash(image))
99 post.images.add(PostImage.objects.create_with_hash(image))
100
100
101 list(map(thread.add_tag, tags))
101 list(map(thread.add_tag, tags))
102
102
103 if new_thread:
103 if new_thread:
104 boards.models.thread.Thread.objects.process_oldest_threads()
104 boards.models.thread.Thread.objects.process_oldest_threads()
105 else:
105 else:
106 thread.last_edit_time = posting_time
106 thread.last_edit_time = posting_time
107 thread.bump()
107 thread.bump()
108 thread.save()
108 thread.save()
109
109
110 post.connect_replies()
110 post.connect_replies()
111 post.connect_threads(threads)
111 post.connect_threads(threads)
112 post.connect_notifications()
112 post.connect_notifications()
113
113
114 return post
114 return post
115
115
116 def delete_posts_by_ip(self, ip):
116 def delete_posts_by_ip(self, ip):
117 """
117 """
118 Deletes all posts of the author with same IP
118 Deletes all posts of the author with same IP
119 """
119 """
120
120
121 posts = self.filter(poster_ip=ip)
121 posts = self.filter(poster_ip=ip)
122 for post in posts:
122 for post in posts:
123 post.delete()
123 post.delete()
124
124
125 @cached_result
125 @cached_result()
126 def get_posts_per_day(self) -> float:
126 def get_posts_per_day(self) -> float:
127 """
127 """
128 Gets average count of posts per day for the last 7 days
128 Gets average count of posts per day for the last 7 days
129 """
129 """
130
130
131 day_end = date.today()
131 day_end = date.today()
132 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
132 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
133
133
134 day_time_start = timezone.make_aware(datetime.combine(
134 day_time_start = timezone.make_aware(datetime.combine(
135 day_start, dtime()), timezone.get_current_timezone())
135 day_start, dtime()), timezone.get_current_timezone())
136 day_time_end = timezone.make_aware(datetime.combine(
136 day_time_end = timezone.make_aware(datetime.combine(
137 day_end, dtime()), timezone.get_current_timezone())
137 day_end, dtime()), timezone.get_current_timezone())
138
138
139 posts_per_period = float(self.filter(
139 posts_per_period = float(self.filter(
140 pub_time__lte=day_time_end,
140 pub_time__lte=day_time_end,
141 pub_time__gte=day_time_start).count())
141 pub_time__gte=day_time_start).count())
142
142
143 ppd = posts_per_period / POSTS_PER_DAY_RANGE
143 ppd = posts_per_period / POSTS_PER_DAY_RANGE
144
144
145 return ppd
145 return ppd
146
146
147
147
148 class Post(models.Model, Viewable):
148 class Post(models.Model, Viewable):
149 """A post is a message."""
149 """A post is a message."""
150
150
151 objects = PostManager()
151 objects = PostManager()
152
152
153 class Meta:
153 class Meta:
154 app_label = APP_LABEL_BOARDS
154 app_label = APP_LABEL_BOARDS
155 ordering = ('id',)
155 ordering = ('id',)
156
156
157 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
157 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
158 pub_time = models.DateTimeField()
158 pub_time = models.DateTimeField()
159 text = TextField(blank=True, null=True)
159 text = TextField(blank=True, null=True)
160 _text_rendered = TextField(blank=True, null=True, editable=False)
160 _text_rendered = TextField(blank=True, null=True, editable=False)
161
161
162 images = models.ManyToManyField(PostImage, null=True, blank=True,
162 images = models.ManyToManyField(PostImage, null=True, blank=True,
163 related_name='ip+', db_index=True)
163 related_name='ip+', db_index=True)
164
164
165 poster_ip = models.GenericIPAddressField()
165 poster_ip = models.GenericIPAddressField()
166
166
167 last_edit_time = models.DateTimeField()
167 last_edit_time = models.DateTimeField()
168
168
169 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
169 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
170 null=True,
170 null=True,
171 blank=True, related_name='rfp+',
171 blank=True, related_name='rfp+',
172 db_index=True)
172 db_index=True)
173 refmap = models.TextField(null=True, blank=True)
173 refmap = models.TextField(null=True, blank=True)
174 threads = models.ManyToManyField('Thread', db_index=True)
174 threads = models.ManyToManyField('Thread', db_index=True)
175 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
175 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
176
176
177 def __str__(self):
177 def __str__(self):
178 return 'P#{}/{}'.format(self.id, self.title)
178 return 'P#{}/{}'.format(self.id, self.title)
179
179
180 def get_title(self) -> str:
180 def get_title(self) -> str:
181 """
181 """
182 Gets original post title or part of its text.
182 Gets original post title or part of its text.
183 """
183 """
184
184
185 title = self.title
185 title = self.title
186 if not title:
186 if not title:
187 title = self.get_text()
187 title = self.get_text()
188
188
189 return title
189 return title
190
190
191 def build_refmap(self) -> None:
191 def build_refmap(self) -> None:
192 """
192 """
193 Builds a replies map string from replies list. This is a cache to stop
193 Builds a replies map string from replies list. This is a cache to stop
194 the server from recalculating the map on every post show.
194 the server from recalculating the map on every post show.
195 """
195 """
196
196
197 post_urls = [REFMAP_STR.format(refpost.get_url(), refpost.id)
197 post_urls = [REFMAP_STR.format(refpost.get_url(), refpost.id)
198 for refpost in self.referenced_posts.all()]
198 for refpost in self.referenced_posts.all()]
199
199
200 self.refmap = ', '.join(post_urls)
200 self.refmap = ', '.join(post_urls)
201
201
202 def is_referenced(self) -> bool:
202 def is_referenced(self) -> bool:
203 return self.refmap and len(self.refmap) > 0
203 return self.refmap and len(self.refmap) > 0
204
204
205 def is_opening(self) -> bool:
205 def is_opening(self) -> bool:
206 """
206 """
207 Checks if this is an opening post or just a reply.
207 Checks if this is an opening post or just a reply.
208 """
208 """
209
209
210 return self.get_thread().get_opening_post_id() == self.id
210 return self.get_thread().get_opening_post_id() == self.id
211
211
212 @cached_result
212 @cached_result()
213 def get_url(self):
213 def get_url(self):
214 """
214 """
215 Gets full url to the post.
215 Gets full url to the post.
216 """
216 """
217
217
218 thread = self.get_thread()
218 thread = self.get_thread()
219
219
220 opening_id = thread.get_opening_post_id()
220 opening_id = thread.get_opening_post_id()
221
221
222 thread_url = reverse('thread', kwargs={'post_id': opening_id})
222 thread_url = reverse('thread', kwargs={'post_id': opening_id})
223 if self.id != opening_id:
223 if self.id != opening_id:
224 thread_url += '#' + str(self.id)
224 thread_url += '#' + str(self.id)
225
225
226 return thread_url
226 return thread_url
227
227
228 def get_thread(self):
228 def get_thread(self):
229 return self.thread
229 return self.thread
230
230
231 def get_threads(self) -> list:
231 def get_threads(self) -> list:
232 """
232 """
233 Gets post's thread.
233 Gets post's thread.
234 """
234 """
235
235
236 return self.threads
236 return self.threads
237
237
238 def get_view(self, moderator=False, need_open_link=False,
238 def get_view(self, moderator=False, need_open_link=False,
239 truncated=False, reply_link=False, *args, **kwargs) -> str:
239 truncated=False, reply_link=False, *args, **kwargs) -> str:
240 """
240 """
241 Renders post's HTML view. Some of the post params can be passed over
241 Renders post's HTML view. Some of the post params can be passed over
242 kwargs for the means of caching (if we view the thread, some params
242 kwargs for the means of caching (if we view the thread, some params
243 are same for every post and don't need to be computed over and over.
243 are same for every post and don't need to be computed over and over.
244 """
244 """
245
245
246 thread = self.get_thread()
246 thread = self.get_thread()
247 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
247 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
248
248
249 if is_opening:
249 if is_opening:
250 opening_post_id = self.id
250 opening_post_id = self.id
251 else:
251 else:
252 opening_post_id = thread.get_opening_post_id()
252 opening_post_id = thread.get_opening_post_id()
253
253
254 css_class = 'post'
254 css_class = 'post'
255 if thread.archived:
255 if thread.archived:
256 css_class += ' archive_post'
256 css_class += ' archive_post'
257 elif not thread.can_bump():
257 elif not thread.can_bump():
258 css_class += ' dead_post'
258 css_class += ' dead_post'
259
259
260 return render_to_string('boards/post.html', {
260 return render_to_string('boards/post.html', {
261 PARAMETER_POST: self,
261 PARAMETER_POST: self,
262 PARAMETER_MODERATOR: moderator,
262 PARAMETER_MODERATOR: moderator,
263 PARAMETER_IS_OPENING: is_opening,
263 PARAMETER_IS_OPENING: is_opening,
264 PARAMETER_THREAD: thread,
264 PARAMETER_THREAD: thread,
265 PARAMETER_CSS_CLASS: css_class,
265 PARAMETER_CSS_CLASS: css_class,
266 PARAMETER_NEED_OPEN_LINK: need_open_link,
266 PARAMETER_NEED_OPEN_LINK: need_open_link,
267 PARAMETER_TRUNCATED: truncated,
267 PARAMETER_TRUNCATED: truncated,
268 PARAMETER_OP_ID: opening_post_id,
268 PARAMETER_OP_ID: opening_post_id,
269 PARAMETER_REPLY_LINK: reply_link,
269 PARAMETER_REPLY_LINK: reply_link,
270 })
270 })
271
271
272 def get_search_view(self, *args, **kwargs):
272 def get_search_view(self, *args, **kwargs):
273 return self.get_view(args, kwargs)
273 return self.get_view(args, kwargs)
274
274
275 def get_first_image(self) -> PostImage:
275 def get_first_image(self) -> PostImage:
276 return self.images.earliest('id')
276 return self.images.earliest('id')
277
277
278 def delete(self, using=None):
278 def delete(self, using=None):
279 """
279 """
280 Deletes all post images and the post itself.
280 Deletes all post images and the post itself.
281 """
281 """
282
282
283 for image in self.images.all():
283 for image in self.images.all():
284 image_refs_count = Post.objects.filter(images__in=[image]).count()
284 image_refs_count = Post.objects.filter(images__in=[image]).count()
285 if image_refs_count == 1:
285 if image_refs_count == 1:
286 image.delete()
286 image.delete()
287
287
288 thread = self.get_thread()
288 thread = self.get_thread()
289 thread.last_edit_time = timezone.now()
289 thread.last_edit_time = timezone.now()
290 thread.save()
290 thread.save()
291
291
292 super(Post, self).delete(using)
292 super(Post, self).delete(using)
293
293
294 logging.getLogger('boards.post.delete').info(
294 logging.getLogger('boards.post.delete').info(
295 'Deleted post {}'.format(self))
295 'Deleted post {}'.format(self))
296
296
297 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
297 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
298 include_last_update=False) -> str:
298 include_last_update=False) -> str:
299 """
299 """
300 Gets post HTML or JSON data that can be rendered on a page or used by
300 Gets post HTML or JSON data that can be rendered on a page or used by
301 API.
301 API.
302 """
302 """
303
303
304 if format_type == DIFF_TYPE_HTML:
304 if format_type == DIFF_TYPE_HTML:
305 params = dict()
305 params = dict()
306 params['post'] = self
306 params['post'] = self
307 if PARAMETER_TRUNCATED in request.GET:
307 if PARAMETER_TRUNCATED in request.GET:
308 params[PARAMETER_TRUNCATED] = True
308 params[PARAMETER_TRUNCATED] = True
309 else:
309 else:
310 params[PARAMETER_REPLY_LINK] = True
310 params[PARAMETER_REPLY_LINK] = True
311
311
312 return render_to_string('boards/api_post.html', params)
312 return render_to_string('boards/api_post.html', params)
313 elif format_type == DIFF_TYPE_JSON:
313 elif format_type == DIFF_TYPE_JSON:
314 post_json = {
314 post_json = {
315 'id': self.id,
315 'id': self.id,
316 'title': self.title,
316 'title': self.title,
317 'text': self._text_rendered,
317 'text': self._text_rendered,
318 }
318 }
319 if self.images.exists():
319 if self.images.exists():
320 post_image = self.get_first_image()
320 post_image = self.get_first_image()
321 post_json['image'] = post_image.image.url
321 post_json['image'] = post_image.image.url
322 post_json['image_preview'] = post_image.image.url_200x150
322 post_json['image_preview'] = post_image.image.url_200x150
323 if include_last_update:
323 if include_last_update:
324 post_json['bump_time'] = datetime_to_epoch(
324 post_json['bump_time'] = datetime_to_epoch(
325 self.get_thread().bump_time)
325 self.get_thread().bump_time)
326 return post_json
326 return post_json
327
327
328 def notify_clients(self, recursive=True):
328 def notify_clients(self, recursive=True):
329 """
329 """
330 Sends post HTML data to the thread web socket.
330 Sends post HTML data to the thread web socket.
331 """
331 """
332
332
333 if not settings.WEBSOCKETS_ENABLED:
333 if not settings.WEBSOCKETS_ENABLED:
334 return
334 return
335
335
336 thread_ids = list()
336 thread_ids = list()
337 for thread in self.get_threads().all():
337 for thread in self.get_threads().all():
338 thread_ids.append(thread.id)
338 thread_ids.append(thread.id)
339
339
340 thread.notify_clients()
340 thread.notify_clients()
341
341
342 if recursive:
342 if recursive:
343 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
343 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
344 post_id = reply_number.group(1)
344 post_id = reply_number.group(1)
345
345
346 try:
346 try:
347 ref_post = Post.objects.get(id=post_id)
347 ref_post = Post.objects.get(id=post_id)
348
348
349 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
349 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
350 # If post is in this thread, its thread was already notified.
350 # If post is in this thread, its thread was already notified.
351 # Otherwise, notify its thread separately.
351 # Otherwise, notify its thread separately.
352 ref_post.notify_clients(recursive=False)
352 ref_post.notify_clients(recursive=False)
353 except ObjectDoesNotExist:
353 except ObjectDoesNotExist:
354 pass
354 pass
355
355
356 def save(self, force_insert=False, force_update=False, using=None,
356 def save(self, force_insert=False, force_update=False, using=None,
357 update_fields=None):
357 update_fields=None):
358 self._text_rendered = Parser().parse(self.get_raw_text())
358 self._text_rendered = Parser().parse(self.get_raw_text())
359
359
360 if self.id:
360 if self.id:
361 for thread in self.get_threads().all():
361 for thread in self.get_threads().all():
362 if thread.can_bump():
362 if thread.can_bump():
363 thread.update_bump_status()
363 thread.update_bump_status()
364 thread.last_edit_time = self.last_edit_time
364 thread.last_edit_time = self.last_edit_time
365
365
366 thread.save(update_fields=['last_edit_time', 'bumpable'])
366 thread.save(update_fields=['last_edit_time', 'bumpable'])
367
367
368 super().save(force_insert, force_update, using, update_fields)
368 super().save(force_insert, force_update, using, update_fields)
369
369
370 def get_text(self) -> str:
370 def get_text(self) -> str:
371 return self._text_rendered
371 return self._text_rendered
372
372
373 def get_raw_text(self) -> str:
373 def get_raw_text(self) -> str:
374 return self.text
374 return self.text
375
375
376 def get_absolute_id(self) -> str:
376 def get_absolute_id(self) -> str:
377 """
377 """
378 If the post has many threads, shows its main thread OP id in the post
378 If the post has many threads, shows its main thread OP id in the post
379 ID.
379 ID.
380 """
380 """
381
381
382 if self.get_threads().count() > 1:
382 if self.get_threads().count() > 1:
383 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
383 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
384 else:
384 else:
385 return str(self.id)
385 return str(self.id)
386
386
387 def connect_notifications(self):
387 def connect_notifications(self):
388 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
388 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
389 user_name = reply_number.group(1).lower()
389 user_name = reply_number.group(1).lower()
390 Notification.objects.get_or_create(name=user_name, post=self)
390 Notification.objects.get_or_create(name=user_name, post=self)
391
391
392 def connect_replies(self):
392 def connect_replies(self):
393 """
393 """
394 Connects replies to a post to show them as a reflink map
394 Connects replies to a post to show them as a reflink map
395 """
395 """
396
396
397 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
397 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
398 post_id = reply_number.group(1)
398 post_id = reply_number.group(1)
399
399
400 try:
400 try:
401 referenced_post = Post.objects.get(id=post_id)
401 referenced_post = Post.objects.get(id=post_id)
402
402
403 referenced_post.referenced_posts.add(self)
403 referenced_post.referenced_posts.add(self)
404 referenced_post.last_edit_time = self.pub_time
404 referenced_post.last_edit_time = self.pub_time
405 referenced_post.build_refmap()
405 referenced_post.build_refmap()
406 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
406 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
407 except ObjectDoesNotExist:
407 except ObjectDoesNotExist:
408 pass
408 pass
409
409
410 def connect_threads(self, opening_posts):
410 def connect_threads(self, opening_posts):
411 """
411 """
412 If the referenced post is an OP in another thread,
412 If the referenced post is an OP in another thread,
413 make this post multi-thread.
413 make this post multi-thread.
414 """
414 """
415
415
416 for opening_post in opening_posts:
416 for opening_post in opening_posts:
417 threads = opening_post.get_threads().all()
417 threads = opening_post.get_threads().all()
418 for thread in threads:
418 for thread in threads:
419 if thread.can_bump():
419 if thread.can_bump():
420 thread.update_bump_status()
420 thread.update_bump_status()
421
421
422 thread.last_edit_time = self.last_edit_time
422 thread.last_edit_time = self.last_edit_time
423 thread.save(update_fields=['last_edit_time', 'bumpable'])
423 thread.save(update_fields=['last_edit_time', 'bumpable'])
424
424
425 self.threads.add(thread)
425 self.threads.add(thread)
@@ -1,220 +1,226 b''
1 import logging
1 import logging
2 from adjacent import Client
2 from adjacent import Client
3
3
4 from django.db.models import Count, Sum
4 from django.db.models import Count, Sum
5 from django.utils import timezone
5 from django.utils import timezone
6 from django.db import models
6 from django.db import models
7
7
8 from boards import settings
8 from boards import settings
9 import boards
9 import boards
10 from boards.utils import cached_result
10 from boards.utils import cached_result, datetime_to_epoch
11 from boards.models.post import Post
11 from boards.models.post import Post
12 from boards.models.tag import Tag
12 from boards.models.tag import Tag
13
13
14
14
15 __author__ = 'neko259'
15 __author__ = 'neko259'
16
16
17
17
18 logger = logging.getLogger(__name__)
18 logger = logging.getLogger(__name__)
19
19
20
20
21 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
21 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
22 WS_NOTIFICATION_TYPE = 'notification_type'
22 WS_NOTIFICATION_TYPE = 'notification_type'
23
23
24 WS_CHANNEL_THREAD = "thread:"
24 WS_CHANNEL_THREAD = "thread:"
25
25
26
26
27 class ThreadManager(models.Manager):
27 class ThreadManager(models.Manager):
28 def process_oldest_threads(self):
28 def process_oldest_threads(self):
29 """
29 """
30 Preserves maximum thread count. If there are too many threads,
30 Preserves maximum thread count. If there are too many threads,
31 archive or delete the old ones.
31 archive or delete the old ones.
32 """
32 """
33
33
34 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
34 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
35 thread_count = threads.count()
35 thread_count = threads.count()
36
36
37 if thread_count > settings.MAX_THREAD_COUNT:
37 if thread_count > settings.MAX_THREAD_COUNT:
38 num_threads_to_delete = thread_count - settings.MAX_THREAD_COUNT
38 num_threads_to_delete = thread_count - settings.MAX_THREAD_COUNT
39 old_threads = threads[thread_count - num_threads_to_delete:]
39 old_threads = threads[thread_count - num_threads_to_delete:]
40
40
41 for thread in old_threads:
41 for thread in old_threads:
42 if settings.ARCHIVE_THREADS:
42 if settings.ARCHIVE_THREADS:
43 self._archive_thread(thread)
43 self._archive_thread(thread)
44 else:
44 else:
45 thread.delete()
45 thread.delete()
46
46
47 logger.info('Processed %d old threads' % num_threads_to_delete)
47 logger.info('Processed %d old threads' % num_threads_to_delete)
48
48
49 def _archive_thread(self, thread):
49 def _archive_thread(self, thread):
50 thread.archived = True
50 thread.archived = True
51 thread.bumpable = False
51 thread.bumpable = False
52 thread.last_edit_time = timezone.now()
52 thread.last_edit_time = timezone.now()
53 thread.update_posts_time()
53 thread.update_posts_time()
54 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
54 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
55
55
56
56
57 class Thread(models.Model):
57 class Thread(models.Model):
58 objects = ThreadManager()
58 objects = ThreadManager()
59
59
60 class Meta:
60 class Meta:
61 app_label = 'boards'
61 app_label = 'boards'
62
62
63 tags = models.ManyToManyField('Tag')
63 tags = models.ManyToManyField('Tag')
64 bump_time = models.DateTimeField(db_index=True)
64 bump_time = models.DateTimeField(db_index=True)
65 last_edit_time = models.DateTimeField()
65 last_edit_time = models.DateTimeField()
66 archived = models.BooleanField(default=False)
66 archived = models.BooleanField(default=False)
67 bumpable = models.BooleanField(default=True)
67 bumpable = models.BooleanField(default=True)
68 max_posts = models.IntegerField(default=settings.MAX_POSTS_PER_THREAD)
68 max_posts = models.IntegerField(default=settings.MAX_POSTS_PER_THREAD)
69
69
70 def get_tags(self) -> list:
70 def get_tags(self) -> list:
71 """
71 """
72 Gets a sorted tag list.
72 Gets a sorted tag list.
73 """
73 """
74
74
75 return self.tags.order_by('name')
75 return self.tags.order_by('name')
76
76
77 def bump(self):
77 def bump(self):
78 """
78 """
79 Bumps (moves to up) thread if possible.
79 Bumps (moves to up) thread if possible.
80 """
80 """
81
81
82 if self.can_bump():
82 if self.can_bump():
83 self.bump_time = self.last_edit_time
83 self.bump_time = self.last_edit_time
84
84
85 self.update_bump_status()
85 self.update_bump_status()
86
86
87 logger.info('Bumped thread %d' % self.id)
87 logger.info('Bumped thread %d' % self.id)
88
88
89 def has_post_limit(self) -> bool:
89 def has_post_limit(self) -> bool:
90 return self.max_posts > 0
90 return self.max_posts > 0
91
91
92 def update_bump_status(self):
92 def update_bump_status(self):
93 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
93 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
94 self.bumpable = False
94 self.bumpable = False
95 self.update_posts_time()
95 self.update_posts_time()
96
96
97 def _get_cache_key(self):
98 return [datetime_to_epoch(self.last_edit_time)]
99
100 @cached_result(key_method=_get_cache_key)
97 def get_reply_count(self) -> int:
101 def get_reply_count(self) -> int:
98 return self.get_replies().count()
102 return self.get_replies().count()
99
103
104 @cached_result(key_method=_get_cache_key)
100 def get_images_count(self) -> int:
105 def get_images_count(self) -> int:
101 return self.get_replies().annotate(images_count=Count(
106 return self.get_replies().annotate(images_count=Count(
102 'images')).aggregate(Sum('images_count'))['images_count__sum']
107 'images')).aggregate(Sum('images_count'))['images_count__sum']
103
108
104 def can_bump(self) -> bool:
109 def can_bump(self) -> bool:
105 """
110 """
106 Checks if the thread can be bumped by replying to it.
111 Checks if the thread can be bumped by replying to it.
107 """
112 """
108
113
109 return self.bumpable and not self.archived
114 return self.bumpable and not self.archived
110
115
111 def get_last_replies(self) -> list:
116 def get_last_replies(self) -> list:
112 """
117 """
113 Gets several last replies, not including opening post
118 Gets several last replies, not including opening post
114 """
119 """
115
120
116 if settings.LAST_REPLIES_COUNT > 0:
121 if settings.LAST_REPLIES_COUNT > 0:
117 reply_count = self.get_reply_count()
122 reply_count = self.get_reply_count()
118
123
119 if reply_count > 0:
124 if reply_count > 0:
120 reply_count_to_show = min(settings.LAST_REPLIES_COUNT,
125 reply_count_to_show = min(settings.LAST_REPLIES_COUNT,
121 reply_count - 1)
126 reply_count - 1)
122 replies = self.get_replies()
127 replies = self.get_replies()
123 last_replies = replies[reply_count - reply_count_to_show:]
128 last_replies = replies[reply_count - reply_count_to_show:]
124
129
125 return last_replies
130 return last_replies
126
131
127 def get_skipped_replies_count(self) -> int:
132 def get_skipped_replies_count(self) -> int:
128 """
133 """
129 Gets number of posts between opening post and last replies.
134 Gets number of posts between opening post and last replies.
130 """
135 """
131 reply_count = self.get_reply_count()
136 reply_count = self.get_reply_count()
132 last_replies_count = min(settings.LAST_REPLIES_COUNT,
137 last_replies_count = min(settings.LAST_REPLIES_COUNT,
133 reply_count - 1)
138 reply_count - 1)
134 return reply_count - last_replies_count - 1
139 return reply_count - last_replies_count - 1
135
140
136 def get_replies(self, view_fields_only=False) -> list:
141 def get_replies(self, view_fields_only=False) -> list:
137 """
142 """
138 Gets sorted thread posts
143 Gets sorted thread posts
139 """
144 """
140
145
141 query = Post.objects.filter(threads__in=[self])
146 query = Post.objects.filter(threads__in=[self])
142 query = query.order_by('pub_time').prefetch_related('images', 'thread', 'threads')
147 query = query.order_by('pub_time').prefetch_related('images', 'thread', 'threads')
143 if view_fields_only:
148 if view_fields_only:
144 query = query.defer('poster_ip')
149 query = query.defer('poster_ip')
145 return query.all()
150 return query.all()
146
151
147 def get_replies_with_images(self, view_fields_only=False) -> list:
152 def get_replies_with_images(self, view_fields_only=False) -> list:
148 """
153 """
149 Gets replies that have at least one image attached
154 Gets replies that have at least one image attached
150 """
155 """
151
156
152 return self.get_replies(view_fields_only).annotate(images_count=Count(
157 return self.get_replies(view_fields_only).annotate(images_count=Count(
153 'images')).filter(images_count__gt=0)
158 'images')).filter(images_count__gt=0)
154
159
160 # TODO Do we still need this?
155 def add_tag(self, tag: Tag):
161 def add_tag(self, tag: Tag):
156 """
162 """
157 Connects thread to a tag and tag to a thread
163 Connects thread to a tag and tag to a thread
158 """
164 """
159
165
160 self.tags.add(tag)
166 self.tags.add(tag)
161
167
162 def get_opening_post(self, only_id=False) -> Post:
168 def get_opening_post(self, only_id=False) -> Post:
163 """
169 """
164 Gets the first post of the thread
170 Gets the first post of the thread
165 """
171 """
166
172
167 query = self.get_replies().order_by('pub_time')
173 query = self.get_replies().order_by('pub_time')
168 if only_id:
174 if only_id:
169 query = query.only('id')
175 query = query.only('id')
170 opening_post = query.first()
176 opening_post = query.first()
171
177
172 return opening_post
178 return opening_post
173
179
174 @cached_result
180 @cached_result()
175 def get_opening_post_id(self) -> int:
181 def get_opening_post_id(self) -> int:
176 """
182 """
177 Gets ID of the first thread post.
183 Gets ID of the first thread post.
178 """
184 """
179
185
180 return self.get_opening_post(only_id=True).id
186 return self.get_opening_post(only_id=True).id
181
187
182 def get_pub_time(self):
188 def get_pub_time(self):
183 """
189 """
184 Gets opening post's pub time because thread does not have its own one.
190 Gets opening post's pub time because thread does not have its own one.
185 """
191 """
186
192
187 return self.get_opening_post().pub_time
193 return self.get_opening_post().pub_time
188
194
189 def delete(self, using=None):
195 def delete(self, using=None):
190 """
196 """
191 Deletes thread with all replies.
197 Deletes thread with all replies.
192 """
198 """
193
199
194 for reply in self.get_replies().all():
200 for reply in self.get_replies().all():
195 reply.delete()
201 reply.delete()
196
202
197 super(Thread, self).delete(using)
203 super(Thread, self).delete(using)
198
204
199 def __str__(self):
205 def __str__(self):
200 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
206 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
201
207
202 def get_tag_url_list(self) -> list:
208 def get_tag_url_list(self) -> list:
203 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
209 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
204
210
205 def update_posts_time(self):
211 def update_posts_time(self):
206 self.post_set.update(last_edit_time=self.last_edit_time)
212 self.post_set.update(last_edit_time=self.last_edit_time)
207 for post in self.post_set.all():
213 for post in self.post_set.all():
208 post.threads.update(last_edit_time=self.last_edit_time)
214 post.threads.update(last_edit_time=self.last_edit_time)
209
215
210 def notify_clients(self):
216 def notify_clients(self):
211 if not settings.WEBSOCKETS_ENABLED:
217 if not settings.WEBSOCKETS_ENABLED:
212 return
218 return
213
219
214 client = Client()
220 client = Client()
215
221
216 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
222 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
217 client.publish(channel_name, {
223 client.publish(channel_name, {
218 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
224 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
219 })
225 })
220 client.send() No newline at end of file
226 client.send()
@@ -1,38 +1,32 b''
1 {% extends "boards/base.html" %}
1 {% extends "boards/base.html" %}
2
2
3 {% load i18n %}
3 {% load i18n %}
4 {% load cache %}
5 {% load static from staticfiles %}
4 {% load static from staticfiles %}
6 {% load board %}
5 {% load board %}
7 {% load tz %}
6 {% load tz %}
8
7
9 {% block head %}
8 {% block head %}
10 <title>{{ opening_post.get_title|striptags|truncatewords:10 }}
9 <title>{{ opening_post.get_title|striptags|truncatewords:10 }}
11 - {{ site_name }}</title>
10 - {{ site_name }}</title>
12 {% endblock %}
11 {% endblock %}
13
12
14 {% block metapanel %}
13 {% block metapanel %}
15
14
16 {% get_current_language as LANGUAGE_CODE %}
17 {% get_current_timezone as TIME_ZONE %}
18
19 <span class="metapanel"
15 <span class="metapanel"
20 data-last-update="{{ last_update }}"
16 data-last-update="{{ last_update }}"
21 data-ws-token-time="{{ ws_token_time }}"
17 data-ws-token-time="{{ ws_token_time }}"
22 data-ws-token="{{ ws_token }}"
18 data-ws-token="{{ ws_token }}"
23 data-ws-project="{{ ws_project }}"
19 data-ws-project="{{ ws_project }}"
24 data-ws-host="{{ ws_host }}"
20 data-ws-host="{{ ws_host }}"
25 data-ws-port="{{ ws_port }}">
21 data-ws-port="{{ ws_port }}">
26
22
27 {% block thread_meta_panel %}
23 {% block thread_meta_panel %}
28 {% endblock %}
24 {% endblock %}
29
25
30 {% cache 600 thread_meta thread.last_edit_time moderator LANGUAGE_CODE TIME_ZONE %}
31 <span id="reply-count">{{ thread.get_reply_count }}</span>{% if thread.has_post_limit %}/{{ thread.max_posts }}{% endif %} {% trans 'messages' %},
26 <span id="reply-count">{{ thread.get_reply_count }}</span>{% if thread.has_post_limit %}/{{ thread.max_posts }}{% endif %} {% trans 'messages' %},
32 <span id="image-count">{{ thread.get_images_count }}</span> {% trans 'images' %}.
27 <span id="image-count">{{ thread.get_images_count }}</span> {% trans 'images' %}.
33 {% trans 'Last update: ' %}<span id="last-update"><time datetime="{{ thread.last_edit_time|date:'c' }}">{{ thread.last_edit_time }}</time></span>
28 {% trans 'Last update: ' %}<span id="last-update"><time datetime="{{ thread.last_edit_time|date:'c' }}">{{ thread.last_edit_time }}</time></span>
34 [<a href="rss/">RSS</a>]
29 [<a href="rss/">RSS</a>]
35 {% endcache %}
36 </span>
30 </span>
37
31
38 {% endblock %}
32 {% endblock %}
@@ -1,69 +1,76 b''
1 """
1 """
2 This module contains helper functions and helper classes.
2 This module contains helper functions and helper classes.
3 """
3 """
4 import time
4 import time
5 import hmac
5 import hmac
6 import functools
7
6 from django.core.cache import cache
8 from django.core.cache import cache
7 from django.db.models import Model
9 from django.db.models import Model
8
10
9 from django.utils import timezone
11 from django.utils import timezone
10
12
11 from neboard import settings
13 from neboard import settings
12
14
13
15
14 KEY_CAPTCHA_FAILS = 'key_captcha_fails'
16 CACHE_KEY_DELIMITER = '_'
15 KEY_CAPTCHA_DELAY_TIME = 'key_captcha_delay_time'
16 KEY_CAPTCHA_LAST_ACTIVITY = 'key_captcha_last_activity'
17
17
18
18
19 def get_client_ip(request):
19 def get_client_ip(request):
20 x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
20 x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
21 if x_forwarded_for:
21 if x_forwarded_for:
22 ip = x_forwarded_for.split(',')[-1].strip()
22 ip = x_forwarded_for.split(',')[-1].strip()
23 else:
23 else:
24 ip = request.META.get('REMOTE_ADDR')
24 ip = request.META.get('REMOTE_ADDR')
25 return ip
25 return ip
26
26
27
27
28 # TODO The output format is not epoch because it includes microseconds
28 # TODO The output format is not epoch because it includes microseconds
29 def datetime_to_epoch(datetime):
29 def datetime_to_epoch(datetime):
30 return int(time.mktime(timezone.localtime(
30 return int(time.mktime(timezone.localtime(
31 datetime,timezone.get_current_timezone()).timetuple())
31 datetime,timezone.get_current_timezone()).timetuple())
32 * 1000000 + datetime.microsecond)
32 * 1000000 + datetime.microsecond)
33
33
34
34
35 def get_websocket_token(user_id='', timestamp=''):
35 def get_websocket_token(user_id='', timestamp=''):
36 """
36 """
37 Create token to validate information provided by new connection.
37 Create token to validate information provided by new connection.
38 """
38 """
39
39
40 sign = hmac.new(settings.CENTRIFUGE_PROJECT_SECRET.encode())
40 sign = hmac.new(settings.CENTRIFUGE_PROJECT_SECRET.encode())
41 sign.update(settings.CENTRIFUGE_PROJECT_ID.encode())
41 sign.update(settings.CENTRIFUGE_PROJECT_ID.encode())
42 sign.update(user_id.encode())
42 sign.update(user_id.encode())
43 sign.update(timestamp.encode())
43 sign.update(timestamp.encode())
44 token = sign.hexdigest()
44 token = sign.hexdigest()
45
45
46 return token
46 return token
47
47
48
48
49 def cached_result(function):
49 def cached_result(key_method=None):
50 """
50 """
51 Caches method result in the Django's cache system, persisted by object name,
51 Caches method result in the Django's cache system, persisted by object name,
52 object name and model id if object is a Django model.
52 object name and model id if object is a Django model.
53 """
53 """
54 def _cached_result(function):
54 def inner_func(obj, *args, **kwargs):
55 def inner_func(obj, *args, **kwargs):
55 # TODO Include method arguments to the cache key
56 # TODO Include method arguments to the cache key
56 cache_key = obj.__class__.__name__ + '_' + function.__name__
57 cache_key_params = [obj.__class__.__name__, function.__name__]
57 if isinstance(obj, Model):
58 if isinstance(obj, Model):
58 cache_key += '_' + str(obj.id)
59 cache_key_params.append(str(obj.id))
60
61 if key_method is not None:
62 cache_key_params += [str(arg) for arg in key_method(obj)]
63
64 cache_key = CACHE_KEY_DELIMITER.join(cache_key_params)
59
65
60 persisted_result = cache.get(cache_key)
66 persisted_result = cache.get(cache_key)
61 if persisted_result:
67 if persisted_result is not None:
62 result = persisted_result
68 result = persisted_result
63 else:
69 else:
64 result = function(obj, *args, **kwargs)
70 result = function(obj, *args, **kwargs)
65 cache.set(cache_key, result)
71 cache.set(cache_key, result)
66
72
67 return result
73 return result
68
74
69 return inner_func
75 return inner_func
76 return _cached_result
General Comments 0
You need to be logged in to leave comments. Login now