diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 5662d1d57..b644337a5 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -2,7 +2,7 @@ github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: bookwyrm -open_collective: # Replace with a single Open Collective username +open_collective: bookwyrm ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry diff --git a/README.md b/README.md index f07d9cefb..6a624ab3c 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,36 @@ Social reading and reviewing, decentralized with ActivityPub ## Contents - [Joining BookWyrm](#joining-bookwyrm) -- [The overall idea](#the-overall-idea) +- [Contributing](#contributing) +- [About BookWyrm](#about-bookwyrm) - [What it is and isn't](#what-it-is-and-isnt) - [The role of federation](#the-role-of-federation) - [Features](#features) - [Setting up the developer environment](#setting-up-the-developer-environment) - [Installing in Production](#installing-in-production) - - [Project structure](#project-structure) - [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). +You can request an invite to https://bookwyrm.social by [email](mailto:mousereeve@riseup.net), [Mastodon direct message](https://friend.camp/@tripofmice), or [Twitter direct message](https://twitter.com/tripofmice). -## The overall idea + +## Contributing +There are many ways you can contribute to this project, regardless of your level of technical expertise. + +### Feedback and feature requests +Please feel encouraged and welcome to point out bugs, suggestions, feature requests, and ideas for how things ought to work using [GitHub issues](https://github.com/mouse-reeve/bookwyrm/issues). + +### Code contributions +Code contributons are gladly welcomed! If you're not sure where to start, take a look at the ["Good first issue"](https://github.com/mouse-reeve/bookwyrm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. Because BookWyrm is a small project, there isn't a lot of formal structure, but there is a huge capacity for one-on-one support, which can look like asking questions as you go, pair programming, video chats, et cetera, so please feel free to reach out. + +If you have questions about the project or contributing, you can seet up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min). + +### Financial Support +BookWyrm is an ad-free passion project with no intentions of seeking out venture funding or corporate financial relationships. If you want to help keep the project going, you can donate to the [Patreon](https://www.patreon.com/bookwyrm), or make a one time gift via [PayPal](https://paypal.me/oulipo). + +## About BookWyrm ### 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. @@ -54,6 +68,25 @@ Since the project is still in its early stages, the features are growing every d - 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 + +### The Tech Stack +Web backend + - [Django](https://www.djangoproject.com/) web server + - [PostgreSQL](https://www.postgresql.org/) database + - [ActivityPub](http://activitypub.rocks/) federation + - [Celery](http://celeryproject.org/) task queuing + - [Redis](https://redis.io/) task backend + +Front end + - Django templates + - [Bulma.io](https://bulma.io/) css framework + - Vanilla JavaScript, in moderation + +Deployment + - [Docker](https://www.docker.com/) and docker-compose + - [Gunicorn](https://gunicorn.org/) web runner + - [Flower](https://github.com/mher/flower) celery monitoring + - [Nginx](https://nginx.org/en/) HTTP server ## Setting up the developer environment @@ -116,7 +149,6 @@ This project is still young and isn't, at the momoment, very stable, so please p ```python from bookwyrm import models user = models.User.objects.get(id=1) - user.is_admin = True user.is_staff = True user.is_superuser = True user.save() @@ -133,9 +165,3 @@ There are three concepts in the book data model: - `Edition`, a concrete, actually published version of a book Whenever a user interacts with a book, they are interacting with a specific edition. Every work has a default edition, but the user can select other editions. Reviews aggregated for all editions of a work when you view an edition's page. - - -## Contributing -There are many ways you can contribute to this project! You are welcome and encouraged to create or contribute an issue to report a bug, request a feature, make a usability suggestion, or express a nebulous desire. - -If you'd like to add to the codebase, that's super rad and you should do it! At this point, there isn't a formalized process, but you can take a look at the open issues, or contact me directly and chat about it. diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index 201e8042d..510f1f3f4 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -16,7 +16,7 @@ from .response import ActivitypubResponse from .book import Edition, Work, Author from .verbs import Create, Delete, Undo, Update from .verbs import Follow, Accept, Reject, Block -from .verbs import Add, AddBook, Remove +from .verbs import Add, AddBook, AddListItem, Remove # this creates a list of all the Activity types that we can serialize, # so when an Activity comes in from outside, we can check if it's known diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 4bbb5e9f3..5f35f1d7e 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -65,6 +65,13 @@ class ActivityObject: def to_model(self, model, instance=None, save=True): ''' convert from an activity to a model instance ''' + if self.type != model.activity_serializer.type: + raise ActivitySerializerError( + 'Wrong activity type "%s" for activity of type "%s"' % \ + (model.activity_serializer.type, + self.type) + ) + if not isinstance(self, model.activity_serializer): raise ActivitySerializerError( 'Wrong activity type "%s" for model "%s" (expects "%s")' % \ diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index 5502ced01..190cd7395 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -70,17 +70,26 @@ class Reject(Verb): @dataclass(init=False) class Add(Verb): '''Add activity ''' - target: ActivityObject + target: str + object: ActivityObject type: str = 'Add' @dataclass(init=False) -class AddBook(Verb): +class AddBook(Add): '''Add activity that's aware of the book obj ''' - target: Edition + object: Edition type: str = 'Add' +@dataclass(init=False) +class AddListItem(AddBook): + '''Add activity that's aware of the book obj ''' + notes: str = None + order: int = 0 + approved: bool = True + + @dataclass(init=False) class Remove(Verb): '''Remove activity ''' diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index ab9900a7e..527d2f425 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 @@ -231,7 +239,8 @@ def get_image(url): 'User-Agent': settings.USER_AGENT, }, ) - except (RequestError, SSLError): + except (RequestError, SSLError) as e: + logger.exception(e) return None if not resp.ok: return None diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index cd196d274..a767a45ac 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -142,7 +142,12 @@ class Connector(AbstractConnector): work = book.parent_work # we can mass download edition data from OL to avoid repeatedly querying - edition_options = self.load_edition_data(work.openlibrary_key) + try: + edition_options = self.load_edition_data(work.openlibrary_key) + except ConnectorException: + # who knows, man + return + for edition_data in edition_options.get('entries'): # does this edition have ANY interesting data? if ignore_edition(edition_data): diff --git a/bookwyrm/goodreads_import.py b/bookwyrm/goodreads_import.py index 3dcdc2f09..1b2b971c7 100644 --- a/bookwyrm/goodreads_import.py +++ b/bookwyrm/goodreads_import.py @@ -4,7 +4,6 @@ import logging from bookwyrm import models from bookwyrm.models import ImportJob, ImportItem -from bookwyrm.status import create_notification from bookwyrm.tasks import app logger = logging.getLogger(__name__) @@ -68,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() diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index cb5baafc0..e0e690550 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -136,14 +136,7 @@ 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: + if not relationship.user_object.manually_approves_followers: relationship.accept() @@ -223,9 +216,9 @@ def handle_create_list(activity): def handle_update_list(activity): ''' update a list ''' try: - book_list = models.List.objects.get(id=activity['object']['id']) + book_list = models.List.objects.get(remote_id=activity['object']['id']) except models.List.DoesNotExist: - return + book_list = None activitypub.BookList( **activity['object']).to_model(models.List, instance=book_list) @@ -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): @@ -362,8 +319,19 @@ def handle_add(activity): #this is janky as heck but I haven't thought of a better solution try: activitypub.AddBook(**activity).to_model(models.ShelfBook) + return except activitypub.ActivitySerializerError: - activitypub.AddBook(**activity).to_model(models.Tag) + pass + try: + activitypub.AddListItem(**activity).to_model(models.ListItem) + return + except activitypub.ActivitySerializerError: + pass + try: + activitypub.AddBook(**activity).to_model(models.UserTag) + return + except activitypub.ActivitySerializerError: + pass @app.task diff --git a/bookwyrm/migrations/0044_auto_20210207_1924.py b/bookwyrm/migrations/0044_auto_20210207_1924.py index 84b17055e..7289c73d8 100644 --- a/bookwyrm/migrations/0044_auto_20210207_1924.py +++ b/bookwyrm/migrations/0044_auto_20210207_1924.py @@ -10,7 +10,10 @@ def set_user(app_registry, schema_editor): 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) + try: + item.save(broadcast=False) + except TypeError: + item.save() class Migration(migrations.Migration): 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/favorite.py b/bookwyrm/models/favorite.py index 7d630cf58..f90195016 100644 --- a/bookwyrm/models/favorite.py +++ b/bookwyrm/models/favorite.py @@ -1,4 +1,5 @@ ''' like/fav/star a status ''' +from django.apps import apps from django.db import models from django.utils import timezone @@ -22,6 +23,30 @@ class Favorite(ActivityMixin, BookWyrmModel): 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 5004a9b09..ef48ed956 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -1,4 +1,5 @@ ''' make a list of books!! ''' +from django.apps import apps from django.db import models from bookwyrm import activitypub @@ -67,10 +68,26 @@ class ListItem(CollectionItemMixin, BookWyrmModel): order = fields.IntegerField(blank=True, null=True) endorsement = models.ManyToManyField('User', related_name='endorsers') - activity_serializer = activitypub.AddBook + activity_serializer = activitypub.AddListItem object_field = 'book' collection_field = 'book_list' + 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', + ) + + class Meta: ''' an opinionated constraint! you can't put a book on a list twice ''' unique_together = ('book', 'book_list') 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/relationship.py b/bookwyrm/models/relationship.py index 6aaffae99..044b08acc 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -1,4 +1,5 @@ ''' defines relationships between users ''' +from django.apps import apps from django.db import models, transaction from django.db.models import Q @@ -80,18 +81,34 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): try: UserFollows.objects.get( user_subject=self.user_subject, - user_object=self.user_object + user_object=self.user_object, ) + # blocking in either direction is a no-go UserBlocks.objects.get( user_subject=self.user_subject, - user_object=self.user_object + user_object=self.user_object, + ) + UserBlocks.objects.get( + user_subject=self.user_object, + user_object=self.user_subject, ) return None 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, + ) + def accept(self): ''' turn this request into the real deal''' diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index edf602816..62effeb88 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -52,6 +52,38 @@ 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 ''' @@ -236,6 +268,33 @@ class Boost(ActivityMixin, Status): ) 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" ''' 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 1d8b5d35b..0bef2856b 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -103,7 +103,7 @@
-

{% include 'snippets/stars.html' with rating=rating %} ({{ review_count }} review{{ reviews|length|pluralize }})

+

{% include 'snippets/stars.html' with rating=rating %} ({{ review_count }} review{{ review_count|pluralize }})

{% include 'snippets/trimmed_text.html' with full=book|book_description %} @@ -224,6 +224,17 @@ {% endif %} + + {% if lists.exists %} +
+

Lists

+ +
+ {% endif %}
diff --git a/bookwyrm/templates/lists/list.html b/bookwyrm/templates/lists/list.html index 7899d5932..5f9baa038 100644 --- a/bookwyrm/templates/lists/list.html +++ b/bookwyrm/templates/lists/list.html @@ -21,12 +21,12 @@
{% 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 %}