Use dataclasses to define activitypub (de)serialization (#177)

* Use dataclasses to define activitypub (de)serialization
This commit is contained in:
Mouse Reeve
2020-09-17 13:02:52 -07:00
committed by GitHub
parent 2c0a07a330
commit 8bbf1fe252
46 changed files with 1449 additions and 1228 deletions

View File

@ -1,9 +1,17 @@
''' bring all the models into the app namespace '''
import inspect
import sys
from .book import Connector, Book, Work, Edition, Author
from .shelf import Shelf, ShelfBook
from .status import Status, Review, Comment, Quotation
from .status import Favorite, Boost, Tag, Notification, ReadThrough
from .user import User, UserFollows, UserFollowRequest, UserBlocks
from .user import FederatedServer
from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {c[0]: c[1].activity_serializer for c in cls_members \
if hasattr(c[1], 'activity_serializer')}

View File

@ -1,11 +1,21 @@
''' base model with default fields '''
from base64 import b64encode
from dataclasses import dataclass
from typing import Callable
from uuid import uuid4
from urllib.parse import urlencode
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from django.db import models
from django.dispatch import receiver
from fedireads import activitypub
from fedireads.settings import DOMAIN
class FedireadsModel(models.Model):
''' fields and functions for every model '''
''' shared fields '''
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
remote_id = models.CharField(max_length=255, null=True)
@ -19,6 +29,7 @@ class FedireadsModel(models.Model):
return '%s/%s/%d' % (base_path, model_name, self.id)
class Meta:
''' this is just here to provide default fields for other models '''
abstract = True
@ -30,3 +41,179 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
if not instance.remote_id:
instance.remote_id = instance.get_remote_id()
instance.save()
class ActivitypubMixin:
''' add this mixin for models that are AP serializable '''
activity_serializer = lambda: {}
def to_activity(self, pure=False):
''' convert from a model to an activity '''
if pure:
mappings = self.pure_activity_mappings
else:
mappings = self.activity_mappings
fields = {}
for mapping in mappings:
if not hasattr(self, mapping.model_key) or not mapping.activity_key:
continue
value = getattr(self, mapping.model_key)
if hasattr(value, 'remote_id'):
value = value.remote_id
fields[mapping.activity_key] = mapping.activity_formatter(value)
if pure:
return self.pure_activity_serializer(
**fields
).serialize()
return self.activity_serializer(
**fields
).serialize()
def to_create_activity(self, user, pure=False):
''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity(pure=pure)
signer = pkcs1_15.new(RSA.import_key(user.private_key))
content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
create_id = self.remote_id + '/activity'
signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id,
created=activity_object['published'],
signatureValue=b64encode(signed_message).decode('utf8')
)
return activitypub.Create(
id=create_id,
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
object=activity_object,
signature=signature,
).serialize()
def to_update_activity(self, user):
''' wrapper for Updates to an activity '''
activity_id = '%s#update/%s' % (user.remote_id, uuid4())
return activitypub.Update(
id=activity_id,
actor=user.remote_id,
to=['https://www.w3.org/ns/activitystreams#Public'],
object=self.to_activity()
).serialize()
def to_undo_activity(self, user):
''' undo an action '''
return activitypub.Undo(
id='%s#undo' % user.remote_id,
actor=user.remote_id,
object=self.to_activity()
)
class OrderedCollectionPageMixin(ActivitypubMixin):
''' just the paginator utilities, so you don't HAVE to
override ActivitypubMixin's to_activity (ie, for outbox '''
@property
def collection_remote_id(self):
''' this can be overriden if there's a special remote id, ie outbox '''
return self.remote_id
def page(self, min_id=None, max_id=None):
''' helper function to create the pagination url '''
params = {'page': 'true'}
if min_id:
params['min_id'] = min_id
if max_id:
params['max_id'] = max_id
return '?%s' % urlencode(params)
def next_page(self, items):
''' use the max id of the last item '''
if not items.count():
return ''
return self.page(max_id=items[items.count() - 1].id)
def prev_page(self, items):
''' use the min id of the first item '''
if not items.count():
return ''
return self.page(min_id=items[0].id)
def to_ordered_collection_page(self, queryset, remote_id, \
id_only=False, min_id=None, max_id=None):
''' serialize and pagiante a queryset '''
# TODO: weird place to define this
limit = 20
# filters for use in the django queryset min/max
filters = {}
if min_id is not None:
filters['id__gt'] = min_id
if max_id is not None:
filters['id__lte'] = max_id
page_id = self.page(min_id=min_id, max_id=max_id)
items = queryset.filter(
**filters
).all()[:limit]
if id_only:
page = [s.remote_id for s in items]
else:
page = [s.to_activity() for s in items]
return activitypub.OrderedCollectionPage(
id='%s%s' % (remote_id, page_id),
partOf=remote_id,
orderedItems=page,
next='%s%s' % (remote_id, self.next_page(items)),
prev='%s%s' % (remote_id, self.prev_page(items))
).serialize()
def to_ordered_collection(self, queryset, \
remote_id=None, page=False, **kwargs):
''' an ordered collection of whatevers '''
remote_id = remote_id or self.remote_id
if page:
return self.to_ordered_collection_page(
queryset, remote_id, **kwargs)
name = ''
if hasattr(self, 'name'):
name = self.name
size = queryset.count()
return activitypub.OrderedCollection(
id=remote_id,
totalItems=size,
name=name,
first='%s%s' % (remote_id, self.page()),
last='%s%s' % (remote_id, self.page(min_id=0))
).serialize()
class OrderedCollectionMixin(OrderedCollectionPageMixin):
''' extends activitypub models to work as ordered collections '''
@property
def collection_queryset(self):
''' usually an ordered collection model aggregates a different model '''
raise NotImplementedError('Model must define collection_queryset')
activity_serializer = activitypub.OrderedCollection
def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset '''
return self.to_ordered_collection(self.collection_queryset, **kwargs)
@dataclass(frozen=True)
class ActivityMapping:
''' translate between an activitypub json field and a model field '''
activity_key: str
model_key: str
activity_formatter: Callable = lambda x: x
model_formatter: Callable = lambda x: x

View File

@ -1,15 +1,16 @@
''' database schema for books and shelves '''
from django.utils import timezone
from django.db import models
from django.utils import timezone
from django.utils.http import http_date
from model_utils.managers import InheritanceManager
from fedireads import activitypub
from fedireads.settings import DOMAIN
from fedireads.utils.fields import ArrayField
from .base_model import FedireadsModel
from fedireads.connectors.settings import CONNECTORS
from .base_model import ActivityMapping, ActivitypubMixin, FedireadsModel
ConnectorFiles = models.TextChoices('ConnectorFiles', CONNECTORS)
class Connector(FedireadsModel):
@ -45,7 +46,7 @@ class Connector(FedireadsModel):
]
class Book(FedireadsModel):
class Book(ActivitypubMixin, FedireadsModel):
''' a generic book, which can mean either an edition or a work '''
# these identifiers apply to both works and editions
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
@ -86,6 +87,52 @@ class Book(FedireadsModel):
published_date = models.DateTimeField(blank=True, null=True)
objects = InheritanceManager()
@property
def ap_authors(self):
return [a.remote_id for a in self.authors.all()]
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('authors', 'ap_authors'),
ActivityMapping(
'first_published_date',
'first_published_date',
activity_formatter=lambda d: http_date(d.timestamp()) if d else None
),
ActivityMapping(
'published_date',
'published_date',
activity_formatter=lambda d: http_date(d.timestamp()) if d else None
),
ActivityMapping('title', 'title'),
ActivityMapping('sort_title', 'sort_title'),
ActivityMapping('subtitle', 'subtitle'),
ActivityMapping('description', 'description'),
ActivityMapping('languages', 'languages'),
ActivityMapping('series', 'series'),
ActivityMapping('series_number', 'series_number'),
ActivityMapping('subjects', 'subjects'),
ActivityMapping('subject_places', 'subject_places'),
ActivityMapping('openlibrary_key', 'openlibrary_key'),
ActivityMapping('librarything_key', 'librarything_key'),
ActivityMapping('goodreads_key', 'goodreads_key'),
ActivityMapping('work', 'parent_work'),
ActivityMapping('isbn_10', 'isbn_10'),
ActivityMapping('isbn_13', 'isbn_13'),
ActivityMapping('oclc_number', 'oclc_number'),
ActivityMapping('asin', 'asin'),
ActivityMapping('pages', 'pages'),
ActivityMapping('physical_format', 'physical_format'),
ActivityMapping('publishers', 'publishers'),
ActivityMapping('lccn', 'lccn'),
ActivityMapping('editions', 'editions_path'),
]
def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it '''
if not isinstance(self, Edition) and not isinstance(self, Work):
@ -106,7 +153,6 @@ class Book(FedireadsModel):
the remote canonical copy '''
return 'https://%s/book/%d' % (DOMAIN, self.id)
def __repr__(self):
return "<{} key={!r} title={!r}>".format(
self.__class__,
@ -114,16 +160,17 @@ class Book(FedireadsModel):
self.title,
)
@property
def activitypub_serialize(self):
return activitypub.get_book(self)
class Work(Book):
''' a work (an abstract concept of a book that manifests in an edition) '''
# library of congress catalog control number
lccn = models.CharField(max_length=255, blank=True, null=True)
@property
def editions_path(self):
return self.remote_id + '/editions'
@property
def default_edition(self):
ed = Edition.objects.filter(parent_work=self, default=True).first()
@ -131,6 +178,8 @@ class Work(Book):
ed = Edition.objects.filter(parent_work=self).first()
return ed
activity_serializer = activitypub.Work
class Edition(Book):
''' an edition of a book '''
@ -155,8 +204,10 @@ class Edition(Book):
)
parent_work = models.ForeignKey('Work', on_delete=models.PROTECT, null=True)
activity_serializer = activitypub.Edition
class Author(FedireadsModel):
class Author(ActivitypubMixin, FedireadsModel):
''' copy of an author from OL '''
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
sync = models.BooleanField(default=True)
@ -181,17 +232,25 @@ class Author(FedireadsModel):
the remote canonical copy (ditto here for author)'''
return 'https://%s/book/%d' % (DOMAIN, self.id)
@property
def activitypub_serialize(self):
return activitypub.get_author(self)
@property
def display_name(self):
''' Helper to return a displayable name'''
if self.name:
return name
return self.name
# don't want to return a spurious space if all of these are None
elif self.first_name and self.last_name:
if self.first_name and self.last_name:
return self.first_name + ' ' + self.last_name
else:
return self.last_name or self.first_name
return self.last_name or self.first_name
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'remote_id'),
ActivityMapping('name', 'display_name'),
ActivityMapping('born', 'born'),
ActivityMapping('died', 'died'),
ActivityMapping('aliases', 'aliases'),
ActivityMapping('bio', 'bio'),
ActivityMapping('openlibrary_key', 'openlibrary_key'),
ActivityMapping('wikipedia_link', 'wikipedia_link'),
]
activity_serializer = activitypub.Author

