0008-notifications-add-a-listing-API.patch
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 |
- |