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'

View file

@ -1,9 +1,9 @@
''' select and call a connector for whatever book task needs doing '''
from requests import HTTPError
import importlib
from urllib.parse import urlparse
from requests import HTTPError
from fedireads import models
from fedireads.tasks import app

View file

@ -4,6 +4,7 @@ from django.utils.http import http_date
import requests
from fedireads import models
from fedireads.activitypub import ActivityEncoder
from fedireads.tasks import app
from fedireads.signatures import make_signature, make_digest
@ -38,7 +39,11 @@ def broadcast(sender, activity, software=None, \
# TODO: other kinds of privacy
if privacy == 'public':
recipients += get_public_recipients(sender, software=software)
broadcast_task.delay(sender.id, activity, recipients)
broadcast_task.delay(
sender.id,
json.dumps(activity, cls=ActivityEncoder),
recipients
)
@app.task

View file

@ -1,14 +1,14 @@
''' handles all of the activity coming in to the server '''
import json
from urllib.parse import urldefrag
import requests
import django.db.utils
from django.http import HttpResponse
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt
import requests
from fedireads import books_manager, models, outgoing
from fedireads import activitypub, books_manager, models, outgoing
from fedireads import status as status_builder
from fedireads.remote_user import get_or_create_remote_user, refresh_remote_user
from fedireads.tasks import app
@ -84,6 +84,7 @@ def shared_inbox(request):
def has_valid_signature(request, activity):
''' verify incoming signature '''
try:
signature = Signature.parse(request)
@ -111,14 +112,13 @@ def handle_follow(activity):
''' someone wants to follow a local user '''
# figure out who they want to follow -- not using get_or_create because
# we only allow you to follow local users
try:
to_follow = models.User.objects.get(remote_id=activity['object'])
except models.User.DoesNotExist:
return False
# raises models.User.DoesNotExist id the remote id is not found
# figure out who the actor is
user = get_or_create_remote_user(activity['actor'])
try:
request = models.UserFollowRequest.objects.create(
relationship = models.UserFollowRequest.objects.create(
user_subject=user,
user_object=to_follow,
relationship_id=activity['id']
@ -137,7 +137,7 @@ def handle_follow(activity):
'FOLLOW',
related_user=user
)
outgoing.handle_accept(user, to_follow, request)
outgoing.handle_accept(user, to_follow, relationship)
else:
status_builder.create_notification(
to_follow,
@ -150,11 +150,9 @@ def handle_follow(activity):
def handle_unfollow(activity):
''' unfollow a local user '''
obj = activity['object']
try:
requester = get_or_create_remote_user(obj['actor'])
to_unfollow = models.User.objects.get(remote_id=obj['object'])
except models.User.DoesNotExist:
return False
# raises models.User.DoesNotExist
to_unfollow.followers.remove(requester)
@ -184,67 +182,63 @@ def handle_follow_reject(activity):
requester = models.User.objects.get(remote_id=activity['object']['actor'])
rejecter = get_or_create_remote_user(activity['actor'])
try:
request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=rejecter
)
request.delete()
except models.UserFollowRequest.DoesNotExist:
return False
#raises models.UserFollowRequest.DoesNotExist:
@app.task
def handle_create(activity):
''' someone did something, good on them '''
user = get_or_create_remote_user(activity['actor'])
if activity['object'].get('type') not in \
['Note', 'Comment', 'Quotation', 'Review']:
# if it's an article or unknown type, ignore it
return
user = get_or_create_remote_user(activity['actor'])
if user.local:
# we really oughtn't even be sending in this case
return True
return
if activity['object'].get('fedireadsType') and \
'inReplyToBook' in activity['object']:
if activity['object']['fedireadsType'] == 'Review':
builder = status_builder.create_review_from_activity
elif activity['object']['fedireadsType'] == 'Quotation':
builder = status_builder.create_quotation_from_activity
else:
builder = status_builder.create_comment_from_activity
# render the json into an activity object
serializer = activitypub.activity_objects[activity['object']['type']]
activity = serializer(**activity['object'])
# create the status, it'll throw a ValueError if anything is missing
builder(user, activity['object'])
elif activity['object'].get('inReplyTo'):
# only create the status if it's in reply to a status we already know
if not status_builder.get_status(activity['object']['inReplyTo']):
return True
# ignore notes that aren't replies to known statuses
if activity.type == 'Note':
reply = models.Status.objects.filter(
remote_id=activity.inReplyTo
).first()
if not reply:
return
status = status_builder.create_status_from_activity(
user,
activity['object']
)
if status and status.reply_parent:
model = models.activity_models[activity.type]
status = activity.to_model(model)
# create a notification if this is a reply
if status.reply_parent and status.reply_parent.user.local:
status_builder.create_notification(
status.reply_parent.user,
'REPLY',
related_user=status.user,
related_status=status,
)
return True
@app.task
def handle_favorite(activity):
''' approval of your good good post '''
try:
status_id = activity['object'].split('/')[-1]
status = models.Status.objects.get(id=status_id)
liker = get_or_create_remote_user(activity['actor'])
except (models.Status.DoesNotExist, models.User.DoesNotExist):
return False
fav = activitypub.Like(**activity['object'])
# raises ValueError in to_model if a foreign key could not be resolved in
if not liker.local:
status_builder.create_favorite_from_activity(liker, activity)
liker = get_or_create_remote_user(activity['actor'])
if liker.local:
return
status = fav.to_model(models.Favorite)
status_builder.create_notification(
status.user,
@ -257,10 +251,8 @@ def handle_favorite(activity):
@app.task
def handle_unfavorite(activity):
''' approval of your good good post '''
favorite_id = activity['object']['id']
fav = models.Favorite.objects.filter(remote_id=favorite_id).first()
if not fav:
return False
like = activitypub.Like(**activity['object'])
fav = models.Favorite.objects.filter(remote_id=like.id).first()
fav.delete()
@ -268,12 +260,9 @@ def handle_unfavorite(activity):
@app.task
def handle_boost(activity):
''' someone gave us a boost! '''
try:
status_id = activity['object'].split('/')[-1]
status = models.Status.objects.get(id=status_id)
booster = get_or_create_remote_user(activity['actor'])
except (models.Status.DoesNotExist, models.User.DoesNotExist):
return False
if not booster.local:
status_builder.create_boost_from_activity(booster, activity)

View file

@ -0,0 +1,21 @@
# Generated by Django 3.0.3 on 2020-05-24 03:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0041_user_remote_id'),
]
operations = [
migrations.RemoveField(
model_name='status',
name='activity_type',
),
migrations.RemoveField(
model_name='status',
name='status_type',
),
]

View file

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2020-08-10 20:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0044_siteinvite_user'),
('fedireads', '0042_auto_20200524_0346'),
]
operations = [
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
''' handles all the activity coming out of the server '''
from datetime import datetime
from urllib.parse import urlencode
from django.db import IntegrityError, transaction
from django.http import HttpResponseNotFound, JsonResponse
@ -27,36 +26,11 @@ def outbox(request, username):
except models.User.DoesNotExist:
return HttpResponseNotFound()
# paginated list of messages
if request.GET.get('page'):
limit = 20
min_id = request.GET.get('min_id')
max_id = request.GET.get('max_id')
# filters for use in the django queryset min/max
filters = {}
# params for the outbox page id
params = {'page': 'true'}
if min_id is not None:
params['min_id'] = min_id
filters['id__gt'] = min_id
if max_id is not None:
params['max_id'] = max_id
filters['id__lte'] = max_id
page_id = user.outbox + '?' + urlencode(params)
statuses = models.Status.objects.filter(
user=user,
**filters
).select_subclasses().all()[:limit]
return JsonResponse(
activitypub.get_outbox_page(user, page_id, statuses, max_id, min_id)
)
# collection overview
size = models.Status.objects.filter(user=user).count()
return JsonResponse(activitypub.get_outbox(user, size))
return JsonResponse(
user.to_outbox(**request.GET),
encoder=activitypub.ActivityEncoder
)
def handle_account_search(query):
@ -83,7 +57,15 @@ def handle_account_search(query):
def handle_follow(user, to_follow):
''' someone local wants to follow someone '''
activity = activitypub.get_follow_request(user, to_follow)
try:
relationship, _ = models.UserFollowRequest.objects.get_or_create(
user_subject=user,
user_object=to_follow,
)
except IntegrityError as err:
if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
raise
activity = relationship.to_activity()
broadcast(user, activity, direct_recipients=[to_follow])
@ -93,7 +75,7 @@ def handle_unfollow(user, to_unfollow):
user_subject=user,
user_object=to_unfollow
)
activity = activitypub.get_unfollow(relationship)
activity = relationship.to_undo_activity(user)
broadcast(user, activity, direct_recipients=[to_unfollow])
to_unfollow.followers.remove(user)
@ -105,25 +87,23 @@ def handle_accept(user, to_follow, follow_request):
follow_request.delete()
relationship.save()
activity = activitypub.get_accept(to_follow, follow_request)
activity = relationship.to_accept_activity()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_reject(user, to_follow, relationship):
''' a local user who managed follows rejects a follow request '''
activity = relationship.to_reject_activity(user)
relationship.delete()
activity = activitypub.get_reject(to_follow, relationship)
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_shelve(user, book, shelf):
''' a local user is getting a book put on their shelf '''
# update the database
models.ShelfBook(book=book, shelf=shelf, added_by=user).save()
shelve = models.ShelfBook(book=book, shelf=shelf, added_by=user).save()
activity = activitypub.get_add(user, book, shelf)
broadcast(user, activity)
broadcast(user, shelve.to_add_activity(user))
# tell the world about this cool thing that happened
verb = {
@ -155,20 +135,16 @@ def handle_shelve(user, book, shelf):
read.finish_date = datetime.now()
read.save()
activity = activitypub.get_status(status)
create_activity = activitypub.get_create(user, activity)
broadcast(user, create_activity)
broadcast(user, status.to_create_activity(user))
def handle_unshelve(user, book, shelf):
''' a local user is getting a book put on their shelf '''
# update the database
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
activity = row.to_remove_activity(user)
row.delete()
activity = activitypub.get_remove(user, book, shelf)
broadcast(user, activity)
@ -185,11 +161,11 @@ def handle_import_books(user, items):
item.book = item.book.default_edition
if not item.book:
continue
_, created = models.ShelfBook.objects.get_or_create(
shelf_book, created = models.ShelfBook.objects.get_or_create(
book=item.book, shelf=desired_shelf, added_by=user)
if created:
new_books.append(item.book)
activity = activitypub.get_add(user, item.book, desired_shelf)
activity = shelf_book.to_add_activity(user)
broadcast(user, activity)
if item.rating or item.review:
@ -214,82 +190,62 @@ def handle_import_books(user, items):
status.status_type = 'Update'
status.save()
create_activity = activitypub.get_create(
user, activitypub.get_status(status))
broadcast(user, create_activity)
broadcast(user, status.to_create_activity(user))
return status
return None
def handle_rate(user, book, rating):
''' a review that's just a rating '''
builder = create_rating
fr_serializer = activitypub.get_rating
ap_serializer = activitypub.get_rating_note
handle_status(user, book, builder, fr_serializer, ap_serializer, rating)
handle_status(user, book, builder, rating)
def handle_review(user, book, name, content, rating):
''' post a review '''
# validated and saves the review in the database so it has an id
builder = create_review
fr_serializer = activitypub.get_review
ap_serializer = activitypub.get_review_article
handle_status(
user, book, builder, fr_serializer,
ap_serializer, name, content, rating)
handle_status(user, book, builder, name, content, rating)
def handle_quotation(user, book, content, quote):
''' post a review '''
# validated and saves the review in the database so it has an id
builder = create_quotation
fr_serializer = activitypub.get_quotation
ap_serializer = activitypub.get_quotation_article
handle_status(
user, book, builder, fr_serializer, ap_serializer, content, quote)
handle_status(user, book, builder, content, quote)
def handle_comment(user, book, content):
''' post a comment '''
# validated and saves the review in the database so it has an id
builder = create_comment
fr_serializer = activitypub.get_comment
ap_serializer = activitypub.get_comment_article
handle_status(
user, book, builder, fr_serializer, ap_serializer, content)
handle_status(user, book, builder, content)
def handle_status(user, book_id, \
builder, fr_serializer, ap_serializer, *args):
def handle_status(user, book_id, builder, *args):
''' generic handler for statuses '''
book = models.Edition.objects.get(id=book_id)
status = builder(user, book, *args)
activity = fr_serializer(status)
create_activity = activitypub.get_create(user, activity)
broadcast(user, create_activity, software='fedireads')
broadcast(user, status.to_create_activity(user), software='fedireads')
# re-format the activity for non-fedireads servers
remote_activity = ap_serializer(status)
remote_create_activity = activitypub.get_create(user, remote_activity)
remote_activity = status.to_create_activity(user, pure=True)
broadcast(user, remote_create_activity, software='other')
broadcast(user, remote_activity, software='other')
def handle_tag(user, book, name):
''' tag a book '''
tag = create_tag(user, book, name)
tag_activity = activitypub.get_add_tag(tag)
broadcast(user, tag_activity)
broadcast(user, tag.to_add_activity(user))
def handle_untag(user, book, name):
''' tag a book '''
book = models.Book.objects.get(id=book)
tag = models.Tag.objects.get(name=name, book=book, user=user)
tag_activity = activitypub.get_remove_tag(tag)
tag_activity = tag.to_remove_activity(user)
tag.delete()
broadcast(user, tag_activity)
@ -306,10 +262,8 @@ def handle_reply(user, review, content):
related_user=user,
related_status=reply,
)
reply_activity = activitypub.get_status(reply)
create_activity = activitypub.get_create(user, reply_activity)
broadcast(user, create_activity)
broadcast(user, reply.to_create_activity(user))
def handle_favorite(user, status):
@ -323,7 +277,7 @@ def handle_favorite(user, status):
# you already fav'ed that
return
fav_activity = activitypub.get_favorite(favorite)
fav_activity = favorite.to_activity()
broadcast(
user, fav_activity, privacy='direct', direct_recipients=[status.user])
@ -339,7 +293,7 @@ def handle_unfavorite(user, status):
# can't find that status, idk
return
fav_activity = activitypub.get_unfavorite(favorite)
fav_activity = activitypub.Undo(actor=user, object=favorite)
broadcast(user, fav_activity, direct_recipients=[status.user])
@ -355,19 +309,15 @@ def handle_boost(user, status):
)
boost.save()
boost_activity = activitypub.get_boost(boost)
boost_activity = boost.to_activity()
broadcast(user, boost_activity)
def handle_update_book(user, book):
''' broadcast the news about our book '''
book_activity = activitypub.get_book(book)
update_activity = activitypub.get_update(user, book_activity)
broadcast(user, update_activity)
broadcast(user, book.to_update_activity(user))
def handle_update_user(user):
''' broadcast editing a user's profile '''
actor = activitypub.get_actor(user)
update_activity = activitypub.get_update(user, actor)
broadcast(user, update_activity)
broadcast(user, user.to_update_activity())

View file

@ -6,8 +6,7 @@ import requests
from django.core.files.base import ContentFile
from django.db import transaction
from fedireads import models
from fedireads.status import create_review_from_activity
from fedireads import activitypub, models
def get_or_create_remote_user(actor):
@ -33,8 +32,9 @@ def get_or_create_remote_user(actor):
get_remote_reviews(user)
return user
def fetch_user_data(actor):
# load the user's info from the actor url
''' load the user's info from the actor url '''
response = requests.get(
actor,
headers={'Accept': 'application/activity+json'}
@ -51,50 +51,17 @@ def fetch_user_data(actor):
def create_remote_user(data):
''' parse the activitypub actor data into a user '''
actor = data['id']
actor_parts = urlparse(actor)
actor = activitypub.Person(**data)
return actor.to_model(models.User)
# the webfinger format for the username.
username = '%s@%s' % (actor_parts.path.split('/')[-1], actor_parts.netloc)
shared_inbox = data.get('endpoints').get('sharedInbox') if \
data.get('endpoints') else None
# throws a key error if it can't find any of these fields
return models.User.objects.create_user(
username,
'', '', # email and passwords are left blank
remote_id=actor,
name=data.get('name'),
summary=data.get('summary'),
inbox=data['inbox'], #fail if there's no inbox
outbox=data['outbox'], # fail if there's no outbox
shared_inbox=shared_inbox,
public_key=data.get('publicKey').get('publicKeyPem'),
local=False,
fedireads_user=data.get('fedireadsUser', False),
manually_approves_followers=data.get(
'manuallyApprovesFollowers', False),
)
def refresh_remote_user(user):
''' get updated user data from its home instance '''
data = fetch_user_data(user.remote_id)
shared_inbox = data.get('endpoints').get('sharedInbox') if \
data.get('endpoints') else None
activity = activitypub.Person(**data)
activity.to_model(models.User, instance=user)
# TODO - I think dataclasses change will mean less repetition here later.
user.name = data.get('name')
user.summary = data.get('summary')
user.inbox = data['inbox'] #fail if there's no inbox
user.outbox = data['outbox'] # fail if there's no outbox
user.shared_inbox = shared_inbox
user.public_key = data.get('publicKey').get('publicKeyPem')
user.local = False
user.fedireads_user = data.get('fedireadsUser', False)
user.manually_approves_followers = data.get(
'manuallyApprovesFollowers', False)
user.save()
def get_avatar(data):
''' find the icon attachment and load the image from the remote sever '''
@ -122,7 +89,7 @@ def get_remote_reviews(user):
# TODO: pagination?
for status in data['orderedItems']:
if status.get('fedireadsType') == 'Review':
create_review_from_activity(user, status)
activitypub.Review(**status).to_model(models.Review)
def get_or_create_remote_server(domain):

View file

@ -39,7 +39,7 @@ def make_signature(sender, destination, date, digest):
return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items())
def make_digest(data):
return 'SHA-256=' + b64encode(hashlib.sha256(data).digest()).decode('utf-8')
return 'SHA-256=' + b64encode(hashlib.sha256(data.encode('utf-8')).digest()).decode('utf-8')
def verify_digest(request):
algorithm, digest = request.headers['digest'].split('=', 1)

View file

@ -6,23 +6,6 @@ from fedireads.books_manager import get_or_create_book
from fedireads.sanitize_html import InputHtmlParser
def create_review_from_activity(author, activity):
''' parse an activity json blob into a status '''
book_id = activity['inReplyToBook']
book = get_or_create_book(book_id)
name = activity.get('name')
rating = activity.get('rating')
content = activity.get('content')
published = activity.get('published')
remote_id = activity['id']
review = create_review(author, book, name, content, rating)
review.published_date = published
review.remote_id = remote_id
review.save()
return review
def create_rating(user, book, rating):
''' a review that's just a rating '''
if not rating or rating < 1 or rating > 5:
@ -111,50 +94,6 @@ def create_comment(user, book, content):
)
def create_status_from_activity(author, activity):
''' parse a status object out of an activity json blob '''
content = activity.get('content')
reply_parent_id = activity.get('inReplyTo')
reply_parent = get_status(reply_parent_id)
remote_id = activity['id']
if models.Status.objects.filter(remote_id=remote_id).count():
return None
status = create_status(author, content, reply_parent=reply_parent,
remote_id=remote_id)
status.published_date = activity.get('published')
status.save()
return status
def create_favorite_from_activity(user, activity):
''' create a new favorite entry '''
status = get_status(activity['object'])
remote_id = activity['id']
try:
return models.Favorite.objects.create(
status=status,
user=user,
remote_id=remote_id,
)
except IntegrityError:
return models.Favorite.objects.get(status=status, user=user)
def create_boost_from_activity(user, activity):
''' create a new boost activity '''
status = get_status(activity['object'])
remote_id = activity['id']
try:
return models.Boost.objects.create(
status=status,
user=user,
remote_id=remote_id,
)
except IntegrityError:
return models.Boost.objects.get(status=status, user=user)
def get_status(remote_id):
''' find a status in the database '''
return models.Status.objects.select_subclasses().filter(

View file

@ -0,0 +1 @@
from . import *

View file

@ -0,0 +1,27 @@
import datetime
from django.test import TestCase
from fedireads import models
class Author(TestCase):
def setUp(self):
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
)
self.author = models.Author.objects.create(
name='Author fullname',
first_name='Auth',
last_name='Or',
aliases=['One', 'Two'],
bio='bio bio bio',
)
def test_serialize_model(self):
activity = self.author.to_activity()
self.assertEqual(activity['id'], self.author.remote_id)
self.assertIsInstance(activity['aliases'], list)
self.assertEqual(activity['aliases'], ['One', 'Two'])
self.assertEqual(activity['name'], 'Author fullname')

