From 7c8b08fd724723dcbd5d1776af444c8b6eba98d4 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Sat, 17 Mar 2018 10:40:09 +0100 Subject: [PATCH 8/8] notifications: add a listing API - listing API allow showing notifications on a remote site - each notification description contains signed ack and forget URL allowing direct interfaction between the browser and combo from a remote site, based on CORS AJAX calls (authorizations are opened) - ack and forget web-services are also still usable with classi Django session authentication. - first test done using django-webtest&pytest-django --- combo/apps/notifications/api_views.py | 79 +++++++++++++++++++--- combo/apps/notifications/models.py | 12 +++- .../templates/combo/notificationscell.html | 4 +- combo/apps/notifications/urls.py | 8 ++- tests/test_notification.py | 57 +++++++++++++--- 5 files changed, 136 insertions(+), 24 deletions(-) diff --git a/combo/apps/notifications/api_views.py b/combo/apps/notifications/api_views.py index 8737241..1b92db3 100644 --- a/combo/apps/notifications/api_views.py +++ b/combo/apps/notifications/api_views.py @@ -14,6 +14,10 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import hmac + +from django.conf import settings + from rest_framework import serializers, permissions, status from rest_framework.generics import GenericAPIView from rest_framework.response import Response @@ -21,6 +25,28 @@ from rest_framework.response import Response from .models import Notification +class IsAuthenticatedOrSignedPermission(object): + @classmethod + def signature(self, url): + return hmac.HMAC(settings.SECRET_KEY, url).hexdigest() + + @classmethod + def sign_url(cls, url): + signature = cls.signature(url) + separator = '&' if '?' in url else '?' + return '%s%ssignature=%s' % (url, separator, signature) + + def has_permission(self, request, view): + if request.user and request.user.is_authenticated: + if 'user_id' in view.kwargs and str(request.user.id) == view.kwargs['user_id']: + return True + full_path = request.build_absolute_uri() + if not ('&signature=' in full_path or '?signature=' in full_path): + return False + payload, signature = full_path.rsplit('signature=', 1) + return self.signature(payload[:-1]) == signature + + class NotificationSerializer(serializers.Serializer): summary = serializers.CharField(required=True, allow_blank=False, max_length=140) id = serializers.CharField(required=False, allow_null=True) @@ -32,6 +58,34 @@ class NotificationSerializer(serializers.Serializer): duration = serializers.IntegerField(required=False, allow_null=True, min_value=0) +class Get(GenericAPIView): + permission_classes = (permissions.IsAuthenticated,) + serializer_class = NotificationSerializer + + def get(self, request, *args, **kwargs): + notifications = Notification.objects.visible(request.user) + data = [] + response = {'err': 0, 'data': data} + for notification in notifications: + id = notification.public_id, + data.append({ + 'id': id, + 'acked': notification.acked, + 'summary': notification.summary, + 'body': notification.body, + 'url': notification.url, + 'origin': notification.origin, + 'end_timestamp': notification.end_timestamp, + 'ack_url': IsAuthenticatedOrSignedPermission.sign_url( + request.build_absolute_uri(notification.ack_url)), + 'forget_url': IsAuthenticatedOrSignedPermission.sign_url( + request.build_absolute_uri(notification.forget_url)), + }) + return Response(response) + +get = Get.as_view() + + class Add(GenericAPIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = NotificationSerializer @@ -56,29 +110,36 @@ class Add(GenericAPIView): ) except ValueError as e: response = {'err': 1, 'err_desc': {'id': [unicode(e)]}} - return Response(response, status.HTTP_400_BAD_REQUEST) + return Response(response, status.HTTP_404_NOT_FOUND) else: response = {'err': 0, 'data': {'id': notification.public_id}} return Response(response) add = Add.as_view() +class CORSApi(object): + def options(self, request, *args, **kwargs): + response = super(CORSApi, self).options(request, *args, **kwargs) + response['Access-Control-Request-Method'] = 'GET' + response['Access-Control-Allow-Origin'] = '*' + return response -class Ack(GenericAPIView): - permission_classes = (permissions.IsAuthenticated,) - def get(self, request, notification_id, *args, **kwargs): - Notification.objects.find(request.user, notification_id).ack() +class Ack(CORSApi, GenericAPIView): + permission_classes = (IsAuthenticatedOrSignedPermission,) + + def get(self, request, user_id, notification_id, *args, **kwargs): + Notification.objects.find(user_id, notification_id).ack() return Response({'err': 0}) ack = Ack.as_view() -class Forget(GenericAPIView): - permission_classes = (permissions.IsAuthenticated,) +class Forget(CORSApi, GenericAPIView): + permission_classes = (IsAuthenticatedOrSignedPermission,) - def get(self, request, notification_id, *args, **kwargs): - Notification.objects.find(request.user, notification_id).forget() + def get(self, request, user_id, notification_id, *args, **kwargs): + Notification.objects.find(user_id, notification_id).forget() return Response({'err': 0}) forget = Forget.as_view() diff --git a/combo/apps/notifications/models.py b/combo/apps/notifications/models.py index 5422b05..2a1032f 100644 --- a/combo/apps/notifications/models.py +++ b/combo/apps/notifications/models.py @@ -22,6 +22,7 @@ from django.utils.translation import ugettext_lazy as _ from django.utils.timezone import now, timedelta from django.db.models import Q from django.db.models.query import QuerySet +from django.core.urlresolvers import reverse from combo.data.models import CellBase from combo.data.library import register_cell_class @@ -72,7 +73,6 @@ class Notification(models.Model): acked = models.BooleanField(_('Acked'), default=False) external_id = models.SlugField(_('External identifier'), null=True) - class Meta: verbose_name = _('Notification') unique_together = ( @@ -152,6 +152,16 @@ class Notification(models.Model): self.acked = True self.save(update_fields=['acked']) + @property + def ack_url(self): + return reverse('api-notification-ack', kwargs={ + 'user_id': self.user_id, 'notification_id': self.public_id}) + + @property + def forget_url(self): + return reverse('api-notification-forget', kwargs={ + 'user_id': self.user_id, 'notification_id': self.public_id}) + @register_cell_class class NotificationsCell(CellBase): diff --git a/combo/apps/notifications/templates/combo/notificationscell.html b/combo/apps/notifications/templates/combo/notificationscell.html index b815597..999661f 100644 --- a/combo/apps/notifications/templates/combo/notificationscell.html +++ b/combo/apps/notifications/templates/combo/notificationscell.html @@ -4,7 +4,9 @@