Merge branch 'main' into review-rate

This commit is contained in:
Mouse Reeve
2021-02-25 10:17:52 -08:00
97 changed files with 1505 additions and 1261 deletions

View File

@ -2,14 +2,13 @@
import inspect
import sys
from .base_activity import ActivityEncoder, Signature
from .base_activity import ActivityEncoder, Signature, naive_parse
from .base_activity import Link, Mention
from .base_activity import ActivitySerializerError, resolve_remote_id
from .image import Image
from .note import Note, GeneratedNote, Article, Comment, Quotation
from .note import Review, Rating
from .note import Tombstone
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .ordered_collection import BookList, Shelf
from .person import Person, PublicKey
@ -17,10 +16,15 @@ from .response import ActivitypubResponse
from .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject, Block
from .verbs import Add, AddBook, AddListItem, Remove
from .verbs import Add, Remove
from .verbs import Announce, Like
# this creates a list of all the Activity types that we can serialize,
# so when an Activity comes in from outside, we can check if it's known
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_objects = {c[0]: c[1] for c in cls_members \
if hasattr(c[1], 'to_model')}
def parse(activity_json):
''' figure out what activity this is and parse it '''
return naive_parse(activity_objects, activity_json)

View File

@ -40,6 +40,20 @@ class Signature:
signatureValue: str
type: str = 'RsaSignature2017'
def naive_parse(activity_objects, activity_json, serializer=None):
''' this navigates circular import issues '''
if not serializer:
if activity_json.get('publicKeyPem'):
# ugh
activity_json['type'] = 'PublicKey'
try:
activity_type = activity_json['type']
serializer = activity_objects[activity_type]
except KeyError as e:
raise ActivitySerializerError(e)
return serializer(activity_objects=activity_objects, **activity_json)
@dataclass(init=False)
class ActivityObject:
@ -47,13 +61,30 @@ class ActivityObject:
id: str
type: str
def __init__(self, **kwargs):
def __init__(self, activity_objects=None, **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]
if value in (None, MISSING):
raise KeyError()
try:
is_subclass = issubclass(field.type, ActivityObject)
except TypeError:
is_subclass = False
# serialize a model obj
if hasattr(value, 'to_activity'):
value = value.to_activity()
# parse a dict into the appropriate activity
elif is_subclass and isinstance(value, dict):
if activity_objects:
value = naive_parse(activity_objects, value)
else:
value = naive_parse(
activity_objects, value, serializer=field.type)
except KeyError:
if field.default == MISSING and \
field.default_factory == MISSING:
@ -63,31 +94,29 @@ class ActivityObject:
setattr(self, field.name, value)
def to_model(self, model, instance=None, save=True):
def to_model(self, model=None, instance=None, allow_create=True, save=True):
''' convert from an activity to a model instance '''
if self.type != model.activity_serializer.type:
raise ActivitySerializerError(
'Wrong activity type "%s" for activity of type "%s"' % \
(model.activity_serializer.type,
self.type)
)
model = model or get_model_from_type(self.type)
if not isinstance(self, model.activity_serializer):
raise ActivitySerializerError(
'Wrong activity type "%s" for model "%s" (expects "%s")' % \
(self.__class__,
model.__name__,
model.activity_serializer)
)
# only reject statuses if we're potentially creating them
if allow_create and \
hasattr(model, 'ignore_activity') and \
model.ignore_activity(self):
return None
if hasattr(model, 'ignore_activity') and model.ignore_activity(self):
return instance
# check for an existing instance
instance = instance or model.find_existing(self.serialize())
# check for an existing instance, if we're not updating a known obj
instance = instance or model.find_existing(self.serialize()) or model()
if not instance and not allow_create:
# so that we don't create when we want to delete or update
return None
instance = instance or model()
for field in instance.simple_fields:
field.set_field_from_activity(instance, self)
try:
field.set_field_from_activity(instance, self)
except AttributeError as e:
raise ActivitySerializerError(e)
# image fields have to be set after other fields because they can save
# too early and jank up users
@ -139,7 +168,14 @@ class ActivityObject:
def serialize(self):
''' convert to dictionary with context attr '''
data = self.__dict__
data = self.__dict__.copy()
# recursively serialize
for (k, v) in data.items():
try:
if issubclass(type(v), ActivityObject):
data[k] = v.serialize()
except TypeError:
pass
data = {k:v for (k, v) in data.items() if v is not None}
data['@context'] = 'https://www.w3.org/ns/activitystreams'
return data
@ -182,7 +218,7 @@ def set_related_field(
getattr(model_field, 'activitypub_field'),
instance.remote_id
)
item = activity.to_model(model)
item = activity.to_model()
# if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation
@ -191,11 +227,24 @@ def set_related_field(
item.save()
def resolve_remote_id(model, remote_id, refresh=False, save=True):
def get_model_from_type(activity_type):
''' given the activity, what type of model '''
models = apps.get_models()
model = [m for m in models if hasattr(m, 'activity_serializer') and \
hasattr(m.activity_serializer, 'type') and \
m.activity_serializer.type == activity_type]
if not model:
raise ActivitySerializerError(
'No model found for activity type "%s"' % activity_type)
return model[0]
def resolve_remote_id(remote_id, model=None, refresh=False, save=True):
''' take a remote_id and return an instance, creating if necessary '''
result = model.find_existing_by_remote_id(remote_id)
if result and not refresh:
return result
if model:# a bonus check we can do if we already know the model
result = model.find_existing_by_remote_id(remote_id)
if result and not refresh:
return result
# load the data and create the object
try:
@ -204,13 +253,15 @@ def resolve_remote_id(model, remote_id, refresh=False, save=True):
raise ActivitySerializerError(
'Could not connect to host for remote_id in %s model: %s' % \
(model.__name__, remote_id))
# determine the model implicitly, if not provided
if not model:
model = get_model_from_type(data.get('type'))
# check for existing items with shared unique identifiers
if not result:
result = model.find_existing(data)
if result and not refresh:
return result
result = model.find_existing(data)
if result and not refresh:
return result
item = model.activity_serializer(**data)
# if we're refreshing, "result" will be set and we'll update it
return item.to_model(model, instance=result, save=save)
return item.to_model(model=model, instance=result, save=save)

View File

@ -67,4 +67,4 @@ class Author(ActivityObject):
librarythingKey: str = ''
goodreadsKey: str = ''
wikipediaLink: str = ''
type: str = 'Person'
type: str = 'Author'

View File

@ -1,20 +0,0 @@
''' boosting and liking posts '''
from dataclasses import dataclass
from .base_activity import ActivityObject
@dataclass(init=False)
class Like(ActivityObject):
''' a user faving an object '''
actor: str
object: str
type: str = 'Like'
@dataclass(init=False)
class Boost(ActivityObject):
''' boosting a status '''
actor: str
object: str
type: str = 'Announce'

View File

@ -1,6 +1,7 @@
''' note serializer and children thereof '''
from dataclasses import dataclass, field
from typing import Dict, List
from django.apps import apps
from .base_activity import ActivityObject, Link
from .image import Image
@ -8,10 +9,13 @@ from .image import Image
@dataclass(init=False)
class Tombstone(ActivityObject):
''' the placeholder for a deleted status '''
published: str
deleted: str
type: str = 'Tombstone'
def to_model(self, *args, **kwargs):
''' this should never really get serialized, just searched for '''
model = apps.get_model('bookwyrm.Status')
return model.find_existing_by_remote_id(self.id)
@dataclass(init=False)
class Note(ActivityObject):

View File

@ -17,6 +17,7 @@ class OrderedCollection(ActivityObject):
@dataclass(init=False)
class OrderedCollectionPrivate(OrderedCollection):
''' an ordered collection with privacy settings '''
to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
@ -38,6 +39,6 @@ class OrderedCollectionPage(ActivityObject):
''' structure of an ordered collection activity '''
partOf: str
orderedItems: List
next: str
prev: str
next: str = None
prev: str = None
type: str = 'OrderedCollectionPage'

View File

@ -9,7 +9,7 @@ class ActivitypubResponse(JsonResponse):
configures some stuff beforehand. Made to be a drop-in replacement of
JsonResponse.
"""
def __init__(self, data, encoder=ActivityEncoder, safe=True,
def __init__(self, data, encoder=ActivityEncoder, safe=False,
json_dumps_params=None, **kwargs):
if 'content_type' not in kwargs:

View File

@ -1,10 +1,12 @@
''' undo wrapper activity '''
from dataclasses import dataclass
from typing import List
from django.apps import apps
from .base_activity import ActivityObject, Signature
from .base_activity import ActivityObject, Signature, resolve_remote_id
from .book import Edition
@dataclass(init=False)
class Verb(ActivityObject):
''' generic fields for activities - maybe an unecessary level of
@ -12,6 +14,10 @@ class Verb(ActivityObject):
actor: str
object: ActivityObject
def action(self):
''' usually we just want to save, this can be overridden as needed '''
self.object.to_model()
@dataclass(init=False)
class Create(Verb):
@ -29,6 +35,12 @@ class Delete(Verb):
cc: List
type: str = 'Delete'
def action(self):
''' find and delete the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Update(Verb):
@ -36,29 +48,60 @@ class Update(Verb):
to: List
type: str = 'Update'
def action(self):
''' update a model instance from the dataclass '''
self.object.to_model(allow_create=False)
@dataclass(init=False)
class Undo(Verb):
''' Undo an activity '''
type: str = 'Undo'
def action(self):
''' find and remove the activity object '''
# this is so hacky but it does make it work....
# (because you Reject a request and Undo a follow
model = None
if self.object.type == 'Follow':
model = apps.get_model('bookwyrm.UserFollows')
obj = self.object.to_model(model=model, save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Follow(Verb):
''' Follow activity '''
object: str
type: str = 'Follow'
def action(self):
''' relationship save '''
self.to_model()
@dataclass(init=False)
class Block(Verb):
''' Block activity '''
object: str
type: str = 'Block'
def action(self):
''' relationship save '''
self.to_model()
@dataclass(init=False)
class Accept(Verb):
''' Accept activity '''
object: Follow
type: str = 'Accept'
def action(self):
''' find and remove the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.accept()
@dataclass(init=False)
class Reject(Verb):
@ -66,32 +109,60 @@ class Reject(Verb):
object: Follow
type: str = 'Reject'
def action(self):
''' find and remove the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.reject()
@dataclass(init=False)
class Add(Verb):
'''Add activity '''
target: str
object: ActivityObject
type: str = 'Add'
@dataclass(init=False)
class AddBook(Add):
'''Add activity that's aware of the book obj '''
object: Edition
type: str = 'Add'
@dataclass(init=False)
class AddListItem(AddBook):
'''Add activity that's aware of the book obj '''
notes: str = None
order: int = 0
approved: bool = True
def action(self):
''' add obj to collection '''
target = resolve_remote_id(self.target, refresh=False)
# we want to related field that isn't the book, this is janky af sorry
model = [t for t in type(target)._meta.related_objects \
if t.name != 'edition'][0].related_model
self.to_model(model=model)
@dataclass(init=False)
class Remove(Verb):
'''Remove activity '''
target: ActivityObject
type: str = 'Remove'
def action(self):
''' find and remove the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Like(Verb):
''' a user faving an object '''
object: str
type: str = 'Like'
def action(self):
''' like '''
self.to_model()
@dataclass(init=False)
class Announce(Verb):
''' boosting a status '''
object: str
type: str = 'Announce'
def action(self):
''' boost '''
self.to_model()