moviewyrm/bookwyrm/activitypub/base_activity.py

194 lines
6.5 KiB
Python

''' basics for an activitypub serializer '''
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
from django.apps import apps
from django.db import transaction
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app
class ActivitySerializerError(ValueError):
''' routine problems serializing activitypub json '''
class ActivityEncoder(JSONEncoder):
''' used to convert an Activity object into json '''
def default(self, o):
return o.__dict__
@dataclass
class Link:
''' for tagging a book in a status '''
href: str
name: str
type: str = 'Link'
@dataclass
class Mention(Link):
''' a subtype of Link for mentioning an actor '''
type: str = 'Mention'
@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 and \
field.default_factory == MISSING:
raise ActivitySerializerError(\
'Missing required field: %s' % field.name)
value = field.default
setattr(self, field.name, value)
def to_model(self, model, instance=None, save=True):
''' convert from an activity to a model instance '''
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)
)
if hasattr(model, 'ignore_activity') and model.ignore_activity(self):
return instance
# check for an existing instance, if we're not updating a known obj
instance = instance or model.find_existing(self.serialize()) or model()
for field in instance.simple_fields:
field.set_field_from_activity(instance, self)
# image fields have to be set after other fields because they can save
# too early and jank up users
for field in instance.image_fields:
field.set_field_from_activity(instance, self, save=save)
if not save:
return instance
with transaction.atomic():
# we can't set many to many and reverse fields on an unsaved object
instance.save()
# add many to many fields, which have to be set post-save
for field in instance.many_to_many_fields:
# mention books/users, for example
field.set_field_from_activity(instance, self)
# reversed relationships in the models
for (model_field_name, activity_field_name) in \
instance.deserialize_reverse_fields:
# attachments on Status, for example
values = getattr(self, activity_field_name)
if values is None or values is MISSING:
continue
model_field = getattr(model, model_field_name)
try:
# this is for one to many
related_model = model_field.field.model
related_field_name = model_field.field.name
except AttributeError:
# it's a one to one or foreign key
related_model = model_field.related.related_model
related_field_name = model_field.related.related_name
values = [values]
for item in values:
set_related_field.delay(
related_model.__name__,
instance.__class__.__name__,
related_field_name,
instance.remote_id,
item
)
return instance
def serialize(self):
''' convert to dictionary with context attr '''
data = self.__dict__
data['@context'] = 'https://www.w3.org/ns/activitystreams'
return data
@app.task
@transaction.atomic
def set_related_field(
model_name, origin_model_name,
related_field_name, related_remote_id, data):
''' load reverse related fields (editions, attachments) without blocking '''
model = apps.get_model('bookwyrm.%s' % model_name, require_ready=True)
origin_model = apps.get_model(
'bookwyrm.%s' % origin_model_name,
require_ready=True
)
with transaction.atomic():
if isinstance(data, str):
item = resolve_remote_id(model, data, save=False)
else:
# look for a match based on all the available data
item = model.find_existing(data)
if not item:
# create a new model instance
item = model.activity_serializer(**data)
item = item.to_model(model, save=False)
# this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance:
raise ValueError(
'Invalid related remote id: %s' % related_remote_id)
# edition.parent_work = instance, for example
setattr(item, related_field_name, instance)
item.save()
def resolve_remote_id(model, remote_id, 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
# load the data and create the object
try:
data = get_data(remote_id)
except (ConnectorException, ConnectionError):
raise ActivitySerializerError(
'Could not connect to host for remote_id in %s model: %s' % \
(model.__name__, remote_id))
# check for existing items with shared unique identifiers
if not 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)