new data model: collection mark; local timeline
This commit is contained in:
parent
72347011b4
commit
7ee5c7d587
5 changed files with 112 additions and 129 deletions
|
@ -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*:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]:
|
||||||
|
|
184
social/models.py
184
social/models.py
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Add table
Reference in a new issue