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