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