##// END OF EJS Templates
Rebuild refmap after post from it was deleted
neko259 -
r1619:9c0a2b69 default
parent child Browse files
Show More
@@ -1,394 +1,399 b''
1 1 import uuid
2 2
3 3 import re
4 4 from boards import settings
5 5 from boards.abstracts.tripcode import Tripcode
6 6 from boards.models import Attachment, KeyPair, GlobalId
7 7 from boards.models.attachment import FILE_TYPES_IMAGE
8 8 from boards.models.base import Viewable
9 9 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
10 10 from boards.models.post.manager import PostManager
11 11 from boards.utils import datetime_to_epoch
12 12 from django.core.exceptions import ObjectDoesNotExist
13 13 from django.core.urlresolvers import reverse
14 14 from django.db import models
15 15 from django.db.models import TextField, QuerySet, F
16 16 from django.template.defaultfilters import truncatewords, striptags
17 17 from django.template.loader import render_to_string
18 18
19 19 CSS_CLS_HIDDEN_POST = 'hidden_post'
20 20 CSS_CLS_DEAD_POST = 'dead_post'
21 21 CSS_CLS_ARCHIVE_POST = 'archive_post'
22 22 CSS_CLS_POST = 'post'
23 23 CSS_CLS_MONOCHROME = 'monochrome'
24 24
25 25 TITLE_MAX_WORDS = 10
26 26
27 27 APP_LABEL_BOARDS = 'boards'
28 28
29 29 BAN_REASON_AUTO = 'Auto'
30 30
31 31 TITLE_MAX_LENGTH = 200
32 32
33 33 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
34 34 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
35 35 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
36 36 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
37 37
38 38 PARAMETER_TRUNCATED = 'truncated'
39 39 PARAMETER_TAG = 'tag'
40 40 PARAMETER_OFFSET = 'offset'
41 41 PARAMETER_DIFF_TYPE = 'type'
42 42 PARAMETER_CSS_CLASS = 'css_class'
43 43 PARAMETER_THREAD = 'thread'
44 44 PARAMETER_IS_OPENING = 'is_opening'
45 45 PARAMETER_POST = 'post'
46 46 PARAMETER_OP_ID = 'opening_post_id'
47 47 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
48 48 PARAMETER_REPLY_LINK = 'reply_link'
49 49 PARAMETER_NEED_OP_DATA = 'need_op_data'
50 50
51 51 POST_VIEW_PARAMS = (
52 52 'need_op_data',
53 53 'reply_link',
54 54 'need_open_link',
55 55 'truncated',
56 56 'mode_tree',
57 57 'perms',
58 58 'tree_depth',
59 59 )
60 60
61 61
62 62 class Post(models.Model, Viewable):
63 63 """A post is a message."""
64 64
65 65 objects = PostManager()
66 66
67 67 class Meta:
68 68 app_label = APP_LABEL_BOARDS
69 69 ordering = ('id',)
70 70
71 71 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
72 72 pub_time = models.DateTimeField()
73 73 text = TextField(blank=True, null=True)
74 74 _text_rendered = TextField(blank=True, null=True, editable=False)
75 75
76 76 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
77 77 related_name='attachment_posts')
78 78
79 79 poster_ip = models.GenericIPAddressField()
80 80
81 81 # Used for cache and threads updating
82 82 last_edit_time = models.DateTimeField()
83 83
84 84 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
85 85 null=True,
86 86 blank=True, related_name='refposts',
87 87 db_index=True)
88 88 refmap = models.TextField(null=True, blank=True)
89 89 threads = models.ManyToManyField('Thread', db_index=True,
90 90 related_name='multi_replies')
91 91 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
92 92
93 93 url = models.TextField()
94 94 uid = models.TextField(db_index=True)
95 95
96 96 # Global ID with author key. If the message was downloaded from another
97 97 # server, this indicates the server.
98 98 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
99 99 on_delete=models.CASCADE)
100 100
101 101 tripcode = models.CharField(max_length=50, blank=True, default='')
102 102 opening = models.BooleanField(db_index=True)
103 103 hidden = models.BooleanField(default=False)
104 104 version = models.IntegerField(default=1)
105 105
106 106 def __str__(self):
107 107 return 'P#{}/{}'.format(self.id, self.get_title())
108 108
109 109 def get_title(self) -> str:
110 110 return self.title
111 111
112 112 def get_title_or_text(self):
113 113 title = self.get_title()
114 114 if not title:
115 115 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
116 116
117 117 return title
118 118
119 def build_refmap(self) -> None:
119 def build_refmap(self, excluded_ids=None) -> None:
120 120 """
121 121 Builds a replies map string from replies list. This is a cache to stop
122 122 the server from recalculating the map on every post show.
123 123 """
124 124
125 post_urls = [refpost.get_link_view()
126 for refpost in self.referenced_posts.all()]
125 replies = self.referenced_posts
126 if excluded_ids is not None:
127 replies = replies.exclude(id__in=excluded_ids)
128 else:
129 replies = replies.all()
130
131 post_urls = [refpost.get_link_view() for refpost in replies]
127 132
128 133 self.refmap = ', '.join(post_urls)
129 134
130 135 def is_referenced(self) -> bool:
131 136 return self.refmap and len(self.refmap) > 0
132 137
133 138 def is_opening(self) -> bool:
134 139 """
135 140 Checks if this is an opening post or just a reply.
136 141 """
137 142
138 143 return self.opening
139 144
140 145 def get_absolute_url(self, thread=None):
141 146 url = None
142 147
143 148 if thread is None:
144 149 thread = self.get_thread()
145 150
146 151 # Url is cached only for the "main" thread. When getting url
147 152 # for other threads, do it manually.
148 153 if self.url:
149 154 url = self.url
150 155
151 156 if url is None:
152 157 opening = self.is_opening()
153 158 opening_id = self.id if opening else thread.get_opening_post_id()
154 159 url = reverse('thread', kwargs={'post_id': opening_id})
155 160 if not opening:
156 161 url += '#' + str(self.id)
157 162
158 163 return url
159 164
160 165 def get_thread(self):
161 166 return self.thread
162 167
163 168 def get_thread_id(self):
164 169 return self.thread_id
165 170
166 171 def get_threads(self) -> QuerySet:
167 172 """
168 173 Gets post's thread.
169 174 """
170 175
171 176 return self.threads
172 177
173 178 def _get_cache_key(self):
174 179 return [datetime_to_epoch(self.last_edit_time)]
175 180
176 181 def get_view(self, *args, **kwargs) -> str:
177 182 """
178 183 Renders post's HTML view. Some of the post params can be passed over
179 184 kwargs for the means of caching (if we view the thread, some params
180 185 are same for every post and don't need to be computed over and over.
181 186 """
182 187
183 188 thread = self.get_thread()
184 189
185 190 css_classes = [CSS_CLS_POST]
186 191 if thread.is_archived():
187 192 css_classes.append(CSS_CLS_ARCHIVE_POST)
188 193 elif not thread.can_bump():
189 194 css_classes.append(CSS_CLS_DEAD_POST)
190 195 if self.is_hidden():
191 196 css_classes.append(CSS_CLS_HIDDEN_POST)
192 197 if thread.is_monochrome():
193 198 css_classes.append(CSS_CLS_MONOCHROME)
194 199
195 200 params = dict()
196 201 for param in POST_VIEW_PARAMS:
197 202 if param in kwargs:
198 203 params[param] = kwargs[param]
199 204
200 205 params.update({
201 206 PARAMETER_POST: self,
202 207 PARAMETER_IS_OPENING: self.is_opening(),
203 208 PARAMETER_THREAD: thread,
204 209 PARAMETER_CSS_CLASS: ' '.join(css_classes),
205 210 })
206 211
207 212 return render_to_string('boards/post.html', params)
208 213
209 214 def get_search_view(self, *args, **kwargs):
210 215 return self.get_view(need_op_data=True, *args, **kwargs)
211 216
212 217 def get_first_image(self) -> Attachment:
213 218 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE).earliest('id')
214 219
215 220 def set_global_id(self, key_pair=None):
216 221 """
217 222 Sets global id based on the given key pair. If no key pair is given,
218 223 default one is used.
219 224 """
220 225
221 226 if key_pair:
222 227 key = key_pair
223 228 else:
224 229 try:
225 230 key = KeyPair.objects.get(primary=True)
226 231 except KeyPair.DoesNotExist:
227 232 # Do not update the global id because there is no key defined
228 233 return
229 234 global_id = GlobalId(key_type=key.key_type,
230 235 key=key.public_key,
231 236 local_id=self.id)
232 237 global_id.save()
233 238
234 239 self.global_id = global_id
235 240
236 241 self.save(update_fields=['global_id'])
237 242
238 243 def get_pub_time_str(self):
239 244 return str(self.pub_time)
240 245
241 246 def get_replied_ids(self):
242 247 """
243 248 Gets ID list of the posts that this post replies.
244 249 """
245 250
246 251 raw_text = self.get_raw_text()
247 252
248 253 local_replied = REGEX_REPLY.findall(raw_text)
249 254 global_replied = []
250 255 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
251 256 key_type = match[0]
252 257 key = match[1]
253 258 local_id = match[2]
254 259
255 260 try:
256 261 global_id = GlobalId.objects.get(key_type=key_type,
257 262 key=key, local_id=local_id)
258 263 for post in Post.objects.filter(global_id=global_id).only('id'):
259 264 global_replied.append(post.id)
260 265 except GlobalId.DoesNotExist:
261 266 pass
262 267 return local_replied + global_replied
263 268
264 269 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
265 270 include_last_update=False) -> str:
266 271 """
267 272 Gets post HTML or JSON data that can be rendered on a page or used by
268 273 API.
269 274 """
270 275
271 276 return get_exporter(format_type).export(self, request,
272 277 include_last_update)
273 278
274 279 def notify_clients(self, recursive=True):
275 280 """
276 281 Sends post HTML data to the thread web socket.
277 282 """
278 283
279 284 if not settings.get_bool('External', 'WebsocketsEnabled'):
280 285 return
281 286
282 287 thread_ids = list()
283 288 for thread in self.get_threads().all():
284 289 thread_ids.append(thread.id)
285 290
286 291 thread.notify_clients()
287 292
288 293 if recursive:
289 294 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
290 295 post_id = reply_number.group(1)
291 296
292 297 try:
293 298 ref_post = Post.objects.get(id=post_id)
294 299
295 300 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
296 301 # If post is in this thread, its thread was already notified.
297 302 # Otherwise, notify its thread separately.
298 303 ref_post.notify_clients(recursive=False)
299 304 except ObjectDoesNotExist:
300 305 pass
301 306
302 307 def build_url(self):
303 308 self.url = self.get_absolute_url()
304 309 self.save(update_fields=['url'])
305 310
306 311 def save(self, force_insert=False, force_update=False, using=None,
307 312 update_fields=None):
308 313 new_post = self.id is None
309 314
310 315 self.uid = str(uuid.uuid4())
311 316 if update_fields is not None and 'uid' not in update_fields:
312 317 update_fields += ['uid']
313 318
314 319 if not new_post:
315 320 for thread in self.get_threads().all():
316 321 thread.last_edit_time = self.last_edit_time
317 322
318 323 thread.save(update_fields=['last_edit_time', 'status'])
319 324
320 325 super().save(force_insert, force_update, using, update_fields)
321 326
322 327 if self.url is None:
323 328 self.build_url()
324 329
325 330 def get_text(self) -> str:
326 331 return self._text_rendered
327 332
328 333 def get_raw_text(self) -> str:
329 334 return self.text
330 335
331 336 def get_sync_text(self) -> str:
332 337 """
333 338 Returns text applicable for sync. It has absolute post reflinks.
334 339 """
335 340
336 341 replacements = dict()
337 342 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
338 343 try:
339 344 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
340 345 replacements[post_id] = absolute_post_id
341 346 except Post.DoesNotExist:
342 347 pass
343 348
344 349 text = self.get_raw_text() or ''
345 350 for key in replacements:
346 351 text = text.replace('[post]{}[/post]'.format(key),
347 352 '[post]{}[/post]'.format(replacements[key]))
348 353 text = text.replace('\r\n', '\n').replace('\r', '\n')
349 354
350 355 return text
351 356
352 357 def connect_threads(self, opening_posts):
353 358 for opening_post in opening_posts:
354 359 threads = opening_post.get_threads().all()
355 360 for thread in threads:
356 361 if thread.can_bump():
357 362 thread.update_bump_status()
358 363
359 364 thread.last_edit_time = self.last_edit_time
360 365 thread.save(update_fields=['last_edit_time', 'status'])
361 366 self.threads.add(opening_post.get_thread())
362 367
363 368 def get_tripcode(self):
364 369 if self.tripcode:
365 370 return Tripcode(self.tripcode)
366 371
367 372 def get_link_view(self):
368 373 """
369 374 Gets view of a reflink to the post.
370 375 """
371 376 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
372 377 self.id)
373 378 if self.is_opening():
374 379 result = '<b>{}</b>'.format(result)
375 380
376 381 return result
377 382
378 383 def is_hidden(self) -> bool:
379 384 return self.hidden
380 385
381 386 def set_hidden(self, hidden):
382 387 self.hidden = hidden
383 388
384 389 def increment_version(self):
385 390 self.version = F('version') + 1
386 391
387 392 def clear_cache(self):
388 393 """
389 394 Clears sync data (content cache, signatures etc).
390 395 """
391 396 global_id = self.global_id
392 397 if global_id is not None and global_id.is_local()\
393 398 and global_id.content is not None:
394 399 global_id.clear_cache()
@@ -1,108 +1,115 b''
1 1 import re
2 2 from boards import thumbs
3 3 from boards.mdx_neboard import get_parser
4 4
5 5 from boards.models import Post, GlobalId, Attachment
6 6 from boards.models.attachment.viewers import FILE_TYPES_IMAGE
7 7 from boards.models.post import REGEX_NOTIFICATION, REGEX_REPLY,\
8 8 REGEX_GLOBAL_REPLY
9 9 from boards.models.post.manager import post_import_deps
10 10 from boards.models.user import Notification
11 11 from django.db.models.signals import post_save, pre_save, pre_delete, \
12 12 post_delete
13 13 from django.dispatch import receiver
14 14 from django.utils import timezone
15 15
16 16
17 17 THUMB_SIZES = ((200, 150),)
18 18
19 19
20 20 @receiver(post_save, sender=Post)
21 21 def connect_replies(instance, **kwargs):
22 22 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
23 23 post_id = reply_number.group(1)
24 24
25 25 try:
26 26 referenced_post = Post.objects.get(id=post_id)
27 27
28 28 if not referenced_post.referenced_posts.filter(
29 29 id=instance.id).exists():
30 30 referenced_post.referenced_posts.add(instance)
31 31 referenced_post.last_edit_time = instance.pub_time
32 32 referenced_post.build_refmap()
33 33 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
34 34 except Post.DoesNotExist:
35 35 pass
36 36
37 37
38 38 @receiver(post_save, sender=Post)
39 39 @receiver(post_import_deps, sender=Post)
40 40 def connect_global_replies(instance, **kwargs):
41 41 for reply_number in re.finditer(REGEX_GLOBAL_REPLY, instance.get_raw_text()):
42 42 key_type = reply_number.group(1)
43 43 key = reply_number.group(2)
44 44 local_id = reply_number.group(3)
45 45
46 46 try:
47 47 global_id = GlobalId.objects.get(key_type=key_type, key=key,
48 48 local_id=local_id)
49 49 referenced_post = Post.objects.get(global_id=global_id)
50 50 referenced_post.referenced_posts.add(instance)
51 51 referenced_post.last_edit_time = instance.pub_time
52 52 referenced_post.build_refmap()
53 53 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
54 54 except (GlobalId.DoesNotExist, Post.DoesNotExist):
55 55 pass
56 56
57 57
58 58 @receiver(post_save, sender=Post)
59 59 def connect_notifications(instance, **kwargs):
60 60 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
61 61 user_name = reply_number.group(1).lower()
62 62 Notification.objects.get_or_create(name=user_name, post=instance)
63 63
64 64
65 65 @receiver(pre_save, sender=Post)
66 66 @receiver(post_import_deps, sender=Post)
67 67 def parse_text(instance, **kwargs):
68 68 instance._text_rendered = get_parser().parse(instance.get_raw_text())
69 69
70 70
71 71 @receiver(pre_delete, sender=Post)
72 72 def delete_attachments(instance, **kwargs):
73 73 for attachment in instance.attachments.all():
74 74 attachment_refs_count = attachment.attachment_posts.count()
75 75 if attachment_refs_count == 1:
76 76 attachment.delete()
77 77
78 78
79 79 @receiver(post_delete, sender=Post)
80 80 def update_thread_on_delete(instance, **kwargs):
81 81 thread = instance.get_thread()
82 82 thread.last_edit_time = timezone.now()
83 83 thread.save()
84 84
85 85
86 86 @receiver(post_delete, sender=Post)
87 87 def delete_global_id(instance, **kwargs):
88 88 if instance.global_id and instance.global_id.id:
89 89 instance.global_id.delete()
90 90
91 91
92 92 @receiver(post_save, sender=Attachment)
93 93 def generate_thumb(instance, **kwargs):
94 94 if instance.mimetype in FILE_TYPES_IMAGE:
95 95 for size in THUMB_SIZES:
96 96 (w, h) = size
97 97 split = instance.file.name.rsplit('.', 1)
98 98 thumb_name = '%s.%sx%s.%s' % (split[0], w, h, split[1])
99 99
100 100 if not instance.file.storage.exists(thumb_name):
101 101 # you can use another thumbnailing function if you like
102 102 thumb_content = thumbs.generate_thumb(instance.file, size, split[1])
103 103
104 104 thumb_name_ = instance.file.storage.save(thumb_name, thumb_content)
105 105
106 106 if not thumb_name == thumb_name_:
107 107 raise ValueError(
108 108 'There is already a file named %s' % thumb_name_)
109
110
111 @receiver(pre_delete, sender=Post)
112 def rebuild_refmap(instance, **kwargs):
113 for referenced_post in instance.refposts.all():
114 referenced_post.build_refmap(excluded_ids=[instance.id])
115 referenced_post.save(update_fields=['refmap'])
General Comments 0
You need to be logged in to leave comments. Login now