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