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