diff --git a/catalog/common/models.py b/catalog/common/models.py index 15c7e801..910e7e98 100644 --- a/catalog/common/models.py +++ b/catalog/common/models.py @@ -155,8 +155,33 @@ class LookupIdDescriptor(object): # TODO make it mixin of Field # return sid[0] in IdType.values() -class Item(PolymorphicModel): - URL_PATH = None # subclass must specify this +class SoftDeleteMixin: + """ + SoftDeleteMixin + + Model must add this: + is_deleted = models.BooleanField(default=False, db_index=True) + + Model may override this: + def clear(self): + pass + """ + + def clear(self): + pass + + def delete(self, using=None, soft=True, *args, **kwargs): + print('SOFT') + if soft: + self.clear() + self.is_deleted = True + self.save(using=using) + else: + return super().delete(using=using, *args, **kwargs) + + +class Item(PolymorphicModel, SoftDeleteMixin): + url_path = None # subclass must specify this category = None # subclass must specify this uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) # item_type = models.CharField(_("类型"), choices=ItemType.choices, blank=False, max_length=50) @@ -174,23 +199,13 @@ class Item(PolymorphicModel): is_deleted = models.BooleanField(default=False, db_index=True) history = HistoricalRecords() merged_to_item = models.ForeignKey('Item', null=True, on_delete=models.SET_NULL, default=None, related_name="merged_from_items") - # parent_item = models.ForeignKey('Item', null=True, on_delete=models.SET_NULL, related_name='child_items') - # identical_item = models.ForeignKey('Item', null=True, on_delete=models.SET_NULL, related_name='identical_items') - # def get_lookup_id(self, id_type: str) -> str: - # prefix = id_type.strip().lower() + ':' - # return next((x[len(prefix):] for x in self.lookup_ids if x.startswith(prefix)), None) class Meta: unique_together = [['polymorphic_ctype_id', 'primary_lookup_id_type', 'primary_lookup_id_value']] - def delete(self, using=None, soft=True, *args, **kwargs): - if soft: - self.primary_lookup_id_value = None - self.primary_lookup_id_type = None - self.is_deleted = True - self.save(using=using) - else: - return super().delete(using=using, *args, **kwargs) + def clear(self): + self.primary_lookup_id_value = None + self.primary_lookup_id_type = None def __str__(self): return f"{self.id}{' ' + self.primary_lookup_id_type + ':' + self.primary_lookup_id_value if self.primary_lookup_id_value else ''} ({self.title})" @@ -221,13 +236,17 @@ class Item(PolymorphicModel): @property def url(self): - return f'/{self.URL_PATH}/{base62.encode(self.uid.int)}' + return f'/{self.url_path}/{base62.encode(self.uid.int)}' @classmethod def get_by_url(cls, url_or_b62): b62 = url_or_b62.split('/')[-1] return cls.objects.get(uid=uuid.UUID(int=base62.decode(b62))) + # def get_lookup_id(self, id_type: str) -> str: + # prefix = id_type.strip().lower() + ':' + # return next((x[len(prefix):] for x in self.lookup_ids if x.startswith(prefix)), None) + def update_lookup_ids(self, lookup_ids): # TODO # ll = set(lookup_ids) diff --git a/journal/models.py b/journal/models.py index a9555b83..aad41e18 100644 --- a/journal/models.py +++ b/journal/models.py @@ -1,7 +1,7 @@ from django.db import models from polymorphic.models import PolymorphicModel from users.models import User -from catalog.common.models import Item, ItemCategory +from catalog.common.models import Item, ItemCategory, SoftDeleteMixin from catalog.collection.models import Collection as CatalogCollection from decimal import * from enum import Enum @@ -15,24 +15,14 @@ from functools import cached_property from django.db.models import Count -class Piece(PolymorphicModel): +class UserOwnedObjectMixin: + """ + UserOwnedObjectMixin + + Models must add these: owner = models.ForeignKey(User, on_delete=models.PROTECT) - visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only - metadata = models.JSONField(default=dict) - created_time = models.DateTimeField(auto_now_add=True) - edited_time = models.DateTimeField(auto_now=True) - is_deleted = models.BooleanField(default=False, db_index=True) - - def clear(self): - pass - - def delete(self, using=None, soft=True, *args, **kwargs): - if soft: - self.clear() - self.is_deleted = True - self.save(using=using) - else: - return super().delete(using=using, *args, **kwargs) + visibility = models.PositiveSmallIntegerField(default=0) + """ def is_visible_to(self, viewer): if not viewer.is_authenticated: @@ -63,11 +53,21 @@ class Piece(PolymorphicModel): return visible_entities -class Content(Piece): - target: models.ForeignKey(Item, on_delete=models.PROTECT) +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.target})" + return f"{self.id}({self.item})" class Note(Content): @@ -107,7 +107,7 @@ class List(Piece): self._owner = self.owner super().save(*args, **kwargs) - MEMBER_CLASS = None # subclass must override this + # MEMBER_CLASS = None # subclass must override this # subclass must add this: # items = models.ManyToManyField(Item, through='ListMember') @@ -127,9 +127,9 @@ class List(Piece): return None else: ml = self.ordered_members - p = {self.__class__.__name__.lower(): self} + p = {'_' + self.__class__.__name__.lower(): self} p.update(params) - i = self.MEMBER_CLASS.objects.create(position=ml.last().position + 1 if ml.count() else 1, item=item, **p) + 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): @@ -162,13 +162,18 @@ class List(Piece): member.save() -class ListMember(models.Model): - # subclass must add this: - # list = models.ForeignKey('ListClass', related_name='members', on_delete=models.CASCADE) +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() - metadata = models.JSONField(default=dict) - comment = models.ForeignKey(Review, on_delete=models.PROTECT, null=True) class Meta: abstract = True @@ -202,12 +207,13 @@ QueueTypeNames = [ [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) + _queue = models.ForeignKey('Queue', related_name='members', on_delete=models.CASCADE) class Queue(List): @@ -258,12 +264,11 @@ class QueueManager: def initialize(self): for ic in ItemCategory: - if ic != ItemCategory.Collection: - for qt in QueueType: - Queue.objects.create(owner=self.owner, item_category=ic, queue_type=qt) + 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() + 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: @@ -276,7 +281,7 @@ class QueueManager: 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 + 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: @@ -301,6 +306,14 @@ class QueueManager: 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 @@ -308,7 +321,7 @@ Collection class CollectionMember(ListMember): - collection = models.ForeignKey('Collection', related_name='members', on_delete=models.CASCADE) + _collection = models.ForeignKey('Collection', related_name='members', on_delete=models.CASCADE) class Collection(List): @@ -340,7 +353,7 @@ Tag class TagMember(ListMember): - tag = models.ForeignKey('Tag', related_name='members', on_delete=models.CASCADE) + _tag = models.ForeignKey('Tag', related_name='members', on_delete=models.CASCADE) TagValidators = [RegexValidator(regex=r'\s+', inverse_match=True)] diff --git a/journal/tests.py b/journal/tests.py index b3a67a20..ffc5233f 100644 --- a/journal/tests.py +++ b/journal/tests.py @@ -32,7 +32,7 @@ class QueueTest(TestCase): user = User.objects.create(mastodon_site="site", username="name") queue_manager = QueueManager(user=user) queue_manager.initialize() - self.assertEqual(user.queue_set.all().count(), 30) + self.assertEqual(user.queue_set.all().count(), 33) book1 = Edition.objects.create(title="Hyperion") book2 = Edition.objects.create(title="Andymion") q1 = queue_manager.get_queue(ItemCategory.Book, QueueType.WISHED) diff --git a/social/__init__.py b/social/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/social/admin.py b/social/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/social/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/social/apps.py b/social/apps.py new file mode 100644 index 00000000..0567a2c3 --- /dev/null +++ b/social/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SocialConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'social' diff --git a/social/models.py b/social/models.py new file mode 100644 index 00000000..a3878883 --- /dev/null +++ b/social/models.py @@ -0,0 +1,173 @@ +""" +Models for Social app + +DataSignalManager captures create/update/(soft/hard)delete from Journal app, and generate Activity objects, +ActivityManager generates chronological view for user and, in future, ActivityStreams + +""" + +from django.db import models +from users.models import User +from catalog.common.models import Item +from journal.models import * +import logging +from functools import cached_property +from django.db.models.signals import post_save, post_delete, pre_delete +from django.db.models import Q + + +_logger = logging.getLogger(__name__) + + +class ActionType(models.TextChoices): + Create = 'create' + Delete = 'delete' + Update = 'update' + Like = 'like' + Undo_Like = 'undo_like' + Announce = 'announce' + Undo_Announce = 'undo_announce' + Follow = 'follow' + Undo_Follow = 'undo_follow' + Flag = 'flag' + Move = 'move' + Accept = 'accept' + Reject = 'reject' + Block = 'block' + Undo_Block = 'undo_block' + + +class ActivityManager: + def __init__(self, user): + self.owner = user + + def get_viewable_activities(self, before_time=None): + q = Q(owner_id__in=self.owner.following, visibility__lt=2) | Q(owner=self.owner) + if before_time: + q = q & Q(created_time__lt=before_time) + return Activity.objects.filter(q) + + @staticmethod + def get_manager_for_user(user): + return ActivityManager(user) + + +User.activity_manager = cached_property(ActivityManager.get_manager_for_user) +User.activity_manager.__set_name__(User, 'activity_manager') + + +class Activity(models.Model, UserOwnedObjectMixin): + owner = models.ForeignKey(User, on_delete=models.PROTECT) + visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only + action_type = models.CharField(blank=False, choices=ActionType.choices, max_length=50) + action_object = models.ForeignKey(Piece, on_delete=models.SET_NULL, null=True) + is_viewable = models.BooleanField(default=True) # if viewable in local time line, otherwise it's event only for s2s + # action_uid = TODO + + @property + def target(self): + return get_attself.action_object + + @property + def action_class(self): + return self.action_object.__class__.__name__ + + +class DefaultSignalProcessor(): + def __init__(self, action_object): + self.action_object = action_object + + def activity_viewable(self, action_type): + return action_type == ActionType.Create and bool(getattr(self.action_object, 'attached_to', None)) + + def created(self): + return Activity.objects.create(owner=self.action_object.owner, visibility=self.action_object.visibility, action_object=self.action_object, action_type=ActionType.Create, is_viewable=self.activity_viewable(ActionType.Create)) + + def updated(self): + create_activity = Activity.objects.filter(owner=self.action_object.owner, action_object=self.action_object, action_type=ActionType.Create).first() + action_type = ActionType.Update if create_activity else ActionType.Create + is_viewable = self.activity_viewable(action_type) + return Activity.objects.create(owner=self.action_object.owner, visibility=self.action_object.visibility, action_object=self.action_object, action_type=action_type, is_viewable=is_viewable) + + def deleted(self): + create_activity = Activity.objects.filter(owner=self.action_object.owner, action_object=self.action_object, action_type=ActionType.Create).first() + if create_activity: + create_activity.viewable = False + create_activity.save() + # FIXME action_object=self.action_object causing issues in test when hard delete, the bare minimum is to save id of the actual object that ActivityPub requires + return Activity.objects.create(owner=self.action_object.owner, visibility=self.action_object.visibility, action_object=None, action_type=ActionType.Delete, is_viewable=self.activity_viewable(ActionType.Delete)) + + +class UnhandledSignalProcessor(DefaultSignalProcessor): + def created(self): + _logger.warning(f'unhandled created signal for {self.action_object}') + + def updated(self): + _logger.warning(f'unhandled updated signal for {self.action_object}') + + def deleted(self): + _logger.warning(f'unhandled deleted signal for {self.action_object}') + + +class DataSignalManager: + processors = {} + + @staticmethod + def save_handler(sender, instance, created, **kwargs): + processor_class = DataSignalManager.processors.get(instance.__class__) + if not processor_class: + processor_class = GenericSignalProcessor + processor = processor_class(instance) + if created: + processor.created() + elif getattr(instance, 'is_deleted', False): + processor.deleted() + else: + processor.updated() + + @staticmethod + def delete_handler(sender, instance, **kwargs): + processor_class = DataSignalManager.processors.get(instance.__class__) + if not processor_class: + processor_class = GenericSignalProcessor + processor = processor_class(instance) + processor.deleted() + + @staticmethod + def add_handler_for_model(model): + post_save.connect(DataSignalManager.save_handler, sender=model) + pre_delete.connect(DataSignalManager.delete_handler, sender=model) + + @staticmethod + def register(processor): + DataSignalManager.add_handler_for_model(processor.model) + DataSignalManager.processors[processor.model] = processor + return processor + + +@DataSignalManager.register +class MarkProcessor(DefaultSignalProcessor): + model = QueueMember + + +# @DataSignalManager.register +# class ReplyProcessor(DefaultSignalProcessor): +# model = Reply + +# def activity_viewable(self): +# return False + + +# @DataSignalManager.register +# class RatingProcessor(DefaultSignalProcessor): +# model = Rating + + +@DataSignalManager.register +class ReviewProcessor(DefaultSignalProcessor): + model = Review + + +@DataSignalManager.register +class CollectionProcessor(DefaultSignalProcessor): + model = Collection diff --git a/social/tests.py b/social/tests.py new file mode 100644 index 00000000..2626fe5a --- /dev/null +++ b/social/tests.py @@ -0,0 +1,23 @@ +from django.test import TestCase +from catalog.models import * +from journal.models import * +from .models import * +from users.models import User + + +class SocialTest(TestCase): + def setUp(self): + self.book1 = Edition.objects.create(title="Hyperion") + self.book2 = Edition.objects.create(title="Andymion") + self.alice = User.objects.create(mastodon_site="MySpace", username="Alice") + self.alice.queue_manager.initialize() + self.bob = User.objects.create(mastodon_site="KKCity", username="Bob") + self.bob.queue_manager.initialize() + + def test_timeline(self): + timeline = list(self.alice.activity_manager.get_viewable_activities()) + self.assertEqual(timeline, []) + + self.alice.queue_manager.update_for_item(self.book1, QueueType.WISHED) + timeline = list(self.alice.activity_manager.get_viewable_activities()) + self.assertEqual(len(timeline), 1) diff --git a/social/views.py b/social/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/social/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.