View file

@ -0,0 +1,32 @@
import json
import pathlib
from django.test import TestCase
from fedireads import activitypub, models
class Person(TestCase):
def setUp(self):
self.user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json'
)
self.user_data = json.loads(datafile.read_bytes())
def test_load_user_data(self):
activity = activitypub.Person(**self.user_data)
self.assertEqual(activity.id, 'https://example.com/user/mouse')
self.assertEqual(activity.preferredUsername, 'mouse')
self.assertEqual(activity.type, 'Person')
def test_serialize_model(self):
activity = self.user.to_activity()
self.assertEqual(activity['id'], self.user.remote_id)
self.assertEqual(
activity['endpoints'],
{'sharedInbox': self.user.shared_inbox}
)

View file

@ -0,0 +1,46 @@
import json
import pathlib
from django.test import TestCase
from fedireads import activitypub, models
class Quotation(TestCase):
''' we have hecka ways to create statuses '''
def setUp(self):
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword',
local=False,
inbox='https://example.com/user/mouse/inbox',
outbox='https://example.com/user/mouse/outbox',
remote_id='https://example.com/user/mouse',
)
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_quotation.json'
)
self.status_data = json.loads(datafile.read_bytes())
def test_quotation_activity(self):
quotation = activitypub.Quotation(**self.status_data)
self.assertEqual(quotation.type, 'Quotation')
self.assertEqual(
quotation.id, 'https://example.com/user/mouse/quotation/13')
self.assertEqual(quotation.content, 'commentary')
self.assertEqual(quotation.quote, 'quote body')
self.assertEqual(quotation.inReplyToBook, 'https://example.com/book/1')
self.assertEqual(
quotation.published, '2020-05-10T02:38:31.150343+00:00')
def test_activity_to_model(self):
activity = activitypub.Quotation(**self.status_data)
quotation = activity.to_model(models.Quotation)
self.assertEqual(quotation.book, self.book)
self.assertEqual(quotation.user, self.user)

