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