##// END OF EJS Templates
Update tag threads' tags when the tag's parent is changed
neko259 -
r1471:cdc57c47 default
parent child Browse files
Show More
@@ -1,84 +1,85 b''
1 from django.contrib import admin
1 from django.contrib import admin
2 from boards.models import Post, Tag, Ban, Thread, Banner
2 from boards.models import Post, Tag, Ban, Thread, Banner
3 from django.utils.translation import ugettext_lazy as _
3 from django.utils.translation import ugettext_lazy as _
4
4
5
5
6 @admin.register(Post)
6 @admin.register(Post)
7 class PostAdmin(admin.ModelAdmin):
7 class PostAdmin(admin.ModelAdmin):
8
8
9 list_display = ('id', 'title', 'text', 'poster_ip')
9 list_display = ('id', 'title', 'text', 'poster_ip')
10 list_filter = ('pub_time',)
10 list_filter = ('pub_time',)
11 search_fields = ('id', 'title', 'text', 'poster_ip')
11 search_fields = ('id', 'title', 'text', 'poster_ip')
12 exclude = ('referenced_posts', 'refmap')
12 exclude = ('referenced_posts', 'refmap')
13 readonly_fields = ('poster_ip', 'threads', 'thread', 'images',
13 readonly_fields = ('poster_ip', 'threads', 'thread', 'images',
14 'attachments', 'uid', 'url', 'pub_time', 'opening')
14 'attachments', 'uid', 'url', 'pub_time', 'opening')
15
15
16 def ban_poster(self, request, queryset):
16 def ban_poster(self, request, queryset):
17 bans = 0
17 bans = 0
18 for post in queryset:
18 for post in queryset:
19 poster_ip = post.poster_ip
19 poster_ip = post.poster_ip
20 ban, created = Ban.objects.get_or_create(ip=poster_ip)
20 ban, created = Ban.objects.get_or_create(ip=poster_ip)
21 if created:
21 if created:
22 bans += 1
22 bans += 1
23 self.message_user(request, _('{} posters were banned').format(bans))
23 self.message_user(request, _('{} posters were banned').format(bans))
24
24
25 actions = ['ban_poster']
25 actions = ['ban_poster']
26
26
27
27
28 @admin.register(Tag)
28 @admin.register(Tag)
29 class TagAdmin(admin.ModelAdmin):
29 class TagAdmin(admin.ModelAdmin):
30
30
31 def thread_count(self, obj: Tag) -> int:
31 def thread_count(self, obj: Tag) -> int:
32 return obj.get_thread_count()
32 return obj.get_thread_count()
33
33
34 def display_children(self, obj: Tag):
34 def display_children(self, obj: Tag):
35 return ', '.join([str(child) for child in obj.get_children().all()])
35 return ', '.join([str(child) for child in obj.get_children().all()])
36
36
37 def save_model(self, request, obj, form, change):
38 super().save_model(request, obj, form, change)
39 for thread in obj.get_threads().all():
40 thread.refresh_tags()
41
37 list_display = ('name', 'thread_count', 'display_children')
42 list_display = ('name', 'thread_count', 'display_children')
38 search_fields = ('name',)
43 search_fields = ('name',)
39
44
40
45
41 @admin.register(Thread)
46 @admin.register(Thread)
42 class ThreadAdmin(admin.ModelAdmin):
47 class ThreadAdmin(admin.ModelAdmin):
43
48
44 def title(self, obj: Thread) -> str:
49 def title(self, obj: Thread) -> str:
45 return obj.get_opening_post().get_title()
50 return obj.get_opening_post().get_title()
46
51
47 def reply_count(self, obj: Thread) -> int:
52 def reply_count(self, obj: Thread) -> int:
48 return obj.get_reply_count()
53 return obj.get_reply_count()
49
54
50 def ip(self, obj: Thread):
55 def ip(self, obj: Thread):
51 return obj.get_opening_post().poster_ip
56 return obj.get_opening_post().poster_ip
52
57
53 def display_tags(self, obj: Thread):
58 def display_tags(self, obj: Thread):
54 return ', '.join([str(tag) for tag in obj.get_tags().all()])
59 return ', '.join([str(tag) for tag in obj.get_tags().all()])
55
60
56 def op(self, obj: Thread):
61 def op(self, obj: Thread):
57 return obj.get_opening_post_id()
62 return obj.get_opening_post_id()
58
63
59 # Save parent tags when editing tags
64 # Save parent tags when editing tags
60 def save_related(self, request, form, formsets, change):
65 def save_related(self, request, form, formsets, change):
61 super().save_related(request, form, formsets, change)
66 super().save_related(request, form, formsets, change)
62 obj = form.instance
67 form.instance.refresh_tags()
63 for tag in obj.get_tags().all():
64 parents = tag.get_all_parents()
65 if len(parents) > 0:
66 obj.tags.add(*parents)
67
68
68 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
69 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
69 'display_tags')
70 'display_tags')
70 list_filter = ('bump_time', 'status')
71 list_filter = ('bump_time', 'status')
71 search_fields = ('id', 'title')
72 search_fields = ('id', 'title')
72 filter_horizontal = ('tags',)
73 filter_horizontal = ('tags',)
73
74
74
75
75 @admin.register(Ban)
76 @admin.register(Ban)
76 class BanAdmin(admin.ModelAdmin):
77 class BanAdmin(admin.ModelAdmin):
77 list_display = ('ip', 'can_read')
78 list_display = ('ip', 'can_read')
78 list_filter = ('can_read',)
79 list_filter = ('can_read',)
79 search_fields = ('ip',)
80 search_fields = ('ip',)
80
81
81
82
82 @admin.register(Banner)
83 @admin.register(Banner)
83 class BannerAdmin(admin.ModelAdmin):
84 class BannerAdmin(admin.ModelAdmin):
84 list_display = ('title', 'text')
85 list_display = ('title', 'text')
@@ -1,272 +1,281 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
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
274 # If tags have parent, add them to the tag list
275 @transaction.atomic
276 def refresh_tags(self):
277 for tag in self.get_tags().all():
278 parents = tag.get_all_parents()
279 if len(parents) > 0:
280 self.tags.add(*parents)
281
General Comments 0
You need to be logged in to leave comments. Login now