Projet

Général

Profil

0008-notifications-add-a-listing-API.patch

Benjamin Dauvergne, 18 mars 2018 22:13

Télécharger (13 ko)

Voir les différences:

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(-)
combo/apps/notifications/api_views.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
import hmac
18

  
19
from django.conf import settings
20

  
17 21
from rest_framework import serializers, permissions, status
18 22
from rest_framework.generics import GenericAPIView
19 23
from rest_framework.response import Response
......
21 25
from .models import Notification
22 26

  
23 27

  
28
class IsAuthenticatedOrSignedPermission(object):
29
    @classmethod
30
    def signature(self, url):
31
        return hmac.HMAC(settings.SECRET_KEY, url).hexdigest()
32

  
33
    @classmethod
34
    def sign_url(cls, url):
35
        signature = cls.signature(url)
36
        separator = '&' if '?' in url else '?'
37
        return '%s%ssignature=%s' % (url, separator, signature)
38

  
39
    def has_permission(self, request, view):
40
        if request.user and request.user.is_authenticated:
41
            if 'user_id' in view.kwargs and str(request.user.id) == view.kwargs['user_id']:
42
                return True
43
        full_path = request.build_absolute_uri()
44
        if not ('&signature=' in full_path or '?signature=' in full_path):
45
            return False
46
        payload, signature = full_path.rsplit('signature=', 1)
47
        return self.signature(payload[:-1]) == signature
48

  
49

  
24 50
class NotificationSerializer(serializers.Serializer):
25 51
    summary = serializers.CharField(required=True, allow_blank=False, max_length=140)
26 52
    id = serializers.CharField(required=False, allow_null=True)
......
32 58
    duration = serializers.IntegerField(required=False, allow_null=True, min_value=0)
33 59

  
34 60

  
61
class Get(GenericAPIView):
62
    permission_classes = (permissions.IsAuthenticated,)
63
    serializer_class = NotificationSerializer
64

  
65
    def get(self, request, *args, **kwargs):
66
        notifications = Notification.objects.visible(request.user)
67
        data = []
68
        response = {'err': 0, 'data': data}
69
        for notification in notifications:
70
            id = notification.public_id,
71
            data.append({
72
                'id': id,
73
                'acked': notification.acked,
74
                'summary': notification.summary,
75
                'body': notification.body,
76
                'url': notification.url,
77
                'origin': notification.origin,
78
                'end_timestamp': notification.end_timestamp,
79
                'ack_url': IsAuthenticatedOrSignedPermission.sign_url(
80
                    request.build_absolute_uri(notification.ack_url)),
81
                'forget_url': IsAuthenticatedOrSignedPermission.sign_url(
82
                    request.build_absolute_uri(notification.forget_url)),
83
            })
84
        return Response(response)
85

  
86
get = Get.as_view()
87

  
88

  
35 89
class Add(GenericAPIView):
36 90
    permission_classes = (permissions.IsAuthenticated,)
37 91
    serializer_class = NotificationSerializer
......
56 110
            )
57 111
        except ValueError as e:
58 112
            response = {'err': 1, 'err_desc': {'id': [unicode(e)]}}
59
            return Response(response, status.HTTP_400_BAD_REQUEST)
113
            return Response(response, status.HTTP_404_NOT_FOUND)
60 114
        else:
61 115
            response = {'err': 0, 'data': {'id': notification.public_id}}
62 116
            return Response(response)
63 117

  
64 118
add = Add.as_view()
65 119

  
120
class CORSApi(object):
121
    def options(self, request, *args, **kwargs):
122
        response = super(CORSApi, self).options(request, *args, **kwargs)
123
        response['Access-Control-Request-Method'] = 'GET'
124
        response['Access-Control-Allow-Origin'] = '*'
125
        return response
66 126

  
67
class Ack(GenericAPIView):
68
    permission_classes = (permissions.IsAuthenticated,)
69 127

  
70
    def get(self, request, notification_id, *args, **kwargs):
71
        Notification.objects.find(request.user, notification_id).ack()
