Merge branch 'main' into switch-edition

This commit is contained in:
Mouse Reeve
2020-12-13 21:10:37 -08:00
113 changed files with 3354 additions and 1578 deletions

View File

@ -12,9 +12,9 @@ from .status import Status, GeneratedNote, Review, Comment, Quotation
from .status import Favorite, Boost, Notification, ReadThrough
from .attachment import Image
from .tag import Tag
from .tag import Tag, UserTag
from .user import User
from .user import User, KeyPair
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .federated_server import FederatedServer

View File

@ -3,7 +3,8 @@ from django.db import models
from bookwyrm import activitypub
from .base_model import ActivitypubMixin
from .base_model import ActivityMapping, BookWyrmModel
from .base_model import BookWyrmModel
from . import fields
class Attachment(ActivitypubMixin, BookWyrmModel):
@ -14,19 +15,16 @@ class Attachment(ActivitypubMixin, BookWyrmModel):
related_name='attachments',
null=True
)
reverse_unfurl = True
class Meta:
''' one day we'll have other types of attachments besides images '''
abstract = True
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'image'),
ActivityMapping('name', 'caption'),
]
class Image(Attachment):
''' an image attachment '''
image = models.ImageField(upload_to='status/', null=True, blank=True)
caption = models.TextField(null=True, blank=True)
image = fields.ImageField(
upload_to='status/', null=True, blank=True, activitypub_field='url')
caption = fields.TextField(null=True, blank=True, activitypub_field='name')
activity_serializer = activitypub.Image

View File

