Merge branch 'main' into review-rate
This commit is contained in:
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -67,4 +67,4 @@ class Author(ActivityObject):
|
||||
librarythingKey: str = ''
|
||||
goodreadsKey: str = ''
|
||||
wikipediaLink: str = ''
|
||||
type: str = 'Person'
|
||||
type: str = 'Author'
|
||||
|
@ -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'
|
@ -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):
|
||||
|
@ -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'
|
||||
|
@ -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:
|
||||
|
@ -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()
|
||||
|
Reference in New Issue
Block a user