From f4008eb8c8d9eae179d2f91c239e57cdc73a0cf8 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 7 Mar 2020 14:50:29 -0800 Subject: [PATCH] Adds notifications Fixes #70 --- fedireads/incoming.py | 20 +++++++++- fedireads/migrations/0011_notification.py | 32 ++++++++++++++++ fedireads/models/__init__.py | 2 +- fedireads/models/status.py | 26 +++++++++++++ fedireads/outgoing.py | 10 ++++- fedireads/status.py | 12 ++++++ fedireads/templates/layout.html | 8 ++++ fedireads/templates/notifications.html | 37 +++++++++++++++++++ .../templates/snippets/user_preview.html | 11 ++++++ fedireads/templatetags/fr_display.py | 6 +++ fedireads/urls.py | 3 +- fedireads/view_actions.py | 5 +++ fedireads/views.py | 9 +++++ 13 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 fedireads/migrations/0011_notification.py create mode 100644 fedireads/templates/notifications.html create mode 100644 fedireads/templates/snippets/user_preview.html diff --git a/fedireads/incoming.py b/fedireads/incoming.py index 1962ba43e..22a701e30 100644 --- a/fedireads/incoming.py +++ b/fedireads/incoming.py @@ -13,7 +13,8 @@ import requests from fedireads import activitypub from fedireads import models from fedireads import outgoing -from fedireads.status import create_review, create_status, create_tag +from fedireads.status import create_review, create_status, create_tag, \ + create_notification from fedireads.remote_user import get_or_create_remote_user @@ -212,6 +213,7 @@ def handle_incoming_follow(activity): # Accept, but then do we need to match the activity id? return HttpResponse() + create_notification(to_follow, 'FOLLOW', related_user=user) outgoing.handle_outgoing_accept(user, to_follow, activity) return HttpResponse() @@ -271,7 +273,14 @@ def handle_incoming_create(activity): return HttpResponseBadRequest() elif not user.local: try: - create_status(user, content) + status = create_status(user, content) + if status.reply_parent: + create_notification( + status.reply_parent.user, + 'REPLY', + related_user=status.user, + related_status=status, + ) except ValueError: return HttpResponseBadRequest() @@ -289,6 +298,13 @@ def handle_incoming_favorite(activity): if not liker.local: status.favorites.add(liker) + + create_notification( + status.user, + 'FAVORITE', + related_user=liker, + related_status=status, + ) return HttpResponse() diff --git a/fedireads/migrations/0011_notification.py b/fedireads/migrations/0011_notification.py new file mode 100644 index 000000000..fbdc4b1a9 --- /dev/null +++ b/fedireads/migrations/0011_notification.py @@ -0,0 +1,32 @@ +# Generated by Django 3.0.3 on 2020-03-07 22:23 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('fedireads', '0010_auto_20200307_0655'), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('read', models.BooleanField(default=False)), + ('notification_type', models.CharField(max_length=255)), + ('related_book', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')), + ('related_status', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Status')), + ('related_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='related_user', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/fedireads/models/__init__.py b/fedireads/models/__init__.py index dc463a075..6aecd41ed 100644 --- a/fedireads/models/__init__.py +++ b/fedireads/models/__init__.py @@ -1,5 +1,5 @@ ''' bring all the models into the app namespace ''' from .book import Book, Work, Edition, Author from .shelf import Shelf, ShelfBook -from .status import Status, Review, Favorite, Tag +from .status import Status, Review, Favorite, Tag, Notification from .user import User, UserRelationship, FederatedServer diff --git a/fedireads/models/status.py b/fedireads/models/status.py index 01acdae3b..4077cef7a 100644 --- a/fedireads/models/status.py +++ b/fedireads/models/status.py @@ -76,3 +76,29 @@ class Tag(FedireadsModel): class Meta: unique_together = ('user', 'book', 'name') + +class Notification(FedireadsModel): + ''' you've been tagged, liked, followed, etc ''' + user = models.ForeignKey('User', on_delete=models.PROTECT) + related_book = models.ForeignKey( + 'Book', on_delete=models.PROTECT, null=True) + related_user = models.ForeignKey( + 'User', + on_delete=models.PROTECT, null=True, related_name='related_user') + related_status = models.ForeignKey( + 'Status', on_delete=models.PROTECT, null=True) + read = models.BooleanField(default=False) + notification_type = models.CharField(max_length=255) + + def save(self, *args, **kwargs): + # TODO: there's probably a real way to do enums + types = [ + 'FAVORITE', + 'REPLY', + 'TAG', + 'FOLLOW' + ] + if not self.notification_type in types: + raise ValueError('Invalid notitication type') + super().save(*args, **kwargs) + diff --git a/fedireads/outgoing.py b/fedireads/outgoing.py index 8d5eb9f40..ac45f405e 100644 --- a/fedireads/outgoing.py +++ b/fedireads/outgoing.py @@ -7,7 +7,8 @@ from urllib.parse import urlencode from fedireads import activitypub from fedireads import models -from fedireads.status import create_review, create_status, create_tag +from fedireads.status import create_review, create_status, create_tag, \ + create_notification from fedireads.remote_user import get_or_create_remote_user from fedireads.broadcast import get_recipients, broadcast @@ -189,6 +190,13 @@ def handle_comment(user, review, content): ''' respond to a review or status ''' # validated and saves the comment in the database so it has an id comment = create_status(user, content, reply_parent=review) + if comment.reply_parent: + create_notification( + comment.reply_parent.user, + 'REPLY', + related_user=user, + related_status=comment, + ) comment_activity = activitypub.get_status(comment) create_activity = activitypub.get_create(user, comment_activity) diff --git a/fedireads/status.py b/fedireads/status.py index 9fada1283..120b4ba19 100644 --- a/fedireads/status.py +++ b/fedireads/status.py @@ -61,6 +61,18 @@ def create_tag(user, possible_book, name): return tag +def create_notification(user, notification_type, related_user=None, \ + related_book=None, related_status=None): + ''' let a user know when someone interacts with their content ''' + models.Notification.objects.create( + user=user, + related_book=related_book, + related_user=related_user, + related_status=related_status, + notification_type=notification_type, + ) + + def sanitize(content): ''' remove invalid html from free text ''' parser = InputHtmlParser() diff --git a/fedireads/templates/layout.html b/fedireads/templates/layout.html index c6290f938..e37679f08 100644 --- a/fedireads/templates/layout.html +++ b/fedireads/templates/layout.html @@ -1,3 +1,4 @@ +{% load fr_display %} @@ -47,6 +48,13 @@ + {% if request.user.is_authenticated %} +
+ + 🔔 ({{ request.user | notification_count }}) + +
+ {% endif %} diff --git a/fedireads/templates/notifications.html b/fedireads/templates/notifications.html new file mode 100644 index 000000000..91b98b403 --- /dev/null +++ b/fedireads/templates/notifications.html @@ -0,0 +1,37 @@ +{% extends 'layout.html' %} +{% load humanize %}l +{% block content %} +
+
+