@ -3,48 +3,42 @@ from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.utils.fields import ArrayField
from bookwyrm.settings import DOMAIN
from .base_model import ActivitypubMixin, ActivityMapping, BookWyrmModel
from .base_model import ActivitypubMixin, BookWyrmModel
from . import fields
class Author(ActivitypubMixin, BookWyrmModel):
''' basic biographic info '''
origin_id = models.CharField(max_length=255, null=True)
''' copy of an author from OL '''
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
openlibrary_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
sync = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now)
wikipedia_link = models.CharField(max_length=255, blank=True, null=True)
wikipedia_link = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
# idk probably other keys would be useful here?
born = models.DateTimeField(blank=True, null=True)
died = models.DateTimeField(blank=True, null=True)
name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255, blank=True, null=True)
first_name = models.CharField(max_length=255, blank=True, null=True)
aliases = ArrayField(
born = fields.DateTimeField(blank=True, null=True)
died = fields.DateTimeField(blank=True, null=True)
name = fields.CharField(max_length=255)
aliases = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
bio = models.TextField(null=True, blank=True)
bio = fields.TextField(null=True, blank=True)
@property
def display_name(self):
''' Helper to return a displayable name'''
if self.name:
return self.name
# don't want to return a spurious space if all of these are None
if self.first_name and self.last_name:
return self.first_name + ' ' + self.last_name
return self.last_name or self.first_name
def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it '''
if self.id and not self.remote_id:
self.remote_id = self.get_remote_id()
if not self.id:
self.origin_id = self.remote_id
self.remote_id = None
return super().save(*args, **kwargs)
def get_remote_id(self):
''' editions and works both use "book" instead of model_name '''
return 'https://%s/author/%s' % (DOMAIN, self.id)
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('name', 'name'),
ActivityMapping('born', 'born'),
ActivityMapping('died', 'died'),
ActivityMapping('aliases', 'aliases'),
ActivityMapping('bio', 'bio'),
ActivityMapping('openlibraryKey', 'openlibrary_key'),
ActivityMapping('wikipediaLink', 'wikipedia_link'),
]
activity_serializer = activitypub.Author

View File

@ -1,34 +1,27 @@
''' base model with default fields '''
from datetime import datetime
from base64 import b64encode
from dataclasses import dataclass
from typing import Callable
from functools import reduce
import operator
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.core.paginator import Paginator
from django.db import models
from django.db.models.fields.files import ImageFieldFile
from django.db.models import Q
from django.dispatch import receiver
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import DOMAIN, PAGE_LENGTH
from .fields import ImageField, ManyToManyField, RemoteIdField
PrivacyLevels = models.TextChoices('Privacy', [
'public',
'unlisted',
'followers',
'direct'
])
class BookWyrmModel(models.Model):
''' shared fields '''
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
remote_id = models.CharField(max_length=255, null=True)
remote_id = RemoteIdField(null=True, activitypub_field='id')
def get_remote_id(self):
''' generate a url that resolves to the local object '''
@ -44,6 +37,7 @@ class BookWyrmModel(models.Model):
@receiver(models.signals.post_save)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
''' set the remote_id after save (when the id is available) '''
if not created or not hasattr(instance, 'get_remote_id'):
@ -53,58 +47,115 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
instance.save()
def unfurl_related_field(related_field):
''' load reverse lookups (like public key owner or Status attachment '''
if hasattr(related_field, 'all'):
return [unfurl_related_field(i) for i in related_field.all()]
if related_field.reverse_unfurl:
return related_field.field_to_activity()
return related_field.remote_id
class ActivitypubMixin:
''' add this mixin for models that are AP serializable '''
activity_serializer = lambda: {}
reverse_unfurl = False
def to_activity(self, pure=False):
''' convert from a model to an activity '''
if pure:
# works around bookwyrm-specific fields for vanilla AP services
mappings = self.pure_activity_mappings
else:
# may include custom fields that bookwyrm instances will understand
mappings = self.activity_mappings
fields = {}
for mapping in mappings:
if not hasattr(self, mapping.model_key) or not mapping.activity_key:
# this field on the model isn't serialized
def __init__(self, *args, **kwargs):
''' collect some info on model fields '''
self.image_fields = []
self.many_to_many_fields = []
self.simple_fields = [] # "simple"
for field in self._meta.get_fields():
if not hasattr(field, 'field_to_activity'):
continue
value = getattr(self, mapping.model_key)
if hasattr(value, 'remote_id'):
# this is probably a foreign key field, which we want to
# serialize as just the remote_id url reference
value = value.remote_id
elif isinstance(value, datetime):
value = value.isoformat()
elif isinstance(value, ImageFieldFile):
value = image_formatter(value)
# run the custom formatter function set in the model
formatted_value = mapping.activity_formatter(value)
if mapping.activity_key in fields and \
isinstance(fields[mapping.activity_key], list):
# there can be two database fields that map to the same AP list
# this happens in status tags, which combines user and book tags
fields[mapping.activity_key] += formatted_value
if isinstance(field, ImageField):
self.image_fields.append(field)
elif isinstance(field, ManyToManyField):
self.many_to_many_fields.append(field)
else:
fields[mapping.activity_key] = formatted_value
self.simple_fields.append(field)
if pure:
return self.pure_activity_serializer(
**fields
).serialize()
return self.activity_serializer(
**fields
).serialize()
self.activity_fields = self.image_fields + \
self.many_to_many_fields + self.simple_fields
self.deserialize_reverse_fields = self.deserialize_reverse_fields \
if hasattr(self, 'deserialize_reverse_fields') else []
self.serialize_reverse_fields = self.serialize_reverse_fields \
if hasattr(self, 'serialize_reverse_fields') else []
super().__init__(*args, **kwargs)
def to_create_activity(self, user, pure=False):
@classmethod
def find_existing_by_remote_id(cls, remote_id):
''' look up a remote id in the db '''
return cls.find_existing({'id': remote_id})
@classmethod
def find_existing(cls, data):
''' compare data to fields that can be used for deduplation.
This always includes remote_id, but can also be unique identifiers
like an isbn for an edition '''
filters = []
for field in cls._meta.get_fields():
if not hasattr(field, 'deduplication_field') or \
not field.deduplication_field:
continue
value = data.get(field.activitypub_field)
if not value:
continue
filters.append({field.name: value})
if hasattr(cls, 'origin_id') and 'id' in data:
# kinda janky, but this handles special case for books
filters.append({'origin_id': data['id']})
if not filters:
# if there are no deduplication fields, it will match the first
# item no matter what. this shouldn't happen but just in case.
return None
objects = cls.objects
if hasattr(objects, 'select_subclasses'):
objects = objects.select_subclasses()
# an OR operation on all the match fields
match = objects.filter(
reduce(
operator.or_, (Q(**f) for f in filters)
)
)
# there OUGHT to be only one match
return match.first()
def to_activity(self):
''' convert from a model to an activity '''
activity = {}
for field in self.activity_fields:
field.set_activity_from_field(activity, self)
if hasattr(self, 'serialize_reverse_fields'):
# for example, editions of a work
for model_field_name, activity_field_name in \
self.serialize_reverse_fields:
related_field = getattr(self, model_field_name)
activity[activity_field_name] = \
unfurl_related_field(related_field)
if not activity.get('id'):
activity['id'] = self.get_remote_id()
return self.activity_serializer(**activity).serialize()
def to_create_activity(self, user):
''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity(pure=pure)
activity_object = self.to_activity()
signer = pkcs1_15.new(RSA.import_key(user.private_key))
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
create_id = self.remote_id + '/activity'
@ -118,8 +169,8 @@ class ActivitypubMixin:
return activitypub.Create(
id=create_id,
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
to=activity_object['to'],
cc=activity_object['cc'],
object=activity_object,
signature=signature,
).serialize()
@ -127,21 +178,18 @@ class ActivitypubMixin:
def to_delete_activity(self, user):
''' notice of deletion '''
# this should be a tombstone
activity_object = self.to_activity()
return activitypub.Delete(
id=self.remote_id + '/activity',
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
object=activity_object,
object=self.to_activity(),
).serialize()
def to_update_activity(self, user):
''' wrapper for Updates to an activity '''
activity_id = '%s#update/%s' % (user.remote_id, uuid4())
activity_id = '%s#update/%s' % (self.remote_id, uuid4())
return activitypub.Update(
id=activity_id,
actor=user.remote_id,
@ -153,10 +201,10 @@ class ActivitypubMixin:
def to_undo_activity(self, user):
''' undo an action '''
return activitypub.Undo(
id='%s#undo' % user.remote_id,
id='%s#undo' % self.remote_id,
actor=user.remote_id,
object=self.to_activity()
)
).serialize()
class OrderedCollectionPageMixin(ActivitypubMixin):
@ -167,77 +215,53 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
''' 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(
return to_ordered_collection_page(
queryset, remote_id, **kwargs)
name = ''
if hasattr(self, 'name'):
name = self.name
name = self.name if hasattr(self, 'name') else None
owner = self.user.remote_id if hasattr(self, 'user') else ''
size = queryset.count()
paginated = Paginator(queryset, PAGE_LENGTH)
return activitypub.OrderedCollection(
id=remote_id,
totalItems=size,
totalItems=paginated.count,
name=name,
first='%s%s' % (remote_id, self.page()),
last='%s%s' % (remote_id, self.page(min_id=0))
owner=owner,
first='%s?page=1' % remote_id,
last='%s?page=%d' % (remote_id, paginated.num_pages)
).serialize()
def to_ordered_collection_page(queryset, remote_id, id_only=False, page=1):
''' serialize and pagiante a queryset '''
paginated = Paginator(queryset, PAGE_LENGTH)
activity_page = paginated.page(page)
if id_only:
items = [s.remote_id for s in activity_page.object_list]
else:
items = [s.to_activity() for s in activity_page.object_list]
prev_page = next_page = None
if activity_page.has_next():
next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number())
if activity_page.has_previous():
prev_page = '%s?page=%d' % \
(remote_id, activity_page.previous_page_number())
return activitypub.OrderedCollectionPage(
id='%s?page=%s' % (remote_id, page),
partOf=remote_id,
orderedItems=items,
next=next_page,
prev=prev_page
).serialize()
class OrderedCollectionMixin(OrderedCollectionPageMixin):
''' extends activitypub models to work as ordered collections '''
@property
@ -250,39 +274,3 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
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
def tag_formatter(items, name_field, activity_type):
''' helper function to format lists of foreign keys into Tags '''
tags = []
for item in items.all():
tags.append(activitypub.Link(
href=item.remote_id,
name=getattr(item, name_field),
type=activity_type
))
return tags
def image_formatter(image):
''' convert images into activitypub json '''
if image and hasattr(image, 'url'):
url = image.url
else:
return None
url = 'https://%s%s' % (DOMAIN, url)
return activitypub.Image(url=url)
def image_attachments_formatter(images):
''' create a list of image attachments '''
return [image_formatter(i) for i in images]

View File

@ -2,24 +2,26 @@
import re
from django.db import models
from django.db.models import Q
from django.utils import timezone
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.utils.fields import ArrayField
from .base_model import ActivityMapping, BookWyrmModel
from .base_model import BookWyrmModel
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from . import fields
class Book(ActivitypubMixin, BookWyrmModel):
''' a generic book, which can mean either an edition or a work '''
origin_id = models.CharField(max_length=255, null=True, blank=True)
# these identifiers apply to both works and editions
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
librarything_key = models.CharField(max_length=255, blank=True, null=True)
goodreads_key = models.CharField(max_length=255, blank=True, null=True)
openlibrary_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
librarything_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
goodreads_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
# info about where the data comes from and where/if to sync
sync = models.BooleanField(default=True)
@ -31,78 +33,43 @@ class Book(ActivitypubMixin, BookWyrmModel):
# TODO: edit history
# book/work metadata
title = models.CharField(max_length=255)
sort_title = models.CharField(max_length=255, blank=True, null=True)
subtitle = models.CharField(max_length=255, blank=True, null=True)
description = models.TextField(blank=True, null=True)
languages = ArrayField(
title = fields.CharField(max_length=255)
sort_title = fields.CharField(max_length=255, blank=True, null=True)
subtitle = fields.CharField(max_length=255, blank=True, null=True)
description = fields.TextField(blank=True, null=True)
languages = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
series = models.CharField(max_length=255, blank=True, null=True)
series_number = models.CharField(max_length=255, blank=True, null=True)
subjects = ArrayField(
models.CharField(max_length=255), blank=True, default=list
series = fields.CharField(max_length=255, blank=True, null=True)
series_number = fields.CharField(max_length=255, blank=True, null=True)
subjects = fields.ArrayField(
models.CharField(max_length=255), blank=True, null=True, default=list
)
subject_places = ArrayField(
models.CharField(max_length=255), blank=True, default=list
subject_places = fields.ArrayField(
models.CharField(max_length=255), blank=True, null=True, default=list
)
# TODO: include an annotation about the type of authorship (ie, translator)
authors = models.ManyToManyField('Author')
authors = fields.ManyToManyField('Author')
# preformatted authorship string for search and easier display
author_text = models.CharField(max_length=255, blank=True, null=True)
cover = models.ImageField(upload_to='covers/', blank=True, null=True)
first_published_date = models.DateTimeField(blank=True, null=True)
published_date = models.DateTimeField(blank=True, null=True)
cover = fields.ImageField(upload_to='covers/', blank=True, null=True)
first_published_date = fields.DateTimeField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True)
objects = InheritanceManager()
@property
def ap_authors(self):
''' the activitypub serialization should be a list of author ids '''
return [a.remote_id for a in self.authors.all()]
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('authors', 'ap_authors'),
ActivityMapping('firstPublishedDate', 'firstpublished_date'),
ActivityMapping('publishedDate', 'published_date'),
ActivityMapping('title', 'title'),
ActivityMapping('sortTitle', 'sort_title'),
ActivityMapping('subtitle', 'subtitle'),
ActivityMapping('description', 'description'),
ActivityMapping('languages', 'languages'),
ActivityMapping('series', 'series'),
ActivityMapping('seriesNumber', 'series_number'),
ActivityMapping('subjects', 'subjects'),
ActivityMapping('subjectPlaces', 'subject_places'),
ActivityMapping('openlibraryKey', 'openlibrary_key'),
ActivityMapping('librarythingKey', 'librarything_key'),
ActivityMapping('goodreadsKey', 'goodreads_key'),
ActivityMapping('work', 'parent_work'),
ActivityMapping('isbn10', 'isbn_10'),
ActivityMapping('isbn13', 'isbn_13'),
ActivityMapping('oclcNumber', 'oclc_number'),
ActivityMapping('asin', 'asin'),
ActivityMapping('pages', 'pages'),
ActivityMapping('physicalFormat', 'physical_format'),
ActivityMapping('publishers', 'publishers'),
ActivityMapping('lccn', 'lccn'),
ActivityMapping('editions', 'editions_path'),
ActivityMapping('cover', 'cover'),
]
def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it '''
if not isinstance(self, Edition) and not isinstance(self, Work):
raise ValueError('Books should be added as Editions or Works')
if self.id and not self.remote_id:
self.remote_id = self.get_remote_id()
super().save(*args, **kwargs)
if not self.id:
self.origin_id = self.remote_id
self.remote_id = None
return super().save(*args, **kwargs)
def get_remote_id(self):
''' editions and works both use "book" instead of model_name '''
@ -119,47 +86,38 @@ class Book(ActivitypubMixin, BookWyrmModel):
class Work(OrderedCollectionPageMixin, Book):
''' a work (an abstract concept of a book that manifests in an edition) '''
# library of congress catalog control number
lccn = models.CharField(max_length=255, blank=True, null=True)
lccn = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
# this has to be nullable but should never be null
default_edition = models.ForeignKey(
default_edition = fields.ForeignKey(
'Edition',
on_delete=models.PROTECT,
null=True
)
@property
def editions_path(self):
''' it'd be nice to serialize the edition instead but, recursion '''
default = self.default_edition
ed_list = [
e.remote_id for e in self.edition_set.filter(~Q(id=default.id)).all()
]
return [default.remote_id] + ed_list
def to_edition_list(self, **kwargs):
''' activitypub serialization for this work's editions '''
remote_id = self.remote_id + '/editions'
return self.to_ordered_collection(
self.edition_set,
remote_id=remote_id,
**kwargs
)
def get_default_edition(self):
''' in case the default edition is not set '''
return self.default_edition or self.editions.first()
activity_serializer = activitypub.Work
serialize_reverse_fields = [('editions', 'editions')]
deserialize_reverse_fields = [('editions', 'editions')]
class Edition(Book):
''' an edition of a book '''
# these identifiers only apply to editions, not works
isbn_10 = models.CharField(max_length=255, blank=True, null=True)
isbn_13 = models.CharField(max_length=255, blank=True, null=True)
oclc_number = models.CharField(max_length=255, blank=True, null=True)
asin = models.CharField(max_length=255, blank=True, null=True)
pages = models.IntegerField(blank=True, null=True)
physical_format = models.CharField(max_length=255, blank=True, null=True)
publishers = ArrayField(
isbn_10 = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
isbn_13 = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
oclc_number = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
asin = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
pages = fields.IntegerField(blank=True, null=True)
physical_format = fields.CharField(max_length=255, blank=True, null=True)
publishers = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
shelves = models.ManyToManyField(
@ -168,9 +126,12 @@ class Edition(Book):
through='ShelfBook',
through_fields=('book', 'shelf')
)
parent_work = models.ForeignKey('Work', on_delete=models.PROTECT, null=True)
parent_work = fields.ForeignKey(
'Work', on_delete=models.PROTECT, null=True,
related_name='editions', activitypub_field='work')
activity_serializer = activitypub.Edition
name_field = 'title'
def save(self, *args, **kwargs):
''' calculate isbn 10/13 '''

363
bookwyrm/models/fields.py Normal file
View File

@ -0,0 +1,363 @@
''' activitypub-aware django model fields '''
from dataclasses import MISSING
import re
from uuid import uuid4
import dateutil.parser
from dateutil.parser import ParserError
from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.connectors import get_image
def validate_remote_id(value):
''' make sure the remote_id looks like a url '''
if not value or not re.match(r'^http.?:\/\/[^\s]+$', value):
raise ValidationError(
_('%(value)s is not a valid remote_id'),
params={'value': value},
)
class ActivitypubFieldMixin:
''' make a database field serializable '''
def __init__(self, *args, \
activitypub_field=None, activitypub_wrapper=None,
deduplication_field=False, **kwargs):
self.deduplication_field = deduplication_field
if activitypub_wrapper:
self.activitypub_wrapper = activitypub_field
self.activitypub_field = activitypub_wrapper
else:
self.activitypub_field = activitypub_field
super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data):
''' helper function for assinging a value to the field '''
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
return
setattr(instance, self.name, formatted)
def set_activity_from_field(self, activity, instance):
''' update the json object '''
value = getattr(instance, self.name)
formatted = self.field_to_activity(value)
if formatted is None:
return
key = self.get_activitypub_field()
if isinstance(activity.get(key), list):
activity[key] += formatted
else:
activity[key] = formatted
def field_to_activity(self, value):
''' formatter to convert a model value into activitypub '''
if hasattr(self, 'activitypub_wrapper'):
return {self.activitypub_wrapper: value}
return value
def field_from_activity(self, value):
''' formatter to convert activitypub into a model value '''
if hasattr(self, 'activitypub_wrapper'):
value = value.get(self.activitypub_wrapper)
return value
def get_activitypub_field(self):
''' model_field_name to activitypubFieldName '''
if self.activitypub_field:
return self.activitypub_field
name = self.name.split('.')[-1]
components = name.split('_')
return components[0] + ''.join(x.title() for x in components[1:])
class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
''' default (de)serialization for foreign key and one to one '''
def field_from_activity(self, value):
if not value:
return None
related_model = self.related_model
if isinstance(value, dict) and value.get('id'):
# this is an activitypub object, which we can deserialize
activity_serializer = related_model.activity_serializer
return activity_serializer(**value).to_model(related_model)
try:
# make sure the value looks like a remote id
validate_remote_id(value)
except ValidationError:
# we don't know what this is, ignore it
return None
# gets or creates the model field from the remote id
return activitypub.resolve_remote_id(related_model, value)
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
''' a url that serves as a unique identifier '''
def __init__(self, *args, max_length=255, validators=None, **kwargs):
validators = validators or [validate_remote_id]
super().__init__(
*args, max_length=max_length, validators=validators,
**kwargs
)
# for this field, the default is true. false everywhere else.
self.deduplication_field = kwargs.get('deduplication_field', True)
class UsernameField(ActivitypubFieldMixin, models.CharField):
''' activitypub-aware username field '''
def __init__(self, activitypub_field='preferredUsername'):
self.activitypub_field = activitypub_field
# I don't totally know why pylint is mad at this, but it makes it work
super( #pylint: disable=bad-super-call
ActivitypubFieldMixin, self
).__init__(
_('username'),
max_length=150,
unique=True,
validators=[AbstractUser.username_validator],
error_messages={
'unique': _('A user with that username already exists.'),
},
)
def deconstruct(self):
''' implementation of models.Field deconstruct '''
name, path, args, kwargs = super().deconstruct()
del kwargs['verbose_name']
del kwargs['max_length']
del kwargs['unique']
del kwargs['validators']
del kwargs['error_messages']
return name, path, args, kwargs
def field_to_activity(self, value):
return value.split('@')[0]
PrivacyLevels = models.TextChoices('Privacy', [
'public',
'unlisted',
'followers',
'direct'
])
class PrivacyField(ActivitypubFieldMixin, models.CharField):
''' this maps to two differente activitypub fields '''
public = 'https://www.w3.org/ns/activitystreams#Public'
def __init__(self, *args, **kwargs):
super().__init__(
*args, max_length=255,
choices=PrivacyLevels.choices, default='public')
def set_field_from_activity(self, instance, data):
to = data.to
cc = data.cc
if to == [self.public]:
setattr(instance, self.name, 'public')
elif cc == []:
setattr(instance, self.name, 'direct')
elif self.public in cc:
setattr(instance, self.name, 'unlisted')
else:
setattr(instance, self.name, 'followers')
def set_activity_from_field(self, activity, instance):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
followers = instance.user.__class__._meta.get_field('followers')\
.field_to_activity(instance.user.followers)
if instance.privacy == 'public':
activity['to'] = [self.public]
activity['cc'] = [followers] + mentions
elif instance.privacy == 'unlisted':
activity['to'] = [followers]
activity['cc'] = [self.public] + mentions
elif instance.privacy == 'followers':
activity['to'] = [followers]
activity['cc'] = mentions
if instance.privacy == 'direct':
activity['to'] = mentions
activity['cc'] = []
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
''' activitypub-aware foreign key field '''
def field_to_activity(self, value):
if not value:
return None
return value.remote_id
class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
''' activitypub-aware foreign key field '''
def field_to_activity(self, value):
if not value:
return None
return value.to_activity()
class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
''' activitypub-aware many to many field '''
def __init__(self, *args, link_only=False, **kwargs):
self.link_only = link_only
super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data):
''' helper function for assinging a value to the field '''
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
return
getattr(instance, self.name).set(formatted)
def field_to_activity(self, value):
if self.link_only:
return '%s/%s' % (value.instance.remote_id, self.name)
return [i.remote_id for i in value.all()]
def field_from_activity(self, value):
items = []
for remote_id in value:
try:
validate_remote_id(remote_id)
except ValidationError:
continue
items.append(
activitypub.resolve_remote_id(self.related_model, remote_id)
)
return items
class TagField(ManyToManyField):
''' special case of many to many that uses Tags '''
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.activitypub_field = 'tag'
def field_to_activity(self, value):
tags = []
for item in value.all():
activity_type = item.__class__.__name__
if activity_type == 'User':
activity_type = 'Mention'
tags.append(activitypub.Link(
href=item.remote_id,
name=getattr(item, item.name_field),
type=activity_type
))
return tags
def field_from_activity(self, value):
if not isinstance(value, list):
return None
items = []
for link_json in value:
link = activitypub.Link(**link_json)
tag_type = link.type if link.type != 'Mention' else 'Person'
if tag_type != self.related_model.activity_serializer.type:
# tags can contain multiple types
continue
items.append(
activitypub.resolve_remote_id(self.related_model, link.href)
)
return items
def image_serializer(value):
''' helper for serializing images '''
if value and hasattr(value, 'url'):
url = value.url
else:
return None
url = 'https://%s%s' % (DOMAIN, url)
return activitypub.Image(url=url)
class ImageField(ActivitypubFieldMixin, models.ImageField):
''' activitypub-aware image field '''
# pylint: disable=arguments-differ
def set_field_from_activity(self, instance, data, save=True):
''' helper function for assinging a value to the field '''
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
return
getattr(instance, self.name).save(*formatted, save=save)
def field_to_activity(self, value):
return image_serializer(value)
def field_from_activity(self, value):
image_slug = value
# 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
try:
validate_remote_id(url)
except ValidationError:
return None
response = get_image(url)
if not response:
return None
image_name = str(uuid4()) + '.' + url.split('.')[-1]
image_content = ContentFile(response.content)
return [image_name, image_content]
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
''' activitypub-aware datetime field '''
def field_to_activity(self, value):
if not value:
return None
return value.isoformat()
def field_from_activity(self, value):
try:
date_value = dateutil.parser.parse(value)
try:
return timezone.make_aware(date_value)
except ValueError:
return date_value
except (ParserError, TypeError):
return None
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
''' activitypub-aware array field '''
def field_to_activity(self, value):
return [str(i) for i in value]
class CharField(ActivitypubFieldMixin, models.CharField):
''' activitypub-aware char field '''
class TextField(ActivitypubFieldMixin, models.TextField):
''' activitypub-aware text field '''
class BooleanField(ActivitypubFieldMixin, models.BooleanField):
''' activitypub-aware boolean field '''
class IntegerField(ActivitypubFieldMixin, models.IntegerField):
''' activitypub-aware boolean field '''

View File

@ -2,14 +2,13 @@
import re
import dateutil.parser
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.utils import timezone
from bookwyrm import books_manager
from bookwyrm.connectors import ConnectorException
from bookwyrm.models import ReadThrough, User, Book
from bookwyrm.utils.fields import JSONField
from .base_model import PrivacyLevels
from .fields import PrivacyLevels
# Mapping goodreads -> bookwyrm shelf titles.

View File

@ -2,20 +2,23 @@
from django.db import models
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, ActivityMapping, BookWyrmModel
from .base_model import ActivitypubMixin, BookWyrmModel
from . import fields
class UserRelationship(ActivitypubMixin, BookWyrmModel):
''' many-to-many through table for followers '''
user_subject = models.ForeignKey(
user_subject = fields.ForeignKey(
'User',
on_delete=models.PROTECT,
related_name='%(class)s_user_subject'
related_name='%(class)s_user_subject',
activitypub_field='actor',
)
user_object = models.ForeignKey(
user_object = fields.ForeignKey(
'User',
on_delete=models.PROTECT,
related_name='%(class)s_user_object'
related_name='%(class)s_user_object',
activitypub_field='object',
)
class Meta:
@ -32,14 +35,9 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
)
]
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user_subject'),
ActivityMapping('object', 'user_object'),
]
activity_serializer = activitypub.Follow
def get_remote_id(self, status=None):
def get_remote_id(self, status=None):# pylint: disable=arguments-differ
''' use shelf identifier in remote_id '''
status = status or 'follows'
base_path = self.user_subject.remote_id

View File

@ -3,19 +3,22 @@ import re
from django.db import models
from bookwyrm import activitypub
from .base_model import BookWyrmModel, OrderedCollectionMixin, PrivacyLevels
from .base_model import BookWyrmModel
from .base_model import OrderedCollectionMixin
from . import fields
class Shelf(OrderedCollectionMixin, BookWyrmModel):
''' a list of books owned by a user '''
name = models.CharField(max_length=100)
name = fields.CharField(max_length=100)
identifier = models.CharField(max_length=100)
user = models.ForeignKey('User', on_delete=models.PROTECT)
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='owner')
editable = models.BooleanField(default=True)
privacy = models.CharField(
privacy = fields.CharField(
max_length=255,
default='public',
choices=PrivacyLevels.choices
choices=fields.PrivacyLevels.choices
)
books = models.ManyToManyField(
'Edition',
@ -50,15 +53,20 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
class ShelfBook(BookWyrmModel):
''' many to many join table for books and shelves '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
added_by = models.ForeignKey(
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object')
shelf = fields.ForeignKey(
'Shelf', on_delete=models.PROTECT, activitypub_field='target')
added_by = fields.ForeignKey(
'User',
blank=True,
null=True,
on_delete=models.PROTECT
on_delete=models.PROTECT,
activitypub_field='actor'
)
activity_serializer = activitypub.AddBook
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(

View File

@ -6,26 +6,23 @@ from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from .base_model import ActivityMapping, BookWyrmModel, PrivacyLevels
from .base_model import tag_formatter, image_attachments_formatter
from .base_model import BookWyrmModel
from . import fields
from .fields import image_serializer
class Status(OrderedCollectionPageMixin, BookWyrmModel):
''' any post, like a reply to a review, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
content = models.TextField(blank=True, null=True)
mention_users = models.ManyToManyField('User', related_name='mention_user')
mention_books = models.ManyToManyField(
'Edition', related_name='mention_book')
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
content = fields.TextField(blank=True, null=True)
mention_users = fields.TagField('User', related_name='mention_user')
mention_books = fields.TagField('Edition', related_name='mention_book')
local = models.BooleanField(default=True)
privacy = models.CharField(
max_length=255,
default='public',
choices=PrivacyLevels.choices
)
sensitive = models.BooleanField(default=False)
# the created date can't be this, because of receiving federated posts
published_date = models.DateTimeField(default=timezone.now)
privacy = fields.PrivacyField(max_length=255)
sensitive = fields.BooleanField(default=False)
# created date is different than publish date because of federated posts
published_date = fields.DateTimeField(
default=timezone.now, activitypub_field='published')
deleted = models.BooleanField(default=False)
deleted_date = models.DateTimeField(blank=True, null=True)
favorites = models.ManyToManyField(
@ -35,88 +32,25 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
through_fields=('status', 'user'),
related_name='user_favorites'
)
reply_parent = models.ForeignKey(
reply_parent = fields.ForeignKey(
'self',
null=True,
on_delete=models.PROTECT
on_delete=models.PROTECT,
activitypub_field='inReplyTo',
)
objects = InheritanceManager()
# ---- activitypub serialization settings for this model ----- #
@property
def ap_to(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()
@property
def ap_status_image(self):
''' attach a book cover, if relevent '''
if hasattr(self, 'book'):
return self.book.ap_cover
if self.mention_books.first():
return self.mention_books.first().ap_cover
return None
shared_mappings = [
ActivityMapping('url', 'remote_id', lambda x: None),
ActivityMapping('id', 'remote_id'),
ActivityMapping('inReplyTo', 'reply_parent'),
ActivityMapping('published', 'published_date'),
ActivityMapping('attributedTo', 'user'),
ActivityMapping('to', 'ap_to'),
ActivityMapping('cc', 'ap_cc'),
ActivityMapping('replies', 'ap_replies'),
ActivityMapping(
'tag', 'mention_books',
lambda x: tag_formatter(x, 'title', 'Book'),
lambda x: activitypub.tag_formatter(x, 'Book')
),
ActivityMapping(
'tag', 'mention_users',
lambda x: tag_formatter(x, 'username', 'Mention'),
lambda x: activitypub.tag_formatter(x, 'Mention')
),
ActivityMapping(
'attachment', 'attachments',
lambda x: image_attachments_formatter(x.all()),
)
]
# serializing to bookwyrm 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', 'ap_pure_name'),
ActivityMapping('content', 'ap_pure_content'),
ActivityMapping('attachment', 'ap_status_image'),
]
activity_serializer = activitypub.Note
serialize_reverse_fields = [('attachments', 'attachment')]
deserialize_reverse_fields = [('attachments', 'attachment')]
#----- 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()
return cls.objects.filter(
reply_parent=status
).select_subclasses().order_by('published_date')
@property
def status_type(self):
@ -131,7 +65,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
**kwargs
)
def to_activity(self, pure=False):
def to_activity(self, pure=False):# pylint: disable=arguments-differ
''' return tombstone if the status is deleted '''
if self.deleted:
return activitypub.Tombstone(
@ -140,7 +74,24 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
deleted=self.deleted_date.isoformat(),
published=self.deleted_date.isoformat()
).serialize()
return ActivitypubMixin.to_activity(self, pure=pure)
activity = ActivitypubMixin.to_activity(self)
activity['replies'] = self.to_replies()
# "pure" serialization for non-bookwyrm instances
if pure:
activity['content'] = self.pure_content
if 'name' in activity:
activity['name'] = self.pure_name
activity['type'] = self.pure_type
activity['attachment'] = [
image_serializer(b.cover) for b in self.mention_books.all() \
if b.cover]
if hasattr(self, 'book'):
activity['attachment'].append(
image_serializer(self.book.cover)
)
return activity
def save(self, *args, **kwargs):
''' update user active time '''
@ -153,40 +104,42 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
class GeneratedNote(Status):
''' these are app-generated messages about user activity '''
@property
def ap_pure_content(self):
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
message = self.content
books = ', '.join(
'<a href="%s">"%s"</a>' % (self.book.remote_id, self.book.title) \
'<a href="%s">"%s"</a>' % (book.remote_id, book.title) \
for book in self.mention_books.all()
)
return '%s %s' % (message, books)
return '%s %s %s' % (self.user.display_name, message, books)
activity_serializer = activitypub.GeneratedNote
pure_activity_serializer = activitypub.Note
pure_type = 'Note'
class Comment(Status):
''' like a review but without a rating and transient '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
@property
def ap_pure_content(self):
def pure_content(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.remote_id, self.book.title)
activity_serializer = activitypub.Comment
pure_activity_serializer = activitypub.Note
pure_type = 'Note'
class Quotation(Status):
''' like a review but without a rating and transient '''
quote = models.TextField()
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
quote = fields.TextField()
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
@property
def ap_pure_content(self):
def pure_content(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,
@ -196,14 +149,15 @@ class Quotation(Status):
)
activity_serializer = activitypub.Quotation
pure_activity_serializer = activitypub.Note
pure_type = 'Note'
class Review(Status):
''' a book review '''
name = models.CharField(max_length=255, null=True)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
rating = models.IntegerField(
name = fields.CharField(max_length=255, null=True)
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
rating = fields.IntegerField(
default=None,
null=True,
blank=True,
@ -211,9 +165,10 @@ class Review(Status):
)
@property
def ap_pure_name(self):
def pure_name(self):
''' clarify review names for mastodon serialization '''
if self.rating:
#pylint: disable=bad-string-format-type
return 'Review of "%s" (%d stars): %s' % (
self.book.title,
self.rating,
@ -225,26 +180,21 @@ class Review(Status):
)
@property
def ap_pure_content(self):
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \
(self.book.remote_id, self.book.title)
activity_serializer = activitypub.Review
pure_activity_serializer = activitypub.Article
pure_type = 'Article'
class Favorite(ActivitypubMixin, BookWyrmModel):
''' fav'ing a post '''
user = models.ForeignKey('User', 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'),
]
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
status = fields.ForeignKey(
'Status', on_delete=models.PROTECT, activitypub_field='object')
activity_serializer = activitypub.Like
@ -254,7 +204,6 @@ class Favorite(ActivitypubMixin, BookWyrmModel):
self.user.save()
super().save(*args, **kwargs)
class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status')
@ -262,16 +211,12 @@ class Favorite(ActivitypubMixin, BookWyrmModel):
class Boost(Status):
''' boost'ing a post '''
boosted_status = models.ForeignKey(
boosted_status = fields.ForeignKey(
'Status',
on_delete=models.PROTECT,
related_name="boosters")
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'boosted_status'),
]
related_name='boosters',
activitypub_field='object',
)
activity_serializer = activitypub.Boost

