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