Merge branch 'main' into progress_update
This commit is contained in:
@ -2,19 +2,22 @@
|
||||
import inspect
|
||||
import sys
|
||||
|
||||
from .book import Book, Work, Edition
|
||||
from .book import Book, Work, Edition, BookDataModel
|
||||
from .author import Author
|
||||
from .connector import Connector
|
||||
|
||||
from .shelf import Shelf, ShelfBook
|
||||
|
||||
from .status import Status, GeneratedNote, Review, Comment, Quotation
|
||||
from .status import Favorite, Boost, Notification, ReadThrough, ProgressMode, ProgressUpdate
|
||||
from .status import Boost
|
||||
from .attachment import Image
|
||||
from .favorite import Favorite
|
||||
from .notification import Notification
|
||||
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
||||
|
||||
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
|
||||
|
||||
@ -25,3 +28,6 @@ from .site import SiteSettings, SiteInvite, PasswordReset
|
||||
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||
activity_models = {c[1].activity_serializer.__name__: c[1] \
|
||||
for c in cls_members if hasattr(c[1], 'activity_serializer')}
|
||||
|
||||
status_models = [
|
||||
c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)]
|
||||
|
@ -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
|
||||
|
@ -1,50 +1,28 @@
|
||||
''' database schema for info about authors '''
|
||||
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 .book import BookDataModel
|
||||
from . import fields
|
||||
|
||||
|
||||
class Author(ActivitypubMixin, BookWyrmModel):
|
||||
class Author(BookDataModel):
|
||||
''' 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)
|
||||
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, deduplication_field=True)
|
||||
aliases = fields.ArrayField(
|
||||
models.CharField(max_length=255), blank=True, default=list
|
||||
)
|
||||
bio = models.TextField(null=True, blank=True)
|
||||
bio = fields.HtmlField(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 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
|
||||
|
@ -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 '''
|
||||
@ -42,8 +35,14 @@ class BookWyrmModel(models.Model):
|
||||
''' this is just here to provide default fields for other models '''
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
''' how to link to this object in the local app '''
|
||||
return self.get_remote_id().replace('https://%s' % DOMAIN, '')
|
||||
|
||||
|
||||
@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 +52,116 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
|
||||
instance.save()
|
||||
|
||||
|
||||
def unfurl_related_field(related_field, sort_field=None):
|
||||
''' 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.order_by(
|
||||
sort_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.get_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, sort_field in \
|
||||
self.serialize_reverse_fields:
|
||||
related_field = getattr(self, model_field_name)
|
||||
activity[activity_field_name] = \
|
||||
unfurl_related_field(related_field, sort_field)
|
||||
|
||||
if not activity.get('id'):
|
||||
activity['id'] = self.get_remote_id()
|
||||
return self.activity_serializer(**activity).serialize()
|
||||
|
||||
|
||||
def to_create_activity(self, user, **kwargs):
|
||||
''' returns the object wrapped in a Create activity '''
|
||||
activity_object = self.to_activity(pure=pure)
|
||||
activity_object = self.to_activity(**kwargs)
|
||||
|
||||
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 +175,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 +184,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 +207,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 +221,55 @@ 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()
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def to_ordered_collection_page(
|
||||
queryset, remote_id, id_only=False, page=1, **kwargs):
|
||||
''' 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 +282,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]
|
||||
|
@ -2,111 +2,104 @@
|
||||
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 '''
|
||||
class BookDataModel(ActivitypubMixin, BookWyrmModel):
|
||||
''' fields shared between editable book data (books, works, authors) '''
|
||||
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)
|
||||
sync_cover = models.BooleanField(default=True)
|
||||
last_sync_date = models.DateTimeField(default=timezone.now)
|
||||
last_edited_by = models.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, null=True)
|
||||
|
||||
class Meta:
|
||||
''' can't initialize this model, that wouldn't make sense '''
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' ensure that the remote_id is within this instance '''
|
||||
if self.id:
|
||||
self.remote_id = self.get_remote_id()
|
||||
else:
|
||||
self.origin_id = self.remote_id
|
||||
self.remote_id = None
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Book(BookDataModel):
|
||||
''' a generic book, which can mean either an edition or a work '''
|
||||
connector = models.ForeignKey(
|
||||
'Connector', on_delete=models.PROTECT, null=True)
|
||||
|
||||
# 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.HtmlField(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')
|
||||
# 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)
|
||||
authors = fields.ManyToManyField('Author')
|
||||
cover = fields.ImageField(
|
||||
upload_to='covers/', blank=True, null=True, alt_field='alt_text')
|
||||
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()]
|
||||
def author_text(self):
|
||||
''' format a list of authors '''
|
||||
return ', '.join(a.name for a in self.authors.all())
|
||||
|
||||
@property
|
||||
def latest_readthrough(self):
|
||||
return self.readthrough_set.order_by('-updated_date').first()
|
||||
|
||||
activity_mappings = [
|
||||
ActivityMapping('id', 'remote_id'),
|
||||
@property
|
||||
def edition_info(self):
|
||||
''' properties of this edition, as a string '''
|
||||
items = [
|
||||
self.physical_format if hasattr(self, 'physical_format') else None,
|
||||
self.languages[0] + ' language' if self.languages and \
|
||||
self.languages[0] != 'English' else None,
|
||||
str(self.published_date.year) if self.published_date else None,
|
||||
]
|
||||
return ', '.join(i for i in items if i)
|
||||
|
||||
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'),
|
||||
]
|
||||
@property
|
||||
def alt_text(self):
|
||||
''' image alt test '''
|
||||
text = '%s cover' % self.title
|
||||
if self.edition_info:
|
||||
text += ' (%s)' % self.edition_info
|
||||
return text
|
||||
|
||||
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)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_remote_id(self):
|
||||
''' editions and works both use "book" instead of model_name '''
|
||||
@ -123,47 +116,56 @@ 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
|
||||
null=True,
|
||||
load_remote=False
|
||||
)
|
||||
|
||||
@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 save(self, *args, **kwargs):
|
||||
''' set some fields on the edition object '''
|
||||
# set rank
|
||||
for edition in self.editions.all():
|
||||
edition.save()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_default_edition(self):
|
||||
''' in case the default edition is not set '''
|
||||
return self.default_edition or self.editions.order_by(
|
||||
'-edition_rank'
|
||||
).first()
|
||||
|
||||
def to_edition_list(self, **kwargs):
|
||||
''' activitypub serialization for this work's editions '''
|
||||
remote_id = self.remote_id + '/editions'
|
||||
''' an ordered collection of editions '''
|
||||
return self.to_ordered_collection(
|
||||
self.edition_set,
|
||||
remote_id=remote_id,
|
||||
self.editions.order_by('-edition_rank').all(),
|
||||
remote_id='%s/editions' % self.remote_id,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
activity_serializer = activitypub.Work
|
||||
serialize_reverse_fields = [('editions', 'editions', '-edition_rank')]
|
||||
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(
|
||||
@ -172,17 +174,42 @@ 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')
|
||||
edition_rank = fields.IntegerField(default=0)
|
||||
|
||||
activity_serializer = activitypub.Edition
|
||||
name_field = 'title'
|
||||
|
||||
@property
|
||||
def get_rank(self):
|
||||
''' calculate how complete the data is on this edition '''
|
||||
if self.parent_work and self.parent_work.default_edition == self:
|
||||
# default edition has the highest rank
|
||||
return 20
|
||||
rank = 0
|
||||
rank += int(bool(self.cover)) * 3
|
||||
rank += int(bool(self.isbn_13))
|
||||
rank += int(bool(self.isbn_10))
|
||||
rank += int(bool(self.oclc_number))
|
||||
rank += int(bool(self.pages))
|
||||
rank += int(bool(self.physical_format))
|
||||
rank += int(bool(self.description))
|
||||
# max rank is 9
|
||||
return rank
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' calculate isbn 10/13 '''
|
||||
''' set some fields on the edition object '''
|
||||
# calculate isbn 10/13
|
||||
if self.isbn_13 and self.isbn_13[:3] == '978' and not self.isbn_10:
|
||||
self.isbn_10 = isbn_13_to_10(self.isbn_13)
|
||||
if self.isbn_10 and not self.isbn_13:
|
||||
self.isbn_13 = isbn_10_to_13(self.isbn_10)
|
||||
|
||||
# set rank
|
||||
self.edition_rank = self.get_rank
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
26
bookwyrm/models/favorite.py
Normal file
26
bookwyrm/models/favorite.py
Normal file
@ -0,0 +1,26 @@
|
||||
''' like/fav/star a status '''
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from . import fields
|
||||
|
||||
class Favorite(ActivitypubMixin, BookWyrmModel):
|
||||
''' fav'ing a post '''
|
||||
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
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
''' can't fav things twice '''
|
||||
unique_together = ('user', 'status')
|
427
bookwyrm/models/fields.py
Normal file
427
bookwyrm/models/fields.py
Normal file
@ -0,0 +1,427 @@
|
||||
''' 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.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.connectors import get_image
|
||||
from bookwyrm.sanitize_html import InputHtmlParser
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
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},
|
||||
)
|
||||
|
||||
|
||||
def validate_localname(value):
|
||||
''' make sure localnames look okay '''
|
||||
if not re.match(r'^[A-Za-z\-_\.0-9]+$', value):
|
||||
raise ValidationError(
|
||||
_('%(value)s is not a valid username'),
|
||||
params={'value': value},
|
||||
)
|
||||
|
||||
|
||||
def validate_username(value):
|
||||
''' make sure usernames look okay '''
|
||||
if not re.match(r'^[A-Za-z\-_\.0-9]+@[A-Za-z\-_\.0-9]+\.[a-z]{2,}$', value):
|
||||
raise ValidationError(
|
||||
_('%(value)s is not a valid username'),
|
||||
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 '''
|
||||
try:
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
except AttributeError:
|
||||
# masssively hack-y workaround for boosts
|
||||
if self.get_activitypub_field() != 'attributedTo':
|
||||
raise
|
||||
value = getattr(data, 'actor')
|
||||
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()
|
||||
# TODO: surely there's a better way
|
||||
if instance.__class__.__name__ == 'Boost' and key == 'attributedTo':
|
||||
key = 'actor'
|
||||
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 __init__(self, *args, load_remote=True, **kwargs):
|
||||
self.load_remote = load_remote
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def field_from_activity(self, value):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
related_model = self.related_model
|
||||
if isinstance(value, dict) and value.get('id'):
|
||||
if not self.load_remote:
|
||||
# only look in the local database
|
||||
return related_model.find_existing(value)
|
||||
# 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
|
||||
if not self.load_remote:
|
||||
# only look in the local database
|
||||
return related_model.find_existing_by_remote_id(value)
|
||||
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', **kwargs):
|
||||
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=[validate_username],
|
||||
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 = []
|
||||
if value is None or value is MISSING:
|
||||
return []
|
||||
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 == 'Book':
|
||||
tag_type = 'Edition'
|
||||
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, alt):
|
||||
''' 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, name=alt)
|
||||
|
||||
|
||||
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||
''' activitypub-aware image field '''
|
||||
def __init__(self, *args, alt_field=None, **kwargs):
|
||||
self.alt_field = alt_field
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# 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 set_activity_from_field(self, activity, instance):
|
||||
value = getattr(instance, self.name)
|
||||
if value is None:
|
||||
return
|
||||
alt_text = getattr(instance, self.alt_field)
|
||||
formatted = self.field_to_activity(value, alt_text)
|
||||
|
||||
key = self.get_activitypub_field()
|
||||
activity[key] = formatted
|
||||
|
||||
|
||||
def field_to_activity(self, value, alt=None):
|
||||
return image_serializer(value, alt)
|
||||
|
||||
|
||||
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 HtmlField(ActivitypubFieldMixin, models.TextField):
|
||||
''' a text field for storing html '''
|
||||
def field_from_activity(self, value):
|
||||
if not value or value == MISSING:
|
||||
return None
|
||||
sanitizer = InputHtmlParser()
|
||||
sanitizer.feed(value)
|
||||
return sanitizer.get_output()
|
||||
|
||||
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 '''
|
@ -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.connectors import connector_manager
|
||||
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.
|
||||
@ -43,6 +42,7 @@ class ImportJob(models.Model):
|
||||
created_date = models.DateTimeField(default=timezone.now)
|
||||
task_id = models.CharField(max_length=100, null=True)
|
||||
include_reviews = models.BooleanField(default=True)
|
||||
complete = models.BooleanField(default=False)
|
||||
privacy = models.CharField(
|
||||
max_length=255,
|
||||
default='public',
|
||||
@ -72,12 +72,12 @@ class ImportItem(models.Model):
|
||||
|
||||
def get_book_from_isbn(self):
|
||||
''' search by isbn '''
|
||||
search_result = books_manager.first_search_result(
|
||||
search_result = connector_manager.first_search_result(
|
||||
self.isbn, min_confidence=0.999
|
||||
)
|
||||
if search_result:
|
||||
# raises ConnectorException
|
||||
return books_manager.get_or_create_book(search_result.key)
|
||||
return search_result.connector.get_or_create_book(search_result.key)
|
||||
return None
|
||||
|
||||
|
||||
@ -87,12 +87,12 @@ class ImportItem(models.Model):
|
||||
self.data['Title'],
|
||||
self.data['Author']
|
||||
)
|
||||
search_result = books_manager.first_search_result(
|
||||
search_result = connector_manager.first_search_result(
|
||||
search_term, min_confidence=0.999
|
||||
)
|
||||
if search_result:
|
||||
# raises ConnectorException
|
||||
return books_manager.get_or_create_book(search_result.key)
|
||||
return search_result.connector.get_or_create_book(search_result.key)
|
||||
return None
|
||||
|
||||
|
||||
|
33
bookwyrm/models/notification.py
Normal file
33
bookwyrm/models/notification.py
Normal file
@ -0,0 +1,33 @@
|
||||
''' alert a user to activity '''
|
||||
from django.db import models
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
|
||||
NotificationType = models.TextChoices(
|
||||
'NotificationType',
|
||||
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
|
||||
|
||||
class Notification(BookWyrmModel):
|
||||
''' you've been tagged, liked, followed, etc '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
related_book = models.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, null=True)
|
||||
related_user = models.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.PROTECT, null=True, related_name='related_user')
|
||||
related_status = models.ForeignKey(
|
||||
'Status', on_delete=models.PROTECT, null=True)
|
||||
related_import = models.ForeignKey(
|
||||
'ImportJob', on_delete=models.PROTECT, null=True)
|
||||
read = models.BooleanField(default=False)
|
||||
notification_type = models.CharField(
|
||||
max_length=255, choices=NotificationType.choices)
|
||||
|
||||
class Meta:
|
||||
''' checks if notifcation is in enum list for valid types '''
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=models.Q(notification_type__in=NotificationType.values),
|
||||
name="notification_type_valid",
|
||||
)
|
||||
]
|
56
bookwyrm/models/readthrough.py
Normal file
56
bookwyrm/models/readthrough.py
Normal file
@ -0,0 +1,56 @@
|
||||
''' progress in a book '''
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
class ProgressMode(models.TextChoices):
|
||||
PAGE = 'PG', 'page'
|
||||
PERCENT = 'PCT', 'percent'
|
||||
|
||||
class ReadThrough(BookWyrmModel):
|
||||
''' Store a read through a book in the database. '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||
progress = models.IntegerField(
|
||||
null=True,
|
||||
blank=True)
|
||||
progress_mode = models.CharField(
|
||||
max_length=3,
|
||||
choices=ProgressMode.choices,
|
||||
default=ProgressMode.PAGE)
|
||||
start_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
finish_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def create_update(self):
|
||||
if self.progress:
|
||||
return self.progressupdate_set.create(
|
||||
user=self.user,
|
||||
progress=self.progress,
|
||||
mode=self.progress_mode)
|
||||
|
||||
class ProgressUpdate(BookWyrmModel):
|
||||
''' Store progress through a book in the database. '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
readthrough = models.ForeignKey('ReadThrough', on_delete=models.PROTECT)
|
||||
progress = models.IntegerField()
|
||||
mode = models.CharField(
|
||||
max_length=3,
|
||||
choices=ProgressMode.choices,
|
||||
default=ProgressMode.PAGE)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
@ -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
|
||||
@ -56,7 +54,7 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
|
||||
|
||||
|
||||
def to_reject_activity(self):
|
||||
''' generate an Accept for this follow request '''
|
||||
''' generate a Reject for this follow request '''
|
||||
return activitypub.Reject(
|
||||
id=self.get_remote_id(status='rejects'),
|
||||
actor=self.user_object.remote_id,
|
||||
|
@ -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 ActivitypubMixin, 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',
|
||||
@ -36,7 +39,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
''' list of books for this shelf, overrides OrderedCollectionMixin '''
|
||||
return self.books
|
||||
return self.books.all()
|
||||
|
||||
def get_remote_id(self):
|
||||
''' shelf identifier instead of id '''
|
||||
@ -48,17 +51,22 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||
unique_together = ('user', 'identifier')
|
||||
|
||||
|
||||
class ShelfBook(BookWyrmModel):
|
||||
class ShelfBook(ActivitypubMixin, 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(
|
||||
|
@ -12,11 +12,27 @@ from .user import User
|
||||
class SiteSettings(models.Model):
|
||||
''' customized settings for this instance '''
|
||||
name = models.CharField(default='BookWyrm', max_length=100)
|
||||
instance_tagline = models.CharField(
|
||||
max_length=150, default='Social Reading and Reviewing')
|
||||
instance_description = models.TextField(
|
||||
default="This instance has no description.")
|
||||
default='This instance has no description.')
|
||||
registration_closed_text = models.TextField(
|
||||
default='Contact an administrator to get an invite')
|
||||
code_of_conduct = models.TextField(
|
||||
default="Add a code of conduct here.")
|
||||
default='Add a code of conduct here.')
|
||||
allow_registration = models.BooleanField(default=True)
|
||||
logo = models.ImageField(
|
||||
upload_to='logos/', null=True, blank=True
|
||||
)
|
||||
logo_small = models.ImageField(
|
||||
upload_to='logos/', null=True, blank=True
|
||||
)
|
||||
favicon = models.ImageField(
|
||||
upload_to='logos/', null=True, blank=True
|
||||
)
|
||||
support_link = models.CharField(max_length=255, null=True, blank=True)
|
||||
support_title = models.CharField(max_length=100, null=True, blank=True)
|
||||
admin_email = models.EmailField(max_length=255, null=True, blank=True)
|
||||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
@ -34,6 +50,7 @@ def new_access_code():
|
||||
|
||||
class SiteInvite(models.Model):
|
||||
''' gives someone access to create an account on the instance '''
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
code = models.CharField(max_length=32, default=new_access_code)
|
||||
expiry = models.DateTimeField(blank=True, null=True)
|
||||
use_limit = models.IntegerField(blank=True, null=True)
|
||||
@ -49,7 +66,7 @@ class SiteInvite(models.Model):
|
||||
@property
|
||||
def link(self):
|
||||
''' formats the invite link '''
|
||||
return "https://{}/invite/{}".format(DOMAIN, self.code)
|
||||
return 'https://{}/invite/{}'.format(DOMAIN, self.code)
|
||||
|
||||
|
||||
def get_passowrd_reset_expiry():
|
||||
@ -71,4 +88,4 @@ class PasswordReset(models.Model):
|
||||
@property
|
||||
def link(self):
|
||||
''' formats the invite link '''
|
||||
return "https://{}/password-reset/{}".format(DOMAIN, self.code)
|
||||
return 'https://{}/password-reset/{}'.format(DOMAIN, self.code)
|
||||
|
@ -1,31 +1,34 @@
|
||||
''' models for storing different kinds of Activities '''
|
||||
from django.utils import timezone
|
||||
from dataclasses import MISSING
|
||||
import re
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
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.HtmlField(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)
|
||||
content_warning = fields.CharField(
|
||||
max_length=500, blank=True, null=True, activitypub_field='summary')
|
||||
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,94 +38,57 @@ 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', 'id')]
|
||||
deserialize_reverse_fields = [('attachments', 'attachment')]
|
||||
|
||||
@classmethod
|
||||
def ignore_activity(cls, activity):
|
||||
''' keep notes if they are replies to existing statuses '''
|
||||
if activity.type != 'Note':
|
||||
return False
|
||||
if cls.objects.filter(
|
||||
remote_id=activity.inReplyTo).exists():
|
||||
return False
|
||||
|
||||
# keep notes if they mention local users
|
||||
if activity.tag == MISSING or activity.tag is None:
|
||||
return True
|
||||
tags = [l['href'] for l in activity.tag if l['type'] == 'Mention']
|
||||
for tag in tags:
|
||||
user_model = apps.get_model('bookwyrm.User', require_ready=True)
|
||||
if user_model.objects.filter(
|
||||
remote_id=tag, local=True).exists():
|
||||
# we found a mention of a known use boost
|
||||
return False
|
||||
return True
|
||||
|
||||
#----- 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):
|
||||
''' expose the type of status for the ui using activity type '''
|
||||
return self.activity_serializer.__name__
|
||||
|
||||
@property
|
||||
def boostable(self):
|
||||
''' you can't boost dms '''
|
||||
return self.privacy in ['unlisted', 'public']
|
||||
|
||||
def to_replies(self, **kwargs):
|
||||
''' helper function for loading AP serialized replies to a status '''
|
||||
return self.to_ordered_collection(
|
||||
@ -131,7 +97,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 +106,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 and hasattr(self, 'pure_content'):
|
||||
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, b.alt_text) \
|
||||
for b in self.mention_books.all()[:4] if b.cover]
|
||||
if hasattr(self, 'book') and self.book.cover:
|
||||
activity['attachment'].append(
|
||||
image_serializer(self.book.cover, self.book.alt_text)
|
||||
)
|
||||
return activity
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
@ -153,57 +136,62 @@ 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)
|
||||
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % \
|
||||
(self.content, 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.HtmlField()
|
||||
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,
|
||||
quote = re.sub(r'^<p>', '<p>"', self.quote)
|
||||
quote = re.sub(r'</p>$', '"</p>', quote)
|
||||
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
|
||||
quote,
|
||||
self.book.remote_id,
|
||||
self.book.title,
|
||||
self.content,
|
||||
)
|
||||
|
||||
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 +199,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,139 +214,37 @@ 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)
|
||||
return self.content
|
||||
|
||||
activity_serializer = activitypub.Review
|
||||
pure_activity_serializer = activitypub.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'),
|
||||
]
|
||||
|
||||
activity_serializer = activitypub.Like
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Meta:
|
||||
''' can't fav things twice '''
|
||||
unique_together = ('user', 'status')
|
||||
pure_type = 'Article'
|
||||
|
||||
|
||||
class Boost(Status):
|
||||
''' boost'ing a post '''
|
||||
boosted_status = models.ForeignKey(
|
||||
boosted_status = fields.ForeignKey(
|
||||
'Status',
|
||||
on_delete=models.PROTECT,
|
||||
related_name="boosters")
|
||||
related_name='boosters',
|
||||
activitypub_field='object',
|
||||
)
|
||||
|
||||
activity_mappings = [
|
||||
ActivityMapping('id', 'remote_id'),
|
||||
ActivityMapping('actor', 'user'),
|
||||
ActivityMapping('object', 'boosted_status'),
|
||||
]
|
||||
def __init__(self, *args, **kwargs):
|
||||
''' the user field is "actor" here instead of "attributedTo" '''
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
reserve_fields = ['user', 'boosted_status']
|
||||
self.simple_fields = [f for f in self.simple_fields if \
|
||||
f.name in reserve_fields]
|
||||
self.activity_fields = self.simple_fields
|
||||
self.many_to_many_fields = []
|
||||
self.image_fields = []
|
||||
self.deserialize_reverse_fields = []
|
||||
|
||||
activity_serializer = activitypub.Boost
|
||||
|
||||
# This constraint can't work as it would cross tables.
|
||||
# class Meta:
|
||||
# unique_together = ('user', 'boosted_status')
|
||||
|
||||
|
||||
class ProgressMode(models.TextChoices):
|
||||
PAGE = 'PG', 'page'
|
||||
PERCENT = 'PCT', 'percent'
|
||||
|
||||
class ReadThrough(BookWyrmModel):
|
||||
''' Store a read through a book in the database. '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
book = models.ForeignKey('Book', on_delete=models.PROTECT)
|
||||
progress = models.IntegerField(
|
||||
null=True,
|
||||
blank=True)
|
||||
progress_mode = models.CharField(
|
||||
max_length=3,
|
||||
choices=ProgressMode.choices,
|
||||
default=ProgressMode.PAGE)
|
||||
start_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
finish_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def create_update(self):
|
||||
if self.progress:
|
||||
return self.progressupdate_set.create(
|
||||
user=self.user,
|
||||
progress=self.progress,
|
||||
mode=self.progress_mode)
|
||||
|
||||
|
||||
class ProgressUpdate(BookWyrmModel):
|
||||
''' Store progress through a book in the database. '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
readthrough = models.ForeignKey('ReadThrough', on_delete=models.PROTECT)
|
||||
progress = models.IntegerField()
|
||||
mode = models.CharField(
|
||||
max_length=3,
|
||||
choices=ProgressMode.choices,
|
||||
default=ProgressMode.PAGE)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
NotificationType = models.TextChoices(
|
||||
'NotificationType',
|
||||
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
|
||||
|
||||
class Notification(BookWyrmModel):
|
||||
''' you've been tagged, liked, followed, etc '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
related_book = models.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, null=True)
|
||||
related_user = models.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.PROTECT, null=True, related_name='related_user')
|
||||
related_status = models.ForeignKey(
|
||||
'Status', on_delete=models.PROTECT, null=True)
|
||||
related_import = models.ForeignKey(
|
||||
'ImportJob', on_delete=models.PROTECT, null=True)
|
||||
read = models.BooleanField(default=False)
|
||||
notification_type = models.CharField(
|
||||
max_length=255, choices=NotificationType.choices)
|
||||
|
||||
class Meta:
|
||||
''' checks if notifcation is in enum list for valid types '''
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=models.Q(notification_type__in=NotificationType.values),
|
||||
name="notification_type_valid",
|
||||
)
|
||||
]
|
||||
|
@ -6,19 +6,20 @@ 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
|
||||
def book_queryset(cls, identifier):
|
||||
''' county of books associated with this tag '''
|
||||
return cls.objects.filter(identifier=identifier)
|
||||
return cls.objects.filter(
|
||||
identifier=identifier
|
||||
).order_by('-updated_date')
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
@ -30,6 +31,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(
|
||||
@ -45,16 +66,10 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
|
||||
id='%s#remove' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.book.to_activity(),
|
||||
target=self.to_activity(),
|
||||
target=self.remote_id,
|
||||
).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')
|
||||
|
@ -1,49 +1,71 @@
|
||||
''' database schema for user data '''
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
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 bookwyrm.utils import regex
|
||||
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.HtmlField(null=True, blank=True)
|
||||
local = models.BooleanField(default=False)
|
||||
bookwyrm_user = fields.BooleanField(default=True)
|
||||
localname = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True
|
||||
unique=True,
|
||||
validators=[fields.validate_localname],
|
||||
)
|
||||
# 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, null=True, blank=True)
|
||||
avatar = fields.ImageField(
|
||||
upload_to='avatars/', blank=True, null=True,
|
||||
activitypub_field='icon', alt_field='alt_text')
|
||||
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,84 +88,69 @@ 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)
|
||||
manually_approves_followers = fields.BooleanField(default=False)
|
||||
|
||||
# ---- activitypub serialization settings for this model ----- #
|
||||
name_field = 'username'
|
||||
@property
|
||||
def ap_followers(self):
|
||||
''' generates url for activitypub followers page '''
|
||||
return '%s/followers' % self.remote_id
|
||||
def alt_text(self):
|
||||
''' alt text with username '''
|
||||
return 'avatar for %s' % (self.localname or self.username)
|
||||
|
||||
@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 and 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):
|
||||
def to_outbox(self, filter_type=None, **kwargs):
|
||||
''' an ordered collection of statuses '''
|
||||
queryset = Status.objects.filter(
|
||||
if filter_type:
|
||||
filter_class = apps.get_model(
|
||||
'bookwyrm.%s' % filter_type, require_ready=True)
|
||||
if not issubclass(filter_class, Status):
|
||||
raise TypeError(
|
||||
'filter_status_class must be a subclass of models.Status')
|
||||
queryset = filter_class.objects
|
||||
else:
|
||||
queryset = Status.objects
|
||||
|
||||
queryset = queryset.filter(
|
||||
user=self,
|
||||
deleted=False,
|
||||
).select_subclasses()
|
||||
privacy__in=['public', 'unlisted'],
|
||||
).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, \
|
||||
remote_id=remote_id, id_only=True, **kwargs)
|
||||
return self.to_ordered_collection(
|
||||
self.following.order_by('-updated_date').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, \
|
||||
remote_id=remote_id, id_only=True, **kwargs)
|
||||
return self.to_ordered_collection(
|
||||
self.followers.order_by('-updated_date').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()
|
||||
@ -163,35 +170,72 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
def save(self, *args, **kwargs):
|
||||
''' populate fields for new local users '''
|
||||
# this user already exists, no need to populate fields
|
||||
if self.id:
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
if not self.local:
|
||||
if not self.local and not re.match(regex.full_username, self.username):
|
||||
# generate a username that uses the domain (webfinger format)
|
||||
actor_parts = urlparse(self.remote_id)
|
||||
self.username = '%s@%s' % (self.username, actor_parts.netloc)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
if self.id or not self.local:
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
# populate fields for local users
|
||||
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.username)
|
||||
self.localname = self.username
|
||||
self.username = '%s@%s' % (self.username, DOMAIN)
|
||||
self.actor = self.remote_id
|
||||
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.localname)
|
||||
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)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
''' this model doesn't inherit bookwyrm model, so here we are '''
|
||||
return '/user/%s' % (self.localname or self.username)
|
||||
|
||||
|
||||
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', 'id')]
|
||||
|
||||
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 +254,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&type=Review'
|
||||
data = get_data(outbox_page)
|
||||
|
||||
# TODO: pagination?
|
||||
for activity in data['orderedItems']:
|
||||
if not activity['type'] == 'Review':
|
||||
continue
|
||||
activitypub.Review(**activity).to_model(Review)
|
||||
|
Reference in New Issue
Block a user