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