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