thread.py
325 lines
| 10.4 KiB
| text/x-python
|
PythonLexer
|
r691 | import logging | ||
|
r1616 | from datetime import timedelta | ||
|
r1760 | from django.db import models, transaction | ||
|
r1345 | from django.db.models import Count, Sum, QuerySet, Q | ||
|
r691 | from django.utils import timezone | ||
|
r957 | |||
|
r1760 | import boards | ||
from boards import settings | ||||
|
r1416 | from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE | ||
|
r1760 | from boards.models.attachment import FILE_TYPES_IMAGE | ||
|
r958 | from boards.models.post import Post | ||
|
r1891 | from boards.models.tag import Tag, DEFAULT_LOCALE, TagAlias | ||
|
r1760 | from boards.utils import cached_result, datetime_to_epoch | ||
|
r957 | |||
|
r1346 | FAV_THREAD_NO_UPDATES = -1 | ||
|
r691 | |||
__author__ = 'neko259' | ||||
logger = logging.getLogger(__name__) | ||||
|
r1088 | WS_NOTIFICATION_TYPE_NEW_POST = 'new_post' | ||
WS_NOTIFICATION_TYPE = 'notification_type' | ||||
WS_CHANNEL_THREAD = "thread:" | ||||
|
r1428 | STATUS_CHOICES = ( | ||
(STATUS_ACTIVE, STATUS_ACTIVE), | ||||
(STATUS_BUMPLIMIT, STATUS_BUMPLIMIT), | ||||
(STATUS_ARCHIVE, STATUS_ARCHIVE), | ||||
) | ||||
|
r1088 | |||
|
r715 | class ThreadManager(models.Manager): | ||
|
r1616 | def process_old_threads(self): | ||
|
r715 | """ | ||
Preserves maximum thread count. If there are too many threads, | ||||
|
r716 | archive or delete the old ones. | ||
|
r715 | """ | ||
|
r1616 | old_time_delta = settings.get_int('Messages', 'ThreadArchiveDays') | ||
old_time = timezone.now() - timedelta(days=old_time_delta) | ||||
old_ops = Post.objects.filter(opening=True, pub_time__lte=old_time).exclude(thread__status=STATUS_ARCHIVE) | ||||
|
r715 | |||
|
r1616 | for op in old_ops: | ||
thread = op.get_thread() | ||||
if settings.get_bool('Storage', 'ArchiveThreads'): | ||||
self._archive_thread(thread) | ||||
else: | ||||
thread.delete() | ||||
logger.info('Processed old thread {}'.format(thread)) | ||||
|
r715 | |||
|
r716 | def _archive_thread(self, thread): | ||
|
r1414 | thread.status = STATUS_ARCHIVE | ||
|
r716 | thread.last_edit_time = timezone.now() | ||
|
r1029 | thread.update_posts_time() | ||
|
r1414 | thread.save(update_fields=['last_edit_time', 'status']) | ||
|
r716 | |||
|
r1345 | def get_new_posts(self, datas): | ||
query = None | ||||
# TODO Use classes instead of dicts | ||||
for data in datas: | ||||
|
r1346 | if data['last_id'] != FAV_THREAD_NO_UPDATES: | ||
|
r1455 | q = (Q(id=data['op'].get_thread_id()) | ||
|
r1705 | & Q(replies__id__gt=data['last_id'])) | ||
|
r1346 | if query is None: | ||
query = q | ||||
else: | ||||
query = query | q | ||||
if query is not None: | ||||
return self.filter(query).annotate( | ||||
|
r1705 | new_post_count=Count('replies')) | ||
|
r1345 | |||
def get_new_post_count(self, datas): | ||||
|
r1347 | new_posts = self.get_new_posts(datas) | ||
|
r1705 | return new_posts.aggregate(total_count=Count('replies'))\ | ||
|
r1347 | ['total_count'] if new_posts else 0 | ||
|
r1345 | |||
|
r715 | |||
|
r1136 | def get_thread_max_posts(): | ||
|
r1153 | return settings.get_int('Messages', 'MaxPostsPerThread') | ||
|
r1136 | |||
|
r691 | class Thread(models.Model): | ||
|
r715 | objects = ThreadManager() | ||
|
r691 | |||
class Meta: | ||||
app_label = 'boards' | ||||
|
r1269 | tags = models.ManyToManyField('Tag', related_name='thread_tags') | ||
|
r966 | bump_time = models.DateTimeField(db_index=True) | ||
|
r691 | last_edit_time = models.DateTimeField() | ||
|
r1136 | max_posts = models.IntegerField(default=get_thread_max_posts) | ||
|
r1428 | status = models.CharField(max_length=50, default=STATUS_ACTIVE, | ||
|
r1667 | choices=STATUS_CHOICES, db_index=True) | ||
|
r1434 | monochrome = models.BooleanField(default=False) | ||
|
r1951 | stickerpack = models.BooleanField(default=False) | ||
|
r691 | |||
|
r1186 | def get_tags(self) -> QuerySet: | ||
|
r691 | """ | ||
Gets a sorted tag list. | ||||
""" | ||||
|
r1891 | return self.tags.filter(aliases__in=TagAlias.objects.filter_localized(parent__thread_tags=self)).order_by('aliases__name') | ||
|
r691 | |||
def bump(self): | ||||
""" | ||||
Bumps (moves to up) thread if possible. | ||||
""" | ||||
if self.can_bump(): | ||||
|
r1029 | self.bump_time = self.last_edit_time | ||
|
r691 | |||
|
r1046 | self.update_bump_status() | ||
|
r885 | |||
|
r691 | logger.info('Bumped thread %d' % self.id) | ||
|
r1083 | def has_post_limit(self) -> bool: | ||
|
r1055 | return self.max_posts > 0 | ||
|
r1134 | def update_bump_status(self, exclude_posts=None): | ||
|
r1055 | if self.has_post_limit() and self.get_reply_count() >= self.max_posts: | ||
|
r1414 | self.status = STATUS_BUMPLIMIT | ||
|
r1134 | self.update_posts_time(exclude_posts=exclude_posts) | ||
|
r1046 | |||
|
r1106 | def _get_cache_key(self): | ||
return [datetime_to_epoch(self.last_edit_time)] | ||||
@cached_result(key_method=_get_cache_key) | ||||
|
r1083 | def get_reply_count(self) -> int: | ||
|
r958 | return self.get_replies().count() | ||
|
r691 | |||
|
r1106 | @cached_result(key_method=_get_cache_key) | ||
|
r1083 | def get_images_count(self) -> int: | ||
|
r1590 | return self.get_replies().filter( | ||
attachments__mimetype__in=FILE_TYPES_IMAGE)\ | ||||
.annotate(images_count=Count( | ||||
|
r1591 | 'attachments')).aggregate(Sum('images_count'))['images_count__sum'] or 0 | ||
|
r691 | |||
|
r1960 | @cached_result(key_method=_get_cache_key) | ||
def get_attachment_count(self) -> int: | ||||
return self.get_replies().annotate(attachment_count=Count('attachments'))\ | ||||
.aggregate(Sum('attachment_count'))['attachment_count__sum'] or 0 | ||||
|
r1083 | def can_bump(self) -> bool: | ||
|
r691 | """ | ||
Checks if the thread can be bumped by replying to it. | ||||
""" | ||||
|
r1414 | return self.get_status() == STATUS_ACTIVE | ||
|
r691 | |||
|
r1186 | def get_last_replies(self) -> QuerySet: | ||
|
r691 | """ | ||
Gets several last replies, not including opening post | ||||
""" | ||||
|
r1153 | last_replies_count = settings.get_int('View', 'LastRepliesCount') | ||
if last_replies_count > 0: | ||||
|
r691 | reply_count = self.get_reply_count() | ||
if reply_count > 0: | ||||
|
r1153 | reply_count_to_show = min(last_replies_count, | ||
|
r691 | reply_count - 1) | ||
|
r694 | replies = self.get_replies() | ||
|
r987 | last_replies = replies[reply_count - reply_count_to_show:] | ||
|
r691 | |||
return last_replies | ||||
|
r1083 | def get_skipped_replies_count(self) -> int: | ||
|
r691 | """ | ||
Gets number of posts between opening post and last replies. | ||||
""" | ||||
reply_count = self.get_reply_count() | ||||
|
r1153 | last_replies_count = min(settings.get_int('View', 'LastRepliesCount'), | ||
|
r691 | reply_count - 1) | ||
return reply_count - last_replies_count - 1 | ||||
|
r1614 | # TODO Remove argument, it is not used | ||
def get_replies(self, view_fields_only=True) -> QuerySet: | ||||
|
r691 | """ | ||
Gets sorted thread posts | ||||
""" | ||||
|
r1704 | query = self.replies.order_by('pub_time').prefetch_related( | ||
'attachments') | ||||
|
r1530 | return query | ||
|
r691 | |||
|
r1614 | def get_viewable_replies(self) -> QuerySet: | ||
""" | ||||
Gets replies with only fields that are used for viewing. | ||||
""" | ||||
|
r1928 | return self.get_replies().defer('text', 'last_edit_time') | ||
|
r1614 | |||
|
r1186 | def get_top_level_replies(self) -> QuerySet: | ||
|
r1180 | return self.get_replies().exclude(refposts__threads__in=[self]) | ||
|
r1186 | def get_replies_with_images(self, view_fields_only=False) -> QuerySet: | ||
|
r950 | """ | ||
Gets replies that have at least one image attached | ||||
""" | ||||
|
r1590 | return self.get_replies(view_fields_only).filter( | ||
attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count( | ||||
'attachments')).filter(images_count__gt=0) | ||||
|
r693 | |||
|
r1083 | def get_opening_post(self, only_id=False) -> Post: | ||
|
r691 | """ | ||
Gets the first post of the thread | ||||
""" | ||||
|
r1381 | query = self.get_replies().filter(opening=True) | ||
|
r949 | if only_id: | ||
query = query.only('id') | ||||
opening_post = query.first() | ||||
return opening_post | ||||
|
r691 | |||
|
r1106 | @cached_result() | ||
|
r1083 | def get_opening_post_id(self) -> int: | ||
|
r691 | """ | ||
Gets ID of the first thread post. | ||||
""" | ||||
|
r957 | return self.get_opening_post(only_id=True).id | ||
|
r691 | |||
def get_pub_time(self): | ||||
""" | ||||
Gets opening post's pub time because thread does not have its own one. | ||||
""" | ||||
|
r949 | return self.get_opening_post().pub_time | ||
|
r718 | |||
|
r875 | def __str__(self): | ||
|
r1968 | return 'T#{}/{}'.format(self.id, self.get_opening_post()) | ||
|
r1027 | |||
|
r1083 | def get_tag_url_list(self) -> list: | ||
|
r1874 | return boards.models.Tag.objects.get_tag_url_list(self.get_tags().all()) | ||
|
r1029 | |||
|
r1134 | def update_posts_time(self, exclude_posts=None): | ||
|
r1221 | last_edit_time = self.last_edit_time | ||
|
r1708 | for post in self.replies.all(): | ||
|
r1219 | if exclude_posts is None or post not in exclude_posts: | ||
|
r1134 | # Manual update is required because uids are generated on save | ||
|
r1221 | post.last_edit_time = last_edit_time | ||
|
r1134 | post.save(update_fields=['last_edit_time']) | ||
|
r1120 | |||
|
r1149 | def get_absolute_url(self): | ||
return self.get_opening_post().get_absolute_url() | ||||
|
r1257 | |||
def get_required_tags(self): | ||||
return self.get_tags().filter(required=True) | ||||
|
r1323 | |||
|
r1739 | def get_sections_str(self): | ||
return Tag.objects.get_tag_url_list(self.get_required_tags()) | ||||
|
r1323 | def get_replies_newer(self, post_id): | ||
return self.get_replies().filter(id__gt=post_id) | ||||
|
r1344 | def is_archived(self): | ||
|
r1414 | return self.get_status() == STATUS_ARCHIVE | ||
|
r1968 | def is_bumplimit(self): | ||
return self.get_status() == STATUS_BUMPLIMIT | ||||
|
r1414 | def get_status(self): | ||
return self.status | ||||
|
r1434 | |||
def is_monochrome(self): | ||||
return self.monochrome | ||||
|
r1471 | |||
|
r1951 | def is_stickerpack(self): | ||
return self.stickerpack | ||||
|
r1471 | # If tags have parent, add them to the tag list | ||
@transaction.atomic | ||||
def refresh_tags(self): | ||||
for tag in self.get_tags().all(): | ||||
parents = tag.get_all_parents() | ||||
if len(parents) > 0: | ||||
self.tags.add(*parents) | ||||
|
r1473 | def get_reply_tree(self): | ||
replies = self.get_replies().prefetch_related('refposts') | ||||
tree = [] | ||||
for reply in replies: | ||||
parents = reply.refposts.all() | ||||
|
r1489 | |||
|
r1473 | found_parent = False | ||
|
r1489 | searching_for_index = False | ||
|
r1473 | if len(parents) > 0: | ||
index = 0 | ||||
|
r1489 | parent_depth = 0 | ||
indexes_to_insert = [] | ||||
|
r1473 | for depth, element in tree: | ||
index += 1 | ||||
|
r1489 | |||
# If this element is next after parent on the same level, | ||||
# insert child before it | ||||
if searching_for_index and depth <= parent_depth: | ||||
indexes_to_insert.append((index - 1, parent_depth)) | ||||
searching_for_index = False | ||||
|
r1473 | if element in parents: | ||
|
r1488 | found_parent = True | ||
|
r1489 | searching_for_index = True | ||
parent_depth = depth | ||||
|
r1487 | |||
|
r1473 | if not found_parent: | ||
tree.append((0, reply)) | ||||
|
r1489 | else: | ||
if searching_for_index: | ||||
tree.append((parent_depth + 1, reply)) | ||||
offset = 0 | ||||
for last_index, parent_depth in indexes_to_insert: | ||||
tree.insert(last_index + offset, (parent_depth + 1, reply)) | ||||
offset += 1 | ||||
|
r1473 | |||
return tree | ||||