new data model: social

This commit is contained in:
Your Name 2022-12-13 06:44:29 +00:00
parent 3511ac16a0
commit ad696d8377
9 changed files with 294 additions and 54 deletions

View file

@ -155,8 +155,33 @@ class LookupIdDescriptor(object): # TODO make it mixin of Field
# return sid[0] in IdType.values() # return sid[0] in IdType.values()
class Item(PolymorphicModel): class SoftDeleteMixin:
URL_PATH = None # subclass must specify this """
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 category = None # subclass must specify this
uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True)
# item_type = models.CharField(_("类型"), choices=ItemType.choices, blank=False, max_length=50) # 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) is_deleted = models.BooleanField(default=False, db_index=True)
history = HistoricalRecords() history = HistoricalRecords()
merged_to_item = models.ForeignKey('Item', null=True, on_delete=models.SET_NULL, default=None, related_name="merged_from_items") 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: class Meta:
unique_together = [['polymorphic_ctype_id', 'primary_lookup_id_type', 'primary_lookup_id_value']] unique_together = [['polymorphic_ctype_id', 'primary_lookup_id_type', 'primary_lookup_id_value']]
def delete(self, using=None, soft=True, *args, **kwargs): def clear(self):
if soft:
self.primary_lookup_id_value = None self.primary_lookup_id_value = None
self.primary_lookup_id_type = None self.primary_lookup_id_type = None
self.is_deleted = True
self.save(using=using)
else:
return super().delete(using=using, *args, **kwargs)
def __str__(self): 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})" 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 @property
def url(self): def url(self):
return f'/{self.URL_PATH}/{base62.encode(self.uid.int)}' return f'/{self.url_path}/{base62.encode(self.uid.int)}'
@classmethod @classmethod
def get_by_url(cls, url_or_b62): def get_by_url(cls, url_or_b62):
b62 = url_or_b62.split('/')[-1] b62 = url_or_b62.split('/')[-1]
return cls.objects.get(uid=uuid.UUID(int=base62.decode(b62))) 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): def update_lookup_ids(self, lookup_ids):
# TODO # TODO
# ll = set(lookup_ids) # ll = set(lookup_ids)

View file