Notifications

+
+ {% csrf_token %} + +
+ {% for notification in notifications %} +
+

+ {% if notification.notification_type == 'FAVORITE' %} + {% include 'snippets/username.html' with user=notification.related_user %} + favorited your + status + + {% elif notification.notification_type == 'REPLY' %} + {% include 'snippets/username.html' with user=notification.related_user %} + replied + to your + status + + {% elif notification.notification_type == 'FOLLOW' %} + {% include 'snippets/username.html' with user=notification.related_user %} + followed you + {% endif %} + {{ notification.created_date | naturaltime }} +

+
+ {% endfor %} +
+
+ +{% endblock %} + diff --git a/fedireads/templates/snippets/user_preview.html b/fedireads/templates/snippets/user_preview.html new file mode 100644 index 000000000..3989e908d --- /dev/null +++ b/fedireads/templates/snippets/user_preview.html @@ -0,0 +1,11 @@ +
+
+ {% include 'snippets/avatar.html' with user=user %} + {% include 'snippets/username.html' with user=user %} + {{ user.username }} +
+ {% if not is_self %} + {% include 'snippets/follow_button.html' with user=user %} + {% endif %} +
+ diff --git a/fedireads/templatetags/fr_display.py b/fedireads/templatetags/fr_display.py index 2f5b5ac60..62d861750 100644 --- a/fedireads/templatetags/fr_display.py +++ b/fedireads/templatetags/fr_display.py @@ -49,6 +49,12 @@ def get_user_identifier(user): return user.localname if user.localname else user.username +@register.filter(name='notification_count') +def get_notification_count(user): + ''' how many UNREAD notifications are there ''' + return user.notification_set.filter(read=False).count() + + @register.simple_tag(takes_context=True) def shelve_button_identifier(context, book): ''' check what shelf a user has a book on, if any ''' diff --git a/fedireads/urls.py b/fedireads/urls.py index 61cb48da2..0de293083 100644 --- a/fedireads/urls.py +++ b/fedireads/urls.py @@ -38,7 +38,7 @@ urlpatterns = [ re_path(r'^register/?$', views.register), re_path(r'^login/?$', views.user_login), re_path(r'^logout/?$', views.user_logout), - # this endpoint is both ui and fed depending on Accept type + re_path(r'^notifications/?', views.notifications_page), re_path(r'%s/?$' % user_path, views.user_page), re_path(r'%s/edit/?$' % user_path, views.edit_profile_page), re_path(r'^user/edit/?$', views.edit_profile_page), @@ -59,5 +59,6 @@ urlpatterns = [ re_path(r'^unfollow/?$', actions.unfollow), re_path(r'^search/?$', actions.search), re_path(r'^edit_profile/?$', actions.edit_profile), + re_path(r'^clear-notifications/?$', actions.clear_notifications), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/fedireads/view_actions.py b/fedireads/view_actions.py index 5595cb020..eb8e5f477 100644 --- a/fedireads/view_actions.py +++ b/fedireads/view_actions.py @@ -157,3 +157,8 @@ def search(request): return TemplateResponse(request, template, {'results': results}) +@login_required +def clear_notifications(request): + request.user.notification_set.filter(read=True).delete() + return redirect('/notifications') + diff --git a/fedireads/views.py b/fedireads/views.py index f115024f4..2ad6ff7f1 100644 --- a/fedireads/views.py +++ b/fedireads/views.py @@ -141,6 +141,15 @@ def register(request): return redirect('/') +def notifications_page(request): + ''' list notitications ''' + data = { + 'notifications': request.user.notification_set.all().order_by('-created_date') + } + request.user.notification_set.update(read=True) + return TemplateResponse(request, 'notifications.html', data) + + def user_page(request, username): ''' profile page for a user ''' content = request.headers.get('Accept')