##// END OF EJS Templates
Save "opening post" flag with the post itself and don't count it every time. Speed up getting posts with attachments and images
neko259 -
r1337:4c8c3ec5 default
parent child Browse files
Show More
@@ -0,0 +1,20 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0025_auto_20150825_2049'),
11 ]
12
13 operations = [
14 migrations.AddField(
15 model_name='post',
16 name='opening',
17 field=models.BooleanField(default=False),
18 preserve_default=False,
19 ),
20 ]
@@ -0,0 +1,22 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import migrations
5
6
7 class Migration(migrations.Migration):
8
9 def build_opening_flag(apps, schema_editor):
10 Post = apps.get_model('boards', 'Post')
11 for post in Post.objects.all():
12 op = Post.objects.filter(threads__in=[post.thread]).order_by('pub_time').first()
13 post.opening = op.id == post.id
14 post.save(update_fields=['opening'])
15
16 dependencies = [
17 ('boards', '0026_post_opening'),
18 ]
19
20 operations = [
21 migrations.RunPython(build_opening_flag),
22 ]
@@ -1,455 +1,450 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 import uuid
5 import uuid
6
6
7 from django.core.exceptions import ObjectDoesNotExist
7 from django.core.exceptions import ObjectDoesNotExist
8 from django.core.urlresolvers import reverse
8 from django.core.urlresolvers import reverse
9 from django.db import models, transaction
9 from django.db import models, transaction
10 from django.db.models import TextField, QuerySet
10 from django.db.models import TextField, QuerySet
11 from django.template.loader import render_to_string
11 from django.template.loader import render_to_string
12 from django.utils import timezone
12 from django.utils import timezone
13
13
14 from boards import settings
14 from boards import settings
15 from boards.abstracts.tripcode import Tripcode
15 from boards.abstracts.tripcode import Tripcode
16 from boards.mdx_neboard import Parser
16 from boards.mdx_neboard import Parser
17 from boards.models import PostImage, Attachment
17 from boards.models import PostImage, Attachment
18 from boards.models.base import Viewable
18 from boards.models.base import Viewable
19 from boards import utils
19 from boards import utils
20 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
20 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
21 from boards.models.user import Notification, Ban
21 from boards.models.user import Notification, Ban
22 import boards.models.thread
22 import boards.models.thread
23
23 from boards.utils import cached_result
24
24
25 APP_LABEL_BOARDS = 'boards'
25 APP_LABEL_BOARDS = 'boards'
26
26
27 POSTS_PER_DAY_RANGE = 7
27 POSTS_PER_DAY_RANGE = 7
28
28
29 BAN_REASON_AUTO = 'Auto'
29 BAN_REASON_AUTO = 'Auto'
30
30
31 IMAGE_THUMB_SIZE = (200, 150)
31 IMAGE_THUMB_SIZE = (200, 150)
32
32
33 TITLE_MAX_LENGTH = 200
33 TITLE_MAX_LENGTH = 200
34
34
35 # TODO This should be removed
35 # TODO This should be removed
36 NO_IP = '0.0.0.0'
36 NO_IP = '0.0.0.0'
37
37
38 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
38 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
39 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
39 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
40
40
41 PARAMETER_TRUNCATED = 'truncated'
41 PARAMETER_TRUNCATED = 'truncated'
42 PARAMETER_TAG = 'tag'
42 PARAMETER_TAG = 'tag'
43 PARAMETER_OFFSET = 'offset'
43 PARAMETER_OFFSET = 'offset'
44 PARAMETER_DIFF_TYPE = 'type'
44 PARAMETER_DIFF_TYPE = 'type'
45 PARAMETER_CSS_CLASS = 'css_class'
45 PARAMETER_CSS_CLASS = 'css_class'
46 PARAMETER_THREAD = 'thread'
46 PARAMETER_THREAD = 'thread'
47 PARAMETER_IS_OPENING = 'is_opening'
47 PARAMETER_IS_OPENING = 'is_opening'
48 PARAMETER_MODERATOR = 'moderator'
48 PARAMETER_MODERATOR = 'moderator'
49 PARAMETER_POST = 'post'
49 PARAMETER_POST = 'post'
50 PARAMETER_OP_ID = 'opening_post_id'
50 PARAMETER_OP_ID = 'opening_post_id'
51 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
51 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
52 PARAMETER_REPLY_LINK = 'reply_link'
52 PARAMETER_REPLY_LINK = 'reply_link'
53 PARAMETER_NEED_OP_DATA = 'need_op_data'
53 PARAMETER_NEED_OP_DATA = 'need_op_data'
54
54
55 POST_VIEW_PARAMS = (
55 POST_VIEW_PARAMS = (
56 'need_op_data',
56 'need_op_data',
57 'reply_link',
57 'reply_link',
58 'moderator',
58 'moderator',
59 'need_open_link',
59 'need_open_link',
60 'truncated',
60 'truncated',
61 'mode_tree',
61 'mode_tree',
62 )
62 )
63
63
64 IMAGE_TYPES = (
64 IMAGE_TYPES = (
65 'jpeg',
65 'jpeg',
66 'jpg',
66 'jpg',
67 'png',
67 'png',
68 'bmp',
68 'bmp',
69 'gif',
69 'gif',
70 )
70 )
71
71
72
72
73 class PostManager(models.Manager):
73 class PostManager(models.Manager):
74 @transaction.atomic
74 @transaction.atomic
75 def create_post(self, title: str, text: str, file=None, thread=None,
75 def create_post(self, title: str, text: str, file=None, thread=None,
76 ip=NO_IP, tags: list=None, opening_posts: list=None, tripcode=None):
76 ip=NO_IP, tags: list=None, opening_posts: list=None, tripcode=None):
77 """
77 """
78 Creates new post
78 Creates new post
79 """
79 """
80
80
81 is_banned = Ban.objects.filter(ip=ip).exists()
81 is_banned = Ban.objects.filter(ip=ip).exists()
82
82
83 # TODO Raise specific exception and catch it in the views
83 # TODO Raise specific exception and catch it in the views
84 if is_banned:
84 if is_banned:
85 raise Exception("This user is banned")
85 raise Exception("This user is banned")
86
86
87 if not tags:
87 if not tags:
88 tags = []
88 tags = []
89 if not opening_posts:
89 if not opening_posts:
90 opening_posts = []
90 opening_posts = []
91
91
92 posting_time = timezone.now()
92 posting_time = timezone.now()
93 new_thread = False
93 new_thread = False
94 if not thread:
94 if not thread:
95 thread = boards.models.thread.Thread.objects.create(
95 thread = boards.models.thread.Thread.objects.create(
96 bump_time=posting_time, last_edit_time=posting_time)
96 bump_time=posting_time, last_edit_time=posting_time)
97 list(map(thread.tags.add, tags))
97 list(map(thread.tags.add, tags))
98 boards.models.thread.Thread.objects.process_oldest_threads()
98 boards.models.thread.Thread.objects.process_oldest_threads()
99 new_thread = True
99 new_thread = True
100
100
101 pre_text = Parser().preparse(text)
101 pre_text = Parser().preparse(text)
102
102
103 post = self.create(title=title,
103 post = self.create(title=title,
104 text=pre_text,
104 text=pre_text,
105 pub_time=posting_time,
105 pub_time=posting_time,
106 poster_ip=ip,
106 poster_ip=ip,
107 thread=thread,
107 thread=thread,
108 last_edit_time=posting_time,
108 last_edit_time=posting_time,
109 tripcode=tripcode)
109 tripcode=tripcode,
110 opening=new_thread)
110 post.threads.add(thread)
111 post.threads.add(thread)
111
112
112 logger = logging.getLogger('boards.post.create')
113 logger = logging.getLogger('boards.post.create')
113
114
114 logger.info('Created post {} by {}'.format(post, post.poster_ip))
115 logger.info('Created post {} by {}'.format(post, post.poster_ip))
115
116
116 # TODO Move this to other place
117 # TODO Move this to other place
117 if file:
118 if file:
118 file_type = file.name.split('.')[-1].lower()
119 file_type = file.name.split('.')[-1].lower()
119 if file_type in IMAGE_TYPES:
120 if file_type in IMAGE_TYPES:
120 post.images.add(PostImage.objects.create_with_hash(file))
121 post.images.add(PostImage.objects.create_with_hash(file))
121 else:
122 else:
122 post.attachments.add(Attachment.objects.create_with_hash(file))
123 post.attachments.add(Attachment.objects.create_with_hash(file))
123
124
124 post.build_url()
125 post.build_url()
125 post.connect_replies()
126 post.connect_replies()
126 post.connect_threads(opening_posts)
127 post.connect_threads(opening_posts)
127 post.connect_notifications()
128 post.connect_notifications()
128
129
129 # Thread needs to be bumped only when the post is already created
130 # Thread needs to be bumped only when the post is already created
130 if not new_thread:
131 if not new_thread:
131 thread.last_edit_time = posting_time
132 thread.last_edit_time = posting_time
132 thread.bump()
133 thread.bump()
133 thread.save()
134 thread.save()
134
135
135 return post
136 return post
136
137
137 def delete_posts_by_ip(self, ip):
138 def delete_posts_by_ip(self, ip):
138 """
139 """
139 Deletes all posts of the author with same IP
140 Deletes all posts of the author with same IP
140 """
141 """
141
142
142 posts = self.filter(poster_ip=ip)
143 posts = self.filter(poster_ip=ip)
143 for post in posts:
144 for post in posts:
144 post.delete()
145 post.delete()
145
146
146 @utils.cached_result()
147 @utils.cached_result()
147 def get_posts_per_day(self) -> float:
148 def get_posts_per_day(self) -> float:
148 """
149 """
149 Gets average count of posts per day for the last 7 days
150 Gets average count of posts per day for the last 7 days
150 """
151 """
151
152
152 day_end = date.today()
153 day_end = date.today()
153 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
154 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
154
155
155 day_time_start = timezone.make_aware(datetime.combine(
156 day_time_start = timezone.make_aware(datetime.combine(
156 day_start, dtime()), timezone.get_current_timezone())
157 day_start, dtime()), timezone.get_current_timezone())
157 day_time_end = timezone.make_aware(datetime.combine(
158 day_time_end = timezone.make_aware(datetime.combine(
158 day_end, dtime()), timezone.get_current_timezone())
159 day_end, dtime()), timezone.get_current_timezone())
159
160
160 posts_per_period = float(self.filter(
161 posts_per_period = float(self.filter(
161 pub_time__lte=day_time_end,
162 pub_time__lte=day_time_end,
162 pub_time__gte=day_time_start).count())
163 pub_time__gte=day_time_start).count())
163
164
164 ppd = posts_per_period / POSTS_PER_DAY_RANGE
165 ppd = posts_per_period / POSTS_PER_DAY_RANGE
165
166
166 return ppd
167 return ppd
167
168
168
169
169 class Post(models.Model, Viewable):
170 class Post(models.Model, Viewable):
170 """A post is a message."""
171 """A post is a message."""
171
172
172 objects = PostManager()
173 objects = PostManager()
173
174
174 class Meta:
175 class Meta:
175 app_label = APP_LABEL_BOARDS
176 app_label = APP_LABEL_BOARDS
176 ordering = ('id',)
177 ordering = ('id',)
177
178
178 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
179 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
179 pub_time = models.DateTimeField()
180 pub_time = models.DateTimeField()
180 text = TextField(blank=True, null=True)
181 text = TextField(blank=True, null=True)
181 _text_rendered = TextField(blank=True, null=True, editable=False)
182 _text_rendered = TextField(blank=True, null=True, editable=False)
182
183
183 images = models.ManyToManyField(PostImage, null=True, blank=True,
184 images = models.ManyToManyField(PostImage, null=True, blank=True,
184 related_name='post_images', db_index=True)
185 related_name='post_images', db_index=True)
185 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
186 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
186 related_name='attachment_posts')
187 related_name='attachment_posts')
187
188
188 poster_ip = models.GenericIPAddressField()
189 poster_ip = models.GenericIPAddressField()
189
190
190 # TODO This field can be removed cause UID is used for update now
191 # TODO This field can be removed cause UID is used for update now
191 last_edit_time = models.DateTimeField()
192 last_edit_time = models.DateTimeField()
192
193
193 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
194 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
194 null=True,
195 null=True,
195 blank=True, related_name='refposts',
196 blank=True, related_name='refposts',
196 db_index=True)
197 db_index=True)
197 refmap = models.TextField(null=True, blank=True)
198 refmap = models.TextField(null=True, blank=True)
198 threads = models.ManyToManyField('Thread', db_index=True)
199 threads = models.ManyToManyField('Thread', db_index=True)
199 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
200 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
200
201
201 url = models.TextField()
202 url = models.TextField()
202 uid = models.TextField(db_index=True)
203 uid = models.TextField(db_index=True)
203
204
204 tripcode = models.CharField(max_length=50, null=True)
205 tripcode = models.CharField(max_length=50, null=True)
206 opening = models.BooleanField()
205
207
206 def __str__(self):
208 def __str__(self):
207 return 'P#{}/{}'.format(self.id, self.title)
209 return 'P#{}/{}'.format(self.id, self.title)
208
210
209 def get_referenced_posts(self):
211 def get_referenced_posts(self):
210 threads = self.get_threads().all()
212 threads = self.get_threads().all()
211 return self.referenced_posts.filter(threads__in=threads)\
213 return self.referenced_posts.filter(threads__in=threads)\
212 .order_by('pub_time').distinct().all()
214 .order_by('pub_time').distinct().all()
213
215
214 def get_title(self) -> str:
216 def get_title(self) -> str:
215 """
217 """
216 Gets original post title or part of its text.
218 Gets original post title or part of its text.
217 """
219 """
218
220
219 title = self.title
221 title = self.title
220 if not title:
222 if not title:
221 title = self.get_text()
223 title = self.get_text()
222
224
223 return title
225 return title
224
226
225 def build_refmap(self) -> None:
227 def build_refmap(self) -> None:
226 """
228 """
227 Builds a replies map string from replies list. This is a cache to stop
229 Builds a replies map string from replies list. This is a cache to stop
228 the server from recalculating the map on every post show.
230 the server from recalculating the map on every post show.
229 """
231 """
230
232
231 post_urls = [refpost.get_link_view()
233 post_urls = [refpost.get_link_view()
232 for refpost in self.referenced_posts.all()]
234 for refpost in self.referenced_posts.all()]
233
235
234 self.refmap = ', '.join(post_urls)
236 self.refmap = ', '.join(post_urls)
235
237
236 def is_referenced(self) -> bool:
238 def is_referenced(self) -> bool:
237 return self.refmap and len(self.refmap) > 0
239 return self.refmap and len(self.refmap) > 0
238
240
239 def is_opening(self) -> bool:
241 def is_opening(self) -> bool:
240 """
242 """
241 Checks if this is an opening post or just a reply.
243 Checks if this is an opening post or just a reply.
242 """
244 """
243
245
244 return self.get_thread().get_opening_post_id() == self.id
246 return self.opening
245
247
246 def get_absolute_url(self):
248 def get_absolute_url(self):
247 if self.url:
249 if self.url:
248 return self.url
250 return self.url
249 else:
251 else:
250 opening_id = self.get_thread().get_opening_post_id()
252 opening_id = self.get_thread().get_opening_post_id()
251 post_url = reverse('thread', kwargs={'post_id': opening_id})
253 post_url = reverse('thread', kwargs={'post_id': opening_id})
252 if self.id != opening_id:
254 if self.id != opening_id:
253 post_url += '#' + str(self.id)
255 post_url += '#' + str(self.id)
254 return post_url
256 return post_url
255
257
256 def get_thread(self):
258 def get_thread(self):
257 return self.thread
259 return self.thread
258
260
259 def get_threads(self) -> QuerySet:
261 def get_threads(self) -> QuerySet:
260 """
262 """
261 Gets post's thread.
263 Gets post's thread.
262 """
264 """
263
265
264 return self.threads
266 return self.threads
265
267
266 def get_view(self, *args, **kwargs) -> str:
268 def get_view(self, *args, **kwargs) -> str:
267 """
269 """
268 Renders post's HTML view. Some of the post params can be passed over
270 Renders post's HTML view. Some of the post params can be passed over
269 kwargs for the means of caching (if we view the thread, some params
271 kwargs for the means of caching (if we view the thread, some params
270 are same for every post and don't need to be computed over and over.
272 are same for every post and don't need to be computed over and over.
271 """
273 """
272
274
273 thread = self.get_thread()
275 thread = self.get_thread()
274 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
275
276 if is_opening:
277 opening_post_id = self.id
278 else:
279 opening_post_id = thread.get_opening_post_id()
280
276
281 css_class = 'post'
277 css_class = 'post'
282 if thread.archived:
278 if thread.archived:
283 css_class += ' archive_post'
279 css_class += ' archive_post'
284 elif not thread.can_bump():
280 elif not thread.can_bump():
285 css_class += ' dead_post'
281 css_class += ' dead_post'
286
282
287 params = dict()
283 params = dict()
288 for param in POST_VIEW_PARAMS:
284 for param in POST_VIEW_PARAMS:
289 if param in kwargs:
285 if param in kwargs:
290 params[param] = kwargs[param]
286 params[param] = kwargs[param]
291
287
292 params.update({
288 params.update({
293 PARAMETER_POST: self,
289 PARAMETER_POST: self,
294 PARAMETER_IS_OPENING: is_opening,
290 PARAMETER_IS_OPENING: self.is_opening(),
295 PARAMETER_THREAD: thread,
291 PARAMETER_THREAD: thread,
296 PARAMETER_CSS_CLASS: css_class,
292 PARAMETER_CSS_CLASS: css_class,
297 PARAMETER_OP_ID: opening_post_id,
298 })
293 })
299
294
300 return render_to_string('boards/post.html', params)
295 return render_to_string('boards/post.html', params)
301
296
302 def get_search_view(self, *args, **kwargs):
297 def get_search_view(self, *args, **kwargs):
303 return self.get_view(need_op_data=True, *args, **kwargs)
298 return self.get_view(need_op_data=True, *args, **kwargs)
304
299
305 def get_first_image(self) -> PostImage:
300 def get_first_image(self) -> PostImage:
306 return self.images.earliest('id')
301 return self.images.earliest('id')
307
302
308 def delete(self, using=None):
303 def delete(self, using=None):
309 """
304 """
310 Deletes all post images and the post itself.
305 Deletes all post images and the post itself.
311 """
306 """
312
307
313 for image in self.images.all():
308 for image in self.images.all():
314 image_refs_count = image.post_images.count()
309 image_refs_count = image.post_images.count()
315 if image_refs_count == 1:
310 if image_refs_count == 1:
316 image.delete()
311 image.delete()
317
312
318 for attachment in self.attachments.all():
313 for attachment in self.attachments.all():
319 attachment_refs_count = attachment.attachment_posts.count()
314 attachment_refs_count = attachment.attachment_posts.count()
320 if attachment_refs_count == 1:
315 if attachment_refs_count == 1:
321 attachment.delete()
316 attachment.delete()
322
317
323 thread = self.get_thread()
318 thread = self.get_thread()
324 thread.last_edit_time = timezone.now()
319 thread.last_edit_time = timezone.now()
325 thread.save()
320 thread.save()
326
321
327 super(Post, self).delete(using)
322 super(Post, self).delete(using)
328
323
329 logging.getLogger('boards.post.delete').info(
324 logging.getLogger('boards.post.delete').info(
330 'Deleted post {}'.format(self))
325 'Deleted post {}'.format(self))
331
326
332 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
327 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
333 include_last_update=False) -> str:
328 include_last_update=False) -> str:
334 """
329 """
335 Gets post HTML or JSON data that can be rendered on a page or used by
330 Gets post HTML or JSON data that can be rendered on a page or used by
336 API.
331 API.
337 """
332 """
338
333
339 return get_exporter(format_type).export(self, request,
334 return get_exporter(format_type).export(self, request,
340 include_last_update)
335 include_last_update)
341
336
342 def notify_clients(self, recursive=True):
337 def notify_clients(self, recursive=True):
343 """
338 """
344 Sends post HTML data to the thread web socket.
339 Sends post HTML data to the thread web socket.
345 """
340 """
346
341
347 if not settings.get_bool('External', 'WebsocketsEnabled'):
342 if not settings.get_bool('External', 'WebsocketsEnabled'):
348 return
343 return
349
344
350 thread_ids = list()
345 thread_ids = list()
351 for thread in self.get_threads().all():
346 for thread in self.get_threads().all():
352 thread_ids.append(thread.id)
347 thread_ids.append(thread.id)
353
348
354 thread.notify_clients()
349 thread.notify_clients()
355
350
356 if recursive:
351 if recursive:
357 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
352 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
358 post_id = reply_number.group(1)
353 post_id = reply_number.group(1)
359
354
360 try:
355 try:
361 ref_post = Post.objects.get(id=post_id)
356 ref_post = Post.objects.get(id=post_id)
362
357
363 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
358 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
364 # If post is in this thread, its thread was already notified.
359 # If post is in this thread, its thread was already notified.
365 # Otherwise, notify its thread separately.
360 # Otherwise, notify its thread separately.
366 ref_post.notify_clients(recursive=False)
361 ref_post.notify_clients(recursive=False)
367 except ObjectDoesNotExist:
362 except ObjectDoesNotExist:
368 pass
363 pass
369
364
370 def build_url(self):
365 def build_url(self):
371 self.url = self.get_absolute_url()
366 self.url = self.get_absolute_url()
372 self.save(update_fields=['url'])
367 self.save(update_fields=['url'])
373
368
374 def save(self, force_insert=False, force_update=False, using=None,
369 def save(self, force_insert=False, force_update=False, using=None,
375 update_fields=None):
370 update_fields=None):
376 self._text_rendered = Parser().parse(self.get_raw_text())
371 self._text_rendered = Parser().parse(self.get_raw_text())
377
372
378 self.uid = str(uuid.uuid4())
373 self.uid = str(uuid.uuid4())
379 if update_fields is not None and 'uid' not in update_fields:
374 if update_fields is not None and 'uid' not in update_fields:
380 update_fields += ['uid']
375 update_fields += ['uid']
381
376
382 if self.id:
377 if self.id:
383 for thread in self.get_threads().all():
378 for thread in self.get_threads().all():
384 thread.last_edit_time = self.last_edit_time
379 thread.last_edit_time = self.last_edit_time
385
380
386 thread.save(update_fields=['last_edit_time', 'bumpable'])
381 thread.save(update_fields=['last_edit_time', 'bumpable'])
387
382
388 super().save(force_insert, force_update, using, update_fields)
383 super().save(force_insert, force_update, using, update_fields)
389
384
390 def get_text(self) -> str:
385 def get_text(self) -> str:
391 return self._text_rendered
386 return self._text_rendered
392
387
393 def get_raw_text(self) -> str:
388 def get_raw_text(self) -> str:
394 return self.text
389 return self.text
395
390
396 def get_absolute_id(self) -> str:
391 def get_absolute_id(self) -> str:
397 """
392 """
398 If the post has many threads, shows its main thread OP id in the post
393 If the post has many threads, shows its main thread OP id in the post
399 ID.
394 ID.
400 """
395 """
401
396
402 if self.get_threads().count() > 1:
397 if self.get_threads().count() > 1:
403 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
398 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
404 else:
399 else:
405 return str(self.id)
400 return str(self.id)
406
401
407 def connect_notifications(self):
402 def connect_notifications(self):
408 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
403 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
409 user_name = reply_number.group(1).lower()
404 user_name = reply_number.group(1).lower()
410 Notification.objects.get_or_create(name=user_name, post=self)
405 Notification.objects.get_or_create(name=user_name, post=self)
411
406
412 def connect_replies(self):
407 def connect_replies(self):
413 """
408 """
414 Connects replies to a post to show them as a reflink map
409 Connects replies to a post to show them as a reflink map
415 """
410 """
416
411
417 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
412 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
418 post_id = reply_number.group(1)
413 post_id = reply_number.group(1)
419
414
420 try:
415 try:
421 referenced_post = Post.objects.get(id=post_id)
416 referenced_post = Post.objects.get(id=post_id)
422
417
423 referenced_post.referenced_posts.add(self)
418 referenced_post.referenced_posts.add(self)
424 referenced_post.last_edit_time = self.pub_time
419 referenced_post.last_edit_time = self.pub_time
425 referenced_post.build_refmap()
420 referenced_post.build_refmap()
426 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
421 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
427 except ObjectDoesNotExist:
422 except ObjectDoesNotExist:
428 pass
423 pass
429
424
430 def connect_threads(self, opening_posts):
425 def connect_threads(self, opening_posts):
431 for opening_post in opening_posts:
426 for opening_post in opening_posts:
432 threads = opening_post.get_threads().all()
427 threads = opening_post.get_threads().all()
433 for thread in threads:
428 for thread in threads:
434 if thread.can_bump():
429 if thread.can_bump():
435 thread.update_bump_status()
430 thread.update_bump_status()
436
431
437 thread.last_edit_time = self.last_edit_time
432 thread.last_edit_time = self.last_edit_time
438 thread.save(update_fields=['last_edit_time', 'bumpable'])
433 thread.save(update_fields=['last_edit_time', 'bumpable'])
439 self.threads.add(opening_post.get_thread())
434 self.threads.add(opening_post.get_thread())
440
435
441 def get_tripcode(self):
436 def get_tripcode(self):
442 if self.tripcode:
437 if self.tripcode:
443 return Tripcode(self.tripcode)
438 return Tripcode(self.tripcode)
444
439
445 def get_link_view(self):
440 def get_link_view(self):
446 """
441 """
447 Gets view of a reflink to the post.
442 Gets view of a reflink to the post.
448 """
443 """
449
444
450 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
445 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
451 self.id)
446 self.id)
452 if self.is_opening():
447 if self.is_opening():
453 result = '<b>{}</b>'.format(result)
448 result = '<b>{}</b>'.format(result)
454
449
455 return result
450 return result
@@ -1,234 +1,235 b''
1 import logging
1 import logging
2 from adjacent import Client
2 from adjacent import Client
3
3
4 from django.db.models import Count, Sum, QuerySet
4 from django.db.models import Count, Sum, QuerySet
5 from django.utils import timezone
5 from django.utils import timezone
6 from django.db import models
6 from django.db import models
7
7
8 from boards import settings
8 from boards import settings
9 import boards
9 import boards
10 from boards.utils import cached_result, datetime_to_epoch
10 from boards.utils import cached_result, datetime_to_epoch
11 from boards.models.post import Post
11 from boards.models.post import Post
12 from boards.models.tag import Tag
12 from boards.models.tag import Tag
13
13
14
14
15 __author__ = 'neko259'
15 __author__ = 'neko259'
16
16
17
17
18 logger = logging.getLogger(__name__)
18 logger = logging.getLogger(__name__)
19
19
20
20
21 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
21 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
22 WS_NOTIFICATION_TYPE = 'notification_type'
22 WS_NOTIFICATION_TYPE = 'notification_type'
23
23
24 WS_CHANNEL_THREAD = "thread:"
24 WS_CHANNEL_THREAD = "thread:"
25
25
26
26
27 class ThreadManager(models.Manager):
27 class ThreadManager(models.Manager):
28 def process_oldest_threads(self):
28 def process_oldest_threads(self):
29 """
29 """
30 Preserves maximum thread count. If there are too many threads,
30 Preserves maximum thread count. If there are too many threads,
31 archive or delete the old ones.
31 archive or delete the old ones.
32 """
32 """
33
33
34 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
34 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
35 thread_count = threads.count()
35 thread_count = threads.count()
36
36
37 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
37 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
38 if thread_count > max_thread_count:
38 if thread_count > max_thread_count:
39 num_threads_to_delete = thread_count - max_thread_count
39 num_threads_to_delete = thread_count - max_thread_count
40 old_threads = threads[thread_count - num_threads_to_delete:]
40 old_threads = threads[thread_count - num_threads_to_delete:]
41
41
42 for thread in old_threads:
42 for thread in old_threads:
43 if settings.get_bool('Storage', 'ArchiveThreads'):
43 if settings.get_bool('Storage', 'ArchiveThreads'):
44 self._archive_thread(thread)
44 self._archive_thread(thread)
45 else:
45 else:
46 thread.delete()
46 thread.delete()
47
47
48 logger.info('Processed %d old threads' % num_threads_to_delete)
48 logger.info('Processed %d old threads' % num_threads_to_delete)
49
49
50 def _archive_thread(self, thread):
50 def _archive_thread(self, thread):
51 thread.archived = True
51 thread.archived = True
52 thread.bumpable = False
52 thread.bumpable = False
53 thread.last_edit_time = timezone.now()
53 thread.last_edit_time = timezone.now()
54 thread.update_posts_time()
54 thread.update_posts_time()
55 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
55 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
56
56
57
57
58 def get_thread_max_posts():
58 def get_thread_max_posts():
59 return settings.get_int('Messages', 'MaxPostsPerThread')
59 return settings.get_int('Messages', 'MaxPostsPerThread')
60
60
61
61
62 class Thread(models.Model):
62 class Thread(models.Model):
63 objects = ThreadManager()
63 objects = ThreadManager()
64
64
65 class Meta:
65 class Meta:
66 app_label = 'boards'
66 app_label = 'boards'
67
67
68 tags = models.ManyToManyField('Tag', related_name='thread_tags')
68 tags = models.ManyToManyField('Tag', related_name='thread_tags')
69 bump_time = models.DateTimeField(db_index=True)
69 bump_time = models.DateTimeField(db_index=True)
70 last_edit_time = models.DateTimeField()
70 last_edit_time = models.DateTimeField()
71 archived = models.BooleanField(default=False)
71 archived = models.BooleanField(default=False)
72 bumpable = models.BooleanField(default=True)
72 bumpable = models.BooleanField(default=True)
73 max_posts = models.IntegerField(default=get_thread_max_posts)
73 max_posts = models.IntegerField(default=get_thread_max_posts)
74
74
75 def get_tags(self) -> QuerySet:
75 def get_tags(self) -> QuerySet:
76 """
76 """
77 Gets a sorted tag list.
77 Gets a sorted tag list.
78 """
78 """
79
79
80 return self.tags.order_by('name')
80 return self.tags.order_by('name')
81
81
82 def bump(self):
82 def bump(self):
83 """
83 """
84 Bumps (moves to up) thread if possible.
84 Bumps (moves to up) thread if possible.
85 """
85 """
86
86
87 if self.can_bump():
87 if self.can_bump():
88 self.bump_time = self.last_edit_time
88 self.bump_time = self.last_edit_time
89
89
90 self.update_bump_status()
90 self.update_bump_status()
91
91
92 logger.info('Bumped thread %d' % self.id)
92 logger.info('Bumped thread %d' % self.id)
93
93
94 def has_post_limit(self) -> bool:
94 def has_post_limit(self) -> bool:
95 return self.max_posts > 0
95 return self.max_posts > 0
96
96
97 def update_bump_status(self, exclude_posts=None):
97 def update_bump_status(self, exclude_posts=None):
98 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
98 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
99 self.bumpable = False
99 self.bumpable = False
100 self.update_posts_time(exclude_posts=exclude_posts)
100 self.update_posts_time(exclude_posts=exclude_posts)
101
101
102 def _get_cache_key(self):
102 def _get_cache_key(self):
103 return [datetime_to_epoch(self.last_edit_time)]
103 return [datetime_to_epoch(self.last_edit_time)]
104
104
105 @cached_result(key_method=_get_cache_key)
105 @cached_result(key_method=_get_cache_key)
106 def get_reply_count(self) -> int:
106 def get_reply_count(self) -> int:
107 return self.get_replies().count()
107 return self.get_replies().count()
108
108
109 @cached_result(key_method=_get_cache_key)
109 @cached_result(key_method=_get_cache_key)
110 def get_images_count(self) -> int:
110 def get_images_count(self) -> int:
111 return self.get_replies().annotate(images_count=Count(
111 return self.get_replies().annotate(images_count=Count(
112 'images')).aggregate(Sum('images_count'))['images_count__sum']
112 'images')).aggregate(Sum('images_count'))['images_count__sum']
113
113
114 def can_bump(self) -> bool:
114 def can_bump(self) -> bool:
115 """
115 """
116 Checks if the thread can be bumped by replying to it.
116 Checks if the thread can be bumped by replying to it.
117 """
117 """
118
118
119 return self.bumpable and not self.archived
119 return self.bumpable and not self.archived
120
120
121 def get_last_replies(self) -> QuerySet:
121 def get_last_replies(self) -> QuerySet:
122 """
122 """
123 Gets several last replies, not including opening post
123 Gets several last replies, not including opening post
124 """
124 """
125
125
126 last_replies_count = settings.get_int('View', 'LastRepliesCount')
126 last_replies_count = settings.get_int('View', 'LastRepliesCount')
127
127
128 if last_replies_count > 0:
128 if last_replies_count > 0:
129 reply_count = self.get_reply_count()
129 reply_count = self.get_reply_count()
130
130
131 if reply_count > 0:
131 if reply_count > 0:
132 reply_count_to_show = min(last_replies_count,
132 reply_count_to_show = min(last_replies_count,
133 reply_count - 1)
133 reply_count - 1)
134 replies = self.get_replies()
134 replies = self.get_replies()
135 last_replies = replies[reply_count - reply_count_to_show:]
135 last_replies = replies[reply_count - reply_count_to_show:]
136
136
137 return last_replies
137 return last_replies
138
138
139 def get_skipped_replies_count(self) -> int:
139 def get_skipped_replies_count(self) -> int:
140 """
140 """
141 Gets number of posts between opening post and last replies.
141 Gets number of posts between opening post and last replies.
142 """
142 """
143 reply_count = self.get_reply_count()
143 reply_count = self.get_reply_count()
144 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
144 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
145 reply_count - 1)
145 reply_count - 1)
146 return reply_count - last_replies_count - 1
146 return reply_count - last_replies_count - 1
147
147
148 def get_replies(self, view_fields_only=False) -> QuerySet:
148 def get_replies(self, view_fields_only=False) -> QuerySet:
149 """
149 """
150 Gets sorted thread posts
150 Gets sorted thread posts
151 """
151 """
152
152
153 query = Post.objects.filter(threads__in=[self])
153 query = Post.objects.filter(threads__in=[self])
154 query = query.order_by('pub_time').prefetch_related('images', 'thread', 'threads')
154 query = query.order_by('pub_time').prefetch_related(
155 'images', 'thread', 'threads', 'attachments')
155 if view_fields_only:
156 if view_fields_only:
156 query = query.defer('poster_ip')
157 query = query.defer('poster_ip')
157 return query.all()
158 return query.all()
158
159
159 def get_top_level_replies(self) -> QuerySet:
160 def get_top_level_replies(self) -> QuerySet:
160 return self.get_replies().exclude(refposts__threads__in=[self])
161 return self.get_replies().exclude(refposts__threads__in=[self])
161
162
162 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
163 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
163 """
164 """
164 Gets replies that have at least one image attached
165 Gets replies that have at least one image attached
165 """
166 """
166
167
167 return self.get_replies(view_fields_only).annotate(images_count=Count(
168 return self.get_replies(view_fields_only).annotate(images_count=Count(
168 'images')).filter(images_count__gt=0)
169 'images')).filter(images_count__gt=0)
169
170
170 def get_opening_post(self, only_id=False) -> Post:
171 def get_opening_post(self, only_id=False) -> Post:
171 """
172 """
172 Gets the first post of the thread
173 Gets the first post of the thread
173 """
174 """
174
175
175 query = self.get_replies().order_by('pub_time')
176 query = self.get_replies().order_by('pub_time')
176 if only_id:
177 if only_id:
177 query = query.only('id')
178 query = query.only('id')
178 opening_post = query.first()
179 opening_post = query.first()
179
180
180 return opening_post
181 return opening_post
181
182
182 @cached_result()
183 @cached_result()
183 def get_opening_post_id(self) -> int:
184 def get_opening_post_id(self) -> int:
184 """
185 """
185 Gets ID of the first thread post.
186 Gets ID of the first thread post.
186 """
187 """
187
188
188 return self.get_opening_post(only_id=True).id
189 return self.get_opening_post(only_id=True).id
189
190
190 def get_pub_time(self):
191 def get_pub_time(self):
191 """
192 """
192 Gets opening post's pub time because thread does not have its own one.
193 Gets opening post's pub time because thread does not have its own one.
193 """
194 """
194
195
195 return self.get_opening_post().pub_time
196 return self.get_opening_post().pub_time
196
197
197 def __str__(self):
198 def __str__(self):
198 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
199 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
199
200
200 def get_tag_url_list(self) -> list:
201 def get_tag_url_list(self) -> list:
201 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
202 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
202
203
203 def update_posts_time(self, exclude_posts=None):
204 def update_posts_time(self, exclude_posts=None):
204 last_edit_time = self.last_edit_time
205 last_edit_time = self.last_edit_time
205
206
206 for post in self.post_set.all():
207 for post in self.post_set.all():
207 if exclude_posts is None or post not in exclude_posts:
208 if exclude_posts is None or post not in exclude_posts:
208 # Manual update is required because uids are generated on save
209 # Manual update is required because uids are generated on save
209 post.last_edit_time = last_edit_time
210 post.last_edit_time = last_edit_time
210 post.save(update_fields=['last_edit_time'])
211 post.save(update_fields=['last_edit_time'])
211
212
212 post.get_threads().update(last_edit_time=last_edit_time)
213 post.get_threads().update(last_edit_time=last_edit_time)
213
214
214 def notify_clients(self):
215 def notify_clients(self):
215 if not settings.get_bool('External', 'WebsocketsEnabled'):
216 if not settings.get_bool('External', 'WebsocketsEnabled'):
216 return
217 return
217
218
218 client = Client()
219 client = Client()
219
220
220 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
221 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
221 client.publish(channel_name, {
222 client.publish(channel_name, {
222 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
223 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
223 })
224 })
224 client.send()
225 client.send()
225
226
226 def get_absolute_url(self):
227 def get_absolute_url(self):
227 return self.get_opening_post().get_absolute_url()
228 return self.get_opening_post().get_absolute_url()
228
229
229 def get_required_tags(self):
230 def get_required_tags(self):
230 return self.get_tags().filter(required=True)
231 return self.get_tags().filter(required=True)
231
232
232 def get_replies_newer(self, post_id):
233 def get_replies_newer(self, post_id):
233 return self.get_replies().filter(id__gt=post_id)
234 return self.get_replies().filter(id__gt=post_id)
234
235
@@ -1,186 +1,186 b''
1 {% extends "boards/base.html" %}
1 {% extends "boards/base.html" %}
2
2
3 {% load i18n %}
3 {% load i18n %}
4 {% load board %}
4 {% load board %}
5 {% load static %}
5 {% load static %}
6 {% load tz %}
6 {% load tz %}
7
7
8 {% block head %}
8 {% block head %}
9 <meta name="robots" content="noindex">
9 <meta name="robots" content="noindex">
10
10
11 {% if tag %}
11 {% if tag %}
12 <title>{{ tag.name }} - {{ site_name }}</title>
12 <title>{{ tag.name }} - {{ site_name }}</title>
13 {% else %}
13 {% else %}
14 <title>{{ site_name }}</title>
14 <title>{{ site_name }}</title>
15 {% endif %}
15 {% endif %}
16
16
17 {% if prev_page_link %}
17 {% if prev_page_link %}
18 <link rel="prev" href="{{ prev_page_link }}" />
18 <link rel="prev" href="{{ prev_page_link }}" />
19 {% endif %}
19 {% endif %}
20 {% if next_page_link %}
20 {% if next_page_link %}
21 <link rel="next" href="{{ next_page_link }}" />
21 <link rel="next" href="{{ next_page_link }}" />
22 {% endif %}
22 {% endif %}
23
23
24 {% endblock %}
24 {% endblock %}
25
25
26 {% block content %}
26 {% block content %}
27
27
28 {% get_current_language as LANGUAGE_CODE %}
28 {% get_current_language as LANGUAGE_CODE %}
29 {% get_current_timezone as TIME_ZONE %}
29 {% get_current_timezone as TIME_ZONE %}
30
30
31 {% for banner in banners %}
31 {% for banner in banners %}
32 <div class="post">
32 <div class="post">
33 <div class="title">{{ banner.title }}</div>
33 <div class="title">{{ banner.title }}</div>
34 <div>{{ banner.text }}</div>
34 <div>{{ banner.text }}</div>
35 <div>{% trans 'Related message' %}: <a href="{{ banner.post.get_absolute_url }}">>>{{ banner.post.id }}</a></div>
35 <div>{% trans 'Related message' %}: <a href="{{ banner.post.get_absolute_url }}">>>{{ banner.post.id }}</a></div>
36 </div>
36 </div>
37 {% endfor %}
37 {% endfor %}
38
38
39 {% if tag %}
39 {% if tag %}
40 <div class="tag_info">
40 <div class="tag_info">
41 {% if random_image_post %}
41 {% if random_image_post %}
42 <div class="tag-image">
42 <div class="tag-image">
43 {% with image=random_image_post.images.first %}
43 {% with image=random_image_post.images.first %}
44 <a href="{{ random_image_post.get_absolute_url }}"><img
44 <a href="{{ random_image_post.get_absolute_url }}"><img
45 src="{{ image.image.url_200x150 }}"
45 src="{{ image.image.url_200x150 }}"
46 width="{{ image.pre_width }}"
46 width="{{ image.pre_width }}"
47 height="{{ image.pre_height }}"/></a>
47 height="{{ image.pre_height }}"/></a>
48 {% endwith %}
48 {% endwith %}
49 </div>
49 </div>
50 {% endif %}
50 {% endif %}
51 <div class="tag-text-data">
51 <div class="tag-text-data">
52 <h2>
52 <h2>
53 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
53 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
54 {% if is_favorite %}
54 {% if is_favorite %}
55 <button name="method" value="unsubscribe" class="fav">β˜…</button>
55 <button name="method" value="unsubscribe" class="fav">β˜…</button>
56 {% else %}
56 {% else %}
57 <button name="method" value="subscribe" class="not_fav">β˜…</button>
57 <button name="method" value="subscribe" class="not_fav">β˜…</button>
58 {% endif %}
58 {% endif %}
59 </form>
59 </form>
60 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
60 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
61 {% if is_hidden %}
61 {% if is_hidden %}
62 <button name="method" value="unhide" class="fav">H</button>
62 <button name="method" value="unhide" class="fav">H</button>
63 {% else %}
63 {% else %}
64 <button name="method" value="hide" class="not_fav">H</button>
64 <button name="method" value="hide" class="not_fav">H</button>
65 {% endif %}
65 {% endif %}
66 </form>
66 </form>
67 {{ tag.get_view|safe }}
67 {{ tag.get_view|safe }}
68 {% if moderator %}
68 {% if moderator %}
69 <span class="moderator_info">| <a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a></span>
69 <span class="moderator_info">| <a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a></span>
70 {% endif %}
70 {% endif %}
71 </h2>
71 </h2>
72 {% if tag.get_description %}
72 {% if tag.get_description %}
73 <p>{{ tag.get_description|safe }}</p>
73 <p>{{ tag.get_description|safe }}</p>
74 {% endif %}
74 {% endif %}
75 <p>{% blocktrans with active_thread_count=tag.get_active_thread_count thread_count=tag.get_thread_count post_count=tag.get_post_count %}This tag has {{ thread_count }} threads ({{ active_thread_count}} active) and {{ post_count }} posts.{% endblocktrans %}</p>
75 <p>{% blocktrans with active_thread_count=tag.get_active_thread_count thread_count=tag.get_thread_count post_count=tag.get_post_count %}This tag has {{ thread_count }} threads ({{ active_thread_count}} active) and {{ post_count }} posts.{% endblocktrans %}</p>
76 {% if related_tags %}
76 {% if related_tags %}
77 <p>{% trans 'Related tags:' %}
77 <p>{% trans 'Related tags:' %}
78 {% for rel_tag in related_tags %}
78 {% for rel_tag in related_tags %}
79 {{ rel_tag.get_view|safe }}{% if not forloop.last %}, {% else %}.{% endif %}
79 {{ rel_tag.get_view|safe }}{% if not forloop.last %}, {% else %}.{% endif %}
80 {% endfor %}
80 {% endfor %}
81 </p>
81 </p>
82 {% endif %}
82 {% endif %}
83 </div>
83 </div>
84 </div>
84 </div>
85 {% endif %}
85 {% endif %}
86
86
87 {% if threads %}
87 {% if threads %}
88 {% if prev_page_link %}
88 {% if prev_page_link %}
89 <div class="page_link">
89 <div class="page_link">
90 <a href="{{ prev_page_link }}">{% trans "Previous page" %}</a>
90 <a href="{{ prev_page_link }}">{% trans "Previous page" %}</a>
91 </div>
91 </div>
92 {% endif %}
92 {% endif %}
93
93
94 {% for thread in threads %}
94 {% for thread in threads %}
95 <div class="thread">
95 <div class="thread">
96 {% post_view thread.get_opening_post moderator=moderator is_opening=True thread=thread truncated=True need_open_link=True %}
96 {% post_view thread.get_opening_post moderator=moderator thread=thread truncated=True need_open_link=True %}
97 {% if not thread.archived %}
97 {% if not thread.archived %}
98 {% with last_replies=thread.get_last_replies %}
98 {% with last_replies=thread.get_last_replies %}
99 {% if last_replies %}
99 {% if last_replies %}
100 {% with skipped_replies_count=thread.get_skipped_replies_count %}
100 {% with skipped_replies_count=thread.get_skipped_replies_count %}
101 {% if skipped_replies_count %}
101 {% if skipped_replies_count %}
102 <div class="skipped_replies">
102 <div class="skipped_replies">
103 <a href="{% url 'thread' thread.get_opening_post_id %}">
103 <a href="{% url 'thread' thread.get_opening_post_id %}">
104 {% blocktrans count count=skipped_replies_count %}Skipped {{ count }} reply. Open thread to see all replies.{% plural %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
104 {% blocktrans count count=skipped_replies_count %}Skipped {{ count }} reply. Open thread to see all replies.{% plural %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
105 </a>
105 </a>
106 </div>
106 </div>
107 {% endif %}
107 {% endif %}
108 {% endwith %}
108 {% endwith %}
109 <div class="last-replies">
109 <div class="last-replies">
110 {% for post in last_replies %}
110 {% for post in last_replies %}
111 {% post_view post is_opening=False moderator=moderator truncated=True %}
111 {% post_view post moderator=moderator truncated=True %}
112 {% endfor %}
112 {% endfor %}
113 </div>
113 </div>
114 {% endif %}
114 {% endif %}
115 {% endwith %}
115 {% endwith %}
116 {% endif %}
116 {% endif %}
117 </div>
117 </div>
118 {% endfor %}
118 {% endfor %}
119
119
120 {% if next_page_link %}
120 {% if next_page_link %}
121 <div class="page_link">
121 <div class="page_link">
122 <a href="{{ next_page_link }}">{% trans "Next page" %}</a>
122 <a href="{{ next_page_link }}">{% trans "Next page" %}</a>
123 </div>
123 </div>
124 {% endif %}
124 {% endif %}
125 {% else %}
125 {% else %}
126 <div class="post">
126 <div class="post">
127 {% trans 'No threads exist. Create the first one!' %}</div>
127 {% trans 'No threads exist. Create the first one!' %}</div>
128 {% endif %}
128 {% endif %}
129
129
130 <div class="post-form-w">
130 <div class="post-form-w">
131 <script src="{% static 'js/panel.js' %}"></script>
131 <script src="{% static 'js/panel.js' %}"></script>
132 <div class="post-form">
132 <div class="post-form">
133 <div class="form-title">{% trans "Create new thread" %}</div>
133 <div class="form-title">{% trans "Create new thread" %}</div>
134 <div class="swappable-form-full">
134 <div class="swappable-form-full">
135 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
135 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
136 {{ form.as_div }}
136 {{ form.as_div }}
137 <div class="form-submit">
137 <div class="form-submit">
138 <input type="submit" value="{% trans "Post" %}"/>
138 <input type="submit" value="{% trans "Post" %}"/>
139 <button id="preview-button" onclick="return false;">{% trans 'Preview' %}</button>
139 <button id="preview-button" onclick="return false;">{% trans 'Preview' %}</button>
140 </div>
140 </div>
141 </form>
141 </form>
142 </div>
142 </div>
143 <div>
143 <div>
144 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
144 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
145 </div>
145 </div>
146 <div id="preview-text"></div>
146 <div id="preview-text"></div>
147 <div><a href="{% url "staticpage" name="help" %}">{% trans 'Text syntax' %}</a></div>
147 <div><a href="{% url "staticpage" name="help" %}">{% trans 'Text syntax' %}</a></div>
148 <div><a href="{% url "tags" "required" %}">{% trans 'Tags' %}</a></div>
148 <div><a href="{% url "tags" "required" %}">{% trans 'Tags' %}</a></div>
149 </div>
149 </div>
150 </div>
150 </div>
151
151
152 <script src="{% static 'js/form.js' %}"></script>
152 <script src="{% static 'js/form.js' %}"></script>
153 <script src="{% static 'js/thread_create.js' %}"></script>
153 <script src="{% static 'js/thread_create.js' %}"></script>
154
154
155 {% endblock %}
155 {% endblock %}
156
156
157 {% block metapanel %}
157 {% block metapanel %}
158
158
159 <span class="metapanel">
159 <span class="metapanel">
160 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
160 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
161 {% trans "Pages:" %}
161 {% trans "Pages:" %}
162 [
162 [
163 {% with dividers=paginator.get_dividers %}
163 {% with dividers=paginator.get_dividers %}
164 {% for page in paginator.get_divided_range %}
164 {% for page in paginator.get_divided_range %}
165 {% if page in dividers %}
165 {% if page in dividers %}
166 …,
166 …,
167 {% endif %}
167 {% endif %}
168 <a
168 <a
169 {% ifequal page current_page.number %}
169 {% ifequal page current_page.number %}
170 class="current_page"
170 class="current_page"
171 {% endifequal %}
171 {% endifequal %}
172 href="
172 href="
173 {% if tag %}
173 {% if tag %}
174 {% url "tag" tag_name=tag.name %}?page={{ page }}
174 {% url "tag" tag_name=tag.name %}?page={{ page }}
175 {% else %}
175 {% else %}
176 {% url "index" %}?page={{ page }}
176 {% url "index" %}?page={{ page }}
177 {% endif %}
177 {% endif %}
178 ">{{ page }}</a>
178 ">{{ page }}</a>
179 {% if not forloop.last %},{% endif %}
179 {% if not forloop.last %},{% endif %}
180 {% endfor %}
180 {% endfor %}
181 {% endwith %}
181 {% endwith %}
182 ]
182 ]
183 [<a href="rss/">RSS</a>]
183 [<a href="rss/">RSS</a>]
184 </span>
184 </span>
185
185
186 {% endblock %}
186 {% endblock %}
@@ -1,109 +1,105 b''
1 {% load i18n %}
1 {% load i18n %}
2 {% load board %}
2 {% load board %}
3
3
4 {% get_current_language as LANGUAGE_CODE %}
4 {% get_current_language as LANGUAGE_CODE %}
5
5
6 <div class="{{ css_class }}" id="{{ post.id }}" data-uid="{{ post.uid }}">
6 <div class="{{ css_class }}" id="{{ post.id }}" data-uid="{{ post.uid }}">
7 <div class="post-info">
7 <div class="post-info">
8 <a class="post_id" href="{{ post.get_absolute_url }}">#{{ post.get_absolute_id }}</a>
8 <a class="post_id" href="{{ post.get_absolute_url }}">#{{ post.get_absolute_id }}</a>
9 <span class="title">{{ post.title }}</span>
9 <span class="title">{{ post.title }}</span>
10 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
10 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
11 {% if post.tripcode %}
11 {% if post.tripcode %}
12 {% with tripcode=post.get_tripcode %}
12 {% with tripcode=post.get_tripcode %}
13 <a href="{% url 'feed' %}?tripcode={{ tripcode.get_full_text }}"
13 <a href="{% url 'feed' %}?tripcode={{ tripcode.get_full_text }}"
14 class="tripcode" title="{{ tripcode.get_full_text }}"
14 class="tripcode" title="{{ tripcode.get_full_text }}"
15 style="border: solid 2px #{{ tripcode.get_color }}; border-left: solid 1ex #{{ tripcode.get_color }};">{{ tripcode.get_short_text }}</a>
15 style="border: solid 2px #{{ tripcode.get_color }}; border-left: solid 1ex #{{ tripcode.get_color }};">{{ tripcode.get_short_text }}</a>
16 {% endwith %}
16 {% endwith %}
17 {% endif %}
17 {% endif %}
18 {% comment %}
18 {% comment %}
19 Thread death time needs to be shown only if the thread is alredy archived
19 Thread death time needs to be shown only if the thread is alredy archived
20 and this is an opening post (thread death time) or a post for popup
20 and this is an opening post (thread death time) or a post for popup
21 (we don't see OP here so we show the death time in the post itself).
21 (we don't see OP here so we show the death time in the post itself).
22 {% endcomment %}
22 {% endcomment %}
23 {% if thread.archived %}
23 {% if thread.archived %}
24 {% if is_opening %}
24 {% if is_opening %}
25 β€” <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
25 β€” <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
26 {% endif %}
26 {% endif %}
27 {% endif %}
27 {% endif %}
28 {% if is_opening %}
28 {% if is_opening %}
29 {% if need_open_link %}
29 {% if need_open_link %}
30 {% if thread.archived %}
30 {% if thread.archived %}
31 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
31 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
32 {% else %}
32 {% else %}
33 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
33 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
34 {% endif %}
34 {% endif %}
35 {% endif %}
35 {% endif %}
36 {% else %}
36 {% else %}
37 {% if need_op_data %}
37 {% if need_op_data %}
38 {% with thread.get_opening_post as op %}
38 {% with thread.get_opening_post as op %}
39 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title|striptags|truncatewords:5 }}</span>
39 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title|striptags|truncatewords:5 }}</span>
40 {% endwith %}
40 {% endwith %}
41 {% endif %}
41 {% endif %}
42 {% endif %}
42 {% endif %}
43 {% if reply_link and not thread.archived %}
43 {% if reply_link and not thread.archived %}
44 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
44 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
45 {% endif %}
45 {% endif %}
46
46
47 {% if moderator %}
47 {% if moderator %}
48 <span class="moderator_info">
48 <span class="moderator_info">
49 | <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
49 | <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
50 {% if is_opening %}
50 {% if is_opening %}
51 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
51 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
52 {% endif %}
52 {% endif %}
53 </span>
53 </span>
54 {% endif %}
54 {% endif %}
55 </div>
55 </div>
56 {% comment %}
56 {% comment %}
57 Post images. Currently only 1 image can be posted and shown, but post model
57 Post images. Currently only 1 image can be posted and shown, but post model
58 supports multiple.
58 supports multiple.
59 {% endcomment %}
59 {% endcomment %}
60 {% if post.images.exists %}
60 {% for image in post.images.all %}
61 {% with post.images.first as image %}
61 {{ image.get_view|safe }}
62 {{ image.get_view|safe }}
62 {% endfor %}
63 {% endwith %}
63 {% for file in post.attachments.all %}
64 {% endif %}
64 {{ file.get_view|safe }}
65 {% if post.attachments.exists %}
65 {% endfor %}
66 {% with post.attachments.first as file %}
67 {{ file.get_view|safe }}
68 {% endwith %}
69 {% endif %}
70 {% comment %}
66 {% comment %}
71 Post message (text)
67 Post message (text)
72 {% endcomment %}
68 {% endcomment %}
73 <div class="message">
69 <div class="message">
74 {% autoescape off %}
70 {% autoescape off %}
75 {% if truncated %}
71 {% if truncated %}
76 {{ post.get_text|truncatewords_html:50 }}
72 {{ post.get_text|truncatewords_html:50 }}
77 {% else %}
73 {% else %}
78 {{ post.get_text }}
74 {{ post.get_text }}
79 {% endif %}
75 {% endif %}
80 {% endautoescape %}
76 {% endautoescape %}
81 </div>
77 </div>
82 {% if post.is_referenced %}
78 {% if post.is_referenced %}
83 {% if mode_tree %}
79 {% if mode_tree %}
84 <div class="tree_reply">
80 <div class="tree_reply">
85 {% for refpost in post.get_referenced_posts %}
81 {% for refpost in post.get_referenced_posts %}
86 {% post_view refpost mode_tree=True %}
82 {% post_view refpost mode_tree=True %}
87 {% endfor %}
83 {% endfor %}
88 </div>
84 </div>
89 {% else %}
85 {% else %}
90 <div class="refmap">
86 <div class="refmap">
91 {% trans "Replies" %}: {{ post.refmap|safe }}
87 {% trans "Replies" %}: {{ post.refmap|safe }}
92 </div>
88 </div>
93 {% endif %}
89 {% endif %}
94 {% endif %}
90 {% endif %}
95 {% comment %}
91 {% comment %}
96 Thread metadata: counters, tags etc
92 Thread metadata: counters, tags etc
97 {% endcomment %}
93 {% endcomment %}
98 {% if is_opening %}
94 {% if is_opening %}
99 <div class="metadata">
95 <div class="metadata">
100 {% if is_opening and need_open_link %}
96 {% if is_opening and need_open_link %}
101 {% blocktrans count count=thread.get_reply_count %}{{ count }} message{% plural %}{{ count }} messages{% endblocktrans %},
97 {% blocktrans count count=thread.get_reply_count %}{{ count }} message{% plural %}{{ count }} messages{% endblocktrans %},
102 {% blocktrans count count=thread.get_images_count %}{{ count }} image{% plural %}{{ count }} images{% endblocktrans %}.
98 {% blocktrans count count=thread.get_images_count %}{{ count }} image{% plural %}{{ count }} images{% endblocktrans %}.
103 {% endif %}
99 {% endif %}
104 <span class="tags">
100 <span class="tags">
105 {{ thread.get_tag_url_list|safe }}
101 {{ thread.get_tag_url_list|safe }}
106 </span>
102 </span>
107 </div>
103 </div>
108 {% endif %}
104 {% endif %}
109 </div>
105 </div>
General Comments 0
You need to be logged in to leave comments. Login now