##// END OF EJS Templates
Show threads' sections in the landing page
neko259 -
r1739:a7aa222d default
parent child Browse files
Show More
@@ -1,325 +1,328 b''
1 import logging
1 import logging
2 from adjacent import Client
2 from adjacent import Client
3 from datetime import timedelta
3 from datetime import timedelta
4
4
5
5
6 from django.db.models import Count, Sum, QuerySet, Q
6 from django.db.models import Count, Sum, QuerySet, Q
7 from django.utils import timezone
7 from django.utils import timezone
8 from django.db import models, transaction
8 from django.db import models, transaction
9
9
10 from boards.models.attachment import FILE_TYPES_IMAGE
10 from boards.models.attachment import FILE_TYPES_IMAGE
11 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
11 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
12
12
13 from boards import settings
13 from boards import settings
14 import boards
14 import boards
15 from boards.utils import cached_result, datetime_to_epoch
15 from boards.utils import cached_result, datetime_to_epoch
16 from boards.models.post import Post
16 from boards.models.post import Post
17 from boards.models.tag import Tag
17 from boards.models.tag import Tag
18
18
19 FAV_THREAD_NO_UPDATES = -1
19 FAV_THREAD_NO_UPDATES = -1
20
20
21
21
22 __author__ = 'neko259'
22 __author__ = 'neko259'
23
23
24
24
25 logger = logging.getLogger(__name__)
25 logger = logging.getLogger(__name__)
26
26
27
27
28 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
28 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
29 WS_NOTIFICATION_TYPE = 'notification_type'
29 WS_NOTIFICATION_TYPE = 'notification_type'
30
30
31 WS_CHANNEL_THREAD = "thread:"
31 WS_CHANNEL_THREAD = "thread:"
32
32
33 STATUS_CHOICES = (
33 STATUS_CHOICES = (
34 (STATUS_ACTIVE, STATUS_ACTIVE),
34 (STATUS_ACTIVE, STATUS_ACTIVE),
35 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
35 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
36 (STATUS_ARCHIVE, STATUS_ARCHIVE),
36 (STATUS_ARCHIVE, STATUS_ARCHIVE),
37 )
37 )
38
38
39
39
40 class ThreadManager(models.Manager):
40 class ThreadManager(models.Manager):
41 def process_old_threads(self):
41 def process_old_threads(self):
42 """
42 """
43 Preserves maximum thread count. If there are too many threads,
43 Preserves maximum thread count. If there are too many threads,
44 archive or delete the old ones.
44 archive or delete the old ones.
45 """
45 """
46 old_time_delta = settings.get_int('Messages', 'ThreadArchiveDays')
46 old_time_delta = settings.get_int('Messages', 'ThreadArchiveDays')
47 old_time = timezone.now() - timedelta(days=old_time_delta)
47 old_time = timezone.now() - timedelta(days=old_time_delta)
48 old_ops = Post.objects.filter(opening=True, pub_time__lte=old_time).exclude(thread__status=STATUS_ARCHIVE)
48 old_ops = Post.objects.filter(opening=True, pub_time__lte=old_time).exclude(thread__status=STATUS_ARCHIVE)
49
49
50 for op in old_ops:
50 for op in old_ops:
51 thread = op.get_thread()
51 thread = op.get_thread()
52 if settings.get_bool('Storage', 'ArchiveThreads'):
52 if settings.get_bool('Storage', 'ArchiveThreads'):
53 self._archive_thread(thread)
53 self._archive_thread(thread)
54 else:
54 else:
55 thread.delete()
55 thread.delete()
56 logger.info('Processed old thread {}'.format(thread))
56 logger.info('Processed old thread {}'.format(thread))
57
57
58
58
59 def _archive_thread(self, thread):
59 def _archive_thread(self, thread):
60 thread.status = STATUS_ARCHIVE
60 thread.status = STATUS_ARCHIVE
61 thread.last_edit_time = timezone.now()
61 thread.last_edit_time = timezone.now()
62 thread.update_posts_time()
62 thread.update_posts_time()
63 thread.save(update_fields=['last_edit_time', 'status'])
63 thread.save(update_fields=['last_edit_time', 'status'])
64
64
65 def get_new_posts(self, datas):
65 def get_new_posts(self, datas):
66 query = None
66 query = None
67 # TODO Use classes instead of dicts
67 # TODO Use classes instead of dicts
68 for data in datas:
68 for data in datas:
69 if data['last_id'] != FAV_THREAD_NO_UPDATES:
69 if data['last_id'] != FAV_THREAD_NO_UPDATES:
70 q = (Q(id=data['op'].get_thread_id())
70 q = (Q(id=data['op'].get_thread_id())
71 & Q(replies__id__gt=data['last_id']))
71 & Q(replies__id__gt=data['last_id']))
72 if query is None:
72 if query is None:
73 query = q
73 query = q
74 else:
74 else:
75 query = query | q
75 query = query | q
76 if query is not None:
76 if query is not None:
77 return self.filter(query).annotate(
77 return self.filter(query).annotate(
78 new_post_count=Count('replies'))
78 new_post_count=Count('replies'))
79
79
80 def get_new_post_count(self, datas):
80 def get_new_post_count(self, datas):
81 new_posts = self.get_new_posts(datas)
81 new_posts = self.get_new_posts(datas)
82 return new_posts.aggregate(total_count=Count('replies'))\
82 return new_posts.aggregate(total_count=Count('replies'))\
83 ['total_count'] if new_posts else 0
83 ['total_count'] if new_posts else 0
84
84
85
85
86 def get_thread_max_posts():
86 def get_thread_max_posts():
87 return settings.get_int('Messages', 'MaxPostsPerThread')
87 return settings.get_int('Messages', 'MaxPostsPerThread')
88
88
89
89
90 class Thread(models.Model):
90 class Thread(models.Model):
91 objects = ThreadManager()
91 objects = ThreadManager()
92
92
93 class Meta:
93 class Meta:
94 app_label = 'boards'
94 app_label = 'boards'
95
95
96 tags = models.ManyToManyField('Tag', related_name='thread_tags')
96 tags = models.ManyToManyField('Tag', related_name='thread_tags')
97 bump_time = models.DateTimeField(db_index=True)
97 bump_time = models.DateTimeField(db_index=True)
98 last_edit_time = models.DateTimeField()
98 last_edit_time = models.DateTimeField()
99 max_posts = models.IntegerField(default=get_thread_max_posts)
99 max_posts = models.IntegerField(default=get_thread_max_posts)
100 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
100 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
101 choices=STATUS_CHOICES, db_index=True)
101 choices=STATUS_CHOICES, db_index=True)
102 monochrome = models.BooleanField(default=False)
102 monochrome = models.BooleanField(default=False)
103
103
104 def get_tags(self) -> QuerySet:
104 def get_tags(self) -> QuerySet:
105 """
105 """
106 Gets a sorted tag list.
106 Gets a sorted tag list.
107 """
107 """
108
108
109 return self.tags.order_by('name')
109 return self.tags.order_by('name')
110
110
111 def bump(self):
111 def bump(self):
112 """
112 """
113 Bumps (moves to up) thread if possible.
113 Bumps (moves to up) thread if possible.
114 """
114 """
115
115
116 if self.can_bump():
116 if self.can_bump():
117 self.bump_time = self.last_edit_time
117 self.bump_time = self.last_edit_time
118
118
119 self.update_bump_status()
119 self.update_bump_status()
120
120
121 logger.info('Bumped thread %d' % self.id)
121 logger.info('Bumped thread %d' % self.id)
122
122
123 def has_post_limit(self) -> bool:
123 def has_post_limit(self) -> bool:
124 return self.max_posts > 0
124 return self.max_posts > 0
125
125
126 def update_bump_status(self, exclude_posts=None):
126 def update_bump_status(self, exclude_posts=None):
127 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
127 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
128 self.status = STATUS_BUMPLIMIT
128 self.status = STATUS_BUMPLIMIT
129 self.update_posts_time(exclude_posts=exclude_posts)
129 self.update_posts_time(exclude_posts=exclude_posts)
130
130
131 def _get_cache_key(self):
131 def _get_cache_key(self):
132 return [datetime_to_epoch(self.last_edit_time)]
132 return [datetime_to_epoch(self.last_edit_time)]
133
133
134 @cached_result(key_method=_get_cache_key)
134 @cached_result(key_method=_get_cache_key)
135 def get_reply_count(self) -> int:
135 def get_reply_count(self) -> int:
136 return self.get_replies().count()
136 return self.get_replies().count()
137
137
138 @cached_result(key_method=_get_cache_key)
138 @cached_result(key_method=_get_cache_key)
139 def get_images_count(self) -> int:
139 def get_images_count(self) -> int:
140 return self.get_replies().filter(
140 return self.get_replies().filter(
141 attachments__mimetype__in=FILE_TYPES_IMAGE)\
141 attachments__mimetype__in=FILE_TYPES_IMAGE)\
142 .annotate(images_count=Count(
142 .annotate(images_count=Count(
143 'attachments')).aggregate(Sum('images_count'))['images_count__sum'] or 0
143 'attachments')).aggregate(Sum('images_count'))['images_count__sum'] or 0
144
144
145 def can_bump(self) -> bool:
145 def can_bump(self) -> bool:
146 """
146 """
147 Checks if the thread can be bumped by replying to it.
147 Checks if the thread can be bumped by replying to it.
148 """
148 """
149
149
150 return self.get_status() == STATUS_ACTIVE
150 return self.get_status() == STATUS_ACTIVE
151
151
152 def get_last_replies(self) -> QuerySet:
152 def get_last_replies(self) -> QuerySet:
153 """
153 """
154 Gets several last replies, not including opening post
154 Gets several last replies, not including opening post
155 """
155 """
156
156
157 last_replies_count = settings.get_int('View', 'LastRepliesCount')
157 last_replies_count = settings.get_int('View', 'LastRepliesCount')
158
158
159 if last_replies_count > 0:
159 if last_replies_count > 0:
160 reply_count = self.get_reply_count()
160 reply_count = self.get_reply_count()
161
161
162 if reply_count > 0:
162 if reply_count > 0:
163 reply_count_to_show = min(last_replies_count,
163 reply_count_to_show = min(last_replies_count,
164 reply_count - 1)
164 reply_count - 1)
165 replies = self.get_replies()
165 replies = self.get_replies()
166 last_replies = replies[reply_count - reply_count_to_show:]
166 last_replies = replies[reply_count - reply_count_to_show:]
167
167
168 return last_replies
168 return last_replies
169
169
170 def get_skipped_replies_count(self) -> int:
170 def get_skipped_replies_count(self) -> int:
171 """
171 """
172 Gets number of posts between opening post and last replies.
172 Gets number of posts between opening post and last replies.
173 """
173 """
174 reply_count = self.get_reply_count()
174 reply_count = self.get_reply_count()
175 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
175 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
176 reply_count - 1)
176 reply_count - 1)
177 return reply_count - last_replies_count - 1
177 return reply_count - last_replies_count - 1
178
178
179 # TODO Remove argument, it is not used
179 # TODO Remove argument, it is not used
180 def get_replies(self, view_fields_only=True) -> QuerySet:
180 def get_replies(self, view_fields_only=True) -> QuerySet:
181 """
181 """
182 Gets sorted thread posts
182 Gets sorted thread posts
183 """
183 """
184 query = self.replies.order_by('pub_time').prefetch_related(
184 query = self.replies.order_by('pub_time').prefetch_related(
185 'attachments')
185 'attachments')
186 return query
186 return query
187
187
188 def get_viewable_replies(self) -> QuerySet:
188 def get_viewable_replies(self) -> QuerySet:
189 """
189 """
190 Gets replies with only fields that are used for viewing.
190 Gets replies with only fields that are used for viewing.
191 """
191 """
192 return self.get_replies().defer('text', 'last_edit_time', 'version')
192 return self.get_replies().defer('text', 'last_edit_time', 'version')
193
193
194 def get_top_level_replies(self) -> QuerySet:
194 def get_top_level_replies(self) -> QuerySet:
195 return self.get_replies().exclude(refposts__threads__in=[self])
195 return self.get_replies().exclude(refposts__threads__in=[self])
196
196
197 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
197 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
198 """
198 """
199 Gets replies that have at least one image attached
199 Gets replies that have at least one image attached
200 """
200 """
201 return self.get_replies(view_fields_only).filter(
201 return self.get_replies(view_fields_only).filter(
202 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
202 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
203 'attachments')).filter(images_count__gt=0)
203 'attachments')).filter(images_count__gt=0)
204
204
205 def get_opening_post(self, only_id=False) -> Post:
205 def get_opening_post(self, only_id=False) -> Post:
206 """
206 """
207 Gets the first post of the thread
207 Gets the first post of the thread
208 """
208 """
209
209
210 query = self.get_replies().filter(opening=True)
210 query = self.get_replies().filter(opening=True)
211 if only_id:
211 if only_id:
212 query = query.only('id')
212 query = query.only('id')
213 opening_post = query.first()
213 opening_post = query.first()
214
214
215 return opening_post
215 return opening_post
216
216
217 @cached_result()
217 @cached_result()
218 def get_opening_post_id(self) -> int:
218 def get_opening_post_id(self) -> int:
219 """
219 """
220 Gets ID of the first thread post.
220 Gets ID of the first thread post.
221 """
221 """
222
222
223 return self.get_opening_post(only_id=True).id
223 return self.get_opening_post(only_id=True).id
224
224
225 def get_pub_time(self):
225 def get_pub_time(self):
226 """
226 """
227 Gets opening post's pub time because thread does not have its own one.
227 Gets opening post's pub time because thread does not have its own one.
228 """
228 """
229
229
230 return self.get_opening_post().pub_time
230 return self.get_opening_post().pub_time
231
231
232 def __str__(self):
232 def __str__(self):
233 return 'T#{}'.format(self.id)
233 return 'T#{}'.format(self.id)
234
234
235 def get_tag_url_list(self) -> list:
235 def get_tag_url_list(self) -> list:
236 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
236 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
237
237
238 def update_posts_time(self, exclude_posts=None):
238 def update_posts_time(self, exclude_posts=None):
239 last_edit_time = self.last_edit_time
239 last_edit_time = self.last_edit_time
240
240
241 for post in self.replies.all():
241 for post in self.replies.all():
242 if exclude_posts is None or post not in exclude_posts:
242 if exclude_posts is None or post not in exclude_posts:
243 # Manual update is required because uids are generated on save
243 # Manual update is required because uids are generated on save
244 post.last_edit_time = last_edit_time
244 post.last_edit_time = last_edit_time
245 post.save(update_fields=['last_edit_time'])
245 post.save(update_fields=['last_edit_time'])
246
246
247 def notify_clients(self):
247 def notify_clients(self):
248 if not settings.get_bool('External', 'WebsocketsEnabled'):
248 if not settings.get_bool('External', 'WebsocketsEnabled'):
249 return
249 return
250
250
251 client = Client()
251 client = Client()
252
252
253 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
253 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
254 client.publish(channel_name, {
254 client.publish(channel_name, {
255 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
255 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
256 })
256 })
257 client.send()
257 client.send()
258
258
259 def get_absolute_url(self):
259 def get_absolute_url(self):
260 return self.get_opening_post().get_absolute_url()
260 return self.get_opening_post().get_absolute_url()
261
261
262 def get_required_tags(self):
262 def get_required_tags(self):
263 return self.get_tags().filter(required=True)
263 return self.get_tags().filter(required=True)
264
264
265 def get_sections_str(self):
266 return Tag.objects.get_tag_url_list(self.get_required_tags())
267
265 def get_replies_newer(self, post_id):
268 def get_replies_newer(self, post_id):
266 return self.get_replies().filter(id__gt=post_id)
269 return self.get_replies().filter(id__gt=post_id)
267
270
268 def is_archived(self):
271 def is_archived(self):
269 return self.get_status() == STATUS_ARCHIVE
272 return self.get_status() == STATUS_ARCHIVE
270
273
271 def get_status(self):
274 def get_status(self):
272 return self.status
275 return self.status
273
276
274 def is_monochrome(self):
277 def is_monochrome(self):
275 return self.monochrome
278 return self.monochrome
276
279
277 # If tags have parent, add them to the tag list
280 # If tags have parent, add them to the tag list
278 @transaction.atomic
281 @transaction.atomic
279 def refresh_tags(self):
282 def refresh_tags(self):
280 for tag in self.get_tags().all():
283 for tag in self.get_tags().all():
281 parents = tag.get_all_parents()
284 parents = tag.get_all_parents()
282 if len(parents) > 0:
285 if len(parents) > 0:
283 self.tags.add(*parents)
286 self.tags.add(*parents)
284
287
285 def get_reply_tree(self):
288 def get_reply_tree(self):
286 replies = self.get_replies().prefetch_related('refposts')
289 replies = self.get_replies().prefetch_related('refposts')
287 tree = []
290 tree = []
288 for reply in replies:
291 for reply in replies:
289 parents = reply.refposts.all()
292 parents = reply.refposts.all()
290
293
291 found_parent = False
294 found_parent = False
292 searching_for_index = False
295 searching_for_index = False
293
296
294 if len(parents) > 0:
297 if len(parents) > 0:
295 index = 0
298 index = 0
296 parent_depth = 0
299 parent_depth = 0
297
300
298 indexes_to_insert = []
301 indexes_to_insert = []
299
302
300 for depth, element in tree:
303 for depth, element in tree:
301 index += 1
304 index += 1
302
305
303 # If this element is next after parent on the same level,
306 # If this element is next after parent on the same level,
304 # insert child before it
307 # insert child before it
305 if searching_for_index and depth <= parent_depth:
308 if searching_for_index and depth <= parent_depth:
306 indexes_to_insert.append((index - 1, parent_depth))
309 indexes_to_insert.append((index - 1, parent_depth))
307 searching_for_index = False
310 searching_for_index = False
308
311
309 if element in parents:
312 if element in parents:
310 found_parent = True
313 found_parent = True
311 searching_for_index = True
314 searching_for_index = True
312 parent_depth = depth
315 parent_depth = depth
313
316
314 if not found_parent:
317 if not found_parent:
315 tree.append((0, reply))
318 tree.append((0, reply))
316 else:
319 else:
317 if searching_for_index:
320 if searching_for_index:
318 tree.append((parent_depth + 1, reply))
321 tree.append((parent_depth + 1, reply))
319
322
320 offset = 0
323 offset = 0
321 for last_index, parent_depth in indexes_to_insert:
324 for last_index, parent_depth in indexes_to_insert:
322 tree.insert(last_index + offset, (parent_depth + 1, reply))
325 tree.insert(last_index + offset, (parent_depth + 1, reply))
323 offset += 1
326 offset += 1
324
327
325 return tree
328 return tree
@@ -1,31 +1,33 b''
1 {% extends "boards/base.html" %}
1 {% extends "boards/base.html" %}
2
2
3 {% load i18n %}
3 {% load i18n %}
4 {% load static %}
4 {% load static %}
5
5
6 {% block head %}
6 {% block head %}
7 <title>{{ site_name }}</title>
7 <title>{{ site_name }}</title>
8 {% endblock %}
8 {% endblock %}
9
9
10 {% block content %}
10 {% block content %}
11 <div class="post">
11 <div class="post">
12 <div class="landing-images">
12 <div class="landing-images">
13 {% for image in images %}
13 {% for image in images %}
14 <div class="gallery_image">
14 <div class="gallery_image">
15 {{ image.get_view|safe }}
15 {{ image.get_view|safe }}
16 {% with image.get_random_associated_post as post %}
16 {% with image.get_random_associated_post as post %}
17 {{ post.get_link_view|safe }}
17 {{ post.get_link_view|safe }}
18 {% endwith %}
18 {% endwith %}
19 </div>
19 </div>
20 {% endfor %}
20 {% endfor %}
21 </div>
21 </div>
22 <br />
22 <div class="landing-tags">
23 <div class="landing-tags">
23 {{ section_str|safe }}
24 {{ section_str|safe }}
24 </div>
25 </div>
26 <br />
25 <div class="landing-threads">
27 <div class="landing-threads">
26 {% for op in latest_threads %}
28 {% for op in latest_threads %}
27 {{ op.get_link_view|safe }} {{ op.get_title_or_text }}<br />
29 {{ op.get_link_view|safe }} {{ op.get_title_or_text }} ({{ op.thread.get_sections_str|safe }})<br />
28 {% endfor %}
30 {% endfor %}
29 </div>
31 </div>
30 </div>
32 </div>
31 {% endblock %}
33 {% endblock %}
General Comments 0
You need to be logged in to leave comments. Login now