Merge lp:~allenap/maas/notifications-websocket into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 5630
Proposed branch: lp:~allenap/maas/notifications-websocket
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 285 lines (+203/-5)
5 files modified
src/maasserver/models/notification.py (+16/-4)
src/maasserver/models/tests/test_notification.py (+7/-1)
src/maasserver/testing/factory.py (+24/-0)
src/maasserver/websockets/handlers/notification.py (+41/-0)
src/maasserver/websockets/handlers/tests/test_notification.py (+115/-0)
To merge this branch: bzr merge lp:~allenap/maas/notifications-websocket
Reviewer Review Type Date Requested Status
Lee Trager (community) Approve
Review via email: mp+314345@code.launchpad.net

Commit message

Basic WebSocket handler for Notification.

To post a comment you must log in.
Revision history for this message
Lee Trager (ltrager) wrote :

LGTM just one comment below.

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote :

Thanks for the review.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/models/notification.py'
2--- src/maasserver/models/notification.py 2017-01-04 15:59:23 +0000
3+++ src/maasserver/models/notification.py 2017-01-09 16:30:12 +0000
4@@ -8,6 +8,7 @@
5 ]
6
7 from django.contrib.auth.models import User
8+from django.db import connection
9 from django.db.models import (
10 BooleanField,
11 CharField,
12@@ -66,17 +67,28 @@
13 def find_for_user(self, user):
14 """Find notifications for the given user.
15
16- :return: A `RawQuerySet` of `Notification` instances that haven't been
17+ :return: A `QuerySet` of `Notification` instances that haven't been
18 dismissed by `user`.
19 """
20 if user.is_superuser:
21 where = "notification.users OR notification.admins"
22 else:
23 where = "notification.users"
24- return self.raw(self._sql_find_for_user % where, [user.id, user.id])
25+ # We want to return a QuerySet because things like the WebSocket
26+ # handler code wants to use order_by. This seems reasonable. However,
27+ # we can't do outer joins with Django so we have to use self.raw().
28+ # However #2, that returns a RawQuerySet which doesn't do order_by.
29+ # Nor can we do self.filter(id__in=self.raw(...)) to "legitimise" a
30+ # raw query. Nope, we have to actually fetch those IDs then issue
31+ # another query to get a QuerySet for a set of Notification rows.
32+ with connection.cursor() as cursor:
33+ find_ids = self._sql_find_ids_for_user % where
34+ cursor.execute(find_ids, [user.id, user.id])
35+ ids = [nid for (nid, ) in cursor.fetchall()]
36+ return self.filter(id__in=ids)
37
38- _sql_find_for_user = """\
39- SELECT notification.* FROM maasserver_notification AS notification
40+ _sql_find_ids_for_user = """\
41+ SELECT notification.id FROM maasserver_notification AS notification
42 LEFT OUTER JOIN maasserver_notificationdismissal AS dismissal ON
43 (dismissal.notification_id = notification.id AND dismissal.user_id = %%s)
44 WHERE (notification.user_id = %%s OR %s) AND dismissal.id IS NULL
45
46=== modified file 'src/maasserver/models/tests/test_notification.py'
47--- src/maasserver/models/tests/test_notification.py 2017-01-05 09:49:36 +0000
48+++ src/maasserver/models/tests/test_notification.py 2017-01-09 16:30:12 +0000
49@@ -7,6 +7,7 @@
50
51 import random
52
53+from django.db.models.query import QuerySet
54 from maasserver.models.notification import Notification
55 from maasserver.testing.factory import factory
56 from maasserver.testing.testcase import MAASServerTestCase
57@@ -16,6 +17,8 @@
58 Equals,
59 HasLength,
60 Is,
61+ IsInstance,
62+ MatchesAll,
63 MatchesStructure,
64 Not,
65 )
66@@ -165,7 +168,10 @@
67 def assertNotifications(self, user, notifications):
68 self.assertThat(
69 Notification.objects.find_for_user(user),
70- AfterPreprocessing(list, Equals(notifications)))
71+ MatchesAll(
72+ IsInstance(QuerySet), # Not RawQuerySet.
73+ AfterPreprocessing(list, Equals(notifications)),
74+ ))
75
76 def test_find_and_dismiss_notifications_for_user(self):
77 user = factory.make_User()
78
79=== modified file 'src/maasserver/testing/factory.py'
80--- src/maasserver/testing/factory.py 2017-01-05 18:32:27 +0000
81+++ src/maasserver/testing/factory.py 2017-01-09 16:30:12 +0000
82@@ -82,6 +82,7 @@
83 MDNS,
84 Neighbour,
85 Node,
86+ Notification,
87 OwnerData,
88 PackageRepository,
89 Partition,
90@@ -2067,6 +2068,29 @@
91 distributions=distributions, disabled_pockets=disabled_pockets,
92 components=components, arches=arches, key=key, default=default)
93
94+ def make_Notification(
95+ self, message=None, *, ident=None, user=None, users=False,
96+ admins=False, context=None):
97+
98+ if context is None:
99+ context_name = self.make_name("name")
100+ context = {context_name: self.make_name("value")}
101+
102+ if message is None:
103+ context_names = [key for key in context if key.isidentifier()]
104+ if len(context_names) == 0:
105+ message = self.make_name("message")
106+ else:
107+ context_name = random.choice(context_names)
108+ message = self.make_name("message-{%s}" % context_name)
109+
110+ notification = Notification(
111+ ident=ident, user=user, users=users, admins=admins,
112+ message=message, context=context)
113+ notification.save()
114+
115+ return notification
116+
117
118 # Create factory singleton.
119 factory = Factory()
120
121=== added file 'src/maasserver/websockets/handlers/notification.py'
122--- src/maasserver/websockets/handlers/notification.py 1970-01-01 00:00:00 +0000
123+++ src/maasserver/websockets/handlers/notification.py 2017-01-09 16:30:12 +0000
124@@ -0,0 +1,41 @@
125+# Copyright 2017 Canonical Ltd. This software is licensed under the
126+# GNU Affero General Public License version 3 (see the file LICENSE).
127+
128+"""The notification handler for the WebSocket connection."""
129+
130+__all__ = [
131+ "NotificationHandler",
132+]
133+
134+from maasserver.models.notification import Notification
135+from maasserver.websockets.handlers.timestampedmodel import (
136+ TimestampedModelHandler,
137+)
138+
139+
140+class NotificationHandler(TimestampedModelHandler):
141+
142+ class Meta:
143+ object_class = Notification
144+ allowed_methods = {'list', 'get', 'dismiss'}
145+ exclude = list_exclude = {"context"}
146+
147+ def get_queryset(self):
148+ """Return `Notifications` for the current user."""
149+ return Notification.objects.find_for_user(self.user)
150+
151+ def dehydrate(self, obj, data, for_list=False):
152+ data["message"] = obj.render()
153+ return data
154+
155+ def dismiss(self, params):
156+ """Dismiss the given notification(s).
157+
158+ :param id: One or more notification IDs to dismiss for the currently
159+ logged-in user.
160+ """
161+ data = self.preprocess_form("dismiss", params)
162+ ids = map(int, data.getlist("id"))
163+ notifications = self.get_queryset().filter(id__in=ids)
164+ for notification in notifications:
165+ notification.dismiss(self.user)
166
167=== added file 'src/maasserver/websockets/handlers/tests/test_notification.py'
168--- src/maasserver/websockets/handlers/tests/test_notification.py 1970-01-01 00:00:00 +0000
169+++ src/maasserver/websockets/handlers/tests/test_notification.py 2017-01-09 16:30:12 +0000
170@@ -0,0 +1,115 @@
171+# Copyright 2017 Canonical Ltd. This software is licensed under the
172+# GNU Affero General Public License version 3 (see the file LICENSE).
173+
174+"""Tests for `maasserver.websockets.handlers.notification`."""
175+
176+__all__ = []
177+
178+from maasserver.models.notification import NotificationDismissal
179+from maasserver.testing.factory import factory
180+from maasserver.testing.testcase import MAASServerTestCase
181+from maasserver.websockets.base import dehydrate_datetime
182+from maasserver.websockets.handlers.notification import NotificationHandler
183+from testtools.matchers import (
184+ AfterPreprocessing,
185+ AllMatch,
186+ Equals,
187+ Is,
188+ MatchesDict,
189+ MatchesListwise,
190+ Not,
191+)
192+
193+
194+def MatchesRenderedNotification(ntfn):
195+ """Return a matcher for a rendered notification."""
196+ return MatchesDict({
197+ "id": Equals(ntfn.id),
198+ "ident": Is(None) if ntfn.ident is None else Equals(ntfn.ident),
199+ "user": Is(None) if ntfn.user_id is None else Equals(ntfn.user_id),
200+ "users": Is(ntfn.users),
201+ "admins": Is(ntfn.admins),
202+ "updated": Equals(dehydrate_datetime(ntfn.updated)),
203+ "created": Equals(dehydrate_datetime(ntfn.created)),
204+ "message": Equals(ntfn.render()),
205+ })
206+
207+
208+def HasBeenDismissedBy(user):
209+ """Has `user` dismissed the notification we're matching against?"""
210+
211+ def dismissed_by_user(notification):
212+ return NotificationDismissal.objects.filter(
213+ notification=notification, user=user).exists()
214+
215+ return AfterPreprocessing(dismissed_by_user, Is(True))
216+
217+
218+class TestNotificationHandler(MAASServerTestCase):
219+ """Tests for `NotificationHandler`."""
220+
221+ def test_get(self):
222+ user = factory.make_User()
223+ handler = NotificationHandler(user, {})
224+ notification = factory.make_Notification(user=user)
225+ self.assertThat(
226+ handler.get({"id": notification.id}),
227+ MatchesRenderedNotification(notification))
228+
229+ def test_list(self):
230+ user = factory.make_User()
231+ user2 = factory.make_User()
232+ handler = NotificationHandler(user, {})
233+ notifications = [
234+ factory.make_Notification(user=user), # Will match.
235+ factory.make_Notification(user=user2),
236+ factory.make_Notification(users=True), # Will match.
237+ factory.make_Notification(users=False),
238+ factory.make_Notification(admins=True),
239+ factory.make_Notification(admins=False),
240+ ]
241+ expected = [
242+ MatchesRenderedNotification(notifications[0]),
243+ MatchesRenderedNotification(notifications[2]),
244+ ]
245+ self.assertThat(
246+ handler.list({}), MatchesListwise(expected))
247+
248+ def test_list_for_admin(self):
249+ admin = factory.make_admin()
250+ admin2 = factory.make_admin()
251+ handler = NotificationHandler(admin, {})
252+ notifications = [
253+ factory.make_Notification(user=admin), # Will match.
254+ factory.make_Notification(user=admin2),
255+ factory.make_Notification(users=True), # Will match.
256+ factory.make_Notification(users=False),
257+ factory.make_Notification(admins=True), # Will match.
258+ factory.make_Notification(admins=False),
259+ ]
260+ expected = [
261+ MatchesRenderedNotification(notifications[0]),
262+ MatchesRenderedNotification(notifications[2]),
263+ MatchesRenderedNotification(notifications[4]),
264+ ]
265+ self.assertThat(
266+ handler.list({}), MatchesListwise(expected))
267+
268+ def test_dismiss(self):
269+ user = factory.make_User()
270+ handler = NotificationHandler(user, {})
271+ notification = factory.make_Notification(user=user)
272+ self.assertThat(notification, Not(HasBeenDismissedBy(user)))
273+ handler.dismiss({"id": str(notification.id)})
274+ self.assertThat(notification, HasBeenDismissedBy(user))
275+
276+ def test_dismiss_multiple(self):
277+ user = factory.make_User()
278+ handler = NotificationHandler(user, {})
279+ notifications = [
280+ factory.make_Notification(user=user),
281+ factory.make_Notification(users=True),
282+ ]
283+ self.assertThat(notifications, AllMatch(Not(HasBeenDismissedBy(user))))
284+ handler.dismiss({"id": [str(ntfn.id) for ntfn in notifications]})
285+ self.assertThat(notifications, AllMatch(HasBeenDismissedBy(user)))