bookwyrm/bookwyrm/activitypub/base_activity.py

234 lines
7.7 KiB
Python
Raw Normal View History

''' basics for an activitypub serializer '''
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
from uuid import uuid4
from django.core.files.base import ContentFile
2020-11-28 04:11:22 +00:00
from django.db import transaction
from django.db.models.fields.related_descriptors \
2020-11-24 19:25:07 +00:00
import ForwardManyToOneDescriptor, ManyToManyDescriptor, \
ReverseManyToOneDescriptor
from django.db.models.fields.files import ImageFileDescriptor
import requests
from bookwyrm import books_manager, models
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 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):
2020-10-17 02:13:18 +00:00
''' 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):
2020-10-17 02:13:18 +00:00
''' convert from an activity to a model instance '''
if not isinstance(self, model.activity_serializer):
raise ActivitySerializerError('Wrong activity type for model')
2020-11-08 01:48:50 +00:00
# check for an existing instance, if we're not updating a known obj
if not instance:
try:
return model.objects.get(remote_id=self.id)
except model.DoesNotExist:
pass
2020-11-05 00:28:32 +00:00
model_fields = [m.name for m in model._meta.get_fields()]
mapped_fields = {}
many_to_many_fields = {}
2020-11-24 19:25:07 +00:00
one_to_many_fields = {}
image_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)
formatted_value = mapping.model_formatter(value)
2020-11-24 19:25:07 +00:00
if isinstance(model_field, ForwardManyToOneDescriptor) and \
formatted_value:
2020-11-28 04:11:22 +00:00
# foreign key remote id reolver (work on Edition, for example)
2020-11-24 19:25:07 +00:00
fk_model = model_field.field.related_model
reference = resolve_foreign_key(fk_model, formatted_value)
mapped_fields[mapping.model_key] = reference
elif isinstance(model_field, ManyToManyDescriptor):
2020-11-28 04:11:22 +00:00
# status mentions book/users
many_to_many_fields[mapping.model_key] = formatted_value
2020-11-24 19:25:07 +00:00
elif isinstance(model_field, ReverseManyToOneDescriptor):
2020-11-28 04:11:22 +00:00
# attachments on Status, for example
2020-11-24 19:25:07 +00:00
one_to_many_fields[mapping.model_key] = formatted_value
elif isinstance(model_field, ImageFileDescriptor):
2020-11-24 19:25:07 +00:00
# image fields need custom handling
image_fields[mapping.model_key] = formatted_value
else:
mapped_fields[mapping.model_key] = formatted_value
2020-11-28 04:11:22 +00:00
with transaction.atomic():
if instance:
# updating an existing model isntance
for k, v in mapped_fields.items():
setattr(instance, k, v)
2020-11-24 19:25:07 +00:00
instance.save()
2020-11-28 04:11:22 +00:00
else:
# creating a new model instance
instance = model.objects.create(**mapped_fields)
# add images
for (model_key, value) in image_fields.items():
formatted_value = image_formatter(value)
2020-11-28 04:24:19 +00:00
if not formatted_value:
continue
2020-11-28 04:11:22 +00:00
getattr(instance, model_key).save(*formatted_value, save=True)
for (model_key, values) in many_to_many_fields.items():
# mention books, mention users
getattr(instance, model_key).set(values)
# add one to many fields
for (model_key, values) in one_to_many_fields.items():
2020-11-28 04:24:19 +00:00
if values == MISSING:
continue
2020-11-28 04:11:22 +00:00
model_field = getattr(instance, model_key)
model = model_field.model
for item in values:
item = model.activity_serializer(**item)
field_name = instance.__class__.__name__.lower()
with transaction.atomic():
item = item.to_model(model)
setattr(item, field_name, instance)
item.save()
return instance
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 '''
if model in [models.Edition, models.Work, models.Book]:
return books_manager.get_or_create_book(remote_id)
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 ActivitySerializerError(
'Could not resolve remote_id in %s model: %s' % \
(model.__name__, remote_id))
return result
2020-11-20 17:28:54 +00:00
2020-11-24 19:25:07 +00:00
def tag_formatter(tags, tag_type):
2020-11-20 17:28:54 +00:00
''' helper function to extract foreign keys from tag activity json '''
if not isinstance(tags, list):
return []
2020-11-20 17:28:54 +00:00
items = []
types = {
'Book': models.Book,
'Mention': models.User,
}
2020-11-24 19:25:07 +00:00
for tag in [t for t in tags if t.get('type') == tag_type]:
2020-11-20 17:28:54 +00:00
if not tag_type in types:
continue
remote_id = tag.get('href')
try:
item = resolve_foreign_key(types[tag_type], remote_id)
except ActivitySerializerError:
continue
items.append(item)
return items
2020-11-28 04:11:22 +00:00
def image_formatter(image_slug):
''' helper function to load images and format them for a model '''
2020-11-28 04:11:22 +00:00
# when it's an inline image (User avatar/icon, Book cover), it's a json
# blob, but when it's an attached image, it's just a url
if isinstance(image_slug, dict):
url = image_slug.get('url')
elif isinstance(image_slug, str):
url = image_slug
else:
return None
2020-11-28 01:58:21 +00:00
if not url:
return None
try:
response = requests.get(url)
except ConnectionError:
return None
if not response.ok:
return None
image_name = str(uuid4()) + '.' + url.split('.')[-1]
image_content = ContentFile(response.content)
return [image_name, image_content]