@ -1,7 +1,7 @@
from django.db import models from django.db import models
from polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
from users.models import User 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 catalog.collection.models import Collection as CatalogCollection
from decimal import * from decimal import *
from enum import Enum from enum import Enum
@ -15,24 +15,14 @@ from functools import cached_property
from django.db.models import Count from django.db.models import Count
class Piece(PolymorphicModel): class UserOwnedObjectMixin:
"""
UserOwnedObjectMixin
Models must add these:
owner = models.ForeignKey(User, on_delete=models.PROTECT) owner = models.ForeignKey(User, on_delete=models.PROTECT)
visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only visibility = models.PositiveSmallIntegerField(default=0)
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)
def is_visible_to(self, viewer): def is_visible_to(self, viewer):
if not viewer.is_authenticated: if not viewer.is_authenticated:
@ -63,11 +53,21 @@ class Piece(PolymorphicModel):
return visible_entities return visible_entities
class Content(Piece): class Piece(PolymorphicModel, UserOwnedObjectMixin):
target: models.ForeignKey(Item, on_delete=models.PROTECT) 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): def __str__(self):
return f"{self.id}({self.target})" return f"{self.id}({self.item})"
class Note(Content): class Note(Content):
@ -107,7 +107,7 @@ class List(Piece):
self._owner = self.owner self._owner = self.owner
super().save(*args, **kwargs) super().save(*args, **kwargs)
MEMBER_CLASS = None # subclass must override this # MEMBER_CLASS = None # subclass must override this
# subclass must add this: # subclass must add this:
# items = models.ManyToManyField(Item, through='ListMember') # items = models.ManyToManyField(Item, through='ListMember')
@ -127,9 +127,9 @@ class List(Piece):
return None return None
else: else:
ml = self.ordered_members ml = self.ordered_members
p = {self.__class__.__name__.lower(): self} p = {'_' + self.__class__.__name__.lower(): self}
p.update(params) 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 return i
def remove_item(self, item): def remove_item(self, item):
@ -162,13 +162,18 @@ class List(Piece):
member.save() member.save()
class ListMember(models.Model): class ListMember(Piece):
# subclass must add this: """
# list = models.ForeignKey('ListClass', related_name='members', on_delete=models.CASCADE) 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) item = models.ForeignKey(Item, on_delete=models.PROTECT)
position = models.PositiveIntegerField() position = models.PositiveIntegerField()
metadata = models.JSONField(default=dict)
comment = models.ForeignKey(Review, on_delete=models.PROTECT, null=True)
class Meta: class Meta:
abstract = True abstract = True
@ -202,12 +207,13 @@ QueueTypeNames = [
[ItemCategory.Game, QueueType.WISHED, _('想玩')], [ItemCategory.Game, QueueType.WISHED, _('想玩')],
[ItemCategory.Game, QueueType.STARTED, _('在玩')], [ItemCategory.Game, QueueType.STARTED, _('在玩')],
[ItemCategory.Game, QueueType.DONE, _('玩过')], [ItemCategory.Game, QueueType.DONE, _('玩过')],
[ItemCategory.Collection, QueueType.WISHED, _('关注')],
# TODO add more combinations # TODO add more combinations
] ]
class QueueMember(ListMember): 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): class Queue(List):
@ -258,12 +264,11 @@ class QueueManager:
def initialize(self): def initialize(self):
for ic in ItemCategory: for ic in ItemCategory:
if ic != ItemCategory.Collection:
for qt in QueueType: for qt in QueueType:
Queue.objects.create(owner=self.owner, item_category=ic, queue_type=qt) Queue.objects.create(owner=self.owner, item_category=ic, queue_type=qt)
def _queue_member_for_item(self, item): 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): def _queue_for_item_and_type(item, queue_type):
if not item or not queue_type: if not item or not queue_type:
@ -276,7 +281,7 @@ class QueueManager:
raise ValueError('empty item') raise ValueError('empty item')
lastqm = self._queue_member_for_item(item) lastqm = self._queue_member_for_item(item)
lastqmm = lastqm.metadata if lastqm else None 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 lastqt = lastq.queue_type if lastq else None
queue = self.get_queue(item.category, queue_type) if queue_type else None queue = self.get_queue(item.category, queue_type) if queue_type else None
if lastq != queue: if lastq != queue:
@ -301,6 +306,14 @@ class QueueManager:
def get_queue(self, item_category, queue_type): def get_queue(self, item_category, queue_type):
return self.owner.queue_set.all().filter(item_category=item_category, queue_type=queue_type).first() 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 Collection
@ -308,7 +321,7 @@ Collection
class CollectionMember(ListMember): 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): class Collection(List):
@ -340,7 +353,7 @@ Tag
class TagMember(ListMember): 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)] TagValidators = [RegexValidator(regex=r'\s+', inverse_match=True)]

View file

@ -32,7 +32,7 @@ class QueueTest(TestCase):
user = User.objects.create(mastodon_site="site", username="name") user = User.objects.create(mastodon_site="site", username="name")
queue_manager = QueueManager(user=user) queue_manager = QueueManager(user=user)
queue_manager.initialize() 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") book1 = Edition.objects.create(title="Hyperion")
book2 = Edition.objects.create(title="Andymion") book2 = Edition.objects.create(title="Andymion")
q1 = queue_manager.get_queue(ItemCategory.Book, QueueType.WISHED) q1 = queue_manager.get_queue(ItemCategory.Book, QueueType.WISHED)

0
social/__init__.py Normal file
View file

3
social/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
social/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class SocialConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'social'

173
social/models.py Normal file
View file

@ -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

23
social/tests.py Normal file
View file

@ -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)

3
social/views.py Normal file
View file

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.