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