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
=== modified file 'src/maasserver/models/notification.py'
--- src/maasserver/models/notification.py 2017-01-04 15:59:23 +0000
+++ src/maasserver/models/notification.py 2017-01-09 16:30:12 +0000
@@ -8,6 +8,7 @@
8]8]
99
10from django.contrib.auth.models import User10from django.contrib.auth.models import User
11from django.db import connection
11from django.db.models import (12from django.db.models import (
12 BooleanField,13 BooleanField,
13 CharField,14 CharField,
@@ -66,17 +67,28 @@
66 def find_for_user(self, user):67 def find_for_user(self, user):
67 """Find notifications for the given user.68 """Find notifications for the given user.
6869
69 :return: A `RawQuerySet` of `Notification` instances that haven't been70 :return: A `QuerySet` of `Notification` instances that haven't been
70 dismissed by `user`.71 dismissed by `user`.
71 """72 """
72 if user.is_superuser:73 if user.is_superuser:
73 where = "notification.users OR notification.admins"74 where = "notification.users OR notification.admins"
74 else:75 else:
75 where = "notification.users"76 where = "notification.users"
76 return self.raw(self._sql_find_for_user % where, [user.id, user.id])77 # We want to return a QuerySet because things like the WebSocket
78 # handler code wants to use order_by. This seems reasonable. However,
79 # we can't do outer joins with Django so we have to use self.raw().
80 # However #2, that returns a RawQuerySet which doesn't do order_by.
81 # Nor can we do self.filter(id__in=self.raw(...)) to "legitimise" a
82 # raw query. Nope, we have to actually fetch those IDs then issue
83 # another query to get a QuerySet for a set of Notification rows.
84 with connection.cursor() as cursor:
85 find_ids = self._sql_find_ids_for_user % where
86 cursor.execute(find_ids, [user.id, user.id])
87 ids = [nid for (nid, ) in cursor.fetchall()]
88 return self.filter(id__in=ids)
7789
78 _sql_find_for_user = """\90 _sql_find_ids_for_user = """\
79 SELECT notification.* FROM maasserver_notification AS notification91 SELECT notification.id FROM maasserver_notification AS notification
80 LEFT OUTER JOIN maasserver_notificationdismissal AS dismissal ON92 LEFT OUTER JOIN maasserver_notificationdismissal AS dismissal ON
81 (dismissal.notification_id = notification.id AND dismissal.user_id = %%s)93 (dismissal.notification_id = notification.id AND dismissal.user_id = %%s)
82 WHERE (notification.user_id = %%s OR %s) AND dismissal.id IS NULL94 WHERE (notification.user_id = %%s OR %s) AND dismissal.id IS NULL
8395
=== modified file 'src/maasserver/models/tests/test_notification.py'
--- src/maasserver/models/tests/test_notification.py 2017-01-05 09:49:36 +0000
+++ src/maasserver/models/tests/test_notification.py 2017-01-09 16:30:12 +0000
@@ -7,6 +7,7 @@
77
8import random8import random
99
10from django.db.models.query import QuerySet
10from maasserver.models.notification import Notification11from maasserver.models.notification import Notification
11from maasserver.testing.factory import factory12from maasserver.testing.factory import factory
12from maasserver.testing.testcase import MAASServerTestCase13from maasserver.testing.testcase import MAASServerTestCase
@@ -16,6 +17,8 @@
16 Equals,17 Equals,
17 HasLength,18 HasLength,
18 Is,19 Is,
20 IsInstance,
21 MatchesAll,
19 MatchesStructure,22 MatchesStructure,
20 Not,23 Not,
21)24)
@@ -165,7 +168,10 @@
165 def assertNotifications(self, user, notifications):168 def assertNotifications(self, user, notifications):
166 self.assertThat(169 self.assertThat(
167 Notification.objects.find_for_user(user),170 Notification.objects.find_for_user(user),
168 AfterPreprocessing(list, Equals(notifications)))171 MatchesAll(
172 IsInstance(QuerySet), # Not RawQuerySet.
173 AfterPreprocessing(list, Equals(notifications)),
174 ))
169175
170 def test_find_and_dismiss_notifications_for_user(self):176 def test_find_and_dismiss_notifications_for_user(self):
171 user = factory.make_User()177 user = factory.make_User()
172178
=== modified file 'src/maasserver/testing/factory.py'
--- src/maasserver/testing/factory.py 2017-01-05 18:32:27 +0000
+++ src/maasserver/testing/factory.py 2017-01-09 16:30:12 +0000
@@ -82,6 +82,7 @@
82 MDNS,82 MDNS,
83 Neighbour,83 Neighbour,
84 Node,84 Node,
85 Notification,
85 OwnerData,86 OwnerData,
86 PackageRepository,87 PackageRepository,
87 Partition,88 Partition,
@@ -2067,6 +2068,29 @@
2067 distributions=distributions, disabled_pockets=disabled_pockets,2068 distributions=distributions, disabled_pockets=disabled_pockets,
2068 components=components, arches=arches, key=key, default=default)2069 components=components, arches=arches, key=key, default=default)
20692070
2071 def make_Notification(
2072 self, message=None, *, ident=None, user=None, users=False,
2073 admins=False, context=None):
2074
2075 if context is None:
2076 context_name = self.make_name("name")
2077 context = {context_name: self.make_name("value")}
2078
2079 if message is None:
2080 context_names = [key for key in context if key.isidentifier()]
2081 if len(context_names) == 0:
2082 message = self.make_name("message")
2083 else:
2084 context_name = random.choice(context_names)
2085 message = self.make_name("message-{%s}" % context_name)
2086
2087 notification = Notification(
2088 ident=ident, user=user, users=users, admins=admins,
2089 message=message, context=context)
2090 notification.save()
2091
2092 return notification
2093
20702094
2071# Create factory singleton.2095# Create factory singleton.
2072factory = Factory()2096factory = Factory()
20732097
=== added file 'src/maasserver/websockets/handlers/notification.py'
--- src/maasserver/websockets/handlers/notification.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/websockets/handlers/notification.py 2017-01-09 16:30:12 +0000
@@ -0,0 +1,41 @@
1# Copyright 2017 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""The notification handler for the WebSocket connection."""
5
6__all__ = [
7 "NotificationHandler",
8]
9
10from maasserver.models.notification import Notification
11from maasserver.websockets.handlers.timestampedmodel import (
12 TimestampedModelHandler,
13)
14
15
16class NotificationHandler(TimestampedModelHandler):
17
18 class Meta:
19 object_class = Notification
20 allowed_methods = {'list', 'get', 'dismiss'}
21 exclude = list_exclude = {"context"}
22
23 def get_queryset(self):
24 """Return `Notifications` for the current user."""
25 return Notification.objects.find_for_user(self.user)
26
27 def dehydrate(self, obj, data, for_list=False):
28 data["message"] = obj.render()
29 return data
30
31 def dismiss(self, params):
32 """Dismiss the given notification(s).
33
34 :param id: One or more notification IDs to dismiss for the currently
35 logged-in user.
36 """
37 data = self.preprocess_form("dismiss", params)
38 ids = map(int, data.getlist("id"))
39 notifications = self.get_queryset().filter(id__in=ids)
40 for notification in notifications:
41 notification.dismiss(self.user)
042
=== added file 'src/maasserver/websockets/handlers/tests/test_notification.py'
--- src/maasserver/websockets/handlers/tests/test_notification.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/websockets/handlers/tests/test_notification.py 2017-01-09 16:30:12 +0000
@@ -0,0 +1,115 @@
1# Copyright 2017 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for `maasserver.websockets.handlers.notification`."""
5
6__all__ = []
7
8from maasserver.models.notification import NotificationDismissal
9from maasserver.testing.factory import factory
10from maasserver.testing.testcase import MAASServerTestCase
11from maasserver.websockets.base import dehydrate_datetime
12from maasserver.websockets.handlers.notification import NotificationHandler
13from testtools.matchers import (
14 AfterPreprocessing,
15 AllMatch,
16 Equals,
17 Is,
18 MatchesDict,
19 MatchesListwise,
20 Not,
21)
22
23
24def MatchesRenderedNotification(ntfn):
25 """Return a matcher for a rendered notification."""
26 return MatchesDict({
27 "id": Equals(ntfn.id),
28 "ident": Is(None) if ntfn.ident is None else Equals(ntfn.ident),
29 "user": Is(None) if ntfn.user_id is None else Equals(ntfn.user_id),
30 "users": Is(ntfn.users),
31 "admins": Is(ntfn.admins),
32 "updated": Equals(dehydrate_datetime(ntfn.updated)),
33 "created": Equals(dehydrate_datetime(ntfn.created)),
34 "message": Equals(ntfn.render()),
35 })
36
37
38def HasBeenDismissedBy(user):
39 """Has `user` dismissed the notification we're matching against?"""
40
41 def dismissed_by_user(notification):
42 return NotificationDismissal.objects.filter(
43 notification=notification, user=user).exists()
44
45 return AfterPreprocessing(dismissed_by_user, Is(True))
46
47
48class TestNotificationHandler(MAASServerTestCase):
49 """Tests for `NotificationHandler`."""
50
51 def test_get(self):
52 user = factory.make_User()
53 handler = NotificationHandler(user, {})
54 notification = factory.make_Notification(user=user)
55 self.assertThat(
56 handler.get({"id": notification.id}),
57 MatchesRenderedNotification(notification))
58
59 def test_list(self):
60 user = factory.make_User()
61 user2 = factory.make_User()
62 handler = NotificationHandler(user, {})
63 notifications = [
64 factory.make_Notification(user=user), # Will match.
65 factory.make_Notification(user=user2),
66 factory.make_Notification(users=True), # Will match.
67 factory.make_Notification(users=False),
68 factory.make_Notification(admins=True),
69 factory.make_Notification(admins=False),
70 ]
71 expected = [
72 MatchesRenderedNotification(notifications[0]),
73 MatchesRenderedNotification(notifications[2]),
74 ]
75 self.assertThat(
76 handler.list({}), MatchesListwise(expected))
77
78 def test_list_for_admin(self):
79 admin = factory.make_admin()
80 admin2 = factory.make_admin()
81 handler = NotificationHandler(admin, {})
82 notifications = [
83 factory.make_Notification(user=admin), # Will match.
84 factory.make_Notification(user=admin2),
85 factory.make_Notification(users=True), # Will match.
86 factory.make_Notification(users=False),
87 factory.make_Notification(admins=True), # Will match.
88 factory.make_Notification(admins=False),
89 ]
90 expected = [
91 MatchesRenderedNotification(notifications[0]),
92 MatchesRenderedNotification(notifications[2]),
93 MatchesRenderedNotification(notifications[4]),
94 ]
95 self.assertThat(
96 handler.list({}), MatchesListwise(expected))
97
98 def test_dismiss(self):
99 user = factory.make_User()
100 handler = NotificationHandler(user, {})
101 notification = factory.make_Notification(user=user)
102 self.assertThat(notification, Not(HasBeenDismissedBy(user)))
103 handler.dismiss({"id": str(notification.id)})
104 self.assertThat(notification, HasBeenDismissedBy(user))
105
106 def test_dismiss_multiple(self):
107 user = factory.make_User()
108 handler = NotificationHandler(user, {})
109 notifications = [
110 factory.make_Notification(user=user),
111 factory.make_Notification(users=True),
112 ]
113 self.assertThat(notifications, AllMatch(Not(HasBeenDismissedBy(user))))
114 handler.dismiss({"id": [str(ntfn.id) for ntfn in notifications]})
115 self.assertThat(notifications, AllMatch(HasBeenDismissedBy(user)))