##// END OF EJS Templates
Connect reply to the multi-thread OP only to this OP main thread, not all...
neko259 -
r1201:06e20fca default
parent child Browse files
Show More
@@ -1,427 +1,421 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 import uuid
6 6
7 7 from django.core.exceptions import ObjectDoesNotExist
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.loader import render_to_string
12 12 from django.utils import timezone
13 13
14 14 from boards import settings
15 15 from boards.mdx_neboard import Parser
16 16 from boards.models import PostImage
17 17 from boards.models.base import Viewable
18 18 from boards import utils
19 19 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
20 20 from boards.models.user import Notification, Ban
21 21 import boards.models.thread
22 22
23 23
24 24 APP_LABEL_BOARDS = 'boards'
25 25
26 26 POSTS_PER_DAY_RANGE = 7
27 27
28 28 BAN_REASON_AUTO = 'Auto'
29 29
30 30 IMAGE_THUMB_SIZE = (200, 150)
31 31
32 32 TITLE_MAX_LENGTH = 200
33 33
34 34 # TODO This should be removed
35 35 NO_IP = '0.0.0.0'
36 36
37 37 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
38 38 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
39 39
40 40 PARAMETER_TRUNCATED = 'truncated'
41 41 PARAMETER_TAG = 'tag'
42 42 PARAMETER_OFFSET = 'offset'
43 43 PARAMETER_DIFF_TYPE = 'type'
44 44 PARAMETER_CSS_CLASS = 'css_class'
45 45 PARAMETER_THREAD = 'thread'
46 46 PARAMETER_IS_OPENING = 'is_opening'
47 47 PARAMETER_MODERATOR = 'moderator'
48 48 PARAMETER_POST = 'post'
49 49 PARAMETER_OP_ID = 'opening_post_id'
50 50 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
51 51 PARAMETER_REPLY_LINK = 'reply_link'
52 52 PARAMETER_NEED_OP_DATA = 'need_op_data'
53 53
54 54 POST_VIEW_PARAMS = (
55 55 'need_op_data',
56 56 'reply_link',
57 57 'moderator',
58 58 'need_open_link',
59 59 'truncated',
60 60 'mode_tree',
61 61 )
62 62
63 63 REFMAP_STR = '<a href="{}">&gt;&gt;{}</a>'
64 64
65 65
66 66 class PostManager(models.Manager):
67 67 @transaction.atomic
68 68 def create_post(self, title: str, text: str, image=None, thread=None,
69 69 ip=NO_IP, tags: list=None, threads: list=None):
70 70 """
71 71 Creates new post
72 72 """
73 73
74 74 is_banned = Ban.objects.filter(ip=ip).exists()
75 75
76 76 # TODO Raise specific exception and catch it in the views
77 77 if is_banned:
78 78 raise Exception("This user is banned")
79 79
80 80 if not tags:
81 81 tags = []
82 82 if not threads:
83 83 threads = []
84 84
85 85 posting_time = timezone.now()
86 86 if not thread:
87 87 thread = boards.models.thread.Thread.objects.create(
88 88 bump_time=posting_time, last_edit_time=posting_time)
89 89 list(map(thread.tags.add, tags))
90 90 new_thread = True
91 91 else:
92 92 new_thread = False
93 93
94 94 pre_text = Parser().preparse(text)
95 95
96 96 post = self.create(title=title,
97 97 text=pre_text,
98 98 pub_time=posting_time,
99 99 poster_ip=ip,
100 100 thread=thread,
101 101 last_edit_time=posting_time)
102 102 post.threads.add(thread)
103 103
104 104 logger = logging.getLogger('boards.post.create')
105 105
106 106 logger.info('Created post {} by {}'.format(post, post.poster_ip))
107 107
108 108 if image:
109 109 post.images.add(PostImage.objects.create_with_hash(image))
110 110
111 111 if new_thread:
112 112 boards.models.thread.Thread.objects.process_oldest_threads()
113 113 else:
114 114 thread.last_edit_time = posting_time
115 115 thread.bump()
116 116 thread.save()
117 117
118 118 post.build_url()
119 119 post.connect_replies()
120 120 post.connect_threads(threads)
121 121 post.connect_notifications()
122 122
123 123 return post
124 124
125 125 def delete_posts_by_ip(self, ip):
126 126 """
127 127 Deletes all posts of the author with same IP
128 128 """
129 129
130 130 posts = self.filter(poster_ip=ip)
131 131 for post in posts:
132 132 post.delete()
133 133
134 134 @utils.cached_result()
135 135 def get_posts_per_day(self) -> float:
136 136 """
137 137 Gets average count of posts per day for the last 7 days
138 138 """
139 139
140 140 day_end = date.today()
141 141 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
142 142
143 143 day_time_start = timezone.make_aware(datetime.combine(
144 144 day_start, dtime()), timezone.get_current_timezone())
145 145 day_time_end = timezone.make_aware(datetime.combine(
146 146 day_end, dtime()), timezone.get_current_timezone())
147 147
148 148 posts_per_period = float(self.filter(
149 149 pub_time__lte=day_time_end,
150 150 pub_time__gte=day_time_start).count())
151 151
152 152 ppd = posts_per_period / POSTS_PER_DAY_RANGE
153 153
154 154 return ppd
155 155
156 156
157 157 class Post(models.Model, Viewable):
158 158 """A post is a message."""
159 159
160 160 objects = PostManager()
161 161
162 162 class Meta:
163 163 app_label = APP_LABEL_BOARDS
164 164 ordering = ('id',)
165 165
166 166 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
167 167 pub_time = models.DateTimeField()
168 168 text = TextField(blank=True, null=True)
169 169 _text_rendered = TextField(blank=True, null=True, editable=False)
170 170
171 171 images = models.ManyToManyField(PostImage, null=True, blank=True,
172 172 related_name='ip+', db_index=True)
173 173
174 174 poster_ip = models.GenericIPAddressField()
175 175
176 176 # TODO This field can be removed cause UID is used for update now
177 177 last_edit_time = models.DateTimeField()
178 178
179 179 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
180 180 null=True,
181 181 blank=True, related_name='refposts',
182 182 db_index=True)
183 183 refmap = models.TextField(null=True, blank=True)
184 184 threads = models.ManyToManyField('Thread', db_index=True)
185 185 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
186 186
187 187 url = models.TextField()
188 188 uid = models.TextField(db_index=True)
189 189
190 190 def __str__(self):
191 191 return 'P#{}/{}'.format(self.id, self.title)
192 192
193 193 def get_referenced_posts(self):
194 194 threads = self.get_threads().all()
195 195 return self.referenced_posts.filter(threads__in=threads)\
196 196 .order_by('pub_time').distinct().all()
197 197
198 198 def get_title(self) -> str:
199 199 """
200 200 Gets original post title or part of its text.
201 201 """
202 202
203 203 title = self.title
204 204 if not title:
205 205 title = self.get_text()
206 206
207 207 return title
208 208
209 209 def build_refmap(self) -> None:
210 210 """
211 211 Builds a replies map string from replies list. This is a cache to stop
212 212 the server from recalculating the map on every post show.
213 213 """
214 214
215 215 post_urls = [REFMAP_STR.format(refpost.get_absolute_url(), refpost.id)
216 216 for refpost in self.referenced_posts.all()]
217 217
218 218 self.refmap = ', '.join(post_urls)
219 219
220 220 def is_referenced(self) -> bool:
221 221 return self.refmap and len(self.refmap) > 0
222 222
223 223 def is_opening(self) -> bool:
224 224 """
225 225 Checks if this is an opening post or just a reply.
226 226 """
227 227
228 228 return self.get_thread().get_opening_post_id() == self.id
229 229
230 230 def get_absolute_url(self):
231 231 if self.url:
232 232 return self.url
233 233 else:
234 234 opening_id = self.get_thread().get_opening_post_id()
235 235 post_url = reverse('thread', kwargs={'post_id': opening_id})
236 236 if self.id != opening_id:
237 237 post_url += '#' + str(self.id)
238 238 return post_url
239 239
240 240
241 241 def get_thread(self):
242 242 return self.thread
243 243
244 244 def get_threads(self) -> list:
245 245 """
246 246 Gets post's thread.
247 247 """
248 248
249 249 return self.threads
250 250
251 251 def get_view(self, *args, **kwargs) -> str:
252 252 """
253 253 Renders post's HTML view. Some of the post params can be passed over
254 254 kwargs for the means of caching (if we view the thread, some params
255 255 are same for every post and don't need to be computed over and over.
256 256 """
257 257
258 258 thread = self.get_thread()
259 259 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
260 260
261 261 if is_opening:
262 262 opening_post_id = self.id
263 263 else:
264 264 opening_post_id = thread.get_opening_post_id()
265 265
266 266 css_class = 'post'
267 267 if thread.archived:
268 268 css_class += ' archive_post'
269 269 elif not thread.can_bump():
270 270 css_class += ' dead_post'
271 271
272 272 params = dict()
273 273 for param in POST_VIEW_PARAMS:
274 274 if param in kwargs:
275 275 params[param] = kwargs[param]
276 276
277 277 params.update({
278 278 PARAMETER_POST: self,
279 279 PARAMETER_IS_OPENING: is_opening,
280 280 PARAMETER_THREAD: thread,
281 281 PARAMETER_CSS_CLASS: css_class,
282 282 PARAMETER_OP_ID: opening_post_id,
283 283 })
284 284
285 285 return render_to_string('boards/post.html', params)
286 286
287 287 def get_search_view(self, *args, **kwargs):
288 288 return self.get_view(need_op_data=True, *args, **kwargs)
289 289
290 290 def get_first_image(self) -> PostImage:
291 291 return self.images.earliest('id')
292 292
293 293 def delete(self, using=None):
294 294 """
295 295 Deletes all post images and the post itself.
296 296 """
297 297
298 298 for image in self.images.all():
299 299 image_refs_count = Post.objects.filter(images__in=[image]).count()
300 300 if image_refs_count == 1:
301 301 image.delete()
302 302
303 303 thread = self.get_thread()
304 304 thread.last_edit_time = timezone.now()
305 305 thread.save()
306 306
307 307 super(Post, self).delete(using)
308 308
309 309 logging.getLogger('boards.post.delete').info(
310 310 'Deleted post {}'.format(self))
311 311
312 312 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
313 313 include_last_update=False) -> str:
314 314 """
315 315 Gets post HTML or JSON data that can be rendered on a page or used by
316 316 API.
317 317 """
318 318
319 319 return get_exporter(format_type).export(self, request,
320 320 include_last_update)
321 321
322 322 def notify_clients(self, recursive=True):
323 323 """
324 324 Sends post HTML data to the thread web socket.
325 325 """
326 326
327 327 if not settings.get_bool('External', 'WebsocketsEnabled'):
328 328 return
329 329
330 330 thread_ids = list()
331 331 for thread in self.get_threads().all():
332 332 thread_ids.append(thread.id)
333 333
334 334 thread.notify_clients()
335 335
336 336 if recursive:
337 337 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
338 338 post_id = reply_number.group(1)
339 339
340 340 try:
341 341 ref_post = Post.objects.get(id=post_id)
342 342
343 343 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
344 344 # If post is in this thread, its thread was already notified.
345 345 # Otherwise, notify its thread separately.
346 346 ref_post.notify_clients(recursive=False)
347 347 except ObjectDoesNotExist:
348 348 pass
349 349
350 350 def build_url(self):
351 351 self.url = self.get_absolute_url()
352 352 self.save(update_fields=['url'])
353 353
354 354 def save(self, force_insert=False, force_update=False, using=None,
355 355 update_fields=None):
356 356 self._text_rendered = Parser().parse(self.get_raw_text())
357 357
358 358 self.uid = str(uuid.uuid4())
359 359 if update_fields is not None and 'uid' not in update_fields:
360 360 update_fields += ['uid']
361 361
362 362 if self.id:
363 363 for thread in self.get_threads().all():
364 364 if thread.can_bump():
365 365 thread.update_bump_status(exclude_posts=[self])
366 366 thread.last_edit_time = self.last_edit_time
367 367
368 368 thread.save(update_fields=['last_edit_time', 'bumpable'])
369 369
370 370 super().save(force_insert, force_update, using, update_fields)
371 371
372 372 def get_text(self) -> str:
373 373 return self._text_rendered
374 374
375 375 def get_raw_text(self) -> str:
376 376 return self.text
377 377
378 378 def get_absolute_id(self) -> str:
379 379 """
380 380 If the post has many threads, shows its main thread OP id in the post
381 381 ID.
382 382 """
383 383
384 384 if self.get_threads().count() > 1:
385 385 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
386 386 else:
387 387 return str(self.id)
388 388
389 389 def connect_notifications(self):
390 390 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
391 391 user_name = reply_number.group(1).lower()
392 392 Notification.objects.get_or_create(name=user_name, post=self)
393 393
394 394 def connect_replies(self):
395 395 """
396 396 Connects replies to a post to show them as a reflink map
397 397 """
398 398
399 399 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
400 400 post_id = reply_number.group(1)
401 401
402 402 try:
403 403 referenced_post = Post.objects.get(id=post_id)
404 404
405 405 referenced_post.referenced_posts.add(self)
406 406 referenced_post.last_edit_time = self.pub_time
407 407 referenced_post.build_refmap()
408 408 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
409 409 except ObjectDoesNotExist:
410 410 pass
411 411
412 412 def connect_threads(self, opening_posts):
413 """
414 If the referenced post is an OP in another thread,
415 make this post multi-thread.
416 """
417
418 413 for opening_post in opening_posts:
419 414 threads = opening_post.get_threads().all()
420 415 for thread in threads:
421 416 if thread.can_bump():
422 417 thread.update_bump_status()
423 418
424 419 thread.last_edit_time = self.last_edit_time
425 420 thread.save(update_fields=['last_edit_time', 'bumpable'])
426
427 self.threads.add(thread)
421 self.threads.add(opening_post.get_thread())
General Comments 0
You need to be logged in to leave comments. Login now