##// END OF EJS Templates
New fast recursion-free tree render engine
neko259 -
r1473:d5bf9a5c default
parent child Browse files
Show More
@@ -1,375 +1,376 b''
1 import logging
1 import logging
2 import re
2 import re
3 import uuid
3 import uuid
4
4
5 from django.core.exceptions import ObjectDoesNotExist
5 from django.core.exceptions import ObjectDoesNotExist
6 from django.core.urlresolvers import reverse
6 from django.core.urlresolvers import reverse
7 from django.db import models
7 from django.db import models
8 from django.db.models import TextField, QuerySet
8 from django.db.models import TextField, QuerySet
9 from django.template.defaultfilters import striptags, truncatewords
9 from django.template.defaultfilters import striptags, truncatewords
10 from django.template.loader import render_to_string
10 from django.template.loader import render_to_string
11 from django.utils import timezone
11 from django.utils import timezone
12
12
13 from boards import settings
13 from boards import settings
14 from boards.abstracts.tripcode import Tripcode
14 from boards.abstracts.tripcode import Tripcode
15 from boards.mdx_neboard import Parser
15 from boards.mdx_neboard import Parser
16 from boards.models import PostImage, Attachment
16 from boards.models import PostImage, Attachment
17 from boards.models.base import Viewable
17 from boards.models.base import Viewable
18 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
18 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
19 from boards.models.post.manager import PostManager
19 from boards.models.post.manager import PostManager
20 from boards.models.user import Notification
20 from boards.models.user import Notification
21
21
22 CSS_CLS_HIDDEN_POST = 'hidden_post'
22 CSS_CLS_HIDDEN_POST = 'hidden_post'
23 CSS_CLS_DEAD_POST = 'dead_post'
23 CSS_CLS_DEAD_POST = 'dead_post'
24 CSS_CLS_ARCHIVE_POST = 'archive_post'
24 CSS_CLS_ARCHIVE_POST = 'archive_post'
25 CSS_CLS_POST = 'post'
25 CSS_CLS_POST = 'post'
26 CSS_CLS_MONOCHROME = 'monochrome'
26 CSS_CLS_MONOCHROME = 'monochrome'
27
27
28 TITLE_MAX_WORDS = 10
28 TITLE_MAX_WORDS = 10
29
29
30 APP_LABEL_BOARDS = 'boards'
30 APP_LABEL_BOARDS = 'boards'
31
31
32 BAN_REASON_AUTO = 'Auto'
32 BAN_REASON_AUTO = 'Auto'
33
33
34 IMAGE_THUMB_SIZE = (200, 150)
34 IMAGE_THUMB_SIZE = (200, 150)
35
35
36 TITLE_MAX_LENGTH = 200
36 TITLE_MAX_LENGTH = 200
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_POST = 'post'
48 PARAMETER_POST = 'post'
49 PARAMETER_OP_ID = 'opening_post_id'
49 PARAMETER_OP_ID = 'opening_post_id'
50 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
50 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
51 PARAMETER_REPLY_LINK = 'reply_link'
51 PARAMETER_REPLY_LINK = 'reply_link'
52 PARAMETER_NEED_OP_DATA = 'need_op_data'
52 PARAMETER_NEED_OP_DATA = 'need_op_data'
53
53
54 POST_VIEW_PARAMS = (
54 POST_VIEW_PARAMS = (
55 'need_op_data',
55 'need_op_data',
56 'reply_link',
56 'reply_link',
57 'need_open_link',
57 'need_open_link',
58 'truncated',
58 'truncated',
59 'mode_tree',
59 'mode_tree',
60 'perms',
60 'perms',
61 'tree_depth',
61 )
62 )
62
63
63
64
64 class Post(models.Model, Viewable):
65 class Post(models.Model, Viewable):
65 """A post is a message."""
66 """A post is a message."""
66
67
67 objects = PostManager()
68 objects = PostManager()
68
69
69 class Meta:
70 class Meta:
70 app_label = APP_LABEL_BOARDS
71 app_label = APP_LABEL_BOARDS
71 ordering = ('id',)
72 ordering = ('id',)
72
73
73 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
74 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
74 pub_time = models.DateTimeField()
75 pub_time = models.DateTimeField()
75 text = TextField(blank=True, null=True)
76 text = TextField(blank=True, null=True)
76 _text_rendered = TextField(blank=True, null=True, editable=False)
77 _text_rendered = TextField(blank=True, null=True, editable=False)
77
78
78 images = models.ManyToManyField(PostImage, null=True, blank=True,
79 images = models.ManyToManyField(PostImage, null=True, blank=True,
79 related_name='post_images', db_index=True)
80 related_name='post_images', db_index=True)
80 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
81 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
81 related_name='attachment_posts')
82 related_name='attachment_posts')
82
83
83 poster_ip = models.GenericIPAddressField()
84 poster_ip = models.GenericIPAddressField()
84
85
85 # TODO This field can be removed cause UID is used for update now
86 # TODO This field can be removed cause UID is used for update now
86 last_edit_time = models.DateTimeField()
87 last_edit_time = models.DateTimeField()
87
88
88 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
89 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
89 null=True,
90 null=True,
90 blank=True, related_name='refposts',
91 blank=True, related_name='refposts',
91 db_index=True)
92 db_index=True)
92 refmap = models.TextField(null=True, blank=True)
93 refmap = models.TextField(null=True, blank=True)
93 threads = models.ManyToManyField('Thread', db_index=True,
94 threads = models.ManyToManyField('Thread', db_index=True,
94 related_name='multi_replies')
95 related_name='multi_replies')
95 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
96 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
96
97
97 url = models.TextField()
98 url = models.TextField()
98 uid = models.TextField(db_index=True)
99 uid = models.TextField(db_index=True)
99
100
100 tripcode = models.CharField(max_length=50, blank=True, default='')
101 tripcode = models.CharField(max_length=50, blank=True, default='')
101 opening = models.BooleanField(db_index=True)
102 opening = models.BooleanField(db_index=True)
102 hidden = models.BooleanField(default=False)
103 hidden = models.BooleanField(default=False)
103
104
104 def __str__(self):
105 def __str__(self):
105 return 'P#{}/{}'.format(self.id, self.get_title())
106 return 'P#{}/{}'.format(self.id, self.get_title())
106
107
107 def get_referenced_posts(self):
108 def get_referenced_posts(self):
108 threads = self.get_threads().all()
109 threads = self.get_threads().all()
109 return self.referenced_posts.filter(threads__in=threads)\
110 return self.referenced_posts.filter(threads__in=threads)\
110 .order_by('pub_time').distinct().all()
111 .order_by('pub_time').distinct().all()
111
112
112 def get_title(self) -> str:
113 def get_title(self) -> str:
113 return self.title
114 return self.title
114
115
115 def get_title_or_text(self):
116 def get_title_or_text(self):
116 title = self.get_title()
117 title = self.get_title()
117 if not title:
118 if not title:
118 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
119 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
119
120
120 return title
121 return title
121
122
122 def build_refmap(self) -> None:
123 def build_refmap(self) -> None:
123 """
124 """
124 Builds a replies map string from replies list. This is a cache to stop
125 Builds a replies map string from replies list. This is a cache to stop
125 the server from recalculating the map on every post show.
126 the server from recalculating the map on every post show.
126 """
127 """
127
128
128 post_urls = [refpost.get_link_view()
129 post_urls = [refpost.get_link_view()
129 for refpost in self.referenced_posts.all()]
130 for refpost in self.referenced_posts.all()]
130
131
131 self.refmap = ', '.join(post_urls)
132 self.refmap = ', '.join(post_urls)
132
133
133 def is_referenced(self) -> bool:
134 def is_referenced(self) -> bool:
134 return self.refmap and len(self.refmap) > 0
135 return self.refmap and len(self.refmap) > 0
135
136
136 def is_opening(self) -> bool:
137 def is_opening(self) -> bool:
137 """
138 """
138 Checks if this is an opening post or just a reply.
139 Checks if this is an opening post or just a reply.
139 """
140 """
140
141
141 return self.opening
142 return self.opening
142
143
143 def get_absolute_url(self, thread=None):
144 def get_absolute_url(self, thread=None):
144 url = None
145 url = None
145
146
146 if thread is None:
147 if thread is None:
147 thread = self.get_thread()
148 thread = self.get_thread()
148
149
149 # Url is cached only for the "main" thread. When getting url
150 # Url is cached only for the "main" thread. When getting url
150 # for other threads, do it manually.
151 # for other threads, do it manually.
151 if self.url:
152 if self.url:
152 url = self.url
153 url = self.url
153
154
154 if url is None:
155 if url is None:
155 opening = self.is_opening()
156 opening = self.is_opening()
156 opening_id = self.id if opening else thread.get_opening_post_id()
157 opening_id = self.id if opening else thread.get_opening_post_id()
157 url = reverse('thread', kwargs={'post_id': opening_id})
158 url = reverse('thread', kwargs={'post_id': opening_id})
158 if not opening:
159 if not opening:
159 url += '#' + str(self.id)
160 url += '#' + str(self.id)
160
161
161 return url
162 return url
162
163
163 def get_thread(self):
164 def get_thread(self):
164 return self.thread
165 return self.thread
165
166
166 def get_thread_id(self):
167 def get_thread_id(self):
167 return self.thread_id
168 return self.thread_id
168
169
169 def get_threads(self) -> QuerySet:
170 def get_threads(self) -> QuerySet:
170 """
171 """
171 Gets post's thread.
172 Gets post's thread.
172 """
173 """
173
174
174 return self.threads
175 return self.threads
175
176
176 def get_view(self, *args, **kwargs) -> str:
177 def get_view(self, *args, **kwargs) -> str:
177 """
178 """
178 Renders post's HTML view. Some of the post params can be passed over
179 Renders post's HTML view. Some of the post params can be passed over
179 kwargs for the means of caching (if we view the thread, some params
180 kwargs for the means of caching (if we view the thread, some params
180 are same for every post and don't need to be computed over and over.
181 are same for every post and don't need to be computed over and over.
181 """
182 """
182
183
183 thread = self.get_thread()
184 thread = self.get_thread()
184
185
185 css_classes = [CSS_CLS_POST]
186 css_classes = [CSS_CLS_POST]
186 if thread.is_archived():
187 if thread.is_archived():
187 css_classes.append(CSS_CLS_ARCHIVE_POST)
188 css_classes.append(CSS_CLS_ARCHIVE_POST)
188 elif not thread.can_bump():
189 elif not thread.can_bump():
189 css_classes.append(CSS_CLS_DEAD_POST)
190 css_classes.append(CSS_CLS_DEAD_POST)
190 if self.is_hidden():
191 if self.is_hidden():
191 css_classes.append(CSS_CLS_HIDDEN_POST)
192 css_classes.append(CSS_CLS_HIDDEN_POST)
192 if thread.is_monochrome():
193 if thread.is_monochrome():
193 css_classes.append(CSS_CLS_MONOCHROME)
194 css_classes.append(CSS_CLS_MONOCHROME)
194
195
195 params = dict()
196 params = dict()
196 for param in POST_VIEW_PARAMS:
197 for param in POST_VIEW_PARAMS:
197 if param in kwargs:
198 if param in kwargs:
198 params[param] = kwargs[param]
199 params[param] = kwargs[param]
199
200
200 params.update({
201 params.update({
201 PARAMETER_POST: self,
202 PARAMETER_POST: self,
202 PARAMETER_IS_OPENING: self.is_opening(),
203 PARAMETER_IS_OPENING: self.is_opening(),
203 PARAMETER_THREAD: thread,
204 PARAMETER_THREAD: thread,
204 PARAMETER_CSS_CLASS: ' '.join(css_classes),
205 PARAMETER_CSS_CLASS: ' '.join(css_classes),
205 })
206 })
206
207
207 return render_to_string('boards/post.html', params)
208 return render_to_string('boards/post.html', params)
208
209
209 def get_search_view(self, *args, **kwargs):
210 def get_search_view(self, *args, **kwargs):
210 return self.get_view(need_op_data=True, *args, **kwargs)
211 return self.get_view(need_op_data=True, *args, **kwargs)
211
212
212 def get_first_image(self) -> PostImage:
213 def get_first_image(self) -> PostImage:
213 return self.images.earliest('id')
214 return self.images.earliest('id')
214
215
215 def delete(self, using=None):
216 def delete(self, using=None):
216 """
217 """
217 Deletes all post images and the post itself.
218 Deletes all post images and the post itself.
218 """
219 """
219
220
220 for image in self.images.all():
221 for image in self.images.all():
221 image_refs_count = image.post_images.count()
222 image_refs_count = image.post_images.count()
222 if image_refs_count == 1:
223 if image_refs_count == 1:
223 image.delete()
224 image.delete()
224
225
225 for attachment in self.attachments.all():
226 for attachment in self.attachments.all():
226 attachment_refs_count = attachment.attachment_posts.count()
227 attachment_refs_count = attachment.attachment_posts.count()
227 if attachment_refs_count == 1:
228 if attachment_refs_count == 1:
228 attachment.delete()
229 attachment.delete()
229
230
230 thread = self.get_thread()
231 thread = self.get_thread()
231 thread.last_edit_time = timezone.now()
232 thread.last_edit_time = timezone.now()
232 thread.save()
233 thread.save()
233
234
234 super(Post, self).delete(using)
235 super(Post, self).delete(using)
235
236
236 logging.getLogger('boards.post.delete').info(
237 logging.getLogger('boards.post.delete').info(
237 'Deleted post {}'.format(self))
238 'Deleted post {}'.format(self))
238
239
239 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
240 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
240 include_last_update=False) -> str:
241 include_last_update=False) -> str:
241 """
242 """
242 Gets post HTML or JSON data that can be rendered on a page or used by
243 Gets post HTML or JSON data that can be rendered on a page or used by
243 API.
244 API.
244 """
245 """
245
246
246 return get_exporter(format_type).export(self, request,
247 return get_exporter(format_type).export(self, request,
247 include_last_update)
248 include_last_update)
248
249
249 def notify_clients(self, recursive=True):
250 def notify_clients(self, recursive=True):
250 """
251 """
251 Sends post HTML data to the thread web socket.
252 Sends post HTML data to the thread web socket.
252 """
253 """
253
254
254 if not settings.get_bool('External', 'WebsocketsEnabled'):
255 if not settings.get_bool('External', 'WebsocketsEnabled'):
255 return
256 return
256
257
257 thread_ids = list()
258 thread_ids = list()
258 for thread in self.get_threads().all():
259 for thread in self.get_threads().all():
259 thread_ids.append(thread.id)
260 thread_ids.append(thread.id)
260
261
261 thread.notify_clients()
262 thread.notify_clients()
262
263
263 if recursive:
264 if recursive:
264 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
265 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
265 post_id = reply_number.group(1)
266 post_id = reply_number.group(1)
266
267
267 try:
268 try:
268 ref_post = Post.objects.get(id=post_id)
269 ref_post = Post.objects.get(id=post_id)
269
270
270 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
271 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
271 # If post is in this thread, its thread was already notified.
272 # If post is in this thread, its thread was already notified.
272 # Otherwise, notify its thread separately.
273 # Otherwise, notify its thread separately.
273 ref_post.notify_clients(recursive=False)
274 ref_post.notify_clients(recursive=False)
274 except ObjectDoesNotExist:
275 except ObjectDoesNotExist:
275 pass
276 pass
276
277
277 def build_url(self):
278 def build_url(self):
278 self.url = self.get_absolute_url()
279 self.url = self.get_absolute_url()
279 self.save(update_fields=['url'])
280 self.save(update_fields=['url'])
280
281
281 def save(self, force_insert=False, force_update=False, using=None,
282 def save(self, force_insert=False, force_update=False, using=None,
282 update_fields=None):
283 update_fields=None):
283 new_post = self.id is None
284 new_post = self.id is None
284
285
285 self._text_rendered = Parser().parse(self.get_raw_text())
286 self._text_rendered = Parser().parse(self.get_raw_text())
286
287
287 self.uid = str(uuid.uuid4())
288 self.uid = str(uuid.uuid4())
288 if update_fields is not None and 'uid' not in update_fields:
289 if update_fields is not None and 'uid' not in update_fields:
289 update_fields += ['uid']
290 update_fields += ['uid']
290
291
291 if not new_post:
292 if not new_post:
292 for thread in self.get_threads().all():
293 for thread in self.get_threads().all():
293 thread.last_edit_time = self.last_edit_time
294 thread.last_edit_time = self.last_edit_time
294
295
295 thread.save(update_fields=['last_edit_time', 'status'])
296 thread.save(update_fields=['last_edit_time', 'status'])
296
297
297 super().save(force_insert, force_update, using, update_fields)
298 super().save(force_insert, force_update, using, update_fields)
298
299
299 if self.url is None:
300 if self.url is None:
300 self.build_url()
301 self.build_url()
301
302
302 self._connect_replies()
303 self._connect_replies()
303 self._connect_notifications()
304 self._connect_notifications()
304
305
305 def get_text(self) -> str:
306 def get_text(self) -> str:
306 return self._text_rendered
307 return self._text_rendered
307
308
308 def get_raw_text(self) -> str:
309 def get_raw_text(self) -> str:
309 return self.text
310 return self.text
310
311
311 def get_absolute_id(self) -> str:
312 def get_absolute_id(self) -> str:
312 """
313 """
313 If the post has many threads, shows its main thread OP id in the post
314 If the post has many threads, shows its main thread OP id in the post
314 ID.
315 ID.
315 """
316 """
316
317
317 if self.get_threads().count() > 1:
318 if self.get_threads().count() > 1:
318 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
319 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
319 else:
320 else:
320 return str(self.id)
321 return str(self.id)
321
322
322 def _connect_notifications(self):
323 def _connect_notifications(self):
323 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
324 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
324 user_name = reply_number.group(1).lower()
325 user_name = reply_number.group(1).lower()
325 Notification.objects.get_or_create(name=user_name, post=self)
326 Notification.objects.get_or_create(name=user_name, post=self)
326
327
327 def _connect_replies(self):
328 def _connect_replies(self):
328 """
329 """
329 Connects replies to a post to show them as a reflink map
330 Connects replies to a post to show them as a reflink map
330 """
331 """
331
332
332 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
333 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
333 post_id = reply_number.group(1)
334 post_id = reply_number.group(1)
334
335
335 try:
336 try:
336 referenced_post = Post.objects.get(id=post_id)
337 referenced_post = Post.objects.get(id=post_id)
337
338
338 referenced_post.referenced_posts.add(self)
339 referenced_post.referenced_posts.add(self)
339 referenced_post.last_edit_time = self.pub_time
340 referenced_post.last_edit_time = self.pub_time
340 referenced_post.build_refmap()
341 referenced_post.build_refmap()
341 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
342 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
342 except ObjectDoesNotExist:
343 except ObjectDoesNotExist:
343 pass
344 pass
344
345
345 def connect_threads(self, opening_posts):
346 def connect_threads(self, opening_posts):
346 for opening_post in opening_posts:
347 for opening_post in opening_posts:
347 threads = opening_post.get_threads().all()
348 threads = opening_post.get_threads().all()
348 for thread in threads:
349 for thread in threads:
349 if thread.can_bump():
350 if thread.can_bump():
350 thread.update_bump_status()
351 thread.update_bump_status()
351
352
352 thread.last_edit_time = self.last_edit_time
353 thread.last_edit_time = self.last_edit_time
353 thread.save(update_fields=['last_edit_time', 'status'])
354 thread.save(update_fields=['last_edit_time', 'status'])
354 self.threads.add(opening_post.get_thread())
355 self.threads.add(opening_post.get_thread())
355
356
356 def get_tripcode(self):
357 def get_tripcode(self):
357 if self.tripcode:
358 if self.tripcode:
358 return Tripcode(self.tripcode)
359 return Tripcode(self.tripcode)
359
360
360 def get_link_view(self):
361 def get_link_view(self):
361 """
362 """
362 Gets view of a reflink to the post.
363 Gets view of a reflink to the post.
363 """
364 """
364 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
365 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
365 self.id)
366 self.id)
366 if self.is_opening():
367 if self.is_opening():
367 result = '<b>{}</b>'.format(result)
368 result = '<b>{}</b>'.format(result)
368
369
369 return result
370 return result
370
371
371 def is_hidden(self) -> bool:
372 def is_hidden(self) -> bool:
372 return self.hidden
373 return self.hidden
373
374
374 def set_hidden(self, hidden):
375 def set_hidden(self, hidden):
375 self.hidden = hidden
376 self.hidden = hidden
@@ -1,281 +1,298 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, Q
4 from django.db.models import Count, Sum, QuerySet, Q
5 from django.utils import timezone
5 from django.utils import timezone
6 from django.db import models, transaction
6 from django.db import models, transaction
7
7
8 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
8 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
9
9
10 from boards import settings
10 from boards import settings
11 import boards
11 import boards
12 from boards.utils import cached_result, datetime_to_epoch
12 from boards.utils import cached_result, datetime_to_epoch
13 from boards.models.post import Post
13 from boards.models.post import Post
14 from boards.models.tag import Tag
14 from boards.models.tag import Tag
15
15
16 FAV_THREAD_NO_UPDATES = -1
16 FAV_THREAD_NO_UPDATES = -1
17
17
18
18
19 __author__ = 'neko259'
19 __author__ = 'neko259'
20
20
21
21
22 logger = logging.getLogger(__name__)
22 logger = logging.getLogger(__name__)
23
23
24
24
25 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
25 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
26 WS_NOTIFICATION_TYPE = 'notification_type'
26 WS_NOTIFICATION_TYPE = 'notification_type'
27
27
28 WS_CHANNEL_THREAD = "thread:"
28 WS_CHANNEL_THREAD = "thread:"
29
29
30 STATUS_CHOICES = (
30 STATUS_CHOICES = (
31 (STATUS_ACTIVE, STATUS_ACTIVE),
31 (STATUS_ACTIVE, STATUS_ACTIVE),
32 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
32 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
33 (STATUS_ARCHIVE, STATUS_ARCHIVE),
33 (STATUS_ARCHIVE, STATUS_ARCHIVE),
34 )
34 )
35
35
36
36
37 class ThreadManager(models.Manager):
37 class ThreadManager(models.Manager):
38 def process_oldest_threads(self):
38 def process_oldest_threads(self):
39 """
39 """
40 Preserves maximum thread count. If there are too many threads,
40 Preserves maximum thread count. If there are too many threads,
41 archive or delete the old ones.
41 archive or delete the old ones.
42 """
42 """
43
43
44 threads = Thread.objects.exclude(status=STATUS_ARCHIVE).order_by('-bump_time')
44 threads = Thread.objects.exclude(status=STATUS_ARCHIVE).order_by('-bump_time')
45 thread_count = threads.count()
45 thread_count = threads.count()
46
46
47 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
47 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
48 if thread_count > max_thread_count:
48 if thread_count > max_thread_count:
49 num_threads_to_delete = thread_count - max_thread_count
49 num_threads_to_delete = thread_count - max_thread_count
50 old_threads = threads[thread_count - num_threads_to_delete:]
50 old_threads = threads[thread_count - num_threads_to_delete:]
51
51
52 for thread in old_threads:
52 for thread in old_threads:
53 if settings.get_bool('Storage', 'ArchiveThreads'):
53 if settings.get_bool('Storage', 'ArchiveThreads'):
54 self._archive_thread(thread)
54 self._archive_thread(thread)
55 else:
55 else:
56 thread.delete()
56 thread.delete()
57
57
58 logger.info('Processed %d old threads' % num_threads_to_delete)
58 logger.info('Processed %d old threads' % num_threads_to_delete)
59
59
60 def _archive_thread(self, thread):
60 def _archive_thread(self, thread):
61 thread.status = STATUS_ARCHIVE
61 thread.status = STATUS_ARCHIVE
62 thread.last_edit_time = timezone.now()
62 thread.last_edit_time = timezone.now()
63 thread.update_posts_time()
63 thread.update_posts_time()
64 thread.save(update_fields=['last_edit_time', 'status'])
64 thread.save(update_fields=['last_edit_time', 'status'])
65
65
66 def get_new_posts(self, datas):
66 def get_new_posts(self, datas):
67 query = None
67 query = None
68 # TODO Use classes instead of dicts
68 # TODO Use classes instead of dicts
69 for data in datas:
69 for data in datas:
70 if data['last_id'] != FAV_THREAD_NO_UPDATES:
70 if data['last_id'] != FAV_THREAD_NO_UPDATES:
71 q = (Q(id=data['op'].get_thread_id())
71 q = (Q(id=data['op'].get_thread_id())
72 & Q(multi_replies__id__gt=data['last_id']))
72 & Q(multi_replies__id__gt=data['last_id']))
73 if query is None:
73 if query is None:
74 query = q
74 query = q
75 else:
75 else:
76 query = query | q
76 query = query | q
77 if query is not None:
77 if query is not None:
78 return self.filter(query).annotate(
78 return self.filter(query).annotate(
79 new_post_count=Count('multi_replies'))
79 new_post_count=Count('multi_replies'))
80
80
81 def get_new_post_count(self, datas):
81 def get_new_post_count(self, datas):
82 new_posts = self.get_new_posts(datas)
82 new_posts = self.get_new_posts(datas)
83 return new_posts.aggregate(total_count=Count('multi_replies'))\
83 return new_posts.aggregate(total_count=Count('multi_replies'))\
84 ['total_count'] if new_posts else 0
84 ['total_count'] if new_posts else 0
85
85
86
86
87 def get_thread_max_posts():
87 def get_thread_max_posts():
88 return settings.get_int('Messages', 'MaxPostsPerThread')
88 return settings.get_int('Messages', 'MaxPostsPerThread')
89
89
90
90
91 class Thread(models.Model):
91 class Thread(models.Model):
92 objects = ThreadManager()
92 objects = ThreadManager()
93
93
94 class Meta:
94 class Meta:
95 app_label = 'boards'
95 app_label = 'boards'
96
96
97 tags = models.ManyToManyField('Tag', related_name='thread_tags')
97 tags = models.ManyToManyField('Tag', related_name='thread_tags')
98 bump_time = models.DateTimeField(db_index=True)
98 bump_time = models.DateTimeField(db_index=True)
99 last_edit_time = models.DateTimeField()
99 last_edit_time = models.DateTimeField()
100 max_posts = models.IntegerField(default=get_thread_max_posts)
100 max_posts = models.IntegerField(default=get_thread_max_posts)
101 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
101 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
102 choices=STATUS_CHOICES)
102 choices=STATUS_CHOICES)
103 monochrome = models.BooleanField(default=False)
103 monochrome = models.BooleanField(default=False)
104
104
105 def get_tags(self) -> QuerySet:
105 def get_tags(self) -> QuerySet:
106 """
106 """
107 Gets a sorted tag list.
107 Gets a sorted tag list.
108 """
108 """
109
109
110 return self.tags.order_by('name')
110 return self.tags.order_by('name')
111
111
112 def bump(self):
112 def bump(self):
113 """
113 """
114 Bumps (moves to up) thread if possible.
114 Bumps (moves to up) thread if possible.
115 """
115 """
116
116
117 if self.can_bump():
117 if self.can_bump():
118 self.bump_time = self.last_edit_time
118 self.bump_time = self.last_edit_time
119
119
120 self.update_bump_status()
120 self.update_bump_status()
121
121
122 logger.info('Bumped thread %d' % self.id)
122 logger.info('Bumped thread %d' % self.id)
123
123
124 def has_post_limit(self) -> bool:
124 def has_post_limit(self) -> bool:
125 return self.max_posts > 0
125 return self.max_posts > 0
126
126
127 def update_bump_status(self, exclude_posts=None):
127 def update_bump_status(self, exclude_posts=None):
128 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
128 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
129 self.status = STATUS_BUMPLIMIT
129 self.status = STATUS_BUMPLIMIT
130 self.update_posts_time(exclude_posts=exclude_posts)
130 self.update_posts_time(exclude_posts=exclude_posts)
131
131
132 def _get_cache_key(self):
132 def _get_cache_key(self):
133 return [datetime_to_epoch(self.last_edit_time)]
133 return [datetime_to_epoch(self.last_edit_time)]
134
134
135 @cached_result(key_method=_get_cache_key)
135 @cached_result(key_method=_get_cache_key)
136 def get_reply_count(self) -> int:
136 def get_reply_count(self) -> int:
137 return self.get_replies().count()
137 return self.get_replies().count()
138
138
139 @cached_result(key_method=_get_cache_key)
139 @cached_result(key_method=_get_cache_key)
140 def get_images_count(self) -> int:
140 def get_images_count(self) -> int:
141 return self.get_replies().annotate(images_count=Count(
141 return self.get_replies().annotate(images_count=Count(
142 'images')).aggregate(Sum('images_count'))['images_count__sum']
142 'images')).aggregate(Sum('images_count'))['images_count__sum']
143
143
144 def can_bump(self) -> bool:
144 def can_bump(self) -> bool:
145 """
145 """
146 Checks if the thread can be bumped by replying to it.
146 Checks if the thread can be bumped by replying to it.
147 """
147 """
148
148
149 return self.get_status() == STATUS_ACTIVE
149 return self.get_status() == STATUS_ACTIVE
150
150
151 def get_last_replies(self) -> QuerySet:
151 def get_last_replies(self) -> QuerySet:
152 """
152 """
153 Gets several last replies, not including opening post
153 Gets several last replies, not including opening post
154 """
154 """
155
155
156 last_replies_count = settings.get_int('View', 'LastRepliesCount')
156 last_replies_count = settings.get_int('View', 'LastRepliesCount')
157
157
158 if last_replies_count > 0:
158 if last_replies_count > 0:
159 reply_count = self.get_reply_count()
159 reply_count = self.get_reply_count()
160
160
161 if reply_count > 0:
161 if reply_count > 0:
162 reply_count_to_show = min(last_replies_count,
162 reply_count_to_show = min(last_replies_count,
163 reply_count - 1)
163 reply_count - 1)
164 replies = self.get_replies()
164 replies = self.get_replies()
165 last_replies = replies[reply_count - reply_count_to_show:]
165 last_replies = replies[reply_count - reply_count_to_show:]
166
166
167 return last_replies
167 return last_replies
168
168
169 def get_skipped_replies_count(self) -> int:
169 def get_skipped_replies_count(self) -> int:
170 """
170 """
171 Gets number of posts between opening post and last replies.
171 Gets number of posts between opening post and last replies.
172 """
172 """
173 reply_count = self.get_reply_count()
173 reply_count = self.get_reply_count()
174 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
174 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
175 reply_count - 1)
175 reply_count - 1)
176 return reply_count - last_replies_count - 1
176 return reply_count - last_replies_count - 1
177
177
178 def get_replies(self, view_fields_only=False) -> QuerySet:
178 def get_replies(self, view_fields_only=False) -> QuerySet:
179 """
179 """
180 Gets sorted thread posts
180 Gets sorted thread posts
181 """
181 """
182
182
183 query = self.multi_replies.order_by('pub_time').prefetch_related(
183 query = self.multi_replies.order_by('pub_time').prefetch_related(
184 'images', 'thread', 'threads', 'attachments')
184 'images', 'thread', 'threads', 'attachments')
185 if view_fields_only:
185 if view_fields_only:
186 query = query.defer('poster_ip')
186 query = query.defer('poster_ip')
187 return query.all()
187 return query.all()
188
188
189 def get_top_level_replies(self) -> QuerySet:
189 def get_top_level_replies(self) -> QuerySet:
190 return self.get_replies().exclude(refposts__threads__in=[self])
190 return self.get_replies().exclude(refposts__threads__in=[self])
191
191
192 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
192 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
193 """
193 """
194 Gets replies that have at least one image attached
194 Gets replies that have at least one image attached
195 """
195 """
196
196
197 return self.get_replies(view_fields_only).annotate(images_count=Count(
197 return self.get_replies(view_fields_only).annotate(images_count=Count(
198 'images')).filter(images_count__gt=0)
198 'images')).filter(images_count__gt=0)
199
199
200 def get_opening_post(self, only_id=False) -> Post:
200 def get_opening_post(self, only_id=False) -> Post:
201 """
201 """
202 Gets the first post of the thread
202 Gets the first post of the thread
203 """
203 """
204
204
205 query = self.get_replies().filter(opening=True)
205 query = self.get_replies().filter(opening=True)
206 if only_id:
206 if only_id:
207 query = query.only('id')
207 query = query.only('id')
208 opening_post = query.first()
208 opening_post = query.first()
209
209
210 return opening_post
210 return opening_post
211
211
212 @cached_result()
212 @cached_result()
213 def get_opening_post_id(self) -> int:
213 def get_opening_post_id(self) -> int:
214 """
214 """
215 Gets ID of the first thread post.
215 Gets ID of the first thread post.
216 """
216 """
217
217
218 return self.get_opening_post(only_id=True).id
218 return self.get_opening_post(only_id=True).id
219
219
220 def get_pub_time(self):
220 def get_pub_time(self):
221 """
221 """
222 Gets opening post's pub time because thread does not have its own one.
222 Gets opening post's pub time because thread does not have its own one.
223 """
223 """
224
224
225 return self.get_opening_post().pub_time
225 return self.get_opening_post().pub_time
226
226
227 def __str__(self):
227 def __str__(self):
228 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
228 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
229
229
230 def get_tag_url_list(self) -> list:
230 def get_tag_url_list(self) -> list:
231 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
231 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
232
232
233 def update_posts_time(self, exclude_posts=None):
233 def update_posts_time(self, exclude_posts=None):
234 last_edit_time = self.last_edit_time
234 last_edit_time = self.last_edit_time
235
235
236 for post in self.multi_replies.all():
236 for post in self.multi_replies.all():
237 if exclude_posts is None or post not in exclude_posts:
237 if exclude_posts is None or post not in exclude_posts:
238 # Manual update is required because uids are generated on save
238 # Manual update is required because uids are generated on save
239 post.last_edit_time = last_edit_time
239 post.last_edit_time = last_edit_time
240 post.save(update_fields=['last_edit_time'])
240 post.save(update_fields=['last_edit_time'])
241
241
242 post.get_threads().update(last_edit_time=last_edit_time)
242 post.get_threads().update(last_edit_time=last_edit_time)
243
243
244 def notify_clients(self):
244 def notify_clients(self):
245 if not settings.get_bool('External', 'WebsocketsEnabled'):
245 if not settings.get_bool('External', 'WebsocketsEnabled'):
246 return
246 return
247
247
248 client = Client()
248 client = Client()
249
249
250 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
250 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
251 client.publish(channel_name, {
251 client.publish(channel_name, {
252 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
252 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
253 })
253 })
254 client.send()
254 client.send()
255
255
256 def get_absolute_url(self):
256 def get_absolute_url(self):
257 return self.get_opening_post().get_absolute_url()
257 return self.get_opening_post().get_absolute_url()
258
258
259 def get_required_tags(self):
259 def get_required_tags(self):
260 return self.get_tags().filter(required=True)
260 return self.get_tags().filter(required=True)
261
261
262 def get_replies_newer(self, post_id):
262 def get_replies_newer(self, post_id):
263 return self.get_replies().filter(id__gt=post_id)
263 return self.get_replies().filter(id__gt=post_id)
264
264
265 def is_archived(self):
265 def is_archived(self):
266 return self.get_status() == STATUS_ARCHIVE
266 return self.get_status() == STATUS_ARCHIVE
267
267
268 def get_status(self):
268 def get_status(self):
269 return self.status
269 return self.status
270
270
271 def is_monochrome(self):
271 def is_monochrome(self):
272 return self.monochrome
272 return self.monochrome
273
273
274 # If tags have parent, add them to the tag list
274 # If tags have parent, add them to the tag list
275 @transaction.atomic
275 @transaction.atomic
276 def refresh_tags(self):
276 def refresh_tags(self):
277 for tag in self.get_tags().all():
277 for tag in self.get_tags().all():
278 parents = tag.get_all_parents()
278 parents = tag.get_all_parents()
279 if len(parents) > 0:
279 if len(parents) > 0:
280 self.tags.add(*parents)
280 self.tags.add(*parents)
281
281
282 def get_reply_tree(self):
283 replies = self.get_replies().prefetch_related('refposts')
284 tree = []
285 for reply in replies:
286 parents = reply.refposts.all()
287 found_parent = False
288 if len(parents) > 0:
289 index = 0
290 for depth, element in tree:
291 index += 1
292 if element in parents:
293 tree.insert(index, (depth + 1, reply))
294 found_parent = True
295 if not found_parent:
296 tree.append((0, reply))
297
298 return tree
@@ -1,111 +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 }}" {% if tree_depth %}style="margin-left: {{ tree_depth }}em;"{% endif %}>
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 /
12 /
13 {% with tripcode=post.get_tripcode %}
13 {% with tripcode=post.get_tripcode %}
14 <a href="{% url 'feed' %}?tripcode={{ tripcode.get_full_text }}"
14 <a href="{% url 'feed' %}?tripcode={{ tripcode.get_full_text }}"
15 class="tripcode" title="{{ tripcode.get_full_text }}"
15 class="tripcode" title="{{ tripcode.get_full_text }}"
16 style="border: solid 2px #{{ tripcode.get_color }}; border-left: solid 1ex #{{ tripcode.get_color }};">{{ tripcode.get_short_text }}</a>
16 style="border: solid 2px #{{ tripcode.get_color }}; border-left: solid 1ex #{{ tripcode.get_color }};">{{ tripcode.get_short_text }}</a>
17 {% endwith %}
17 {% endwith %}
18 {% endif %}
18 {% endif %}
19 {% comment %}
19 {% comment %}
20 Thread death time needs to be shown only if the thread is alredy archived
20 Thread death time needs to be shown only if the thread is alredy archived
21 and this is an opening post (thread death time) or a post for popup
21 and this is an opening post (thread death time) or a post for popup
22 (we don't see OP here so we show the death time in the post itself).
22 (we don't see OP here so we show the death time in the post itself).
23 {% endcomment %}
23 {% endcomment %}
24 {% if thread.is_archived %}
24 {% if thread.is_archived %}
25 {% if is_opening %}
25 {% if is_opening %}
26 <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
26 <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
27 {% endif %}
27 {% endif %}
28 {% endif %}
28 {% endif %}
29 {% if is_opening %}
29 {% if is_opening %}
30 {% if need_open_link %}
30 {% if need_open_link %}
31 {% if thread.is_archived %}
31 {% if thread.is_archived %}
32 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
32 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
33 {% else %}
33 {% else %}
34 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
34 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
35 {% endif %}
35 {% endif %}
36 {% endif %}
36 {% endif %}
37 {% else %}
37 {% else %}
38 {% if need_op_data %}
38 {% if need_op_data %}
39 {% with thread.get_opening_post as op %}
39 {% with thread.get_opening_post as op %}
40 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title_or_text }}</span>
40 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title_or_text }}</span>
41 {% endwith %}
41 {% endwith %}
42 {% endif %}
42 {% endif %}
43 {% endif %}
43 {% endif %}
44 {% if reply_link and not thread.is_archived %}
44 {% if reply_link and not thread.is_archived %}
45 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
45 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
46 {% endif %}
46 {% endif %}
47
47
48 {% if perms.boards.change_post or perms.boards.delete_post or perms.boards.change_thread or perms_boards.delete_thread %}
48 {% if perms.boards.change_post or perms.boards.delete_post or perms.boards.change_thread or perms_boards.delete_thread %}
49 <span class="moderator_info">
49 <span class="moderator_info">
50 {% if perms.boards.change_post or perms.boards.delete_post %}
50 {% if perms.boards.change_post or perms.boards.delete_post %}
51 | <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
51 | <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
52 {% endif %}
52 {% endif %}
53 {% if perms.boards.change_thread or perms_boards.delete_thread %}
53 {% if perms.boards.change_thread or perms_boards.delete_thread %}
54 {% if is_opening %}
54 {% if is_opening %}
55 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
55 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
56 {% endif %}
56 {% endif %}
57 {% endif %}
57 {% endif %}
58 </form>
58 </form>
59 </span>
59 </span>
60 {% endif %}
60 {% endif %}
61 </div>
61 </div>
62 {% comment %}
62 {% comment %}
63 Post images. Currently only 1 image can be posted and shown, but post model
63 Post images. Currently only 1 image can be posted and shown, but post model
64 supports multiple.
64 supports multiple.
65 {% endcomment %}
65 {% endcomment %}
66 {% for image in post.images.all %}
66 {% for image in post.images.all %}
67 {{ image.get_view|safe }}
67 {{ image.get_view|safe }}
68 {% endfor %}
68 {% endfor %}
69 {% for file in post.attachments.all %}
69 {% for file in post.attachments.all %}
70 {{ file.get_view|safe }}
70 {{ file.get_view|safe }}
71 {% endfor %}
71 {% endfor %}
72 {% comment %}
72 {% comment %}
73 Post message (text)
73 Post message (text)
74 {% endcomment %}
74 {% endcomment %}
75 <div class="message">
75 <div class="message">
76 {% autoescape off %}
76 {% autoescape off %}
77 {% if truncated %}
77 {% if truncated %}
78 {{ post.get_text|truncatewords_html:50 }}
78 {{ post.get_text|truncatewords_html:50 }}
79 {% else %}
79 {% else %}
80 {{ post.get_text }}
80 {{ post.get_text }}
81 {% endif %}
81 {% endif %}
82 {% endautoescape %}
82 {% endautoescape %}
83 </div>
83 </div>
84 {% if post.is_referenced %}
84 {% if post.is_referenced %}
85 {% if mode_tree %}
85 {% if not mode_tree %}
86 <div class="tree_reply">
87 {% for refpost in post.get_referenced_posts %}
88 {% post_view refpost mode_tree=True %}
89 {% endfor %}
90 </div>
91 {% else %}
92 <div class="refmap">
86 <div class="refmap">
93 {% trans "Replies" %}: {{ post.refmap|safe }}
87 {% trans "Replies" %}: {{ post.refmap|safe }}
94 </div>
88 </div>
95 {% endif %}
89 {% endif %}
96 {% endif %}
90 {% endif %}
97 {% comment %}
91 {% comment %}
98 Thread metadata: counters, tags etc
92 Thread metadata: counters, tags etc
99 {% endcomment %}
93 {% endcomment %}
100 {% if is_opening %}
94 {% if is_opening %}
101 <div class="metadata">
95 <div class="metadata">
102 {% if is_opening and need_open_link %}
96 {% if is_opening and need_open_link %}
103 {% 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 %},
104 {% 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 %}.
105 {% endif %}
99 {% endif %}
106 <span class="tags">
100 <span class="tags">
107 {{ thread.get_tag_url_list|safe }}
101 {{ thread.get_tag_url_list|safe }}
108 </span>
102 </span>
109 </div>
103 </div>
110 {% endif %}
104 {% endif %}
111 </div>
105 </div>
@@ -1,19 +1,19 b''
1 {% extends "boards/thread.html" %}
1 {% extends "boards/thread.html" %}
2
2
3 {% load i18n %}
3 {% load i18n %}
4 {% load static from staticfiles %}
4 {% load static from staticfiles %}
5 {% load board %}
5 {% load board %}
6 {% load tz %}
6 {% load tz %}
7
7
8 {% block thread_content %}
8 {% block thread_content %}
9 {% get_current_language as LANGUAGE_CODE %}
9 {% get_current_language as LANGUAGE_CODE %}
10 {% get_current_timezone as TIME_ZONE %}
10 {% get_current_timezone as TIME_ZONE %}
11
11
12 <div class="thread">
12 <div class="thread">
13 {% for post in thread.get_top_level_replies %}
13 {% for depth, post in thread.get_reply_tree %}
14 {% post_view post mode_tree=True %}
14 {% post_view post mode_tree=True tree_depth=depth%}
15 {% endfor %}
15 {% endfor %}
16 </div>
16 </div>
17
17
18 <script src="{% static 'js/thread.js' %}"></script>
18 <script src="{% static 'js/thread.js' %}"></script>
19 {% endblock %}
19 {% endblock %}
General Comments 0
You need to be logged in to leave comments. Login now