View File

@ -1,10 +1,12 @@
''' puttin' books on shelves '''
from django.db import models
from .base_model import FedireadsModel
from fedireads import activitypub
from .base_model import FedireadsModel, OrderedCollectionMixin
class Shelf(FedireadsModel):
class Shelf(OrderedCollectionMixin, FedireadsModel):
''' a list of books owned by a user '''
name = models.CharField(max_length=100)
identifier = models.CharField(max_length=100)
user = models.ForeignKey('User', on_delete=models.PROTECT)
@ -16,17 +18,23 @@ class Shelf(FedireadsModel):
through_fields=('shelf', 'book')
)
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''
return self.books
def get_remote_id(self):
''' shelf identifier instead of id '''
base_path = self.user.remote_id
return '%s/shelf/%s' % (base_path, self.identifier)
class Meta:
''' user/shelf unqiueness '''
unique_together = ('user', 'identifier')
class ShelfBook(FedireadsModel):
# many to many join table for books and shelves
''' many to many join table for books and shelves '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
added_by = models.ForeignKey(
@ -36,5 +44,26 @@ class ShelfBook(FedireadsModel):
on_delete=models.PROTECT
)
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.shelf.to_activity()
).serialize()
def to_remove_activity(self, user):
''' AP for un-shelving a book'''
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.shelf.to_activity()
).serialize()
class Meta:
''' an opinionated constraint!
you can't put a book on shelf twice '''
unique_together = ('book', 'shelf')

