new data model: collection mark; local timeline

This commit is contained in:
Your Name 2022-12-20 11:32:44 -05:00
parent 72347011b4
commit 7ee5c7d587
5 changed files with 112 additions and 129 deletions

View file

@ -15,10 +15,7 @@ User .. Activity
class Activity { class Activity {
+User owner +User owner
+int visibility +int visibility
+Enum action_type
+Piece action_object +Piece action_object
+Item target
+Bool is_viewable
} }
Activity .. Piece Activity .. Piece
Activity .. Item 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. 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: 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*: - `Add` / `Remove` an *Item* to / from a *List*:

View file

@ -39,6 +39,10 @@ class Content(Piece):
abstract = True abstract = True
class Like(Piece):
target = models.ForeignKey(Piece, on_delete=models.CASCADE, related_name='likes')
class Note(Content): class Note(Content):
pass pass
@ -127,8 +131,8 @@ Item.rating = property(Rating.get_rating_for_item)
Item.rating_count = property(Rating.get_rating_count_for_item) Item.rating_count = property(Rating.get_rating_count_for_item)
class Reply(Content): class Reply(Piece):
reply_to_content = models.ForeignKey(Piece, on_delete=models.PROTECT, related_name='replies') reply_to_content = models.ForeignKey(Piece, on_delete=models.SET_NULL, related_name='replies', null=True)
title = models.CharField(max_length=500, null=True) title = models.CharField(max_length=500, null=True)
body = MarkdownxField() body = MarkdownxField()
pass pass

View file

@ -9,6 +9,7 @@ from movies.models import MovieMark, MovieReview
from music.models import AlbumMark, AlbumReview from music.models import AlbumMark, AlbumReview
from games.models import GameMark, GameReview from games.models import GameMark, GameReview
from collection.models import Collection as Legacy_Collection from collection.models import Collection as Legacy_Collection
from collection.models import CollectionMark as Legacy_CollectionMark
from catalog.common import * from catalog.common import *
from catalog.models import * from catalog.models import *
from catalog.sites import * from catalog.sites import *
@ -88,13 +89,9 @@ class Command(BaseCommand):
cls.objects.all().delete() cls.objects.all().delete()
def collection(self, options): def collection(self, options):
qs = Legacy_Collection.objects.all().filter(owner__is_active=True).order_by('id') collection_map = {}
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']))
with transaction.atomic(): with transaction.atomic():
qs = Legacy_Collection.objects.all().filter(owner__is_active=True).order_by('id')
for entity in tqdm(qs): for entity in tqdm(qs):
c = Collection.objects.create( c = Collection.objects.create(
owner_id=entity.owner_id, owner_id=entity.owner_id,
@ -104,6 +101,7 @@ class Command(BaseCommand):
created_time=entity.created_time, created_time=entity.created_time,
edited_time=entity.edited_time, edited_time=entity.edited_time,
) )
collection_map[entity.id] = c.id
c.catalog_item.cover = entity.cover c.catalog_item.cover = entity.cover
c.catalog_item.save() c.catalog_item.save()
for citem in entity.collectionitem_list: for citem in entity.collectionitem_list:
@ -120,6 +118,14 @@ class Command(BaseCommand):
else: else:
# TODO convert song to album # TODO convert song to album
print(f'{c.owner} {c.id} {c.title} {citem.item} were skipped') 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): def review(self, options):
for typ in [GameReview, AlbumReview, BookReview, MovieReview]: for typ in [GameReview, AlbumReview, BookReview, MovieReview]:

View file