View file

@ -0,0 +1,29 @@
{
"id": "https://example.com/user/mouse/comment/6",
"url": "https://example.com/user/mouse/comment/6",
"inReplyTo": null,
"published": "2020-05-08T23:45:44.768012+00:00",
"attributedTo": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"sensitive": null,
"content": "commentary",
"type": "Comment",
"attachment": [],
"replies": {
"id": "https://example.com/user/mouse/comment/6/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": "https://example.com/user/mouse/comment/6/replies?only_other_accounts=true&page=true",
"partOf": "https://example.com/user/mouse/comment/6/replies",
"items": []
}
},
"inReplyToBook": "https://example.com/book/1"
}

View file

@ -0,0 +1,36 @@
{
"id": "https://example.com/user/mouse/quotation/13",
"url": "https://example.com/user/mouse/quotation/13",
"inReplyTo": null,
"published": "2020-05-10T02:38:31.150343+00:00",
"attributedTo": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"sensitive": false,
"content": "commentary",
"type": "Quotation",
"attachment": [
{
"type": "Document",
"mediaType": "image//images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg",
"url": "https://example.com/images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg",
"name": "Cover of \"This Is How You Lose the Time War\""
}
],
"replies": {
"id": "https://example.com/user/mouse/quotation/13/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": "https://example.com/user/mouse/quotation/13/replies?only_other_accounts=true&page=true",
"partOf": "https://example.com/user/mouse/quotation/13/replies",
"items": []
}
},
"inReplyToBook": "https://example.com/book/1",
"quote": "quote body"
}