128
class Ack(CORSApi, GenericAPIView):
129
    permission_classes = (IsAuthenticatedOrSignedPermission,)
130

  
131
    def get(self, request, user_id, notification_id, *args, **kwargs):
132
        Notification.objects.find(user_id, notification_id).ack()
72 133
        return Response({'err': 0})
73 134

  
74 135
ack = Ack.as_view()
75 136

  
76 137

  
77
class Forget(GenericAPIView):
78
    permission_classes = (permissions.IsAuthenticated,)
138
class Forget(CORSApi, GenericAPIView):
139
    permission_classes = (IsAuthenticatedOrSignedPermission,)
79 140

  
80
    def get(self, request, notification_id, *args, **kwargs):
81
        Notification.objects.find(request.user, notification_id).forget()
141
    def get(self, request, user_id, notification_id, *args, **kwargs):
142
        Notification.objects.find(user_id, notification_id).forget()
82 143
        return Response({'err': 0})
83 144

  
84 145
forget = Forget.as_view()
combo/apps/notifications/models.py
22 22
from django.utils.timezone import now, timedelta
23 23
from django.db.models import Q
24 24
from django.db.models.query import QuerySet
25
from django.core.urlresolvers import reverse
25 26

  
26 27
from combo.data.models import CellBase
27 28
from combo.data.library import register_cell_class
......
72 73
    acked = models.BooleanField(_('Acked'), default=False)
73 74
    external_id = models.SlugField(_('External identifier'), null=True)
74 75

  
75

  
76 76
    class Meta:
77 77
        verbose_name = _('Notification')
78 78
        unique_together = (
......
152 152
        self.acked = True
153 153
        self.save(update_fields=['acked'])
154 154

  
155
    @property
156
    def ack_url(self):
157
        return reverse('api-notification-ack', kwargs={
158
            'user_id': self.user_id, 'notification_id': self.public_id})
159

  
160
    @property
161
    def forget_url(self):
162
        return reverse('api-notification-forget', kwargs={
163
            'user_id': self.user_id, 'notification_id': self.public_id})
164

  
155 165

  
156 166
@register_cell_class
157 167
class NotificationsCell(CellBase):
combo/apps/notifications/templates/combo/notificationscell.html
4 4
<ul>
5 5
  {% for notification in notifications %}
6 6
  <li class="combo-notification {% if notification.acked %}combo-notification-acked{% endif %}"
7
      data-combo-notification-id="{{ notification.public_id }}">
7
      data-combo-notification-id="{{ notification.public_id }}"
8
      data-combo-notification-ack-url="{{ notification.ack_url }}"
9
      data-combo-notification-forget-url="{{ notification.forget_url }}">
8 10
    <a href="{{ notification.url|default:"#" }}">{{ notification.summary }}</a>
9 11
    {% if notification.body %}
10 12
    <div class="description">
combo/apps/notifications/urls.py
16 16

  
17 17
from django.conf.urls import url
18 18

  
19
from .api_views import add, ack, forget
19
from .api_views import add, get, ack, forget
20 20

  
21 21
urlpatterns = [
22 22
    url('^api/notification/add/$', add,
23 23
        name='api-notification-add'),
24
    url(r'^api/notification/ack/(?P<notification_id>[\w-]+)/$', ack,
24
    url('^api/notification/get/$', get,
25
        name='api-notification-get'),
26
    url(r'^api/notification/ack/(?P<user_id>\d{1,15})/(?P<notification_id>[\w-]+)/$', ack,
25 27
        name='api-notification-ack'),
26
    url(r'^api/notification/forget/(?P<notification_id>[\w-]+)/$', forget,
28
    url(r'^api/notification/forget/(?P<user_id>\d{1,15})/(?P<notification_id>[\w-]+)/$', forget,
27 29
        name='api-notification-forget'),
28 30
]
tests/test_notification.py
170 170

  
171 171
    del notif['end_timestamp']
172 172
    notif['duration'] = 3600
173
    result = notify(notif, '5', 5)
174
    assert result.end_timestamp.isoformat()[:19] == '2016-11-11T12:11:00'
173
    result1 = notify(notif, '5', 5)
174
    assert result1.end_timestamp.isoformat()[:19] == '2016-11-11T12:11:00'
175 175

  
176 176
    notif['duration'] = '3600'
177
    result = notify(notif, '6', 6)
178
    assert result.end_timestamp.isoformat()[:19] == '2016-11-11T12:11:00'
177
    result2 = notify(notif, '6', 6)
178
    assert result2.end_timestamp.isoformat()[:19] == '2016-11-11T12:11:00'
179 179

  
180
    resp = client.get(reverse('api-notification-ack', kwargs={'notification_id': '6'}))
180
    resp = client.get(result2.ack_url)
181 181
    assert resp.status_code == 200
182 182
    assert Notification.objects.filter(acked=True).count() == 1
183 183
    assert Notification.objects.filter(acked=True).get().public_id == '6'
184 184

  
185
    resp = client.get(reverse('api-notification-forget', kwargs={'notification_id': '5'}))
185
    resp = client.get(result1.forget_url)
186 186
    assert resp.status_code == 200
187 187
    assert Notification.objects.filter(acked=True).count() == 2
188 188
    notif = Notification.objects.find(user, '5').get()
......
220 220
                       json.dumps({'summary': 'ok'}),
221 221
                       content_type='application/json').status_code == 403
222 222
    assert client.get(reverse('api-notification-ack',
223
                              kwargs={'notification_id': '1'})).status_code == 403
223
                              kwargs={'user_id': 1, 'notification_id': '1'})).status_code == 403
