From 7ee5c7d587102cdeae3a456dddb146f8e35c20cd Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 20 Dec 2022 11:32:44 -0500 Subject: [PATCH] new data model: collection mark; local timeline --- doc/social.md | 15 +- journal/models.py | 8 +- legacy/management/commands/migrate_journal.py | 18 +- social/models.py | 184 +++++++----------- social/tests.py | 16 +- 5 files changed, 112 insertions(+), 129 deletions(-) diff --git a/doc/social.md b/doc/social.md index 1b31d102..8784aeec 100644 --- a/doc/social.md +++ b/doc/social.md @@ -15,10 +15,7 @@ User .. Activity class Activity { +User owner +int visibility - +Enum action_type +Piece action_object - +Item target - +Bool is_viewable } Activity .. Piece Activity .. Item @@ -38,6 +35,18 @@ Activity data may be used for: However, 2 is currently implemented separately via `ShelfLogManager` in `journal` app, because users may want to change these records manually. +Local Timeline +-------------- +| Local Timeline Activities | action object class | +| ------------------------- | ------------------- | +| Add an Item to Shelf | ShelfMember | +| Create a Collection | Collection | +| Like a Collection | Like | +| Create a Review | Review | + + +Activity Streams +---------------- These are list of activities should be either shown in the site or delivered as ActivityStreams or both: - `Add` / `Remove` an *Item* to / from a *List*: diff --git a/journal/models.py b/journal/models.py index 9c348b38..04dc422c 100644 --- a/journal/models.py +++ b/journal/models.py @@ -39,6 +39,10 @@ class Content(Piece): abstract = True +class Like(Piece): + target = models.ForeignKey(Piece, on_delete=models.CASCADE, related_name='likes') + + class Note(Content): pass @@ -127,8 +131,8 @@ Item.rating = property(Rating.get_rating_for_item) Item.rating_count = property(Rating.get_rating_count_for_item) -class Reply(Content): - reply_to_content = models.ForeignKey(Piece, on_delete=models.PROTECT, related_name='replies') +class Reply(Piece): + reply_to_content = models.ForeignKey(Piece, on_delete=models.SET_NULL, related_name='replies', null=True) title = models.CharField(max_length=500, null=True) body = MarkdownxField() pass diff --git a/legacy/management/commands/migrate_journal.py b/legacy/management/commands/migrate_journal.py index 075ffa8f..3732abe6 100644 --- a/legacy/management/commands/migrate_journal.py +++ b/legacy/management/commands/migrate_journal.py @@ -9,6 +9,7 @@ from movies.models import MovieMark, MovieReview from music.models import AlbumMark, AlbumReview from games.models import GameMark, GameReview from collection.models import Collection as Legacy_Collection +from collection.models import CollectionMark as Legacy_CollectionMark from catalog.common import * from catalog.models import * from catalog.sites import * @@ -88,13 +89,9 @@ class Command(BaseCommand): cls.objects.all().delete() def collection(self, options): - qs = Legacy_Collection.objects.all().filter(owner__is_active=True).order_by('id') - if options['id']: - if options['maxid']: - qs = qs.filter(id__gte=int(options['id']), id__lte=int(options['maxid'])) - else: - qs = qs.filter(id=int(options['id'])) + collection_map = {} with transaction.atomic(): + qs = Legacy_Collection.objects.all().filter(owner__is_active=True).order_by('id') for entity in tqdm(qs): c = Collection.objects.create( owner_id=entity.owner_id, @@ -104,6 +101,7 @@ class Command(BaseCommand): created_time=entity.created_time, edited_time=entity.edited_time, ) + collection_map[entity.id] = c.id c.catalog_item.cover = entity.cover c.catalog_item.save() for citem in entity.collectionitem_list: @@ -120,6 +118,14 @@ class Command(BaseCommand): else: # TODO convert song to album print(f'{c.owner} {c.id} {c.title} {citem.item} were skipped') + qs = Legacy_CollectionMark.objects.all().filter(owner__is_active=True).order_by('id') + for entity in tqdm(qs): + Like.objects.create( + owner_id=entity.owner_id, + target_id=collection_map[entity.collection_id], + created_time=entity.created_time, + edited_time=entity.edited_time, + ) def review(self, options): for typ in [GameReview, AlbumReview, BookReview, MovieReview]: diff --git a/social/models.py b/social/models.py index 9e0fac8f..fcc0f49c 100644 --- a/social/models.py +++ b/social/models.py @@ -1,7 +1,7 @@ """ Models for Social app -DataSignalManager captures create/update/(soft/hard)delete from Journal app, and generate Activity objects, +DataSignalManager captures create/update/(soft/hard)delete/add/remove from Journal app, and generate Activity objects, ActivityManager generates chronological view for user and, in future, ActivityStreams """ @@ -20,36 +20,32 @@ from django.conf import settings _logger = logging.getLogger(__name__) -class ActionType(models.TextChoices): - Create = 'create' - Delete = 'delete' - Update = 'update' - Add = 'add' - Remove = 'remove' - 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 ActivityTemplate(models.TextChoices): + """ + """ + MarkItem = 'mark_item' + ReviewItem = 'review_item' + CreateCollection = 'create_collection' + LikeCollection = 'like_collection' + + +class LocalActivity(models.Model, UserOwnedObjectMixin): + owner = models.ForeignKey(User, on_delete=models.CASCADE) + visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only + template = models.CharField(blank=False, choices=ActivityTemplate.choices, max_length=50) + action_object = models.ForeignKey(Piece, on_delete=models.CASCADE) + created_time = models.DateTimeField(default=timezone.now, db_index=True) class ActivityManager: def __init__(self, user): self.owner = user - def get_viewable_activities(self, before_time=None): + def get_timeline(self, before_time=None): q = Q(owner_id__in=self.owner.following, visibility__lt=2) | Q(owner=self.owner) - q = q & Q(is_viewable=True) if before_time: q = q & Q(created_time__lt=before_time) - return Activity.objects.filter(q) + return LocalActivity.objects.filter(q) @staticmethod def get_manager_for_user(user): @@ -60,88 +56,27 @@ 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__ - - def __str__(self): - return f'{self.id}:{self.action_type}:{self.action_object}:{self.is_viewable}' - - -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) is None) - - def created(self): - activity = 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)) - return activity - - 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.is_viewable = False - create_activity.save() - else: - _logger.warning(f'unable to find create activity for {self.action_object}') - # 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() + if processor_class: + processor = processor_class(instance) + if created: + if hasattr(processor, 'created'): + processor.created() + elif hasattr(processor, 'updated'): + 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() + if processor_class: + processor = processor_class(instance) + if hasattr(processor, 'deleted'): + processor.deleted() @staticmethod def add_handler_for_model(model): @@ -156,29 +91,58 @@ class DataSignalManager: return processor +class DefaultActivityProcessor: + model = None + template = None + + def __init__(self, action_object): + self.action_object = action_object + + def created(self): + params = { + 'owner': self.action_object.owner, + 'visibility': self.action_object.visibility, + 'template': self.template, + 'action_object': self.action_object, + } + LocalActivity.objects.create(**params) + + def updated(self): + activity = LocalActivity.objects.filter(action_object=self.action_object).first() + if not activity: + self.created() + elif activity.visibility != self.action_object.visibility: + activity.visibility = self.action_object.visibility + activity.save() + + @DataSignalManager.register -class MarkProcessor(DefaultSignalProcessor): +class MarkProcessor(DefaultActivityProcessor): model = ShelfMember - - -# @DataSignalManager.register -# class ReplyProcessor(DefaultSignalProcessor): -# model = Reply - -# def activity_viewable(self): -# return False - - -# @DataSignalManager.register -# class RatingProcessor(DefaultSignalProcessor): -# model = Rating + template = ActivityTemplate.MarkItem @DataSignalManager.register -class ReviewProcessor(DefaultSignalProcessor): +class ReviewProcessor(DefaultActivityProcessor): model = Review + template = ActivityTemplate.ReviewItem @DataSignalManager.register -class CollectionProcessor(DefaultSignalProcessor): +class CollectionProcessor(DefaultActivityProcessor): model = Collection + template = ActivityTemplate.CreateCollection + + +@DataSignalManager.register +class LikeCollectionProcessor(DefaultActivityProcessor): + model = Like + template = ActivityTemplate.LikeCollection + + def created(self): + if isinstance(self.action_object, Collection): + super.created() + + def updated(self): + if isinstance(self.action_object, Collection): + super.update() diff --git a/social/tests.py b/social/tests.py index ba46913b..9640b636 100644 --- a/social/tests.py +++ b/social/tests.py @@ -17,38 +17,38 @@ class SocialTest(TestCase): def test_timeline(self): # alice see 0 activity in timeline in the beginning - timeline = self.alice.activity_manager.get_viewable_activities() + timeline = self.alice.activity_manager.get_timeline() self.assertEqual(len(timeline), 0) # 1 activity after adding first book to shelf self.alice.shelf_manager.move_item(self.book1, ShelfType.WISHLIST, visibility=1) - timeline = self.alice.activity_manager.get_viewable_activities() + timeline = self.alice.activity_manager.get_timeline() self.assertEqual(len(timeline), 1) # 2 activities after adding second book to shelf self.alice.shelf_manager.move_item(self.book2, ShelfType.WISHLIST) - timeline = self.alice.activity_manager.get_viewable_activities() + timeline = self.alice.activity_manager.get_timeline() self.assertEqual(len(timeline), 2) # 2 activities after change first mark self.alice.shelf_manager.move_item(self.book1, ShelfType.PROGRESS) - timeline = self.alice.activity_manager.get_viewable_activities() + timeline = self.alice.activity_manager.get_timeline() self.assertEqual(len(timeline), 2) # bob see 0 activity in timeline in the beginning - timeline2 = self.bob.activity_manager.get_viewable_activities() + timeline2 = self.bob.activity_manager.get_timeline() self.assertEqual(len(timeline2), 0) # bob follows alice, see 2 activities self.bob.mastodon_following = ['Alice@MySpace'] self.alice.mastodon_follower = ['Bob@KKCity'] self.bob.following = self.bob.get_following_ids() - timeline2 = self.bob.activity_manager.get_viewable_activities() + timeline2 = self.bob.activity_manager.get_timeline() self.assertEqual(len(timeline2), 2) # alice:3 bob:2 after alice adding second book to shelf as private self.alice.shelf_manager.move_item(self.movie, ShelfType.WISHLIST, visibility=2) - timeline = self.alice.activity_manager.get_viewable_activities() + timeline = self.alice.activity_manager.get_timeline() self.assertEqual(len(timeline), 3) - timeline2 = self.bob.activity_manager.get_viewable_activities() + timeline2 = self.bob.activity_manager.get_timeline() self.assertEqual(len(timeline2), 2)