View file

@ -19,32 +19,24 @@ class Status(TestCase):
def test_status(self):
status = models.Status.objects.first()
self.assertEqual(status.status_type, 'Note')
self.assertEqual(status.activity_type, 'Note')
expected_id = 'https://%s/user/mouse/status/%d' % \
(settings.DOMAIN, status.id)
self.assertEqual(status.remote_id, expected_id)
def test_comment(self):
comment = models.Comment.objects.first()
self.assertEqual(comment.status_type, 'Comment')
self.assertEqual(comment.activity_type, 'Note')
expected_id = 'https://%s/user/mouse/comment/%d' % \
(settings.DOMAIN, comment.id)
self.assertEqual(comment.remote_id, expected_id)
def test_quotation(self):
quotation = models.Quotation.objects.first()
self.assertEqual(quotation.status_type, 'Quotation')
self.assertEqual(quotation.activity_type, 'Note')
expected_id = 'https://%s/user/mouse/quotation/%d' % \
(settings.DOMAIN, quotation.id)
self.assertEqual(quotation.remote_id, expected_id)
def test_review(self):
review = models.Review.objects.first()
self.assertEqual(review.status_type, 'Review')
self.assertEqual(review.activity_type, 'Article')
expected_id = 'https://%s/user/mouse/review/%d' % \
(settings.DOMAIN, review.id)
self.assertEqual(review.remote_id, expected_id)

