##// END OF EJS Templates
Fixed post reflinks that were parsed as quotes in the line start
neko259 -
r925:1c0513f9 default
parent child Browse files
Show More
@@ -1,441 +1,441 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.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 bbcode_extended
16 16 from boards.models import PostImage
17 17 from boards.models.base import Viewable
18 18 from boards.models.thread import Thread
19 19 from boards.utils import datetime_to_epoch
20 20
21 21
22 22 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
23 23 WS_NOTIFICATION_TYPE = 'notification_type'
24 24
25 25 WS_CHANNEL_THREAD = "thread:"
26 26
27 27 APP_LABEL_BOARDS = 'boards'
28 28
29 29 CACHE_KEY_PPD = 'ppd'
30 30 CACHE_KEY_POST_URL = 'post_url'
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
48 48 PARAMETER_TRUNCATED = 'truncated'
49 49 PARAMETER_TAG = 'tag'
50 50 PARAMETER_OFFSET = 'offset'
51 51 PARAMETER_DIFF_TYPE = 'type'
52 52 PARAMETER_BUMPABLE = 'bumpable'
53 53 PARAMETER_THREAD = 'thread'
54 54 PARAMETER_IS_OPENING = 'is_opening'
55 55 PARAMETER_MODERATOR = 'moderator'
56 56 PARAMETER_POST = 'post'
57 57 PARAMETER_OP_ID = 'opening_post_id'
58 58 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
59 59
60 60 DIFF_TYPE_HTML = 'html'
61 61 DIFF_TYPE_JSON = 'json'
62 62
63 63 PREPARSE_PATTERNS = {
64 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 66 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
67 67 }
68 68
69 69
70 70 class PostManager(models.Manager):
71 71 @transaction.atomic
72 72 def create_post(self, title: str, text: str, image=None, thread=None,
73 73 ip=NO_IP, tags: list=None):
74 74 """
75 75 Creates new post
76 76 """
77 77
78 78 if not tags:
79 79 tags = []
80 80
81 81 posting_time = timezone.now()
82 82 if not thread:
83 83 thread = Thread.objects.create(bump_time=posting_time,
84 84 last_edit_time=posting_time)
85 85 new_thread = True
86 86 else:
87 87 new_thread = False
88 88
89 89 pre_text = self._preparse_text(text)
90 90
91 91 post = self.create(title=title,
92 92 text=pre_text,
93 93 pub_time=posting_time,
94 94 thread_new=thread,
95 95 poster_ip=ip,
96 96 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
97 97 # last!
98 98 last_edit_time=posting_time)
99 99
100 100 logger = logging.getLogger('boards.post.create')
101 101
102 102 logger.info('Created post {} by {}'.format(
103 103 post, post.poster_ip))
104 104
105 105 if image:
106 106 post_image = PostImage.objects.create(image=image)
107 107 post.images.add(post_image)
108 108 logger.info('Created image #{} for post #{}'.format(
109 109 post_image.id, post.id))
110 110
111 111 thread.replies.add(post)
112 112 list(map(thread.add_tag, tags))
113 113
114 114 if new_thread:
115 115 Thread.objects.process_oldest_threads()
116 116 else:
117 117 thread.bump()
118 118 thread.last_edit_time = posting_time
119 119 thread.save()
120 120
121 121 self.connect_replies(post)
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 def connect_replies(self, post):
135 135 """
136 136 Connects replies to a post to show them as a reflink map
137 137 """
138 138
139 139 for reply_number in re.finditer(REGEX_REPLY, post.get_raw_text()):
140 140 post_id = reply_number.group(1)
141 141 ref_post = self.filter(id=post_id)
142 142 if ref_post.count() > 0:
143 143 referenced_post = ref_post[0]
144 144 referenced_post.referenced_posts.add(post)
145 145 referenced_post.last_edit_time = post.pub_time
146 146 referenced_post.build_refmap()
147 147 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
148 148
149 149 referenced_thread = referenced_post.get_thread()
150 150 referenced_thread.last_edit_time = post.pub_time
151 151 referenced_thread.save(update_fields=['last_edit_time'])
152 152
153 153 def get_posts_per_day(self):
154 154 """
155 155 Gets average count of posts per day for the last 7 days
156 156 """
157 157
158 158 day_end = date.today()
159 159 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
160 160
161 161 cache_key = CACHE_KEY_PPD + str(day_end)
162 162 ppd = cache.get(cache_key)
163 163 if ppd:
164 164 return ppd
165 165
166 166 day_time_start = timezone.make_aware(datetime.combine(
167 167 day_start, dtime()), timezone.get_current_timezone())
168 168 day_time_end = timezone.make_aware(datetime.combine(
169 169 day_end, dtime()), timezone.get_current_timezone())
170 170
171 171 posts_per_period = float(self.filter(
172 172 pub_time__lte=day_time_end,
173 173 pub_time__gte=day_time_start).count())
174 174
175 175 ppd = posts_per_period / POSTS_PER_DAY_RANGE
176 176
177 177 cache.set(cache_key, ppd)
178 178 return ppd
179 179
180 180 def _preparse_text(self, text):
181 181 """
182 182 Preparses text to change patterns like '>>' to a proper bbcode
183 183 tags.
184 184 """
185 185
186 186 for key, value in PREPARSE_PATTERNS.items():
187 187 text = re.sub(key, value, text, flags=re.MULTILINE)
188 188
189 189 return text
190 190
191 191
192 192 class Post(models.Model, Viewable):
193 193 """A post is a message."""
194 194
195 195 objects = PostManager()
196 196
197 197 class Meta:
198 198 app_label = APP_LABEL_BOARDS
199 199 ordering = ('id',)
200 200
201 201 title = models.CharField(max_length=TITLE_MAX_LENGTH)
202 202 pub_time = models.DateTimeField()
203 203 text = TextField(blank=True, null=True)
204 204 _text_rendered = TextField(blank=True, null=True, editable=False)
205 205
206 206 images = models.ManyToManyField(PostImage, null=True, blank=True,
207 207 related_name='ip+', db_index=True)
208 208
209 209 poster_ip = models.GenericIPAddressField()
210 210 poster_user_agent = models.TextField()
211 211
212 212 thread_new = models.ForeignKey('Thread', null=True, default=None,
213 213 db_index=True)
214 214 last_edit_time = models.DateTimeField()
215 215
216 216 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
217 217 null=True,
218 218 blank=True, related_name='rfp+',
219 219 db_index=True)
220 220 refmap = models.TextField(null=True, blank=True)
221 221
222 222 def __str__(self):
223 223 return 'P#{}/{}'.format(self.id, self.title)
224 224
225 225 def get_title(self) -> str:
226 226 """
227 227 Gets original post title or part of its text.
228 228 """
229 229
230 230 title = self.title
231 231 if not title:
232 232 title = self.get_text()
233 233
234 234 return title
235 235
236 236 def build_refmap(self) -> None:
237 237 """
238 238 Builds a replies map string from replies list. This is a cache to stop
239 239 the server from recalculating the map on every post show.
240 240 """
241 241 map_string = ''
242 242
243 243 first = True
244 244 for refpost in self.referenced_posts.all():
245 245 if not first:
246 246 map_string += ', '
247 247 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
248 248 refpost.id)
249 249 first = False
250 250
251 251 self.refmap = map_string
252 252
253 253 def get_sorted_referenced_posts(self):
254 254 return self.refmap
255 255
256 256 def is_referenced(self) -> bool:
257 257 if not self.refmap:
258 258 return False
259 259 else:
260 260 return len(self.refmap) > 0
261 261
262 262 def is_opening(self) -> bool:
263 263 """
264 264 Checks if this is an opening post or just a reply.
265 265 """
266 266
267 267 return self.get_thread().get_opening_post_id() == self.id
268 268
269 269 @transaction.atomic
270 270 def add_tag(self, tag):
271 271 edit_time = timezone.now()
272 272
273 273 thread = self.get_thread()
274 274 thread.add_tag(tag)
275 275 self.last_edit_time = edit_time
276 276 self.save(update_fields=['last_edit_time'])
277 277
278 278 thread.last_edit_time = edit_time
279 279 thread.save(update_fields=['last_edit_time'])
280 280
281 281 def get_url(self, thread=None):
282 282 """
283 283 Gets full url to the post.
284 284 """
285 285
286 286 cache_key = CACHE_KEY_POST_URL + str(self.id)
287 287 link = cache.get(cache_key)
288 288
289 289 if not link:
290 290 if not thread:
291 291 thread = self.get_thread()
292 292
293 293 opening_id = thread.get_opening_post_id()
294 294
295 295 if self.id != opening_id:
296 296 link = reverse('thread', kwargs={
297 297 'post_id': opening_id}) + '#' + str(self.id)
298 298 else:
299 299 link = reverse('thread', kwargs={'post_id': self.id})
300 300
301 301 cache.set(cache_key, link)
302 302
303 303 return link
304 304
305 305 def get_thread(self) -> Thread:
306 306 """
307 307 Gets post's thread.
308 308 """
309 309
310 310 return self.thread_new
311 311
312 312 def get_referenced_posts(self):
313 313 return self.referenced_posts.only('id', 'thread_new')
314 314
315 315 def get_view(self, moderator=False, need_open_link=False,
316 316 truncated=False, *args, **kwargs):
317 317 """
318 318 Renders post's HTML view. Some of the post params can be passed over
319 319 kwargs for the means of caching (if we view the thread, some params
320 320 are same for every post and don't need to be computed over and over.
321 321 """
322 322
323 323 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
324 324 thread = kwargs.get(PARAMETER_THREAD, self.get_thread())
325 325 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
326 326
327 327 if is_opening:
328 328 opening_post_id = self.id
329 329 else:
330 330 opening_post_id = thread.get_opening_post_id()
331 331
332 332 return render_to_string('boards/post.html', {
333 333 PARAMETER_POST: self,
334 334 PARAMETER_MODERATOR: moderator,
335 335 PARAMETER_IS_OPENING: is_opening,
336 336 PARAMETER_THREAD: thread,
337 337 PARAMETER_BUMPABLE: can_bump,
338 338 PARAMETER_NEED_OPEN_LINK: need_open_link,
339 339 PARAMETER_TRUNCATED: truncated,
340 340 PARAMETER_OP_ID: opening_post_id,
341 341 })
342 342
343 343 def get_search_view(self, *args, **kwargs):
344 344 return self.get_view(args, kwargs)
345 345
346 346 def get_first_image(self) -> PostImage:
347 347 return self.images.earliest('id')
348 348
349 349 def delete(self, using=None):
350 350 """
351 351 Deletes all post images and the post itself. If the post is opening,
352 352 thread with all posts is deleted.
353 353 """
354 354
355 355 self.images.all().delete()
356 356
357 357 if self.is_opening():
358 358 self.get_thread().delete()
359 359 else:
360 360 thread = self.get_thread()
361 361 thread.last_edit_time = timezone.now()
362 362 thread.save()
363 363
364 364 super(Post, self).delete(using)
365 365
366 366 logging.getLogger('boards.post.delete').info(
367 367 'Deleted post {}'.format(self))
368 368
369 369 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
370 370 include_last_update=False):
371 371 """
372 372 Gets post HTML or JSON data that can be rendered on a page or used by
373 373 API.
374 374 """
375 375
376 376 if format_type == DIFF_TYPE_HTML:
377 377 params = dict()
378 378 params['post'] = self
379 379 if PARAMETER_TRUNCATED in request.GET:
380 380 params[PARAMETER_TRUNCATED] = True
381 381
382 382 return render_to_string('boards/api_post.html', params)
383 383 elif format_type == DIFF_TYPE_JSON:
384 384 post_json = {
385 385 'id': self.id,
386 386 'title': self.title,
387 387 'text': self._text_rendered,
388 388 }
389 389 if self.images.exists():
390 390 post_image = self.get_first_image()
391 391 post_json['image'] = post_image.image.url
392 392 post_json['image_preview'] = post_image.image.url_200x150
393 393 if include_last_update:
394 394 post_json['bump_time'] = datetime_to_epoch(
395 395 self.thread_new.bump_time)
396 396 return post_json
397 397
398 398 def send_to_websocket(self, request, recursive=True):
399 399 """
400 400 Sends post HTML data to the thread web socket.
401 401 """
402 402
403 403 if not settings.WEBSOCKETS_ENABLED:
404 404 return
405 405
406 406 client = Client()
407 407
408 408 thread = self.get_thread()
409 409 thread_id = thread.id
410 410 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
411 411 client.publish(channel_name, {
412 412 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
413 413 })
414 414 client.send()
415 415
416 416 logger = logging.getLogger('boards.post.websocket')
417 417
418 418 logger.info('Sent notification from post #{} to channel {}'.format(
419 419 self.id, channel_name))
420 420
421 421 if recursive:
422 422 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
423 423 post_id = reply_number.group(1)
424 424 ref_post = Post.objects.filter(id=post_id)[0]
425 425
426 426 # If post is in this thread, its thread was already notified.
427 427 # Otherwise, notify its thread separately.
428 428 if ref_post.thread_new_id != thread_id:
429 429 ref_post.send_to_websocket(request, recursive=False)
430 430
431 431 def save(self, force_insert=False, force_update=False, using=None,
432 432 update_fields=None):
433 433 self._text_rendered = bbcode_extended(self.get_raw_text())
434 434
435 435 super().save(force_insert, force_update, using, update_fields)
436 436
437 437 def get_text(self) -> str:
438 438 return self._text_rendered
439 439
440 440 def get_raw_text(self) -> str:
441 441 return self.text
General Comments 0
You need to be logged in to leave comments. Login now