Merge lp:~allenap/maas/notifications-form into lp:~maas-committers/maas/trunk
- notifications-form
- Merge into trunk
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 |
Related bugs: |
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.
Description of the change
Gavin Panella (allenap) wrote : | # |
Thanks!
MAAS Lander (maas-lander) wrote : | # |
The attempt to merge lp:~allenap/maas/notifications-form into lp:maas failed. Below is the output from the failed tests.
Hit:1 http://
Get:2 http://
Get:3 http://
Get:4 http://
Fetched 306 kB in 0s (567 kB/s)
Reading package lists...
sudo DEBIAN_
--no-
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~
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubun
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+
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
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( |
Forms never works the way you want them to, but this looks good!