1
0
Fork 0

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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1449 additions and 1228 deletions

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View 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'

View 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'

View 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'

View file

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

View 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'

View file

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

View file

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

View 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'