mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-30 03:50:40 +00:00
Use dataclasses to define activitypub (de)serialization (#177)
* Use dataclasses to define activitypub (de)serialization
This commit is contained in:
parent
2c0a07a330
commit
8bbf1fe252
46 changed files with 1449 additions and 1228 deletions
|
@ -1,16 +1,19 @@
|
||||||
''' bring activitypub functions into the namespace '''
|
''' bring activitypub functions into the namespace '''
|
||||||
from .actor import get_actor
|
import inspect
|
||||||
from .book import get_book, get_author, get_shelf
|
import sys
|
||||||
from .create import get_create, get_update
|
|
||||||
from .follow import get_following, get_followers
|
from .base_activity import ActivityEncoder, Image, PublicKey, Signature
|
||||||
from .follow import get_follow_request, get_unfollow, get_accept, get_reject
|
from .note import Note, Article, Comment, Review, Quotation
|
||||||
from .outbox import get_outbox, get_outbox_page
|
from .interaction import Boost, Like
|
||||||
from .shelve import get_add, get_remove
|
from .ordered_collection import OrderedCollection, OrderedCollectionPage
|
||||||
from .status import get_review, get_review_article
|
from .person import Person
|
||||||
from .status import get_rating, get_rating_note
|
from .book import Edition, Work, Author
|
||||||
from .status import get_comment, get_comment_article
|
from .verbs import Create, Undo, Update
|
||||||
from .status import get_quotation, get_quotation_article
|
from .verbs import Follow, Accept, Reject
|
||||||
from .status import get_status, get_replies, get_replies_page
|
from .verbs import Add, Remove
|
||||||
from .status import get_favorite, get_unfavorite
|
|
||||||
from .status import get_boost
|
# this creates a list of all the Activity types that we can serialize,
|
||||||
from .status import get_add_tag, get_remove_tag
|
# so when an Activity comes in from outside, we can check if it's known
|
||||||
|
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||||
|
activity_objects = {c[0]: c[1] for c in cls_members \
|
||||||
|
if hasattr(c[1], 'to_model')}
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
''' actor serializer '''
|
|
||||||
from fedireads.settings import DOMAIN
|
|
||||||
|
|
||||||
def get_actor(user):
|
|
||||||
''' activitypub actor from db User '''
|
|
||||||
avatar = user.avatar
|
|
||||||
|
|
||||||
icon_path = '/static/images/default_avi.jpg'
|
|
||||||
icon_type = 'image/jpeg'
|
|
||||||
if avatar:
|
|
||||||
icon_path = avatar.url
|
|
||||||
icon_type = 'image/%s' % icon_path.split('.')[-1]
|
|
||||||
|
|
||||||
icon_url = 'https://%s%s' % (DOMAIN, icon_path)
|
|
||||||
return {
|
|
||||||
'@context': [
|
|
||||||
'https://www.w3.org/ns/activitystreams',
|
|
||||||
'https://w3id.org/security/v1',
|
|
||||||
{
|
|
||||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
|
||||||
"schema": "http://schema.org#",
|
|
||||||
"PropertyValue": "schema:PropertyValue",
|
|
||||||
"value": "schema:value",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
'id': user.remote_id,
|
|
||||||
'type': 'Person',
|
|
||||||
'preferredUsername': user.localname,
|
|
||||||
'name': user.name,
|
|
||||||
'inbox': user.inbox,
|
|
||||||
'outbox': '%s/outbox' % user.remote_id,
|
|
||||||
'followers': '%s/followers' % user.remote_id,
|
|
||||||
'following': '%s/following' % user.remote_id,
|
|
||||||
'summary': user.summary,
|
|
||||||
'publicKey': {
|
|
||||||
'id': '%s/#main-key' % user.remote_id,
|
|
||||||
'owner': user.remote_id,
|
|
||||||
'publicKeyPem': user.public_key,
|
|
||||||
},
|
|
||||||
'endpoints': {
|
|
||||||
'sharedInbox': user.shared_inbox,
|
|
||||||
},
|
|
||||||
'fedireadsUser': True,
|
|
||||||
'manuallyApprovesFollowers': user.manually_approves_followers,
|
|
||||||
"icon": {
|
|
||||||
"type": "Image",
|
|
||||||
"mediaType": icon_type,
|
|
||||||
"url": icon_url,
|
|
||||||
},
|
|
||||||
}
|
|
118
fedireads/activitypub/base_activity.py
Normal file
118
fedireads/activitypub/base_activity.py
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
''' basics for an activitypub serializer '''
|
||||||
|
from dataclasses import dataclass, fields, MISSING
|
||||||
|
from json import JSONEncoder
|
||||||
|
|
||||||
|
from django.db.models.fields.related_descriptors \
|
||||||
|
import ForwardManyToOneDescriptor
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityEncoder(JSONEncoder):
|
||||||
|
''' used to convert an Activity object into json '''
|
||||||
|
def default(self, o):
|
||||||
|
return o.__dict__
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Image:
|
||||||
|
''' image block '''
|
||||||
|
mediaType: str
|
||||||
|
url: str
|
||||||
|
type: str = 'Image'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PublicKey:
|
||||||
|
''' public key block '''
|
||||||
|
id: str
|
||||||
|
owner: str
|
||||||
|
publicKeyPem: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Signature:
|
||||||
|
''' public key block '''
|
||||||
|
creator: str
|
||||||
|
created: str
|
||||||
|
signatureValue: str
|
||||||
|
type: str = 'RsaSignature2017'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class ActivityObject:
|
||||||
|
''' actor activitypub json '''
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
''' this lets you pass in an object with fields
|
||||||
|
that aren't in the dataclass, which it ignores.
|
||||||
|
Any field in the dataclass is required or has a
|
||||||
|
default value '''
|
||||||
|
for field in fields(self):
|
||||||
|
try:
|
||||||
|
value = kwargs[field.name]
|
||||||
|
except KeyError:
|
||||||
|
if field.default == MISSING:
|
||||||
|
raise TypeError('Missing required field: %s' % field.name)
|
||||||
|
value = field.default
|
||||||
|
setattr(self, field.name, value)
|
||||||
|
|
||||||
|
|
||||||
|
def to_model(self, model, instance=None):
|
||||||
|
''' convert from an activity to a model '''
|
||||||
|
if not isinstance(self, model.activity_serializer):
|
||||||
|
raise TypeError('Wrong activity type for model')
|
||||||
|
|
||||||
|
model_fields = [m.name for m in model._meta.get_fields()]
|
||||||
|
mapped_fields = {}
|
||||||
|
|
||||||
|
for mapping in model.activity_mappings:
|
||||||
|
if mapping.model_key not in model_fields:
|
||||||
|
continue
|
||||||
|
# value is None if there's a default that isn't supplied
|
||||||
|
# in the activity but is supplied in the formatter
|
||||||
|
value = None
|
||||||
|
if mapping.activity_key:
|
||||||
|
value = getattr(self, mapping.activity_key)
|
||||||
|
model_field = getattr(model, mapping.model_key)
|
||||||
|
|
||||||
|
# remote_id -> foreign key resolver
|
||||||
|
if isinstance(model_field, ForwardManyToOneDescriptor) and value:
|
||||||
|
fk_model = model_field.field.related_model
|
||||||
|
value = resolve_foreign_key(fk_model, value)
|
||||||
|
|
||||||
|
mapped_fields[mapping.model_key] = mapping.model_formatter(value)
|
||||||
|
|
||||||
|
|
||||||
|
# updating an existing model isntance
|
||||||
|
if instance:
|
||||||
|
for k, v in mapped_fields.items():
|
||||||
|
setattr(instance, k, v)
|
||||||
|
instance.save()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
# creating a new model instance
|
||||||
|
return model.objects.create(**mapped_fields)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
''' convert to dictionary with context attr '''
|
||||||
|
data = self.__dict__
|
||||||
|
data['@context'] = 'https://www.w3.org/ns/activitystreams'
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_foreign_key(model, remote_id):
|
||||||
|
''' look up the remote_id on an activity json field '''
|
||||||
|
result = model.objects
|
||||||
|
if hasattr(model.objects, 'select_subclasses'):
|
||||||
|
result = result.select_subclasses()
|
||||||
|
|
||||||
|
result = result.filter(
|
||||||
|
remote_id=remote_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise ValueError('Could not resolve remote_id in %s model: %s' % \
|
||||||
|
(model.__name__, remote_id))
|
||||||
|
return result
|
|
@ -1,127 +1,67 @@
|
||||||
''' federate book data '''
|
''' book and author data '''
|
||||||
from fedireads.settings import DOMAIN
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List
|
||||||
|
|
||||||
def get_book(book, recursive=True):
|
from .base_activity import ActivityObject, Image
|
||||||
''' activitypub serialize a book '''
|
|
||||||
|
|
||||||
fields = [
|
@dataclass(init=False)
|
||||||
'title',
|
class Book(ActivityObject):
|
||||||
'sort_title',
|
''' serializes an edition or work, abstract '''
|
||||||
'subtitle',
|
authors: List[str]
|
||||||
'isbn_13',
|
first_published_date: str
|
||||||
'oclc_number',
|
published_date: str
|
||||||
'openlibrary_key',
|
|
||||||
'librarything_key',
|
|
||||||
'lccn',
|
|
||||||
'oclc_number',
|
|
||||||
'pages',
|
|
||||||
'physical_format',
|
|
||||||
'misc_identifiers',
|
|
||||||
|
|
||||||
'description',
|
title: str
|
||||||
'languages',
|
sort_title: str
|
||||||
'series',
|
subtitle: str
|
||||||
'series_number',
|
description: str
|
||||||
'subjects',
|
languages: List[str]
|
||||||
'subject_places',
|
series: str
|
||||||
'pages',
|
series_number: str
|
||||||
'physical_format',
|
subjects: List[str]
|
||||||
]
|
subject_places: List[str]
|
||||||
|
|
||||||
book_type = type(book).__name__
|
openlibrary_key: str
|
||||||
activity = {
|
librarything_key: str
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
goodreads_key: str
|
||||||
'type': 'Document',
|
|
||||||
'book_type': book_type,
|
|
||||||
'name': book.title,
|
|
||||||
'url': book.local_id,
|
|
||||||
|
|
||||||
'authors': [a.local_id for a in book.authors.all()],
|
attachment: List[Image] = field(default=lambda: [])
|
||||||
'first_published_date': book.first_published_date.isoformat() if \
|
type: str = 'Book'
|
||||||
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}
|
|
||||||
|
|
||||||
|
|
||||||
def get_author(author):
|
@dataclass(init=False)
|
||||||
''' serialize an author '''
|
class Edition(Book):
|
||||||
fields = [
|
''' Edition instance of a book object '''
|
||||||
'name',
|
isbn_10: str
|
||||||
'born',
|
isbn_13: str
|
||||||
'died',
|
oclc_number: str
|
||||||
'aliases',
|
asin: str
|
||||||
'bio'
|
pages: str
|
||||||
'openlibrary_key',
|
physical_format: str
|
||||||
'wikipedia_link',
|
publishers: List[str]
|
||||||
]
|
|
||||||
activity = {
|
work: str
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
type: str = 'Edition'
|
||||||
'url': author.local_id,
|
|
||||||
'type': 'Person',
|
|
||||||
}
|
|
||||||
for field in fields:
|
|
||||||
if hasattr(author, field):
|
|
||||||
activity[field] = author.__getattribute__(field)
|
|
||||||
return activity
|
|
||||||
|
|
||||||
|
|
||||||
def get_shelf(shelf, page=None):
|
@dataclass(init=False)
|
||||||
''' serialize shelf object '''
|
class Work(Book):
|
||||||
id_slug = shelf.remote_id
|
''' work instance of a book object '''
|
||||||
if page:
|
lccn: str
|
||||||
return get_shelf_page(shelf, page)
|
editions: List[str]
|
||||||
count = shelf.books.count()
|
type: str = 'Work'
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': id_slug,
|
|
||||||
'type': 'OrderedCollection',
|
|
||||||
'totalItems': count,
|
|
||||||
'first': '%s?page=1' % id_slug,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_shelf_page(shelf, page):
|
|
||||||
''' list of books on a shelf '''
|
@dataclass(init=False)
|
||||||
page = int(page)
|
class Author(ActivityObject):
|
||||||
page_length = 10
|
''' author of a book '''
|
||||||
start = (page - 1) * page_length
|
url: str
|
||||||
end = start + page_length
|
name: str
|
||||||
shelf_page = shelf.books.all()[start:end]
|
born: str
|
||||||
id_slug = shelf.local_id
|
died: str
|
||||||
data = {
|
aliases: str
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
bio: str
|
||||||
'id': '%s?page=%d' % (id_slug, page),
|
openlibrary_key: str
|
||||||
'type': 'OrderedCollectionPage',
|
wikipedia_link: str
|
||||||
'totalItems': shelf.books.count(),
|
type: str = 'Person'
|
||||||
'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
|
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
''' format Create activities and sign them '''
|
|
||||||
from base64 import b64encode
|
|
||||||
from Crypto.PublicKey import RSA
|
|
||||||
from Crypto.Signature import pkcs1_15
|
|
||||||
from Crypto.Hash import SHA256
|
|
||||||
|
|
||||||
def get_create(user, status_json):
|
|
||||||
''' create activitypub json for a Create activity '''
|
|
||||||
signer = pkcs1_15.new(RSA.import_key(user.private_key))
|
|
||||||
content = status_json['content']
|
|
||||||
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
|
|
||||||
'id': '%s/activity' % status_json['id'],
|
|
||||||
'type': 'Create',
|
|
||||||
'actor': user.remote_id,
|
|
||||||
'published': status_json['published'],
|
|
||||||
|
|
||||||
'to': ['%s/followers' % user.remote_id],
|
|
||||||
'cc': ['https://www.w3.org/ns/activitystreams#Public'],
|
|
||||||
|
|
||||||
'object': status_json,
|
|
||||||
'signature': {
|
|
||||||
'type': 'RsaSignature2017',
|
|
||||||
'creator': '%s#main-key' % user.remote_id,
|
|
||||||
'created': status_json['published'],
|
|
||||||
'signatureValue': b64encode(signed_message).decode('utf8'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_update(user, activity_json):
|
|
||||||
''' a user profile or book or whatever got updated '''
|
|
||||||
# TODO: should this have a signature??
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': 'https://friend.camp/users/tripofmice#updates/1585446332',
|
|
||||||
'type': 'Update',
|
|
||||||
'actor': user.remote_id,
|
|
||||||
'to': [
|
|
||||||
'https://www.w3.org/ns/activitystreams#Public'
|
|
||||||
],
|
|
||||||
|
|
||||||
'object': activity_json,
|
|
||||||
}
|
|
|
@ -1,113 +0,0 @@
|
||||||
''' makin' freinds inthe ap json format '''
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from fedireads.settings import DOMAIN
|
|
||||||
|
|
||||||
|
|
||||||
def get_follow_request(user, to_follow):
|
|
||||||
''' a local user wants to follow someone '''
|
|
||||||
uuid = uuid4()
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': 'https://%s/%s' % (DOMAIN, str(uuid)),
|
|
||||||
'summary': '',
|
|
||||||
'type': 'Follow',
|
|
||||||
'actor': user.remote_id,
|
|
||||||
'object': to_follow.remote_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_unfollow(relationship):
|
|
||||||
''' undo that precious bond of friendship '''
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': '%s/undo' % relationship.remote_id,
|
|
||||||
'type': 'Undo',
|
|
||||||
'actor': relationship.user_subject.remote_id,
|
|
||||||
'object': {
|
|
||||||
'id': relationship.relationship_id,
|
|
||||||
'type': 'Follow',
|
|
||||||
'actor': relationship.user_subject.remote_id,
|
|
||||||
'object': relationship.user_object.remote_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_accept(user, relationship):
|
|
||||||
''' accept a follow request '''
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': '%s#accepts/follows/' % user.remote_id,
|
|
||||||
'type': 'Accept',
|
|
||||||
'actor': user.remote_id,
|
|
||||||
'object': {
|
|
||||||
'id': relationship.relationship_id,
|
|
||||||
'type': 'Follow',
|
|
||||||
'actor': relationship.user_subject.remote_id,
|
|
||||||
'object': relationship.user_object.remote_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_reject(user, relationship):
|
|
||||||
''' reject a follow request '''
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': '%s#rejects/follows/' % user.remote_id,
|
|
||||||
'type': 'Reject',
|
|
||||||
'actor': user.remote_id,
|
|
||||||
'object': {
|
|
||||||
'id': relationship.relationship_id,
|
|
||||||
'type': 'Follow',
|
|
||||||
'actor': relationship.user_subject.remote_id,
|
|
||||||
'object': relationship.user_object.remote_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_followers(user, page, follow_queryset):
|
|
||||||
''' list of people who follow a user '''
|
|
||||||
id_slug = '%s/followers' % user.remote_id
|
|
||||||
return get_follow_info(id_slug, page, follow_queryset)
|
|
||||||
|
|
||||||
|
|
||||||
def get_following(user, page, follow_queryset):
|
|
||||||
''' list of people who follow a user '''
|
|
||||||
id_slug = '%s/following' % user.remote_id
|
|
||||||
return get_follow_info(id_slug, page, follow_queryset)
|
|
||||||
|
|
||||||
|
|
||||||
def get_follow_info(id_slug, page, follow_queryset):
|
|
||||||
''' a list of followers or following '''
|
|
||||||
if page:
|
|
||||||
return get_follow_page(follow_queryset, id_slug, page)
|
|
||||||
count = follow_queryset.count()
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': id_slug,
|
|
||||||
'type': 'OrderedCollection',
|
|
||||||
'totalItems': count,
|
|
||||||
'first': '%s?page=1' % id_slug,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_follow_page(user_list, id_slug, page):
|
|
||||||
''' format a list of followers/following '''
|
|
||||||
page = int(page)
|
|
||||||
page_length = 10
|
|
||||||
start = (page - 1) * page_length
|
|
||||||
end = start + page_length
|
|
||||||
follower_page = user_list.all()[start:end]
|
|
||||||
data = {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': '%s?page=%d' % (id_slug, page),
|
|
||||||
'type': 'OrderedCollectionPage',
|
|
||||||
'totalItems': user_list.count(),
|
|
||||||
'partOf': id_slug,
|
|
||||||
'orderedItems': [u.remote_id for u in follower_page],
|
|
||||||
}
|
|
||||||
if end <= user_list.count():
|
|
||||||
# there are still more pages
|
|
||||||
data['next'] = '%s?page=%d' % (id_slug, page + 1)
|
|
||||||
if start > 0:
|
|
||||||
data['prev'] = '%s?page=%d' % (id_slug, page - 1)
|
|
||||||
return data
|
|
20
fedireads/activitypub/interaction.py
Normal file
20
fedireads/activitypub/interaction.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
''' boosting and liking posts '''
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .base_activity import ActivityObject
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Like(ActivityObject):
|
||||||
|
''' a user faving an object '''
|
||||||
|
actor: str
|
||||||
|
object: str
|
||||||
|
type: str = 'Like'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Boost(ActivityObject):
|
||||||
|
''' boosting a status '''
|
||||||
|
actor: str
|
||||||
|
object: str
|
||||||
|
type: str = 'Announce'
|
50
fedireads/activitypub/note.py
Normal file
50
fedireads/activitypub/note.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
''' note serializer and children thereof '''
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from .base_activity import ActivityObject, Image
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Note(ActivityObject):
|
||||||
|
''' Note activity '''
|
||||||
|
url: str
|
||||||
|
inReplyTo: str
|
||||||
|
published: str
|
||||||
|
attributedTo: str
|
||||||
|
to: List[str]
|
||||||
|
cc: List[str]
|
||||||
|
content: str
|
||||||
|
replies: Dict
|
||||||
|
# TODO: this is wrong???
|
||||||
|
attachment: List[Image] = field(default=lambda: [])
|
||||||
|
sensitive: bool = False
|
||||||
|
type: str = 'Note'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Article(Note):
|
||||||
|
''' what's an article except a note with more fields '''
|
||||||
|
name: str
|
||||||
|
type: str = 'Article'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Comment(Note):
|
||||||
|
''' like a note but with a book '''
|
||||||
|
inReplyToBook: str
|
||||||
|
type: str = 'Comment'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Review(Comment):
|
||||||
|
''' a full book review '''
|
||||||
|
name: str
|
||||||
|
rating: int
|
||||||
|
type: str = 'Review'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Quotation(Comment):
|
||||||
|
''' a quote and commentary on a book '''
|
||||||
|
quote: str
|
||||||
|
type: str = 'Quotation'
|
25
fedireads/activitypub/ordered_collection.py
Normal file
25
fedireads/activitypub/ordered_collection.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
''' defines activitypub collections (lists) '''
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from .base_activity import ActivityObject
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class OrderedCollection(ActivityObject):
|
||||||
|
''' structure of an ordered collection activity '''
|
||||||
|
totalItems: int
|
||||||
|
first: str
|
||||||
|
last: str = ''
|
||||||
|
name: str = ''
|
||||||
|
type: str = 'OrderedCollection'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class OrderedCollectionPage(ActivityObject):
|
||||||
|
''' structure of an ordered collection activity '''
|
||||||
|
partOf: str
|
||||||
|
orderedItems: List
|
||||||
|
next: str
|
||||||
|
prev: str
|
||||||
|
type: str = 'OrderedCollectionPage'
|
|
@ -1,43 +0,0 @@
|
||||||
''' activitypub json for collections '''
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from .status import get_status, get_review
|
|
||||||
|
|
||||||
def get_outbox(user, size):
|
|
||||||
''' helper function for creating an outbox '''
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': user.outbox,
|
|
||||||
'type': 'OrderedCollection',
|
|
||||||
'totalItems': size,
|
|
||||||
'first': '%s?page=true' % user.outbox,
|
|
||||||
'last': '%s?min_id=0&page=true' % user.outbox
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_outbox_page(user, page_id, statuses, max_id, min_id):
|
|
||||||
''' helper for formatting outbox pages '''
|
|
||||||
# not generalizing this more because the format varies for some reason
|
|
||||||
page = {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': page_id,
|
|
||||||
'type': 'OrderedCollectionPage',
|
|
||||||
'partOf': user.outbox,
|
|
||||||
'orderedItems': [],
|
|
||||||
}
|
|
||||||
|
|
||||||
for status in statuses:
|
|
||||||
if status.status_type == 'Review':
|
|
||||||
status_activity = get_review(status)
|
|
||||||
else:
|
|
||||||
status_activity = get_status(status)
|
|
||||||
page['orderedItems'].append(status_activity)
|
|
||||||
|
|
||||||
if max_id:
|
|
||||||
page['next'] = user.outbox + '?' + \
|
|
||||||
urlencode({'min_id': max_id, 'page': 'true'})
|
|
||||||
if min_id:
|
|
||||||
page['prev'] = user.outbox + '?' + \
|
|
||||||
urlencode({'max_id': min_id, 'page': 'true'})
|
|
||||||
|
|
||||||
return page
|
|
22
fedireads/activitypub/person.py
Normal file
22
fedireads/activitypub/person.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
''' actor serializer '''
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from .base_activity import ActivityObject, Image, PublicKey
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Person(ActivityObject):
|
||||||
|
''' actor activitypub json '''
|
||||||
|
preferredUsername: str
|
||||||
|
name: str
|
||||||
|
inbox: str
|
||||||
|
outbox: str
|
||||||
|
followers: str
|
||||||
|
summary: str
|
||||||
|
publicKey: PublicKey
|
||||||
|
endpoints: Dict
|
||||||
|
icon: Image = field(default=lambda: {})
|
||||||
|
fedireadsUser: str = False
|
||||||
|
manuallyApprovesFollowers: str = False
|
||||||
|
discoverable: str = True
|
||||||
|
type: str = 'Person'
|
|
@ -1,32 +0,0 @@
|
||||||
''' activitypub json for collections '''
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
def get_add(*args):
|
|
||||||
''' activitypub Add activity '''
|
|
||||||
return get_add_remove(*args, action='Add')
|
|
||||||
|
|
||||||
|
|
||||||
def get_remove(*args):
|
|
||||||
''' activitypub Add activity '''
|
|
||||||
return get_add_remove(*args, action='Remove')
|
|
||||||
|
|
||||||
|
|
||||||
def get_add_remove(user, book, shelf, action='Add'):
|
|
||||||
''' format a shelve book json blob '''
|
|
||||||
uuid = uuid4()
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': str(uuid),
|
|
||||||
'type': action,
|
|
||||||
'actor': user.remote_id,
|
|
||||||
'object': {
|
|
||||||
'type': 'Document',
|
|
||||||
'name': book.title,
|
|
||||||
'url': book.local_id,
|
|
||||||
},
|
|
||||||
'target': {
|
|
||||||
'type': 'Collection',
|
|
||||||
'name': shelf.name,
|
|
||||||
'id': shelf.remote_id,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,254 +0,0 @@
|
||||||
''' status serializers '''
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from fedireads.settings import DOMAIN
|
|
||||||
|
|
||||||
|
|
||||||
def get_rating(review):
|
|
||||||
''' activitypub serialize rating activity '''
|
|
||||||
status = get_status(review)
|
|
||||||
status['inReplyToBook'] = review.book.local_id
|
|
||||||
status['fedireadsType'] = review.status_type
|
|
||||||
status['rating'] = review.rating
|
|
||||||
status['content'] = '%d star rating of "%s"' % (
|
|
||||||
review.rating, review.book.title)
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
def get_quotation(quotation):
|
|
||||||
''' fedireads json for quotations '''
|
|
||||||
status = get_status(quotation)
|
|
||||||
status['inReplyToBook'] = quotation.book.local_id
|
|
||||||
status['fedireadsType'] = quotation.status_type
|
|
||||||
status['quote'] = quotation.quote
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
def get_quotation_article(quotation):
|
|
||||||
''' a book quotation formatted for a non-fedireads isntance (mastodon) '''
|
|
||||||
status = get_status(quotation)
|
|
||||||
content = '"%s"<br>-- <a href="%s">"%s"</a>)<br><br>%s' % (
|
|
||||||
quotation.quote,
|
|
||||||
quotation.book.local_id,
|
|
||||||
quotation.book.title,
|
|
||||||
quotation.content,
|
|
||||||
)
|
|
||||||
status['content'] = content
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
def get_review(review):
|
|
||||||
''' fedireads json for book reviews '''
|
|
||||||
status = get_status(review)
|
|
||||||
status['inReplyToBook'] = review.book.local_id
|
|
||||||
status['fedireadsType'] = review.status_type
|
|
||||||
status['name'] = review.name
|
|
||||||
status['rating'] = review.rating
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
def get_comment(comment):
|
|
||||||
''' fedireads json for book reviews '''
|
|
||||||
status = get_status(comment)
|
|
||||||
status['inReplyToBook'] = comment.book.local_id
|
|
||||||
status['fedireadsType'] = comment.status_type
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
def get_rating_note(review):
|
|
||||||
''' simple rating, send it as a note not an artciel '''
|
|
||||||
status = get_status(review)
|
|
||||||
status['content'] = 'Rated "%s": %d stars' % (
|
|
||||||
review.book.title,
|
|
||||||
review.rating,
|
|
||||||
)
|
|
||||||
status['type'] = 'Note'
|
|
||||||
return status
|
|
||||||
|
|
||||||
def get_review_article(review):
|
|
||||||
''' a book review formatted for a non-fedireads isntance (mastodon) '''
|
|
||||||
status = get_status(review)
|
|
||||||
if review.rating:
|
|
||||||
status['name'] = 'Review of "%s" (%d stars): %s' % (
|
|
||||||
review.book.title,
|
|
||||||
review.rating,
|
|
||||||
review.name
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
status['name'] = 'Review of "%s": %s' % (
|
|
||||||
review.book.title,
|
|
||||||
review.name
|
|
||||||
)
|
|
||||||
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
def get_comment_article(comment):
|
|
||||||
''' a book comment formatted for a non-fedireads isntance (mastodon) '''
|
|
||||||
status = get_status(comment)
|
|
||||||
status['content'] += '<br><br>(comment on <a href="%s">"%s"</a>)' % \
|
|
||||||
(comment.book.local_id, comment.book.title)
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
def get_status(status):
|
|
||||||
''' create activitypub json for a status '''
|
|
||||||
user = status.user
|
|
||||||
uri = status.remote_id
|
|
||||||
reply_parent_id = status.reply_parent.remote_id \
|
|
||||||
if status.reply_parent else None
|
|
||||||
|
|
||||||
image_attachments = []
|
|
||||||
books = list(status.mention_books.all()[:3])
|
|
||||||
if hasattr(status, 'book'):
|
|
||||||
books.append(status.book)
|
|
||||||
for book in books:
|
|
||||||
if book and book.cover:
|
|
||||||
image_path = book.cover.url
|
|
||||||
image_type = image_path.split('.')[-1]
|
|
||||||
image_attachments.append({
|
|
||||||
'type': 'Document',
|
|
||||||
'mediaType': 'image/%s' % image_type,
|
|
||||||
'url': 'https://%s%s' % (DOMAIN, image_path),
|
|
||||||
'name': 'Cover of "%s"' % book.title,
|
|
||||||
})
|
|
||||||
status_json = {
|
|
||||||
'id': uri,
|
|
||||||
'url': uri,
|
|
||||||
'inReplyTo': reply_parent_id,
|
|
||||||
'published': status.published_date.isoformat(),
|
|
||||||
'attributedTo': user.remote_id,
|
|
||||||
# TODO: assuming all posts are public -- should check privacy db field
|
|
||||||
'to': ['https://www.w3.org/ns/activitystreams#Public'],
|
|
||||||
'cc': ['%s/followers' % user.remote_id],
|
|
||||||
'sensitive': status.sensitive,
|
|
||||||
'content': status.content,
|
|
||||||
'type': status.activity_type,
|
|
||||||
'attachment': image_attachments,
|
|
||||||
'replies': {
|
|
||||||
'id': '%s/replies' % uri,
|
|
||||||
'type': 'Collection',
|
|
||||||
'first': {
|
|
||||||
'type': 'CollectionPage',
|
|
||||||
'next': '%s/replies?only_other_accounts=true&page=true' % uri,
|
|
||||||
'partOf': '%s/replies' % uri,
|
|
||||||
'items': [], # TODO: populate with replies
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return status_json
|
|
||||||
|
|
||||||
|
|
||||||
def get_replies(status, replies):
|
|
||||||
''' collection of replies '''
|
|
||||||
id_slug = status.remote_id + '/replies'
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': id_slug,
|
|
||||||
'type': 'Collection',
|
|
||||||
'first': {
|
|
||||||
'id': '%s?page=true' % id_slug,
|
|
||||||
'type': 'CollectionPage',
|
|
||||||
'next': '%s?only_other_accounts=true&page=true' % id_slug,
|
|
||||||
'partOf': id_slug,
|
|
||||||
'items': [get_status(r) for r in replies],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_replies_page(status, replies):
|
|
||||||
''' actual reply list content '''
|
|
||||||
id_slug = status.remote_id + '/replies?page=true&only_other_accounts=true'
|
|
||||||
items = []
|
|
||||||
for reply in replies:
|
|
||||||
if reply.user.local:
|
|
||||||
items.append(get_status(reply))
|
|
||||||
else:
|
|
||||||
items.append(reply.remote_id)
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': id_slug,
|
|
||||||
'type': 'CollectionPage',
|
|
||||||
'next': '%s&min_id=%d' % (id_slug, replies[len(replies) - 1].id),
|
|
||||||
'partOf': status.remote_id + '/replies',
|
|
||||||
'items': [items]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_favorite(favorite):
|
|
||||||
''' like a post '''
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': favorite.remote_id,
|
|
||||||
'type': 'Like',
|
|
||||||
'actor': favorite.user.remote_id,
|
|
||||||
'object': favorite.status.remote_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_unfavorite(favorite):
|
|
||||||
''' like a post '''
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': '%s/undo' % favorite.remote_id,
|
|
||||||
'type': 'Undo',
|
|
||||||
'actor': favorite.user.remote_id,
|
|
||||||
'object': {
|
|
||||||
'id': favorite.remote_id,
|
|
||||||
'type': 'Like',
|
|
||||||
'actor': favorite.user.remote_id,
|
|
||||||
'object': favorite.status.remote_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_boost(boost):
|
|
||||||
''' boost/announce a post '''
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': boost.remote_id,
|
|
||||||
'type': 'Announce',
|
|
||||||
'actor': boost.user.remote_id,
|
|
||||||
'object': boost.boosted_status.remote_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_add_tag(tag):
|
|
||||||
''' add activity for tagging a book '''
|
|
||||||
uuid = uuid4()
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': str(uuid),
|
|
||||||
'type': 'Add',
|
|
||||||
'actor': tag.user.remote_id,
|
|
||||||
'object': {
|
|
||||||
'type': 'Tag',
|
|
||||||
'id': tag.remote_id,
|
|
||||||
'name': tag.name,
|
|
||||||
},
|
|
||||||
'target': {
|
|
||||||
'type': 'Book',
|
|
||||||
'id': tag.book.local_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_remove_tag(tag):
|
|
||||||
''' add activity for tagging a book '''
|
|
||||||
uuid = uuid4()
|
|
||||||
return {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
||||||
'id': str(uuid),
|
|
||||||
'type': 'Remove',
|
|
||||||
'actor': tag.user.remote_id,
|
|
||||||
'object': {
|
|
||||||
'type': 'Tag',
|
|
||||||
'id': tag.remote_id,
|
|
||||||
'name': tag.name,
|
|
||||||
},
|
|
||||||
'target': {
|
|
||||||
'type': 'Book',
|
|
||||||
'id': tag.book.local_id,
|
|
||||||
}
|
|
||||||
}
|
|
68
fedireads/activitypub/verbs.py
Normal file
68
fedireads/activitypub/verbs.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
''' undo wrapper activity '''
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from .base_activity import ActivityObject, Signature
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Verb(ActivityObject):
|
||||||
|
''' generic fields for activities - maybe an unecessary level of
|
||||||
|
abstraction but w/e '''
|
||||||
|
actor: str
|
||||||
|
object: ActivityObject
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Create(Verb):
|
||||||
|
''' Create activity '''
|
||||||
|
to: List
|
||||||
|
cc: List
|
||||||
|
signature: Signature
|
||||||
|
type: str = 'Create'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Update(Verb):
|
||||||
|
''' Update activity '''
|
||||||
|
to: List
|
||||||
|
type: str = 'Update'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Undo(Verb):
|
||||||
|
''' Undo an activity '''
|
||||||
|
type: str = 'Undo'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Follow(Verb):
|
||||||
|
''' Follow activity '''
|
||||||
|
type: str = 'Follow'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Accept(Verb):
|
||||||
|
''' Accept activity '''
|
||||||
|
object: Follow
|
||||||
|
type: str = 'Accept'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Reject(Verb):
|
||||||
|
''' Reject activity '''
|
||||||
|
object: Follow
|
||||||
|
type: str = 'Reject'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Add(Verb):
|
||||||
|
'''Add activity '''
|
||||||
|
target: ActivityObject
|
||||||
|
type: str = 'Add'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Remove(Verb):
|
||||||
|
'''Remove activity '''
|
||||||
|
target: ActivityObject
|
||||||
|
type: str = 'Remove'
|
|
@ -1,9 +1,9 @@
|
||||||
''' select and call a connector for whatever book task needs doing '''
|
''' select and call a connector for whatever book task needs doing '''
|
||||||
from requests import HTTPError
|
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from requests import HTTPError
|
||||||
|
|
||||||
from fedireads import models
|
from fedireads import models
|
||||||
from fedireads.tasks import app
|
from fedireads.tasks import app
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ from django.utils.http import http_date
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from fedireads import models
|
from fedireads import models
|
||||||
|
from fedireads.activitypub import ActivityEncoder
|
||||||
from fedireads.tasks import app
|
from fedireads.tasks import app
|
||||||
from fedireads.signatures import make_signature, make_digest
|
from fedireads.signatures import make_signature, make_digest
|
||||||
|
|
||||||
|
@ -38,7 +39,11 @@ def broadcast(sender, activity, software=None, \
|
||||||
# TODO: other kinds of privacy
|
# TODO: other kinds of privacy
|
||||||
if privacy == 'public':
|
if privacy == 'public':
|
||||||
recipients += get_public_recipients(sender, software=software)
|
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
|
@app.task
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
''' handles all of the activity coming in to the server '''
|
''' handles all of the activity coming in to the server '''
|
||||||
import json
|
import json
|
||||||
from urllib.parse import urldefrag
|
from urllib.parse import urldefrag
|
||||||
import requests
|
|
||||||
|
|
||||||
import django.db.utils
|
import django.db.utils
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.http import HttpResponseBadRequest, HttpResponseNotFound
|
from django.http import HttpResponseBadRequest, HttpResponseNotFound
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
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 import status as status_builder
|
||||||
from fedireads.remote_user import get_or_create_remote_user, refresh_remote_user
|
from fedireads.remote_user import get_or_create_remote_user, refresh_remote_user
|
||||||
from fedireads.tasks import app
|
from fedireads.tasks import app
|
||||||
|
@ -84,6 +84,7 @@ def shared_inbox(request):
|
||||||
|
|
||||||
|
|
||||||
def has_valid_signature(request, activity):
|
def has_valid_signature(request, activity):
|
||||||
|
''' verify incoming signature '''
|
||||||
try:
|
try:
|
||||||
signature = Signature.parse(request)
|
signature = Signature.parse(request)
|
||||||
|
|
||||||
|
@ -111,14 +112,13 @@ def handle_follow(activity):
|
||||||
''' someone wants to follow a local user '''
|
''' someone wants to follow a local user '''
|
||||||
# figure out who they want to follow -- not using get_or_create because
|
# figure out who they want to follow -- not using get_or_create because
|
||||||
# we only allow you to follow local users
|
# we only allow you to follow local users
|
||||||
try:
|
to_follow = models.User.objects.get(remote_id=activity['object'])
|
||||||
to_follow = models.User.objects.get(remote_id=activity['object'])
|
# raises models.User.DoesNotExist id the remote id is not found
|
||||||
except models.User.DoesNotExist:
|
|
||||||
return False
|
|
||||||
# figure out who the actor is
|
# figure out who the actor is
|
||||||
user = get_or_create_remote_user(activity['actor'])
|
user = get_or_create_remote_user(activity['actor'])
|
||||||
try:
|
try:
|
||||||
request = models.UserFollowRequest.objects.create(
|
relationship = models.UserFollowRequest.objects.create(
|
||||||
user_subject=user,
|
user_subject=user,
|
||||||
user_object=to_follow,
|
user_object=to_follow,
|
||||||
relationship_id=activity['id']
|
relationship_id=activity['id']
|
||||||
|
@ -137,7 +137,7 @@ def handle_follow(activity):
|
||||||
'FOLLOW',
|
'FOLLOW',
|
||||||
related_user=user
|
related_user=user
|
||||||
)
|
)
|
||||||
outgoing.handle_accept(user, to_follow, request)
|
outgoing.handle_accept(user, to_follow, relationship)
|
||||||
else:
|
else:
|
||||||
status_builder.create_notification(
|
status_builder.create_notification(
|
||||||
to_follow,
|
to_follow,
|
||||||
|
@ -150,11 +150,9 @@ def handle_follow(activity):
|
||||||
def handle_unfollow(activity):
|
def handle_unfollow(activity):
|
||||||
''' unfollow a local user '''
|
''' unfollow a local user '''
|
||||||
obj = activity['object']
|
obj = activity['object']
|
||||||
try:
|
requester = get_or_create_remote_user(obj['actor'])
|
||||||
requester = get_or_create_remote_user(obj['actor'])
|
to_unfollow = models.User.objects.get(remote_id=obj['object'])
|
||||||
to_unfollow = models.User.objects.get(remote_id=obj['object'])
|
# raises models.User.DoesNotExist
|
||||||
except models.User.DoesNotExist:
|
|
||||||
return False
|
|
||||||
|
|
||||||
to_unfollow.followers.remove(requester)
|
to_unfollow.followers.remove(requester)
|
||||||
|
|
||||||
|
@ -184,67 +182,63 @@ def handle_follow_reject(activity):
|
||||||
requester = models.User.objects.get(remote_id=activity['object']['actor'])
|
requester = models.User.objects.get(remote_id=activity['object']['actor'])
|
||||||
rejecter = get_or_create_remote_user(activity['actor'])
|
rejecter = get_or_create_remote_user(activity['actor'])
|
||||||
|
|
||||||
try:
|
request = models.UserFollowRequest.objects.get(
|
||||||
request = models.UserFollowRequest.objects.get(
|
user_subject=requester,
|
||||||
user_subject=requester,
|
user_object=rejecter
|
||||||
user_object=rejecter
|
)
|
||||||
)
|
request.delete()
|
||||||
request.delete()
|
#raises models.UserFollowRequest.DoesNotExist:
|
||||||
except models.UserFollowRequest.DoesNotExist:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def handle_create(activity):
|
def handle_create(activity):
|
||||||
''' someone did something, good on them '''
|
''' 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:
|
if user.local:
|
||||||
# we really oughtn't even be sending in this case
|
# we really oughtn't even be sending in this case
|
||||||
return True
|
return
|
||||||
|
|
||||||
if activity['object'].get('fedireadsType') and \
|
# render the json into an activity object
|
||||||
'inReplyToBook' in activity['object']:
|
serializer = activitypub.activity_objects[activity['object']['type']]
|
||||||
if activity['object']['fedireadsType'] == 'Review':
|
activity = serializer(**activity['object'])
|
||||||
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
|
|
||||||
|
|
||||||
# create the status, it'll throw a ValueError if anything is missing
|
# ignore notes that aren't replies to known statuses
|
||||||
builder(user, activity['object'])
|
if activity.type == 'Note':
|
||||||
elif activity['object'].get('inReplyTo'):
|
reply = models.Status.objects.filter(
|
||||||
# only create the status if it's in reply to a status we already know
|
remote_id=activity.inReplyTo
|
||||||
if not status_builder.get_status(activity['object']['inReplyTo']):
|
).first()
|
||||||
return True
|
if not reply:
|
||||||
|
return
|
||||||
|
|
||||||
status = status_builder.create_status_from_activity(
|
model = models.activity_models[activity.type]
|
||||||
user,
|
status = activity.to_model(model)
|
||||||
activity['object']
|
|
||||||
|
# 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,
|
||||||
)
|
)
|
||||||
if status and status.reply_parent:
|
|
||||||
status_builder.create_notification(
|
|
||||||
status.reply_parent.user,
|
|
||||||
'REPLY',
|
|
||||||
related_user=status.user,
|
|
||||||
related_status=status,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def handle_favorite(activity):
|
def handle_favorite(activity):
|
||||||
''' approval of your good good post '''
|
''' approval of your good good post '''
|
||||||
try:
|
fav = activitypub.Like(**activity['object'])
|
||||||
status_id = activity['object'].split('/')[-1]
|
# raises ValueError in to_model if a foreign key could not be resolved in
|
||||||
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
|
|
||||||
|
|
||||||
if not liker.local:
|
liker = get_or_create_remote_user(activity['actor'])
|
||||||
status_builder.create_favorite_from_activity(liker, activity)
|
if liker.local:
|
||||||
|
return
|
||||||
|
|
||||||
|
status = fav.to_model(models.Favorite)
|
||||||
|
|
||||||
status_builder.create_notification(
|
status_builder.create_notification(
|
||||||
status.user,
|
status.user,
|
||||||
|
@ -257,10 +251,8 @@ def handle_favorite(activity):
|
||||||
@app.task
|
@app.task
|
||||||
def handle_unfavorite(activity):
|
def handle_unfavorite(activity):
|
||||||
''' approval of your good good post '''
|
''' approval of your good good post '''
|
||||||
favorite_id = activity['object']['id']
|
like = activitypub.Like(**activity['object'])
|
||||||
fav = models.Favorite.objects.filter(remote_id=favorite_id).first()
|
fav = models.Favorite.objects.filter(remote_id=like.id).first()
|
||||||
if not fav:
|
|
||||||
return False
|
|
||||||
|
|
||||||
fav.delete()
|
fav.delete()
|
||||||
|
|
||||||
|
@ -268,12 +260,9 @@ def handle_unfavorite(activity):
|
||||||
@app.task
|
@app.task
|
||||||
def handle_boost(activity):
|
def handle_boost(activity):
|
||||||
''' someone gave us a boost! '''
|
''' someone gave us a boost! '''
|
||||||
try:
|
status_id = activity['object'].split('/')[-1]
|
||||||
status_id = activity['object'].split('/')[-1]
|
status = models.Status.objects.get(id=status_id)
|
||||||
status = models.Status.objects.get(id=status_id)
|
booster = get_or_create_remote_user(activity['actor'])
|
||||||
booster = get_or_create_remote_user(activity['actor'])
|
|
||||||
except (models.Status.DoesNotExist, models.User.DoesNotExist):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not booster.local:
|
if not booster.local:
|
||||||
status_builder.create_boost_from_activity(booster, activity)
|
status_builder.create_boost_from_activity(booster, activity)
|
||||||
|
|
21
fedireads/migrations/0042_auto_20200524_0346.py
Normal file
21
fedireads/migrations/0042_auto_20200524_0346.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
14
fedireads/migrations/0045_merge_20200810_2010.py
Normal file
14
fedireads/migrations/0045_merge_20200810_2010.py
Normal 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 = [
|
||||||
|
]
|
|
@ -1,9 +1,17 @@
|
||||||
''' bring all the models into the app namespace '''
|
''' bring all the models into the app namespace '''
|
||||||
|
import inspect
|
||||||
|
import sys
|
||||||
|
|
||||||
from .book import Connector, Book, Work, Edition, Author
|
from .book import Connector, Book, Work, Edition, Author
|
||||||
from .shelf import Shelf, ShelfBook
|
from .shelf import Shelf, ShelfBook
|
||||||
from .status import Status, Review, Comment, Quotation
|
from .status import Status, Review, Comment, Quotation
|
||||||
from .status import Favorite, Boost, Tag, Notification, ReadThrough
|
from .status import Favorite, Boost, Tag, Notification, ReadThrough
|
||||||
from .user import User, UserFollows, UserFollowRequest, UserBlocks
|
from .user import User, UserFollows, UserFollowRequest, UserBlocks
|
||||||
from .user import FederatedServer
|
from .user import FederatedServer
|
||||||
|
|
||||||
from .import_job import ImportJob, ImportItem
|
from .import_job import ImportJob, ImportItem
|
||||||
from .site import SiteSettings, SiteInvite
|
from .site import SiteSettings, SiteInvite
|
||||||
|
|
||||||
|
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||||
|
activity_models = {c[0]: c[1].activity_serializer for c in cls_members \
|
||||||
|
if hasattr(c[1], 'activity_serializer')}
|
||||||
|
|
|
@ -1,11 +1,21 @@
|
||||||
''' base model with default fields '''
|
''' 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.db import models
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from fedireads import activitypub
|
||||||
from fedireads.settings import DOMAIN
|
from fedireads.settings import DOMAIN
|
||||||
|
|
||||||
class FedireadsModel(models.Model):
|
class FedireadsModel(models.Model):
|
||||||
''' fields and functions for every model '''
|
''' shared fields '''
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
created_date = models.DateTimeField(auto_now_add=True)
|
||||||
updated_date = models.DateTimeField(auto_now=True)
|
updated_date = models.DateTimeField(auto_now=True)
|
||||||
remote_id = models.CharField(max_length=255, null=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)
|
return '%s/%s/%d' % (base_path, model_name, self.id)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
''' this is just here to provide default fields for other models '''
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,3 +41,179 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
|
||||||
if not instance.remote_id:
|
if not instance.remote_id:
|
||||||
instance.remote_id = instance.get_remote_id()
|
instance.remote_id = instance.get_remote_id()
|
||||||
instance.save()
|
instance.save()
|
||||||
|
|
||||||
|
|
||||||
|
class ActivitypubMixin:
|
||||||
|
''' add this mixin for models that are AP serializable '''
|
||||||
|
activity_serializer = lambda: {}
|
||||||
|
|
||||||
|
def to_activity(self, pure=False):
|
||||||
|
''' convert from a model to an activity '''
|
||||||
|
if pure:
|
||||||
|
mappings = self.pure_activity_mappings
|
||||||
|
else:
|
||||||
|
mappings = self.activity_mappings
|
||||||
|
|
||||||
|
fields = {}
|
||||||
|
for mapping in mappings:
|
||||||
|
if not hasattr(self, mapping.model_key) or not mapping.activity_key:
|
||||||
|
continue
|
||||||
|
value = getattr(self, mapping.model_key)
|
||||||
|
if hasattr(value, 'remote_id'):
|
||||||
|
value = value.remote_id
|
||||||
|
fields[mapping.activity_key] = mapping.activity_formatter(value)
|
||||||
|
|
||||||
|
if pure:
|
||||||
|
return self.pure_activity_serializer(
|
||||||
|
**fields
|
||||||
|
).serialize()
|
||||||
|
return self.activity_serializer(
|
||||||
|
**fields
|
||||||
|
).serialize()
|
||||||
|
|
||||||
|
|
||||||
|
def to_create_activity(self, user, pure=False):
|
||||||
|
''' returns the object wrapped in a Create activity '''
|
||||||
|
activity_object = self.to_activity(pure=pure)
|
||||||
|
|
||||||
|
signer = pkcs1_15.new(RSA.import_key(user.private_key))
|
||||||
|
content = activity_object['content']
|
||||||
|
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
|
||||||
|
create_id = self.remote_id + '/activity'
|
||||||
|
|
||||||
|
signature = activitypub.Signature(
|
||||||
|
creator='%s#main-key' % user.remote_id,
|
||||||
|
created=activity_object['published'],
|
||||||
|
signatureValue=b64encode(signed_message).decode('utf8')
|
||||||
|
)
|
||||||
|
|
||||||
|
return activitypub.Create(
|
||||||
|
id=create_id,
|
||||||
|
actor=user.remote_id,
|
||||||
|
to=['%s/followers' % user.remote_id],
|
||||||
|
cc=['https://www.w3.org/ns/activitystreams#Public'],
|
||||||
|
object=activity_object,
|
||||||
|
signature=signature,
|
||||||
|
).serialize()
|
||||||
|
|
||||||
|
|
||||||
|
def to_update_activity(self, user):
|
||||||
|
''' wrapper for Updates to an activity '''
|
||||||
|
activity_id = '%s#update/%s' % (user.remote_id, uuid4())
|
||||||
|
return activitypub.Update(
|
||||||
|
id=activity_id,
|
||||||
|
actor=user.remote_id,
|
||||||
|
to=['https://www.w3.org/ns/activitystreams#Public'],
|
||||||
|
object=self.to_activity()
|
||||||
|
).serialize()
|
||||||
|
|
||||||
|
|
||||||
|
def to_undo_activity(self, user):
|
||||||
|
''' undo an action '''
|
||||||
|
return activitypub.Undo(
|
||||||
|
id='%s#undo' % user.remote_id,
|
||||||
|
actor=user.remote_id,
|
||||||
|
object=self.to_activity()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderedCollectionPageMixin(ActivitypubMixin):
|
||||||
|
''' just the paginator utilities, so you don't HAVE to
|
||||||
|
override ActivitypubMixin's to_activity (ie, for outbox '''
|
||||||
|
@property
|
||||||
|
def collection_remote_id(self):
|
||||||
|
''' this can be overriden if there's a special remote id, ie outbox '''
|
||||||
|
return self.remote_id
|
||||||
|
|
||||||
|
def page(self, min_id=None, max_id=None):
|
||||||
|
''' helper function to create the pagination url '''
|
||||||
|
params = {'page': 'true'}
|
||||||
|
if min_id:
|
||||||
|
params['min_id'] = min_id
|
||||||
|
if max_id:
|
||||||
|
params['max_id'] = max_id
|
||||||
|
return '?%s' % urlencode(params)
|
||||||
|
|
||||||
|
def next_page(self, items):
|
||||||
|
''' use the max id of the last item '''
|
||||||
|
if not items.count():
|
||||||
|
return ''
|
||||||
|
return self.page(max_id=items[items.count() - 1].id)
|
||||||
|
|
||||||
|
def prev_page(self, items):
|
||||||
|
''' use the min id of the first item '''
|
||||||
|
if not items.count():
|
||||||
|
return ''
|
||||||
|
return self.page(min_id=items[0].id)
|
||||||
|
|
||||||
|
def to_ordered_collection_page(self, queryset, remote_id, \
|
||||||
|
id_only=False, min_id=None, max_id=None):
|
||||||
|
''' serialize and pagiante a queryset '''
|
||||||
|
# TODO: weird place to define this
|
||||||
|
limit = 20
|
||||||
|
# filters for use in the django queryset min/max
|
||||||
|
filters = {}
|
||||||
|
if min_id is not None:
|
||||||
|
filters['id__gt'] = min_id
|
||||||
|
if max_id is not None:
|
||||||
|
filters['id__lte'] = max_id
|
||||||
|
page_id = self.page(min_id=min_id, max_id=max_id)
|
||||||
|
|
||||||
|
items = queryset.filter(
|
||||||
|
**filters
|
||||||
|
).all()[:limit]
|
||||||
|
|
||||||
|
if id_only:
|
||||||
|
page = [s.remote_id for s in items]
|
||||||
|
else:
|
||||||
|
page = [s.to_activity() for s in items]
|
||||||
|
return activitypub.OrderedCollectionPage(
|
||||||
|
id='%s%s' % (remote_id, page_id),
|
||||||
|
partOf=remote_id,
|
||||||
|
orderedItems=page,
|
||||||
|
next='%s%s' % (remote_id, self.next_page(items)),
|
||||||
|
prev='%s%s' % (remote_id, self.prev_page(items))
|
||||||
|
).serialize()
|
||||||
|
|
||||||
|
def to_ordered_collection(self, queryset, \
|
||||||
|
remote_id=None, page=False, **kwargs):
|
||||||
|
''' an ordered collection of whatevers '''
|
||||||
|
remote_id = remote_id or self.remote_id
|
||||||
|
if page:
|
||||||
|
return self.to_ordered_collection_page(
|
||||||
|
queryset, remote_id, **kwargs)
|
||||||
|
name = ''
|
||||||
|
if hasattr(self, 'name'):
|
||||||
|
name = self.name
|
||||||
|
|
||||||
|
size = queryset.count()
|
||||||
|
return activitypub.OrderedCollection(
|
||||||
|
id=remote_id,
|
||||||
|
totalItems=size,
|
||||||
|
name=name,
|
||||||
|
first='%s%s' % (remote_id, self.page()),
|
||||||
|
last='%s%s' % (remote_id, self.page(min_id=0))
|
||||||
|
).serialize()
|
||||||
|
|
||||||
|
|
||||||
|
class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
||||||
|
''' extends activitypub models to work as ordered collections '''
|
||||||
|
@property
|
||||||
|
def collection_queryset(self):
|
||||||
|
''' usually an ordered collection model aggregates a different model '''
|
||||||
|
raise NotImplementedError('Model must define collection_queryset')
|
||||||
|
|
||||||
|
activity_serializer = activitypub.OrderedCollection
|
||||||
|
|
||||||
|
def to_activity(self, **kwargs):
|
||||||
|
''' an ordered collection of the specified model queryset '''
|
||||||
|
return self.to_ordered_collection(self.collection_queryset, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActivityMapping:
|
||||||
|
''' translate between an activitypub json field and a model field '''
|
||||||
|
activity_key: str
|
||||||
|
model_key: str
|
||||||
|
activity_formatter: Callable = lambda x: x
|
||||||
|
model_formatter: Callable = lambda x: x
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
''' database schema for books and shelves '''
|
''' database schema for books and shelves '''
|
||||||
from django.utils import timezone
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.http import http_date
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
|
|
||||||
from fedireads import activitypub
|
from fedireads import activitypub
|
||||||
from fedireads.settings import DOMAIN
|
from fedireads.settings import DOMAIN
|
||||||
from fedireads.utils.fields import ArrayField
|
from fedireads.utils.fields import ArrayField
|
||||||
from .base_model import FedireadsModel
|
|
||||||
|
|
||||||
from fedireads.connectors.settings import CONNECTORS
|
from fedireads.connectors.settings import CONNECTORS
|
||||||
|
|
||||||
|
from .base_model import ActivityMapping, ActivitypubMixin, FedireadsModel
|
||||||
|
|
||||||
|
|
||||||
ConnectorFiles = models.TextChoices('ConnectorFiles', CONNECTORS)
|
ConnectorFiles = models.TextChoices('ConnectorFiles', CONNECTORS)
|
||||||
class Connector(FedireadsModel):
|
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 '''
|
''' a generic book, which can mean either an edition or a work '''
|
||||||
# these identifiers apply to both works and editions
|
# these identifiers apply to both works and editions
|
||||||
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
|
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)
|
published_date = models.DateTimeField(blank=True, null=True)
|
||||||
objects = InheritanceManager()
|
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):
|
def save(self, *args, **kwargs):
|
||||||
''' can't be abstract for query reasons, but you shouldn't USE it '''
|
''' can't be abstract for query reasons, but you shouldn't USE it '''
|
||||||
if not isinstance(self, Edition) and not isinstance(self, Work):
|
if not isinstance(self, Edition) and not isinstance(self, Work):
|
||||||
|
@ -106,7 +153,6 @@ class Book(FedireadsModel):
|
||||||
the remote canonical copy '''
|
the remote canonical copy '''
|
||||||
return 'https://%s/book/%d' % (DOMAIN, self.id)
|
return 'https://%s/book/%d' % (DOMAIN, self.id)
|
||||||
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<{} key={!r} title={!r}>".format(
|
return "<{} key={!r} title={!r}>".format(
|
||||||
self.__class__,
|
self.__class__,
|
||||||
|
@ -114,16 +160,17 @@ class Book(FedireadsModel):
|
||||||
self.title,
|
self.title,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def activitypub_serialize(self):
|
|
||||||
return activitypub.get_book(self)
|
|
||||||
|
|
||||||
|
|
||||||
class Work(Book):
|
class Work(Book):
|
||||||
''' a work (an abstract concept of a book that manifests in an edition) '''
|
''' a work (an abstract concept of a book that manifests in an edition) '''
|
||||||
# library of congress catalog control number
|
# library of congress catalog control number
|
||||||
lccn = models.CharField(max_length=255, blank=True, null=True)
|
lccn = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def editions_path(self):
|
||||||
|
return self.remote_id + '/editions'
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def default_edition(self):
|
def default_edition(self):
|
||||||
ed = Edition.objects.filter(parent_work=self, default=True).first()
|
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()
|
ed = Edition.objects.filter(parent_work=self).first()
|
||||||
return ed
|
return ed
|
||||||
|
|
||||||
|
activity_serializer = activitypub.Work
|
||||||
|
|
||||||
|
|
||||||
class Edition(Book):
|
class Edition(Book):
|
||||||
''' an edition of a book '''
|
''' an edition of a book '''
|
||||||
|
@ -155,8 +204,10 @@ class Edition(Book):
|
||||||
)
|
)
|
||||||
parent_work = models.ForeignKey('Work', on_delete=models.PROTECT, null=True)
|
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 '''
|
''' copy of an author from OL '''
|
||||||
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
|
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
|
||||||
sync = models.BooleanField(default=True)
|
sync = models.BooleanField(default=True)
|
||||||
|
@ -181,17 +232,25 @@ class Author(FedireadsModel):
|
||||||
the remote canonical copy (ditto here for author)'''
|
the remote canonical copy (ditto here for author)'''
|
||||||
return 'https://%s/book/%d' % (DOMAIN, self.id)
|
return 'https://%s/book/%d' % (DOMAIN, self.id)
|
||||||
|
|
||||||
@property
|
|
||||||
def activitypub_serialize(self):
|
|
||||||
return activitypub.get_author(self)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_name(self):
|
def display_name(self):
|
||||||
''' Helper to return a displayable name'''
|
''' Helper to return a displayable name'''
|
||||||
if self.name:
|
if self.name:
|
||||||
return name
|
return self.name
|
||||||
# don't want to return a spurious space if all of these are None
|
# 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
|
return self.first_name + ' ' + self.last_name
|
||||||
else:
|
return self.last_name or self.first_name
|
||||||
return self.last_name or self.first_name
|
|
||||||
|
activity_mappings = [
|
||||||
|
ActivityMapping('id', 'remote_id'),
|
||||||
|
ActivityMapping('url', 'remote_id'),
|
||||||
|
ActivityMapping('name', 'display_name'),
|
||||||
|
ActivityMapping('born', 'born'),
|
||||||
|
ActivityMapping('died', 'died'),
|
||||||
|
ActivityMapping('aliases', 'aliases'),
|
||||||
|
ActivityMapping('bio', 'bio'),
|
||||||
|
ActivityMapping('openlibrary_key', 'openlibrary_key'),
|
||||||
|
ActivityMapping('wikipedia_link', 'wikipedia_link'),
|
||||||
|
]
|
||||||
|
activity_serializer = activitypub.Author
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
''' puttin' books on shelves '''
|
''' puttin' books on shelves '''
|
||||||
from django.db import models
|
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)
|
name = models.CharField(max_length=100)
|
||||||
identifier = models.CharField(max_length=100)
|
identifier = models.CharField(max_length=100)
|
||||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||||
|
@ -16,17 +18,23 @@ class Shelf(FedireadsModel):
|
||||||
through_fields=('shelf', 'book')
|
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):
|
def get_remote_id(self):
|
||||||
''' shelf identifier instead of id '''
|
''' shelf identifier instead of id '''
|
||||||
base_path = self.user.remote_id
|
base_path = self.user.remote_id
|
||||||
return '%s/shelf/%s' % (base_path, self.identifier)
|
return '%s/shelf/%s' % (base_path, self.identifier)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
''' user/shelf unqiueness '''
|
||||||
unique_together = ('user', 'identifier')
|
unique_together = ('user', 'identifier')
|
||||||
|
|
||||||
|
|
||||||
class ShelfBook(FedireadsModel):
|
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)
|
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||||
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
|
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
|
||||||
added_by = models.ForeignKey(
|
added_by = models.ForeignKey(
|
||||||
|
@ -36,5 +44,26 @@ class ShelfBook(FedireadsModel):
|
||||||
on_delete=models.PROTECT
|
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:
|
class Meta:
|
||||||
|
''' an opinionated constraint!
|
||||||
|
you can't put a book on shelf twice '''
|
||||||
unique_together = ('book', 'shelf')
|
unique_together = ('book', 'shelf')
|
||||||
|
|
|
@ -3,6 +3,7 @@ import base64
|
||||||
from Crypto import Random
|
from Crypto import Random
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
import datetime
|
||||||
|
|
||||||
from fedireads.settings import DOMAIN
|
from fedireads.settings import DOMAIN
|
||||||
from .user import User
|
from .user import User
|
||||||
|
|
|
@ -2,23 +2,25 @@
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.http import http_date
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
|
|
||||||
from fedireads import activitypub
|
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 '''
|
''' any post, like a reply to a review, etc '''
|
||||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||||
status_type = models.CharField(max_length=255, default='Note')
|
|
||||||
content = models.TextField(blank=True, null=True)
|
content = models.TextField(blank=True, null=True)
|
||||||
mention_users = models.ManyToManyField('User', related_name='mention_user')
|
mention_users = models.ManyToManyField('User', related_name='mention_user')
|
||||||
mention_books = models.ManyToManyField(
|
mention_books = models.ManyToManyField(
|
||||||
'Edition', related_name='mention_book')
|
'Edition', related_name='mention_book')
|
||||||
activity_type = models.CharField(max_length=255, default='Note')
|
|
||||||
local = models.BooleanField(default=True)
|
local = models.BooleanField(default=True)
|
||||||
privacy = models.CharField(max_length=255, default='public')
|
privacy = models.CharField(max_length=255, default='public')
|
||||||
sensitive = models.BooleanField(default=False)
|
sensitive = models.BooleanField(default=False)
|
||||||
|
@ -38,40 +40,100 @@ class Status(FedireadsModel):
|
||||||
)
|
)
|
||||||
objects = InheritanceManager()
|
objects = InheritanceManager()
|
||||||
|
|
||||||
|
# ---- activitypub serialization settings for this model ----- #
|
||||||
@property
|
@property
|
||||||
def activitypub_serialize(self):
|
def ap_to(self):
|
||||||
return activitypub.get_status(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):
|
class Comment(Status):
|
||||||
''' like a review but without a rating and transient '''
|
''' like a review but without a rating and transient '''
|
||||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
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
|
@property
|
||||||
def activitypub_serialize(self):
|
def ap_pure_content(self):
|
||||||
return activitypub.get_comment(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):
|
class Quotation(Status):
|
||||||
''' like a review but without a rating and transient '''
|
''' like a review but without a rating and transient '''
|
||||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
|
||||||
quote = models.TextField()
|
quote = models.TextField()
|
||||||
|
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
self.status_type = 'Quotation'
|
|
||||||
self.activity_type = 'Note'
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def activitypub_serialize(self):
|
def ap_pure_content(self):
|
||||||
return activitypub.get_quotation(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):
|
class Review(Status):
|
||||||
|
@ -85,23 +147,41 @@ class Review(Status):
|
||||||
validators=[MinValueValidator(1), MaxValueValidator(5)]
|
validators=[MinValueValidator(1), MaxValueValidator(5)]
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
@property
|
||||||
self.status_type = 'Review'
|
def ap_pure_name(self):
|
||||||
self.activity_type = 'Article'
|
''' clarify review names for mastodon serialization '''
|
||||||
super().save(*args, **kwargs)
|
return 'Review of "%s" (%d stars): %s' % (
|
||||||
|
self.book.title,
|
||||||
|
self.rating,
|
||||||
|
self.name
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def activitypub_serialize(self):
|
def ap_pure_content(self):
|
||||||
return activitypub.get_review(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 '''
|
''' fav'ing a post '''
|
||||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||||
status = models.ForeignKey('Status', 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:
|
class Meta:
|
||||||
|
''' can't fav things twice '''
|
||||||
unique_together = ('user', 'status')
|
unique_together = ('user', 'status')
|
||||||
|
|
||||||
|
|
||||||
|
@ -112,29 +192,69 @@ class Boost(Status):
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="boosters")
|
related_name="boosters")
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
activity_mappings = [
|
||||||
self.status_type = 'Boost'
|
ActivityMapping('id', 'remote_id'),
|
||||||
self.activity_type = 'Announce'
|
ActivityMapping('actor', 'user'),
|
||||||
super().save(*args, **kwargs)
|
ActivityMapping('object', 'boosted_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
activity_serializer = activitypub.Like
|
||||||
|
|
||||||
# This constraint can't work as it would cross tables.
|
# This constraint can't work as it would cross tables.
|
||||||
# class Meta:
|
# class Meta:
|
||||||
# unique_together = ('user', 'boosted_status')
|
# unique_together = ('user', 'boosted_status')
|
||||||
|
|
||||||
class Tag(FedireadsModel):
|
|
||||||
|
class Tag(OrderedCollectionMixin, FedireadsModel):
|
||||||
''' freeform tags for books '''
|
''' freeform tags for books '''
|
||||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
identifier = 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):
|
def save(self, *args, **kwargs):
|
||||||
|
''' create a url-safe lookup key for the tag '''
|
||||||
if not self.id:
|
if not self.id:
|
||||||
# add identifiers to new tags
|
# add identifiers to new tags
|
||||||
self.identifier = urllib.parse.quote_plus(self.name)
|
self.identifier = urllib.parse.quote_plus(self.name)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
''' unqiueness constraint '''
|
||||||
unique_together = ('user', 'book', 'name')
|
unique_together = ('user', 'book', 'name')
|
||||||
|
|
||||||
|
|
||||||
|
@ -172,7 +292,9 @@ class Notification(FedireadsModel):
|
||||||
read = models.BooleanField(default=False)
|
read = models.BooleanField(default=False)
|
||||||
notification_type = models.CharField(
|
notification_type = models.CharField(
|
||||||
max_length=255, choices=NotificationType.choices)
|
max_length=255, choices=NotificationType.choices)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
''' checks if notifcation is in enum list for valid types '''
|
||||||
constraints = [
|
constraints = [
|
||||||
models.CheckConstraint(
|
models.CheckConstraint(
|
||||||
check=models.Q(notification_type__in=NotificationType.values),
|
check=models.Q(notification_type__in=NotificationType.values),
|
||||||
|
|
|
@ -1,16 +1,20 @@
|
||||||
''' database schema for user data '''
|
''' database schema for user data '''
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from fedireads import activitypub
|
from fedireads import activitypub
|
||||||
from fedireads.models.shelf import Shelf
|
from fedireads.models.shelf import Shelf
|
||||||
|
from fedireads.models.status import Status
|
||||||
from fedireads.settings import DOMAIN
|
from fedireads.settings import DOMAIN
|
||||||
from fedireads.signatures import create_key_pair
|
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 '''
|
''' a user who wants to read books '''
|
||||||
private_key = models.TextField(blank=True, null=True)
|
private_key = models.TextField(blank=True, null=True)
|
||||||
public_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)
|
updated_date = models.DateTimeField(auto_now=True)
|
||||||
manually_approves_followers = models.BooleanField(default=False)
|
manually_approves_followers = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# ---- activitypub serialization settings for this model ----- #
|
||||||
@property
|
@property
|
||||||
def activitypub_serialize(self):
|
def ap_followers(self):
|
||||||
return activitypub.get_actor(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):
|
class UserRelationship(FedireadsModel):
|
||||||
|
@ -87,6 +184,7 @@ class UserRelationship(FedireadsModel):
|
||||||
relationship_id = models.CharField(max_length=100)
|
relationship_id = models.CharField(max_length=100)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
''' relationships should be unique '''
|
||||||
abstract = True
|
abstract = True
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(
|
models.UniqueConstraint(
|
||||||
|
@ -106,12 +204,14 @@ class UserRelationship(FedireadsModel):
|
||||||
|
|
||||||
|
|
||||||
class UserFollows(UserRelationship):
|
class UserFollows(UserRelationship):
|
||||||
|
''' Following a user '''
|
||||||
@property
|
@property
|
||||||
def status(self):
|
def status(self):
|
||||||
return 'follows'
|
return 'follows'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_request(cls, follow_request):
|
def from_request(cls, follow_request):
|
||||||
|
''' converts a follow request into a follow relationship '''
|
||||||
return cls(
|
return cls(
|
||||||
user_subject=follow_request.user_subject,
|
user_subject=follow_request.user_subject,
|
||||||
user_object=follow_request.user_object,
|
user_object=follow_request.user_object,
|
||||||
|
@ -120,10 +220,35 @@ class UserFollows(UserRelationship):
|
||||||
|
|
||||||
|
|
||||||
class UserFollowRequest(UserRelationship):
|
class UserFollowRequest(UserRelationship):
|
||||||
|
''' following a user requires manual or automatic confirmation '''
|
||||||
@property
|
@property
|
||||||
def status(self):
|
def status(self):
|
||||||
return 'follow_request'
|
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):
|
class UserBlocks(UserRelationship):
|
||||||
@property
|
@property
|
||||||
|
@ -145,7 +270,12 @@ class FederatedServer(FedireadsModel):
|
||||||
def execute_before_save(sender, instance, *args, **kwargs):
|
def execute_before_save(sender, instance, *args, **kwargs):
|
||||||
''' populate fields for new local users '''
|
''' populate fields for new local users '''
|
||||||
# this user already exists, no need to poplate fields
|
# 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
|
return
|
||||||
|
|
||||||
# populate fields for local users
|
# populate fields for local users
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
''' handles all the activity coming out of the server '''
|
''' handles all the activity coming out of the server '''
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.http import HttpResponseNotFound, JsonResponse
|
from django.http import HttpResponseNotFound, JsonResponse
|
||||||
|
@ -27,36 +26,11 @@ def outbox(request, username):
|
||||||
except models.User.DoesNotExist:
|
except models.User.DoesNotExist:
|
||||||
return HttpResponseNotFound()
|
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
|
# collection overview
|
||||||
size = models.Status.objects.filter(user=user).count()
|
return JsonResponse(
|
||||||
return JsonResponse(activitypub.get_outbox(user, size))
|
user.to_outbox(**request.GET),
|
||||||
|
encoder=activitypub.ActivityEncoder
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def handle_account_search(query):
|
def handle_account_search(query):
|
||||||
|
@ -83,7 +57,15 @@ def handle_account_search(query):
|
||||||
|
|
||||||
def handle_follow(user, to_follow):
|
def handle_follow(user, to_follow):
|
||||||
''' someone local wants to follow someone '''
|
''' 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])
|
broadcast(user, activity, direct_recipients=[to_follow])
|
||||||
|
|
||||||
|
|
||||||
|
@ -93,7 +75,7 @@ def handle_unfollow(user, to_unfollow):
|
||||||
user_subject=user,
|
user_subject=user,
|
||||||
user_object=to_unfollow
|
user_object=to_unfollow
|
||||||
)
|
)
|
||||||
activity = activitypub.get_unfollow(relationship)
|
activity = relationship.to_undo_activity(user)
|
||||||
broadcast(user, activity, direct_recipients=[to_unfollow])
|
broadcast(user, activity, direct_recipients=[to_unfollow])
|
||||||
to_unfollow.followers.remove(user)
|
to_unfollow.followers.remove(user)
|
||||||
|
|
||||||
|
@ -105,25 +87,23 @@ def handle_accept(user, to_follow, follow_request):
|
||||||
follow_request.delete()
|
follow_request.delete()
|
||||||
relationship.save()
|
relationship.save()
|
||||||
|
|
||||||
activity = activitypub.get_accept(to_follow, follow_request)
|
activity = relationship.to_accept_activity()
|
||||||
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
|
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
|
||||||
|
|
||||||
|
|
||||||
def handle_reject(user, to_follow, relationship):
|
def handle_reject(user, to_follow, relationship):
|
||||||
''' a local user who managed follows rejects a follow request '''
|
''' a local user who managed follows rejects a follow request '''
|
||||||
|
activity = relationship.to_reject_activity(user)
|
||||||
relationship.delete()
|
relationship.delete()
|
||||||
|
|
||||||
activity = activitypub.get_reject(to_follow, relationship)
|
|
||||||
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
|
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
|
||||||
|
|
||||||
|
|
||||||
def handle_shelve(user, book, shelf):
|
def handle_shelve(user, book, shelf):
|
||||||
''' a local user is getting a book put on their shelf '''
|
''' a local user is getting a book put on their shelf '''
|
||||||
# update the database
|
# 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, shelve.to_add_activity(user))
|
||||||
broadcast(user, activity)
|
|
||||||
|
|
||||||
# tell the world about this cool thing that happened
|
# tell the world about this cool thing that happened
|
||||||
verb = {
|
verb = {
|
||||||
|
@ -155,20 +135,16 @@ def handle_shelve(user, book, shelf):
|
||||||
read.finish_date = datetime.now()
|
read.finish_date = datetime.now()
|
||||||
read.save()
|
read.save()
|
||||||
|
|
||||||
activity = activitypub.get_status(status)
|
broadcast(user, status.to_create_activity(user))
|
||||||
create_activity = activitypub.get_create(user, activity)
|
|
||||||
|
|
||||||
broadcast(user, create_activity)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_unshelve(user, book, shelf):
|
def handle_unshelve(user, book, shelf):
|
||||||
''' a local user is getting a book put on their shelf '''
|
''' a local user is getting a book put on their shelf '''
|
||||||
# update the database
|
# update the database
|
||||||
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
|
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
|
||||||
|
activity = row.to_remove_activity(user)
|
||||||
row.delete()
|
row.delete()
|
||||||
|
|
||||||
activity = activitypub.get_remove(user, book, shelf)
|
|
||||||
|
|
||||||
broadcast(user, activity)
|
broadcast(user, activity)
|
||||||
|
|
||||||
|
|
||||||
|
@ -185,11 +161,11 @@ def handle_import_books(user, items):
|
||||||
item.book = item.book.default_edition
|
item.book = item.book.default_edition
|
||||||
if not item.book:
|
if not item.book:
|
||||||
continue
|
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)
|
book=item.book, shelf=desired_shelf, added_by=user)
|
||||||
if created:
|
if created:
|
||||||
new_books.append(item.book)
|
new_books.append(item.book)
|
||||||
activity = activitypub.get_add(user, item.book, desired_shelf)
|
activity = shelf_book.to_add_activity(user)
|
||||||
broadcast(user, activity)
|
broadcast(user, activity)
|
||||||
|
|
||||||
if item.rating or item.review:
|
if item.rating or item.review:
|
||||||
|
@ -214,82 +190,62 @@ def handle_import_books(user, items):
|
||||||
status.status_type = 'Update'
|
status.status_type = 'Update'
|
||||||
status.save()
|
status.save()
|
||||||
|
|
||||||
create_activity = activitypub.get_create(
|
broadcast(user, status.to_create_activity(user))
|
||||||
user, activitypub.get_status(status))
|
|
||||||
broadcast(user, create_activity)
|
|
||||||
return status
|
return status
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def handle_rate(user, book, rating):
|
def handle_rate(user, book, rating):
|
||||||
''' a review that's just a rating '''
|
''' a review that's just a rating '''
|
||||||
builder = create_rating
|
builder = create_rating
|
||||||
fr_serializer = activitypub.get_rating
|
handle_status(user, book, builder, rating)
|
||||||
ap_serializer = activitypub.get_rating_note
|
|
||||||
|
|
||||||
handle_status(user, book, builder, fr_serializer, ap_serializer, rating)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_review(user, book, name, content, rating):
|
def handle_review(user, book, name, content, rating):
|
||||||
''' post a review '''
|
''' post a review '''
|
||||||
# validated and saves the review in the database so it has an id
|
# validated and saves the review in the database so it has an id
|
||||||
builder = create_review
|
builder = create_review
|
||||||
fr_serializer = activitypub.get_review
|
handle_status(user, book, builder, name, content, rating)
|
||||||
ap_serializer = activitypub.get_review_article
|
|
||||||
handle_status(
|
|
||||||
user, book, builder, fr_serializer,
|
|
||||||
ap_serializer, name, content, rating)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_quotation(user, book, content, quote):
|
def handle_quotation(user, book, content, quote):
|
||||||
''' post a review '''
|
''' post a review '''
|
||||||
# validated and saves the review in the database so it has an id
|
# validated and saves the review in the database so it has an id
|
||||||
builder = create_quotation
|
builder = create_quotation
|
||||||
fr_serializer = activitypub.get_quotation
|
handle_status(user, book, builder, content, quote)
|
||||||
ap_serializer = activitypub.get_quotation_article
|
|
||||||
handle_status(
|
|
||||||
user, book, builder, fr_serializer, ap_serializer, content, quote)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_comment(user, book, content):
|
def handle_comment(user, book, content):
|
||||||
''' post a comment '''
|
''' post a comment '''
|
||||||
# validated and saves the review in the database so it has an id
|
# validated and saves the review in the database so it has an id
|
||||||
builder = create_comment
|
builder = create_comment
|
||||||
fr_serializer = activitypub.get_comment
|
handle_status(user, book, builder, content)
|
||||||
ap_serializer = activitypub.get_comment_article
|
|
||||||
handle_status(
|
|
||||||
user, book, builder, fr_serializer, ap_serializer, content)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_status(user, book_id, \
|
def handle_status(user, book_id, builder, *args):
|
||||||
builder, fr_serializer, ap_serializer, *args):
|
|
||||||
''' generic handler for statuses '''
|
''' generic handler for statuses '''
|
||||||
book = models.Edition.objects.get(id=book_id)
|
book = models.Edition.objects.get(id=book_id)
|
||||||
status = builder(user, book, *args)
|
status = builder(user, book, *args)
|
||||||
|
|
||||||
activity = fr_serializer(status)
|
broadcast(user, status.to_create_activity(user), software='fedireads')
|
||||||
create_activity = activitypub.get_create(user, activity)
|
|
||||||
broadcast(user, create_activity, software='fedireads')
|
|
||||||
|
|
||||||
# re-format the activity for non-fedireads servers
|
# re-format the activity for non-fedireads servers
|
||||||
remote_activity = ap_serializer(status)
|
remote_activity = status.to_create_activity(user, pure=True)
|
||||||
remote_create_activity = activitypub.get_create(user, remote_activity)
|
|
||||||
|
|
||||||
broadcast(user, remote_create_activity, software='other')
|
broadcast(user, remote_activity, software='other')
|
||||||
|
|
||||||
|
|
||||||
def handle_tag(user, book, name):
|
def handle_tag(user, book, name):
|
||||||
''' tag a book '''
|
''' tag a book '''
|
||||||
tag = create_tag(user, book, name)
|
tag = create_tag(user, book, name)
|
||||||
tag_activity = activitypub.get_add_tag(tag)
|
broadcast(user, tag.to_add_activity(user))
|
||||||
|
|
||||||
broadcast(user, tag_activity)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_untag(user, book, name):
|
def handle_untag(user, book, name):
|
||||||
''' tag a book '''
|
''' tag a book '''
|
||||||
book = models.Book.objects.get(id=book)
|
book = models.Book.objects.get(id=book)
|
||||||
tag = models.Tag.objects.get(name=name, book=book, user=user)
|
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()
|
tag.delete()
|
||||||
|
|
||||||
broadcast(user, tag_activity)
|
broadcast(user, tag_activity)
|
||||||
|
@ -306,10 +262,8 @@ def handle_reply(user, review, content):
|
||||||
related_user=user,
|
related_user=user,
|
||||||
related_status=reply,
|
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):
|
def handle_favorite(user, status):
|
||||||
|
@ -323,7 +277,7 @@ def handle_favorite(user, status):
|
||||||
# you already fav'ed that
|
# you already fav'ed that
|
||||||
return
|
return
|
||||||
|
|
||||||
fav_activity = activitypub.get_favorite(favorite)
|
fav_activity = favorite.to_activity()
|
||||||
broadcast(
|
broadcast(
|
||||||
user, fav_activity, privacy='direct', direct_recipients=[status.user])
|
user, fav_activity, privacy='direct', direct_recipients=[status.user])
|
||||||
|
|
||||||
|
@ -339,7 +293,7 @@ def handle_unfavorite(user, status):
|
||||||
# can't find that status, idk
|
# can't find that status, idk
|
||||||
return
|
return
|
||||||
|
|
||||||
fav_activity = activitypub.get_unfavorite(favorite)
|
fav_activity = activitypub.Undo(actor=user, object=favorite)
|
||||||
broadcast(user, fav_activity, direct_recipients=[status.user])
|
broadcast(user, fav_activity, direct_recipients=[status.user])
|
||||||
|
|
||||||
|
|
||||||
|
@ -355,19 +309,15 @@ def handle_boost(user, status):
|
||||||
)
|
)
|
||||||
boost.save()
|
boost.save()
|
||||||
|
|
||||||
boost_activity = activitypub.get_boost(boost)
|
boost_activity = boost.to_activity()
|
||||||
broadcast(user, boost_activity)
|
broadcast(user, boost_activity)
|
||||||
|
|
||||||
|
|
||||||
def handle_update_book(user, book):
|
def handle_update_book(user, book):
|
||||||
''' broadcast the news about our book '''
|
''' broadcast the news about our book '''
|
||||||
book_activity = activitypub.get_book(book)
|
broadcast(user, book.to_update_activity(user))
|
||||||
update_activity = activitypub.get_update(user, book_activity)
|
|
||||||
broadcast(user, update_activity)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_update_user(user):
|
def handle_update_user(user):
|
||||||
''' broadcast editing a user's profile '''
|
''' broadcast editing a user's profile '''
|
||||||
actor = activitypub.get_actor(user)
|
broadcast(user, user.to_update_activity())
|
||||||
update_activity = activitypub.get_update(user, actor)
|
|
||||||
broadcast(user, update_activity)
|
|
||||||
|
|
|
@ -6,8 +6,7 @@ import requests
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from fedireads import models
|
from fedireads import activitypub, models
|
||||||
from fedireads.status import create_review_from_activity
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_remote_user(actor):
|
def get_or_create_remote_user(actor):
|
||||||
|
@ -33,8 +32,9 @@ def get_or_create_remote_user(actor):
|
||||||
get_remote_reviews(user)
|
get_remote_reviews(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def fetch_user_data(actor):
|
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(
|
response = requests.get(
|
||||||
actor,
|
actor,
|
||||||
headers={'Accept': 'application/activity+json'}
|
headers={'Accept': 'application/activity+json'}
|
||||||
|
@ -51,50 +51,17 @@ def fetch_user_data(actor):
|
||||||
|
|
||||||
def create_remote_user(data):
|
def create_remote_user(data):
|
||||||
''' parse the activitypub actor data into a user '''
|
''' parse the activitypub actor data into a user '''
|
||||||
actor = data['id']
|
actor = activitypub.Person(**data)
|
||||||
actor_parts = urlparse(actor)
|
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):
|
def refresh_remote_user(user):
|
||||||
|
''' get updated user data from its home instance '''
|
||||||
data = fetch_user_data(user.remote_id)
|
data = fetch_user_data(user.remote_id)
|
||||||
|
|
||||||
shared_inbox = data.get('endpoints').get('sharedInbox') if \
|
activity = activitypub.Person(**data)
|
||||||
data.get('endpoints') else None
|
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):
|
def get_avatar(data):
|
||||||
''' find the icon attachment and load the image from the remote sever '''
|
''' find the icon attachment and load the image from the remote sever '''
|
||||||
|
@ -122,7 +89,7 @@ def get_remote_reviews(user):
|
||||||
# TODO: pagination?
|
# TODO: pagination?
|
||||||
for status in data['orderedItems']:
|
for status in data['orderedItems']:
|
||||||
if status.get('fedireadsType') == 'Review':
|
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):
|
def get_or_create_remote_server(domain):
|
||||||
|
|
|
@ -39,7 +39,7 @@ def make_signature(sender, destination, date, digest):
|
||||||
return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items())
|
return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items())
|
||||||
|
|
||||||
def make_digest(data):
|
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):
|
def verify_digest(request):
|
||||||
algorithm, digest = request.headers['digest'].split('=', 1)
|
algorithm, digest = request.headers['digest'].split('=', 1)
|
||||||
|
|
|
@ -6,23 +6,6 @@ from fedireads.books_manager import get_or_create_book
|
||||||
from fedireads.sanitize_html import InputHtmlParser
|
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):
|
def create_rating(user, book, rating):
|
||||||
''' a review that's just a rating '''
|
''' a review that's just a rating '''
|
||||||
if not rating or rating < 1 or rating > 5:
|
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):
|
def get_status(remote_id):
|
||||||
''' find a status in the database '''
|
''' find a status in the database '''
|
||||||
return models.Status.objects.select_subclasses().filter(
|
return models.Status.objects.select_subclasses().filter(
|
||||||
|
|
1
fedireads/tests/activitypub/__init__.py
Normal file
1
fedireads/tests/activitypub/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from . import *
|
27
fedireads/tests/activitypub/test_author.py
Normal file
27
fedireads/tests/activitypub/test_author.py
Normal 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')
|
32
fedireads/tests/activitypub/test_person.py
Normal file
32
fedireads/tests/activitypub/test_person.py
Normal 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}
|
||||||
|
)
|
46
fedireads/tests/activitypub/test_quotation.py
Normal file
46
fedireads/tests/activitypub/test_quotation.py
Normal 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)
|
29
fedireads/tests/data/ap_comment.json
Normal file
29
fedireads/tests/data/ap_comment.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
36
fedireads/tests/data/ap_quotation.json
Normal file
36
fedireads/tests/data/ap_quotation.json
Normal 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"
|
||||||
|
}
|
|
@ -19,32 +19,24 @@ class Status(TestCase):
|
||||||
|
|
||||||
def test_status(self):
|
def test_status(self):
|
||||||
status = models.Status.objects.first()
|
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' % \
|
expected_id = 'https://%s/user/mouse/status/%d' % \
|
||||||
(settings.DOMAIN, status.id)
|
(settings.DOMAIN, status.id)
|
||||||
self.assertEqual(status.remote_id, expected_id)
|
self.assertEqual(status.remote_id, expected_id)
|
||||||
|
|
||||||
def test_comment(self):
|
def test_comment(self):
|
||||||
comment = models.Comment.objects.first()
|
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' % \
|
expected_id = 'https://%s/user/mouse/comment/%d' % \
|
||||||
(settings.DOMAIN, comment.id)
|
(settings.DOMAIN, comment.id)
|
||||||
self.assertEqual(comment.remote_id, expected_id)
|
self.assertEqual(comment.remote_id, expected_id)
|
||||||
|
|
||||||
def test_quotation(self):
|
def test_quotation(self):
|
||||||
quotation = models.Quotation.objects.first()
|
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' % \
|
expected_id = 'https://%s/user/mouse/quotation/%d' % \
|
||||||
(settings.DOMAIN, quotation.id)
|
(settings.DOMAIN, quotation.id)
|
||||||
self.assertEqual(quotation.remote_id, expected_id)
|
self.assertEqual(quotation.remote_id, expected_id)
|
||||||
|
|
||||||
def test_review(self):
|
def test_review(self):
|
||||||
review = models.Review.objects.first()
|
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' % \
|
expected_id = 'https://%s/user/mouse/review/%d' % \
|
||||||
(settings.DOMAIN, review.id)
|
(settings.DOMAIN, review.id)
|
||||||
self.assertEqual(review.remote_id, expected_id)
|
self.assertEqual(review.remote_id, expected_id)
|
||||||
|
|
|
@ -16,41 +16,3 @@ class Comment(TestCase):
|
||||||
comment = status_builder.create_comment(
|
comment = status_builder.create_comment(
|
||||||
self.user, self.book, 'commentary')
|
self.user, self.book, 'commentary')
|
||||||
self.assertEqual(comment.content, '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')
|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
from django.test import TestCase
|
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
|
from fedireads import status as status_builder
|
||||||
|
|
||||||
|
|
||||||
|
@ -8,8 +10,13 @@ class Quotation(TestCase):
|
||||||
''' we have hecka ways to create statuses '''
|
''' we have hecka ways to create statuses '''
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = models.User.objects.create_user(
|
self.user = models.User.objects.create_user(
|
||||||
'mouse', 'mouse@mouse.mouse', 'mouseword')
|
'mouse', 'mouse@mouse.mouse', 'mouseword',
|
||||||
self.book = models.Edition.objects.create(title='Example Edition')
|
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):
|
def test_create_quotation(self):
|
||||||
|
@ -17,50 +24,3 @@ class Quotation(TestCase):
|
||||||
self.user, self.book, 'commentary', 'a quote')
|
self.user, self.book, 'commentary', 'a quote')
|
||||||
self.assertEqual(quotation.quote, 'a quote')
|
self.assertEqual(quotation.quote, 'a quote')
|
||||||
self.assertEqual(quotation.content, 'commentary')
|
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')
|
|
||||||
|
|
|
@ -37,45 +37,3 @@ class Review(TestCase):
|
||||||
self.assertEqual(review.name, 'review name')
|
self.assertEqual(review.name, 'review name')
|
||||||
self.assertEqual(review.content, 'content')
|
self.assertEqual(review.content, 'content')
|
||||||
self.assertEqual(review.rating, None)
|
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')
|
|
||||||
|
|
|
@ -8,7 +8,12 @@ class Status(TestCase):
|
||||||
''' we have hecka ways to create statuses '''
|
''' we have hecka ways to create statuses '''
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = models.User.objects.create_user(
|
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):
|
def test_create_status(self):
|
||||||
|
@ -21,45 +26,3 @@ class Status(TestCase):
|
||||||
self.user, content, reply_parent=status)
|
self.user, content, reply_parent=status)
|
||||||
self.assertEqual(reply.content, content)
|
self.assertEqual(reply.content, content)
|
||||||
self.assertEqual(reply.reply_parent, status)
|
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'
|
|
||||||
)
|
|
||||||
|
|
59
fedireads/tests/test_incoming_favorite.py
Normal file
59
fedireads/tests/test_incoming_favorite.py
Normal 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)
|
|
@ -1,6 +1,6 @@
|
||||||
from django.test import TestCase
|
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
from fedireads import models, remote_user
|
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 '''
|
''' not too much going on in the books model but here we are '''
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.remote_user = models.User.objects.create_user(
|
self.remote_user = models.User.objects.create_user(
|
||||||
'mouse', 'mouse@mouse.com', 'mouseword',
|
'rat', 'rat@rat.com', 'ratword',
|
||||||
local=False,
|
local=False,
|
||||||
remote_id='https://example.com/users/mouse',
|
remote_id='https://example.com/users/rat',
|
||||||
inbox='https://example.com/users/mouse/inbox',
|
inbox='https://example.com/users/rat/inbox',
|
||||||
outbox='https://example.com/users/mouse/outbox',
|
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):
|
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)
|
user = remote_user.get_or_create_remote_user(actor)
|
||||||
self.assertEqual(user, self.remote_user)
|
self.assertEqual(user, self.remote_user)
|
||||||
|
|
||||||
|
|
||||||
def test_create_remote_user(self):
|
def test_create_remote_user(self):
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath('data/ap_user.json')
|
user = remote_user.create_remote_user(self.user_data)
|
||||||
data = json.loads(datafile.read_bytes())
|
self.assertFalse(user.local)
|
||||||
user = remote_user.create_remote_user(data)
|
self.assertEqual(user.remote_id, 'https://example.com/user/mouse')
|
||||||
self.assertEqual(user.username, 'mouse@example.com')
|
self.assertEqual(user.username, 'mouse@example.com')
|
||||||
self.assertEqual(user.name, 'MOUSE?? MOUSE!!')
|
self.assertEqual(user.name, 'MOUSE?? MOUSE!!')
|
||||||
self.assertEqual(user.inbox, 'https://example.com/user/mouse/inbox')
|
self.assertEqual(user.inbox, 'https://example.com/user/mouse/inbox')
|
||||||
self.assertEqual(user.outbox, 'https://example.com/user/mouse/outbox')
|
self.assertEqual(user.outbox, 'https://example.com/user/mouse/outbox')
|
||||||
self.assertEqual(user.shared_inbox, 'https://example.com/inbox')
|
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.local, False)
|
||||||
self.assertEqual(user.fedireads_user, True)
|
self.assertEqual(user.fedireads_user, True)
|
||||||
self.assertEqual(user.manually_approves_followers, False)
|
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)
|
||||||
|
|
|
@ -10,12 +10,17 @@ from django.test import TestCase, Client
|
||||||
from django.utils.http import http_date
|
from django.utils.http import http_date
|
||||||
|
|
||||||
from fedireads.models import User
|
from fedireads.models import User
|
||||||
from fedireads.activitypub import get_follow_request
|
from fedireads.activitypub import Follow
|
||||||
from fedireads.settings import DOMAIN
|
from fedireads.settings import DOMAIN
|
||||||
from fedireads.signatures import create_key_pair, make_signature, make_digest
|
from fedireads.signatures import create_key_pair, make_signature, make_digest
|
||||||
|
|
||||||
def get_follow_data(follower, followee):
|
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'))
|
Sender = namedtuple('Sender', ('remote_id', 'private_key', 'public_key'))
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,11 @@ username_regex = r'(?P<username>[\w\-_]+@[\w\-\_\.]+)'
|
||||||
localname_regex = r'(?P<username>[\w\-_]+)'
|
localname_regex = r'(?P<username>[\w\-_]+)'
|
||||||
user_path = r'^user/%s' % username_regex
|
user_path = r'^user/%s' % username_regex
|
||||||
local_user_path = r'^user/%s' % localname_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+)'
|
book_path = r'^book/(?P<book_id>\d+)'
|
||||||
|
|
||||||
handler404 = 'fedireads.views.not_found_page'
|
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'^editions/(?P<work_id>\d+)/?$', views.editions_page),
|
||||||
|
|
||||||
re_path(r'^author/(?P<author_id>[\w\-]+)(.json)?/?$', views.author_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'^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)?/?$' % user_path, views.shelf_page),
|
||||||
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % local_user_path, views.shelf_page),
|
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % local_user_path, views.shelf_page),
|
||||||
|
|
|
@ -9,7 +9,8 @@ from django.core.exceptions import PermissionDenied
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
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 forms, models, books_manager
|
||||||
from fedireads import goodreads_import
|
from fedireads import goodreads_import
|
||||||
from fedireads.tasks import app
|
from fedireads.tasks import app
|
||||||
|
@ -222,8 +223,9 @@ def about_page(request):
|
||||||
}
|
}
|
||||||
return TemplateResponse(request, 'about.html', data)
|
return TemplateResponse(request, 'about.html', data)
|
||||||
|
|
||||||
|
|
||||||
def invite_page(request, code):
|
def invite_page(request, code):
|
||||||
''' Handle invites. '''
|
''' endpoint for sending invites '''
|
||||||
try:
|
try:
|
||||||
invite = models.SiteInvite.objects.get(code=code)
|
invite = models.SiteInvite.objects.get(code=code)
|
||||||
if not invite.valid():
|
if not invite.valid():
|
||||||
|
@ -240,6 +242,7 @@ def invite_page(request, code):
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def manage_invites(request):
|
def manage_invites(request):
|
||||||
|
''' invite management page '''
|
||||||
data = {
|
data = {
|
||||||
'invites': models.SiteInvite.objects.filter(user=request.user),
|
'invites': models.SiteInvite.objects.filter(user=request.user),
|
||||||
'form': forms.CreateInviteForm(),
|
'form': forms.CreateInviteForm(),
|
||||||
|
@ -270,7 +273,7 @@ def user_page(request, username, subpage=None):
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
# we have a json 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
|
# otherwise we're at a UI view
|
||||||
|
|
||||||
# TODO: change display with privacy and authentication considerations
|
# TODO: change display with privacy and authentication considerations
|
||||||
|
@ -308,10 +311,7 @@ def followers_page(request, username):
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
user = models.User.objects.get(localname=username)
|
return JsonResponse(user.to_followers_activity(**request.GET))
|
||||||
followers = user.followers
|
|
||||||
page = request.GET.get('page')
|
|
||||||
return JsonResponse(activitypub.get_followers(user, page, followers))
|
|
||||||
|
|
||||||
return user_page(request, username, subpage='followers')
|
return user_page(request, username, subpage='followers')
|
||||||
|
|
||||||
|
@ -328,10 +328,7 @@ def following_page(request, username):
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
user = models.User.objects.get(localname=username)
|
return JsonResponse(user.to_following_activity(**request.GET))
|
||||||
following = user.following
|
|
||||||
page = request.GET.get('page')
|
|
||||||
return JsonResponse(activitypub.get_following(user, page, following))
|
|
||||||
|
|
||||||
return user_page(request, username, subpage='following')
|
return user_page(request, username, subpage='following')
|
||||||
|
|
||||||
|
@ -361,7 +358,7 @@ def status_page(request, username, status_id):
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
return JsonResponse(status.activitypub_serialize)
|
return JsonResponse(status.to_activity(), encoder=ActivityEncoder)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'status': status,
|
'status': status,
|
||||||
|
@ -382,28 +379,10 @@ def replies_page(request, username, status_id):
|
||||||
if status.user.localname != username:
|
if status.user.localname != username:
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
replies = models.Status.objects.filter(
|
return JsonResponse(
|
||||||
reply_parent=status,
|
status.to_replies(**request.GET),
|
||||||
).select_subclasses()
|
encoder=ActivityEncoder
|
||||||
|
)
|
||||||
if request.GET.get('only_other_accounts'):
|
|
||||||
replies = replies.filter(
|
|
||||||
~Q(user=status.user)
|
|
||||||
)
|
|
||||||
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
|
@login_required
|
||||||
|
@ -423,7 +402,7 @@ def book_page(request, book_id, tab='friends'):
|
||||||
''' info about a book '''
|
''' info about a book '''
|
||||||
book = models.Book.objects.select_subclasses().get(id=book_id)
|
book = models.Book.objects.select_subclasses().get(id=book_id)
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
return JsonResponse(activitypub.get_book(book))
|
return JsonResponse(book.to_activity(), encoder=ActivityEncoder)
|
||||||
|
|
||||||
if isinstance(book, models.Work):
|
if isinstance(book, models.Work):
|
||||||
book = book.default_edition
|
book = book.default_edition
|
||||||
|
@ -531,7 +510,7 @@ def author_page(request, author_id):
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
if is_api_request(request):
|
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)
|
books = models.Work.objects.filter(authors=author)
|
||||||
data = {
|
data = {
|
||||||
|
@ -544,6 +523,13 @@ def author_page(request, author_id):
|
||||||
def tag_page(request, tag_id):
|
def tag_page(request, tag_id):
|
||||||
''' books related to a tag '''
|
''' books related to a tag '''
|
||||||
tag_obj = models.Tag.objects.filter(identifier=tag_id).first()
|
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()
|
books = models.Edition.objects.filter(tag__identifier=tag_id).distinct()
|
||||||
data = {
|
data = {
|
||||||
'books': books,
|
'books': books,
|
||||||
|
@ -562,8 +548,7 @@ def shelf_page(request, username, shelf_identifier):
|
||||||
shelf = models.Shelf.objects.get(user=user, identifier=shelf_identifier)
|
shelf = models.Shelf.objects.get(user=user, identifier=shelf_identifier)
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
page = request.GET.get('page')
|
return JsonResponse(shelf.to_activity(**request.GET))
|
||||||
return JsonResponse(activitypub.get_shelf(shelf, page=page))
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'shelf': shelf,
|
'shelf': shelf,
|
||||||
|
|
Loading…
Reference in a new issue