View file

@ -16,41 +16,3 @@ class Comment(TestCase):
comment = status_builder.create_comment(
self.user, self.book, 'commentary')
self.assertEqual(comment.content, 'commentary')
def test_comment_from_activity(self):
activity = {
"id": "https://example.com/user/mouse/comment/6",
"url": "https://example.com/user/mouse/comment/6",
"inReplyTo": None,
"published": "2020-05-08T23:45:44.768012+00:00",
"attributedTo": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"sensitive": False,
"content": "commentary",
"type": "Note",
"attachment": [],
"replies": {
"id": "https://example.com/user/mouse/comment/6/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": "https://example.com/user/mouse/comment/6/replies?only_other_accounts=true&page=true",
"partOf": "https://example.com/user/mouse/comment/6/replies",
"items": []
}
},
"inReplyToBook": self.book.remote_id,
"fedireadsType": "Comment"
}
comment = status_builder.create_comment_from_activity(
self.user, activity)
self.assertEqual(comment.content, 'commentary')
self.assertEqual(comment.book, self.book)
self.assertEqual(
comment.published_date, '2020-05-08T23:45:44.768012+00:00')

View file

@ -1,6 +1,8 @@
from django.test import TestCase
import json
import pathlib
from fedireads import models
from fedireads import activitypub, models
from fedireads import status as status_builder
@ -8,8 +10,13 @@ class Quotation(TestCase):
''' we have hecka ways to create statuses '''
def setUp(self):
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword')
self.book = models.Edition.objects.create(title='Example Edition')
'mouse', 'mouse@mouse.mouse', 'mouseword',
remote_id='https://example.com/user/mouse'
)
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
)
def test_create_quotation(self):
@ -17,50 +24,3 @@ class Quotation(TestCase):
self.user, self.book, 'commentary', 'a quote')
self.assertEqual(quotation.quote, 'a quote')
self.assertEqual(quotation.content, 'commentary')
def test_quotation_from_activity(self):
activity = {
'id': 'https://example.com/user/mouse/quotation/13',
'url': 'https://example.com/user/mouse/quotation/13',
'inReplyTo': None,
'published': '2020-05-10T02:38:31.150343+00:00',
'attributedTo': 'https://example.com/user/mouse',
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
'https://example.com/user/mouse/followers'
],
'sensitive': False,
'content': 'commentary',
'type': 'Note',
'attachment': [
{
'type': 'Document',
'mediaType': 'image//images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg',
'url': 'https://example.com/images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg',
'name': 'Cover of \'This Is How You Lose the Time War\''
}
],
'replies': {
'id': 'https://example.com/user/mouse/quotation/13/replies',
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': 'https://example.com/user/mouse/quotation/13/replies?only_other_accounts=true&page=true',
'partOf': 'https://example.com/user/mouse/quotation/13/replies',
'items': []
}
},
'inReplyToBook': self.book.remote_id,
'fedireadsType': 'Quotation',
'quote': 'quote body'
}
quotation = status_builder.create_quotation_from_activity(
self.user, activity)
self.assertEqual(quotation.content, 'commentary')
self.assertEqual(quotation.quote, 'quote body')
self.assertEqual(quotation.book, self.book)
self.assertEqual(
quotation.published_date, '2020-05-10T02:38:31.150343+00:00')

