Updates status model and serializer

This commit is contained in:
Mouse Reeve 2020-11-30 14:24:31 -08:00
parent 8bc0a57bd4
commit 3966c84e08
7 changed files with 167 additions and 166 deletions

View file

@ -8,7 +8,6 @@ from .image import Image
@dataclass(init=False) @dataclass(init=False)
class Tombstone(ActivityObject): class Tombstone(ActivityObject):
''' the placeholder for a deleted status ''' ''' the placeholder for a deleted status '''
url: str
published: str published: str
deleted: str deleted: str
type: str = 'Tombstone' type: str = 'Tombstone'
@ -17,14 +16,13 @@ class Tombstone(ActivityObject):
@dataclass(init=False) @dataclass(init=False)
class Note(ActivityObject): class Note(ActivityObject):
''' Note activity ''' ''' Note activity '''
url: str
inReplyTo: str
published: str published: str
attributedTo: str attributedTo: str
to: List[str]
cc: List[str]
content: str content: str
replies: Dict to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
replies: Dict = field(default_factory=lambda: {})
inReplyTo: str = ''
tag: List[Link] = field(default_factory=lambda: []) tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Image] = field(default_factory=lambda: []) attachment: List[Image] = field(default_factory=lambda: [])
sensitive: bool = False sensitive: bool = False

View file

@ -3,30 +3,27 @@ from django.db import models
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import ActivitypubMixin from .base_model import ActivitypubMixin
from .base_model import ActivityMapping, BookWyrmModel from .base_model import BookWyrmModel
from . import fields
class Attachment(ActivitypubMixin, BookWyrmModel): class Attachment(ActivitypubMixin, BookWyrmModel):
''' an image (or, in the future, video etc) associated with a status ''' ''' an image (or, in the future, video etc) associated with a status '''
status = models.ForeignKey( status = fields.ForeignKey(
'Status', 'Status',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='attachments', related_name='attachments',
null=True null=True
) )
reverse_unfurl = True
class Meta: class Meta:
''' one day we'll have other types of attachments besides images ''' ''' one day we'll have other types of attachments besides images '''
abstract = True abstract = True
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'image'),
ActivityMapping('name', 'caption'),
]
class Image(Attachment): class Image(Attachment):
''' an image attachment ''' ''' an image attachment '''
image = models.ImageField(upload_to='status/', null=True, blank=True) image = fields.ImageField(upload_to='status/', null=True, blank=True)
caption = models.TextField(null=True, blank=True) caption = fields.TextField(null=True, blank=True)
activity_serializer = activitypub.Image activity_serializer = activitypub.Image

View file

@ -61,9 +61,19 @@ def get_field_name(field):
return components[0] + ''.join(x.title() for x in components[1:]) return components[0] + ''.join(x.title() for x in components[1:])
def unfurl_related_field(related_field):
''' load reverse lookups (like public key owner or Status attachment '''
if hasattr(related_field, 'all'):
return [unfurl_related_field(i) for i in related_field.all()]
if related_field.reverse_unfurl:
return related_field.to_activity()
return related_field.remote_id
class ActivitypubMixin: class ActivitypubMixin:
''' add this mixin for models that are AP serializable ''' ''' add this mixin for models that are AP serializable '''
activity_serializer = lambda: {} activity_serializer = lambda: {}
reverse_unfurl = False
def to_activity(self): def to_activity(self):
''' convert from a model to an activity ''' ''' convert from a model to an activity '''
@ -73,18 +83,24 @@ class ActivitypubMixin:
continue continue
key = get_field_name(field) key = get_field_name(field)
value = field.to_activity(getattr(self, field.name)) value = field.to_activity(getattr(self, field.name))
if value is not None: if value is None:
continue
if key in activity and isinstance(activity[key], list):
activity[key] += value
else:
activity[key] = value activity[key] = value
if hasattr(self, 'serialize_reverse_fields'): if hasattr(self, 'serialize_reverse_fields'):
for field_name in self.serialize_reverse_fields: for field_name in self.serialize_reverse_fields:
activity[field_name] = getattr(self, field_name).remote_id related_field = getattr(self, field_name)
activity[field_name] = unfurl_related_field(related_field)
return self.activity_serializer(**activity).serialize() return self.activity_serializer(**activity).serialize()
def to_create_activity(self, user, pure=False): def to_create_activity(self, user):
''' returns the object wrapped in a Create activity ''' ''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity(pure=pure) activity_object = self.to_activity()
signer = pkcs1_15.new(RSA.import_key(user.private_key)) signer = pkcs1_15.new(RSA.import_key(user.private_key))
content = activity_object['content'] content = activity_object['content']
@ -100,8 +116,8 @@ class ActivitypubMixin:
return activitypub.Create( return activitypub.Create(
id=create_id, id=create_id,
actor=user.remote_id, actor=user.remote_id,
to=['%s/followers' % user.remote_id], to=activity_object['to'],
cc=['https://www.w3.org/ns/activitystreams#Public'], cc=activity_object['cc'],
object=activity_object, object=activity_object,
signature=signature, signature=signature,
).serialize() ).serialize()
@ -245,30 +261,3 @@ class ActivityMapping:
model_key: str model_key: str
activity_formatter: Callable = lambda x: x activity_formatter: Callable = lambda x: x
model_formatter: Callable = lambda x: x model_formatter: Callable = lambda x: x
def tag_formatter(items, name_field, activity_type):
''' helper function to format lists of foreign keys into Tags '''
tags = []
for item in items.all():
tags.append(activitypub.Link(
href=item.remote_id,
name=getattr(item, name_field),
type=activity_type
))
return tags
def image_formatter(image):
''' convert images into activitypub json '''
if image and hasattr(image, 'url'):
url = image.url
else:
return None
url = 'https://%s%s' % (DOMAIN, url)
return activitypub.Image(url=url)
def image_attachments_formatter(images):
''' create a list of image attachments '''
return [image_formatter(i) for i in images]

