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