View File

@ -3,6 +3,7 @@ import base64
from Crypto import Random
from django.db import models
from django.utils import timezone
import datetime
from fedireads.settings import DOMAIN
from .user import User

View File

@ -2,23 +2,25 @@
import urllib.parse
from django.utils import timezone
from django.utils.http import http_date
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from model_utils.managers import InheritanceManager
from fedireads import activitypub
from .base_model import FedireadsModel
from fedireads.settings import DOMAIN
from .base_model import ActivitypubMixin, OrderedCollectionMixin, \
OrderedCollectionPageMixin
from .base_model import ActivityMapping, FedireadsModel
class Status(FedireadsModel):
class Status(OrderedCollectionPageMixin, FedireadsModel):
''' any post, like a reply to a review, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
status_type = models.CharField(max_length=255, default='Note')
content = models.TextField(blank=True, null=True)
mention_users = models.ManyToManyField('User', related_name='mention_user')
mention_books = models.ManyToManyField(
'Edition', related_name='mention_book')
activity_type = models.CharField(max_length=255, default='Note')
local = models.BooleanField(default=True)
privacy = models.CharField(max_length=255, default='public')
sensitive = models.BooleanField(default=False)
@ -38,40 +40,100 @@ class Status(FedireadsModel):
)
objects = InheritanceManager()
# ---- activitypub serialization settings for this model ----- #
@property
def activitypub_serialize(self):
return activitypub.get_status(self)
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
def ap_replies(self):
''' structured replies block '''
return self.to_replies()
shared_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'remote_id'),
ActivityMapping('inReplyTo', 'reply_parent'),
ActivityMapping(
'published',
'published_date',
activity_formatter=lambda d: http_date(d.timestamp())
),
ActivityMapping('attributedTo', 'user'),
ActivityMapping('to', 'ap_to'),
ActivityMapping('cc', 'ap_cc'),
ActivityMapping('replies', 'ap_replies'),
]
# serializing to fedireads 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', 'pure_ap_name'),
ActivityMapping('content', 'ap_pure_content'),
]
activity_serializer = activitypub.Note
#----- replies collection activitypub ----#
@classmethod
def replies(cls, status):
''' load all replies to a status. idk if there's a better way
to write this so it's just a property '''
return cls.objects.filter(reply_parent=status).select_subclasses()
def to_replies(self, **kwargs):
''' helper function for loading AP serialized replies to a status '''
return self.to_ordered_collection(
self.replies(self),
remote_id='%s/replies' % self.remote_id,
**kwargs
)
class Comment(Status):
''' like a review but without a rating and transient '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
def save(self, *args, **kwargs):
self.status_type = 'Comment'
self.activity_type = 'Note'
super().save(*args, **kwargs)
@property
def activitypub_serialize(self):
return activitypub.get_comment(self)
def ap_pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \
(self.book.local_id, self.book.title)
activity_serializer = activitypub.Comment
pure_activity_serializer = activitypub.Note
class Quotation(Status):
''' like a review but without a rating and transient '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
quote = models.TextField()
def save(self, *args, **kwargs):
self.status_type = 'Quotation'
self.activity_type = 'Note'
super().save(*args, **kwargs)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
@property
def activitypub_serialize(self):
return activitypub.get_quotation(self)
def ap_pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return '"%s"<br>-- <a href="%s">"%s"</a>)<br><br>%s' % (
self.quote,
self.book.local_id,
self.book.title,
self.content,
)
activity_serializer = activitypub.Quotation
class Review(Status):
@ -85,23 +147,41 @@ class Review(Status):
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
def save(self, *args, **kwargs):
self.status_type = 'Review'
self.activity_type = 'Article'
super().save(*args, **kwargs)
@property
def ap_pure_name(self):
''' clarify review names for mastodon serialization '''
return 'Review of "%s" (%d stars): %s' % (
self.book.title,
self.rating,
self.name
)
@property
def activitypub_serialize(self):
return activitypub.get_review(self)
def ap_pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \
(self.book.local_id, self.book.title)
activity_serializer = activitypub.Review
class Favorite(FedireadsModel):
class Favorite(ActivitypubMixin, FedireadsModel):
''' fav'ing a post '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
status = models.ForeignKey('Status', on_delete=models.PROTECT)
# ---- activitypub serialization settings for this model ----- #
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'status'),
]
activity_serializer = activitypub.Like
class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status')
@ -112,29 +192,69 @@ class Boost(Status):
on_delete=models.PROTECT,
related_name="boosters")
def save(self, *args, **kwargs):
self.status_type = 'Boost'
self.activity_type = 'Announce'
super().save(*args, **kwargs)
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'boosted_status'),
]
activity_serializer = activitypub.Like
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')
class Tag(FedireadsModel):
class Tag(OrderedCollectionMixin, FedireadsModel):
''' freeform tags for books '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
name = models.CharField(max_length=100)
identifier = models.CharField(max_length=100)
@classmethod
def book_queryset(cls, identifier):
''' county of books associated with this tag '''
return cls.objects.filter(identifier=identifier)
@property
def collection_queryset(self):
''' books associated with this tag '''
return self.book_queryset(self.identifier)
def get_remote_id(self):
''' tag should use identifier not id in remote_id '''
base_path = 'https://%s' % DOMAIN
return '%s/tag/%s' % (base_path, self.identifier)
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.to_activity(),
).serialize()
def to_remove_activity(self, user):
''' AP for un-shelving a book'''
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.to_activity(),
).serialize()
def save(self, *args, **kwargs):
''' create a url-safe lookup key for the tag '''
if not self.id:
# add identifiers to new tags
self.identifier = urllib.parse.quote_plus(self.name)
super().save(*args, **kwargs)
class Meta:
''' unqiueness constraint '''
unique_together = ('user', 'book', 'name')
@ -172,7 +292,9 @@ class Notification(FedireadsModel):
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices)
class Meta:
''' checks if notifcation is in enum list for valid types '''
constraints = [
models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values),

