396 lines
14 KiB
Python
396 lines
14 KiB
Python
from django.db import models
|
|
from polymorphic.models import PolymorphicModel
|
|
from users.models import User
|
|
from catalog.common.models import Item, ItemCategory, SoftDeleteMixin
|
|
from catalog.collection.models import Collection as CatalogCollection
|
|
from decimal import *
|
|
from enum import Enum
|
|
from markdownx.models import MarkdownxField
|
|
from django.utils import timezone
|
|
from django.conf import settings
|
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.core.validators import RegexValidator
|
|
from functools import cached_property
|
|
from django.db.models import Count
|
|
|
|
|
|
class UserOwnedObjectMixin:
|
|
"""
|
|
UserOwnedObjectMixin
|
|
|
|
Models must add these:
|
|
owner = models.ForeignKey(User, on_delete=models.PROTECT)
|
|
visibility = models.PositiveSmallIntegerField(default=0)
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
class Piece(PolymorphicModel, UserOwnedObjectMixin):
|
|
owner = models.ForeignKey(User, on_delete=models.PROTECT)
|
|
visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only
|
|
created_time = models.DateTimeField(auto_now_add=True)
|
|
edited_time = models.DateTimeField(auto_now=True)
|
|
is_deleted = models.BooleanField(default=False, db_index=True)
|
|
metadata = models.JSONField(default=dict)
|
|
attached_to = models.ForeignKey(User, null=True, default=None, on_delete=models.SET_NULL, related_name="attached_with")
|
|
|
|
|
|
class Content(SoftDeleteMixin, Piece):
|
|
item: models.ForeignKey(Item, on_delete=models.PROTECT)
|
|
|
|
def __str__(self):
|
|
return f"{self.id}({self.item})"
|
|
|
|
|
|
class Note(Content):
|
|
pass
|
|
|
|
|
|
class Review(Content):
|
|
warning = models.BooleanField(default=False)
|
|
title = models.CharField(max_length=500, blank=False, null=True)
|
|
body = MarkdownxField()
|
|
pass
|
|
|
|
|
|
class Rating(Content):
|
|
grade = models.IntegerField(default=0, validators=[MaxValueValidator(10), MinValueValidator(0)])
|
|
|
|
|
|
class Reply(Content):
|
|
reply_to_content = models.ForeignKey(Content, on_delete=models.PROTECT, related_name='replies')
|
|
title = models.CharField(max_length=500, null=True)
|
|
body = MarkdownxField()
|
|
pass
|
|
|
|
|
|
"""
|
|
List (abstract class)
|
|
"""
|
|
|
|
|
|
class List(Piece):
|
|
class Meta:
|
|
abstract = True
|
|
|
|
_owner = models.ForeignKey(User, on_delete=models.PROTECT) # duplicated owner field to make unique key possible for subclasses
|
|
|
|
def save(self, *args, **kwargs):
|
|
self._owner = self.owner
|
|
super().save(*args, **kwargs)
|
|
|
|
# MEMBER_CLASS = None # subclass must override this
|
|
# subclass must add this:
|
|
# items = models.ManyToManyField(Item, through='ListMember')
|
|
|
|
@property
|
|
def ordered_members(self):
|
|
return self.members.all().order_by('position', 'item_id')
|
|
|
|
@property
|
|
def ordered_items(self):
|
|
return self.items.all().order_by(self.MEMBER_CLASS.__name__.lower() + '__position')
|
|
|
|
def has_item(self, item):
|
|
return self.members.filter(item=item).count() > 0
|
|
|
|
def append_item(self, item, **params):
|
|
if item is None or self.has_item(item):
|
|
return None
|
|
else:
|
|
ml = self.ordered_members
|
|
p = {'_' + self.__class__.__name__.lower(): self}
|
|
p.update(params)
|
|
i = self.MEMBER_CLASS.objects.create(owner=self.owner, position=ml.last().position + 1 if ml.count() else 1, item=item, **p)
|
|
return i
|
|
|
|
def remove_item(self, item):
|
|
member = self.members.all().filter(item=item).first()
|
|
if member:
|
|
member.delete()
|
|
|
|
def move_up_item(self, item):
|
|
members = self.ordered_members
|
|
member = members.filter(item=item).first()
|
|
if member:
|
|
other = members.filter(position__lt=member.position).last()
|
|
if other:
|
|
p = other.position
|
|
other.position = member.position
|
|
member.position = p
|
|
other.save()
|
|
member.save()
|
|
|
|
def move_down_item(self, item):
|
|
members = self.ordered_members
|
|
member = members.filter(item=item).first()
|
|
if member:
|
|
other = members.filter(position__gt=member.position).first()
|
|
if other:
|
|
p = other.position
|
|
other.position = member.position
|
|
member.position = p
|
|
other.save()
|
|
member.save()
|
|
|
|
|
|
class ListMember(Piece):
|
|
"""
|
|
ListMember - List class's member class
|
|
It's an abstract class, subclass must add this:
|
|
|
|
_list = models.ForeignKey('ListClass', related_name='members', on_delete=models.CASCADE)
|
|
|
|
it starts with _ bc Django internally created OneToOne Field on Piece
|
|
https://docs.djangoproject.com/en/3.2/topics/db/models/#specifying-the-parent-link-field
|
|
"""
|
|
item = models.ForeignKey(Item, on_delete=models.PROTECT)
|
|
position = models.PositiveIntegerField()
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
"""
|
|
Queue
|
|
"""
|
|
|
|
|
|
class QueueType(models.TextChoices):
|
|
WISHED = ('wished', '未开始')
|
|
STARTED = ('started', '进行中')
|
|
DONE = ('done', '完成')
|
|
# DISCARDED = ('discarded', '放弃')
|
|
|
|
|
|
QueueTypeNames = [
|
|
[ItemCategory.Book, QueueType.WISHED, _('想读')],
|
|
[ItemCategory.Book, QueueType.STARTED, _('在读')],
|
|
[ItemCategory.Book, QueueType.DONE, _('读过')],
|
|
[ItemCategory.Movie, QueueType.WISHED, _('想看')],
|
|
[ItemCategory.Movie, QueueType.STARTED, _('在看')],
|
|
[ItemCategory.Movie, QueueType.DONE, _('看过')],
|
|
[ItemCategory.TV, QueueType.WISHED, _('想看')],
|
|
[ItemCategory.TV, QueueType.STARTED, _('在看')],
|
|
[ItemCategory.TV, QueueType.DONE, _('看过')],
|
|
[ItemCategory.Music, QueueType.WISHED, _('想听')],
|
|
[ItemCategory.Music, QueueType.STARTED, _('在听')],
|
|
[ItemCategory.Music, QueueType.DONE, _('听过')],
|
|
[ItemCategory.Game, QueueType.WISHED, _('想玩')],
|
|
[ItemCategory.Game, QueueType.STARTED, _('在玩')],
|
|
[ItemCategory.Game, QueueType.DONE, _('玩过')],
|
|
[ItemCategory.Collection, QueueType.WISHED, _('关注')],
|
|
# TODO add more combinations
|
|
]
|
|
|
|
|
|
class QueueMember(ListMember):
|
|
_queue = models.ForeignKey('Queue', related_name='members', on_delete=models.CASCADE)
|
|
|
|
|
|
class Queue(List):
|
|
class Meta:
|
|
unique_together = [['_owner', 'item_category', 'queue_type']]
|
|
|
|
MEMBER_CLASS = QueueMember
|
|
items = models.ManyToManyField(Item, through='QueueMember', related_name="+")
|
|
item_category = models.CharField(choices=ItemCategory.choices, max_length=100, null=False, blank=False)
|
|
queue_type = models.CharField(choices=QueueType.choices, max_length=100, null=False, blank=False)
|
|
|
|
def __str__(self):
|
|
return f'{self.id} {self.title}'
|
|
|
|
@cached_property
|
|
def queue_type_name(self):
|
|
return next(iter([n[2] for n in iter(QueueTypeNames) if n[0] == self.item_category and n[1] == self.queue_type]), self.queue_type)
|
|
|
|
@cached_property
|
|
def title(self):
|
|
q = _("{item_category} {queue_type_name} list").format(queue_type_name=self.queue_type_name, item_category=self.item_category)
|
|
return _("{user}'s {queue_name}").format(user=self.owner.mastodon_username, queue_name=q)
|
|
|
|
|
|
class QueueLogEntry(models.Model):
|
|
owner = models.ForeignKey(User, on_delete=models.PROTECT)
|
|
queue = models.ForeignKey(Queue, on_delete=models.PROTECT, related_name='entries', null=True) # None means removed from any queue
|
|
item = models.ForeignKey(Item, on_delete=models.PROTECT)
|
|
metadata = models.JSONField(default=dict)
|
|
created_time = models.DateTimeField(auto_now_add=True)
|
|
edited_time = models.DateTimeField(auto_now=True)
|
|
queued_time = models.DateTimeField(default=timezone.now)
|
|
|
|
def __str__(self):
|
|
return f'{self.owner}:{self.queue}:{self.item}:{self.metadata}'
|
|
|
|
|
|
class QueueManager:
|
|
"""
|
|
QueueManager
|
|
|
|
all queue operations should go thru this class so that QueueLogEntry can be properly populated
|
|
QueueLogEntry can later be modified if user wish to change history
|
|
"""
|
|
|
|
def __init__(self, user):
|
|
self.owner = user
|
|
|
|
def initialize(self):
|
|
for ic in ItemCategory:
|
|
for qt in QueueType:
|
|
Queue.objects.create(owner=self.owner, item_category=ic, queue_type=qt)
|
|
|
|
def _queue_member_for_item(self, item):
|
|
return QueueMember.objects.filter(item=item, _queue__in=self.owner.queue_set.all()).first()
|
|
|
|
def _queue_for_item_and_type(item, queue_type):
|
|
if not item or not queue_type:
|
|
return None
|
|
return self.owner.queue_set.all().filter(item_category=item.category, queue_type=queue_type)
|
|
|
|
def update_for_item(self, item, queue_type, metadata=None):
|
|
# None means no change for metadata, comment
|
|
if not item:
|
|
raise ValueError('empty item')
|
|
lastqm = self._queue_member_for_item(item)
|
|
lastqmm = lastqm.metadata if lastqm else None
|
|
lastq = lastqm._queue if lastqm else None
|
|
lastqt = lastq.queue_type if lastq else None
|
|
queue = self.get_queue(item.category, queue_type) if queue_type else None
|
|
if lastq != queue:
|
|
if lastq:
|
|
lastq.remove_item(item)
|
|
if queue:
|
|
queue.append_item(item, metadata=metadata or {})
|
|
elif metadata is not None:
|
|
lastqm.metadata = metadata
|
|
lastqm.save()
|
|
elif lastqm:
|
|
metadata = lastqm.metadata
|
|
if lastqt != queue_type or (lastqt and metadata != lastqmm):
|
|
QueueLogEntry.objects.create(owner=self.owner, queue=queue, item=item, metadata=metadata or {})
|
|
|
|
def get_log(self):
|
|
return QueueLogEntry.objects.filter(owner=self.owner)
|
|
|
|
def get_log_for_item(self, item):
|
|
return QueueLogEntry.objects.filter(owner=self.owner, item=item)
|
|
|
|
def get_queue(self, item_category, queue_type):
|
|
return self.owner.queue_set.all().filter(item_category=item_category, queue_type=queue_type).first()
|
|
|
|
@staticmethod
|
|
def get_manager_for_user(user):
|
|
return QueueManager(user)
|
|
|
|
|
|
User.queue_manager = cached_property(QueueManager.get_manager_for_user)
|
|
User.queue_manager.__set_name__(User, 'queue_manager')
|
|
|
|
|
|
"""
|
|
Collection
|
|
"""
|
|
|
|
|
|
class CollectionMember(ListMember):
|
|
_collection = models.ForeignKey('Collection', related_name='members', on_delete=models.CASCADE)
|
|
|
|
|
|
class Collection(List):
|
|
MEMBER_CLASS = CollectionMember
|
|
catalog_item = models.OneToOneField(CatalogCollection, on_delete=models.PROTECT)
|
|
title = models.CharField(_("title in primary language"), max_length=1000, default="")
|
|
brief = models.TextField(_("简介"), blank=True, default="")
|
|
items = models.ManyToManyField(Item, through='CollectionMember', related_name="collections")
|
|
collaborative = models.PositiveSmallIntegerField(default=0) # 0: Editable by owner only / 1: Editable by bi-direction followers
|
|
|
|
@property
|
|
def plain_description(self):
|
|
html = markdown(self.description)
|
|
return RE_HTML_TAG.sub(' ', html)
|
|
|
|
def save(self, *args, **kwargs):
|
|
if getattr(self, 'catalog_item', None) is None:
|
|
self.catalog_item = CatalogCollection()
|
|
if self.catalog_item.title != self.title or self.catalog_item.brief != self.brief:
|
|
self.catalog_item.title = self.title
|
|
self.catalog_item.brief = self.brief
|
|
self.catalog_item.save()
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
"""
|
|
Tag
|
|
"""
|
|
|
|
|
|
class TagMember(ListMember):
|
|
_tag = models.ForeignKey('Tag', related_name='members', on_delete=models.CASCADE)
|
|
|
|
|
|
TagValidators = [RegexValidator(regex=r'\s+', inverse_match=True)]
|
|
|
|
|
|
class Tag(List):
|
|
MEMBER_CLASS = TagMember
|
|
items = models.ManyToManyField(Item, through='TagMember')
|
|
title = models.CharField(max_length=100, null=False, blank=False, validators=TagValidators)
|
|
# TODO case convert and space removal on save
|
|
# TODO check on save
|
|
|
|
class Meta:
|
|
unique_together = [['_owner', 'title']]
|
|
|
|
@staticmethod
|
|
def cleanup_title(title):
|
|
return title.strip().lower()
|
|
|
|
@staticmethod
|
|
def public_tags_for_item(item):
|
|
tags = item.tag_set.all().filter(visibility=0).values('title').annotate(frequency=Count('owner')).order_by('-frequency')
|
|
return list(map(lambda t: t['title'], tags))
|
|
|
|
@staticmethod
|
|
def all_tags_for_user(user):
|
|
tags = user.tag_set.all().values('title').annotate(frequency=Count('members')).order_by('-frequency')
|
|
return list(map(lambda t: t['title'], tags))
|
|
|
|
@staticmethod
|
|
def add_tag_by_user(item, tag_title, user, default_visibility=0):
|
|
title = Tag.cleanup_title(tag_title)
|
|
tag = Tag.objects.filter(owner=user, title=title).first()
|
|
if not tag:
|
|
tag = Tag.objects.create(owner=user, title=title, visibility=default_visibility)
|
|
tag.append_item(item)
|
|
|
|
|
|
Item.tags = property(Tag.public_tags_for_item)
|
|
User.tags = property(Tag.all_tags_for_user)
|