Use dataclasses to define activitypub (de)serialization (#177)
* Use dataclasses to define activitypub (de)serialization
This commit is contained in:
parent
2c0a07a330
commit
8bbf1fe252
46 changed files with 1449 additions and 1228 deletions
|
@ -1,16 +1,19 @@
|
|||
''' bring activitypub functions into the namespace '''
|
||||
from .actor import get_actor
|
||||
from .book import get_book, get_author, get_shelf
|
||||
from .create import get_create, get_update
|
||||
from .follow import get_following, get_followers
|
||||
from .follow import get_follow_request, get_unfollow, get_accept, get_reject
|
||||
from .outbox import get_outbox, get_outbox_page
|
||||
from .shelve import get_add, get_remove
|
||||
from .status import get_review, get_review_article
|
||||
from .status import get_rating, get_rating_note
|
||||
from .status import get_comment, get_comment_article
|
||||
from .status import get_quotation, get_quotation_article
|
||||
from .status import get_status, get_replies, get_replies_page
|
||||
from .status import get_favorite, get_unfavorite
|
||||
from .status import get_boost
|
||||
from .status import get_add_tag, get_remove_tag
|
||||
import inspect
|
||||
import sys
|
||||
|
||||
from .base_activity import ActivityEncoder, Image, PublicKey, Signature
|
||||
from .note import Note, Article, Comment, Review, Quotation
|
||||
from .interaction import Boost, Like
|
||||
from .ordered_collection import OrderedCollection, OrderedCollectionPage
|
||||
from .person import Person
|
||||
from .book import Edition, Work, Author
|
||||
from .verbs import Create, Undo, Update
|
||||
from .verbs import Follow, Accept, Reject
|
||||
from .verbs import Add, Remove
|
||||
|
||||
# this creates a list of all the Activity types that we can serialize,
|
||||
# so when an Activity comes in from outside, we can check if it's known
|
||||
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||
activity_objects = {c[0]: c[1] for c in cls_members \
|
||||
if hasattr(c[1], 'to_model')}
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
''' actor serializer '''
|
||||
from fedireads.settings import DOMAIN
|
||||
|
||||
def get_actor(user):
|
||||
''' activitypub actor from db User '''
|
||||
avatar = user.avatar
|
||||
|
||||
icon_path = '/static/images/default_avi.jpg'
|
||||
icon_type = 'image/jpeg'
|
||||
if avatar:
|
||||
icon_path = avatar.url
|
||||
icon_type = 'image/%s' % icon_path.split('.')[-1]
|
||||
|
||||
icon_url = 'https://%s%s' % (DOMAIN, icon_path)
|
||||
return {
|
||||
'@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",
|
||||
},
|
||||
],
|
||||
|
||||
'id': user.remote_id,
|
||||
'type': 'Person',
|
||||
'preferredUsername': user.localname,
|
||||
'name': user.name,
|
||||
'inbox': user.inbox,
|
||||
'outbox': '%s/outbox' % user.remote_id,
|
||||
'followers': '%s/followers' % user.remote_id,
|
||||
'following': '%s/following' % user.remote_id,
|
||||
'summary': user.summary,
|
||||
'publicKey': {
|
||||
'id': '%s/#main-key' % user.remote_id,
|
||||
'owner': user.remote_id,
|
||||
'publicKeyPem': user.public_key,
|
||||
},
|
||||
'endpoints': {
|
||||
'sharedInbox': user.shared_inbox,
|
||||
},
|
||||
'fedireadsUser': True,
|
||||
'manuallyApprovesFollowers': user.manually_approves_followers,
|
||||
"icon": {
|
||||
"type": "Image",
|
||||
"mediaType": icon_type,
|
||||
"url": icon_url,
|
||||
},
|
||||
}
|
118
fedireads/activitypub/base_activity.py
Normal file
118
fedireads/activitypub/base_activity.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
''' basics for an activitypub serializer '''
|
||||
from dataclasses import dataclass, fields, MISSING
|
||||
from json import JSONEncoder
|
||||
|
||||
from django.db.models.fields.related_descriptors \
|
||||
import ForwardManyToOneDescriptor
|
||||
|
||||
|
||||
class ActivityEncoder(JSONEncoder):
|
||||
''' used to convert an Activity object into json '''
|
||||
def default(self, o):
|
||||
return o.__dict__
|
||||
|
||||
|
||||
@dataclass
|
||||
class Image:
|
||||
''' image block '''
|
||||
mediaType: str
|
||||
url: str
|
||||
type: str = 'Image'
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublicKey:
|
||||
''' public key block '''
|
||||
id: str
|
||||
owner: str
|
||||
publicKeyPem: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Signature:
|
||||
''' public key block '''
|
||||
creator: str
|
||||
created: str
|
||||
signatureValue: str
|
||||
type: str = 'RsaSignature2017'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class ActivityObject:
|
||||
''' actor activitypub json '''
|
||||
id: str
|
||||
type: str
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
''' this lets you pass in an object with fields
|
||||
that aren't in the dataclass, which it ignores.
|
||||
Any field in the dataclass is required or has a
|
||||
default value '''
|
||||
for field in fields(self):
|
||||
try:
|
||||
value = kwargs[field.name]
|
||||
except KeyError:
|
||||
if field.default == MISSING:
|
||||
raise TypeError('Missing required field: %s' % field.name)
|
||||
value = field.default
|
||||
setattr(self, field.name, value)
|
||||
|
||||
|
||||
def to_model(self, model, instance=None):
|
||||
''' convert from an activity to a model '''
|
||||
if not isinstance(self, model.activity_serializer):
|
||||
raise TypeError('Wrong activity type for model')
|
||||
|
||||
model_fields = [m.name for m in model._meta.get_fields()]
|
||||
mapped_fields = {}
|
||||
|
||||
for mapping in model.activity_mappings:
|
||||
if mapping.model_key not in model_fields:
|
||||
continue
|
||||
# value is None if there's a default that isn't supplied
|
||||
# in the activity but is supplied in the formatter
|
||||
value = None
|
||||
if mapping.activity_key:
|
||||
value = getattr(self, mapping.activity_key)
|
||||
model_field = getattr(model, mapping.model_key)
|
||||
|
||||
# remote_id -> foreign key resolver
|
||||
if isinstance(model_field, ForwardManyToOneDescriptor) and value:
|
||||
fk_model = model_field.field.related_model
|
||||
value = resolve_foreign_key(fk_model, value)
|
||||
|
||||
mapped_fields[mapping.model_key] = mapping.model_formatter(value)
|
||||
|
||||
|
||||
# updating an existing model isntance
|
||||
if instance:
|
||||
for k, v in mapped_fields.items():
|
||||
setattr(instance, k, v)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
# creating a new model instance
|
||||
return model.objects.create(**mapped_fields)
|
||||
|
||||
|
||||
def serialize(self):
|
||||
''' convert to dictionary with context attr '''
|
||||
data = self.__dict__
|
||||
data['@context'] = 'https://www.w3.org/ns/activitystreams'
|
||||
return data
|
||||
|
||||
|
||||
def resolve_foreign_key(model, remote_id):
|
||||
''' look up the remote_id on an activity json field '''
|
||||
result = model.objects
|
||||
if hasattr(model.objects, 'select_subclasses'):
|
||||
result = result.select_subclasses()
|
||||
|
||||
result = result.filter(
|
||||
remote_id=remote_id
|
||||
).first()
|
||||
|
||||
if not result:
|
||||
raise ValueError('Could not resolve remote_id in %s model: %s' % \
|
||||
(model.__name__, remote_id))
|
||||
return result
|
|
@ -1,127 +1,67 @@
|
|||
''' federate book data '''
|
||||
from fedireads.settings import DOMAIN
|
||||
''' book and author data '''
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
def get_book(book, recursive=True):
|
||||
''' activitypub serialize a book '''
|
||||
from .base_activity import ActivityObject, Image
|
||||
|
||||
fields = [
|
||||
'title',
|
||||
'sort_title',
|
||||
'subtitle',
|
||||
'isbn_13',
|
||||
'oclc_number',
|
||||
'openlibrary_key',
|
||||
'librarything_key',
|
||||
'lccn',
|
||||
'oclc_number',
|
||||
'pages',
|
||||
'physical_format',
|
||||
'misc_identifiers',
|
||||
@dataclass(init=False)
|
||||
class Book(ActivityObject):
|
||||
''' serializes an edition or work, abstract '''
|
||||
authors: List[str]
|
||||
first_published_date: str
|
||||
published_date: str
|
||||
|
||||
'description',
|
||||
'languages',
|
||||
'series',
|
||||
'series_number',
|
||||
'subjects',
|
||||
'subject_places',
|
||||
'pages',
|
||||
'physical_format',
|
||||
]
|
||||
title: str
|
||||
sort_title: str
|
||||
subtitle: str
|
||||
description: str
|
||||
languages: List[str]
|
||||
series: str
|
||||
series_number: str
|
||||
subjects: List[str]
|
||||
subject_places: List[str]
|
||||
|
||||
book_type = type(book).__name__
|
||||
activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Document',
|
||||
'book_type': book_type,
|
||||
'name': book.title,
|
||||
'url': book.local_id,
|
||||
openlibrary_key: str
|
||||
librarything_key: str
|
||||
goodreads_key: str
|
||||
|
||||
'authors': [a.local_id for a in book.authors.all()],
|
||||
'first_published_date': book.first_published_date.isoformat() if \
|
||||
book.first_published_date else None,
|
||||
'published_date': book.published_date.isoformat() if \
|
||||
book.published_date else None,
|
||||
}
|
||||
if recursive:
|
||||
if book_type == 'Edition':
|
||||
activity['work'] = get_book(book.parent_work, recursive=False)
|
||||
else:
|
||||
editions = book.edition_set.order_by('default')
|
||||
activity['editions'] = [
|
||||
get_book(b, recursive=False) for b in editions]
|
||||
|
||||
for field in fields:
|
||||
if hasattr(book, field):
|
||||
activity[field] = book.__getattribute__(field)
|
||||
|
||||
if book.cover:
|
||||
image_path = book.cover.url
|
||||
image_type = image_path.split('.')[-1]
|
||||
activity['attachment'] = [{
|
||||
'type': 'Document',
|
||||
'mediaType': 'image/%s' % image_type,
|
||||
'url': 'https://%s%s' % (DOMAIN, image_path),
|
||||
'name': 'Cover of "%s"' % book.title,
|
||||
}]
|
||||
return {k: v for (k, v) in activity.items() if v}
|
||||
attachment: List[Image] = field(default=lambda: [])
|
||||
type: str = 'Book'
|
||||
|
||||
|
||||
def get_author(author):
|
||||
''' serialize an author '''
|
||||
fields = [
|
||||
'name',
|
||||
'born',
|
||||
'died',
|
||||
'aliases',
|
||||
'bio'
|
||||
'openlibrary_key',
|
||||
'wikipedia_link',
|
||||
]
|
||||
activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'url': author.local_id,
|
||||
'type': 'Person',
|
||||
}
|
||||
for field in fields:
|
||||
if hasattr(author, field):
|
||||
activity[field] = author.__getattribute__(field)
|
||||
return activity
|
||||
@dataclass(init=False)
|
||||
class Edition(Book):
|
||||
''' Edition instance of a book object '''
|
||||
isbn_10: str
|
||||
isbn_13: str
|
||||
oclc_number: str
|
||||
asin: str
|
||||
pages: str
|
||||
physical_format: str
|
||||
publishers: List[str]
|
||||
|
||||
work: str
|
||||
type: str = 'Edition'
|
||||
|
||||
|
||||
def get_shelf(shelf, page=None):
|
||||
''' serialize shelf object '''
|
||||
id_slug = shelf.remote_id
|
||||
if page:
|
||||
return get_shelf_page(shelf, page)
|
||||
count = shelf.books.count()
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': id_slug,
|
||||
'type': 'OrderedCollection',
|
||||
'totalItems': count,
|
||||
'first': '%s?page=1' % id_slug,
|
||||
}
|
||||
@dataclass(init=False)
|
||||
class Work(Book):
|
||||
''' work instance of a book object '''
|
||||
lccn: str
|
||||
editions: List[str]
|
||||
type: str = 'Work'
|
||||
|
||||
|
||||
def get_shelf_page(shelf, page):
|
||||
''' list of books on a shelf '''
|
||||
page = int(page)
|
||||
page_length = 10
|
||||
start = (page - 1) * page_length
|
||||
end = start + page_length
|
||||
shelf_page = shelf.books.all()[start:end]
|
||||
id_slug = shelf.local_id
|
||||
data = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': '%s?page=%d' % (id_slug, page),
|
||||
'type': 'OrderedCollectionPage',
|
||||
'totalItems': shelf.books.count(),
|
||||
'partOf': id_slug,
|
||||
'orderedItems': [get_book(b) for b in shelf_page],
|
||||
}
|
||||
if end <= shelf.books.count():
|
||||
# there are still more pages
|
||||
data['next'] = '%s?page=%d' % (id_slug, page + 1)
|
||||
if start > 0:
|
||||
data['prev'] = '%s?page=%d' % (id_slug, page - 1)
|
||||
return data
|
||||
|
||||
@dataclass(init=False)
|
||||
class Author(ActivityObject):
|
||||
''' author of a book '''
|
||||
url: str
|
||||
name: str
|
||||
born: str
|
||||
died: str
|
||||
aliases: str
|
||||
bio: str
|
||||
openlibrary_key: str
|
||||
wikipedia_link: str
|
||||
type: str = 'Person'
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
''' format Create activities and sign them '''
|
||||
from base64 import b64encode
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Signature import pkcs1_15
|
||||
from Crypto.Hash import SHA256
|
||||
|
||||
def get_create(user, status_json):
|
||||
''' create activitypub json for a Create activity '''
|
||||
signer = pkcs1_15.new(RSA.import_key(user.private_key))
|
||||
content = status_json['content']
|
||||
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
|
||||
'id': '%s/activity' % status_json['id'],
|
||||
'type': 'Create',
|
||||
'actor': user.remote_id,
|
||||
'published': status_json['published'],
|
||||
|
||||
'to': ['%s/followers' % user.remote_id],
|
||||
'cc': ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
|
||||
'object': status_json,
|
||||
'signature': {
|
||||
'type': 'RsaSignature2017',
|
||||
'creator': '%s#main-key' % user.remote_id,
|
||||
'created': status_json['published'],
|
||||
'signatureValue': b64encode(signed_message).decode('utf8'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_update(user, activity_json):
|
||||
''' a user profile or book or whatever got updated '''
|
||||
# TODO: should this have a signature??
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': 'https://friend.camp/users/tripofmice#updates/1585446332',
|
||||
'type': 'Update',
|
||||
'actor': user.remote_id,
|
||||
'to': [
|
||||
'https://www.w3.org/ns/activitystreams#Public'
|
||||
],
|
||||
|
||||
'object': activity_json,
|
||||
}
|
|
@ -1,113 +0,0 @@
|
|||
''' makin' freinds inthe ap json format '''
|
||||
from uuid import uuid4
|
||||
|
||||
from fedireads.settings import DOMAIN
|
||||
|
||||
|
||||
def get_follow_request(user, to_follow):
|
||||
''' a local user wants to follow someone '''
|
||||
uuid = uuid4()
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': 'https://%s/%s' % (DOMAIN, str(uuid)),
|
||||
'summary': '',
|
||||
'type': 'Follow',
|
||||
'actor': user.remote_id,
|
||||
'object': to_follow.remote_id,
|
||||
}
|
||||
|
||||
def get_unfollow(relationship):
|
||||
''' undo that precious bond of friendship '''
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': '%s/undo' % relationship.remote_id,
|
||||
'type': 'Undo',
|
||||
'actor': relationship.user_subject.remote_id,
|
||||
'object': {
|
||||
'id': relationship.relationship_id,
|
||||
'type': 'Follow',
|
||||
'actor': relationship.user_subject.remote_id,
|
||||
'object': relationship.user_object.remote_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_accept(user, relationship):
|
||||
''' accept a follow request '''
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': '%s#accepts/follows/' % user.remote_id,
|
||||
'type': 'Accept',
|
||||
'actor': user.remote_id,
|
||||
'object': {
|
||||
'id': relationship.relationship_id,
|
||||
'type': 'Follow',
|
||||
'actor': relationship.user_subject.remote_id,
|
||||
'object': relationship.user_object.remote_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_reject(user, relationship):
|
||||
''' reject a follow request '''
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': '%s#rejects/follows/' % user.remote_id,
|
||||
'type': 'Reject',
|
||||
'actor': user.remote_id,
|
||||
'object': {
|
||||
'id': relationship.relationship_id,
|
||||
'type': 'Follow',
|
||||
'actor': relationship.user_subject.remote_id,
|
||||
'object': relationship.user_object.remote_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_followers(user, page, follow_queryset):
|
||||
''' list of people who follow a user '''
|
||||
id_slug = '%s/followers' % user.remote_id
|
||||
return get_follow_info(id_slug, page, follow_queryset)
|
||||
|
||||
|
||||
def get_following(user, page, follow_queryset):
|
||||
''' list of people who follow a user '''
|
||||
id_slug = '%s/following' % user.remote_id
|
||||
return get_follow_info(id_slug, page, follow_queryset)
|
||||
|
||||
|
||||
def get_follow_info(id_slug, page, follow_queryset):
|
||||
''' a list of followers or following '''
|
||||
if page:
|
||||
return get_follow_page(follow_queryset, id_slug, page)
|
||||
count = follow_queryset.count()
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': id_slug,
|
||||
'type': 'OrderedCollection',
|
||||
'totalItems': count,
|
||||
'first': '%s?page=1' % id_slug,
|
||||
}
|
||||
|
||||
|
||||
def get_follow_page(user_list, id_slug, page):
|
||||
''' format a list of followers/following '''
|
||||
page = int(page)
|
||||
page_length = 10
|
||||
start = (page - 1) * page_length
|
||||
end = start + page_length
|
||||
follower_page = user_list.all()[start:end]
|
||||
data = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': '%s?page=%d' % (id_slug, page),
|
||||
'type': 'OrderedCollectionPage',
|
||||
'totalItems': user_list.count(),
|
||||
'partOf': id_slug,
|
||||
'orderedItems': [u.remote_id for u in follower_page],
|
||||
}
|
||||
if end <= user_list.count():
|
||||
# there are still more pages
|
||||
data['next'] = '%s?page=%d' % (id_slug, page + 1)
|
||||
if start > 0:
|
||||
data['prev'] = '%s?page=%d' % (id_slug, page - 1)
|
||||
return data
|
20
fedireads/activitypub/interaction.py
Normal file
20
fedireads/activitypub/interaction.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
''' boosting and liking posts '''
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .base_activity import ActivityObject
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Like(ActivityObject):
|
||||
''' a user faving an object '''
|
||||
actor: str
|
||||
object: str
|
||||
type: str = 'Like'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Boost(ActivityObject):
|
||||
''' boosting a status '''
|
||||
actor: str
|
||||
object: str
|
||||
type: str = 'Announce'
|
50
fedireads/activitypub/note.py
Normal file
50
fedireads/activitypub/note.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
''' note serializer and children thereof '''
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
|
||||
from .base_activity import ActivityObject, Image
|
||||
|
||||
@dataclass(init=False)
|
||||
class Note(ActivityObject):
|
||||
''' Note activity '''
|
||||
url: str
|
||||
inReplyTo: str
|
||||
published: str
|
||||
attributedTo: str
|
||||
to: List[str]
|
||||
cc: List[str]
|
||||
content: str
|
||||
replies: Dict
|
||||
# TODO: this is wrong???
|
||||
attachment: List[Image] = field(default=lambda: [])
|
||||
sensitive: bool = False
|
||||
type: str = 'Note'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Article(Note):
|
||||
''' what's an article except a note with more fields '''
|
||||
name: str
|
||||
type: str = 'Article'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Comment(Note):
|
||||
''' like a note but with a book '''
|
||||
inReplyToBook: str
|
||||
type: str = 'Comment'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Review(Comment):
|
||||
''' a full book review '''
|
||||
name: str
|
||||
rating: int
|
||||
type: str = 'Review'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Quotation(Comment):
|
||||
''' a quote and commentary on a book '''
|
||||
quote: str
|
||||
type: str = 'Quotation'
|
25
fedireads/activitypub/ordered_collection.py
Normal file
25
fedireads/activitypub/ordered_collection.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
''' defines activitypub collections (lists) '''
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from .base_activity import ActivityObject
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class OrderedCollection(ActivityObject):
|
||||
''' structure of an ordered collection activity '''
|
||||
totalItems: int
|
||||
first: str
|
||||
last: str = ''
|
||||
name: str = ''
|
||||
type: str = 'OrderedCollection'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class OrderedCollectionPage(ActivityObject):
|
||||
''' structure of an ordered collection activity '''
|
||||
partOf: str
|
||||
orderedItems: List
|
||||
next: str
|
||||
prev: str
|
||||
type: str = 'OrderedCollectionPage'
|
|
@ -1,43 +0,0 @@
|
|||
''' activitypub json for collections '''
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .status import get_status, get_review
|
||||
|
||||
def get_outbox(user, size):
|
||||
''' helper function for creating an outbox '''
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': user.outbox,
|
||||
'type': 'OrderedCollection',
|
||||
'totalItems': size,
|
||||
'first': '%s?page=true' % user.outbox,
|
||||
'last': '%s?min_id=0&page=true' % user.outbox
|
||||
}
|
||||
|
||||
|
||||
def get_outbox_page(user, page_id, statuses, max_id, min_id):
|
||||
''' helper for formatting outbox pages '''
|
||||
# not generalizing this more because the format varies for some reason
|
||||
page = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': page_id,
|
||||
'type': 'OrderedCollectionPage',
|
||||
'partOf': user.outbox,
|
||||
'orderedItems': [],
|
||||
}
|
||||
|
||||
for status in statuses:
|
||||
if status.status_type == 'Review':
|
||||
status_activity = get_review(status)
|
||||
else:
|
||||
status_activity = get_status(status)
|
||||
page['orderedItems'].append(status_activity)
|
||||
|
||||
if max_id:
|
||||
page['next'] = user.outbox + '?' + \
|
||||
urlencode({'min_id': max_id, 'page': 'true'})
|
||||
if min_id:
|
||||
page['prev'] = user.outbox + '?' + \
|
||||
urlencode({'max_id': min_id, 'page': 'true'})
|
||||
|
||||
return page
|
22
fedireads/activitypub/person.py
Normal file
22
fedireads/activitypub/person.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
''' actor serializer '''
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict
|
||||
|
||||
from .base_activity import ActivityObject, Image, PublicKey
|
||||
|
||||
@dataclass(init=False)
|
||||
class Person(ActivityObject):
|
||||
''' actor activitypub json '''
|
||||
preferredUsername: str
|
||||
name: str
|
||||
inbox: str
|
||||
outbox: str
|
||||
followers: str
|
||||
summary: str
|
||||
publicKey: PublicKey
|
||||
endpoints: Dict
|
||||
icon: Image = field(default=lambda: {})
|
||||
fedireadsUser: str = False
|
||||
manuallyApprovesFollowers: str = False
|
||||
discoverable: str = True
|
||||
type: str = 'Person'
|
|
@ -1,32 +0,0 @@
|
|||
''' activitypub json for collections '''
|
||||
from uuid import uuid4
|
||||
|
||||
def get_add(*args):
|
||||
''' activitypub Add activity '''
|
||||
return get_add_remove(*args, action='Add')
|
||||
|
||||
|
||||
def get_remove(*args):
|
||||
''' activitypub Add activity '''
|
||||
return get_add_remove(*args, action='Remove')
|
||||
|
||||
|
||||
def get_add_remove(user, book, shelf, action='Add'):
|
||||
''' format a shelve book json blob '''
|
||||
uuid = uuid4()
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': str(uuid),
|
||||
'type': action,
|
||||
'actor': user.remote_id,
|
||||
'object': {
|
||||
'type': 'Document',
|
||||
'name': book.title,
|
||||
'url': book.local_id,
|
||||
},
|
||||
'target': {
|
||||
'type': 'Collection',
|
||||
'name': shelf.name,
|
||||
'id': shelf.remote_id,
|
||||
}
|
||||
}
|
|
@ -1,254 +0,0 @@
|
|||
''' status serializers '''
|
||||
from uuid import uuid4
|
||||
|
||||
from fedireads.settings import DOMAIN
|
||||
|
||||
|
||||
def get_rating(review):
|
||||
''' activitypub serialize rating activity '''
|
||||
status = get_status(review)
|
||||
status['inReplyToBook'] = review.book.local_id
|
||||
status['fedireadsType'] = review.status_type
|
||||
status['rating'] = review.rating
|
||||
status['content'] = '%d star rating of "%s"' % (
|
||||
review.rating, review.book.title)
|
||||
return status
|
||||
|
||||
|
||||
def get_quotation(quotation):
|
||||
''' fedireads json for quotations '''
|
||||
status = get_status(quotation)
|
||||
status['inReplyToBook'] = quotation.book.local_id
|
||||
status['fedireadsType'] = quotation.status_type
|
||||
status['quote'] = quotation.quote
|
||||
return status
|
||||
|
||||
|
||||
def get_quotation_article(quotation):
|
||||
''' a book quotation formatted for a non-fedireads isntance (mastodon) '''
|
||||
status = get_status(quotation)
|
||||
content = '"%s"<br>-- <a href="%s">"%s"</a>)<br><br>%s' % (
|
||||
quotation.quote,
|
||||
quotation.book.local_id,
|
||||
quotation.book.title,
|
||||
quotation.content,
|
||||
)
|
||||
status['content'] = content
|
||||
return status
|
||||
|
||||
|
||||
def get_review(review):
|
||||
''' fedireads json for book reviews '''
|
||||
status = get_status(review)
|
||||
status['inReplyToBook'] = review.book.local_id
|
||||
status['fedireadsType'] = review.status_type
|
||||
status['name'] = review.name
|
||||
status['rating'] = review.rating
|
||||
return status
|
||||
|
||||
|
||||
def get_comment(comment):
|
||||
''' fedireads json for book reviews '''
|
||||
status = get_status(comment)
|
||||
status['inReplyToBook'] = comment.book.local_id
|
||||
status['fedireadsType'] = comment.status_type
|
||||
return status
|
||||
|
||||
|
||||
def get_rating_note(review):
|
||||
''' simple rating, send it as a note not an artciel '''
|
||||
status = get_status(review)
|
||||
status['content'] = 'Rated "%s": %d stars' % (
|
||||
review.book.title,
|
||||
review.rating,
|
||||
)
|
||||
status['type'] = 'Note'
|
||||
return status
|
||||
|
||||
def get_review_article(review):
|
||||
''' a book review formatted for a non-fedireads isntance (mastodon) '''
|
||||
status = get_status(review)
|
||||
if review.rating:
|
||||
status['name'] = 'Review of "%s" (%d stars): %s' % (
|
||||
review.book.title,
|
||||
review.rating,
|
||||
review.name
|
||||
)
|
||||
else:
|
||||
status['name'] = 'Review of "%s": %s' % (
|
||||
review.book.title,
|
||||
review.name
|
||||
)
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def get_comment_article(comment):
|
||||
''' a book comment formatted for a non-fedireads isntance (mastodon) '''
|
||||
status = get_status(comment)
|
||||
status['content'] += '<br><br>(comment on <a href="%s">"%s"</a>)' % \
|
||||
(comment.book.local_id, comment.book.title)
|
||||
return status
|
||||
|
||||
|
||||
def get_status(status):
|
||||
''' create activitypub json for a status '''
|
||||
user = status.user
|
||||
uri = status.remote_id
|
||||
reply_parent_id = status.reply_parent.remote_id \
|
||||
if status.reply_parent else None
|
||||
|
||||
image_attachments = []
|
||||
books = list(status.mention_books.all()[:3])
|
||||
if hasattr(status, 'book'):
|
||||
books.append(status.book)
|
||||
for book in books:
|
||||
if book and book.cover:
|
||||
image_path = book.cover.url
|
||||
image_type = image_path.split('.')[-1]
|
||||
image_attachments.append({
|
||||
'type': 'Document',
|
||||
'mediaType': 'image/%s' % image_type,
|
||||
'url': 'https://%s%s' % (DOMAIN, image_path),
|
||||
'name': 'Cover of "%s"' % book.title,
|
||||
})
|
||||
status_json = {
|
||||
'id': uri,
|
||||
'url': uri,
|
||||
'inReplyTo': reply_parent_id,
|
||||
'published': status.published_date.isoformat(),
|
||||
'attributedTo': user.remote_id,
|
||||
# TODO: assuming all posts are public -- should check privacy db field
|
||||
'to': ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'cc': ['%s/followers' % user.remote_id],
|
||||
'sensitive': status.sensitive,
|
||||
'content': status.content,
|
||||
'type': status.activity_type,
|
||||
'attachment': image_attachments,
|
||||
'replies': {
|
||||
'id': '%s/replies' % uri,
|
||||
'type': 'Collection',
|
||||
'first': {
|
||||
'type': 'CollectionPage',
|
||||
'next': '%s/replies?only_other_accounts=true&page=true' % uri,
|
||||
'partOf': '%s/replies' % uri,
|
||||
'items': [], # TODO: populate with replies
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return status_json
|
||||
|
||||
|
||||
def get_replies(status, replies):
|
||||
''' collection of replies '''
|
||||
id_slug = status.remote_id + '/replies'
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': id_slug,
|
||||
'type': 'Collection',
|
||||
'first': {
|
||||
'id': '%s?page=true' % id_slug,
|
||||
'type': 'CollectionPage',
|
||||
'next': '%s?only_other_accounts=true&page=true' % id_slug,
|
||||
'partOf': id_slug,
|
||||
'items': [get_status(r) for r in replies],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_replies_page(status, replies):
|
||||
''' actual reply list content '''
|
||||
id_slug = status.remote_id + '/replies?page=true&only_other_accounts=true'
|
||||
items = []
|
||||
for reply in replies:
|
||||
if reply.user.local:
|
||||
items.append(get_status(reply))
|
||||
else:
|
||||
items.append(reply.remote_id)
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': id_slug,
|
||||
'type': 'CollectionPage',
|
||||
'next': '%s&min_id=%d' % (id_slug, replies[len(replies) - 1].id),
|
||||
'partOf': status.remote_id + '/replies',
|
||||
'items': [items]
|
||||
}
|
||||
|
||||
|
||||
def get_favorite(favorite):
|
||||
''' like a post '''
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': favorite.remote_id,
|
||||
'type': 'Like',
|
||||
'actor': favorite.user.remote_id,
|
||||
'object': favorite.status.remote_id,
|
||||
}
|
||||
|
||||
|
||||
def get_unfavorite(favorite):
|
||||
''' like a post '''
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': '%s/undo' % favorite.remote_id,
|
||||
'type': 'Undo',
|
||||
'actor': favorite.user.remote_id,
|
||||
'object': {
|
||||
'id': favorite.remote_id,
|
||||
'type': 'Like',
|
||||
'actor': favorite.user.remote_id,
|
||||
'object': favorite.status.remote_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_boost(boost):
|
||||
''' boost/announce a post '''
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': boost.remote_id,
|
||||
'type': 'Announce',
|
||||
'actor': boost.user.remote_id,
|
||||
'object': boost.boosted_status.remote_id,
|
||||
}
|
||||
|
||||
|
||||
def get_add_tag(tag):
|
||||
''' add activity for tagging a book '''
|
||||
uuid = uuid4()
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': str(uuid),
|
||||
'type': 'Add',
|
||||
'actor': tag.user.remote_id,
|
||||
'object': {
|
||||
'type': 'Tag',
|
||||
'id': tag.remote_id,
|
||||
'name': tag.name,
|
||||
},
|
||||
'target': {
|
||||
'type': 'Book',
|
||||
'id': tag.book.local_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_remove_tag(tag):
|
||||
''' add activity for tagging a book '''
|
||||
uuid = uuid4()
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': str(uuid),
|
||||
'type': 'Remove',
|
||||
'actor': tag.user.remote_id,
|
||||
'object': {
|
||||
'type': 'Tag',
|
||||
'id': tag.remote_id,
|
||||
'name': tag.name,
|
||||
},
|
||||
'target': {
|
||||
'type': 'Book',
|
||||
'id': tag.book.local_id,
|
||||
}
|
||||
}
|
68
fedireads/activitypub/verbs.py
Normal file
68
fedireads/activitypub/verbs.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
''' undo wrapper activity '''
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from .base_activity import ActivityObject, Signature
|
||||
|
||||
@dataclass(init=False)
|
||||
class Verb(ActivityObject):
|
||||
''' generic fields for activities - maybe an unecessary level of
|
||||
abstraction but w/e '''
|
||||
actor: str
|
||||
object: ActivityObject
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Create(Verb):
|
||||
''' Create activity '''
|
||||
to: List
|
||||
cc: List
|
||||
signature: Signature
|
||||
type: str = 'Create'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Update(Verb):
|
||||
''' Update activity '''
|
||||
to: List
|
||||
type: str = 'Update'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Undo(Verb):
|
||||
''' Undo an activity '''
|
||||
type: str = 'Undo'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Follow(Verb):
|
||||
''' Follow activity '''
|
||||
type: str = 'Follow'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Accept(Verb):
|
||||
''' Accept activity '''
|
||||
object: Follow
|
||||
type: str = 'Accept'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Reject(Verb):
|
||||
''' Reject activity '''
|
||||
object: Follow
|
||||
type: str = 'Reject'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Add(Verb):
|
||||
'''Add activity '''
|
||||
target: ActivityObject
|
||||
type: str = 'Add'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Remove(Verb):
|
||||
'''Remove activity '''
|
||||
target: ActivityObject
|
||||
type: str = 'Remove'
|
Loading…
Add table
Add a link
Reference in a new issue