View File

@ -1,16 +1,20 @@
''' database schema for user data '''
from urllib.parse import urlparse
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.dispatch import receiver
from fedireads import activitypub
from fedireads.models.shelf import Shelf
from fedireads.models.status import Status
from fedireads.settings import DOMAIN
from fedireads.signatures import create_key_pair
from .base_model import FedireadsModel
from .base_model import OrderedCollectionPageMixin
from .base_model import ActivityMapping, FedireadsModel
class User(AbstractUser):
class User(OrderedCollectionPageMixin, AbstractUser):
''' a user who wants to read books '''
private_key = models.TextField(blank=True, null=True)
public_key = models.TextField(blank=True, null=True)
@ -66,9 +70,102 @@ class User(AbstractUser):
updated_date = models.DateTimeField(auto_now=True)
manually_approves_followers = models.BooleanField(default=False)
# ---- activitypub serialization settings for this model ----- #
@property
def activitypub_serialize(self):
return activitypub.get_actor(self)
def ap_followers(self):
''' generates url for activitypub followers page '''
return '%s/followers' % self.remote_id
@property
def ap_icon(self):
''' send default icon if one isn't set '''
if self.avatar:
url = self.avatar.url
# TODO not the right way to get the media type
media_type = 'image/%s' % url.split('.')[-1]
else:
url = '%s/static/images/default_avi.jpg' % DOMAIN
media_type = 'image/jpeg'
return activitypub.Image(media_type, url, 'Image')
@property
def ap_public_key(self):
''' format the public key block for activitypub '''
return activitypub.PublicKey(**{
'id': '%s/#main-key' % self.remote_id,
'owner': self.remote_id,
'publicKeyPem': self.public_key,
})
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping(
'preferredUsername',
'username',
activity_formatter=lambda x: x.split('@')[0]
),
ActivityMapping('name', 'name'),
ActivityMapping('inbox', 'inbox'),
ActivityMapping('outbox', 'outbox'),
ActivityMapping('followers', 'ap_followers'),
ActivityMapping('summary', 'summary'),
ActivityMapping(
'publicKey',
'public_key',
model_formatter=lambda x: x.get('publicKeyPem')
),
ActivityMapping('publicKey', 'ap_public_key'),
ActivityMapping(
'endpoints',
'shared_inbox',
activity_formatter=lambda x: {'sharedInbox': x},
model_formatter=lambda x: x.get('sharedInbox')
),
ActivityMapping('icon', 'ap_icon'),
ActivityMapping(
'manuallyApprovesFollowers',
'manually_approves_followers'
),
# this field isn't in the activity but should always be false
ActivityMapping(None, 'local', model_formatter=lambda x: False),
]
activity_serializer = activitypub.Person
def to_outbox(self, **kwargs):
''' an ordered collection of statuses '''
queryset = Status.objects.filter(
user=self,
).select_subclasses()
return self.to_ordered_collection(queryset, \
remote_id=self.outbox, **kwargs)
def to_following_activity(self, **kwargs):
''' activitypub following list '''
remote_id = '%s/following' % self.remote_id
return self.to_ordered_collection(self.following, \
remote_id=remote_id, id_only=True, **kwargs)
def to_followers_activity(self, **kwargs):
''' activitypub followers list '''
remote_id = '%s/followers' % self.remote_id
return self.to_ordered_collection(self.followers, \
remote_id=remote_id, id_only=True, **kwargs)
def to_activity(self, pure=False):
''' override default AP serializer to add context object
idk if this is the best way to go about this '''
activity_object = super().to_activity()
activity_object['@context'] = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'schema': 'http://schema.org#',
'PropertyValue': 'schema:PropertyValue',
'value': 'schema:value',
}
]
return activity_object
class UserRelationship(FedireadsModel):
@ -87,6 +184,7 @@ class UserRelationship(FedireadsModel):
relationship_id = models.CharField(max_length=100)
class Meta:
''' relationships should be unique '''
abstract = True
constraints = [
models.UniqueConstraint(
@ -106,12 +204,14 @@ class UserRelationship(FedireadsModel):
class UserFollows(UserRelationship):
''' Following a user '''
@property
def status(self):
return 'follows'
@classmethod
def from_request(cls, follow_request):
''' converts a follow request into a follow relationship '''
return cls(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
@ -120,10 +220,35 @@ class UserFollows(UserRelationship):
class UserFollowRequest(UserRelationship):
''' following a user requires manual or automatic confirmation '''
@property
def status(self):
return 'follow_request'
def to_activity(self):
''' request activity '''
return activitypub.Follow(
id=self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
).serialize()
def to_accept_activity(self):
''' generate an Accept for this follow request '''
return activitypub.Accept(
id='%s#accepts/follows/' % self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
).serialize()
def to_reject_activity(self):
''' generate an Accept for this follow request '''
return activitypub.Reject(
id='%s#rejects/follows/' % self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
).serialize()
class UserBlocks(UserRelationship):
@property
@ -145,7 +270,12 @@ class FederatedServer(FedireadsModel):
def execute_before_save(sender, instance, *args, **kwargs):
''' populate fields for new local users '''
# this user already exists, no need to poplate fields
if instance.id or not instance.local:
if instance.id:
return
if not instance.local:
# we need to generate a username that uses the domain (webfinger format)
actor_parts = urlparse(instance.remote_id)
instance.username = '%s@%s' % (instance.username, actor_parts.netloc)
return
# populate fields for local users