View file

@ -155,6 +155,7 @@ class Edition(Book):
'Work', on_delete=models.PROTECT, null=True, related_name='editions') 'Work', on_delete=models.PROTECT, null=True, related_name='editions')
activity_serializer = activitypub.Edition activity_serializer = activitypub.Edition
name_field = 'title'
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' calculate isbn 10/13 ''' ''' calculate isbn 10/13 '''

View file

@ -71,6 +71,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
) )
def deconstruct(self): def deconstruct(self):
''' implementation of models.Field deconstruct '''
name, path, args, kwargs = super().deconstruct() name, path, args, kwargs = super().deconstruct()
del kwargs['verbose_name'] del kwargs['verbose_name']
del kwargs['max_length'] del kwargs['max_length']
@ -86,6 +87,8 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
class ForeignKey(ActivitypubFieldMixin, models.ForeignKey): class ForeignKey(ActivitypubFieldMixin, models.ForeignKey):
''' activitypub-aware foreign key field ''' ''' activitypub-aware foreign key field '''
def to_activity(self, value): def to_activity(self, value):
if not value:
return None
return value.remote_id return value.remote_id
def from_activity(self, activity_data): def from_activity(self, activity_data):
pass# TODO pass# TODO
@ -94,6 +97,10 @@ class ForeignKey(ActivitypubFieldMixin, models.ForeignKey):
class OneToOneField(ActivitypubFieldMixin, models.OneToOneField): class OneToOneField(ActivitypubFieldMixin, models.OneToOneField):
''' activitypub-aware foreign key field ''' ''' activitypub-aware foreign key field '''
def to_activity(self, value): def to_activity(self, value):
print('HIIIII')
print(value)
if not value:
return None
return value.to_activity() return value.to_activity()
def from_activity(self, activity_data): def from_activity(self, activity_data):
@ -113,20 +120,45 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
def from_activity(self, activity_data): def from_activity(self, activity_data):
if self.link_only: if self.link_only:
return return None
values = super().from_activity(self, activity_data) values = super().from_activity(activity_data)
return values# TODO return values# TODO
class TagField(ManyToManyField):
''' special case of many to many that uses Tags '''
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.activitypub_field = 'tag'
def to_activity(self, value):
tags = []
for item in value.all():
activity_type = item.__class__.__name__
if activity_type == 'User':
activity_type = 'Mention'
tags.append(activitypub.Link(
href=item.remote_id,
name=getattr(item, item.name_field),
type=activity_type
))
return tags
def image_serializer(value):
''' helper for serializing images '''
print(value)
if value and hasattr(value, 'url'):
url = value.url
else:
return None
url = 'https://%s%s' % (DOMAIN, url)
return activitypub.Image(url=url)
class ImageField(ActivitypubFieldMixin, models.ImageField): class ImageField(ActivitypubFieldMixin, models.ImageField):
''' activitypub-aware image field ''' ''' activitypub-aware image field '''
def to_activity(self, value): def to_activity(self, value):
if value and hasattr(value, 'url'): return image_serializer(value)
url = value.url
else:
return None
url = 'https://%s%s' % (DOMAIN, url)
return activitypub.Image(url=url)
def from_activity(self, activity_data): def from_activity(self, activity_data):
image_slug = super().from_activity(activity_data) image_slug = super().from_activity(activity_data)
@ -150,6 +182,12 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
return [image_name, image_content] return [image_name, image_content]
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
''' activitypub-aware datetime field '''
def to_activity(self, value):
return value.isoformat()
class CharField(ActivitypubFieldMixin, models.CharField): class CharField(ActivitypubFieldMixin, models.CharField):
''' activitypub-aware char field ''' ''' activitypub-aware char field '''
@ -158,3 +196,6 @@ class TextField(ActivitypubFieldMixin, models.TextField):
class BooleanField(ActivitypubFieldMixin, models.BooleanField): class BooleanField(ActivitypubFieldMixin, models.BooleanField):
''' activitypub-aware boolean field ''' ''' activitypub-aware boolean field '''
class IntegerField(ActivitypubFieldMixin, models.IntegerField):
''' activitypub-aware boolean field '''

