Merge lp:~allenap/maas/notifications-websocket-listener 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: 5655
Proposed branch: lp:~allenap/maas/notifications-websocket-listener
Merge into: lp:~maas-committers/maas/trunk
Prerequisite: lp:~allenap/maas/blockdev-sudo-non-interactive
Diff against target: 332 lines (+216/-3)
6 files modified
src/maasserver/models/notification.py (+8/-0)
src/maasserver/models/tests/test_notification.py (+31/-0)
src/maasserver/websockets/handlers/__init__.py (+2/-0)
src/maasserver/websockets/handlers/notification.py (+32/-0)
src/maasserver/websockets/handlers/tests/test_notification.py (+138/-1)
src/maasserver/websockets/tests/test_protocol.py (+5/-2)
To merge this branch: bzr merge lp:~allenap/maas/notifications-websocket-listener
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
Review via email: mp+315356@code.launchpad.net

Commit message

WebSocket listener behaviour for Notification.

To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote :

Looks good to me. Just one thing below to consider changing.

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

Thanks!

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1.7 MiB)

The attempt to merge lp:~allenap/maas/notifications-websocket-listener into lp:maas failed. Below is the output from the failed tests.

Get:1 http://security.ubuntu.com/ubuntu xenial-security InRelease [102 kB]
Hit:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [102 kB]
Hit:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease
Get:5 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/main amd64 Packages [457 kB]
Get:6 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/universe amd64 Packages [383 kB]
Fetched 1,044 kB in 0s (1,728 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind avahi-utils bash bind9 bind9utils build-essential bzr bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common isc-dhcp-server libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libnss-wrapper libpq-dev make nodejs-legacy npm postgresql pxelinux python3-all python3-apt python3-attr python3-bson python3-convoy python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-netaddr python3-netifaces python3-novaclient python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-simplejson python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
authbind is already the newest version (2.1.1+nmu1).
avahi-utils is already the newest version (0.6.32~rc+dfsg-1ubuntu2).
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
distro-info is already the newest version (0.14build1).
git is already the newest version (1:2.7.4-0ubuntu1).
libjs-angularjs is already the newest version (1.2.28-1ubuntu2).
libjs-jquery is already the newest version (1.11.3+dfsg-4).
libjs-yui3-full is already the newest version (3.5.1-1ubuntu3).
libjs-yui3-min is already the newest version (3.5.1-1ubuntu3).
make is already the newest version (4.1-6).
postgresql is already the newest version (9.5+173).
pxelinux is already the newest version (3:6.03+dfsg-11ubuntu1).
python-formencode is already the ...

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-09 15:44:55 +0000
3+++ src/maasserver/models/notification.py 2017-01-24 08:41:46 +0000
4@@ -134,6 +134,14 @@
5 """Render this notification's message using its context."""
6 return self.message.format(**self.context)
7
8+ def is_relevant_to(self, user):
9+ """Is this notification relevant to the given user?"""
10+ return user is not None and (
11+ (self.user_id is not None and self.user_id == user.id) or
12+ (self.users and not user.is_superuser) or
13+ (self.admins and user.is_superuser)
14+ )
15+
16 def dismiss(self, user):
17 """Dismiss this notification.
18
19
20=== modified file 'src/maasserver/models/tests/test_notification.py'
21--- src/maasserver/models/tests/test_notification.py 2017-01-09 15:44:55 +0000
22+++ src/maasserver/models/tests/test_notification.py 2017-01-24 08:41:46 +0000
23@@ -228,6 +228,37 @@
24 self.assertThat(notification.id, Is(None))
25 self.assertThat(Notification.objects.all(), HasLength(0))
26
27+ def test_is_relevant_to_user(self):
28+ user = factory.make_User()
29+ user2 = factory.make_User()
30+ admin = factory.make_admin()
31+
32+ Yes, No = Is(True), Is(False)
33+
34+ notification_to_user = Notification(user=user)
35+ self.assertThat(notification_to_user.is_relevant_to(None), No)
36+ self.assertThat(notification_to_user.is_relevant_to(user), Yes)
37+ self.assertThat(notification_to_user.is_relevant_to(user2), No)
38+ self.assertThat(notification_to_user.is_relevant_to(admin), No)
39+
40+ notification_to_users = Notification(users=True)
41+ self.assertThat(notification_to_users.is_relevant_to(None), No)
42+ self.assertThat(notification_to_users.is_relevant_to(user), Yes)
43+ self.assertThat(notification_to_users.is_relevant_to(user2), Yes)
44+ self.assertThat(notification_to_users.is_relevant_to(admin), No)
45+
46+ notification_to_admins = Notification(admins=True)
47+ self.assertThat(notification_to_admins.is_relevant_to(None), No)
48+ self.assertThat(notification_to_admins.is_relevant_to(user), No)
49+ self.assertThat(notification_to_admins.is_relevant_to(user2), No)
50+ self.assertThat(notification_to_admins.is_relevant_to(admin), Yes)
51+
52+ notification_to_all = Notification(users=True, admins=True)
53+ self.assertThat(notification_to_all.is_relevant_to(None), No)
54+ self.assertThat(notification_to_all.is_relevant_to(user), Yes)
55+ self.assertThat(notification_to_all.is_relevant_to(user2), Yes)
56+ self.assertThat(notification_to_all.is_relevant_to(admin), Yes)
57+
58
59 class TestNotificationRepresentation(MAASServerTestCase):
60 """Tests for the `Notification` representation."""
61
62=== modified file 'src/maasserver/websockets/handlers/__init__.py'
63--- src/maasserver/websockets/handlers/__init__.py 2017-01-23 11:24:56 +0000
64+++ src/maasserver/websockets/handlers/__init__.py 2017-01-24 08:41:46 +0000
65@@ -19,6 +19,7 @@
66 "IPRangeHandler",
67 "IPRangeHandler",
68 "MachineHandler",
69+ "NotificationHandler",
70 "PackageRepositoryHandler",
71 "ServiceHandler",
72 "SpaceHandler",
73@@ -43,6 +44,7 @@
74 from maasserver.websockets.handlers.general import GeneralHandler
75 from maasserver.websockets.handlers.iprange import IPRangeHandler
76 from maasserver.websockets.handlers.machine import MachineHandler
77+from maasserver.websockets.handlers.notification import NotificationHandler
78 from maasserver.websockets.handlers.packagerepository import (
79 PackageRepositoryHandler,
80 )
81
82=== modified file 'src/maasserver/websockets/handlers/notification.py'
83--- src/maasserver/websockets/handlers/notification.py 2017-01-09 16:19:02 +0000
84+++ src/maasserver/websockets/handlers/notification.py 2017-01-24 08:41:46 +0000
85@@ -8,6 +8,7 @@
86 ]
87
88 from maasserver.models.notification import Notification
89+from maasserver.utils.orm import reload_object
90 from maasserver.websockets.handlers.timestampedmodel import (
91 TimestampedModelHandler,
92 )
93@@ -19,6 +20,7 @@
94 object_class = Notification
95 allowed_methods = {'list', 'get', 'dismiss'}
96 exclude = list_exclude = {"context"}
97+ listen_channels = {'notification', 'notificationdismissal'}
98
99 def get_queryset(self):
100 """Return `Notifications` for the current user."""
101@@ -39,3 +41,33 @@
102 notifications = self.get_queryset().filter(id__in=ids)
103 for notification in notifications:
104 notification.dismiss(self.user)
105+
106+ def on_listen(self, channel, action, pk):
107+ """Intercept `on_listen` because dismissals must be handled specially.
108+
109+ The docstring for `on_listen` suggests that handlers should override
110+ `listen` instead of `on_listen`. However, `Handler.on_listen` makes an
111+ assumption about notifications from the database that does not hold
112+ here: dismissals are notified as "$notification_id:$user_id" strings,
113+ not as simply "$notification_id".
114+ """
115+ if channel == "notification":
116+ return super().on_listen(channel, action, pk)
117+ elif channel == "notificationdismissal":
118+ pk, user_id = map(int, pk.split(":"))
119+ if self.user.id == user_id:
120+ # Send a dismissal as a delete of the notification.
121+ return super().on_listen("notification", "delete", pk)
122+ else:
123+ return None # Not relevant to this user.
124+ else:
125+ return None # Channel not recognised.
126+
127+ def listen(self, channel, action, pk):
128+ """Only deal with notifications that are relevant to the user."""
129+ notification = super().listen(channel, action, pk)
130+ user = reload_object(self.user) # Check for up-to-date privs.
131+ if notification.is_relevant_to(user):
132+ return notification
133+ else:
134+ return None
135
136=== modified file 'src/maasserver/websockets/handlers/tests/test_notification.py'
137--- src/maasserver/websockets/handlers/tests/test_notification.py 2017-01-09 16:19:02 +0000
138+++ src/maasserver/websockets/handlers/tests/test_notification.py 2017-01-24 08:41:46 +0000
139@@ -5,16 +5,31 @@
140
141 __all__ = []
142
143+from itertools import product
144+import random
145+from unittest.mock import sentinel
146+
147+from maasserver.models import User
148 from maasserver.models.notification import NotificationDismissal
149 from maasserver.testing.factory import factory
150 from maasserver.testing.testcase import MAASServerTestCase
151-from maasserver.websockets.base import dehydrate_datetime
152+from maasserver.websockets.base import (
153+ dehydrate_datetime,
154+ Handler,
155+)
156 from maasserver.websockets.handlers.notification import NotificationHandler
157+from maastesting.matchers import (
158+ MockCalledOnceWith,
159+ MockNotCalled,
160+)
161+from testscenarios import multiply_scenarios
162 from testtools.matchers import (
163 AfterPreprocessing,
164 AllMatch,
165 Equals,
166 Is,
167+ IsInstance,
168+ MatchesAll,
169 MatchesDict,
170 MatchesListwise,
171 Not,
172@@ -113,3 +128,125 @@
173 self.assertThat(notifications, AllMatch(Not(HasBeenDismissedBy(user))))
174 handler.dismiss({"id": [str(ntfn.id) for ntfn in notifications]})
175 self.assertThat(notifications, AllMatch(HasBeenDismissedBy(user)))
176+
177+
178+class TestNotificationHandlerListening(MAASServerTestCase):
179+ """Tests for `NotificationHandler` listening to database messages."""
180+
181+ def test_on_listen_for_notification_up_calls(self):
182+ super_on_listen = self.patch(Handler, "on_listen")
183+ super_on_listen.return_value = sentinel.on_listen
184+
185+ user = factory.make_User()
186+ handler = NotificationHandler(user, {})
187+
188+ self.assertThat(
189+ handler.on_listen("notification", sentinel.action, sentinel.pk),
190+ Is(sentinel.on_listen))
191+ self.assertThat(
192+ super_on_listen, MockCalledOnceWith(
193+ "notification", sentinel.action, sentinel.pk))
194+
195+ def test_on_listen_for_dismissal_up_calls_with_delete(self):
196+ super_on_listen = self.patch(Handler, "on_listen")
197+ super_on_listen.return_value = sentinel.on_listen
198+
199+ user = factory.make_User()
200+ handler = NotificationHandler(user, {})
201+ notification = factory.make_Notification(user=user)
202+
203+ # A dismissal notification from the database.
204+ dismissal = "%d:%d" % (notification.id, user.id)
205+
206+ self.assertThat(
207+ handler.on_listen(
208+ "notificationdismissal", sentinel.action, dismissal),
209+ Is(sentinel.on_listen))
210+ self.assertThat(
211+ super_on_listen, MockCalledOnceWith(
212+ "notification", "delete", notification.id))
213+
214+ def test_on_listen_for_dismissal_for_other_user_does_nothing(self):
215+ super_on_listen = self.patch(Handler, "on_listen")
216+ super_on_listen.return_value = sentinel.on_listen
217+
218+ user = factory.make_User()
219+ handler = NotificationHandler(user, {})
220+ notification = factory.make_Notification(user=user)
221+
222+ # A dismissal notification from the database FOR ANOTHER USER.
223+ dismissal = "%d:%d" % (notification.id, random.randrange(1, 99999))
224+
225+ self.assertThat(handler.on_listen(
226+ "notificationdismissal", sentinel.action, dismissal), Is(None))
227+ self.assertThat(super_on_listen, MockNotCalled())
228+
229+ def test_listen_reloads_user(self):
230+ admin = factory.make_admin()
231+ handler = NotificationHandler(admin, {})
232+ notification = factory.make_Notification(admins=True)
233+
234+ # The notification is currently relevant to `admin`.
235+ self.assertThat(handler.listen(
236+ "notification", "create", notification.id), Equals(notification))
237+
238+ # Change `admin` to a regular user in the database.
239+ admin_alt = User.objects.get(id=admin.id)
240+ admin_alt.is_superuser = False
241+ admin_alt.save()
242+
243+ # The change is noticed because `handler.listen` reloads the user,
244+ # meaning the notification is no longer relevant.
245+ self.assertThat(handler.listen(
246+ "notification", "create", notification.id), Is(None))
247+
248+
249+class TestNotificationHandlerListeningScenarios(MAASServerTestCase):
250+ """Tests for `NotificationHandler` listening to database messages."""
251+
252+ scenarios_users = (
253+ ("user", dict(make_user=factory.make_User)),
254+ ("admin", dict(make_user=factory.make_admin)),
255+ )
256+
257+ scenarios_notifications = (
258+ ("to-user=%s;to-users=%s;to-admins=%s" % scenario,
259+ dict(zip(("to_user", "to_users", "to_admins"), scenario)))
260+ for scenario in product(
261+ (False, True, "Other"), # To specific user.
262+ (False, True), # To all users.
263+ (False, True), # To all admins.
264+ )
265+ )
266+
267+ scenarios = multiply_scenarios(
268+ scenarios_users, scenarios_notifications)
269+
270+ def test_on_listen(self):
271+ user = self.make_user()
272+
273+ if self.to_user is False:
274+ to_user = None
275+ elif self.to_user is True:
276+ to_user = user
277+ else:
278+ to_user = factory.make_User()
279+
280+ notification = factory.make_Notification(
281+ user=to_user, users=self.to_users, admins=self.to_admins)
282+
283+ if notification.is_relevant_to(user):
284+ expected = MatchesAll(
285+ IsInstance(tuple),
286+ MatchesListwise((
287+ Equals("notification"), Equals("create"),
288+ MatchesRenderedNotification(notification),
289+ )),
290+ first_only=True,
291+ )
292+ else:
293+ expected = Is(None)
294+
295+ handler = NotificationHandler(user, {})
296+ self.assertThat(handler.on_listen(
297+ "notification", "create", notification.id), expected)
298
299=== modified file 'src/maasserver/websockets/tests/test_protocol.py'
300--- src/maasserver/websockets/tests/test_protocol.py 2016-10-26 15:38:28 +0000
301+++ src/maasserver/websockets/tests/test_protocol.py 2017-01-24 08:41:46 +0000
302@@ -697,6 +697,8 @@
303 "fabric",
304 "iprange",
305 "machine",
306+ "notification",
307+ "notificationdismissal",
308 "packagerepository",
309 "service",
310 "space",
311@@ -710,18 +712,19 @@
312 )
313
314 ALL_HANDLERS = (
315- "config",
316 "bootresource",
317+ "config",
318 "controller",
319 "device",
320+ "dhcpsnippet",
321 "discovery",
322- "dhcpsnippet",
323 "domain",
324 "event",
325 "fabric",
326 "general",
327 "iprange",
328 "machine",
329+ "notification",
330 "packagerepository",
331 "service",
332 "space",