import re from decimal import * from markdown import markdown from django.utils.translation import gettext_lazy as _ from django.db import models, IntegrityError from django.core.serializers.json import DjangoJSONEncoder from django.db.models import Q, Count, Sum from markdownx.models import MarkdownxField from users.models import User from django.utils import timezone from django.conf import settings RE_HTML_TAG = re.compile(r"<[^>]*>") MAX_TOP_TAGS = 5 # abstract base classes ################################### class SourceSiteEnum(models.TextChoices): IN_SITE = "in-site", settings.CLIENT_NAME DOUBAN = "douban", _("豆瓣") SPOTIFY = "spotify", _("Spotify") IMDB = "imdb", _("IMDb") STEAM = "steam", _("STEAM") BANGUMI = 'bangumi', _("bangumi") GOODREADS = "goodreads", _("goodreads") TMDB = "tmdb", _("The Movie Database") GOOGLEBOOKS = "googlebooks", _("Google Books") BANDCAMP = "bandcamp", _("BandCamp") IGDB = "igdb", _("IGDB") class Entity(models.Model): rating_total_score = models.PositiveIntegerField(null=True, blank=True) rating_number = models.PositiveIntegerField(null=True, blank=True) rating = models.DecimalField( null=True, blank=True, max_digits=3, decimal_places=1) created_time = models.DateTimeField(auto_now_add=True) edited_time = models.DateTimeField(auto_now=True) last_editor = models.ForeignKey( User, on_delete=models.SET_NULL, related_name='%(class)s_last_editor', null=True, blank=False) brief = models.TextField(_("简介"), blank=True, default="") other_info = models.JSONField(_("其他信息"), blank=True, null=True, encoder=DjangoJSONEncoder, default=dict) # source_url should include shceme, which is normally https:// source_url = models.URLField(_("URL"), max_length=500, unique=True) source_site = models.CharField(_("源网站"), choices=SourceSiteEnum.choices, max_length=50) class Meta: abstract = True constraints = [ models.CheckConstraint(check=models.Q( rating__gte=0), name='%(class)s_rating_lowerbound'), models.CheckConstraint(check=models.Q( rating__lte=10), name='%(class)s_rating_upperbound'), ] def get_absolute_url(self): raise NotImplementedError("Subclass should implement this method") @property def url(self): return settings.APP_WEBSITE + self.get_absolute_url() def get_json(self): return { 'title': self.title, 'brief': self.brief, 'rating': self.rating, 'url': self.url, 'cover_url': settings.APP_WEBSITE + self.cover.url, 'top_tags': self.tags[:5], 'category_name': self.verbose_category_name, 'other_info': self.other_info, } def save(self, *args, **kwargs): """ update rating and strip source url scheme & querystring before save to db """ if self.rating_number and self.rating_total_score: self.rating = Decimal( str(round(self.rating_total_score / self.rating_number, 1))) elif self.rating_number is None and self.rating_total_score is None: self.rating = None else: raise IntegrityError() super().save(*args, **kwargs) def calculate_rating(self, old_rating, new_rating): if (not (self.rating and self.rating_total_score and self.rating_number) and (self.rating or self.rating_total_score or self.rating_number))\ or (not (self.rating or self.rating_number or self.rating_total_score) and old_rating is not None): raise IntegrityError("Rating integiry error.") if old_rating: if new_rating: # old -> new self.rating_total_score += (new_rating - old_rating) else: # old -> none if self.rating_number >= 2: self.rating_total_score -= old_rating self.rating_number -= 1 else: # only one rating record self.rating_number = None self.rating_total_score = None pass else: if new_rating: # none -> new if self.rating_number and self.rating_number >= 1: self.rating_total_score += new_rating self.rating_number += 1 else: # no rating record before self.rating_number = 1 self.rating_total_score = new_rating else: # none -> none pass def update_rating(self, old_rating, new_rating): """ @param old_rating: the old mark rating @param new_rating: the new mark rating """ self.calculate_rating(old_rating, new_rating) self.save() def refresh_rating(self): # TODO: replace update_rating() a = self.marks.filter(rating__gt=0).aggregate(Sum('rating'), Count('rating')) if self.rating_total_score != a['rating__sum'] or self.rating_number != a['rating__count']: self.rating_total_score = a['rating__sum'] self.rating_number = a['rating__count'] self.rating = a['rating__sum'] / a['rating__count'] if a['rating__count'] > 0 else None self.save() return self.rating def get_tags_manager(self): """ Since relation between tag and entity is foreign key, and related name has to be unique, this method works like interface. """ raise NotImplementedError("Subclass should implement this method.") @property def top_tags(self): return self.get_tags_manager().values('content').annotate(tag_frequency=Count('content')).order_by('-tag_frequency')[:MAX_TOP_TAGS] def get_marks_manager(self): """ Normally this won't be used. There is no ocassion where visitor can simply view all the marks. """ raise NotImplementedError("Subclass should implement this method.") def get_reviews_manager(self): """ Normally this won't be used. There is no ocassion where visitor can simply view all the reviews. """ raise NotImplementedError("Subclass should implement this method.") @property def all_tag_list(self): return self.get_tags_manager().values('content').annotate(frequency=Count('content')).order_by('-frequency') @property def tags(self): return list(map(lambda t: t['content'], self.all_tag_list)) @property def marks(self): params = {self.__class__.__name__.lower() + '_id': self.id} return self.mark_class.objects.filter(**params) @classmethod def get_category_mapping_dict(cls): category_mapping_dict = {} for subclass in cls.__subclasses__(): category_mapping_dict[subclass.__name__.lower()] = subclass return category_mapping_dict @property def category_name(self): return self.__class__.__name__ @property def verbose_category_name(self): raise NotImplementedError("Subclass should implement this.") @property def mark_class(self): raise NotImplementedError("Subclass should implement this.") @property def tag_class(self): raise NotImplementedError("Subclass should implement this.") class UserOwnedEntity(models.Model): is_private = models.BooleanField(default=False, null=True) # first set allow null, then migration, finally (in a few days) remove for good visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_%(class)ss') created_time = models.DateTimeField(default=timezone.now) edited_time = models.DateTimeField(default=timezone.now) class Meta: abstract = True def is_visible_to(self, viewer): if not viewer.is_authenticated: return self.visibility == 0 owner = self.owner if owner == viewer: return True if not owner.is_active: return False if self.visibility == 2: return False if viewer.is_blocking(owner) or owner.is_blocking(viewer) or viewer.is_muting(owner): return False if self.visibility == 1: return viewer.is_following(owner) else: return True def is_editable_by(self, viewer): return True if viewer.is_staff or viewer.is_superuser or viewer == self.owner else False @classmethod def get_available(cls, entity, request_user, following_only=False): # e.g. SongMark.get_available(song, request.user) query_kwargs = {entity.__class__.__name__.lower(): entity} all_entities = cls.objects.filter(**query_kwargs).order_by("-created_time") # get all marks for song visible_entities = list(filter(lambda _entity: _entity.is_visible_to(request_user) and (_entity.owner.mastodon_username in request_user.mastodon_following if following_only else True), all_entities)) return visible_entities @classmethod def get_available_for_identicals(cls, entity, request_user, following_only=False): # e.g. SongMark.get_available(song, request.user) query_kwargs = {entity.__class__.__name__.lower() + '__in': entity.get_identicals()} all_entities = cls.objects.filter(**query_kwargs).order_by("-created_time") # get all marks for song visible_entities = list(filter(lambda _entity: _entity.is_visible_to(request_user) and (_entity.owner.mastodon_username in request_user.mastodon_following if following_only else True), all_entities)) return visible_entities @classmethod def get_available_by_user(cls, owner, is_following): # FIXME """ Returns all avaliable owner's entities. Mute/Block relation is not handled in this method. :param owner: visited user :param is_following: if the current user is following the owner """ user_owned_entities = cls.objects.filter(owner=owner) if is_following: user_owned_entities = user_owned_entities.exclude(visibility=2) else: user_owned_entities = user_owned_entities.filter(visibility=0) return user_owned_entities @property def item(self): attr = re.findall(r'[A-Z](?:[a-z]+|[A-Z]*(?=[A-Z]|$))', self.__class__.__name__)[0].lower() return getattr(self, attr) # commonly used entity classes ################################### class MarkStatusEnum(models.TextChoices): WISH = 'wish', _('Wish') DO = 'do', _('Do') COLLECT = 'collect', _('Collect') class Mark(UserOwnedEntity): status = models.CharField(choices=MarkStatusEnum.choices, max_length=20) rating = models.PositiveSmallIntegerField(blank=True, null=True) text = models.CharField(max_length=5000, blank=True, default='') shared_link = models.CharField(max_length=5000, blank=True, default='') def __str__(self): return f"Mark({self.id} {self.owner} {self.status.upper()})" @property def translated_status(self): raise NotImplementedError("Subclass should implement this.") @property def tags(self): tags = self.item.tag_class.objects.filter(mark_id=self.id) return tags class Meta: abstract = True constraints = [ models.CheckConstraint(check=models.Q( rating__gte=0), name='mark_rating_lowerbound'), models.CheckConstraint(check=models.Q( rating__lte=10), name='mark_rating_upperbound'), ] # TODO update entity rating when save # TODO update tags class Review(UserOwnedEntity): title = models.CharField(max_length=120) content = MarkdownxField() shared_link = models.CharField(max_length=5000, blank=True, default='') def __str__(self): return self.title def get_plain_content(self): """ Get plain text format content """ html = markdown(self.content) return RE_HTML_TAG.sub(' ', html) class Meta: abstract = True @property def translated_status(self): return '评论了' class Tag(models.Model): content = models.CharField(max_length=50) def __str__(self): return self.content @property def edited_time(self): return self.mark.edited_time @property def created_time(self): return self.mark.created_time @property def text(self): return self.mark.text @classmethod def find_by_user(cls, tag, owner, viewer): qs = cls.objects.filter(content=tag, mark__owner=owner) if owner != viewer: qs = qs.filter(mark__visibility__lte=owner.get_max_visibility(viewer)) return qs @classmethod def all_by_user(cls, owner): return cls.objects.filter(mark__owner=owner).values('content').annotate(total=Count('content')).order_by('-total') class Meta: abstract = True