diff --git a/README.md b/README.md index 52e5c4cf5..f07d9cefb 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ Social reading and reviewing, decentralized with ActivityPub ## Contents - - [The overall idea](#the-overall-idea) +- [Joining BookWyrm](#joining-bookwyrm) +- [The overall idea](#the-overall-idea) - [What it is and isn't](#what-it-is-and-isnt) - [The role of federation](#the-role-of-federation) - [Features](#features) @@ -13,42 +14,46 @@ Social reading and reviewing, decentralized with ActivityPub - [Book data](#book-data) - [Contributing](#contributing) +## Joining BookWyrm +BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) list. + +I, the maintianer of this project, run https://bookwyrm.social, and I generally give out invite codes to those who ask by [email](mailto:mousereeve@riseup.net), [Mastodon direct message](https://friend.camp/@tripofmice), or [Twitter direct message](https://twitter.com/tripofmice). + ## The overall idea ### What it is and isn't BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a datasource for books, but it does do both of those things to some degree. ### The role of federation -BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon and Pixelfed. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance. +BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance. -Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular type of literature, be just for use by people who are in a book club together, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks. +Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks. ### Features -Since the project is still in its early stages, not everything here is fully implemented. There is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going! +Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going! - Posting about books - Compose reviews, with or without ratings, which are aggregated in the book page - Compose other kinds of statuses about books, such as: - Comments on a book - Quotes or excerpts - - Recommenations of other books - Reply to statuses - - Aggregate reviews of a book across connected BookWyrm instances - - Differentiate local and federated reviews and rating + - View aggregate reviews of a book across connected BookWyrm instances + - Differentiate local and federated reviews and rating in your activity feed - Track reading activity - Shelve books on default "to-read," "currently reading," and "read" shelves - Create custom shelves - - Store started reading/finished reading dates + - Store started reading/finished reading dates, as well as progress updates along the way - Update followers about reading activity (optionally, and with granular privacy controls) + - Create lists of books which can be open to submissions from anyone, curated, or only edited by the creator - Federation with ActivityPub - Broadcast and receive user statuses and activity - - Broadcast copies of books that can be used as canonical data sources + - Share book data between instances to create a networked database of metadata - Identify shared books across instances and aggregate related content - Follow and interact with users across BookWyrm instances - - Inter-operate with non-BookWyrm ActivityPub services + - Inter-operate with non-BookWyrm ActivityPub services (currently, Mastodon is supported) - Granular privacy controls - - Local-only, followers-only, and public posting + - Private, followers-only, and public privacy levels for posting, shelves, and lists - Option for users to manually approve followers - Allow blocking and flagging for moderation - - Control which instances you want to federate with ## Setting up the developer environment @@ -88,6 +93,7 @@ This project is still young and isn't, at the momoment, very stable, so please p `cp .env.example .env` - Add your domain, email address, mailgun credentials - Set a secure redis password and secret key + - Set a secure database password for postgres - Update your nginx configuration in `nginx/default.conf` - Replace `your-domain.com` with your domain name - Run the application (this should also set up a Certbot ssl cert for your domain) @@ -99,6 +105,7 @@ This project is still young and isn't, at the momoment, very stable, so please p `docker-compose up -d` - Initialize the database `./bw-dev initdb` + - Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U ` and saves the backup to a safe locationgi - Congrats! You did it, go to your domain and enjoy the fruits of your labors ### Configure your instance - Register a user account in the applcation UI @@ -114,21 +121,11 @@ This project is still young and isn't, at the momoment, very stable, so please p user.is_superuser = True user.save() ``` - - Go to the admin panel (`/admin/bookwyrm/sitesettings/1/change` on your domain) and set your instance name, description, code of conduct, and toggle whether registration is open on your instance - - -## Project structure -All the url routing is in `bookwyrm/urls.py`. This includes the application views (your home page, user page, book page, etc), application endpoints (things that happen when you click buttons), and federation api endpoints (inboxes, outboxes, webfinger, etc). - -The application views and actions are in `bookwyrm/views.py`. The internal actions call api handlers which deal with federating content. Outgoing messages (any action done by a user that is federated out), as well as outboxes, live in `bookwyrm/outgoing.py`, and all handlers for incoming messages, as well as inboxes and webfinger, live in `bookwyrm/incoming.py`. Connection to openlibrary.org to get book data is handled in `bookwyrm/connectors/openlibrary.py`. ActivityPub serialization is handled in the `bookwyrm/activitypub/` directory. - -Celery is used for background tasks, which includes receiving incoming ActivityPub activities, ActivityPub broadcasting, and external data import. - -The UI is all django templates because that is the default. You can replace it with a complex javascript framework over my ~dead body~ mild objections. + - Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance ## Book data -The application is set up to get book data from arbitrary outside sources -- right now, it's only able to connect to OpenLibrary, but other connectors could be written. By default, a book is non-canonical copy of an OpenLibrary book, and will be updated with OpenLibrary if the data there changes. However, a book can edited and decoupled from its original data source, or added locally with no external data source. +The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written. There are three concepts in the book data model: - `Book`, an abstract, high-level concept that could mean either a `Work` or an `Edition`. No data is saved as a `Book`, it serves as shared model for `Work` and `Edition` diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 3ebf2fabe..4bbb5e9f3 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 72fbe5fc2..1e0bdcb77 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 f4186c4d0..000000000 --- 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/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index ab9900a7e..2cbcda47c 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -107,7 +107,7 @@ class AbstractConnector(AbstractMinimalConnector): if self.is_work_data(data): try: edition_data = self.get_edition_from_work_data(data) - except KeyError: + except (KeyError, ConnectorException): # hack: re-use the work data as the edition data # this is why remote ids aren't necessarily unique edition_data = data @@ -116,7 +116,7 @@ class AbstractConnector(AbstractMinimalConnector): try: work_data = self.get_work_from_edition_data(data) work_data = dict_from_mappings(work_data, self.book_mappings) - except KeyError: + except (KeyError, ConnectorException): work_data = mapped_data edition_data = data @@ -145,8 +145,9 @@ class AbstractConnector(AbstractMinimalConnector): edition.connector = self.connector edition.save() - work.default_edition = edition - work.save() + if not work.default_edition: + work.default_edition = edition + work.save() for author in self.get_authors_from_data(edition_data): edition.authors.add(author) @@ -210,13 +211,20 @@ def get_data(url): 'User-Agent': settings.USER_AGENT, }, ) - except (RequestError, SSLError): + except (RequestError, SSLError) as e: + logger.exception(e) raise ConnectorException() + if not resp.ok: - resp.raise_for_status() + try: + resp.raise_for_status() + except requests.exceptions.HTTPError as e: + logger.exception(e) + raise ConnectorException() try: data = resp.json() - except ValueError: + except ValueError as e: + logger.exception(e) raise ConnectorException() return data diff --git a/bookwyrm/goodreads_import.py b/bookwyrm/goodreads_import.py index b19994eda..1b2b971c7 100644 --- a/bookwyrm/goodreads_import.py +++ b/bookwyrm/goodreads_import.py @@ -3,9 +3,7 @@ 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 logger = logging.getLogger(__name__) @@ -69,7 +67,6 @@ def import_data(job_id): item.fail_reason = 'Could not find a match for book' item.save() finally: - create_notification(job.user, 'IMPORT', related_import=job) job.complete = True job.save() @@ -82,7 +79,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 +87,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 +110,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 +119,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 103b24fca..a88a748e4 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 @@ -136,15 +136,8 @@ def handle_follow(activity): ) # send the accept normally for a duplicate request - manually_approves = relationship.user_object.manually_approves_followers - - status_builder.create_notification( - relationship.user_object, - 'FOLLOW_REQUEST' if manually_approves else 'FOLLOW', - related_user=relationship.user_subject - ) - if not manually_approves: - views.handle_accept(relationship) + if not relationship.user_object.manually_approves_followers: + relationship.accept() @app.task @@ -256,27 +249,6 @@ def handle_create_status(activity): # it was discarded because it's not a bookwyrm type return - # create a notification if this is a reply - notified = [] - if status.reply_parent and status.reply_parent.user.local: - notified.append(status.reply_parent.user) - status_builder.create_notification( - status.reply_parent.user, - 'REPLY', - related_user=status.user, - related_status=status, - ) - if status.mention_users.exists(): - for mentioned_user in status.mention_users.all(): - if not mentioned_user.local or mentioned_user in notified: - continue - status_builder.create_notification( - mentioned_user, - 'MENTION', - related_user=status.user, - related_status=status, - ) - @app.task def handle_delete_status(activity): @@ -309,13 +281,6 @@ def handle_favorite(activity): if fav.user.local: return - status_builder.create_notification( - fav.status.user, - 'FAVORITE', - related_user=fav.user, - related_status=fav.status, - ) - @app.task def handle_unfavorite(activity): @@ -332,19 +297,11 @@ def handle_unfavorite(activity): def handle_boost(activity): ''' someone gave us a boost! ''' try: - boost = activitypub.Boost(**activity).to_model(models.Boost) + activitypub.Boost(**activity).to_model(models.Boost) except activitypub.ActivitySerializerError: # this probably just means we tried to boost an unknown status return - if not boost.user.local: - status_builder.create_notification( - boost.boosted_status.user, - 'BOOST', - related_user=boost.user, - related_status=boost.boosted_status, - ) - @app.task def handle_unboost(activity): diff --git a/bookwyrm/migrations/0017_auto_20201130_1819.py b/bookwyrm/migrations/0017_auto_20201130_1819.py index ce9f1cc7b..0775269b6 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 8deb69a86..6fcf406bd 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 000000000..b9c328eaf --- /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 000000000..84b17055e --- /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/migrations/0045_auto_20210210_2114.py b/bookwyrm/migrations/0045_auto_20210210_2114.py new file mode 100644 index 000000000..87b9a3188 --- /dev/null +++ b/bookwyrm/migrations/0045_auto_20210210_2114.py @@ -0,0 +1,58 @@ +# Generated by Django 3.0.7 on 2021-02-10 21:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0044_auto_20210207_1924'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='notification', + name='notification_type_valid', + ), + migrations.AddField( + model_name='notification', + name='related_list_item', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ListItem'), + ), + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('MENTION', 'Mention'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import'), ('ADD', 'Add')], max_length=255), + ), + migrations.AlterField( + model_name='notification', + name='related_book', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='notification', + name='related_import', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ImportJob'), + ), + migrations.AlterField( + model_name='notification', + name='related_status', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.Status'), + ), + migrations.AlterField( + model_name='notification', + name='related_user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='related_user', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='notification', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddConstraint( + model_name='notification', + constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'MENTION', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT', 'ADD']), name='notification_type_valid'), + ), + ] diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py new file mode 100644 index 000000000..84293725f --- /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 b3337e151..e3450a5ad 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 ba0a54be2..7af487492 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): @@ -27,7 +16,7 @@ class BookWyrmModel(models.Model): ''' generate a url that resolves to the local object ''' base_path = 'https://%s' % DOMAIN if hasattr(self, 'user'): - base_path = self.user.remote_id + base_path = '%s%s' % (base_path, self.user.local_path) model_name = type(self).__name__.lower() return '%s/%s/%d' % (base_path, model_name, self.id) @@ -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 ea7049774..f1f208303 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 8373b0162..f90195016 100644 --- a/bookwyrm/models/favorite.py +++ b/bookwyrm/models/favorite.py @@ -1,12 +1,14 @@ ''' like/fav/star a status ''' +from django.apps import apps 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,9 +20,33 @@ 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) + if self.status.user.local and self.status.user != self.user: + notification_model = apps.get_model( + 'bookwyrm.Notification', require_ready=True) + notification_model.objects.create( + user=self.status.user, + notification_type='FAVORITE', + related_user=self.user, + related_status=self.status + ) + + def delete(self, *args, **kwargs): + ''' delete and delete notifications ''' + # check for notification + if self.status.user.local: + notification_model = apps.get_model( + 'bookwyrm.Notification', require_ready=True) + notification = notification_model.objects.filter( + user=self.status.user, related_user=self.user, + related_status=self.status, notification_type='FAVORITE' + ).first() + if notification: + notification.delete() + super().delete(*args, **kwargs) + class Meta: ''' can't fav things twice ''' unique_together = ('user', 'status') diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index bc10156b6..55de1fab2 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -263,6 +263,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): if formatted is None or formatted is MISSING: return getattr(instance, self.name).set(formatted) + instance.save(broadcast=False) def field_to_activity(self, value): if self.link_only: diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index b10651b96..407d820bb 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -2,6 +2,7 @@ import re import dateutil.parser +from django.apps import apps from django.contrib.postgres.fields import JSONField from django.db import models from django.utils import timezone @@ -50,6 +51,18 @@ class ImportJob(models.Model): ) retry = models.BooleanField(default=False) + def save(self, *args, **kwargs): + ''' save and notify ''' + super().save(*args, **kwargs) + if self.complete: + notification_model = apps.get_model( + 'bookwyrm.Notification', require_ready=True) + notification_model.objects.create( + user=self.user, + notification_type='IMPORT', + related_import=self, + ) + class ImportItem(models.Model): ''' a single line of a csv being imported ''' diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index b567929b9..db8c1af6e 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -1,10 +1,11 @@ ''' make a list of books!! ''' +from django.apps import apps 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 @@ -42,20 +43,22 @@ class List(OrderedCollectionMixin, BookWyrmModel): @property def collection_queryset(self): ''' list of books for this shelf, overrides OrderedCollectionMixin ''' - return self.books.all().order_by('listitem') + return self.books.filter( + listitem__approved=True + ).all().order_by('listitem') class Meta: ''' default sorting ''' 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' @@ -66,24 +69,24 @@ class ListItem(ActivitypubMixin, BookWyrmModel): endorsement = models.ManyToManyField('User', related_name='endorsers') activity_serializer = activitypub.AddBook + object_field = 'book' + collection_field = 'book_list' - 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 save(self, *args, **kwargs): + ''' create a notification too ''' + created = not bool(self.id) + super().save(*args, **kwargs) + list_owner = self.book_list.user + # create a notification if somoene ELSE added to a local user's list + if created and list_owner.local and list_owner != self.user: + model = apps.get_model('bookwyrm.Notification', require_ready=True) + model.objects.create( + user=list_owner, + related_user=self.user, + related_list_item=self, + notification_type='ADD', + ) - 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() class Meta: ''' an opinionated constraint! you can't put a book on a list twice ''' diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index 4ce5dceac..0470b3258 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -5,24 +5,41 @@ from .base_model import BookWyrmModel NotificationType = models.TextChoices( 'NotificationType', - 'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT') + 'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD') class Notification(BookWyrmModel): ''' you've been tagged, liked, followed, etc ''' - user = models.ForeignKey('User', on_delete=models.PROTECT) + user = models.ForeignKey('User', on_delete=models.CASCADE) related_book = models.ForeignKey( - 'Edition', on_delete=models.PROTECT, null=True) + 'Edition', on_delete=models.CASCADE, null=True) related_user = models.ForeignKey( 'User', - on_delete=models.PROTECT, null=True, related_name='related_user') + on_delete=models.CASCADE, null=True, related_name='related_user') related_status = models.ForeignKey( - 'Status', on_delete=models.PROTECT, null=True) + 'Status', on_delete=models.CASCADE, null=True) related_import = models.ForeignKey( - 'ImportJob', on_delete=models.PROTECT, null=True) + 'ImportJob', on_delete=models.CASCADE, null=True) + related_list_item = models.ForeignKey( + 'ListItem', on_delete=models.CASCADE, null=True) read = models.BooleanField(default=False) notification_type = models.CharField( max_length=255, choices=NotificationType.choices) + def save(self, *args, **kwargs): + ''' save, but don't make dupes ''' + # there's probably a better way to do this + if self.__class__.objects.filter( + user=self.user, + related_book=self.related_book, + related_user=self.related_user, + related_status=self.related_status, + related_import=self.related_import, + related_list_item=self.related_list_item, + notification_type=self.notification_type, + ).exists(): + return + super().save(*args, **kwargs) + class Meta: ''' checks if notifcation is in enum list for valid types ''' constraints = [ diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index 7daafaaff..2bec3a818 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 ec84d44f0..ac8f82867 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -1,14 +1,16 @@ ''' defines relationships between users ''' -from django.db import models +from django.apps import apps +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 +25,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 +49,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 +56,84 @@ 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) + + if self.user_object.local: + model = apps.get_model('bookwyrm.Notification', require_ready=True) + notification_type = 'FOLLOW_REQUEST' \ + if self.user_object.manually_approves_followers else 'FOLLOW' + model.objects.create( + user=self.user_object, + related_user=self.user_subject, + notification_type=notification_type, + ) -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 ff5660dd0..921b8617c 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 093dd773d..62effeb88 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,47 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): serialize_reverse_fields = [('attachments', 'attachment', 'id')] deserialize_reverse_fields = [('attachments', 'attachment')] + + def save(self, *args, **kwargs): + ''' save and notify ''' + super().save(*args, **kwargs) + + notification_model = apps.get_model( + 'bookwyrm.Notification', require_ready=True) + + if self.deleted: + notification_model.objects.filter(related_status=self).delete() + + if self.reply_parent and self.reply_parent.user != self.user and \ + self.reply_parent.user.local: + notification_model.objects.create( + user=self.reply_parent.user, + notification_type='REPLY', + related_user=self.user, + related_status=self, + ) + for mention_user in self.mention_users.all(): + # avoid double-notifying about this status + if not mention_user.local or \ + (self.reply_parent and \ + mention_user == self.reply_parent.user): + continue + notification_model.objects.create( + user=mention_user, + notification_type='MENTION', + related_user=self.user, + related_status=self, + ) + + @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 +169,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 +258,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 +266,35 @@ class Boost(Status): related_name='boosters', activitypub_field='object', ) + activity_serializer = activitypub.Boost + + def save(self, *args, **kwargs): + ''' save and notify ''' + super().save(*args, **kwargs) + if not self.boosted_status.user.local: + return + + notification_model = apps.get_model( + 'bookwyrm.Notification', require_ready=True) + notification_model.objects.create( + user=self.boosted_status.user, + related_status=self.boosted_status, + related_user=self.user, + notification_type='BOOST', + ) + + def delete(self, *args, **kwargs): + ''' delete and un-notify ''' + notification_model = apps.get_model( + 'bookwyrm.Notification', require_ready=True) + notification_model.objects.filter( + user=self.boosted_status.user, + related_status=self.boosted_status, + related_user=self.user, + notification_type='BOOST', + ).delete() + super().delete(*args, **kwargs) + def __init__(self, *args, **kwargs): ''' the user field is "actor" here instead of "attributedTo" ''' @@ -244,8 +308,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 6e0ba8ab5..d75f6e050 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 3fd0eaf7d..da717d2e1 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/static/css/format.css b/bookwyrm/static/css/format.css index f10549eda..50ce101eb 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -13,6 +13,13 @@ overflow: hidden; } +/* --- SHELVING --- */ +.shelf-option:disabled > *::after { + font-family: "icomoon"; + content: "\e918"; + margin-left: 0.5em; +} + /* --- TOGGLES --- */ .toggle-button[aria-pressed=true], .toggle-button[aria-pressed=true]:hover { background-color: hsl(171, 100%, 41%); diff --git a/bookwyrm/static/js/shared.js b/bookwyrm/static/js/shared.js index c2fa8b541..758b76dc1 100644 --- a/bookwyrm/static/js/shared.js +++ b/bookwyrm/static/js/shared.js @@ -46,13 +46,14 @@ function back(e) { history.back(); } -function polling(el) { - let delay = 10000 + (Math.random() * 1000); +function polling(el, delay) { + delay = delay || 10000; + delay += (Math.random() * 1000); setTimeout(function() { fetch('/api/updates/' + el.getAttribute('data-poll')) .then(response => response.json()) .then(data => updateCountElement(el, data)); - polling(el); + polling(el, delay * 1.25); }, delay, el); } diff --git a/bookwyrm/status.py b/bookwyrm/status.py index 648f2e7df..4dc4991d0 100644 --- a/bookwyrm/status.py +++ b/bookwyrm/status.py @@ -1,4 +1,5 @@ ''' Handle user activity ''' +from django.db import transaction from django.utils import timezone from bookwyrm import models @@ -19,30 +20,18 @@ def create_generated_note(user, content, mention_books=None, privacy='public'): parser.feed(content) content = parser.get_output() - status = models.GeneratedNote.objects.create( - user=user, - content=content, - privacy=privacy - ) - - if mention_books: - for book in mention_books: - status.mention_books.add(book) + with transaction.atomic(): + # create but don't save + status = models.GeneratedNote( + user=user, + content=content, + privacy=privacy + ) + # we have to save it to set the related fields, but hold off on telling + # folks about it because it is not ready + status.save(broadcast=False) + if mention_books: + status.mention_books.set(mention_books) + status.save(created=True) return status - - -def create_notification(user, notification_type, related_user=None, \ - related_book=None, related_status=None, related_import=None): - ''' let a user know when someone interacts with their content ''' - if user == related_user: - # don't create notification when you interact with your own stuff - return - models.Notification.objects.create( - user=user, - related_book=related_book, - related_user=related_user, - related_status=related_status, - related_import=related_import, - notification_type=notification_type, - ) diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index 51811d17f..1d8b5d35b 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -35,7 +35,7 @@
{% include 'snippets/book_cover.html' with book=book size=large %} {% include 'snippets/rate_action.html' with user=request.user book=book %} - {% include 'snippets/shelve_button.html' %} + {% include 'snippets/shelve_button/shelve_button.html' %} {% if request.user.is_authenticated and not book.cover %}
diff --git a/bookwyrm/templates/components/modal.html b/bookwyrm/templates/components/modal.html index 72402914b..554f9ccde 100644 --- a/bookwyrm/templates/components/modal.html +++ b/bookwyrm/templates/components/modal.html @@ -8,15 +8,17 @@ {% include 'snippets/toggle/toggle_button.html' with label="close" class="delete" nonbutton=True %} {% block modal-form-open %}{% endblock %} + {% if not no_body %} + {% endif %}
{% block modal-footer %}{% endblock %}
{% block modal-form-close %}{% endblock %}
- + {% include 'snippets/toggle/toggle_button.html' with label="close" class="modal-close is-large" nonbutton=True %}
diff --git a/bookwyrm/templates/feed/feed_layout.html b/bookwyrm/templates/feed/feed_layout.html index f0c7b0577..00ac4ab88 100644 --- a/bookwyrm/templates/feed/feed_layout.html +++ b/bookwyrm/templates/feed/feed_layout.html @@ -48,7 +48,7 @@
- {% include 'snippets/shelve_button.html' with book=book %} + {% include 'snippets/shelve_button/shelve_button.html' with book=book %} {% active_shelf book as active_shelf %} {% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %} {% include 'snippets/progress_update.html' with readthrough=book.latest_readthrough %} diff --git a/bookwyrm/templates/lists/curate.html b/bookwyrm/templates/lists/curate.html index 20c8175dd..a7e0fe79f 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 213f9dca6..5f9baa038 100644 --- a/bookwyrm/templates/lists/list.html +++ b/bookwyrm/templates/lists/list.html @@ -21,19 +21,19 @@
{% include 'snippets/book_titleby.html' with book=item.book %} {% include 'snippets/stars.html' with rating=item.book|rating:request.user %} - {% include 'snippets/shelve_button.html' with book=item.book %} + {% include 'snippets/shelve_button/shelve_button.html' with book=item.book %}