View file

@ -37,45 +37,3 @@ class Review(TestCase):
self.assertEqual(review.name, 'review name')
self.assertEqual(review.content, 'content')
self.assertEqual(review.rating, None)
def test_review_from_activity(self):
activity = {
'id': 'https://example.com/user/mouse/review/9',
'url': 'https://example.com/user/mouse/review/9',
'inReplyTo': None,
'published': '2020-05-04T00:00:00.000000+00:00',
'attributedTo': 'https://example.com/user/mouse',
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
'https://example.com/user/mouse/followers'
],
'sensitive': False,
'content': 'review content',
'type': 'Article',
'attachment': [],
'replies': {
'id': 'https://example.com/user/mouse/review/9/replies',
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': 'https://example.com/user/mouse/review/9/replies?only_other_accounts=true&page=true',
'partOf': 'https://example.com/user/mouse/review/9/replies',
'items': []
}
},
'inReplyToBook': self.book.remote_id,
'fedireadsType': 'Review',
'name': 'review title',
'rating': 3
}
review = status_builder.create_review_from_activity(
self.user, activity)
self.assertEqual(review.content, 'review content')
self.assertEqual(review.name, 'review title')
self.assertEqual(review.rating, 3)
self.assertEqual(review.book, self.book)
self.assertEqual(
review.published_date, '2020-05-04T00:00:00.000000+00:00')

View file

@ -8,7 +8,12 @@ class Status(TestCase):
''' we have hecka ways to create statuses '''
def setUp(self):
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword')
'mouse', 'mouse@mouse.mouse', 'mouseword',
local=False,
inbox='https://example.com/user/mouse/inbox',
outbox='https://example.com/user/mouse/outbox',
remote_id='https://example.com/user/mouse'
)
def test_create_status(self):
@ -21,45 +26,3 @@ class Status(TestCase):
self.user, content, reply_parent=status)
self.assertEqual(reply.content, content)
self.assertEqual(reply.reply_parent, status)
def test_create_status_from_activity(self):
book = models.Edition.objects.create(title='Example Edition')
review = status_builder.create_review(
self.user, book, 'review name', 'content', 5)
activity = {
'id': 'https://example.com/user/mouse/status/12',
'url': 'https://example.com/user/mouse/status/12',
'inReplyTo': review.remote_id,
'published': '2020-05-10T02:15:59.635557+00:00',
'attributedTo': 'https://example.com/user/mouse',
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
'https://example.com/user/mouse/followers'
],
'sensitive': False,
'content': 'reply to status',
'type': 'Note',
'attachment': [],
'replies': {
'id': 'https://example.com/user/mouse/status/12/replies',
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': 'https://example.com/user/mouse/status/12/replies?only_other_accounts=true&page=true',
'partOf': 'https://example.com/user/mouse/status/12/replies',
'items': []
}
}
}
status = status_builder.create_status_from_activity(
self.user, activity)
self.assertEqual(status.reply_parent, review)
self.assertEqual(status.content, 'reply to status')
self.assertEqual(
status.published_date,
'2020-05-10T02:15:59.635557+00:00'
)

