##// END OF EJS Templates
Fixed notifications for multi-thread posts. Fixed hiding hidden tag threads when viewing tag page
neko259 -
r1062:5357cb86 default
parent child Browse files
Show More
@@ -1,487 +1,489 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 urllib.parse import unquote
7 7
8 8 from adjacent import Client
9 9 from django.core.exceptions import ObjectDoesNotExist
10 10 from django.core.urlresolvers import reverse
11 11 from django.db import models, transaction
12 12 from django.db.models import TextField
13 13 from django.template.loader import render_to_string
14 14 from django.utils import timezone
15 15
16 16 from boards import settings
17 17 from boards.mdx_neboard import bbcode_extended
18 18 from boards.models import PostImage
19 19 from boards.models.base import Viewable
20 20 from boards.utils import datetime_to_epoch, cached_result
21 21 from boards.models.user import Notification
22 22 import boards.models.thread
23 23
24 24
25 25 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
26 26 WS_NOTIFICATION_TYPE = 'notification_type'
27 27
28 28 WS_CHANNEL_THREAD = "thread:"
29 29
30 30 APP_LABEL_BOARDS = 'boards'
31 31
32 32 POSTS_PER_DAY_RANGE = 7
33 33
34 34 BAN_REASON_AUTO = 'Auto'
35 35
36 36 IMAGE_THUMB_SIZE = (200, 150)
37 37
38 38 TITLE_MAX_LENGTH = 200
39 39
40 40 # TODO This should be removed
41 41 NO_IP = '0.0.0.0'
42 42
43 43 # TODO Real user agent should be saved instead of this
44 44 UNKNOWN_UA = ''
45 45
46 46 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
47 47 REGEX_MULTI_THREAD = re.compile(r'\[thread\](\d+)\[/thread\]')
48 48 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
49 49 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
50 50
51 51 PARAMETER_TRUNCATED = 'truncated'
52 52 PARAMETER_TAG = 'tag'
53 53 PARAMETER_OFFSET = 'offset'
54 54 PARAMETER_DIFF_TYPE = 'type'
55 55 PARAMETER_BUMPABLE = 'bumpable'
56 56 PARAMETER_THREAD = 'thread'
57 57 PARAMETER_IS_OPENING = 'is_opening'
58 58 PARAMETER_MODERATOR = 'moderator'
59 59 PARAMETER_POST = 'post'
60 60 PARAMETER_OP_ID = 'opening_post_id'
61 61 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
62 62 PARAMETER_REPLY_LINK = 'reply_link'
63 63
64 64 DIFF_TYPE_HTML = 'html'
65 65 DIFF_TYPE_JSON = 'json'
66 66
67 67 PREPARSE_PATTERNS = {
68 68 r'>>>(\d+)': r'[thread]\1[/thread]', # Multi-thread post ">>>123"
69 69 r'(?<!>)>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
70 70 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
71 71 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
72 72 r'\B@(\w+)': r'[user]\1[/user]', # User notification "@user"
73 73 }
74 74
75 75
76 76 class PostManager(models.Manager):
77 77 @transaction.atomic
78 78 def create_post(self, title: str, text: str, image=None, thread=None,
79 79 ip=NO_IP, tags: list=None):
80 80 """
81 81 Creates new post
82 82 """
83 83
84 84 if not tags:
85 85 tags = []
86 86
87 87 posting_time = timezone.now()
88 88 if not thread:
89 89 thread = boards.models.thread.Thread.objects.create(
90 90 bump_time=posting_time, last_edit_time=posting_time)
91 91 new_thread = True
92 92 else:
93 93 new_thread = False
94 94
95 95 pre_text = self._preparse_text(text)
96 96
97 97 post = self.create(title=title,
98 98 text=pre_text,
99 99 pub_time=posting_time,
100 100 poster_ip=ip,
101 101 thread=thread,
102 102 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
103 103 # last!
104 104 last_edit_time=posting_time)
105 105 post.threads.add(thread)
106 106
107 107 logger = logging.getLogger('boards.post.create')
108 108
109 109 logger.info('Created post {} by {}'.format(
110 110 post, post.poster_ip))
111 111
112 112 if image:
113 113 post.images.add(PostImage.objects.create_with_hash(image))
114 114
115 115 list(map(thread.add_tag, tags))
116 116
117 117 if new_thread:
118 118 boards.models.thread.Thread.objects.process_oldest_threads()
119 119 else:
120 120 thread.last_edit_time = posting_time
121 121 thread.bump()
122 122 thread.save()
123 123
124 124 post.connect_replies()
125 125 post.connect_threads()
126 126 post.connect_notifications()
127 127
128 128 return post
129 129
130 130 def delete_posts_by_ip(self, ip):
131 131 """
132 132 Deletes all posts of the author with same IP
133 133 """
134 134
135 135 posts = self.filter(poster_ip=ip)
136 136 for post in posts:
137 137 post.delete()
138 138
139 139 @cached_result
140 140 def get_posts_per_day(self):
141 141 """
142 142 Gets average count of posts per day for the last 7 days
143 143 """
144 144
145 145 day_end = date.today()
146 146 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
147 147
148 148 day_time_start = timezone.make_aware(datetime.combine(
149 149 day_start, dtime()), timezone.get_current_timezone())
150 150 day_time_end = timezone.make_aware(datetime.combine(
151 151 day_end, dtime()), timezone.get_current_timezone())
152 152
153 153 posts_per_period = float(self.filter(
154 154 pub_time__lte=day_time_end,
155 155 pub_time__gte=day_time_start).count())
156 156
157 157 ppd = posts_per_period / POSTS_PER_DAY_RANGE
158 158
159 159 return ppd
160 160
161 161 # TODO Make a separate parser module and move preparser there
162 162 def _preparse_text(self, text: str) -> str:
163 163 """
164 164 Preparses text to change patterns like '>>' to a proper bbcode
165 165 tags.
166 166 """
167 167
168 168 for key, value in PREPARSE_PATTERNS.items():
169 169 text = re.sub(key, value, text, flags=re.MULTILINE)
170 170
171 171 for link in REGEX_URL.findall(text):
172 172 text = text.replace(link, unquote(link))
173 173
174 174 return text
175 175
176 176
177 177 class Post(models.Model, Viewable):
178 178 """A post is a message."""
179 179
180 180 objects = PostManager()
181 181
182 182 class Meta:
183 183 app_label = APP_LABEL_BOARDS
184 184 ordering = ('id',)
185 185
186 186 title = models.CharField(max_length=TITLE_MAX_LENGTH)
187 187 pub_time = models.DateTimeField()
188 188 text = TextField(blank=True, null=True)
189 189 _text_rendered = TextField(blank=True, null=True, editable=False)
190 190
191 191 images = models.ManyToManyField(PostImage, null=True, blank=True,
192 192 related_name='ip+', db_index=True)
193 193
194 194 poster_ip = models.GenericIPAddressField()
195 195 poster_user_agent = models.TextField()
196 196
197 197 last_edit_time = models.DateTimeField()
198 198
199 199 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
200 200 null=True,
201 201 blank=True, related_name='rfp+',
202 202 db_index=True)
203 203 refmap = models.TextField(null=True, blank=True)
204 204 threads = models.ManyToManyField('Thread', db_index=True)
205 205 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
206 206
207 207 def __str__(self):
208 208 return 'P#{}/{}'.format(self.id, self.title)
209 209
210 210 def get_title(self) -> str:
211 211 """
212 212 Gets original post title or part of its text.
213 213 """
214 214
215 215 title = self.title
216 216 if not title:
217 217 title = self.get_text()
218 218
219 219 return title
220 220
221 221 def build_refmap(self) -> None:
222 222 """
223 223 Builds a replies map string from replies list. This is a cache to stop
224 224 the server from recalculating the map on every post show.
225 225 """
226 226 map_string = ''
227 227
228 228 post_urls = ['<a href="{}">&gt;&gt;{}</a>'.format(
229 229 refpost.get_url(), refpost.id) for refpost in self.referenced_posts.all()]
230 230
231 231 self.refmap = ', '.join(post_urls)
232 232
233 233 def get_sorted_referenced_posts(self):
234 234 return self.refmap
235 235
236 236 def is_referenced(self) -> bool:
237 237 return self.refmap and len(self.refmap) > 0
238 238
239 239 def is_opening(self) -> bool:
240 240 """
241 241 Checks if this is an opening post or just a reply.
242 242 """
243 243
244 244 return self.get_thread().get_opening_post_id() == self.id
245 245
246 246 @transaction.atomic
247 247 def add_tag(self, tag):
248 248 edit_time = timezone.now()
249 249
250 250 thread = self.get_thread()
251 251 thread.add_tag(tag)
252 252 self.last_edit_time = edit_time
253 253 self.save(update_fields=['last_edit_time'])
254 254
255 255 thread.last_edit_time = edit_time
256 256 thread.save(update_fields=['last_edit_time'])
257 257
258 258 @cached_result
259 259 def get_url(self):
260 260 """
261 261 Gets full url to the post.
262 262 """
263 263
264 264 thread = self.get_thread()
265 265
266 266 opening_id = thread.get_opening_post_id()
267 267
268 268 if self.id != opening_id:
269 269 link = reverse('thread', kwargs={
270 270 'post_id': opening_id}) + '#' + str(self.id)
271 271 else:
272 272 link = reverse('thread', kwargs={'post_id': self.id})
273 273
274 274 return link
275 275
276 276 def get_thread(self):
277 277 return self.thread
278 278
279 279 def get_threads(self):
280 280 """
281 281 Gets post's thread.
282 282 """
283 283
284 284 return self.threads
285 285
286 286 def get_referenced_posts(self):
287 287 return self.referenced_posts.only('id', 'threads')
288 288
289 289 def get_view(self, moderator=False, need_open_link=False,
290 290 truncated=False, *args, **kwargs):
291 291 """
292 292 Renders post's HTML view. Some of the post params can be passed over
293 293 kwargs for the means of caching (if we view the thread, some params
294 294 are same for every post and don't need to be computed over and over.
295 295 """
296 296
297 297 thread = self.get_thread()
298 298 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
299 299 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
300 300
301 301 if is_opening:
302 302 opening_post_id = self.id
303 303 else:
304 304 opening_post_id = thread.get_opening_post_id()
305 305
306 306 return render_to_string('boards/post.html', {
307 307 PARAMETER_POST: self,
308 308 PARAMETER_MODERATOR: moderator,
309 309 PARAMETER_IS_OPENING: is_opening,
310 310 PARAMETER_THREAD: thread,
311 311 PARAMETER_BUMPABLE: can_bump,
312 312 PARAMETER_NEED_OPEN_LINK: need_open_link,
313 313 PARAMETER_TRUNCATED: truncated,
314 314 PARAMETER_OP_ID: opening_post_id,
315 315 })
316 316
317 317 def get_search_view(self, *args, **kwargs):
318 318 return self.get_view(args, kwargs)
319 319
320 320 def get_first_image(self) -> PostImage:
321 321 return self.images.earliest('id')
322 322
323 323 def delete(self, using=None):
324 324 """
325 325 Deletes all post images and the post itself.
326 326 """
327 327
328 328 for image in self.images.all():
329 329 image_refs_count = Post.objects.filter(images__in=[image]).count()
330 330 if image_refs_count == 1:
331 331 image.delete()
332 332
333 333 thread = self.get_thread()
334 334 thread.last_edit_time = timezone.now()
335 335 thread.save()
336 336
337 337 super(Post, self).delete(using)
338 338
339 339 logging.getLogger('boards.post.delete').info(
340 340 'Deleted post {}'.format(self))
341 341
342 342 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
343 343 include_last_update=False):
344 344 """
345 345 Gets post HTML or JSON data that can be rendered on a page or used by
346 346 API.
347 347 """
348 348
349 349 if format_type == DIFF_TYPE_HTML:
350 350 params = dict()
351 351 params['post'] = self
352 352 if PARAMETER_TRUNCATED in request.GET:
353 353 params[PARAMETER_TRUNCATED] = True
354 354 else:
355 355 params[PARAMETER_REPLY_LINK] = True
356 356
357 357 return render_to_string('boards/api_post.html', params)
358 358 elif format_type == DIFF_TYPE_JSON:
359 359 post_json = {
360 360 'id': self.id,
361 361 'title': self.title,
362 362 'text': self._text_rendered,
363 363 }
364 364 if self.images.exists():
365 365 post_image = self.get_first_image()
366 366 post_json['image'] = post_image.image.url
367 367 post_json['image_preview'] = post_image.image.url_200x150
368 368 if include_last_update:
369 369 post_json['bump_time'] = datetime_to_epoch(
370 370 self.get_thread().bump_time)
371 371 return post_json
372 372
373 373 def send_to_websocket(self, request, recursive=True):
374 374 """
375 375 Sends post HTML data to the thread web socket.
376 376 """
377 377
378 378 if not settings.WEBSOCKETS_ENABLED:
379 379 return
380 380
381 381 client = Client()
382 382
383 thread = self.get_thread()
384 thread_id = thread.id
385 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
386 client.publish(channel_name, {
387 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
388 })
389 client.send()
390
391 383 logger = logging.getLogger('boards.post.websocket')
392 384
393 logger.info('Sent notification from post #{} to channel {}'.format(
394 self.id, channel_name))
385 thread_ids = list()
386 for thread in self.get_threads().all():
387 thread_ids.append(thread.id)
388
389 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
390 client.publish(channel_name, {
391 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
392 })
393 client.send()
394
395 logger.info('Sent notification from post #{} to channel {}'.format(
396 self.id, channel_name))
395 397
396 398 if recursive:
397 399 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
398 400 post_id = reply_number.group(1)
399 401
400 402 try:
401 403 ref_post = Post.objects.get(id=post_id)
402 404
403 # If post is in this thread, its thread was already notified.
404 # Otherwise, notify its thread separately.
405 if ref_post.get_thread().id != thread_id:
405 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
406 # If post is in this thread, its thread was already notified.
407 # Otherwise, notify its thread separately.
406 408 ref_post.send_to_websocket(request, recursive=False)
407 409 except ObjectDoesNotExist:
408 410 pass
409 411
410 412 def save(self, force_insert=False, force_update=False, using=None,
411 413 update_fields=None):
412 414 self._text_rendered = bbcode_extended(self.get_raw_text())
413 415
414 416 super().save(force_insert, force_update, using, update_fields)
415 417
416 418 def get_text(self) -> str:
417 419 return self._text_rendered
418 420
419 421 def get_raw_text(self) -> str:
420 422 return self.text
421 423
422 424 def get_absolute_id(self) -> str:
423 425 """
424 426 If the post has many threads, shows its main thread OP id in the post
425 427 ID.
426 428 """
427 429
428 430 if self.get_threads().count() > 1:
429 431 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
430 432 else:
431 433 return str(self.id)
432 434
433 435 def connect_notifications(self):
434 436 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
435 437 user_name = reply_number.group(1).lower()
436 438 Notification.objects.get_or_create(name=user_name, post=self)
437 439
438 440 def connect_replies(self):
439 441 """
440 442 Connects replies to a post to show them as a reflink map
441 443 """
442 444
443 445 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
444 446 post_id = reply_number.group(1)
445 447
446 448 try:
447 449 referenced_post = Post.objects.get(id=post_id)
448 450
449 451 referenced_post.referenced_posts.add(self)
450 452 referenced_post.last_edit_time = self.pub_time
451 453 referenced_post.build_refmap()
452 454 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
453 455
454 456 referenced_threads = referenced_post.get_threads().all()
455 457 for thread in referenced_threads:
456 458 if thread.can_bump():
457 459 thread.update_bump_status()
458 460
459 461 thread.last_edit_time = self.pub_time
460 462 thread.save(update_fields=['last_edit_time', 'bumpable'])
461 463 except ObjectDoesNotExist:
462 464 pass
463 465
464 466 def connect_threads(self):
465 467 """
466 468 If the referenced post is an OP in another thread,
467 469 make this post multi-thread.
468 470 """
469 471
470 472 for reply_number in re.finditer(REGEX_MULTI_THREAD, self.get_raw_text()):
471 473 post_id = reply_number.group(1)
472 474
473 475 try:
474 476 referenced_post = Post.objects.get(id=post_id)
475 477
476 478 if referenced_post.is_opening():
477 479 referenced_threads = referenced_post.get_threads().all()
478 480 for thread in referenced_threads:
479 481 if thread.can_bump():
480 482 thread.update_bump_status()
481 483
482 484 thread.last_edit_time = self.pub_time
483 485 thread.save(update_fields=['last_edit_time', 'bumpable'])
484 486
485 487 self.threads.add(thread)
486 488 except ObjectDoesNotExist:
487 489 pass
@@ -1,99 +1,100 b''
1 1 from django.shortcuts import get_object_or_404
2 2
3 3 from boards.abstracts.settingsmanager import get_settings_manager, \
4 4 SETTING_FAVORITE_TAGS, SETTING_HIDDEN_TAGS
5 5 from boards.models import Tag, Thread
6 6 from boards.views.all_threads import AllThreadsView, DEFAULT_PAGE
7 7 from boards.views.mixins import DispatcherMixin, RedirectNextMixin
8 8 from boards.forms import ThreadForm, PlainErrorList
9 9
10 10 PARAM_HIDDEN_TAGS = 'hidden_tags'
11 11 PARAM_TAG = 'tag'
12 12 PARAM_IS_FAVORITE = 'is_favorite'
13 13 PARAM_IS_HIDDEN = 'is_hidden'
14 14
15 15 __author__ = 'neko259'
16 16
17 17
18 18 class TagView(AllThreadsView, DispatcherMixin, RedirectNextMixin):
19 19
20 20 tag_name = None
21 21
22 22 def get_threads(self):
23 23 tag = get_object_or_404(Tag, name=self.tag_name)
24 24
25 return tag.get_threads()
25 return tag.get_threads().exclude(
26 tags__in=self.settings_manager.get_hidden_tags())
26 27
27 28 def get_context_data(self, **kwargs):
28 29 params = super(TagView, self).get_context_data(**kwargs)
29 30
30 31 settings_manager = get_settings_manager(kwargs['request'])
31 32
32 33 tag = get_object_or_404(Tag, name=self.tag_name)
33 34 params[PARAM_TAG] = tag
34 35
35 36 fav_tag_names = settings_manager.get_setting(SETTING_FAVORITE_TAGS)
36 37 hidden_tag_names = settings_manager.get_setting(SETTING_HIDDEN_TAGS)
37 38
38 39 params[PARAM_IS_FAVORITE] = fav_tag_names is not None and tag.name in fav_tag_names
39 40 params[PARAM_IS_HIDDEN] = hidden_tag_names is not None and tag.name in hidden_tag_names
40 41
41 42 return params
42 43
43 44 def get(self, request, tag_name, page=DEFAULT_PAGE, form=None):
44 45 self.tag_name = tag_name
45 46
46 47 return super(TagView, self).get(request, page, form)
47 48
48 49
49 50 def post(self, request, tag_name, page=DEFAULT_PAGE):
50 51 self.tag_name = tag_name
51 52
52 53 if 'method' in request.POST:
53 54 self.dispatch_method(request)
54 55 form = None
55 56 else:
56 57 form = ThreadForm(request.POST, request.FILES,
57 58 error_class=PlainErrorList)
58 59 form.session = request.session
59 60
60 61 if form.is_valid():
61 62 return self.create_thread(request, form)
62 63 if form.need_to_ban:
63 64 # Ban user because he is suspected to be a bot
64 65 self._ban_current_user(request)
65 66
66 67 return self.get(request, tag_name, page, form)
67 68
68 69 def subscribe(self, request):
69 70 tag = get_object_or_404(Tag, name=self.tag_name)
70 71
71 72 settings_manager = get_settings_manager(request)
72 73 settings_manager.add_fav_tag(tag)
73 74
74 75 def unsubscribe(self, request):
75 76 tag = get_object_or_404(Tag, name=self.tag_name)
76 77
77 78 settings_manager = get_settings_manager(request)
78 79 settings_manager.del_fav_tag(tag)
79 80
80 81 def hide(self, request):
81 82 """
82 83 Adds tag to user's hidden tags. Threads with this tag will not be
83 84 shown.
84 85 """
85 86
86 87 tag = get_object_or_404(Tag, name=self.tag_name)
87 88
88 89 settings_manager = get_settings_manager(request)
89 90 settings_manager.add_hidden_tag(tag)
90 91
91 92 def unhide(self, request):
92 93 """
93 94 Removed tag from user's hidden tags.
94 95 """
95 96
96 97 tag = get_object_or_404(Tag, name=self.tag_name)
97 98
98 99 settings_manager = get_settings_manager(request)
99 100 settings_manager.del_hidden_tag(tag)
General Comments 0
You need to be logged in to leave comments. Login now