From 0fff53ee3c25abaa4f38d54941a31a1fe767249a Mon Sep 17 00:00:00 2001 From: Christophe Henry <contact@c-henry.fr> Date: Wed, 6 Nov 2019 17:52:48 +0100 Subject: [PATCH] feat: Notifications management page (backend) (startinblox/applications/sib-app#255) - feat: Add NotificationsSettings model (startinblox/djangoldp-packages/djangoldp-notifications#18) - feat: Expose the model and expose user's subscriptions(startinblox/djangoldp-packages/djangoldp-notifications#19 --- .gitlab-ci.yml | 19 ++- djangoldp_notification/djangoldp_urls.py | 11 ++ djangoldp_notification/middlewares.py | 31 +++++ .../migrations/0003_auto_20191114_1033.py | 42 +++++++ .../migrations/0004_auto_20191119_1540.py | 29 +++++ djangoldp_notification/models.py | 110 +++++++++++++++--- djangoldp_notification/permissions.py | 6 +- djangoldp_notification/settings.py | 2 +- djangoldp_notification/tests/conftest.py | 15 +++ djangoldp_notification/tests/factories.py | 36 ++++++ djangoldp_notification/tests/test_models.py | 28 +++++ djangoldp_notification/tests/test_views.py | 78 +++++++++++++ djangoldp_notification/tests/utils.py | 5 + djangoldp_notification/views.py | 25 ++++ setup.cfg | 5 +- 15 files changed, 419 insertions(+), 23 deletions(-) create mode 100644 djangoldp_notification/djangoldp_urls.py create mode 100644 djangoldp_notification/middlewares.py create mode 100644 djangoldp_notification/migrations/0003_auto_20191114_1033.py create mode 100644 djangoldp_notification/migrations/0004_auto_20191119_1540.py create mode 100644 djangoldp_notification/tests/conftest.py create mode 100644 djangoldp_notification/tests/factories.py create mode 100644 djangoldp_notification/tests/test_models.py create mode 100644 djangoldp_notification/tests/test_views.py create mode 100644 djangoldp_notification/tests/utils.py create mode 100644 djangoldp_notification/views.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 137fb38..49d79db 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,10 +5,27 @@ stages: - test - release +variables: + SIB_PROJECT_NAME: "sib_test" + SIB_PROJECT_PATH: "$CI_BUILD_DIR/SIB_PROJECT_NAME" + CI_BUILD_DIR: "/builds/startingblox" + DJANGO_SETTINGS_MODULE: "$SIB_PROJECT_NAME.settings" + PYTHONPATH: "$CI_BUILD_DIR/$SIB_PROJECT_NAME" + test: stage: test + before_script: + - export PATH="$PATH:/root/.local/bin" + - mkdir -p $CI_BUILD_DIR + - cd $CI_BUILD_DIR + - pip install git+https://git.happy-dev.fr/startinblox/devops/sib.git@test-mode + - DJANGO_SETTINGS_MODULE="" sib startproject $SIB_PROJECT_NAME --test + - cd $CI_BUILD_DIR/$SIB_PROJECT_NAME + - sib install $SIB_PROJECT_NAME + - cd $CI_PROJECT_DIR + - pip install -e .[dev] script: - - echo 'Make your tests here !' + - pytest except: - master tags: diff --git a/djangoldp_notification/djangoldp_urls.py b/djangoldp_notification/djangoldp_urls.py new file mode 100644 index 0000000..1a82fbf --- /dev/null +++ b/djangoldp_notification/djangoldp_urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import url + +from djangoldp_notification.views import UserSubscriptionsViewset + +urlpatterns = [ + url( + r'^user-subscriptions/(?P<slug>\w+)/$', + UserSubscriptionsViewset.as_view(UserSubscriptionsViewset.list_actions), + name="user-subscriptions" + ), +] diff --git a/djangoldp_notification/middlewares.py b/djangoldp_notification/middlewares.py new file mode 100644 index 0000000..d67000a --- /dev/null +++ b/djangoldp_notification/middlewares.py @@ -0,0 +1,31 @@ +from django.db.models import signals + + +MODEL_MODIFICATION_USER_FIELD = 'modification_user' + + +class CurrentUserMiddleware: + def __init__(self, get_response=None): + self.get_response = get_response + + def __call__(self, request): + self.process_request(request) + response = self.get_response(request) + signals.pre_save.disconnect(dispatch_uid=request) + return response + + def process_request(self, request): + if request.method in ('GET', 'HEAD', 'OPTION'): + # this request shouldn't update anything + # so no signal handler should be attached + return + + if hasattr(request, 'user') and request.user.is_authenticated(): + user = request.user + else: + user = None + + def _update_users(sender, instance, **kwargs): + setattr(instance, MODEL_MODIFICATION_USER_FIELD, user) + + signals.pre_save.connect(_update_users, dispatch_uid=request, weak=False) diff --git a/djangoldp_notification/migrations/0003_auto_20191114_1033.py b/djangoldp_notification/migrations/0003_auto_20191114_1033.py new file mode 100644 index 0000000..d3ec4c1 --- /dev/null +++ b/djangoldp_notification/migrations/0003_auto_20191114_1033.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.25 on 2019-11-14 10:33 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import djangoldp.fields +import djangoldp_notification.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('djangoldp_notification', '0002_auto_20190917_1107'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('urlid', djangoldp.fields.LDPUrlField(blank=True, null=True, unique=True)), + ('receive_by_email', models.BooleanField(default=True, verbose_name='I want to receive notifications by email')), + ('receive_for_direct_messages', models.BooleanField(default=True, verbose_name='I want to receive notifications for direct messages')), + ('receive_push', models.BooleanField(default=True, verbose_name='I want to receive push notifications')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='notification_settings', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'notification settings', + 'verbose_name_plural': 'notification settings', + 'abstract': False, + 'default_permissions': ('add', 'change', 'delete', 'view', 'control'), + }, + ), + migrations.AlterField( + model_name='notification', + name='summary', + field=models.TextField(blank=True), + ), + ] diff --git a/djangoldp_notification/migrations/0004_auto_20191119_1540.py b/djangoldp_notification/migrations/0004_auto_20191119_1540.py new file mode 100644 index 0000000..b11302e --- /dev/null +++ b/djangoldp_notification/migrations/0004_auto_20191119_1540.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.25 on 2019-11-19 15:40 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations + +from djangoldp_notification.models import NotificationSettings + + +def migrate_notification_settings(apps, schema_editor): + LDPUser = apps.get_model(settings.AUTH_USER_MODEL) + + for user in LDPUser.objects.all(): + NotificationSettings.create_default(user) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ('djangoldp_notification', '0003_auto_20191114_1033'), + ] + + operations = [ + migrations.RunPython(migrate_notification_settings, reverse_code=noop), + ] diff --git a/djangoldp_notification/models.py b/djangoldp_notification/models.py index 980dbfb..2049292 100644 --- a/djangoldp_notification/models.py +++ b/djangoldp_notification/models.py @@ -1,22 +1,26 @@ import logging +from enum import Enum from threading import Thread import requests +from django.apps import apps from django.conf import settings -from django.contrib.admin.models import LogEntry -from django.contrib.sessions.models import Session +from django.core.exceptions import ValidationError from django.core.mail import send_mail from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver -from oidc_provider.models import Token -from django.urls import NoReverseMatch +from django.template import loader +from django.urls import NoReverseMatch, reverse +from django.utils.decorators import classonlymethod +from django.utils.translation import ugettext_lazy as _ + from djangoldp.fields import LDPUrlField from djangoldp.models import Model -from django.template import loader -from .permissions import InboxPermissions +from djangoldp_notification.middlewares import MODEL_MODIFICATION_USER_FIELD +from djangoldp_notification.permissions import InboxPermissions, SubscriptionsPermissions class Notification(Model): @@ -24,7 +28,7 @@ class Notification(Model): author = LDPUrlField() object = LDPUrlField() type = models.CharField(max_length=255) - summary = models.TextField() + summary = models.TextField(blank=True) date = models.DateTimeField(auto_now_add=True) unread = models.BooleanField(default=True) @@ -40,6 +44,50 @@ class Notification(Model): return '{}'.format(self.type) +class NotificationSettings(Model): + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + related_name="notification_settings", + on_delete=models.deletion.CASCADE + ) + receive_by_email = models.BooleanField(_("I want to receive notifications by email"), default=True) + receive_for_direct_messages = models.BooleanField( + _("I want to receive notifications for direct messages"), + default=True + ) + receive_push = models.BooleanField(_("I want to receive push notifications"), default=True) + + class Meta(Model.Meta): + owner_field = "user" + permission_classes = [InboxPermissions] + authenticated_perms = ["inherit"] + owner_perms = ["view", "change", "control"] + verbose_name = "notification settings" + verbose_name_plural = "notification settings" + container_path = "notificationsettings" + serializer_fields = ["user", "receive_by_email", "receive_for_direct_messages", "receive_push", "subscriptions"] + + def __str__(self): + return "{}<{}>".format(self.__class__.__name__, self.user) + + def clean(self): + if self.pk: + old_user_pk = NotificationSettings.objects.filter(pk=self.pk).values("user").first().get("user") + new_user_pk = self.user.pk + if old_user_pk != new_user_pk: + raise ValidationError("Can't modify user field") + super().clean() + + @property + def subscriptions(self): + return "{}{}".format(settings.BASE_URL, reverse("user-subscriptions", kwargs={'slug': self.user.slug})) + + @classonlymethod + def create_default(cls, user): + LDPUser = apps.get_model(settings.AUTH_USER_MODEL) + NotificationSettings.objects.get_or_create(user=LDPUser.objects.get(pk=user.pk)) + + class Subscription(Model): object = models.URLField() inbox = models.URLField() @@ -47,32 +95,46 @@ class Subscription(Model): def __str__(self): return '{}'.format(self.object) + class Meta(Model.Meta): + pass + # permission_classes = [SubscriptionsPermissions] # --- SUBSCRIPTION SYSTEM --- @receiver(post_save, dispatch_uid="callback_notif") -def send_notification(sender, instance, **kwargs): +def send_notification(sender, instance, created, **kwargs): if sender != Notification: threads = [] try: - urlContainer = settings.BASE_URL + Model.container_id(instance) - urlResource = settings.BASE_URL + Model.resource_id(instance) + url_container = settings.BASE_URL + Model.container_id(instance) + url_resource = settings.BASE_URL + Model.resource_id(instance) except NoReverseMatch: return - for subscription in Subscription.objects.filter(models.Q(object=urlResource)|models.Q(object=urlContainer)): - process = Thread(target=send_request, args=[subscription.inbox, urlResource]) + for subscription in Subscription.objects.filter(models.Q(object=url_resource) | models.Q(object=url_container)): + process = Thread(target=send_request, args=[subscription.inbox, url_resource, instance, created]) process.start() threads.append(process) -def send_request(target, object_iri): +def send_request(target, object_iri, instance, created): + unknown = _("Unknown author") + author = getattr(getattr(instance, MODEL_MODIFICATION_USER_FIELD, unknown), "urlid", unknown) + request_type = "creation" if created else "update" + try: - req = requests.post(target, - json={"@context": "https://cdn.happy-dev.fr/owl/hdcontext.jsonld", - "object": object_iri, "type": "update"}, - headers={"Content-Type": "application/ld+json"}) - except: - logging.error('Djangoldp_notifications: Error with request') + if target.startswith(settings.SITE_URL): + user = Model.resolve_parent(target.replace(settings.SITE_URL, '')) + Notification.objects.create(user=user, object=object_iri, type=request_type, author=author) + else: + json = { + "@context": settings.LDP_RDF_CONTEXT, + "object": object_iri, + "author": author, + "type": request_type + } + requests.post(target, json=json, headers={"Content-Type": "application/ld+json"}) + except Exception as e: + logging.error('Djangoldp_notifications: Error with request: {}'.format(e)) return True @@ -118,3 +180,13 @@ def send_email_on_notification(sender, instance, created, **kwargs): fail_silently=True, html_message=html_message ) + + +@receiver(post_save, sender=settings.AUTH_USER_MODEL) +def create_notification_settings(sender, instance, created, **kwargs): + if created: + NotificationSettings.objects.get_or_create(user=instance) + else: + user_settings = NotificationSettings.objects.filter(user__pk=instance.pk) + if len(user_settings) == 0: + NotificationSettings.objects.get_or_create(user=instance) diff --git a/djangoldp_notification/permissions.py b/djangoldp_notification/permissions.py index f013752..b2a239b 100644 --- a/djangoldp_notification/permissions.py +++ b/djangoldp_notification/permissions.py @@ -35,4 +35,8 @@ class InboxPermissions(LDPPermissions): if not perm.split('.')[1].split('_')[0] in self.user_permissions(request.user, model, obj): return False - return True \ No newline at end of file + return True + + +class SubscriptionsPermissions: + pass diff --git a/djangoldp_notification/settings.py b/djangoldp_notification/settings.py index 3428c3a..65ef3c0 100644 --- a/djangoldp_notification/settings.py +++ b/djangoldp_notification/settings.py @@ -1 +1 @@ -USER_NESTED_FIELDS = ['inbox'] \ No newline at end of file +USER_NESTED_FIELDS = ['inbox', 'notification_settings'] diff --git a/djangoldp_notification/tests/conftest.py b/djangoldp_notification/tests/conftest.py new file mode 100644 index 0000000..b1beb8b --- /dev/null +++ b/djangoldp_notification/tests/conftest.py @@ -0,0 +1,15 @@ +from importlib import import_module +from os import environ + +from pytest import fail + +settings_module = environ.get("DJANGO_SETTINGS_MODULE") +if settings_module is None or len(settings_module) == 0: + fail("DJANGO_SETTINGS_MODULE needs to be defined and point to your SIB app installation settings") + +try: + import_module(settings_module) +except ImportError: + initial_module = [token for token in settings_module.split(".") if len(token) > 0][0] + fail("Unable to import {}. Try to configure PYTHONPATH to point the " + "directory containing the {} module".format(settings_module, initial_module)) diff --git a/djangoldp_notification/tests/factories.py b/djangoldp_notification/tests/factories.py new file mode 100644 index 0000000..52a9fa9 --- /dev/null +++ b/djangoldp_notification/tests/factories.py @@ -0,0 +1,36 @@ +import factory + +from django.db.models.signals import post_save +from djangoldp.factories import UserFactory +from djangoldp_circle.models import Circle + +from djangoldp_notification.models import NotificationSettings + + +@factory.django.mute_signals(post_save) +class CircleFactory(factory.django.DjangoModelFactory): + class Meta: + model = Circle + + urlid = factory.Faker("url") + name = factory.Faker('word') + description = factory.Faker('text', max_nb_chars=250) + owner = factory.SubFactory(UserFactory) + jabberID = factory.Faker('email') + jabberRoom = True + + @factory.post_generation + def members(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for member in extracted: + self.team.add(member) + + +class NotificationSettingsFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + + class Meta: + model = NotificationSettings diff --git a/djangoldp_notification/tests/test_models.py b/djangoldp_notification/tests/test_models.py new file mode 100644 index 0000000..24b451d --- /dev/null +++ b/djangoldp_notification/tests/test_models.py @@ -0,0 +1,28 @@ +from djangoldp.factories import UserFactory + +from djangoldp_notification.models import Subscription +from djangoldp_notification.tests.factories import CircleFactory, NotificationSettingsFactory +from djangoldp_notification.tests.utils import DjangoLDPTestCase + + +class TestNotificationOrigin(DjangoLDPTestCase): + user_settings = None + user = None + circle = None + + def setUp(self): + self.user = user = UserFactory(username="karl_marx") + self.circle1 = CircleFactory(owner=user) + self.user_settings = NotificationSettingsFactory(user=user) + Subscription.objects.create(object=self.circle1.urlid, inbox="https://localhost/users/karl_marx/inbox/") + + def test_serialises(self): + result = dict(self.get("/notificationsettings/1/").data) + expected = { + 'receive_by_email': True, + 'receive_for_direct_messages': True, + 'receive_push': True, + 'subscriptions': 'http://localhost:8000/user-subscriptions/karl_marx/' + } + [result.pop(k) for k in ['user', '@id', 'permissions', '@context']] + self.assertDictEqual(result, expected) diff --git a/djangoldp_notification/tests/test_views.py b/djangoldp_notification/tests/test_views.py new file mode 100644 index 0000000..ce8e2da --- /dev/null +++ b/djangoldp_notification/tests/test_views.py @@ -0,0 +1,78 @@ +from django.urls import reverse +from djangoldp.factories import UserFactory + +from djangoldp_notification.models import Subscription +from djangoldp_notification.tests.factories import CircleFactory, NotificationSettingsFactory +from djangoldp_notification.tests.utils import DjangoLDPTestCase + + +class TestUserSubscriptionsViewset(DjangoLDPTestCase): + user_settings = None + user1 = None + user2 = None + circle = None + + def setUp(self): + self.user1 = UserFactory(username="karl_marx", password="password") + self.user2 = UserFactory(username="peter_kropotkin", password="password") + self.circle1 = CircleFactory(owner=self.user1, urlid='http://green.org/') + self.user_settings = NotificationSettingsFactory(user=self.user1) + Subscription.objects.create(object=self.circle1.urlid, inbox="https://localhost/users/karl_marx/inbox/") + + def test_get_queryset(self): + """ + Asserts that `user-subscriptions` API URL returns a list of the current logged user's usbscriptions + """ + user_subscription_url = reverse("user-subscriptions", kwargs={'slug': self.user1.slug}) + + with self.login(self.user1): + result = dict(self.get(user_subscription_url).data) + result['ldp:contains'] = [dict(item) for item in result['ldp:contains']] + + expected = { + '@context': 'https://cdn.happy-dev.fr/owl/hdcontext.jsonld', + '@id': 'http://localhost:8000/user-subscriptions/karl_marx/', + '@type': 'ldp:Container', + 'ldp:contains': [ + { + '@id': 'http://localhost:8000/subscriptions/1/', + 'object': 'http://green.org/', + 'inbox': 'https://localhost/users/karl_marx/inbox/', + 'permissions': [{'mode': {'@type': 'view'}}] + } + ], + 'permissions': [{'mode': {'@type': 'view'}}] + } + self.assertDictEqual(result, expected) + + def test_get_queryset_anonyous(self): + """ + Asserts that `user-subscriptions` API URL returns an list of usbscriptions for the anonymous user + or if currently logged user queries another user's subscriptions. + """ + user_subscription_url = reverse("user-subscriptions", kwargs={'slug': self.user2.slug}) + + # Logged with user2 + with self.login(self.user1): + result = dict(self.get(user_subscription_url).data) + + expected = { + '@context': 'https://cdn.happy-dev.fr/owl/hdcontext.jsonld', + '@id': 'http://localhost:8000/user-subscriptions/peter_kropotkin/', + '@type': 'ldp:Container', + 'ldp:contains': [], + 'permissions': [{'mode': {'@type': 'view'}}] + } + self.assertDictEqual(result, expected) + + # Anonymous scenario + result = dict(self.get(user_subscription_url).data) + + expected = { + '@context': 'https://cdn.happy-dev.fr/owl/hdcontext.jsonld', + '@id': 'http://localhost:8000/user-subscriptions/peter_kropotkin/', + '@type': 'ldp:Container', + 'ldp:contains': [], + 'permissions': [{'mode': {'@type': 'view'}}] + } + self.assertDictEqual(result, expected) diff --git a/djangoldp_notification/tests/utils.py b/djangoldp_notification/tests/utils.py new file mode 100644 index 0000000..6260eb9 --- /dev/null +++ b/djangoldp_notification/tests/utils.py @@ -0,0 +1,5 @@ +from test_plus import TestCase + + +class DjangoLDPTestCase(TestCase): + pass diff --git a/djangoldp_notification/views.py b/djangoldp_notification/views.py new file mode 100644 index 0000000..f1439e7 --- /dev/null +++ b/djangoldp_notification/views.py @@ -0,0 +1,25 @@ +import logging + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.urls import reverse, NoReverseMatch +from djangoldp.views import LDPViewSet + +from djangoldp_notification.models import Subscription + + +class UserSubscriptionsViewset(LDPViewSet): + model = Subscription + + def get_queryset(self): + user = self.request.user + slug = self.kwargs.get("slug") + if isinstance(user, AnonymousUser) or self.request.user.slug != slug: + return Subscription.objects.none() + + try: + user_inbox = reverse("ldpuser-notification-list", kwargs={'slug': slug}) + return super().get_queryset().filter(inbox__endswith=user_inbox).all() + except NoReverseMatch as e: + logging.error("Error while trying to get subscriptions for user %s: %s", self.request.user.username, e) + return super().get_queryset().none() diff --git a/setup.cfg b/setup.cfg index ee79f41..e3d0e1b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,7 +15,10 @@ install_requires = [options.extras_require] include_package_data = True dev = - factory_boy>=2.11.0 + factory_boy>=2.12.0 + pytest==5.1.1 + pytest-django==3.5.1 + django-test-plus==1.3.1 [semantic_release] version_source = tag -- GitLab