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