@ -1,7 +1,7 @@
""" """
Models for Social app 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 ActivityManager generates chronological view for user and, in future, ActivityStreams
""" """
@ -20,36 +20,32 @@ from django.conf import settings
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class ActionType(models.TextChoices): class ActivityTemplate(models.TextChoices):
Create = 'create' """
Delete = 'delete' """
Update = 'update' MarkItem = 'mark_item'
Add = 'add' ReviewItem = 'review_item'
Remove = 'remove' CreateCollection = 'create_collection'
Like = 'like' LikeCollection = 'like_collection'
Undo_Like = 'undo_like'
Announce = 'announce'
Undo_Announce = 'undo_announce' class LocalActivity(models.Model, UserOwnedObjectMixin):
Follow = 'follow' owner = models.ForeignKey(User, on_delete=models.CASCADE)
Undo_Follow = 'undo_follow' visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only
Flag = 'flag' template = models.CharField(blank=False, choices=ActivityTemplate.choices, max_length=50)
Move = 'move' action_object = models.ForeignKey(Piece, on_delete=models.CASCADE)
Accept = 'accept' created_time = models.DateTimeField(default=timezone.now, db_index=True)
Reject = 'reject'
Block = 'block'
Undo_Block = 'undo_block'
class ActivityManager: class ActivityManager:
def __init__(self, user): def __init__(self, user):
self.owner = 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(owner_id__in=self.owner.following, visibility__lt=2) | Q(owner=self.owner)
q = q & Q(is_viewable=True)
if before_time: if before_time:
q = q & Q(created_time__lt=before_time) q = q & Q(created_time__lt=before_time)
return Activity.objects.filter(q) return LocalActivity.objects.filter(q)
@staticmethod @staticmethod
def get_manager_for_user(user): 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') 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: class DataSignalManager:
processors = {} processors = {}
@staticmethod @staticmethod
def save_handler(sender, instance, created, **kwargs): def save_handler(sender, instance, created, **kwargs):
processor_class = DataSignalManager.processors.get(instance.__class__) processor_class = DataSignalManager.processors.get(instance.__class__)
if not processor_class: if processor_class:
processor_class = GenericSignalProcessor processor = processor_class(instance)
processor = processor_class(instance) if created:
if created: if hasattr(processor, 'created'):
processor.created() processor.created()
elif getattr(instance, 'is_deleted', False): elif hasattr(processor, 'updated'):
processor.deleted() processor.updated()
else:
processor.updated()
@staticmethod @staticmethod
def delete_handler(sender, instance, **kwargs): def delete_handler(sender, instance, **kwargs):
processor_class = DataSignalManager.processors.get(instance.__class__) processor_class = DataSignalManager.processors.get(instance.__class__)
if not processor_class: if processor_class:
processor_class = GenericSignalProcessor processor = processor_class(instance)
processor = processor_class(instance) if hasattr(processor, 'deleted'):
processor.deleted() processor.deleted()
@staticmethod @staticmethod
def add_handler_for_model(model): def add_handler_for_model(model):
@ -156,29 +91,58 @@ class DataSignalManager:
return processor 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 @DataSignalManager.register
class MarkProcessor(DefaultSignalProcessor): class MarkProcessor(DefaultActivityProcessor):
model = ShelfMember model = ShelfMember
template = ActivityTemplate.MarkItem
# @DataSignalManager.register
# class ReplyProcessor(DefaultSignalProcessor):
# model = Reply
# def activity_viewable(self):
# return False
# @DataSignalManager.register
# class RatingProcessor(DefaultSignalProcessor):
# model = Rating
@DataSignalManager.register @DataSignalManager.register
class ReviewProcessor(DefaultSignalProcessor): class ReviewProcessor(DefaultActivityProcessor):
model = Review model = Review
template = ActivityTemplate.ReviewItem
@DataSignalManager.register @DataSignalManager.register
class CollectionProcessor(DefaultSignalProcessor): class CollectionProcessor(DefaultActivityProcessor):
model = Collection 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()

View file

@ -17,38 +17,38 @@ class SocialTest(TestCase):
def test_timeline(self): def test_timeline(self):
# alice see 0 activity in timeline in the beginning # 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) self.assertEqual(len(timeline), 0)
# 1 activity after adding first book to shelf # 1 activity after adding first book to shelf
self.alice.shelf_manager.move_item(self.book1, ShelfType.WISHLIST, visibility=1) 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) self.assertEqual(len(timeline), 1)
# 2 activities after adding second book to shelf # 2 activities after adding second book to shelf
self.alice.shelf_manager.move_item(self.book2, ShelfType.WISHLIST) 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) self.assertEqual(len(timeline), 2)
# 2 activities after change first mark # 2 activities after change first mark
self.alice.shelf_manager.move_item(self.book1, ShelfType.PROGRESS) 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) self.assertEqual(len(timeline), 2)
# bob see 0 activity in timeline in the beginning # 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) self.assertEqual(len(timeline2), 0)
# bob follows alice, see 2 activities # bob follows alice, see 2 activities
self.bob.mastodon_following = ['Alice@MySpace'] self.bob.mastodon_following = ['Alice@MySpace']
self.alice.mastodon_follower = ['Bob@KKCity'] self.alice.mastodon_follower = ['Bob@KKCity']
self.bob.following = self.bob.get_following_ids() 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) self.assertEqual(len(timeline2), 2)
# alice:3 bob:2 after alice adding second book to shelf as private # 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) 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) 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) self.assertEqual(len(timeline2), 2)