View file

@ -6,26 +6,27 @@ from model_utils.managers import InheritanceManager
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from .base_model import ActivityMapping, BookWyrmModel, PrivacyLevels from .base_model import BookWyrmModel, PrivacyLevels
from .base_model import tag_formatter, image_attachments_formatter from . import fields
from .fields import image_serializer
class Status(OrderedCollectionPageMixin, BookWyrmModel): class Status(OrderedCollectionPageMixin, BookWyrmModel):
''' any post, like a reply to a review, etc ''' ''' any post, like a reply to a review, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT) user = fields.ForeignKey(
content = models.TextField(blank=True, null=True) 'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
mention_users = models.ManyToManyField('User', related_name='mention_user') content = fields.TextField(blank=True, null=True)
mention_books = models.ManyToManyField( mention_users = fields.TagField('User', related_name='mention_user')
'Edition', related_name='mention_book') mention_books = fields.TagField('Edition', related_name='mention_book')
local = models.BooleanField(default=True) local = models.BooleanField(default=True)
privacy = models.CharField( privacy = models.CharField(
max_length=255, max_length=255,
default='public', default='public',
choices=PrivacyLevels.choices choices=PrivacyLevels.choices
) )
sensitive = models.BooleanField(default=False) sensitive = fields.BooleanField(default=False)
# the created date can't be this, because of receiving federated posts # the created date can't be this, because of receiving federated posts
published_date = models.DateTimeField(default=timezone.now) published_date = fields.DateTimeField(
default=timezone.now, activitypub_field='published')
deleted = models.BooleanField(default=False) deleted = models.BooleanField(default=False)
deleted_date = models.DateTimeField(blank=True, null=True) deleted_date = models.DateTimeField(blank=True, null=True)
favorites = models.ManyToManyField( favorites = models.ManyToManyField(
@ -35,79 +36,21 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
through_fields=('status', 'user'), through_fields=('status', 'user'),
related_name='user_favorites' related_name='user_favorites'
) )
reply_parent = models.ForeignKey( reply_parent = fields.ForeignKey(
'self', 'self',
null=True, null=True,
on_delete=models.PROTECT on_delete=models.PROTECT,
activitypub_field='inReplyTo',
) )
objects = InheritanceManager() objects = InheritanceManager()
# ---- activitypub serialization settings for this model ----- #
@property
def ap_to(self):
''' should be related to post privacy I think '''
return ['https://www.w3.org/ns/activitystreams#Public']
@property
def ap_cc(self):
''' should be related to post privacy I think '''
return [self.user.ap_followers]
@property @property
def ap_replies(self): def ap_replies(self):
''' structured replies block ''' ''' structured replies block '''
return self.to_replies() return self.to_replies()
@property
def ap_status_image(self):
''' attach a book cover, if relevent '''
if hasattr(self, 'book'):
return self.book.ap_cover
if self.mention_books.first():
return self.mention_books.first().ap_cover
return None
shared_mappings = [
ActivityMapping('url', 'remote_id', lambda x: None),
ActivityMapping('id', 'remote_id'),
ActivityMapping('inReplyTo', 'reply_parent'),
ActivityMapping('published', 'published_date'),
ActivityMapping('attributedTo', 'user'),
ActivityMapping('to', 'ap_to'),
ActivityMapping('cc', 'ap_cc'),
ActivityMapping('replies', 'ap_replies'),
ActivityMapping(
'tag', 'mention_books',
lambda x: tag_formatter(x, 'title', 'Book'),
),
ActivityMapping(
'tag', 'mention_users',
lambda x: tag_formatter(x, 'username', 'Mention'),
),
ActivityMapping(
'attachment', 'attachments',
lambda x: image_attachments_formatter(x.all()),
)
]
# serializing to bookwyrm expanded activitypub
activity_mappings = shared_mappings + [
ActivityMapping('name', 'name'),
ActivityMapping('inReplyToBook', 'book'),
ActivityMapping('rating', 'rating'),
ActivityMapping('quote', 'quote'),
ActivityMapping('content', 'content'),
]
# for serializing to standard activitypub without extended types
pure_activity_mappings = shared_mappings + [
ActivityMapping('name', 'ap_pure_name'),
ActivityMapping('content', 'ap_pure_content'),
ActivityMapping('attachment', 'ap_status_image'),
]
activity_serializer = activitypub.Note activity_serializer = activitypub.Note
serialize_reverse_fields = ['attachments']
#----- replies collection activitypub ----# #----- replies collection activitypub ----#
@classmethod @classmethod
@ -138,7 +81,43 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
deleted=self.deleted_date.isoformat(), deleted=self.deleted_date.isoformat(),
published=self.deleted_date.isoformat() published=self.deleted_date.isoformat()
).serialize() ).serialize()
return ActivitypubMixin.to_activity(self, pure=pure) activity = ActivitypubMixin.to_activity(self)
activity['replies'] = self.to_replies()
# privacy controls
public = 'https://www.w3.org/ns/activitystreams#Public'
mentions = [u.remote_id for u in self.mention_users.all()]
# this is a link to the followers list:
followers = self.user.__class__._meta.get_field('followers')\
.to_activity(self.user.followers)
if self.privacy == 'public':
activity['to'] = [public]
activity['cc'] = [followers] + mentions
elif self.privacy == 'unlisted':
activity['to'] = [followers]
activity['cc'] = [public] + mentions
elif self.privacy == 'followers':
activity['to'] = [followers]
activity['cc'] = mentions
if self.privacy == 'direct':
activity['to'] = mentions
activity['cc'] = []
# "pure" serialization for non-bookwyrm instances
if pure:
activity['content'] = self.pure_content
if 'name' in activity:
activity['name'] = self.pure_name
activity['type'] = self.pure_type
activity['attachment'] = [
image_serializer(b.cover) for b in self.mention_books.all() \
if b.cover]
if hasattr(self, 'book'):
activity['attachment'].append(
image_serializer(self.book.cover)
)
return activity
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' update user active time ''' ''' update user active time '''
@ -151,40 +130,40 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
class GeneratedNote(Status): class GeneratedNote(Status):
''' these are app-generated messages about user activity ''' ''' these are app-generated messages about user activity '''
@property @property
def ap_pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
message = self.content message = self.content
books = ', '.join( books = ', '.join(
'<a href="%s">"%s"</a>' % (self.book.remote_id, self.book.title) \ '<a href="%s">"%s"</a>' % (book.remote_id, book.title) \
for book in self.mention_books.all() for book in self.mention_books.all()
) )
return '%s %s' % (message, books) return '%s %s %s' % (self.user.display_name, message, books)
activity_serializer = activitypub.GeneratedNote activity_serializer = activitypub.GeneratedNote
pure_activity_serializer = activitypub.Note pure_type = 'Note'
class Comment(Status): class Comment(Status):
''' like a review but without a rating and transient ''' ''' like a review but without a rating and transient '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT) book = fields.ForeignKey('Edition', on_delete=models.PROTECT)
@property @property
def ap_pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \ return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \
(self.book.remote_id, self.book.title) (self.book.remote_id, self.book.title)
activity_serializer = activitypub.Comment activity_serializer = activitypub.Comment
pure_activity_serializer = activitypub.Note pure_type = 'Note'
class Quotation(Status): class Quotation(Status):
''' like a review but without a rating and transient ''' ''' like a review but without a rating and transient '''
quote = models.TextField() quote = fields.TextField()
book = models.ForeignKey('Edition', on_delete=models.PROTECT) book = fields.ForeignKey('Edition', on_delete=models.PROTECT)
@property @property
def ap_pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
return '"%s"<br>-- <a href="%s">"%s"</a><br><br>%s' % ( return '"%s"<br>-- <a href="%s">"%s"</a><br><br>%s' % (
self.quote, self.quote,
@ -194,14 +173,14 @@ class Quotation(Status):
) )
activity_serializer = activitypub.Quotation activity_serializer = activitypub.Quotation
pure_activity_serializer = activitypub.Note pure_type = 'Note'
class Review(Status): class Review(Status):
''' a book review ''' ''' a book review '''
name = models.CharField(max_length=255, null=True) name = fields.CharField(max_length=255, null=True)
book = models.ForeignKey('Edition', on_delete=models.PROTECT) book = fields.ForeignKey('Edition', on_delete=models.PROTECT)
rating = models.IntegerField( rating = fields.IntegerField(
default=None, default=None,
null=True, null=True,
blank=True, blank=True,
@ -209,7 +188,7 @@ class Review(Status):
) )
@property @property
def ap_pure_name(self): def pure_name(self):
''' clarify review names for mastodon serialization ''' ''' clarify review names for mastodon serialization '''
if self.rating: if self.rating:
return 'Review of "%s" (%d stars): %s' % ( return 'Review of "%s" (%d stars): %s' % (
@ -223,26 +202,21 @@ class Review(Status):
) )
@property @property
def ap_pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \ return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \
(self.book.remote_id, self.book.title) (self.book.remote_id, self.book.title)
activity_serializer = activitypub.Review activity_serializer = activitypub.Review
pure_activity_serializer = activitypub.Article pure_type = 'Article'
class Favorite(ActivitypubMixin, BookWyrmModel): class Favorite(ActivitypubMixin, BookWyrmModel):
''' fav'ing a post ''' ''' fav'ing a post '''
user = models.ForeignKey('User', on_delete=models.PROTECT) user = fields.ForeignKey(
status = models.ForeignKey('Status', on_delete=models.PROTECT) 'User', on_delete=models.PROTECT, activitypub_field='actor')
status = fields.ForeignKey(
# ---- activitypub serialization settings for this model ----- # 'Status', on_delete=models.PROTECT, activitypub_field='object')
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'status'),
]
activity_serializer = activitypub.Like activity_serializer = activitypub.Like
@ -252,7 +226,6 @@ class Favorite(ActivitypubMixin, BookWyrmModel):
self.user.save() self.user.save()
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Meta: class Meta:
''' can't fav things twice ''' ''' can't fav things twice '''
unique_together = ('user', 'status') unique_together = ('user', 'status')
@ -260,16 +233,12 @@ class Favorite(ActivitypubMixin, BookWyrmModel):
class Boost(Status): class Boost(Status):
''' boost'ing a post ''' ''' boost'ing a post '''
boosted_status = models.ForeignKey( boosted_status = fields.ForeignKey(
'Status', 'Status',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="boosters") related_name='boosters',
activitypub_field='object',
activity_mappings = [ )
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'boosted_status'),
]
activity_serializer = activitypub.Boost activity_serializer = activitypub.Boost

View file

@ -88,8 +88,14 @@ class User(OrderedCollectionPageMixin, AbstractUser):
last_active_date = models.DateTimeField(auto_now=True) last_active_date = models.DateTimeField(auto_now=True)
manually_approves_followers = fields.BooleanField(default=False) manually_approves_followers = fields.BooleanField(default=False)
@property
def display_name(self):
''' show the cleanest version of the user's name possible '''
if self.name != '':
return self.name
return self.localname or self.username
activity_serializer = activitypub.Person activity_serializer = activitypub.Person
serialize_related = []
def to_outbox(self, **kwargs): def to_outbox(self, **kwargs):
''' an ordered collection of statuses ''' ''' an ordered collection of statuses '''
@ -112,7 +118,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
return self.to_ordered_collection(self.followers, \ return self.to_ordered_collection(self.followers, \
remote_id=remote_id, id_only=True, **kwargs) remote_id=remote_id, id_only=True, **kwargs)
def to_activity(self, pure=False): def to_activity(self):
''' override default AP serializer to add context object ''' override default AP serializer to add context object
idk if this is the best way to go about this ''' idk if this is the best way to go about this '''
activity_object = super().to_activity() activity_object = super().to_activity()