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