lib.itmens/journal/models.py
2022-12-13 06:44:29 +00:00

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)