##// END OF EJS Templates
Split post module into post and manager
neko259 -
r1234:730ce4d2 decentral
parent child Browse files
Show More
@@ -0,0 +1,125 b''
1 from datetime import datetime, timedelta, date
2 from datetime import time as dtime
3 import logging
4 from django.db import models, transaction
5 from django.utils import timezone
6 from boards import utils
7 from boards.mdx_neboard import Parser
8 from boards.models import PostImage
9 import boards.models
10
11 __author__ = 'vurdalak'
12
13
14 NO_IP = '0.0.0.0'
15 POSTS_PER_DAY_RANGE = 7
16
17
18 class PostManager(models.Manager):
19 @transaction.atomic
20 def create_post(self, title: str, text: str, image=None, thread=None,
21 ip=NO_IP, tags: list=None, opening_posts: list=None):
22 """
23 Creates new post
24 """
25
26 is_banned = boards.models.Ban.objects.filter(ip=ip).exists()
27
28 # TODO Raise specific exception and catch it in the views
29 if is_banned:
30 raise Exception("This user is banned")
31
32 if not tags:
33 tags = []
34 if not opening_posts:
35 opening_posts = []
36
37 posting_time = timezone.now()
38 if not thread:
39 thread = boards.models.thread.Thread.objects.create(
40 bump_time=posting_time, last_edit_time=posting_time)
41 list(map(thread.tags.add, tags))
42 boards.models.thread.Thread.objects.process_oldest_threads()
43 else:
44 thread.last_edit_time = posting_time
45 thread.bump()
46 thread.save()
47
48 pre_text = Parser().preparse(text)
49
50 post = self.create(title=title,
51 text=pre_text,
52 pub_time=posting_time,
53 poster_ip=ip,
54 thread=thread,
55 last_edit_time=posting_time)
56 post.threads.add(thread)
57
58 post.set_global_id()
59
60 logger = logging.getLogger('boards.post.create')
61
62 logger.info('Created post {} by {}'.format(post, post.poster_ip))
63
64 if image:
65 post.images.add(PostImage.objects.create_with_hash(image))
66
67 post.build_url()
68 post.connect_replies()
69 post.connect_threads(opening_posts)
70 post.connect_notifications()
71
72 return post
73
74 @transaction.atomic
75 def import_post(self, title: str, text: str, pub_time: str,
76 opening_post=None, tags=list()):
77 if opening_post is None:
78 thread = boards.models.thread.Thread.objects.create(
79 bump_time=pub_time, last_edit_time=pub_time)
80 list(map(thread.tags.add, tags))
81 else:
82 thread = opening_post.get_thread()
83
84 post = self.create(title=title, text=text,
85 pub_time=pub_time,
86 poster_ip=NO_IP,
87 last_edit_time=pub_time,
88 thread_id=thread.id)
89
90 post.build_url()
91 post.connect_replies()
92 post.connect_notifications()
93
94 return post
95
96 def delete_posts_by_ip(self, ip):
97 """
98 Deletes all posts of the author with same IP
99 """
100
101 posts = self.filter(poster_ip=ip)
102 for post in posts:
103 post.delete()
104
105 @utils.cached_result()
106 def get_posts_per_day(self) -> float:
107 """
108 Gets average count of posts per day for the last 7 days
109 """
110
111 day_end = date.today()
112 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
113
114 day_time_start = timezone.make_aware(datetime.combine(
115 day_start, dtime()), timezone.get_current_timezone())
116 day_time_end = timezone.make_aware(datetime.combine(
117 day_end, dtime()), timezone.get_current_timezone())
118
119 posts_per_period = float(self.filter(
120 pub_time__lte=day_time_end,
121 pub_time__gte=day_time_start).count())
122
123 ppd = posts_per_period / POSTS_PER_DAY_RANGE
124
125 return ppd
@@ -1,16 +1,15 b''
1 1 from django.core.management import BaseCommand
2 2 from django.db import transaction
3 3
4 4 from boards.models import Post
5 from boards.models.post import NO_IP
6
5 from boards.models.post.manager import NO_IP
7 6
8 7 __author__ = 'neko259'
9 8
10 9
11 10 class Command(BaseCommand):
12 11 help = 'Removes user and IP data from all posts'
13 12
14 13 @transaction.atomic
15 14 def handle(self, *args, **options):
16 15 Post.objects.all().update(poster_ip=NO_IP) No newline at end of file
@@ -1,520 +1,405 b''
1 from datetime import datetime, timedelta, date
2 from datetime import time as dtime
3 1 import logging
4 2 import re
5 3 import uuid
6 4
7 5 from django.core.exceptions import ObjectDoesNotExist
8 6 from django.core.urlresolvers import reverse
9 from django.db import models, transaction
7 from django.db import models
10 8 from django.db.models import TextField, QuerySet
9
11 10 from django.template.loader import render_to_string
11
12 12 from django.utils import timezone
13 13
14 14 from boards.mdx_neboard import Parser
15 15 from boards.models import KeyPair, GlobalId
16 16 from boards import settings
17 17 from boards.models import PostImage
18 18 from boards.models.base import Viewable
19 from boards import utils
20 19 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
21 from boards.models.user import Notification, Ban
22 import boards.models.thread
20 from boards.models.post.manager import PostManager
21 from boards.models.user import Notification
23 22
24 23 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
25 24 WS_NOTIFICATION_TYPE = 'notification_type'
26 25
27 26 WS_CHANNEL_THREAD = "thread:"
28 27
29 28 APP_LABEL_BOARDS = 'boards'
30 29
31 POSTS_PER_DAY_RANGE = 7
32
33 30 BAN_REASON_AUTO = 'Auto'
34 31
35 32 IMAGE_THUMB_SIZE = (200, 150)
36 33
37 34 TITLE_MAX_LENGTH = 200
38 35
39 NO_IP = '0.0.0.0'
40
41 36 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
42 37 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
43 38 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
44 39 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
45 40
46 41 PARAMETER_TRUNCATED = 'truncated'
47 42 PARAMETER_TAG = 'tag'
48 43 PARAMETER_OFFSET = 'offset'
49 44 PARAMETER_DIFF_TYPE = 'type'
50 45 PARAMETER_CSS_CLASS = 'css_class'
51 46 PARAMETER_THREAD = 'thread'
52 47 PARAMETER_IS_OPENING = 'is_opening'
53 48 PARAMETER_MODERATOR = 'moderator'
54 49 PARAMETER_POST = 'post'
55 50 PARAMETER_OP_ID = 'opening_post_id'
56 51 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
57 52 PARAMETER_REPLY_LINK = 'reply_link'
58 53 PARAMETER_NEED_OP_DATA = 'need_op_data'
59 54
60 55 POST_VIEW_PARAMS = (
61 56 'need_op_data',
62 57 'reply_link',
63 58 'moderator',
64 59 'need_open_link',
65 60 'truncated',
66 61 'mode_tree',
67 62 )
68 63
69 64 REFMAP_STR = '<a href="{}">&gt;&gt;{}</a>'
70 65
71 66
72 class PostManager(models.Manager):
73 @transaction.atomic
74 def create_post(self, title: str, text: str, image=None, thread=None,
75 ip=NO_IP, tags: list=None, opening_posts: list=None):
76 """
77 Creates new post
78 """
79
80 is_banned = Ban.objects.filter(ip=ip).exists()
81
82 # TODO Raise specific exception and catch it in the views
83 if is_banned:
84 raise Exception("This user is banned")
85
86 if not tags:
87 tags = []
88 if not opening_posts:
89 opening_posts = []
90
91 posting_time = timezone.now()
92 if not thread:
93 thread = boards.models.thread.Thread.objects.create(
94 bump_time=posting_time, last_edit_time=posting_time)
95 list(map(thread.tags.add, tags))
96 boards.models.thread.Thread.objects.process_oldest_threads()
97 else:
98 thread.last_edit_time = posting_time
99 thread.bump()
100 thread.save()
101
102 pre_text = Parser().preparse(text)
103
104 post = self.create(title=title,
105 text=pre_text,
106 pub_time=posting_time,
107 poster_ip=ip,
108 thread=thread,
109 last_edit_time=posting_time)
110 post.threads.add(thread)
111
112 post.set_global_id()
113
114 logger = logging.getLogger('boards.post.create')
115
116 logger.info('Created post {} by {}'.format(post, post.poster_ip))
117
118 if image:
119 post.images.add(PostImage.objects.create_with_hash(image))
120
121 post.build_url()
122 post.connect_replies()
123 post.connect_threads(opening_posts)
124 post.connect_notifications()
125
126 return post
127
128 @transaction.atomic
129 def import_post(self, title: str, text:str, pub_time: str,
130 opening_post=None, tags=list()):
131 if opening_post is None:
132 thread = boards.models.thread.Thread.objects.create(
133 bump_time=pub_time, last_edit_time=pub_time)
134 list(map(thread.tags.add, tags))
135 else:
136 thread = opening_post.get_thread()
137
138 post = Post.objects.create(title=title, text=text,
139 pub_time=pub_time,
140 poster_ip=NO_IP,
141 last_edit_time=pub_time,
142 thread_id=thread.id)
143
144 post.build_url()
145 post.connect_replies()
146 post.connect_notifications()
147
148 return post
149
150 def delete_posts_by_ip(self, ip):
151 """
152 Deletes all posts of the author with same IP
153 """
154
155 posts = self.filter(poster_ip=ip)
156 for post in posts:
157 post.delete()
158
159 @utils.cached_result()
160 def get_posts_per_day(self) -> float:
161 """
162 Gets average count of posts per day for the last 7 days
163 """
164
165 day_end = date.today()
166 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
167
168 day_time_start = timezone.make_aware(datetime.combine(
169 day_start, dtime()), timezone.get_current_timezone())
170 day_time_end = timezone.make_aware(datetime.combine(
171 day_end, dtime()), timezone.get_current_timezone())
172
173 posts_per_period = float(self.filter(
174 pub_time__lte=day_time_end,
175 pub_time__gte=day_time_start).count())
176
177 ppd = posts_per_period / POSTS_PER_DAY_RANGE
178
179 return ppd
180
181
182 67 class Post(models.Model, Viewable):
183 68 """A post is a message."""
184 69
185 70 objects = PostManager()
186 71
187 72 class Meta:
188 73 app_label = APP_LABEL_BOARDS
189 74 ordering = ('id',)
190 75
191 76 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
192 77 pub_time = models.DateTimeField()
193 78 text = TextField(blank=True, null=True)
194 79 _text_rendered = TextField(blank=True, null=True, editable=False)
195 80
196 81 images = models.ManyToManyField(PostImage, null=True, blank=True,
197 82 related_name='ip+', db_index=True)
198 83
199 84 poster_ip = models.GenericIPAddressField()
200 85
201 86 # TODO This field can be removed cause UID is used for update now
202 87 last_edit_time = models.DateTimeField()
203 88
204 89 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
205 90 null=True,
206 91 blank=True, related_name='refposts',
207 92 db_index=True)
208 93 refmap = models.TextField(null=True, blank=True)
209 94 threads = models.ManyToManyField('Thread', db_index=True)
210 95 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
211 96
212 97 url = models.TextField()
213 98 uid = models.TextField(db_index=True)
214 99
215 100 # Global ID with author key. If the message was downloaded from another
216 101 # server, this indicates the server.
217 102 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
218 103
219 104 # One post can be signed by many nodes that give their trust to it
220 105 signature = models.ManyToManyField('Signature', null=True, blank=True)
221 106
222 107 def __str__(self):
223 108 return 'P#{}/{}'.format(self.id, self.title)
224 109
225 110 def get_referenced_posts(self):
226 111 threads = self.get_threads().all()
227 112 return self.referenced_posts.filter(threads__in=threads)\
228 113 .order_by('pub_time').distinct().all()
229 114
230 115 def get_title(self) -> str:
231 116 """
232 117 Gets original post title or part of its text.
233 118 """
234 119
235 120 title = self.title
236 121 if not title:
237 122 title = self.get_text()
238 123
239 124 return title
240 125
241 126 def build_refmap(self) -> None:
242 127 """
243 128 Builds a replies map string from replies list. This is a cache to stop
244 129 the server from recalculating the map on every post show.
245 130 """
246 131
247 132 post_urls = [REFMAP_STR.format(refpost.get_absolute_url(), refpost.id)
248 133 for refpost in self.referenced_posts.all()]
249 134
250 135 self.refmap = ', '.join(post_urls)
251 136
252 137 def is_referenced(self) -> bool:
253 138 return self.refmap and len(self.refmap) > 0
254 139
255 140 def is_opening(self) -> bool:
256 141 """
257 142 Checks if this is an opening post or just a reply.
258 143 """
259 144
260 145 return self.get_thread().get_opening_post_id() == self.id
261 146
262 147 def get_absolute_url(self):
263 148 if self.url:
264 149 return self.url
265 150 else:
266 151 opening_id = self.get_thread().get_opening_post_id()
267 152 post_url = reverse('thread', kwargs={'post_id': opening_id})
268 153 if self.id != opening_id:
269 154 post_url += '#' + str(self.id)
270 155 return post_url
271 156
272 157 def get_thread(self):
273 158 return self.thread
274 159
275 160 def get_threads(self) -> QuerySet:
276 161 """
277 162 Gets post's thread.
278 163 """
279 164
280 165 return self.threads
281 166
282 167 def get_view(self, *args, **kwargs) -> str:
283 168 """
284 169 Renders post's HTML view. Some of the post params can be passed over
285 170 kwargs for the means of caching (if we view the thread, some params
286 171 are same for every post and don't need to be computed over and over.
287 172 """
288 173
289 174 thread = self.get_thread()
290 175 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
291 176
292 177 if is_opening:
293 178 opening_post_id = self.id
294 179 else:
295 180 opening_post_id = thread.get_opening_post_id()
296 181
297 182 css_class = 'post'
298 183 if thread.archived:
299 184 css_class += ' archive_post'
300 185 elif not thread.can_bump():
301 186 css_class += ' dead_post'
302 187
303 188 params = dict()
304 189 for param in POST_VIEW_PARAMS:
305 190 if param in kwargs:
306 191 params[param] = kwargs[param]
307 192
308 193 params.update({
309 194 PARAMETER_POST: self,
310 195 PARAMETER_IS_OPENING: is_opening,
311 196 PARAMETER_THREAD: thread,
312 197 PARAMETER_CSS_CLASS: css_class,
313 198 PARAMETER_OP_ID: opening_post_id,
314 199 })
315 200
316 201 return render_to_string('boards/post.html', params)
317 202
318 203 def get_search_view(self, *args, **kwargs):
319 204 return self.get_view(need_op_data=True, *args, **kwargs)
320 205
321 206 def get_first_image(self) -> PostImage:
322 207 return self.images.earliest('id')
323 208
324 209 def delete(self, using=None):
325 210 """
326 211 Deletes all post images and the post itself.
327 212 """
328 213
329 214 for image in self.images.all():
330 215 image_refs_count = Post.objects.filter(images__in=[image]).count()
331 216 if image_refs_count == 1:
332 217 image.delete()
333 218
334 219 self.signature.all().delete()
335 220 if self.global_id:
336 221 self.global_id.delete()
337 222
338 223 thread = self.get_thread()
339 224 thread.last_edit_time = timezone.now()
340 225 thread.save()
341 226
342 227 super(Post, self).delete(using)
343 228
344 229 logging.getLogger('boards.post.delete').info(
345 230 'Deleted post {}'.format(self))
346 231
347 232 def set_global_id(self, key_pair=None):
348 233 """
349 234 Sets global id based on the given key pair. If no key pair is given,
350 235 default one is used.
351 236 """
352 237
353 238 if key_pair:
354 239 key = key_pair
355 240 else:
356 241 try:
357 242 key = KeyPair.objects.get(primary=True)
358 243 except KeyPair.DoesNotExist:
359 244 # Do not update the global id because there is no key defined
360 245 return
361 246 global_id = GlobalId(key_type=key.key_type,
362 247 key=key.public_key,
363 248 local_id=self.id)
364 249 global_id.save()
365 250
366 251 self.global_id = global_id
367 252
368 253 self.save(update_fields=['global_id'])
369 254
370 255 def get_pub_time_str(self):
371 256 return str(self.pub_time)
372 257
373 258 def get_replied_ids(self):
374 259 """
375 260 Gets ID list of the posts that this post replies.
376 261 """
377 262
378 263 raw_text = self.get_raw_text()
379 264
380 265 local_replied = REGEX_REPLY.findall(raw_text)
381 266 global_replied = []
382 267 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
383 268 key_type = match[0]
384 269 key = match[1]
385 270 local_id = match[2]
386 271
387 272 try:
388 273 global_id = GlobalId.objects.get(key_type=key_type,
389 274 key=key, local_id=local_id)
390 275 for post in Post.objects.filter(global_id=global_id).only('id'):
391 276 global_replied.append(post.id)
392 277 except GlobalId.DoesNotExist:
393 278 pass
394 279 return local_replied + global_replied
395 280
396 281 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
397 282 include_last_update=False) -> str:
398 283 """
399 284 Gets post HTML or JSON data that can be rendered on a page or used by
400 285 API.
401 286 """
402 287
403 288 return get_exporter(format_type).export(self, request,
404 289 include_last_update)
405 290
406 291 def notify_clients(self, recursive=True):
407 292 """
408 293 Sends post HTML data to the thread web socket.
409 294 """
410 295
411 296 if not settings.get_bool('External', 'WebsocketsEnabled'):
412 297 return
413 298
414 299 thread_ids = list()
415 300 for thread in self.get_threads().all():
416 301 thread_ids.append(thread.id)
417 302
418 303 thread.notify_clients()
419 304
420 305 if recursive:
421 306 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
422 307 post_id = reply_number.group(1)
423 308
424 309 try:
425 310 ref_post = Post.objects.get(id=post_id)
426 311
427 312 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
428 313 # If post is in this thread, its thread was already notified.
429 314 # Otherwise, notify its thread separately.
430 315 ref_post.notify_clients(recursive=False)
431 316 except ObjectDoesNotExist:
432 317 pass
433 318
434 319 def build_url(self):
435 320 self.url = self.get_absolute_url()
436 321 self.save(update_fields=['url'])
437 322
438 323 def save(self, force_insert=False, force_update=False, using=None,
439 324 update_fields=None):
440 325 self._text_rendered = Parser().parse(self.get_raw_text())
441 326
442 327 self.uid = str(uuid.uuid4())
443 328 if update_fields is not None and 'uid' not in update_fields:
444 329 update_fields += ['uid']
445 330
446 331 if self.id:
447 332 for thread in self.get_threads().all():
448 333 thread.last_edit_time = self.last_edit_time
449 334
450 335 thread.save(update_fields=['last_edit_time'])
451 336
452 337 super().save(force_insert, force_update, using, update_fields)
453 338
454 339 def get_text(self) -> str:
455 340 return self._text_rendered
456 341
457 342 def get_raw_text(self) -> str:
458 343 return self.text
459 344
460 345 def get_sync_text(self) -> str:
461 346 """
462 347 Returns text applicable for sync. It has absolute post reflinks.
463 348 """
464 349
465 350 replacements = dict()
466 351 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
467 352 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
468 353 replacements[post_id] = absolute_post_id
469 354
470 355 text = self.get_raw_text()
471 356 for key in replacements:
472 357 text = text.replace('[post]{}[/post]'.format(key),
473 358 '[post]{}[/post]'.format(replacements[key]))
474 359
475 360 return text
476 361
477 362 def get_absolute_id(self) -> str:
478 363 """
479 364 If the post has many threads, shows its main thread OP id in the post
480 365 ID.
481 366 """
482 367
483 368 if self.get_threads().count() > 1:
484 369 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
485 370 else:
486 371 return str(self.id)
487 372
488 373 def connect_notifications(self):
489 374 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
490 375 user_name = reply_number.group(1).lower()
491 376 Notification.objects.get_or_create(name=user_name, post=self)
492 377
493 378 def connect_replies(self):
494 379 """
495 380 Connects replies to a post to show them as a reflink map
496 381 """
497 382
498 383 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
499 384 post_id = reply_number.group(1)
500 385
501 386 try:
502 387 referenced_post = Post.objects.get(id=post_id)
503 388
504 389 referenced_post.referenced_posts.add(self)
505 390 referenced_post.last_edit_time = self.pub_time
506 391 referenced_post.build_refmap()
507 392 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
508 393 except ObjectDoesNotExist:
509 394 pass
510 395
511 396 def connect_threads(self, opening_posts):
512 397 for opening_post in opening_posts:
513 398 threads = opening_post.get_threads().all()
514 399 for thread in threads:
515 400 if thread.can_bump():
516 401 thread.update_bump_status()
517 402
518 403 thread.last_edit_time = self.last_edit_time
519 404 thread.save(update_fields=['last_edit_time', 'bumpable'])
520 405 self.threads.add(opening_post.get_thread())
@@ -1,46 +1,45 b''
1 1 from django.db import models
2
3 import boards.models.post
2 import boards
4 3
5 4 __author__ = 'neko259'
6 5
7 6 BAN_REASON_AUTO = 'Auto'
8 7 BAN_REASON_MAX_LENGTH = 200
9 8
10 9
11 10 class Ban(models.Model):
12 11
13 12 class Meta:
14 13 app_label = 'boards'
15 14
16 15 ip = models.GenericIPAddressField()
17 16 reason = models.CharField(default=BAN_REASON_AUTO,
18 17 max_length=BAN_REASON_MAX_LENGTH)
19 18 can_read = models.BooleanField(default=True)
20 19
21 20 def __str__(self):
22 21 return self.ip
23 22
24 23
25 24 class NotificationManager(models.Manager):
26 25 def get_notification_posts(self, username: str, last: int = None):
27 26 i_username = username.lower()
28 27
29 28 posts = boards.models.post.Post.objects.filter(notification__name=i_username)
30 29 if last is not None:
31 30 posts = posts.filter(id__gt=last)
32 31 posts = posts.order_by('-id')
33 32
34 33 return posts
35 34
36 35
37 36 class Notification(models.Model):
38 37
39 38 class Meta:
40 39 app_label = 'boards'
41 40
42 41 objects = NotificationManager()
43 42
44 43 post = models.ForeignKey('Post')
45 44 name = models.TextField()
46 45
General Comments 0
You need to be logged in to leave comments. Login now