diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 3ebf2fab..4bbb5e9f 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -93,7 +93,10 @@ class ActivityObject: with transaction.atomic(): # we can't set many to many and reverse fields on an unsaved object try: - instance.save() + try: + instance.save(broadcast=False) + except TypeError: + instance.save() except IntegrityError as e: raise ActivitySerializerError(e) diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index 72fbe5fc..1e0bdcb7 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -18,7 +18,7 @@ class Note(ActivityObject): ''' Note activity ''' published: str attributedTo: str - content: str + content: str = '' to: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: []) replies: Dict = field(default_factory=lambda: {}) diff --git a/bookwyrm/broadcast.py b/bookwyrm/broadcast.py deleted file mode 100644 index f4186c4d..00000000 --- a/bookwyrm/broadcast.py +++ /dev/null @@ -1,87 +0,0 @@ -''' send out activitypub messages ''' -import json -from django.utils.http import http_date -import requests - -from bookwyrm import models, settings -from bookwyrm.activitypub import ActivityEncoder -from bookwyrm.tasks import app -from bookwyrm.signatures import make_signature, make_digest - - -def get_public_recipients(user, software=None): - ''' everybody and their public inboxes ''' - followers = user.followers.filter(local=False) - if software: - followers = followers.filter(bookwyrm_user=(software == 'bookwyrm')) - - # we want shared inboxes when available - shared = followers.filter( - shared_inbox__isnull=False - ).values_list('shared_inbox', flat=True).distinct() - - # if a user doesn't have a shared inbox, we need their personal inbox - # iirc pixelfed doesn't have shared inboxes - inboxes = followers.filter( - shared_inbox__isnull=True - ).values_list('inbox', flat=True) - - return list(shared) + list(inboxes) - - -def broadcast(sender, activity, software=None, \ - privacy='public', direct_recipients=None): - ''' send out an event ''' - # start with parsing the direct recipients - recipients = [u.inbox for u in direct_recipients or []] - # and then add any other recipients - if privacy == 'public': - recipients += get_public_recipients(sender, software=software) - broadcast_task.delay( - sender.id, - json.dumps(activity, cls=ActivityEncoder), - recipients - ) - - -@app.task -def broadcast_task(sender_id, activity, recipients): - ''' the celery task for broadcast ''' - sender = models.User.objects.get(id=sender_id) - errors = [] - for recipient in recipients: - try: - sign_and_send(sender, activity, recipient) - except requests.exceptions.HTTPError as e: - errors.append({ - 'error': str(e), - 'recipient': recipient, - 'activity': activity, - }) - return errors - - -def sign_and_send(sender, data, destination): - ''' crpyto whatever and http junk ''' - now = http_date() - - if not sender.key_pair.private_key: - # this shouldn't happen. it would be bad if it happened. - raise ValueError('No private key found for sender') - - digest = make_digest(data) - - response = requests.post( - destination, - data=data, - headers={ - 'Date': now, - 'Digest': digest, - 'Signature': make_signature(sender, destination, now, digest), - 'Content-Type': 'application/activity+json; charset=utf-8', - 'User-Agent': settings.USER_AGENT, - }, - ) - if not response.ok: - response.raise_for_status() - return response diff --git a/bookwyrm/goodreads_import.py b/bookwyrm/goodreads_import.py index b19994ed..3dcdc2f0 100644 --- a/bookwyrm/goodreads_import.py +++ b/bookwyrm/goodreads_import.py @@ -3,7 +3,6 @@ import csv import logging from bookwyrm import models -from bookwyrm.broadcast import broadcast from bookwyrm.models import ImportJob, ImportItem from bookwyrm.status import create_notification from bookwyrm.tasks import app @@ -82,7 +81,7 @@ def handle_imported_book(user, item, include_reviews, privacy): return existing_shelf = models.ShelfBook.objects.filter( - book=item.book, added_by=user).exists() + book=item.book, user=user).exists() # shelve the book if it hasn't been shelved already if item.shelf and not existing_shelf: @@ -90,9 +89,8 @@ def handle_imported_book(user, item, include_reviews, privacy): identifier=item.shelf, user=user ) - shelf_book = models.ShelfBook.objects.create( - book=item.book, shelf=desired_shelf, added_by=user) - broadcast(user, shelf_book.to_add_activity(user), privacy=privacy) + models.ShelfBook.objects.create( + book=item.book, shelf=desired_shelf, user=user) for read in item.reads: # check for an existing readthrough with the same dates @@ -114,7 +112,7 @@ def handle_imported_book(user, item, include_reviews, privacy): # we don't know the publication date of the review, # but "now" is a bad guess published_date_guess = item.date_read or item.date_added - review = models.Review.objects.create( + models.Review.objects.create( user=user, book=item.book, name=review_title, @@ -123,6 +121,3 @@ def handle_imported_book(user, item, include_reviews, privacy): published_date=published_date_guess, privacy=privacy, ) - # we don't need to send out pure activities because non-bookwyrm - # instances don't need this data - broadcast(user, review.to_create_activity(user), privacy=privacy) diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 103b24fc..562225e7 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -9,7 +9,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST import requests -from bookwyrm import activitypub, models, views +from bookwyrm import activitypub, models from bookwyrm import status as status_builder from bookwyrm.tasks import app from bookwyrm.signatures import Signature @@ -144,7 +144,7 @@ def handle_follow(activity): related_user=relationship.user_subject ) if not manually_approves: - views.handle_accept(relationship) + relationship.accept() @app.task diff --git a/bookwyrm/migrations/0017_auto_20201130_1819.py b/bookwyrm/migrations/0017_auto_20201130_1819.py index ce9f1cc7..0775269b 100644 --- a/bookwyrm/migrations/0017_auto_20201130_1819.py +++ b/bookwyrm/migrations/0017_auto_20201130_1819.py @@ -1,6 +1,6 @@ # Generated by Django 3.0.7 on 2020-11-30 18:19 -import bookwyrm.models.base_model +import bookwyrm.models.activitypub_mixin import bookwyrm.models.fields from django.conf import settings from django.db import migrations, models @@ -38,7 +38,7 @@ class Migration(migrations.Migration): options={ 'abstract': False, }, - bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model), + bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model), ), migrations.AddField( model_name='user', diff --git a/bookwyrm/migrations/0041_auto_20210131_1614.py b/bookwyrm/migrations/0041_auto_20210131_1614.py index 8deb69a8..6fcf406b 100644 --- a/bookwyrm/migrations/0041_auto_20210131_1614.py +++ b/bookwyrm/migrations/0041_auto_20210131_1614.py @@ -1,6 +1,6 @@ # Generated by Django 3.0.7 on 2021-01-31 16:14 -import bookwyrm.models.base_model +import bookwyrm.models.activitypub_mixin import bookwyrm.models.fields from django.conf import settings from django.db import migrations, models @@ -29,7 +29,7 @@ class Migration(migrations.Migration): options={ 'abstract': False, }, - bases=(bookwyrm.models.base_model.OrderedCollectionMixin, models.Model), + bases=(bookwyrm.models.activitypub_mixin.OrderedCollectionMixin, models.Model), ), migrations.CreateModel( name='ListItem', @@ -50,7 +50,7 @@ class Migration(migrations.Migration): 'ordering': ('-created_date',), 'unique_together': {('book', 'book_list')}, }, - bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model), + bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model), ), migrations.AddField( model_name='list', diff --git a/bookwyrm/migrations/0043_auto_20210204_2223.py b/bookwyrm/migrations/0043_auto_20210204_2223.py new file mode 100644 index 00000000..b9c328ea --- /dev/null +++ b/bookwyrm/migrations/0043_auto_20210204_2223.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.7 on 2021-02-04 22:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0042_auto_20210201_2108'), + ] + + operations = [ + migrations.RenameField( + model_name='listitem', + old_name='added_by', + new_name='user', + ), + migrations.RenameField( + model_name='shelfbook', + old_name='added_by', + new_name='user', + ), + ] diff --git a/bookwyrm/migrations/0044_auto_20210207_1924.py b/bookwyrm/migrations/0044_auto_20210207_1924.py new file mode 100644 index 00000000..84b17055 --- /dev/null +++ b/bookwyrm/migrations/0044_auto_20210207_1924.py @@ -0,0 +1,30 @@ +# Generated by Django 3.0.7 on 2021-02-07 19:24 + +import bookwyrm.models.fields +from django.conf import settings +from django.db import migrations +import django.db.models.deletion + +def set_user(app_registry, schema_editor): + db_alias = schema_editor.connection.alias + shelfbook = app_registry.get_model('bookwyrm', 'ShelfBook') + for item in shelfbook.objects.using(db_alias).filter(user__isnull=True): + item.user = item.shelf.user + item.save(broadcast=False) + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0043_auto_20210204_2223'), + ] + + operations = [ + migrations.RunPython(set_user, lambda x, y: None), + migrations.AlterField( + model_name='shelfbook', + name='user', + field=bookwyrm.models.fields.ForeignKey(default=2, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py new file mode 100644 index 00000000..84293725 --- /dev/null +++ b/bookwyrm/models/activitypub_mixin.py @@ -0,0 +1,497 @@ +''' activitypub model functionality ''' +from base64 import b64encode +from functools import reduce +import json +import operator +import logging +from uuid import uuid4 +import requests + +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 +from Crypto.Hash import SHA256 +from django.apps import apps +from django.core.paginator import Paginator +from django.db.models import Q +from django.utils.http import http_date + +from bookwyrm import activitypub +from bookwyrm.settings import USER_AGENT, PAGE_LENGTH +from bookwyrm.signatures import make_signature, make_digest +from bookwyrm.tasks import app +from bookwyrm.models.fields import ImageField, ManyToManyField + +logger = logging.getLogger(__name__) +# I tried to separate these classes into mutliple files but I kept getting +# circular import errors so I gave up. I'm sure it could be done though! +class ActivitypubMixin: + ''' add this mixin for models that are AP serializable ''' + activity_serializer = lambda: {} + reverse_unfurl = False + + def __init__(self, *args, **kwargs): + ''' collect some info on model fields ''' + self.image_fields = [] + self.many_to_many_fields = [] + self.simple_fields = [] # "simple" + # sort model fields by type + for field in self._meta.get_fields(): + if not hasattr(field, 'field_to_activity'): + continue + + if isinstance(field, ImageField): + self.image_fields.append(field) + elif isinstance(field, ManyToManyField): + self.many_to_many_fields.append(field) + else: + self.simple_fields.append(field) + + # a list of allll the serializable fields + self.activity_fields = self.image_fields + \ + self.many_to_many_fields + self.simple_fields + + # these are separate to avoid infinite recursion issues + 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) + + + @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 = [] + # grabs all the data from the model to create django queryset 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, sorry for the dense syntax + match = objects.filter( + reduce(operator.or_, (Q(**f) for f in filters)) + ) + # there OUGHT to be only one match + return match.first() + + + def broadcast(self, activity, sender, software=None): + ''' send out an activity ''' + broadcast_task.delay( + sender.id, + json.dumps(activity, cls=activitypub.ActivityEncoder), + self.get_recipients(software=software) + ) + + + def get_recipients(self, software=None): + ''' figure out which inbox urls to post to ''' + # first we have to figure out who should receive this activity + privacy = self.privacy if hasattr(self, 'privacy') else 'public' + # is this activity owned by a user (statuses, lists, shelves), or is it + # general to the instance (like books) + user = self.user if hasattr(self, 'user') else None + user_model = apps.get_model('bookwyrm.User', require_ready=True) + if not user and isinstance(self, user_model): + # or maybe the thing itself is a user + user = self + # find anyone who's tagged in a status, for example + mentions = self.recipients if hasattr(self, 'recipients') else [] + + # we always send activities to explicitly mentioned users' inboxes + recipients = [u.inbox for u in mentions or []] + + # unless it's a dm, all the followers should receive the activity + if privacy != 'direct': + # we will send this out to a subset of all remote users + queryset = user_model.objects.filter( + local=False, + ) + # filter users first by whether they're using the desired software + # this lets us send book updates only to other bw servers + if software: + queryset = queryset.filter( + bookwyrm_user=(software == 'bookwyrm') + ) + # if there's a user, we only want to send to the user's followers + if user: + queryset = queryset.filter(following=user) + + # ideally, we will send to shared inboxes for efficiency + shared_inboxes = queryset.filter( + shared_inbox__isnull=False + ).values_list('shared_inbox', flat=True).distinct() + # but not everyone has a shared inbox + inboxes = queryset.filter( + shared_inbox__isnull=True + ).values_list('inbox', flat=True) + recipients += list(shared_inboxes) + list(inboxes) + return recipients + + + def to_activity(self): + ''' convert from a model to an activity ''' + activity = generate_activity(self) + return self.activity_serializer(**activity).serialize() + + +class ObjectMixin(ActivitypubMixin): + ''' add this mixin for object models that are AP serializable ''' + def save(self, *args, created=None, **kwargs): + ''' broadcast created/updated/deleted objects as appropriate ''' + broadcast = kwargs.get('broadcast', True) + # this bonus kwarg woul cause an error in the base save method + if 'broadcast' in kwargs: + del kwargs['broadcast'] + + created = created or not bool(self.id) + # first off, we want to save normally no matter what + super().save(*args, **kwargs) + if not broadcast: + return + + # this will work for objects owned by a user (lists, shelves) + user = self.user if hasattr(self, 'user') else None + + if created: + # broadcast Create activities for objects owned by a local user + if not user or not user.local: + return + + try: + software = None + # do we have a "pure" activitypub version of this for mastodon? + if hasattr(self, 'pure_content'): + pure_activity = self.to_create_activity(user, pure=True) + self.broadcast(pure_activity, user, software='other') + software = 'bookwyrm' + # sends to BW only if we just did a pure version for masto + activity = self.to_create_activity(user) + self.broadcast(activity, user, software=software) + except KeyError: + # janky as heck, this catches the mutliple inheritence chain + # for boosts and ignores this auxilliary broadcast + return + return + + # --- updating an existing object + if not user: + # users don't have associated users, they ARE users + user_model = apps.get_model('bookwyrm.User', require_ready=True) + if isinstance(self, user_model): + user = self + # book data tracks last editor + elif hasattr(self, 'last_edited_by'): + user = self.last_edited_by + # again, if we don't know the user or they're remote, don't bother + if not user or not user.local: + return + + # is this a deletion? + if hasattr(self, 'deleted') and self.deleted: + activity = self.to_delete_activity(user) + else: + activity = self.to_update_activity(user) + self.broadcast(activity, user) + + + def to_create_activity(self, user, **kwargs): + ''' returns the object wrapped in a Create activity ''' + activity_object = self.to_activity(**kwargs) + + signature = None + create_id = self.remote_id + '/activity' + if 'content' in activity_object and activity_object['content']: + 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'))) + + signature = activitypub.Signature( + creator='%s#main-key' % user.remote_id, + created=activity_object['published'], + signatureValue=b64encode(signed_message).decode('utf8') + ) + + return activitypub.Create( + id=create_id, + actor=user.remote_id, + to=activity_object['to'], + cc=activity_object['cc'], + object=activity_object, + signature=signature, + ).serialize() + + + def to_delete_activity(self, user): + ''' notice of deletion ''' + 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=self.to_activity(), + ).serialize() + + + def to_update_activity(self, user): + ''' wrapper for Updates to an activity ''' + activity_id = '%s#update/%s' % (self.remote_id, uuid4()) + return activitypub.Update( + id=activity_id, + actor=user.remote_id, + to=['https://www.w3.org/ns/activitystreams#Public'], + object=self.to_activity() + ).serialize() + + +class OrderedCollectionPageMixin(ObjectMixin): + ''' just the paginator utilities, so you don't HAVE to + override ActivitypubMixin's to_activity (ie, for outbox) ''' + @property + def collection_remote_id(self): + ''' this can be overriden if there's a special remote id, ie outbox ''' + return self.remote_id + + + def to_ordered_collection(self, queryset, \ + remote_id=None, page=False, collection_only=False, **kwargs): + ''' an ordered collection of whatevers ''' + if not queryset.ordered: + raise RuntimeError('queryset must be ordered') + + remote_id = remote_id or self.remote_id + if page: + return to_ordered_collection_page( + queryset, remote_id, **kwargs) + + if collection_only or not hasattr(self, 'activity_serializer'): + serializer = activitypub.OrderedCollection + activity = {} + else: + serializer = self.activity_serializer + # a dict from the model fields + activity = generate_activity(self) + + if remote_id: + activity['id'] = remote_id + + paginated = Paginator(queryset, PAGE_LENGTH) + # add computed fields specific to orderd collections + activity['totalItems'] = paginated.count + activity['first'] = '%s?page=1' % remote_id + activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages) + + return serializer(**activity).serialize() + + +class OrderedCollectionMixin(OrderedCollectionPageMixin): + ''' extends activitypub models to work as ordered collections ''' + @property + def collection_queryset(self): + ''' usually an ordered collection model aggregates a different model ''' + raise NotImplementedError('Model must define collection_queryset') + + activity_serializer = activitypub.OrderedCollection + + def to_activity(self, **kwargs): + ''' an ordered collection of the specified model queryset ''' + return self.to_ordered_collection(self.collection_queryset, **kwargs) + + +class CollectionItemMixin(ActivitypubMixin): + ''' for items that are part of an (Ordered)Collection ''' + activity_serializer = activitypub.Add + object_field = collection_field = None + + def save(self, *args, broadcast=True, **kwargs): + ''' broadcast updated ''' + created = not bool(self.id) + # first off, we want to save normally no matter what + super().save(*args, **kwargs) + + # these shouldn't be edited, only created and deleted + if not broadcast or not created or not self.user.local: + return + + # adding an obj to the collection + activity = self.to_add_activity() + self.broadcast(activity, self.user) + + + def delete(self, *args, **kwargs): + ''' broadcast a remove activity ''' + activity = self.to_remove_activity() + super().delete(*args, **kwargs) + self.broadcast(activity, self.user) + + + def to_add_activity(self): + ''' AP for shelving a book''' + object_field = getattr(self, self.object_field) + collection_field = getattr(self, self.collection_field) + return activitypub.Add( + id='%s#add' % self.remote_id, + actor=self.user.remote_id, + object=object_field.to_activity(), + target=collection_field.remote_id + ).serialize() + + def to_remove_activity(self): + ''' AP for un-shelving a book''' + object_field = getattr(self, self.object_field) + collection_field = getattr(self, self.collection_field) + return activitypub.Remove( + id='%s#remove' % self.remote_id, + actor=self.user.remote_id, + object=object_field.to_activity(), + target=collection_field.remote_id + ).serialize() + + +class ActivityMixin(ActivitypubMixin): + ''' add this mixin for models that are AP serializable ''' + def save(self, *args, broadcast=True, **kwargs): + ''' broadcast activity ''' + super().save(*args, **kwargs) + user = self.user if hasattr(self, 'user') else self.user_subject + if broadcast and user.local: + self.broadcast(self.to_activity(), user) + + + def delete(self, *args, broadcast=True, **kwargs): + ''' nevermind, undo that activity ''' + user = self.user if hasattr(self, 'user') else self.user_subject + if broadcast and user.local: + self.broadcast(self.to_undo_activity(), user) + super().delete(*args, **kwargs) + + + def to_undo_activity(self): + ''' undo an action ''' + user = self.user if hasattr(self, 'user') else self.user_subject + return activitypub.Undo( + id='%s#undo' % self.remote_id, + actor=user.remote_id, + object=self.to_activity() + ).serialize() + + +def generate_activity(obj): + ''' go through the fields on an object ''' + activity = {} + for field in obj.activity_fields: + field.set_activity_from_field(activity, obj) + + if hasattr(obj, 'serialize_reverse_fields'): + # for example, editions of a work + for model_field_name, activity_field_name, sort_field in \ + obj.serialize_reverse_fields: + related_field = getattr(obj, model_field_name) + activity[activity_field_name] = \ + unfurl_related_field(related_field, sort_field) + + if not activity.get('id'): + activity['id'] = obj.get_remote_id() + return activity + + +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 + + +@app.task +def broadcast_task(sender_id, activity, recipients): + ''' the celery task for broadcast ''' + user_model = apps.get_model('bookwyrm.User', require_ready=True) + sender = user_model.objects.get(id=sender_id) + for recipient in recipients: + try: + sign_and_send(sender, activity, recipient) + except requests.exceptions.HTTPError as e: + logger.exception(e) + + +def sign_and_send(sender, data, destination): + ''' crpyto whatever and http junk ''' + now = http_date() + + if not sender.key_pair.private_key: + # this shouldn't happen. it would be bad if it happened. + raise ValueError('No private key found for sender') + + digest = make_digest(data) + + response = requests.post( + destination, + data=data, + headers={ + 'Date': now, + 'Digest': digest, + 'Signature': make_signature(sender, destination, now, digest), + 'Content-Type': 'application/activity+json; charset=utf-8', + 'User-Agent': USER_AGENT, + }, + ) + if not response.ok: + response.raise_for_status() + return response + + +# 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() diff --git a/bookwyrm/models/attachment.py b/bookwyrm/models/attachment.py index b3337e15..e3450a5a 100644 --- a/bookwyrm/models/attachment.py +++ b/bookwyrm/models/attachment.py @@ -2,7 +2,7 @@ from django.db import models from bookwyrm import activitypub -from .base_model import ActivitypubMixin +from .activitypub_mixin import ActivitypubMixin from .base_model import BookWyrmModel from . import fields diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index ba0a54be..06546c9b 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -1,20 +1,9 @@ ''' base model with default fields ''' -from base64 import b64encode -from functools import reduce -import operator -from uuid import uuid4 - -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 import Q from django.dispatch import receiver -from bookwyrm import activitypub -from bookwyrm.settings import DOMAIN, PAGE_LENGTH -from .fields import ImageField, ManyToManyField, RemoteIdField +from bookwyrm.settings import DOMAIN +from .fields import RemoteIdField class BookWyrmModel(models.Model): @@ -49,254 +38,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs): return if not instance.remote_id: instance.remote_id = instance.get_remote_id() - 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 __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 - - if isinstance(field, ImageField): - self.image_fields.append(field) - elif isinstance(field, ManyToManyField): - self.many_to_many_fields.append(field) - else: - self.simple_fields.append(field) - - 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) - - - @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 = generate_activity(self) - 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(**kwargs) - - signature = None - create_id = self.remote_id + '/activity' - if 'content' in activity_object: - 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'))) - - signature = activitypub.Signature( - creator='%s#main-key' % user.remote_id, - created=activity_object['published'], - signatureValue=b64encode(signed_message).decode('utf8') - ) - - return activitypub.Create( - id=create_id, - actor=user.remote_id, - to=activity_object['to'], - cc=activity_object['cc'], - object=activity_object, - signature=signature, - ).serialize() - - - def to_delete_activity(self, user): - ''' notice of deletion ''' - 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=self.to_activity(), - ).serialize() - - - def to_update_activity(self, user): - ''' wrapper for Updates to an activity ''' - activity_id = '%s#update/%s' % (self.remote_id, uuid4()) - return activitypub.Update( - id=activity_id, - actor=user.remote_id, - to=['https://www.w3.org/ns/activitystreams#Public'], - object=self.to_activity() - ).serialize() - - - def to_undo_activity(self, user): - ''' undo an action ''' - return activitypub.Undo( - id='%s#undo' % self.remote_id, - actor=user.remote_id, - object=self.to_activity() - ).serialize() - - -class OrderedCollectionPageMixin(ActivitypubMixin): - ''' just the paginator utilities, so you don't HAVE to - override ActivitypubMixin's to_activity (ie, for outbox ''' - @property - def collection_remote_id(self): - ''' this can be overriden if there's a special remote id, ie outbox ''' - return self.remote_id - - - def to_ordered_collection(self, queryset, \ - remote_id=None, page=False, collection_only=False, **kwargs): - ''' an ordered collection of whatevers ''' - if not queryset.ordered: - raise RuntimeError('queryset must be ordered') - - remote_id = remote_id or self.remote_id - if page: - return to_ordered_collection_page( - queryset, remote_id, **kwargs) - - if collection_only or not hasattr(self, 'activity_serializer'): - serializer = activitypub.OrderedCollection - activity = {} - else: - serializer = self.activity_serializer - # a dict from the model fields - activity = generate_activity(self) - - if remote_id: - activity['id'] = remote_id - - paginated = Paginator(queryset, PAGE_LENGTH) - # add computed fields specific to orderd collections - activity['totalItems'] = paginated.count - activity['first'] = '%s?page=1' % remote_id - activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages) - - return serializer(**activity).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 - def collection_queryset(self): - ''' usually an ordered collection model aggregates a different model ''' - raise NotImplementedError('Model must define collection_queryset') - - activity_serializer = activitypub.OrderedCollection - - def to_activity(self, **kwargs): - ''' an ordered collection of the specified model queryset ''' - return self.to_ordered_collection(self.collection_queryset, **kwargs) - - -def generate_activity(obj): - ''' go through the fields on an object ''' - activity = {} - for field in obj.activity_fields: - field.set_activity_from_field(activity, obj) - - if hasattr(obj, 'serialize_reverse_fields'): - # for example, editions of a work - for model_field_name, activity_field_name, sort_field in \ - obj.serialize_reverse_fields: - related_field = getattr(obj, model_field_name) - activity[activity_field_name] = \ - unfurl_related_field(related_field, sort_field) - - if not activity.get('id'): - activity['id'] = obj.get_remote_id() - return activity + try: + instance.save(broadcast=False) + except TypeError: + instance.save() diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index ea704977..f1f20830 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -7,11 +7,11 @@ from model_utils.managers import InheritanceManager from bookwyrm import activitypub from bookwyrm.settings import DOMAIN +from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin from .base_model import BookWyrmModel -from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from . import fields -class BookDataModel(ActivitypubMixin, BookWyrmModel): +class BookDataModel(ObjectMixin, BookWyrmModel): ''' fields shared between editable book data (books, works, authors) ''' origin_id = models.CharField(max_length=255, null=True, blank=True) openlibrary_key = fields.CharField( @@ -74,6 +74,7 @@ class Book(BookDataModel): @property def latest_readthrough(self): + ''' most recent readthrough activity ''' return self.readthrough_set.order_by('-updated_date').first() @property diff --git a/bookwyrm/models/favorite.py b/bookwyrm/models/favorite.py index 8373b016..7d630cf5 100644 --- a/bookwyrm/models/favorite.py +++ b/bookwyrm/models/favorite.py @@ -3,10 +3,11 @@ from django.db import models from django.utils import timezone from bookwyrm import activitypub -from .base_model import ActivitypubMixin, BookWyrmModel +from .activitypub_mixin import ActivityMixin +from .base_model import BookWyrmModel from . import fields -class Favorite(ActivitypubMixin, BookWyrmModel): +class Favorite(ActivityMixin, BookWyrmModel): ''' fav'ing a post ''' user = fields.ForeignKey( 'User', on_delete=models.PROTECT, activitypub_field='actor') @@ -18,7 +19,7 @@ class Favorite(ActivitypubMixin, BookWyrmModel): def save(self, *args, **kwargs): ''' update user active time ''' self.user.last_active_date = timezone.now() - self.user.save() + self.user.save(broadcast=False) super().save(*args, **kwargs) class Meta: diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index 9298920f..5004a9b0 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -3,8 +3,8 @@ from django.db import models from bookwyrm import activitypub from bookwyrm.settings import DOMAIN -from .base_model import ActivitypubMixin, BookWyrmModel -from .base_model import OrderedCollectionMixin +from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin +from .base_model import BookWyrmModel from . import fields @@ -51,13 +51,13 @@ class List(OrderedCollectionMixin, BookWyrmModel): ordering = ('-updated_date',) -class ListItem(ActivitypubMixin, BookWyrmModel): +class ListItem(CollectionItemMixin, BookWyrmModel): ''' ok ''' book = fields.ForeignKey( 'Edition', on_delete=models.PROTECT, activitypub_field='object') book_list = fields.ForeignKey( 'List', on_delete=models.CASCADE, activitypub_field='target') - added_by = fields.ForeignKey( + user = fields.ForeignKey( 'User', on_delete=models.PROTECT, activitypub_field='actor' @@ -68,24 +68,8 @@ class ListItem(ActivitypubMixin, BookWyrmModel): endorsement = models.ManyToManyField('User', related_name='endorsers') activity_serializer = activitypub.AddBook - - def to_add_activity(self, user): - ''' AP for shelving a book''' - return activitypub.Add( - id='%s#add' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.book_list.remote_id, - ).serialize() - - def to_remove_activity(self, user): - ''' AP for un-shelving a book''' - return activitypub.Remove( - id='%s#remove' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.book_list.remote_id - ).serialize() + object_field = 'book' + collection_field = 'book_list' class Meta: ''' an opinionated constraint! you can't put a book on a list twice ''' diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index 7daafaaf..2bec3a81 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -31,7 +31,7 @@ class ReadThrough(BookWyrmModel): def save(self, *args, **kwargs): ''' update user active time ''' self.user.last_active_date = timezone.now() - self.user.save() + self.user.save(broadcast=False) super().save(*args, **kwargs) def create_update(self): @@ -54,5 +54,5 @@ class ProgressUpdate(BookWyrmModel): def save(self, *args, **kwargs): ''' update user active time ''' self.user.last_active_date = timezone.now() - self.user.save() + self.user.save(broadcast=False) super().save(*args, **kwargs) diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index ec84d44f..9f3bf07d 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -1,14 +1,15 @@ ''' defines relationships between users ''' -from django.db import models +from django.db import models, transaction from django.db.models import Q from django.dispatch import receiver from bookwyrm import activitypub -from .base_model import ActivitypubMixin, BookWyrmModel +from .activitypub_mixin import ActivitypubMixin, ActivityMixin +from .base_model import BookWyrmModel from . import fields -class UserRelationship(ActivitypubMixin, BookWyrmModel): +class UserRelationship(BookWyrmModel): ''' many-to-many through table for followers ''' user_subject = fields.ForeignKey( 'User', @@ -23,6 +24,16 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel): activitypub_field='object', ) + @property + def privacy(self): + ''' all relationships are handled directly with the participants ''' + return 'direct' + + @property + def recipients(self): + ''' the remote user needs to recieve direct broadcasts ''' + return [u for u in [self.user_subject, self.user_object] if not u.local] + class Meta: ''' relationships should be unique ''' abstract = True @@ -37,8 +48,6 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel): ) ] - activity_serializer = activitypub.Follow - def get_remote_id(self, status=None):# pylint: disable=arguments-differ ''' use shelf identifier in remote_id ''' status = status or 'follows' @@ -46,55 +55,73 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel): return '%s#%s/%d' % (base_path, status, self.id) - def to_accept_activity(self): - ''' generate an Accept for this follow request ''' - return activitypub.Accept( - id=self.get_remote_id(status='accepts'), - actor=self.user_object.remote_id, - object=self.to_activity() - ).serialize() - - - def to_reject_activity(self): - ''' generate a Reject for this follow request ''' - return activitypub.Reject( - id=self.get_remote_id(status='rejects'), - actor=self.user_object.remote_id, - object=self.to_activity() - ).serialize() - - -class UserFollows(UserRelationship): +class UserFollows(ActivitypubMixin, UserRelationship): ''' Following a user ''' status = 'follows' + activity_serializer = activitypub.Follow + @classmethod def from_request(cls, follow_request): ''' converts a follow request into a follow relationship ''' - return cls( + return cls.objects.create( user_subject=follow_request.user_subject, user_object=follow_request.user_object, remote_id=follow_request.remote_id, ) -class UserFollowRequest(UserRelationship): +class UserFollowRequest(ActivitypubMixin, UserRelationship): ''' following a user requires manual or automatic confirmation ''' status = 'follow_request' + activity_serializer = activitypub.Follow - def save(self, *args, **kwargs): - ''' make sure the follow relationship doesn't already exist ''' + def save(self, *args, broadcast=True, **kwargs): + ''' make sure the follow or block relationship doesn't already exist ''' try: UserFollows.objects.get( user_subject=self.user_subject, user_object=self.user_object ) + UserBlocks.objects.get( + user_subject=self.user_subject, + user_object=self.user_object + ) return None - except UserFollows.DoesNotExist: - return super().save(*args, **kwargs) + except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist): + super().save(*args, **kwargs) + if broadcast and self.user_subject.local and not self.user_object.local: + self.broadcast(self.to_activity(), self.user_subject) -class UserBlocks(UserRelationship): + def accept(self): + ''' turn this request into the real deal''' + user = self.user_object + activity = activitypub.Accept( + id=self.get_remote_id(status='accepts'), + actor=self.user_object.remote_id, + object=self.to_activity() + ).serialize() + with transaction.atomic(): + UserFollows.from_request(self) + self.delete() + + self.broadcast(activity, user) + + + def reject(self): + ''' generate a Reject for this follow request ''' + user = self.user_object + activity = activitypub.Reject( + id=self.get_remote_id(status='rejects'), + actor=self.user_object.remote_id, + object=self.to_activity() + ).serialize() + self.delete() + self.broadcast(activity, user) + + +class UserBlocks(ActivityMixin, UserRelationship): ''' prevent another user from following you and seeing your posts ''' status = 'blocks' activity_serializer = activitypub.Block diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index ff5660dd..921b8617 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -3,8 +3,8 @@ import re from django.db import models from bookwyrm import activitypub -from .base_model import ActivitypubMixin, BookWyrmModel -from .base_model import OrderedCollectionMixin +from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin +from .base_model import BookWyrmModel from . import fields @@ -27,12 +27,11 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): def save(self, *args, **kwargs): ''' set the identifier ''' - saved = super().save(*args, **kwargs) + super().save(*args, **kwargs) if not self.identifier: slug = re.sub(r'[^\w]', '', self.name).lower() self.identifier = '%s-%d' % (slug, self.id) - return super().save(*args, **kwargs) - return saved + super().save(*args, **kwargs) @property def collection_queryset(self): @@ -49,39 +48,18 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): unique_together = ('user', 'identifier') -class ShelfBook(ActivitypubMixin, BookWyrmModel): +class ShelfBook(CollectionItemMixin, BookWyrmModel): ''' many to many join table for books and shelves ''' 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, - activitypub_field='actor' - ) + user = fields.ForeignKey( + 'User', 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( - id='%s#add' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.shelf.remote_id, - ).serialize() - - def to_remove_activity(self, user): - ''' AP for un-shelving a book''' - return activitypub.Remove( - id='%s#remove' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.shelf.to_activity() - ).serialize() + object_field = 'book' + collection_field = 'shelf' class Meta: diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 093dd773..edf60281 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -9,10 +9,12 @@ from django.utils import timezone from model_utils.managers import InheritanceManager from bookwyrm import activitypub -from .base_model import ActivitypubMixin, OrderedCollectionPageMixin +from .activitypub_mixin import ActivitypubMixin, ActivityMixin +from .activitypub_mixin import OrderedCollectionPageMixin from .base_model import BookWyrmModel -from . import fields from .fields import image_serializer +from . import fields + class Status(OrderedCollectionPageMixin, BookWyrmModel): ''' any post, like a reply to a review, etc ''' @@ -50,6 +52,15 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): serialize_reverse_fields = [('attachments', 'attachment', 'id')] deserialize_reverse_fields = [('attachments', 'attachment')] + @property + def recipients(self): + ''' tagged users who definitely need to get this status in broadcast ''' + mentions = [u for u in self.mention_users.all() if not u.local] + if hasattr(self, 'reply_parent') and self.reply_parent \ + and not self.reply_parent.user.local: + mentions.append(self.reply_parent.user) + return list(set(mentions)) + @classmethod def ignore_activity(cls, activity): ''' keep notes if they are replies to existing statuses ''' @@ -126,14 +137,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): return activity - def save(self, *args, **kwargs): - ''' update user active time ''' - if self.user.local: - self.user.last_active_date = timezone.now() - self.user.save() - return super().save(*args, **kwargs) - - class GeneratedNote(Status): ''' these are app-generated messages about user activity ''' @property @@ -223,7 +226,7 @@ class Review(Status): pure_type = 'Article' -class Boost(Status): +class Boost(ActivityMixin, Status): ''' boost'ing a post ''' boosted_status = fields.ForeignKey( 'Status', @@ -231,6 +234,8 @@ class Boost(Status): related_name='boosters', activitypub_field='object', ) + activity_serializer = activitypub.Boost + def __init__(self, *args, **kwargs): ''' the user field is "actor" here instead of "attributedTo" ''' @@ -244,8 +249,6 @@ class Boost(Status): 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') diff --git a/bookwyrm/models/tag.py b/bookwyrm/models/tag.py index 6e0ba8ab..d75f6e05 100644 --- a/bookwyrm/models/tag.py +++ b/bookwyrm/models/tag.py @@ -5,7 +5,8 @@ from django.db import models from bookwyrm import activitypub from bookwyrm.settings import DOMAIN -from .base_model import OrderedCollectionMixin, BookWyrmModel +from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin +from .base_model import BookWyrmModel from . import fields @@ -40,7 +41,7 @@ class Tag(OrderedCollectionMixin, BookWyrmModel): super().save(*args, **kwargs) -class UserTag(BookWyrmModel): +class UserTag(CollectionItemMixin, BookWyrmModel): ''' an instance of a tag on a book by a user ''' user = fields.ForeignKey( 'User', on_delete=models.PROTECT, activitypub_field='actor') @@ -50,25 +51,8 @@ class UserTag(BookWyrmModel): '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( - id='%s#add' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.remote_id, - ).serialize() - - def to_remove_activity(self, user): - ''' AP for un-shelving a book''' - return activitypub.Remove( - id='%s#remove' % self.remote_id, - actor=user.remote_id, - object=self.book.to_activity(), - target=self.remote_id, - ).serialize() - + object_field = 'book' + collection_field = 'tag' class Meta: ''' unqiueness constraint ''' diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 3fd0eaf7..da717d2e 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -17,8 +17,8 @@ from bookwyrm.settings import DOMAIN from bookwyrm.signatures import create_key_pair from bookwyrm.tasks import app from bookwyrm.utils import regex -from .base_model import OrderedCollectionPageMixin -from .base_model import ActivitypubMixin, BookWyrmModel +from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin +from .base_model import BookWyrmModel from .federated_server import FederatedServer from . import fields, Review @@ -211,6 +211,9 @@ class KeyPair(ActivitypubMixin, BookWyrmModel): def save(self, *args, **kwargs): ''' create a key pair ''' + # no broadcasting happening here + if 'broadcast' in kwargs: + del kwargs['broadcast'] if not self.public_key: self.private_key, self.public_key = create_key_pair() return super().save(*args, **kwargs) @@ -291,7 +294,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs): instance.key_pair = KeyPair.objects.create( remote_id='%s/#main-key' % instance.remote_id) - instance.save() + instance.save(broadcast=False) shelves = [{ 'name': 'To Read', @@ -310,7 +313,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs): identifier=shelf['identifier'], user=instance, editable=False - ).save() + ).save(broadcast=False) @app.task diff --git a/bookwyrm/templates/lists/curate.html b/bookwyrm/templates/lists/curate.html index 20c8175d..a7e0fe79 100644 --- a/bookwyrm/templates/lists/curate.html +++ b/bookwyrm/templates/lists/curate.html @@ -23,7 +23,7 @@ {% include 'snippets/book_titleby.html' with book=item.book %} - {% include 'snippets/username.html' with user=item.added_by %} + {% include 'snippets/username.html' with user=item.user %}
diff --git a/bookwyrm/templates/lists/list.html b/bookwyrm/templates/lists/list.html index 213f9dca..7899d593 100644 --- a/bookwyrm/templates/lists/list.html +++ b/bookwyrm/templates/lists/list.html @@ -31,9 +31,9 @@