Use dataclasses to define activitypub (de)serialization (#177)
* Use dataclasses to define activitypub (de)serialization
This commit is contained in:
@ -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')}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user