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