224 224
    assert client.get(reverse('api-notification-forget',
225
                              kwargs={'notification_id': '1'})).status_code == 403
225
                              kwargs={'user_id': 1, 'notification_id': '1'})).status_code == 403
226 226

  
227 227

  
228 228
def test_notification_ws_check_urls():
229 229
    assert reverse('api-notification-add') == '/api/notification/add/'
230 230
    assert reverse('api-notification-ack',
231
                   kwargs={'notification_id': 'noti1'}) == '/api/notification/ack/noti1/'
231
                   kwargs={'user_id': '1', 'notification_id': 'noti1'}) == '/api/notification/ack/1/noti1/'
232 232
    assert reverse('api-notification-forget',
233
                   kwargs={'notification_id': 'noti1'}) == '/api/notification/forget/noti1/'
233
                   kwargs={'user_id': '2', 'notification_id': 'noti1'}) == '/api/notification/forget/2/noti1/'
234 234

  
235 235

  
236 236
def test_notification_id_and_origin(user):
......
255 255

  
256 256
    result = notify({'summary': 'foo', 'id': 'foo:foo', 'origin': 'bar'})
257 257
    assert result['err'] == 0
258

  
259

  
260
def test_notification_ws_get(app, user):
261
    Notification.notify(user, 'foo')
262
    Notification.notify(user, 'bar')
263

  
264
    app.set_user(user)
265

  
266
    response = app.get(reverse('api-notification-get'))
267
    result = response.json
268
    assert result['err'] == 0
269
    assert len(result['data']) == 2
270

  
271
    app.set_user(None)
272
    app.session.flush()
273

  
274
    app.get(reverse('api-notification-get'), status=403)
275
    assert Notification.objects.visible(user).new().count() == 2
276

  
277
    for notif in result['data']:
278
        response = app.options(notif['ack_url'])
279
        assert response['Access-Control-Allow-Origin'] == '*'
280
        assert response['Access-Control-Request-Method'] == 'GET'
281
        assert app.get(notif['ack_url'])
282
        assert app.get(notif['ack_url'])
283

  
284
    assert Notification.objects.visible(user).new().count() == 0
285
    assert Notification.objects.visible(user).count() == 2
286

  
287
    for notif in result['data']:
288
        response = app.options(notif['forget_url'])
289
        assert response['Access-Control-Allow-Origin'] == '*'
290
        assert response['Access-Control-Request-Method'] == 'GET'
291
        assert app.get(notif['forget_url'])
292
        assert app.get(notif['forget_url'])
293

  
294
    assert Notification.objects.visible(user).count() == 0
258
-