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
=== added file 'src/maasserver/forms/notification.py'
--- src/maasserver/forms/notification.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/forms/notification.py 2017-02-14 13:46:55 +0000
@@ -0,0 +1,36 @@
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"""Notification form."""
5
6__all__ = [
7 "NotificationForm",
8]
9
10import json
11
12from maasserver.forms import MAASModelForm
13from maasserver.models.notification import Notification
14
15
16class NotificationForm(MAASModelForm):
17 """Notification creation/edit form."""
18
19 class Meta:
20 model = Notification
21 fields = (
22 'ident',
23 'user',
24 'users',
25 'admins',
26 'message',
27 'context',
28 'category',
29 )
30
31 def clean_context(self):
32 data = self.cleaned_data.get("context")
33 if data is None or len(data) == 0 or data.isspace():
34 return {} # Default to an empty dict when in doubt.
35 else:
36 return json.loads(data)
037
=== added file 'src/maasserver/forms/tests/test_notification.py'
--- src/maasserver/forms/tests/test_notification.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/forms/tests/test_notification.py 2017-02-14 13:46:55 +0000
@@ -0,0 +1,90 @@
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 Notification forms."""
5
6__all__ = []
7
8import json
9import random
10
11from maasserver.forms.notification import NotificationForm
12from maasserver.testing.factory import factory
13from maasserver.testing.testcase import MAASServerTestCase
14from testtools.matchers import (
15 Equals,
16 MatchesStructure,
17)
18
19
20categories = "info", "success", "warning", "error"
21
22
23class TestNotificationForm(MAASServerTestCase):
24
25 def test__notification_can_be_created_with_just_message(self):
26 notification_message = factory.make_name("message")
27 form = NotificationForm({
28 "message": notification_message,
29 })
30 self.assertTrue(form.is_valid(), form.errors)
31 notification = form.save()
32 self.assertThat(notification, MatchesStructure.byEquality(
33 ident=None, message=notification_message, user=None, users=False,
34 admins=False, category="info", context={},
35 ))
36
37 def test__notification_can_be_created_with_all_fields(self):
38 user = factory.make_User()
39 data = {
40 "ident": factory.make_name("ident"),
41 "user": str(user.id),
42 "users": random.choice(["true", "false"]),
43 "admins": random.choice(["true", "false"]),
44 "message": factory.make_name("message"),
45 "context": json.dumps({
46 factory.make_name("key"): factory.make_name("value"),
47 }),
48 "category": random.choice(categories),
49 }
50 form = NotificationForm(data)
51 self.assertTrue(form.is_valid(), form.errors)
52 notification = form.save()
53 expected = dict(
54 data, user=user, users=(data["users"] == "true"),
55 admins=(data["admins"] == "true"),
56 context=json.loads(data["context"]),
57 )
58 self.assertThat(
59 notification,
60 MatchesStructure.byEquality(**expected),
61 )
62
63 def test__notification_can_be_updated(self):
64 notification = factory.make_Notification()
65 user = factory.make_User()
66 data = {
67 "ident": factory.make_name("ident"),
68 "user": str(user.id),
69 "users": "false" if notification.users else "true",
70 "admins": "false" if notification.admins else "true",
71 "message": factory.make_name("message"),
72 "context": json.dumps({
73 factory.make_name("key"): factory.make_name("value"),
74 }),
75 "category": random.choice(
76 [c for c in categories if c != notification.category]),
77 }
78 form = NotificationForm(instance=notification, data=data)
79 self.assertTrue(form.is_valid(), form.errors)
80 notification_saved = form.save()
81 self.assertThat(notification_saved, Equals(notification))
82 expected = dict(
83 data, user=user, users=(data["users"] == "true"),
84 admins=(data["admins"] == "true"),
85 context=json.loads(data["context"]),
86 )
87 self.assertThat(
88 notification_saved,
89 MatchesStructure.byEquality(**expected),
90 )
091
=== added file 'src/maasserver/migrations/builtin/maasserver/0112_update_notification.py'
--- src/maasserver/migrations/builtin/maasserver/0112_update_notification.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/migrations/builtin/maasserver/0112_update_notification.py 2017-02-14 13:46:55 +0000
@@ -0,0 +1,38 @@
1from django.conf import settings
2from django.db import (
3 migrations,
4 models,
5)
6
7
8class Migration(migrations.Migration):
9
10 dependencies = [
11 ('maasserver', '0111_remove_component_error'),
12 ]
13
14 operations = [
15 migrations.AlterField(
16 model_name='notification', name='category',
17 field=models.CharField(
18 default='info', choices=[
19 ('error', 'Error'), ('warning', 'Warning'),
20 ('success', 'Success'), ('info', 'Informational'),
21 ], max_length=10, blank=True),
22 ),
23 migrations.AlterField(
24 model_name='notification', name='ident',
25 field=models.CharField(
26 null=True, default=None, max_length=40, blank=True),
27 ),
28 migrations.AlterField(
29 model_name='notification', name='message',
30 field=models.TextField(),
31 ),
32 migrations.AlterField(
33 model_name='notification', name='user',
34 field=models.ForeignKey(
35 null=True, default=None, blank=True,
36 to=settings.AUTH_USER_MODEL),
37 ),
38 ]
039
=== modified file 'src/maasserver/models/notification.py'
--- src/maasserver/models/notification.py 2017-02-08 10:46:18 +0000
+++ src/maasserver/models/notification.py 2017-02-14 13:46:55 +0000
@@ -10,6 +10,7 @@
10from functools import wraps10from functools import wraps
1111
12from django.contrib.auth.models import User12from django.contrib.auth.models import User
13from django.core.exceptions import ValidationError
13from django.db import connection14from django.db import connection
14from django.db.models import (15from django.db.models import (
15 BooleanField,16 BooleanField,
@@ -100,10 +101,12 @@
100 :return: A `QuerySet` of `Notification` instances that haven't been101 :return: A `QuerySet` of `Notification` instances that haven't been
101 dismissed by `user`.102 dismissed by `user`.
102 """103 """
103 if user.is_superuser:104 if user is None:
104 where = "notification.users OR notification.admins"105 return Notification.objects.none()
106 elif user.is_superuser:
107 query = self._sql_find_ids_for_admins
105 else:108 else:
106 where = "notification.users"109 query = self._sql_find_ids_for_users
107 # We want to return a QuerySet because things like the WebSocket110 # We want to return a QuerySet because things like the WebSocket
108 # handler code wants to use order_by. This seems reasonable. However,111 # handler code wants to use order_by. This seems reasonable. However,
109 # we can't do outer joins with Django so we have to use self.raw().112 # we can't do outer joins with Django so we have to use self.raw().
@@ -112,12 +115,11 @@
112 # raw query. Nope, we have to actually fetch those IDs then issue115 # raw query. Nope, we have to actually fetch those IDs then issue
113 # another query to get a QuerySet for a set of Notification rows.116 # another query to get a QuerySet for a set of Notification rows.
114 with connection.cursor() as cursor:117 with connection.cursor() as cursor:
115 find_ids = self._sql_find_ids_for_user % where118 cursor.execute(query, [user.id, user.id])
116 cursor.execute(find_ids, [user.id, user.id])
117 ids = [nid for (nid, ) in cursor.fetchall()]119 ids = [nid for (nid, ) in cursor.fetchall()]
118 return self.filter(id__in=ids)120 return self.filter(id__in=ids)
119121
120 _sql_find_ids_for_user = """\122 _sql_find_ids_for_xxx = """\
121 SELECT notification.id FROM maasserver_notification AS notification123 SELECT notification.id FROM maasserver_notification AS notification
122 LEFT OUTER JOIN maasserver_notificationdismissal AS dismissal ON124 LEFT OUTER JOIN maasserver_notificationdismissal AS dismissal ON
123 (dismissal.notification_id = notification.id AND dismissal.user_id = %%s)125 (dismissal.notification_id = notification.id AND dismissal.user_id = %%s)
@@ -125,6 +127,9 @@
125 ORDER BY notification.updated, notification.id127 ORDER BY notification.updated, notification.id
126 """128 """
127129
130 _sql_find_ids_for_users = _sql_find_ids_for_xxx % "notification.users"
131 _sql_find_ids_for_admins = _sql_find_ids_for_xxx % "notification.admins"
132
128133
129class Notification(CleanSave, TimestampedModel):134class Notification(CleanSave, TimestampedModel):
130 """A notification message.135 """A notification message.
@@ -156,16 +161,16 @@
156 # The ident column *is* unique, but uniqueness will be ensured using a161 # The ident column *is* unique, but uniqueness will be ensured using a
157 # partial index in PostgreSQL. These cannot be expressed using Django. See162 # partial index in PostgreSQL. These cannot be expressed using Django. See
158 # migrations for the SQL used to create this index.163 # migrations for the SQL used to create this index.
159 ident = CharField(max_length=40, null=True, blank=True)164 ident = CharField(max_length=40, null=True, blank=True, default=None)
160165
161 user = ForeignKey(User, null=True, blank=True)166 user = ForeignKey(User, null=True, blank=True, default=None)
162 users = BooleanField(default=False)167 users = BooleanField(null=False, blank=True, default=False)
163 admins = BooleanField(default=False)168 admins = BooleanField(null=False, blank=True, default=False)
164169
165 message = TextField(null=False, blank=True)170 message = TextField(null=False, blank=False)
166 context = JSONObjectField(null=False, blank=True, default=dict)171 context = JSONObjectField(null=False, blank=True, default=dict)
167 category = CharField(172 category = CharField(
168 null=False, blank=False, default="info", max_length=10,173 null=False, blank=True, default="info", max_length=10,
169 choices=[174 choices=[
170 ("error", "Error"), ("warning", "Warning"),175 ("error", "Error"), ("warning", "Warning"),
171 ("success", "Success"), ("info", "Informational"),176 ("success", "Success"), ("info", "Informational"),
@@ -200,7 +205,25 @@
200205
201 def clean(self):206 def clean(self):
202 super(Notification, self).clean()207 super(Notification, self).clean()
203 self.render() # Just check it works.208 # Elementary cleaning that Django can't seem to do for us, mainly
209 # because setting blank=False causes any number of problems.
210 if self.ident == "":
211 self.ident = None
212 if self.category == "":
213 self.category = "info"
214 # The context must be a a dict (well, mapping, but we check for dict
215 # because it will be converted to JSON later and a dict-like object
216 # won't do). This could be done as a validator but, meh, I'm sick of
217 # jumping through Django-hoops like a circus animal.
218 if not isinstance(self.context, dict):
219 raise ValidationError(
220 {"context": "Context is not a mapping."})
221 # Finally, check that the notification can be rendered. No point in
222 # doing any of this if we cannot relate the message.
223 try:
224 self.render()
225 except Exception as error:
226 raise ValidationError("Notification cannot be rendered.")
204227
205 def __repr__(self):228 def __repr__(self):
206 username = "None" if self.user is None else repr(self.user.username)229 username = "None" if self.user is None else repr(self.user.username)
207230
=== modified file 'src/maasserver/models/tests/test_notification.py'
--- src/maasserver/models/tests/test_notification.py 2017-02-08 10:46:18 +0000
+++ src/maasserver/models/tests/test_notification.py 2017-02-14 13:46:55 +0000
@@ -8,6 +8,7 @@
8import itertools8import itertools
9import random9import random
1010
11from django.core.exceptions import ValidationError
11from django.db.models.query import QuerySet12from django.db.models.query import QuerySet
12from maasserver.models.notification import Notification13from maasserver.models.notification import Notification
13from maasserver.testing.factory import factory14from maasserver.testing.factory import factory
@@ -177,41 +178,55 @@
177 def test_save_checks_that_rendering_works(self):178 def test_save_checks_that_rendering_works(self):
178 message = "Dude, where's my {thing}?"179 message = "Dude, where's my {thing}?"
179 notification = Notification(message=message)180 notification = Notification(message=message)
180 error = self.assertRaises(KeyError, notification.save)181 error = self.assertRaises(ValidationError, notification.save)
181 self.assertThat(str(error), Equals(repr("thing")))182 self.assertThat(error.message_dict, Equals({
183 "__all__": ["Notification cannot be rendered."],
184 }))
182 self.assertThat(notification.id, Is(None))185 self.assertThat(notification.id, Is(None))
183 self.assertThat(Notification.objects.all(), HasLength(0))186 self.assertThat(Notification.objects.all(), HasLength(0))
184187
185 def test_is_relevant_to_user(self):188 def test_is_relevant_to_user(self):
189 make_Notification = factory.make_Notification
190
186 user = factory.make_User()191 user = factory.make_User()
187 user2 = factory.make_User()192 user2 = factory.make_User()
188 admin = factory.make_admin()193 admin = factory.make_admin()
189194
190 Yes, No = Is(True), Is(False)195 Yes, No = Is(True), Is(False)
191196
192 notification_to_user = Notification(user=user)197 def assertRelevance(notification, user, yes_or_no):
193 self.assertThat(notification_to_user.is_relevant_to(None), No)198 # Ensure that is_relevant_to and find_for_user agree, i.e. if
194 self.assertThat(notification_to_user.is_relevant_to(user), Yes)199 # is_relevant_to returns True, the notification is in the set
195 self.assertThat(notification_to_user.is_relevant_to(user2), No)200 # returned by find_for_user. Likewise, if is_relevant_to returns
196 self.assertThat(notification_to_user.is_relevant_to(admin), No)201 # False, the notification is not in the find_for_user set.
197202 self.assertThat(notification.is_relevant_to(user), yes_or_no)
198 notification_to_users = Notification(users=True)203 self.assertThat(
199 self.assertThat(notification_to_users.is_relevant_to(None), No)204 Notification.objects.find_for_user(user).filter(
200 self.assertThat(notification_to_users.is_relevant_to(user), Yes)205 id=notification.id).exists(), yes_or_no)
201 self.assertThat(notification_to_users.is_relevant_to(user2), Yes)206
202 self.assertThat(notification_to_users.is_relevant_to(admin), No)207 notification_to_user = make_Notification(user=user)
203208 assertRelevance(notification_to_user, None, No)
204 notification_to_admins = Notification(admins=True)209 assertRelevance(notification_to_user, user, Yes)
205 self.assertThat(notification_to_admins.is_relevant_to(None), No)210 assertRelevance(notification_to_user, user2, No)
206 self.assertThat(notification_to_admins.is_relevant_to(user), No)211 assertRelevance(notification_to_user, admin, No)
207 self.assertThat(notification_to_admins.is_relevant_to(user2), No)212
208 self.assertThat(notification_to_admins.is_relevant_to(admin), Yes)213 notification_to_users = make_Notification(users=True)
209214 assertRelevance(notification_to_users, None, No)
210 notification_to_all = Notification(users=True, admins=True)215 assertRelevance(notification_to_users, user, Yes)
211 self.assertThat(notification_to_all.is_relevant_to(None), No)216 assertRelevance(notification_to_users, user2, Yes)
212 self.assertThat(notification_to_all.is_relevant_to(user), Yes)217 assertRelevance(notification_to_users, admin, No)
213 self.assertThat(notification_to_all.is_relevant_to(user2), Yes)218
214 self.assertThat(notification_to_all.is_relevant_to(admin), Yes)219 notification_to_admins = make_Notification(admins=True)
220 assertRelevance(notification_to_admins, None, No)
221 assertRelevance(notification_to_admins, user, No)
222 assertRelevance(notification_to_admins, user2, No)
223 assertRelevance(notification_to_admins, admin, Yes)
224
225 notification_to_all = make_Notification(users=True, admins=True)
226 assertRelevance(notification_to_all, None, No)
227 assertRelevance(notification_to_all, user, Yes)
228 assertRelevance(notification_to_all, user2, Yes)
229 assertRelevance(notification_to_all, admin, Yes)
215230
216231
217class TestNotificationRepresentation(MAASServerTestCase):232class TestNotificationRepresentation(MAASServerTestCase):
218233
=== modified file 'src/maasserver/websockets/handlers/tests/test_notification.py'
--- src/maasserver/websockets/handlers/tests/test_notification.py 2017-02-02 17:12:11 +0000
+++ src/maasserver/websockets/handlers/tests/test_notification.py 2017-02-14 13:46:55 +0000
@@ -98,14 +98,13 @@
98 notifications = [98 notifications = [
99 factory.make_Notification(user=admin), # Will match.99 factory.make_Notification(user=admin), # Will match.
100 factory.make_Notification(user=admin2),100 factory.make_Notification(user=admin2),
101 factory.make_Notification(users=True), # Will match.101 factory.make_Notification(users=True),
102 factory.make_Notification(users=False),102 factory.make_Notification(users=False),
103 factory.make_Notification(admins=True), # Will match.103 factory.make_Notification(admins=True), # Will match.
104 factory.make_Notification(admins=False),104 factory.make_Notification(admins=False),
105 ]105 ]
106 expected = [106 expected = [
107 MatchesRenderedNotification(notifications[0]),107 MatchesRenderedNotification(notifications[0]),
108 MatchesRenderedNotification(notifications[2]),
109 MatchesRenderedNotification(notifications[4]),108 MatchesRenderedNotification(notifications[4]),
110 ]109 ]
111 self.assertThat(110 self.assertThat(