##// END OF EJS Templates
Actually delete the opening post when deleting the thread
neko259 -
r884:e3087995 default
parent child Browse files
Show More
@@ -1,447 +1,447 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
6 6 from adjacent import Client
7 7 from django.core.cache import cache
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 import RequestContext
12 12 from django.template.loader import render_to_string
13 13 from django.utils import timezone
14 14
15 15 from boards import settings
16 16 from boards.mdx_neboard import bbcode_extended
17 17 from boards.models import PostImage
18 18 from boards.models.base import Viewable
19 19 from boards.models.thread import Thread
20 20 from boards.utils import datetime_to_epoch
21 21
22 22
23 23 WS_CHANNEL_THREAD = "thread:"
24 24
25 25 APP_LABEL_BOARDS = 'boards'
26 26
27 27 CACHE_KEY_PPD = 'ppd'
28 28 CACHE_KEY_POST_URL = 'post_url'
29 29
30 30 POSTS_PER_DAY_RANGE = 7
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 # TODO This should be removed
39 39 NO_IP = '0.0.0.0'
40 40
41 41 # TODO Real user agent should be saved instead of this
42 42 UNKNOWN_UA = ''
43 43
44 44 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
45 45
46 46 PARAMETER_TRUNCATED = 'truncated'
47 47 PARAMETER_TAG = 'tag'
48 48 PARAMETER_OFFSET = 'offset'
49 49 PARAMETER_DIFF_TYPE = 'type'
50 50
51 51 DIFF_TYPE_HTML = 'html'
52 52 DIFF_TYPE_JSON = 'json'
53 53
54 54 PREPARSE_PATTERNS = {
55 55 r'>>(\d+)': r'[post]\1[/post]',
56 56 r'>(.+)': r'[quote]\1[/quote]'
57 57 }
58 58
59 59
60 60 class PostManager(models.Manager):
61 61 def create_post(self, title, text, image=None, thread=None, ip=NO_IP,
62 62 tags=None):
63 63 """
64 64 Creates new post
65 65 """
66 66
67 67 if not tags:
68 68 tags = []
69 69
70 70 posting_time = timezone.now()
71 71 if not thread:
72 72 thread = Thread.objects.create(bump_time=posting_time,
73 73 last_edit_time=posting_time)
74 74 new_thread = True
75 75 else:
76 76 thread.bump()
77 77 thread.last_edit_time = posting_time
78 78 if thread.can_bump() and (
79 79 thread.get_reply_count() >= settings.MAX_POSTS_PER_THREAD):
80 80 thread.bumpable = False
81 81 thread.save()
82 82 new_thread = False
83 83
84 84 pre_text = self._preparse_text(text)
85 85
86 86 post = self.create(title=title,
87 87 text=pre_text,
88 88 pub_time=posting_time,
89 89 thread_new=thread,
90 90 poster_ip=ip,
91 91 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
92 92 # last!
93 93 last_edit_time=posting_time)
94 94
95 95 logger = logging.getLogger('boards.post.create')
96 96
97 97 logger.info('Created post #{} with title "{}" by {}'.format(
98 98 post.id, post.title, post.poster_ip))
99 99
100 100 if image:
101 101 post_image = PostImage.objects.create(image=image)
102 102 post.images.add(post_image)
103 103 logger.info('Created image #{} for post #{}'.format(
104 104 post_image.id, post.id))
105 105
106 106 thread.replies.add(post)
107 107 list(map(thread.add_tag, tags))
108 108
109 109 if new_thread:
110 110 Thread.objects.process_oldest_threads()
111 111 self.connect_replies(post)
112 112
113 113 return post
114 114
115 115 def delete_posts_by_ip(self, ip):
116 116 """
117 117 Deletes all posts of the author with same IP
118 118 """
119 119
120 120 posts = self.filter(poster_ip=ip)
121 121 for post in posts:
122 122 post.delete()
123 123
124 124 def connect_replies(self, post):
125 125 """
126 126 Connects replies to a post to show them as a reflink map
127 127 """
128 128
129 129 for reply_number in re.finditer(REGEX_REPLY, post.get_raw_text()):
130 130 post_id = reply_number.group(1)
131 131 ref_post = self.filter(id=post_id)
132 132 if ref_post.count() > 0:
133 133 referenced_post = ref_post[0]
134 134 referenced_post.referenced_posts.add(post)
135 135 referenced_post.last_edit_time = post.pub_time
136 136 referenced_post.build_refmap()
137 137 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
138 138
139 139 referenced_thread = referenced_post.get_thread()
140 140 referenced_thread.last_edit_time = post.pub_time
141 141 referenced_thread.save(update_fields=['last_edit_time'])
142 142
143 143 def get_posts_per_day(self):
144 144 """
145 145 Gets average count of posts per day for the last 7 days
146 146 """
147 147
148 148 day_end = date.today()
149 149 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
150 150
151 151 cache_key = CACHE_KEY_PPD + str(day_end)
152 152 ppd = cache.get(cache_key)
153 153 if ppd:
154 154 return ppd
155 155
156 156 day_time_start = timezone.make_aware(datetime.combine(
157 157 day_start, dtime()), timezone.get_current_timezone())
158 158 day_time_end = timezone.make_aware(datetime.combine(
159 159 day_end, dtime()), timezone.get_current_timezone())
160 160
161 161 posts_per_period = float(self.filter(
162 162 pub_time__lte=day_time_end,
163 163 pub_time__gte=day_time_start).count())
164 164
165 165 ppd = posts_per_period / POSTS_PER_DAY_RANGE
166 166
167 167 cache.set(cache_key, ppd)
168 168 return ppd
169 169
170 170 def _preparse_text(self, text):
171 171 """
172 172 Preparses text to change patterns like '>>' to a proper bbcode
173 173 tags.
174 174 """
175 175
176 176 for key, value in PREPARSE_PATTERNS.items():
177 177 text = re.sub(key, value, text)
178 178
179 179 return text
180 180
181 181
182 182 class Post(models.Model, Viewable):
183 183 """A post is a message."""
184 184
185 185 objects = PostManager()
186 186
187 187 class Meta:
188 188 app_label = APP_LABEL_BOARDS
189 189 ordering = ('id',)
190 190
191 191 title = models.CharField(max_length=TITLE_MAX_LENGTH)
192 192 pub_time = models.DateTimeField()
193 193 text = TextField(blank=True, null=True)
194 194 _text_rendered = TextField(blank=True, null=True, editable=False)
195 195
196 196 images = models.ManyToManyField(PostImage, null=True, blank=True,
197 197 related_name='ip+', db_index=True)
198 198
199 199 poster_ip = models.GenericIPAddressField()
200 200 poster_user_agent = models.TextField()
201 201
202 202 thread_new = models.ForeignKey('Thread', null=True, default=None,
203 203 db_index=True)
204 204 last_edit_time = models.DateTimeField()
205 205
206 206 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
207 207 null=True,
208 208 blank=True, related_name='rfp+',
209 209 db_index=True)
210 210 refmap = models.TextField(null=True, blank=True)
211 211
212 212 def __str__(self):
213 213 return 'P#{}/{}'.format(self.id, self.title)
214 214
215 215 def get_title(self):
216 216 """
217 217 Gets original post title or part of its text.
218 218 """
219 219
220 220 title = self.title
221 221 if not title:
222 222 title = self.get_text()
223 223
224 224 return title
225 225
226 226 def build_refmap(self):
227 227 """
228 228 Builds a replies map string from replies list. This is a cache to stop
229 229 the server from recalculating the map on every post show.
230 230 """
231 231 map_string = ''
232 232
233 233 first = True
234 234 for refpost in self.referenced_posts.all():
235 235 if not first:
236 236 map_string += ', '
237 237 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
238 238 refpost.id)
239 239 first = False
240 240
241 241 self.refmap = map_string
242 242
243 243 def get_sorted_referenced_posts(self):
244 244 return self.refmap
245 245
246 246 def is_referenced(self):
247 247 if not self.refmap:
248 248 return False
249 249 else:
250 250 return len(self.refmap) > 0
251 251
252 252 def is_opening(self):
253 253 """
254 254 Checks if this is an opening post or just a reply.
255 255 """
256 256
257 257 return self.get_thread().get_opening_post_id() == self.id
258 258
259 259 @transaction.atomic
260 260 def add_tag(self, tag):
261 261 edit_time = timezone.now()
262 262
263 263 thread = self.get_thread()
264 264 thread.add_tag(tag)
265 265 self.last_edit_time = edit_time
266 266 self.save(update_fields=['last_edit_time'])
267 267
268 268 thread.last_edit_time = edit_time
269 269 thread.save(update_fields=['last_edit_time'])
270 270
271 271 @transaction.atomic
272 272 def remove_tag(self, tag):
273 273 edit_time = timezone.now()
274 274
275 275 thread = self.get_thread()
276 276 thread.remove_tag(tag)
277 277 self.last_edit_time = edit_time
278 278 self.save(update_fields=['last_edit_time'])
279 279
280 280 thread.last_edit_time = edit_time
281 281 thread.save(update_fields=['last_edit_time'])
282 282
283 283 def get_url(self, thread=None):
284 284 """
285 285 Gets full url to the post.
286 286 """
287 287
288 288 cache_key = CACHE_KEY_POST_URL + str(self.id)
289 289 link = cache.get(cache_key)
290 290
291 291 if not link:
292 292 if not thread:
293 293 thread = self.get_thread()
294 294
295 295 opening_id = thread.get_opening_post_id()
296 296
297 297 if self.id != opening_id:
298 298 link = reverse('thread', kwargs={
299 299 'post_id': opening_id}) + '#' + str(self.id)
300 300 else:
301 301 link = reverse('thread', kwargs={'post_id': self.id})
302 302
303 303 cache.set(cache_key, link)
304 304
305 305 return link
306 306
307 307 def get_thread(self):
308 308 """
309 309 Gets post's thread.
310 310 """
311 311
312 312 return self.thread_new
313 313
314 314 def get_referenced_posts(self):
315 315 return self.referenced_posts.only('id', 'thread_new')
316 316
317 317 def get_text(self):
318 318 return self.text
319 319
320 320 def get_view(self, moderator=False, need_open_link=False,
321 321 truncated=False, *args, **kwargs):
322 322 if 'is_opening' in kwargs:
323 323 is_opening = kwargs['is_opening']
324 324 else:
325 325 is_opening = self.is_opening()
326 326
327 327 if 'thread' in kwargs:
328 328 thread = kwargs['thread']
329 329 else:
330 330 thread = self.get_thread()
331 331
332 332 if 'can_bump' in kwargs:
333 333 can_bump = kwargs['can_bump']
334 334 else:
335 335 can_bump = thread.can_bump()
336 336
337 337 if is_opening:
338 338 opening_post_id = self.id
339 339 else:
340 340 opening_post_id = thread.get_opening_post_id()
341 341
342 342 return render_to_string('boards/post.html', {
343 343 'post': self,
344 344 'moderator': moderator,
345 345 'is_opening': is_opening,
346 346 'thread': thread,
347 347 'bumpable': can_bump,
348 348 'need_open_link': need_open_link,
349 349 'truncated': truncated,
350 350 'opening_post_id': opening_post_id,
351 351 })
352 352
353 353 def get_first_image(self):
354 354 return self.images.earliest('id')
355 355
356 356 def delete(self, using=None):
357 357 """
358 358 Deletes all post images and the post itself. If the post is opening,
359 359 thread with all posts is deleted.
360 360 """
361 361
362 362 self.images.all().delete()
363 363
364 364 if self.is_opening():
365 365 self.get_thread().delete()
366 366 else:
367 367 thread = self.get_thread()
368 368 thread.last_edit_time = timezone.now()
369 369 thread.save()
370 370
371 super(Post, self).delete(using)
371 super(Post, self).delete(using)
372 372
373 373 logging.getLogger('boards.post.delete').info(
374 374 'Deleted post P#{}/{}'.format(self.id, self.get_title()))
375 375
376 376 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
377 377 include_last_update=False):
378 378 """
379 379 Gets post HTML or JSON data that can be rendered on a page or used by
380 380 API.
381 381 """
382 382
383 383 if format_type == DIFF_TYPE_HTML:
384 384 context = RequestContext(request)
385 385 context['post'] = self
386 386 if PARAMETER_TRUNCATED in request.GET:
387 387 context[PARAMETER_TRUNCATED] = True
388 388
389 389 # TODO Use dict here
390 390 return render_to_string('boards/api_post.html',
391 391 context_instance=context)
392 392 elif format_type == DIFF_TYPE_JSON:
393 393 post_json = {
394 394 'id': self.id,
395 395 'title': self.title,
396 396 'text': self.text.rendered,
397 397 }
398 398 if self.images.exists():
399 399 post_image = self.get_first_image()
400 400 post_json['image'] = post_image.image.url
401 401 post_json['image_preview'] = post_image.image.url_200x150
402 402 if include_last_update:
403 403 post_json['bump_time'] = datetime_to_epoch(
404 404 self.thread_new.bump_time)
405 405 return post_json
406 406
407 407 def send_to_websocket(self, request, recursive=True):
408 408 """
409 409 Sends post HTML data to the thread web socket.
410 410 """
411 411
412 412 if not settings.WEBSOCKETS_ENABLED:
413 413 return
414 414
415 415 client = Client()
416 416
417 417 channel_name = WS_CHANNEL_THREAD + str(self.get_thread().get_opening_post_id())
418 418 client.publish(channel_name, {
419 419 'html': self.get_post_data(
420 420 format_type=DIFF_TYPE_HTML,
421 421 request=request),
422 422 'diff_type': 'added' if recursive else 'updated',
423 423 })
424 424 client.send()
425 425
426 426 logger = logging.getLogger('boards.post.websocket')
427 427
428 428 logger.info('Sent post #{} to channel {}'.format(self.id, channel_name))
429 429
430 430 if recursive:
431 431 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
432 432 post_id = reply_number.group(1)
433 433 ref_post = Post.objects.filter(id=post_id)[0]
434 434
435 435 ref_post.send_to_websocket(request, recursive=False)
436 436
437 437 def save(self, force_insert=False, force_update=False, using=None,
438 438 update_fields=None):
439 439 self._text_rendered = bbcode_extended(self.get_raw_text())
440 440
441 441 super().save(force_insert, force_update, using, update_fields)
442 442
443 443 def get_text(self):
444 444 return self._text_rendered
445 445
446 446 def get_raw_text(self):
447 447 return self.text
@@ -1,112 +1,115 b''
1 1 from django.core.paginator import Paginator
2 2 from django.test import TestCase
3 3 from boards import settings
4 4 from boards.models import Tag, Post, Thread
5 5
6 6
7 7 class PostTests(TestCase):
8 8
9 9 def _create_post(self):
10 10 tag = Tag.objects.create(name='test_tag')
11 11 return Post.objects.create_post(title='title', text='text',
12 12 tags=[tag])
13 13
14 14 def test_post_add(self):
15 15 """Test adding post"""
16 16
17 17 post = self._create_post()
18 18
19 19 self.assertIsNotNone(post, 'No post was created.')
20 20 self.assertEqual('test_tag', post.get_thread().tags.all()[0].name,
21 21 'No tags were added to the post.')
22 22
23 23 def test_delete_post(self):
24 24 """Test post deletion"""
25 25
26 26 post = self._create_post()
27 27 post_id = post.id
28 28
29 29 post.delete()
30 30
31 31 self.assertFalse(Post.objects.filter(id=post_id).exists())
32 32
33 33 def test_delete_thread(self):
34 34 """Test thread deletion"""
35 35
36 36 opening_post = self._create_post()
37 37 thread = opening_post.get_thread()
38 38 reply = Post.objects.create_post("", "", thread=thread)
39 39
40 thread.delete()
40 opening_post.delete()
41 41
42 self.assertFalse(Post.objects.filter(id=reply.id).exists())
42 self.assertFalse(Post.objects.filter(id=reply.id).exists(),
43 'Reply was not deleted with the thread.')
44 self.assertFalse(Post.objects.filter(id=opening_post.id).exists(),
45 'Opening post was not deleted with the thread.')
43 46
44 47 def test_post_to_thread(self):
45 48 """Test adding post to a thread"""
46 49
47 50 op = self._create_post()
48 51 post = Post.objects.create_post("", "", thread=op.get_thread())
49 52
50 53 self.assertIsNotNone(post, 'Reply to thread wasn\'t created')
51 54 self.assertEqual(op.get_thread().last_edit_time, post.pub_time,
52 55 'Post\'s create time doesn\'t match thread last edit'
53 56 ' time')
54 57
55 58 def test_delete_posts_by_ip(self):
56 59 """Test deleting posts with the given ip"""
57 60
58 61 post = self._create_post()
59 62 post_id = post.id
60 63
61 64 Post.objects.delete_posts_by_ip('0.0.0.0')
62 65
63 66 self.assertFalse(Post.objects.filter(id=post_id).exists())
64 67
65 68 def test_get_thread(self):
66 69 """Test getting all posts of a thread"""
67 70
68 71 opening_post = self._create_post()
69 72
70 73 for i in range(2):
71 74 Post.objects.create_post('title', 'text',
72 75 thread=opening_post.get_thread())
73 76
74 77 thread = opening_post.get_thread()
75 78
76 79 self.assertEqual(3, thread.replies.count())
77 80
78 81 def test_create_post_with_tag(self):
79 82 """Test adding tag to post"""
80 83
81 84 tag = Tag.objects.create(name='test_tag')
82 85 post = Post.objects.create_post(title='title', text='text', tags=[tag])
83 86
84 87 thread = post.get_thread()
85 88 self.assertIsNotNone(post, 'Post not created')
86 89 self.assertTrue(tag in thread.tags.all(), 'Tag not added to thread')
87 90 self.assertTrue(thread in tag.threads.all(), 'Thread not added to tag')
88 91
89 92 def test_thread_max_count(self):
90 93 """Test deletion of old posts when the max thread count is reached"""
91 94
92 95 for i in range(settings.MAX_THREAD_COUNT + 1):
93 96 self._create_post()
94 97
95 98 self.assertEqual(settings.MAX_THREAD_COUNT,
96 99 len(Thread.objects.filter(archived=False)))
97 100
98 101 def test_pages(self):
99 102 """Test that the thread list is properly split into pages"""
100 103
101 104 for i in range(settings.MAX_THREAD_COUNT):
102 105 self._create_post()
103 106
104 107 all_threads = Thread.objects.filter(archived=False)
105 108
106 109 paginator = Paginator(Thread.objects.filter(archived=False),
107 110 settings.THREADS_PER_PAGE)
108 111 posts_in_second_page = paginator.page(2).object_list
109 112 first_post = posts_in_second_page[0]
110 113
111 114 self.assertEqual(all_threads[settings.THREADS_PER_PAGE].id,
112 115 first_post.id) No newline at end of file
General Comments 0
You need to be logged in to leave comments. Login now