View file

@ -0,0 +1,59 @@
import json
import pathlib
from django.test import TestCase
from fedireads import models, incoming
class Favorite(TestCase):
''' not too much going on in the books model but here we are '''
def setUp(self):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
local=False,
remote_id='https://example.com/users/rat',
inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
remote_id='http://local.com/user/mouse')
self.status = models.Status.objects.create(
user=self.local_user,
content='Test status',
remote_id='http://local.com/status/1',
)
datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_user.json'
)
self.user_data = json.loads(datafile.read_bytes())
def test_handle_favorite(self):
activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'http://example.com/activity/1',
'type': 'Create',
'actor': 'https://example.com/users/rat',
'published': 'Mon, 25 May 2020 19:31:20 GMT',
'to': ['https://example.com/user/rat/followers'],
'cc': ['https://www.w3.org/ns/activitystreams#Public'],
'object': {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'http://example.com/fav/1',
'type': 'Like',
'actor': 'https://example.com/users/rat',
'object': 'http://local.com/status/1',
},
'signature': {}
}
result = incoming.handle_favorite(activity)
fav = models.Favorite.objects.get(remote_id='http://example.com/fav/1')
self.assertEqual(fav.status, self.status)
self.assertEqual(fav.remote_id, 'http://example.com/fav/1')
self.assertEqual(fav.user, self.remote_user)

View file

@ -1,6 +1,6 @@
from django.test import TestCase
import json
import pathlib
from django.test import TestCase
from fedireads import models, remote_user
@ -9,29 +9,62 @@ class RemoteUser(TestCase):
''' not too much going on in the books model but here we are '''
def setUp(self):
self.remote_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
'rat', 'rat@rat.com', 'ratword',
local=False,
remote_id='https://example.com/users/mouse',
inbox='https://example.com/users/mouse/inbox',
outbox='https://example.com/users/mouse/outbox',
remote_id='https://example.com/users/rat',
inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_user.json'
)
self.user_data = json.loads(datafile.read_bytes())
def test_get_remote_user(self):
actor = 'https://example.com/users/mouse'
actor = 'https://example.com/users/rat'
user = remote_user.get_or_create_remote_user(actor)
self.assertEqual(user, self.remote_user)
def test_create_remote_user(self):
datafile = pathlib.Path(__file__).parent.joinpath('data/ap_user.json')
data = json.loads(datafile.read_bytes())
user = remote_user.create_remote_user(data)
user = remote_user.create_remote_user(self.user_data)
self.assertFalse(user.local)
self.assertEqual(user.remote_id, 'https://example.com/user/mouse')
self.assertEqual(user.username, 'mouse@example.com')
self.assertEqual(user.name, 'MOUSE?? MOUSE!!')
self.assertEqual(user.inbox, 'https://example.com/user/mouse/inbox')
self.assertEqual(user.outbox, 'https://example.com/user/mouse/outbox')
self.assertEqual(user.shared_inbox, 'https://example.com/inbox')
self.assertEqual(user.public_key, data['publicKey']['publicKeyPem'])
self.assertEqual(
user.public_key,
self.user_data['publicKey']['publicKeyPem']
)
self.assertEqual(user.local, False)
self.assertEqual(user.fedireads_user, True)
self.assertEqual(user.manually_approves_followers, False)
def test_create_remote_user_missing_inbox(self):
del self.user_data['inbox']
self.assertRaises(
TypeError,
remote_user.create_remote_user,
self.user_data
)
def test_create_remote_user_missing_outbox(self):
del self.user_data['outbox']
self.assertRaises(
TypeError,
remote_user.create_remote_user,
self.user_data
)
def test_create_remote_user_default_fields(self):
del self.user_data['manuallyApprovesFollowers']
user = remote_user.create_remote_user(self.user_data)
self.assertEqual(user.manually_approves_followers, False)

View file

@ -10,12 +10,17 @@ from django.test import TestCase, Client
from django.utils.http import http_date
from fedireads.models import User
from fedireads.activitypub import get_follow_request
from fedireads.activitypub import Follow
from fedireads.settings import DOMAIN
from fedireads.signatures import create_key_pair, make_signature, make_digest
def get_follow_data(follower, followee):
return json.dumps(get_follow_request(follower, followee)).encode('utf-8')
follow_activity = Follow(
id='https://test.com/user/follow/id',
actor=follower.remote_id,
object=followee.remote_id,
).serialize()
return json.dumps(follow_activity)
Sender = namedtuple('Sender', ('remote_id', 'private_key', 'public_key'))

View file

