Merge lp:~allenap/maas/notifications-form 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: 5722
Proposed branch: lp:~allenap/maas/notifications-form
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 396 lines (+243/-42)
6 files modified
src/maasserver/forms/notification.py (+36/-0)
src/maasserver/forms/tests/test_notification.py (+90/-0)
src/maasserver/migrations/builtin/maasserver/0112_update_notification.py (+38/-0)
src/maasserver/models/notification.py (+38/-15)
src/maasserver/models/tests/test_notification.py (+40/-25)
src/maasserver/websockets/handlers/tests/test_notification.py (+1/-2)
To merge this branch: bzr merge lp:~allenap/maas/notifications-form
Reviewer Review Type Date Requested Status
Lee Trager (community) Approve
Review via email: mp+317089@code.launchpad.net

Commit message

New form for creating and updating notifications.

Required some alterations to the Notification model because nothing quite works right with Django.

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

Forms never works the way you want them to, but this looks good!

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-form into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [102 kB]
Get:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease [102 kB]
Get:4 http://security.ubuntu.com/ubuntu xenial-security InRelease [102 kB]
Fetched 306 kB in 0s (567 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 newest version (1.3.0-0ubuntu5).
python-lxml is already the newest version (3.5.0-1build1).
python-netaddr is already the newest version (0.7.18-1).
python-netifaces is already the newest version (0.10.4-0.1build2).
python-psyc...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'src/maasserver/forms/notification.py'
2--- src/maasserver/forms/notification.py 1970-01-01 00:00:00 +0000
3+++ src/maasserver/forms/notification.py 2017-02-14 13:46:55 +0000
4@@ -0,0 +1,36 @@
5+# Copyright 2017 Canonical Ltd. This software is licensed under the
6+# GNU Affero General Public License version 3 (see the file LICENSE).
7+
8+"""Notification form."""
9+
10+__all__ = [
11+ "NotificationForm",
12+]
13+
14+import json
15+
16+from maasserver.forms import MAASModelForm
17+from maasserver.models.notification import Notification
18+
19+
20+class NotificationForm(MAASModelForm):
21+ """Notification creation/edit form."""
22+
23+ class Meta:
24+ model = Notification
25+ fields = (
26+ 'ident',
27+ 'user',
28+ 'users',
29+ 'admins',
30+ 'message',
31+ 'context',
32+ 'category',
33+ )
34+
35+ def clean_context(self):
36+ data = self.cleaned_data.get("context")
37+ if data is None or len(data) == 0 or data.isspace():
38+ return {} # Default to an empty dict when in doubt.
39+ else:
40+ return json.loads(data)
41
42=== added file 'src/maasserver/forms/tests/test_notification.py'
43--- src/maasserver/forms/tests/test_notification.py 1970-01-01 00:00:00 +0000
44+++ src/maasserver/forms/tests/test_notification.py 2017-02-14 13:46:55 +0000
45@@ -0,0 +1,90 @@
46+# Copyright 2017 Canonical Ltd. This software is licensed under the
47+# GNU Affero General Public License version 3 (see the file LICENSE).
48+
49+"""Tests for Notification forms."""
50+
51+__all__ = []
52+
53+import json
54+import random
55+
56+from maasserver.forms.notification import NotificationForm
57+from maasserver.testing.factory import factory
58+from maasserver.testing.testcase import MAASServerTestCase
59+from testtools.matchers import (
60+ Equals,
61+ MatchesStructure,
62+)
63+
64+
65+categories = "info", "success", "warning", "error"
66+
67+
68+class TestNotificationForm(MAASServerTestCase):
69+
70+ def test__notification_can_be_created_with_just_message(self):
71+ notification_message = factory.make_name("message")
72+ form = NotificationForm({
73+ "message": notification_message,
74+ })
75+ self.assertTrue(form.is_valid(), form.errors)
76+ notification = form.save()
77+ self.assertThat(notification, MatchesStructure.byEquality(
78+ ident=None, message=notification_message, user=None, users=False,
79+ admins=False, category="info", context={},
80+ ))
81+
82+ def test__notification_can_be_created_with_all_fields(self):
83+ user = factory.make_User()
84+ data = {
85+ "ident": factory.make_name("ident"),
86+ "user": str(user.id),
87+ "users": random.choice(["true", "false"]),
88+ "admins": random.choice(["true", "false"]),
89+ "message": factory.make_name("message"),
90+ "context": json.dumps({
91+ factory.make_name("key"): factory.make_name("value"),
92+ }),
93+ "category": random.choice(categories),
94+ }
95+ form = NotificationForm(data)
96+ self.assertTrue(form.is_valid(), form.errors)
97+ notification = form.save()
98+ expected = dict(
99+ data, user=user, users=(data["users"] == "true"),
100+ admins=(data["admins"] == "true"),
101+ context=json.loads(data["context"]),
102+ )
103+ self.assertThat(
104+ notification,
105+ MatchesStructure.byEquality(**expected),
106+ )
107+
108+ def test__notification_can_be_updated(self):
109+ notification = factory.make_Notification()
110+ user = factory.make_User()
111+ data = {
112+ "ident": factory.make_name("ident"),
113+ "user": str(user.id),
114+ "users": "false" if notification.users else "true",
115+ "admins": "false" if notification.admins else "true",
116+ "message": factory.make_name("message"),
117+ "context": json.dumps({
118+ factory.make_name("key"): factory.make_name("value"),
119+ }),
120+ "category": random.choice(
121+ [c for c in categories if c != notification.category]),
122+ }
123+ form = NotificationForm(instance=notification, data=data)
124+ self.assertTrue(form.is_valid(), form.errors)
125+ notification_saved = form.save()
126+ self.assertThat(notification_saved, Equals(notification))
127+ expected = dict(
128+ data, user=user, users=(data["users"] == "true"),
129+ admins=(data["admins"] == "true"),
130+ context=json.loads(data["context"]),
131+ )
132+ self.assertThat(
133+ notification_saved,
134+ MatchesStructure.byEquality(**expected),
135+ )
136
137=== added file 'src/maasserver/migrations/builtin/maasserver/0112_update_notification.py'
138--- src/maasserver/migrations/builtin/maasserver/0112_update_notification.py 1970-01-01 00:00:00 +0000
139+++ src/maasserver/migrations/builtin/maasserver/0112_update_notification.py 2017-02-14 13:46:55 +0000
140@@ -0,0 +1,38 @@
141+from django.conf import settings
142+from django.db import (
143+ migrations,
144+ models,
145+)
146+
147+
148+class Migration(migrations.Migration):
149+
150+ dependencies = [
151+ ('maasserver', '0111_remove_component_error'),
152+ ]
153+
154+ operations = [
155+ migrations.AlterField(
156+ model_name='notification', name='category',
157+ field=models.CharField(
158+ default='info', choices=[
159+ ('error', 'Error'), ('warning', 'Warning'),
160+ ('success', 'Success'), ('info', 'Informational'),
161+ ], max_length=10, blank=True),
162+ ),
163+ migrations.AlterField(
164+ model_name='notification', name='ident',
165+ field=models.CharField(
166+ null=True, default=None, max_length=40, blank=True),
167+ ),
168+ migrations.AlterField(
169+ model_name='notification', name='message',
170+ field=models.TextField(),
171+ ),
172+ migrations.AlterField(
173+ model_name='notification', name='user',
174+ field=models.ForeignKey(
175+ null=True, default=None, blank=True,
176+ to=settings.AUTH_USER_MODEL),
177+ ),
178+ ]
179
180=== modified file 'src/maasserver/models/notification.py'
181--- src/maasserver/models/notification.py 2017-02-08 10:46:18 +0000
182+++ src/maasserver/models/notification.py 2017-02-14 13:46:55 +0000
183@@ -10,6 +10,7 @@
184 from functools import wraps
185
186 from django.contrib.auth.models import User
187+from django.core.exceptions import ValidationError
188 from django.db import connection
189 from django.db.models import (
190 BooleanField,
191@@ -100,10 +101,12 @@
192 :return: A `QuerySet` of `Notification` instances that haven't been
193 dismissed by `user`.
194 """
195- if user.is_superuser:
196- where = "notification.users OR notification.admins"
197+ if user is None:
198+ return Notification.objects.none()
199+ elif user.is_superuser:
200+ query = self._sql_find_ids_for_admins
201 else:
202- where = "notification.users"
203+ query = self._sql_find_ids_for_users
204 # We want to return a QuerySet because things like the WebSocket
205 # handler code wants to use order_by. This seems reasonable. However,
206 # we can't do outer joins with Django so we have to use self.raw().
207@@ -112,12 +115,11 @@
208 # raw query. Nope, we have to actually fetch those IDs then issue
209 # another query to get a QuerySet for a set of Notification rows.
210 with connection.cursor() as cursor:
211- find_ids = self._sql_find_ids_for_user % where
212- cursor.execute(find_ids, [user.id, user.id])
213+ cursor.execute(query, [user.id, user.id])
214 ids = [nid for (nid, ) in cursor.fetchall()]
215 return self.filter(id__in=ids)
216
217- _sql_find_ids_for_user = """\
218+ _sql_find_ids_for_xxx = """\
219 SELECT notification.id FROM maasserver_notification AS notification
220 LEFT OUTER JOIN maasserver_notificationdismissal AS dismissal ON
221 (dismissal.notification_id = notification.id AND dismissal.user_id = %%s)
222@@ -125,6 +127,9 @@
223 ORDER BY notification.updated, notification.id
224 """
225
226+ _sql_find_ids_for_users = _sql_find_ids_for_xxx % "notification.users"
227+ _sql_find_ids_for_admins = _sql_find_ids_for_xxx % "notification.admins"
228+
229
230 class Notification(CleanSave, TimestampedModel):
231 """A notification message.
232@@ -156,16 +161,16 @@
233 # The ident column *is* unique, but uniqueness will be ensured using a
234 # partial index in PostgreSQL. These cannot be expressed using Django. See
235 # migrations for the SQL used to create this index.
236- ident = CharField(max_length=40, null=True, blank=True)
237-
238- user = ForeignKey(User, null=True, blank=True)
239- users = BooleanField(default=False)
240- admins = BooleanField(default=False)
241-
242- message = TextField(null=False, blank=True)
243+ ident = CharField(max_length=40, null=True, blank=True, default=None)
244+
245+ user = ForeignKey(User, null=True, blank=True, default=None)
246+ users = BooleanField(null=False, blank=True, default=False)
247+ admins = BooleanField(null=False, blank=True, default=False)
248+
249+ message = TextField(null=False, blank=False)
250 context = JSONObjectField(null=False, blank=True, default=dict)
251 category = CharField(
252- null=False, blank=False, default="info", max_length=10,
253+ null=False, blank=True, default="info", max_length=10,
254 choices=[
255 ("error", "Error"), ("warning", "Warning"),
256 ("success", "Success"), ("info", "Informational"),
257@@ -200,7 +205,25 @@
258
259 def clean(self):
260 super(Notification, self).clean()
261- self.render() # Just check it works.
262+ # Elementary cleaning that Django can't seem to do for us, mainly
263+ # because setting blank=False causes any number of problems.
264+ if self.ident == "":
265+ self.ident = None
266+ if self.category == "":
267+ self.category = "info"
268+ # The context must be a a dict (well, mapping, but we check for dict
269+ # because it will be converted to JSON later and a dict-like object
270+ # won't do). This could be done as a validator but, meh, I'm sick of
271+ # jumping through Django-hoops like a circus animal.
272+ if not isinstance(self.context, dict):
273+ raise ValidationError(
274+ {"context": "Context is not a mapping."})
275+ # Finally, check that the notification can be rendered. No point in
276+ # doing any of this if we cannot relate the message.
277+ try:
278+ self.render()
279+ except Exception as error:
280+ raise ValidationError("Notification cannot be rendered.")
281
282 def __repr__(self):
283 username = "None" if self.user is None else repr(self.user.username)
284
285=== modified file 'src/maasserver/models/tests/test_notification.py'
286--- src/maasserver/models/tests/test_notification.py 2017-02-08 10:46:18 +0000
287+++ src/maasserver/models/tests/test_notification.py 2017-02-14 13:46:55 +0000
288@@ -8,6 +8,7 @@
289 import itertools
290 import random
291
292+from django.core.exceptions import ValidationError
293 from django.db.models.query import QuerySet
294 from maasserver.models.notification import Notification
295 from maasserver.testing.factory import factory
296@@ -177,41 +178,55 @@
297 def test_save_checks_that_rendering_works(self):
298 message = "Dude, where's my {thing}?"
299 notification = Notification(message=message)
300- error = self.assertRaises(KeyError, notification.save)
301- self.assertThat(str(error), Equals(repr("thing")))
302+ error = self.assertRaises(ValidationError, notification.save)
303+ self.assertThat(error.message_dict, Equals({
304+ "__all__": ["Notification cannot be rendered."],
305+ }))
306 self.assertThat(notification.id, Is(None))
307 self.assertThat(Notification.objects.all(), HasLength(0))
308
309 def test_is_relevant_to_user(self):
310+ make_Notification = factory.make_Notification
311+
312 user = factory.make_User()
313 user2 = factory.make_User()
314 admin = factory.make_admin()
315
316 Yes, No = Is(True), Is(False)
317
318- notification_to_user = Notification(user=user)
319- self.assertThat(notification_to_user.is_relevant_to(None), No)
320- self.assertThat(notification_to_user.is_relevant_to(user), Yes)
321- self.assertThat(notification_to_user.is_relevant_to(user2), No)
322- self.assertThat(notification_to_user.is_relevant_to(admin), No)
323-
324- notification_to_users = Notification(users=True)
325- self.assertThat(notification_to_users.is_relevant_to(None), No)
326- self.assertThat(notification_to_users.is_relevant_to(user), Yes)
327- self.assertThat(notification_to_users.is_relevant_to(user2), Yes)
328- self.assertThat(notification_to_users.is_relevant_to(admin), No)
329-
330- notification_to_admins = Notification(admins=True)
331- self.assertThat(notification_to_admins.is_relevant_to(None), No)
332- self.assertThat(notification_to_admins.is_relevant_to(user), No)
333- self.assertThat(notification_to_admins.is_relevant_to(user2), No)
334- self.assertThat(notification_to_admins.is_relevant_to(admin), Yes)
335-
336- notification_to_all = Notification(users=True, admins=True)
337- self.assertThat(notification_to_all.is_relevant_to(None), No)
338- self.assertThat(notification_to_all.is_relevant_to(user), Yes)
339- self.assertThat(notification_to_all.is_relevant_to(user2), Yes)
340- self.assertThat(notification_to_all.is_relevant_to(admin), Yes)
341+ def assertRelevance(notification, user, yes_or_no):
342+ # Ensure that is_relevant_to and find_for_user agree, i.e. if
343+ # is_relevant_to returns True, the notification is in the set
344+ # returned by find_for_user. Likewise, if is_relevant_to returns
345+ # False, the notification is not in the find_for_user set.
346+ self.assertThat(notification.is_relevant_to(user), yes_or_no)
347+ self.assertThat(
348+ Notification.objects.find_for_user(user).filter(
349+ id=notification.id).exists(), yes_or_no)
350+
351+ notification_to_user = make_Notification(user=user)
352+ assertRelevance(notification_to_user, None, No)
353+ assertRelevance(notification_to_user, user, Yes)
354+ assertRelevance(notification_to_user, user2, No)
355+ assertRelevance(notification_to_user, admin, No)
356+
357+ notification_to_users = make_Notification(users=True)
358+ assertRelevance(notification_to_users, None, No)
359+ assertRelevance(notification_to_users, user, Yes)
360+ assertRelevance(notification_to_users, user2, Yes)
361+ assertRelevance(notification_to_users, admin, No)
362+
363+ notification_to_admins = make_Notification(admins=True)
364+ assertRelevance(notification_to_admins, None, No)
365+ assertRelevance(notification_to_admins, user, No)
366+ assertRelevance(notification_to_admins, user2, No)
367+ assertRelevance(notification_to_admins, admin, Yes)
368+
369+ notification_to_all = make_Notification(users=True, admins=True)
370+ assertRelevance(notification_to_all, None, No)
371+ assertRelevance(notification_to_all, user, Yes)
372+ assertRelevance(notification_to_all, user2, Yes)
373+ assertRelevance(notification_to_all, admin, Yes)
374
375
376 class TestNotificationRepresentation(MAASServerTestCase):
377
378=== modified file 'src/maasserver/websockets/handlers/tests/test_notification.py'
379--- src/maasserver/websockets/handlers/tests/test_notification.py 2017-02-02 17:12:11 +0000
380+++ src/maasserver/websockets/handlers/tests/test_notification.py 2017-02-14 13:46:55 +0000
381@@ -98,14 +98,13 @@
382 notifications = [
383 factory.make_Notification(user=admin), # Will match.
384 factory.make_Notification(user=admin2),
385- factory.make_Notification(users=True), # Will match.
386+ factory.make_Notification(users=True),
387 factory.make_Notification(users=False),
388 factory.make_Notification(admins=True), # Will match.
389 factory.make_Notification(admins=False),
390 ]
391 expected = [
392 MatchesRenderedNotification(notifications[0]),
393- MatchesRenderedNotification(notifications[2]),
394 MatchesRenderedNotification(notifications[4]),
395 ]
396 self.assertThat(