View File

@ -6,13 +6,12 @@ from django.db import models
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .base_model import OrderedCollectionMixin, BookWyrmModel
from . import fields
class Tag(OrderedCollectionMixin, BookWyrmModel):
''' freeform tags for books '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
name = models.CharField(max_length=100)
name = fields.CharField(max_length=100, unique=True)
identifier = models.CharField(max_length=100)
@classmethod
@ -30,6 +29,26 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
base_path = 'https://%s' % DOMAIN
return '%s/tag/%s' % (base_path, self.identifier)
def save(self, *args, **kwargs):
''' create a url-safe lookup key for the tag '''
if not self.id:
# add identifiers to new tags
self.identifier = urllib.parse.quote_plus(self.name)
super().save(*args, **kwargs)
class UserTag(BookWyrmModel):
''' an instance of a tag on a book by a user '''
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object')
tag = fields.ForeignKey(
'Tag', on_delete=models.PROTECT, activitypub_field='target')
activity_serializer = activitypub.AddBook
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(
@ -48,13 +67,7 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
target=self.to_activity(),
).serialize()
def save(self, *args, **kwargs):
''' create a url-safe lookup key for the tag '''
if not self.id:
# add identifiers to new tags
self.identifier = urllib.parse.quote_plus(self.name)
super().save(*args, **kwargs)
class Meta:
''' unqiueness constraint '''
unique_together = ('user', 'book', 'name')
unique_together = ('user', 'book', 'tag')

View File

@ -6,44 +6,61 @@ from django.db import models
from django.dispatch import receiver
from bookwyrm import activitypub
from bookwyrm.connectors import get_data
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status
from bookwyrm.models.status import Status, Review
from bookwyrm.settings import DOMAIN
from bookwyrm.signatures import create_key_pair
from .base_model import ActivityMapping, OrderedCollectionPageMixin
from .base_model import image_formatter
from bookwyrm.tasks import app
from .base_model import OrderedCollectionPageMixin
from .base_model import ActivitypubMixin, BookWyrmModel
from .federated_server import FederatedServer
from . import fields
class User(OrderedCollectionPageMixin, AbstractUser):
''' a user who wants to read books '''
private_key = models.TextField(blank=True, null=True)
public_key = models.TextField(blank=True, null=True)
inbox = models.CharField(max_length=255, unique=True)
shared_inbox = models.CharField(max_length=255, blank=True, null=True)
username = fields.UsernameField()
key_pair = fields.OneToOneField(
'KeyPair',
on_delete=models.CASCADE,
blank=True, null=True,
activitypub_field='publicKey',
related_name='owner'
)
inbox = fields.RemoteIdField(unique=True)
shared_inbox = fields.RemoteIdField(
activitypub_field='sharedInbox',
activitypub_wrapper='endpoints',
deduplication_field=False,
null=True)
federated_server = models.ForeignKey(
'FederatedServer',
on_delete=models.PROTECT,
null=True,
blank=True,
)
outbox = models.CharField(max_length=255, unique=True)
summary = models.TextField(blank=True, null=True)
local = models.BooleanField(default=True)
bookwyrm_user = models.BooleanField(default=True)
outbox = fields.RemoteIdField(unique=True)
summary = fields.TextField(default='')
local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True)
localname = models.CharField(
max_length=255,
null=True,
unique=True
)
# name is your display name, which you can change at will
name = models.CharField(max_length=100, blank=True, null=True)
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
following = models.ManyToManyField(
name = fields.CharField(max_length=100, default='')
avatar = fields.ImageField(
upload_to='avatars/', blank=True, null=True, activitypub_field='icon')
followers = fields.ManyToManyField(
'self',
link_only=True,
symmetrical=False,
through='UserFollows',
through_fields=('user_subject', 'user_object'),
related_name='followers'
through_fields=('user_object', 'user_subject'),
related_name='following'
)
follow_requests = models.ManyToManyField(
'self',
@ -66,60 +83,20 @@ class User(OrderedCollectionPageMixin, AbstractUser):
through_fields=('user', 'status'),
related_name='favorite_statuses'
)
remote_id = models.CharField(max_length=255, null=True, unique=True)
remote_id = fields.RemoteIdField(
null=True, unique=True, activitypub_field='id')
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
last_active_date = models.DateTimeField(auto_now=True)
manually_approves_followers = models.BooleanField(default=False)
# ---- activitypub serialization settings for this model ----- #
@property
def ap_followers(self):
''' generates url for activitypub followers page '''
return '%s/followers' % self.remote_id
manually_approves_followers = fields.BooleanField(default=False)
@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,
})
def display_name(self):
''' show the cleanest version of the user's name possible '''
if self.name != '':
return self.name
return self.localname or self.username
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping(
'preferredUsername',
'username',
activity_formatter=lambda x: x.split('@')[0]
),
ActivityMapping('name', 'name'),
ActivityMapping('bookwyrmUser', 'bookwyrm_user'),
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', 'avatar'),
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):
@ -127,23 +104,23 @@ class User(OrderedCollectionPageMixin, AbstractUser):
queryset = Status.objects.filter(
user=self,
deleted=False,
).select_subclasses()
).select_subclasses().order_by('-published_date')
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, \
return self.to_ordered_collection(self.following.all(), \
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, \
return self.to_ordered_collection(self.followers.all(), \
remote_id=remote_id, id_only=True, **kwargs)
def to_activity(self, pure=False):
def to_activity(self):
''' override default AP serializer to add context object
idk if this is the best way to go about this '''
activity_object = super().to_activity()
@ -180,18 +157,53 @@ class User(OrderedCollectionPageMixin, AbstractUser):
self.inbox = '%s/inbox' % self.remote_id
self.shared_inbox = 'https://%s/inbox' % DOMAIN
self.outbox = '%s/outbox' % self.remote_id
if not self.private_key:
self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs)
class KeyPair(ActivitypubMixin, BookWyrmModel):
''' public and private keys for a user '''
private_key = models.TextField(blank=True, null=True)
public_key = fields.TextField(
blank=True, null=True, activitypub_field='publicKeyPem')
activity_serializer = activitypub.PublicKey
serialize_reverse_fields = [('owner', 'owner')]
def get_remote_id(self):
# self.owner is set by the OneToOneField on User
return '%s/#main-key' % self.owner.remote_id
def save(self, *args, **kwargs):
''' create a key pair '''
if not self.public_key:
self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs)
def to_activity(self):
''' override default AP serializer to add context object
idk if this is the best way to go about this '''
activity_object = super().to_activity()
del activity_object['@context']
del activity_object['type']
return activity_object
@receiver(models.signals.post_save, sender=User)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
''' create shelves for new users '''
if not instance.local or not created:
if not created:
return
if not instance.local:
set_remote_server.delay(instance.id)
return
instance.key_pair = KeyPair.objects.create(
remote_id='%s/#main-key' % instance.remote_id)
instance.save()
shelves = [{
'name': 'To Read',
'identifier': 'to-read',
@ -210,3 +222,54 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
user=instance,
editable=False
).save()
@app.task
def set_remote_server(user_id):
''' figure out the user's remote server in the background '''
user = User.objects.get(id=user_id)
actor_parts = urlparse(user.remote_id)
user.federated_server = \
get_or_create_remote_server(actor_parts.netloc)
user.save()
if user.bookwyrm_user:
get_remote_reviews.delay(user.outbox)
def get_or_create_remote_server(domain):
''' get info on a remote server '''
try:
return FederatedServer.objects.get(
server_name=domain
)
except FederatedServer.DoesNotExist:
pass
data = get_data('https://%s/.well-known/nodeinfo' % domain)
try:
nodeinfo_url = data.get('links')[0].get('href')
except (TypeError, KeyError):
return None
data = get_data(nodeinfo_url)
server = FederatedServer.objects.create(
server_name=domain,
application_type=data['software']['name'],
application_version=data['software']['version'],
)
return server
@app.task
def get_remote_reviews(outbox):
''' ingest reviews by a new remote bookwyrm user '''
outbox_page = outbox + '?page=true'
data = get_data(outbox_page)
# TODO: pagination?
for activity in data['orderedItems']:
if not activity['type'] == 'Review':
continue
activitypub.Review(**activity).to_model(Review)