From 745ca7d4ff04c3324d13cbcf3c3e99947cc83a9f Mon Sep 17 00:00:00 2001 From: Adam Kelly Date: Mon, 30 Mar 2020 15:13:32 +0100 Subject: [PATCH 1/3] Boosts - handle url, store in database, send, notify. --- fedireads/activitypub/__init__.py | 1 + fedireads/activitypub/status.py | 11 +++++ fedireads/incoming.py | 22 ++++++++++ .../migrations/0026_auto_20200330_1456.py | 42 +++++++++++++++++++ fedireads/models/__init__.py | 2 +- fedireads/models/status.py | 17 +++++++- fedireads/outgoing.py | 16 +++++++ fedireads/status.py | 14 +++++++ fedireads/templates/notifications.html | 3 ++ fedireads/urls.py | 1 + fedireads/view_actions.py | 6 +++ 11 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 fedireads/migrations/0026_auto_20200330_1456.py diff --git a/fedireads/activitypub/__init__.py b/fedireads/activitypub/__init__.py index c14ff51ba..d934ae89c 100644 --- a/fedireads/activitypub/__init__.py +++ b/fedireads/activitypub/__init__.py @@ -10,4 +10,5 @@ from .status import get_review, get_review_article from .status import get_comment, get_comment_article from .status import get_status, get_replies, get_replies_page from .status import get_favorite, get_unfavorite +from .status import get_boost from .status import get_add_tag, get_remove_tag diff --git a/fedireads/activitypub/status.py b/fedireads/activitypub/status.py index f1ff05bf6..779bb3af0 100644 --- a/fedireads/activitypub/status.py +++ b/fedireads/activitypub/status.py @@ -158,6 +158,17 @@ def get_unfavorite(favorite): } +def get_boost(boost): + ''' boost/announce a post ''' + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': boost.absolute_id, + 'type': 'Announce', + 'actor': boost.user.actor, + 'object': boost.boosted_status.absolute_id, + } + + def get_add_tag(tag): ''' add activity for tagging a book ''' uuid = uuid4() diff --git a/fedireads/incoming.py b/fedireads/incoming.py index 12649f433..a766cb688 100644 --- a/fedireads/incoming.py +++ b/fedireads/incoming.py @@ -38,6 +38,7 @@ def shared_inbox(request): 'Reject': handle_follow_reject, 'Create': handle_create, 'Like': handle_favorite, + 'Announce': handle_boost, 'Add': { 'Tag': handle_add, }, @@ -286,6 +287,27 @@ def handle_unfavorite(activity): return HttpResponse() +def handle_boost(activity): + ''' someone gave us a boost! ''' + try: + status_id = activity['object'].split('/')[-1] + status = models.Status.objects.get(id=status_id) + booster = get_or_create_remote_user(activity['actor']) + except (models.Status.DoesNotExist, models.User.DoesNotExist): + return HttpResponseNotFound() + + if not booster.local: + status_builder.create_boost_from_activity(booster, activity) + + status_builder.create_notification( + status.user, + 'BOOST', + related_user=booster, + related_status=status, + ) + + return HttpResponse() + def handle_add(activity): ''' someone is tagging or shelving a book ''' if activity['object']['type'] == 'Tag': diff --git a/fedireads/migrations/0026_auto_20200330_1456.py b/fedireads/migrations/0026_auto_20200330_1456.py new file mode 100644 index 000000000..50e002a89 --- /dev/null +++ b/fedireads/migrations/0026_auto_20200330_1456.py @@ -0,0 +1,42 @@ +# Generated by Django 3.0.3 on 2020-03-30 14:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('fedireads', '0025_auto_20200330_0037'), + ] + + operations = [ + migrations.CreateModel( + name='Boost', + fields=[ + ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Status')), + ], + options={ + 'abstract': False, + }, + bases=('fedireads.status',), + ), + migrations.RemoveConstraint( + model_name='notification', + name='notification_type_valid', + ), + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost')], max_length=255), + ), + migrations.AddConstraint( + model_name='notification', + constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST']), name='notification_type_valid'), + ), + migrations.AddField( + model_name='boost', + name='boosted_status', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='boosters', to='fedireads.Status'), + ), + ] diff --git a/fedireads/models/__init__.py b/fedireads/models/__init__.py index 4d26f032e..e843b26d3 100644 --- a/fedireads/models/__init__.py +++ b/fedireads/models/__init__.py @@ -1,6 +1,6 @@ ''' bring all the models into the app namespace ''' from .book import Connector, Book, Work, Edition, Author from .shelf import Shelf, ShelfBook -from .status import Status, Review, Comment, Favorite, Tag, Notification +from .status import Status, Review, Comment, Favorite, Boost, Tag, Notification from .user import User, UserFollows, UserFollowRequest, UserBlocks from .user import FederatedServer diff --git a/fedireads/models/status.py b/fedireads/models/status.py index 0bdec6d7b..f98df0568 100644 --- a/fedireads/models/status.py +++ b/fedireads/models/status.py @@ -89,6 +89,21 @@ class Favorite(FedireadsModel): unique_together = ('user', 'status') +class Boost(Status): + ''' boost'ing a post ''' + boosted_status = models.ForeignKey( + 'Status', + on_delete=models.PROTECT, + related_name="boosters") + + def save(self, *args, **kwargs): + self.status_type = 'Boost' + self.activity_type = 'Announce' + super().save(*args, **kwargs) + # This constraint can't work as it would cross tables. + # class Meta: + # unique_together = ('user', 'boosted_status') + class Tag(FedireadsModel): ''' freeform tags for books ''' user = models.ForeignKey('User', on_delete=models.PROTECT) @@ -107,7 +122,7 @@ class Tag(FedireadsModel): NotificationType = models.TextChoices( - 'NotificationType', 'FAVORITE REPLY TAG FOLLOW FOLLOW_REQUEST') + 'NotificationType', 'FAVORITE REPLY TAG FOLLOW FOLLOW_REQUEST BOOST') class Notification(FedireadsModel): ''' you've been tagged, liked, followed, etc ''' diff --git a/fedireads/outgoing.py b/fedireads/outgoing.py index 9a272538b..f4cb25a46 100644 --- a/fedireads/outgoing.py +++ b/fedireads/outgoing.py @@ -291,6 +291,22 @@ def handle_unfavorite(user, status): recipients = get_recipients(user, 'direct', [status.user]) broadcast(user, fav_activity, recipients) +def handle_boost(user, status): + ''' a user wishes to boost a status ''' + try: + boost = models.Boost.objects.create( + boosted_status=status, + user=user, + ) + boost.save() + except IntegrityError: + # you already boosted that + # TODO - doesn't work because unique constraint isn't enforcable. + return + + boost_activity = activitypub.get_boost(boost) + recipients = get_recipients(user, 'public') + broadcast(user, boost_activity, recipients) def handle_update_book(user, book): ''' broadcast the news about our book ''' diff --git a/fedireads/status.py b/fedireads/status.py index c46b1c88e..bc79b99c4 100644 --- a/fedireads/status.py +++ b/fedireads/status.py @@ -102,6 +102,20 @@ def create_favorite_from_activity(user, activity): return models.Favorite.objects.get(status=status, user=user) +def create_boost_from_activity(user, activity): + ''' create a new boost activity ''' + status = get_status(activity['object']) + remote_id = activity['id'] + try: + return models.Boost.objects.create( + status=status, + user=user, + remote_id=remote_id, + ) + except IntegrityError: + return models.Boost.objects.get(status=status, user=user) + + def get_status(absolute_id): ''' find a status in the database ''' return get_by_absolute_id(absolute_id, models.Status) diff --git a/fedireads/templates/notifications.html b/fedireads/templates/notifications.html index a6d215dc9..dd5f135c5 100644 --- a/fedireads/templates/notifications.html +++ b/fedireads/templates/notifications.html @@ -32,6 +32,9 @@
{% include 'snippets/follow_request_buttons.html' with user=notification.related_user %}
+ + {% elif notification.notification_type == 'BOOST' %} + boosted your status {% endif %} {% endfor %} diff --git a/fedireads/urls.py b/fedireads/urls.py index aa9ba3625..f5bc9ac78 100644 --- a/fedireads/urls.py +++ b/fedireads/urls.py @@ -85,6 +85,7 @@ urlpatterns = [ re_path(r'^favorite/(?P\d+)/?$', actions.favorite), re_path(r'^unfavorite/(?P\d+)/?$', actions.unfavorite), + re_path(r'^boost/(?P\d+)/?$', actions.boost), re_path(r'^shelve/?$', actions.shelve), diff --git a/fedireads/view_actions.py b/fedireads/view_actions.py index 509e4bfec..ab45d0fb3 100644 --- a/fedireads/view_actions.py +++ b/fedireads/view_actions.py @@ -240,6 +240,12 @@ def unfavorite(request, status_id): outgoing.handle_unfavorite(request.user, status) return redirect(request.headers.get('Referer', '/')) +@login_required +def boost(request, status_id): + ''' boost a status ''' + status = models.Status.objects.get(id=status_id) + outgoing.handle_boost(request.user, status) + return redirect(request.headers.get('Referer', '/')) @login_required def follow(request): From acffb36f46796efcf310e365dfc9cc5a789c0eba Mon Sep 17 00:00:00 2001 From: Adam Kelly Date: Mon, 30 Mar 2020 16:31:31 +0100 Subject: [PATCH 2/3] Display boosts in activity feed. --- fedireads/templates/snippets/status.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/fedireads/templates/snippets/status.html b/fedireads/templates/snippets/status.html index af4b73fa7..92900b279 100644 --- a/fedireads/templates/snippets/status.html +++ b/fedireads/templates/snippets/status.html @@ -10,6 +10,8 @@ reviewed {{ status.book.title }} {% elif status.status_type == 'Comment' %} commented on {{ status.book.title }} + {% elif status.status_type == 'Boost' %} + boosted {% elif status.reply_parent %} {% with parent_status=status|parent %} replied to {% include 'snippets/username.html' with user=parent_status.user possessive=True %} {{ parent_status.status_type|lower }} @@ -43,10 +45,15 @@ {% if status.status_type == 'Review' %}

{{ status.name }} {{ status.rating | stars }} stars, by {% include 'snippets/username.html' with user=status.user %}

{% endif %} - {% if status.status_type != 'Update' %} + {% if status.status_type != 'Update' and status.status_type != 'Boost' %}
{{ status.content | safe }}
{% endif %} + {% if status.status_type == 'Boost' %} + {% include 'snippets/status.html' with status=status.boosted_status depth=depth|add:1 %} + {% endif %} {% if not max_depth and status.reply_parent or status|replies %}

Thread{% endif %} +{% if status.status_type != 'Boost' %} {% include 'snippets/interaction.html' with activity=status %} +{% endif %} From a4c257a8d29d7114bbf6ec7227db9e8c8f94e9aa Mon Sep 17 00:00:00 2001 From: Adam Kelly Date: Mon, 30 Mar 2020 16:39:53 +0100 Subject: [PATCH 3/3] Don't create duplicate boosts. --- fedireads/outgoing.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/fedireads/outgoing.py b/fedireads/outgoing.py index f4cb25a46..dc60b2c03 100644 --- a/fedireads/outgoing.py +++ b/fedireads/outgoing.py @@ -293,16 +293,15 @@ def handle_unfavorite(user, status): def handle_boost(user, status): ''' a user wishes to boost a status ''' - try: - boost = models.Boost.objects.create( - boosted_status=status, - user=user, - ) - boost.save() - except IntegrityError: - # you already boosted that - # TODO - doesn't work because unique constraint isn't enforcable. + if models.Boost.objects.filter( + boosted_status=status, user=user).exists(): + # you already boosted that. return + boost = models.Boost.objects.create( + boosted_status=status, + user=user, + ) + boost.save() boost_activity = activitypub.get_boost(boost) recipients = get_recipients(user, 'public')