@ -10,7 +10,11 @@ username_regex = r'(?P<username>[\w\-_]+@[\w\-\_\.]+)'
localname_regex = r'(?P<username>[\w\-_]+)'
user_path = r'^user/%s' % username_regex
local_user_path = r'^user/%s' % localname_regex
status_path = r'%s/(status|review|comment|quotation)/(?P<status_id>\d+)' % local_user_path
status_types = ['status', 'review', 'comment', 'quotation', 'boost']
status_path = r'%s/(%s)/(?P<status_id>\d+)' % \
(local_user_path, '|'.join(status_types))
book_path = r'^book/(?P<book_id>\d+)'
handler404 = 'fedireads.views.not_found_page'
@ -67,6 +71,7 @@ urlpatterns = [
re_path(r'^editions/(?P<work_id>\d+)/?$', views.editions_page),
re_path(r'^author/(?P<author_id>[\w\-]+)(.json)?/?$', views.author_page),
# TODO: tag needs a .json path
re_path(r'^tag/(?P<tag_id>.+)/?$', views.tag_page),
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % user_path, views.shelf_page),
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % local_user_path, views.shelf_page),

View file

@ -9,7 +9,8 @@ from django.core.exceptions import PermissionDenied
from django.template.response import TemplateResponse
from django.views.decorators.csrf import csrf_exempt
from fedireads import activitypub, outgoing
from fedireads import outgoing
from fedireads.activitypub import ActivityEncoder
from fedireads import forms, models, books_manager
from fedireads import goodreads_import
from fedireads.tasks import app
@ -222,8 +223,9 @@ def about_page(request):
}
return TemplateResponse(request, 'about.html', data)
def invite_page(request, code):
''' Handle invites. '''
''' endpoint for sending invites '''
try:
invite = models.SiteInvite.objects.get(code=code)
if not invite.valid():
@ -240,6 +242,7 @@ def invite_page(request, code):
@login_required
def manage_invites(request):
''' invite management page '''
data = {
'invites': models.SiteInvite.objects.filter(user=request.user),
'form': forms.CreateInviteForm(),
@ -270,7 +273,7 @@ def user_page(request, username, subpage=None):
if is_api_request(request):
# we have a json request
return JsonResponse(user.activitypub_serialize)
return JsonResponse(user.to_activity(), encoder=ActivityEncoder)
# otherwise we're at a UI view
# TODO: change display with privacy and authentication considerations
@ -308,10 +311,7 @@ def followers_page(request, username):
return HttpResponseNotFound()
if is_api_request(request):
user = models.User.objects.get(localname=username)
followers = user.followers
page = request.GET.get('page')
return JsonResponse(activitypub.get_followers(user, page, followers))
return JsonResponse(user.to_followers_activity(**request.GET))
return user_page(request, username, subpage='followers')
@ -328,10 +328,7 @@ def following_page(request, username):
return HttpResponseNotFound()
if is_api_request(request):
user = models.User.objects.get(localname=username)
following = user.following
page = request.GET.get('page')
return JsonResponse(activitypub.get_following(user, page, following))
return JsonResponse(user.to_following_activity(**request.GET))
return user_page(request, username, subpage='following')
@ -361,7 +358,7 @@ def status_page(request, username, status_id):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(status.activitypub_serialize)
return JsonResponse(status.to_activity(), encoder=ActivityEncoder)
data = {
'status': status,
@ -382,28 +379,10 @@ def replies_page(request, username, status_id):
if status.user.localname != username:
return HttpResponseNotFound()
replies = models.Status.objects.filter(
reply_parent=status,
).select_subclasses()
if request.GET.get('only_other_accounts'):
replies = replies.filter(
~Q(user=status.user)
return JsonResponse(
status.to_replies(**request.GET),
encoder=ActivityEncoder
)
else:
replies = replies.filter(user=status.user)
if request.GET.get('page'):
min_id = request.GET.get('min_id')
if min_id:
replies = replies.filter(id__gt=min_id)
max_id = request.GET.get('max_id')
if max_id:
replies = replies.filter(id__lte=max_id)
activity = activitypub.get_replies_page(status, replies)
return JsonResponse(activity)
return JsonResponse(activitypub.get_replies(status, replies))
@login_required
@ -423,7 +402,7 @@ def book_page(request, book_id, tab='friends'):
''' info about a book '''
book = models.Book.objects.select_subclasses().get(id=book_id)
if is_api_request(request):
return JsonResponse(activitypub.get_book(book))
return JsonResponse(book.to_activity(), encoder=ActivityEncoder)
if isinstance(book, models.Work):
book = book.default_edition
@ -531,7 +510,7 @@ def author_page(request, author_id):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(activitypub.get_author(author))
return JsonResponse(author.to_activity(), encoder=ActivityEncoder)
books = models.Work.objects.filter(authors=author)
data = {
@ -544,6 +523,13 @@ def author_page(request, author_id):
def tag_page(request, tag_id):
''' books related to a tag '''
tag_obj = models.Tag.objects.filter(identifier=tag_id).first()
if not tag_obj:
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(
tag_obj.to_activity(**request.GET), encoder=ActivityEncoder)
books = models.Edition.objects.filter(tag__identifier=tag_id).distinct()
data = {
'books': books,
@ -562,8 +548,7 @@ def shelf_page(request, username, shelf_identifier):
shelf = models.Shelf.objects.get(user=user, identifier=shelf_identifier)
if is_api_request(request):
page = request.GET.get('page')
return JsonResponse(activitypub.get_shelf(shelf, page=page))
return JsonResponse(shelf.to_activity(**request.GET))
data = {
'shelf': shelf,