Merge lp:~allenap/maas/notifications-api 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: 5742
Proposed branch: lp:~allenap/maas/notifications-api
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 682 lines (+540/-8)
8 files modified
src/maasserver/api/notification.py (+145/-0)
src/maasserver/api/tests/test_notification.py (+313/-0)
src/maasserver/fields.py (+13/-0)
src/maasserver/forms/notification.py (+8/-5)
src/maasserver/forms/tests/test_notification.py (+18/-0)
src/maasserver/testing/matchers.py (+16/-2)
src/maasserver/tests/test_fields.py (+12/-1)
src/maasserver/urls_api.py (+15/-0)
To merge this branch: bzr merge lp:~allenap/maas/notifications-api
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
Review via email: mp+317361@code.launchpad.net

Commit message

Web API support for notifications.

To post a comment you must log in.
Revision history for this message
Blake Rouse (blake-rouse) wrote :

Looks good. Just a couple of comments.

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

Thanks. I realised that this also needed some more robust testing for creating notifications, so I've added that. In particular it meant I was able to get rid of some minor JSON wrangling and let the form machinery do its weird stuff instead.

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

The attempt to merge lp:~allenap/maas/notifications-api 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]
Get:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease [102 kB]
Get:5 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/main Sources [231 kB]
Get:6 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/main amd64 Packages [475 kB]
Get:7 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates/main Translation-en [188 kB]
Get:8 http://security.ubuntu.com/ubuntu xenial-security/main Sources [59.7 kB]
Get:9 http://security.ubuntu.com/ubuntu xenial-security/main amd64 Packages [212 kB]
Get:10 http://security.ubuntu.com/ubuntu xenial-security/main Translation-en [88.8 kB]
Fetched 1,559 kB in 0s (2,495 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 vers...

Revision history for this message
Blake Rouse (blake-rouse) :
Revision history for this message
Gavin Panella (allenap) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'src/maasserver/api/notification.py'
2--- src/maasserver/api/notification.py 1970-01-01 00:00:00 +0000
3+++ src/maasserver/api/notification.py 2017-02-15 20:56:35 +0000
4@@ -0,0 +1,145 @@
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+__all__ = [
9+ "NotificationHandler",
10+ "NotificationsHandler",
11+]
12+
13+from django.shortcuts import get_object_or_404
14+from maasserver.api.support import (
15+ admin_method,
16+ operation,
17+ OperationsHandler,
18+)
19+from maasserver.exceptions import (
20+ MAASAPIForbidden,
21+ MAASAPIValidationError,
22+)
23+from maasserver.forms.notification import NotificationForm
24+from maasserver.models.notification import Notification
25+from piston3.utils import rc
26+
27+# Notification fields exposed on the API.
28+DISPLAYED_NOTIFICATION_FIELDS = frozenset((
29+ 'id',
30+ 'ident',
31+ 'user',
32+ 'users',
33+ 'admins',
34+ 'message',
35+ 'context',
36+ 'category',
37+))
38+
39+
40+class NotificationsHandler(OperationsHandler):
41+ """Manage the collection of all the notifications in MAAS."""
42+
43+ api_doc_section_name = "Notifications"
44+ update = delete = None
45+
46+ @classmethod
47+ def resource_uri(cls, *args, **kwargs):
48+ return ('notifications_handler', [])
49+
50+ def read(self, request):
51+ """List notifications relevant to the invoking user.
52+
53+ Notifications that have been dismissed are *not* returned.
54+ """
55+ return Notification.objects.find_for_user(request.user).order_by('id')
56+
57+ @admin_method
58+ def create(self, request):
59+ """Create a notification.
60+
61+ This is available to admins *only*.
62+
63+ :param message: The message for this notification. May contain basic
64+ HTML; this will be sanitised before display.
65+ :param context: Optional JSON context. The root object *must* be an
66+ object (i.e. a mapping). The values herein can be referenced by
67+ `message` with Python's "format" (not %) codes.
68+ :param category: Optional category. Choose from: error, warning,
69+ success, or info. Defaults to info.
70+
71+ :param ident: Optional unique identifier for this notification.
72+ :param user: Optional user ID this notification is intended for. By
73+ default it will not be targeted to any individual user.
74+ :param users: Optional boolean, true to notify all users, defaults to
75+ false, i.e. not targeted to all users.
76+ :param admins: Optional boolean, true to notify all admins, defaults to
77+ false, i.e. not targeted to all admins.
78+
79+ Note: if neither user nor users nor admins is set, the notification
80+ will not be seen by anyone.
81+ """
82+ form = NotificationForm(data=request.data)
83+ if form.is_valid():
84+ return form.save()
85+ else:
86+ raise MAASAPIValidationError(form.errors)
87+
88+
89+class NotificationHandler(OperationsHandler):
90+ """Manage an individual notification."""
91+
92+ api_doc_section_name = "Notification"
93+
94+ create = None
95+ model = Notification
96+ fields = DISPLAYED_NOTIFICATION_FIELDS
97+
98+ def read(self, request, id):
99+ """Read a specific notification."""
100+ notification = get_object_or_404(Notification, id=id)
101+ if notification.is_relevant_to(request.user):
102+ return notification
103+ elif request.user.is_superuser:
104+ return notification
105+ else:
106+ raise MAASAPIForbidden()
107+
108+ @admin_method
109+ def update(self, request, id):
110+ """Update a specific notification.
111+
112+ See `NotificationsHandler.create` for field information.
113+ """
114+ notification = get_object_or_404(Notification, id=id)
115+ form = NotificationForm(
116+ data=request.data, instance=notification)
117+ if form.is_valid():
118+ return form.save()
119+ else:
120+ raise MAASAPIValidationError(form.errors)
121+
122+ @admin_method
123+ def delete(self, request, id):
124+ """Delete a specific notification."""
125+ notification = get_object_or_404(Notification, id=id)
126+ notification.delete()
127+ return rc.DELETED
128+
129+ @operation(idempotent=False)
130+ def dismiss(self, request, id):
131+ """Dismiss a specific notification.
132+
133+ Returns HTTP 403 FORBIDDEN if this notification is not relevant
134+ (targeted) to the invoking user.
135+
136+ It is safe to call multiple times for the same notification.
137+ """
138+ notification = get_object_or_404(Notification, id=id)
139+ if notification.is_relevant_to(request.user):
140+ notification.dismiss(request.user)
141+ else:
142+ raise MAASAPIForbidden()
143+
144+ @classmethod
145+ def resource_uri(cls, notification=None):
146+ notification_id = "id"
147+ if notification is not None:
148+ notification_id = notification.id
149+ return ('notification_handler', (notification_id,))
150
151=== added file 'src/maasserver/api/tests/test_notification.py'
152--- src/maasserver/api/tests/test_notification.py 1970-01-01 00:00:00 +0000
153+++ src/maasserver/api/tests/test_notification.py 2017-02-15 20:56:35 +0000
154@@ -0,0 +1,313 @@
155+# Copyright 2017 Canonical Ltd. This software is licensed under the
156+# GNU Affero General Public License version 3 (see the file LICENSE).
157+
158+"""Tests for Notification API."""
159+
160+__all__ = []
161+
162+import http.client
163+import json
164+import random
165+
166+from django.core.urlresolvers import reverse
167+from maasserver.models.notification import (
168+ Notification,
169+ NotificationDismissal,
170+)
171+from maasserver.testing.api import APITestCase
172+from maasserver.testing.factory import factory
173+from maasserver.testing.matchers import HasStatusCode
174+from maasserver.testing.testcase import MAASServerTestCase
175+from maasserver.utils.converters import json_load_bytes
176+from maasserver.utils.orm import reload_object
177+from testtools.matchers import (
178+ AfterPreprocessing,
179+ ContainsDict,
180+ Equals,
181+ Is,
182+ IsInstance,
183+ MatchesDict,
184+ MatchesSetwise,
185+)
186+
187+
188+def get_notifications_uri():
189+ """Return a Notification's URI on the API."""
190+ return reverse('notifications_handler', args=[])
191+
192+
193+def get_notification_uri(notification):
194+ """Return a Notification URI on the API."""
195+ return reverse(
196+ 'notification_handler', args=[notification.id])
197+
198+
199+class TestURIs(MAASServerTestCase):
200+
201+ def test_notifications_handler_path(self):
202+ self.assertThat(
203+ get_notifications_uri(),
204+ Equals("/api/2.0/notifications/"))
205+
206+ def test_notification_handler_path(self):
207+ notification = factory.make_Notification()
208+ self.assertThat(
209+ get_notification_uri(notification),
210+ Equals("/api/2.0/notifications/%s/" % notification.id))
211+
212+
213+def MatchesNotification(notification):
214+ """Match the expected JSON rendering of `notification`."""
215+ return MatchesDict({
216+ "id": Equals(notification.id),
217+ "ident": Equals(notification.ident),
218+ "user": (
219+ Is(None) if notification.user_id is None else ContainsDict({
220+ "username": Equals(notification.user.username),
221+ })
222+ ),
223+ "users": Is(notification.users),
224+ "admins": Is(notification.admins),
225+ "message": Equals(notification.message),
226+ "context": Equals(notification.context),
227+ "category": Equals(notification.category),
228+ "resource_uri": Equals(get_notification_uri(notification)),
229+ })
230+
231+
232+def HasBeenDismissedBy(user):
233+ """Match a notification that has been dismissed by the given user."""
234+
235+ def dismissal_exists(notification):
236+ return NotificationDismissal.objects.filter(
237+ notification=notification, user=user).exists()
238+
239+ return AfterPreprocessing(dismissal_exists, Is(True))
240+
241+
242+class TestNotificationsAPI(APITestCase.ForUserAndAdmin):
243+
244+ def test_read(self):
245+ notifications = [
246+ factory.make_Notification(
247+ user=self.user, users=False, admins=False),
248+ factory.make_Notification(
249+ user=None, users=True, admins=False),
250+ factory.make_Notification(
251+ user=None, users=False, admins=True),
252+ ]
253+ uri = get_notifications_uri()
254+ response = self.client.get(uri)
255+ self.assertThat(response, HasStatusCode(http.client.OK))
256+ self.assertThat(
257+ json_load_bytes(response.content),
258+ MatchesSetwise(*(
259+ MatchesNotification(notification)
260+ for notification in notifications
261+ if notification.is_relevant_to(self.user)
262+ )),
263+ )
264+
265+ def test_create_with_minimal_fields(self):
266+ message = factory.make_name("message")
267+ uri = get_notifications_uri()
268+ response = self.client.post(uri, {"message": message})
269+ if self.user.is_superuser:
270+ self.assertThat(response, HasStatusCode(http.client.OK))
271+ response = json_load_bytes(response.content)
272+ self.assertThat(response, ContainsDict({"id": IsInstance(int)}))
273+ notification = Notification.objects.get(id=response["id"])
274+ self.assertThat(response, MatchesNotification(notification))
275+ else:
276+ self.assertThat(response, HasStatusCode(http.client.FORBIDDEN))
277+
278+ def test_create_with_all_fields(self):
279+ context = {factory.make_name("key"): factory.make_name("value")}
280+ data = {
281+ "ident": factory.make_name("ident"),
282+ "user": str(self.user.id),
283+ "users": random.choice(["true", "false"]),
284+ "admins": random.choice(["true", "false"]),
285+ "message": factory.make_name("message"),
286+ "context": json.dumps(context),
287+ "category": random.choice(("info", "success", "warning", "error")),
288+ }
289+ uri = get_notifications_uri()
290+ response = self.client.post(uri, data)
291+ if self.user.is_superuser:
292+ self.assertThat(response, HasStatusCode(http.client.OK))
293+ self.assertThat(
294+ json_load_bytes(response.content),
295+ ContainsDict({
296+ "id": IsInstance(int),
297+ "ident": Equals(data["ident"]),
298+ "user": ContainsDict({
299+ "username": Equals(self.user.username),
300+ }),
301+ "users": Is(data["users"] == "true"),
302+ "admins": Is(data["admins"] == "true"),
303+ "message": Equals(data["message"]),
304+ "context": Equals(context),
305+ "category": Equals(data["category"]),
306+ }),
307+ )
308+ else:
309+ self.assertThat(response, HasStatusCode(http.client.FORBIDDEN))
310+
311+
312+class TestNotificationsAPI_Anonymous(APITestCase.ForAnonymous):
313+
314+ def test_read(self):
315+ uri = get_notifications_uri()
316+ response = self.client.get(uri)
317+ self.assertThat(response, HasStatusCode(http.client.UNAUTHORIZED))
318+
319+ def test_create(self):
320+ uri = get_notifications_uri()
321+ response = self.client.post(uri, {"message": factory.make_name()})
322+ self.assertThat(response, HasStatusCode(http.client.UNAUTHORIZED))
323+
324+
325+class TestNotificationAPI(APITestCase.ForUserAndAdmin):
326+
327+ def test_read_notification_for_self(self):
328+ notification = factory.make_Notification(user=self.user)
329+ uri = get_notification_uri(notification)
330+ response = self.client.get(uri)
331+ self.assertThat(response, HasStatusCode(http.client.OK))
332+ self.assertThat(
333+ json_load_bytes(response.content),
334+ MatchesNotification(notification),
335+ )
336+
337+ def test_read_notification_for_other(self):
338+ other = factory.make_User()
339+ notification = factory.make_Notification(user=other)
340+ uri = get_notification_uri(notification)
341+ response = self.client.get(uri)
342+ if self.user.is_superuser:
343+ self.assertThat(response, HasStatusCode(http.client.OK))
344+ self.assertThat(
345+ json_load_bytes(response.content),
346+ MatchesNotification(notification),
347+ )
348+ else:
349+ self.assertThat(response, HasStatusCode(http.client.FORBIDDEN))
350+
351+ def test_read_notification_for_users(self):
352+ notification = factory.make_Notification(users=True)
353+ uri = get_notification_uri(notification)
354+ response = self.client.get(uri)
355+ self.assertThat(response, HasStatusCode(http.client.OK))
356+ self.assertThat(
357+ json_load_bytes(response.content),
358+ MatchesNotification(notification),
359+ )
360+
361+ def test_read_notification_for_admins(self):
362+ notification = factory.make_Notification(admins=True)
363+ uri = get_notification_uri(notification)
364+ response = self.client.get(uri)
365+ if self.user.is_superuser:
366+ self.assertThat(response, HasStatusCode(http.client.OK))
367+ self.assertThat(
368+ json_load_bytes(response.content),
369+ MatchesNotification(notification),
370+ )
371+ else:
372+ self.assertThat(response, HasStatusCode(http.client.FORBIDDEN))
373+
374+ def test_update_is_for_admins_only(self):
375+ notification = factory.make_Notification()
376+ message_new = factory.make_name("message")
377+ uri = get_notification_uri(notification)
378+ response = self.client.put(uri, {"message": message_new})
379+ if self.user.is_superuser:
380+ self.assertThat(response, HasStatusCode(http.client.OK))
381+ notification = reload_object(notification)
382+ self.assertThat(notification.message, Equals(message_new))
383+ self.assertThat(
384+ json_load_bytes(response.content),
385+ MatchesNotification(notification),
386+ )
387+ else:
388+ self.assertThat(response, HasStatusCode(http.client.FORBIDDEN))
389+
390+ def test_delete_is_for_admins_only(self):
391+ notification = factory.make_Notification()
392+ uri = get_notification_uri(notification)
393+ response = self.client.delete(uri)
394+ if self.user.is_superuser:
395+ self.assertThat(response, HasStatusCode(http.client.NO_CONTENT))
396+ self.assertThat(reload_object(notification), Is(None))
397+ else:
398+ self.assertThat(response, HasStatusCode(http.client.FORBIDDEN))
399+
400+ def test_dismiss_notification_for_self(self):
401+ notification = factory.make_Notification(user=self.user)
402+ uri = get_notification_uri(notification)
403+ response = self.client.post(uri, {"op": "dismiss"})
404+ self.assertThat(response, HasStatusCode(http.client.OK))
405+ self.assertThat(notification, HasBeenDismissedBy(self.user))
406+
407+ def test_dismiss_notification_for_other(self):
408+ other = factory.make_User()
409+ notification = factory.make_Notification(user=other)
410+ uri = get_notification_uri(notification)
411+ response = self.client.post(uri, {"op": "dismiss"})
412+ self.assertThat(response, HasStatusCode(http.client.FORBIDDEN))
413+
414+ def test_dismiss_notification_for_users(self):
415+ notification = factory.make_Notification(users=True)
416+ uri = get_notification_uri(notification)
417+ response = self.client.post(uri, {"op": "dismiss"})
418+ if notification.is_relevant_to(self.user):
419+ self.assertThat(response, HasStatusCode(http.client.OK))
420+ self.assertThat(notification, HasBeenDismissedBy(self.user))
421+ else:
422+ self.assertThat(response, HasStatusCode(http.client.FORBIDDEN))
423+
424+ def test_dismiss_notification_for_admins(self):
425+ notification = factory.make_Notification(admins=True)
426+ uri = get_notification_uri(notification)
427+ response = self.client.post(uri, {"op": "dismiss"})
428+ if notification.is_relevant_to(self.user):
429+ self.assertThat(response, HasStatusCode(http.client.OK))
430+ self.assertThat(notification, HasBeenDismissedBy(self.user))
431+ else:
432+ self.assertThat(response, HasStatusCode(http.client.FORBIDDEN))
433+
434+
435+class TestNotificationAPI_Anonymous(APITestCase.ForAnonymous):
436+
437+ def test_read_notification_for_other(self):
438+ other = factory.make_User()
439+ notification = factory.make_Notification(user=other)
440+ uri = get_notification_uri(notification)
441+ response = self.client.get(uri)
442+ self.assertThat(response, HasStatusCode(http.client.UNAUTHORIZED))
443+
444+ def test_read_notification_for_users(self):
445+ notification = factory.make_Notification(users=True)
446+ uri = get_notification_uri(notification)
447+ response = self.client.get(uri)
448+ self.assertThat(response, HasStatusCode(http.client.UNAUTHORIZED))
449+
450+ def test_read_notification_for_admins(self):
451+ notification = factory.make_Notification(admins=True)
452+ uri = get_notification_uri(notification)
453+ response = self.client.get(uri)
454+ self.assertThat(response, HasStatusCode(http.client.UNAUTHORIZED))
455+
456+ def test_update_is_for_admins_only(self):
457+ notification = factory.make_Notification()
458+ message_new = factory.make_name("message")
459+ uri = get_notification_uri(notification)
460+ response = self.client.put(uri, {"message": message_new})
461+ self.assertThat(response, HasStatusCode(http.client.UNAUTHORIZED))
462+
463+ def test_delete_is_for_admins_only(self):
464+ notification = factory.make_Notification()
465+ uri = get_notification_uri(notification)
466+ response = self.client.delete(uri)
467+ self.assertThat(response, HasStatusCode(http.client.UNAUTHORIZED))
468
469=== modified file 'src/maasserver/fields.py'
470--- src/maasserver/fields.py 2017-01-27 19:44:19 +0000
471+++ src/maasserver/fields.py 2017-02-15 20:56:35 +0000
472@@ -308,6 +308,19 @@
473 return super(JSONObjectField, self).get_prep_lookup(
474 lookup_type, value)
475
476+ def formfield(self, form_class=None, **kwargs):
477+ """Return a plain `forms.Field` here to avoid "helpful" conversions.
478+
479+ Django's base model field defaults to returning a `CharField`, which
480+ means that anything that's not character data gets smooshed to text by
481+ `CharField.to_pytnon` in forms (via the woefully named `smart_text`).
482+ This is not helpful.
483+ """
484+ if form_class is None:
485+ form_class = forms.Field
486+ return super().formfield(
487+ form_class=form_class, **kwargs)
488+
489
490 class XMLField(Field):
491 """A field for storing xml natively.
492
493=== modified file 'src/maasserver/forms/notification.py'
494--- src/maasserver/forms/notification.py 2017-02-13 12:15:57 +0000
495+++ src/maasserver/forms/notification.py 2017-02-15 20:56:35 +0000
496@@ -7,8 +7,6 @@
497 "NotificationForm",
498 ]
499
500-import json
501-
502 from maasserver.forms import MAASModelForm
503 from maasserver.models.notification import Notification
504
505@@ -30,7 +28,12 @@
506
507 def clean_context(self):
508 data = self.cleaned_data.get("context")
509- if data is None or len(data) == 0 or data.isspace():
510- return {} # Default to an empty dict when in doubt.
511+ if data is None:
512+ return {}
513+ elif isinstance(data, str):
514+ if len(data) == 0 or data.isspace():
515+ return {}
516+ else:
517+ return data
518 else:
519- return json.loads(data)
520+ return data
521
522=== modified file 'src/maasserver/forms/tests/test_notification.py'
523--- src/maasserver/forms/tests/test_notification.py 2017-02-13 12:15:57 +0000
524+++ src/maasserver/forms/tests/test_notification.py 2017-02-15 20:56:35 +0000
525@@ -34,6 +34,24 @@
526 admins=False, category="info", context={},
527 ))
528
529+ def test__notification_can_be_created_with_empty_fields(self):
530+ notification_message = factory.make_name("message")
531+ form = NotificationForm({
532+ "ident": "",
533+ "user": "",
534+ "users": "",
535+ "admins": "",
536+ "message": notification_message,
537+ "context": "",
538+ "category": "",
539+ })
540+ self.assertTrue(form.is_valid(), form.errors)
541+ notification = form.save()
542+ self.assertThat(notification, MatchesStructure.byEquality(
543+ ident=None, message=notification_message, user=None, users=False,
544+ admins=False, category="info", context={},
545+ ))
546+
547 def test__notification_can_be_created_with_all_fields(self):
548 user = factory.make_User()
549 data = {
550
551=== modified file 'src/maasserver/testing/matchers.py'
552--- src/maasserver/testing/matchers.py 2016-12-21 20:08:42 +0000
553+++ src/maasserver/testing/matchers.py 2017-02-15 20:56:35 +0000
554@@ -7,6 +7,8 @@
555 'HasStatusCode',
556 ]
557
558+from http import HTTPStatus
559+
560 from testtools.content import (
561 Content,
562 UTF8_TEXT,
563@@ -18,6 +20,16 @@
564 )
565
566
567+def describe_http_status(code):
568+ """Return a string describing the given HTTP status code."""
569+ try:
570+ code = HTTPStatus(code)
571+ except ValueError:
572+ return "HTTP {code}".format(code=code)
573+ else:
574+ return "HTTP {code.value:d} {code.name}".format(code=code)
575+
576+
577 class HasStatusCode(Matcher):
578 """Match if the given response has the expected HTTP status.
579
580@@ -40,8 +52,10 @@
581 response_dump = response_dump.decode(response.charset)
582 response_dump = response_dump.encode("utf-8", "replace")
583
584- description = "Expected HTTP %s, got %s" % (
585- self.status_code, response.status_code)
586+ description = "Expected %s, got %s" % (
587+ describe_http_status(self.status_code),
588+ describe_http_status(response.status_code),
589+ )
590 details = {
591 "Unexpected HTTP response": Content(
592 UTF8_TEXT, lambda: [response_dump]),
593
594=== modified file 'src/maasserver/tests/test_fields.py'
595--- src/maasserver/tests/test_fields.py 2016-12-12 10:06:16 +0000
596+++ src/maasserver/tests/test_fields.py 2017-02-15 20:56:35 +0000
597@@ -9,6 +9,7 @@
598 from random import randint
599 import re
600
601+from django import forms
602 from django.core import serializers
603 from django.core.exceptions import ValidationError
604 from django.db import (
605@@ -21,6 +22,7 @@
606 EditableBinaryField,
607 HostListFormField,
608 IPListFormField,
609+ JSONObjectField,
610 LargeObjectField,
611 LargeObjectFile,
612 MAC,
613@@ -57,7 +59,11 @@
614 from psycopg2 import OperationalError
615 from psycopg2.extensions import ISQLQuote
616 from testtools import ExpectedException
617-from testtools.matchers import Equals
618+from testtools.matchers import (
619+ AfterPreprocessing,
620+ Equals,
621+ Is,
622+)
623
624
625 class TestMAC(MAASServerTestCase):
626@@ -299,6 +305,11 @@
627 # Others lookups are not allowed.
628 self.assertRaises(TypeError, JSONFieldModel.objects.get, value__gte=3)
629
630+ def test_form_field_is_a_plain_field(self):
631+ self.assertThat(
632+ JSONObjectField().formfield(),
633+ AfterPreprocessing(type, Is(forms.Field)))
634+
635
636 class TestXMLField(MAASLegacyServerTestCase):
637
638
639=== modified file 'src/maasserver/urls_api.py'
640--- src/maasserver/urls_api.py 2017-01-23 19:55:12 +0000
641+++ src/maasserver/urls_api.py 2017-02-15 20:56:35 +0000
642@@ -108,6 +108,10 @@
643 NodesHandler,
644 )
645 from maasserver.api.not_found import not_found_handler
646+from maasserver.api.notification import (
647+ NotificationHandler,
648+ NotificationsHandler,
649+)
650 from maasserver.api.packagerepositories import (
651 PackageRepositoriesHandler,
652 PackageRepositoryHandler,
653@@ -293,6 +297,10 @@
654 StaticRouteHandler, authentication=api_auth)
655 staticroutes_handler = RestrictedResource(
656 StaticRoutesHandler, authentication=api_auth)
657+notification_handler = RestrictedResource(
658+ NotificationHandler, authentication=api_auth)
659+notifications_handler = RestrictedResource(
660+ NotificationsHandler, authentication=api_auth)
661
662
663 # Admin handlers.
664@@ -313,6 +321,7 @@
665 license_keys_handler = AdminRestrictedResource(
666 LicenseKeysHandler, authentication=api_auth)
667
668+
669 # API URLs accessible to anonymous users.
670 urlpatterns = patterns(
671 '',
672@@ -501,6 +510,12 @@
673 url(
674 r'^dhcp-snippets/(?P<id>[^/]+)/$',
675 dhcp_snippet_handler, name='dhcp_snippet_handler'),
676+ url(
677+ r'^notifications/$',
678+ notifications_handler, name='notifications_handler'),
679+ url(
680+ r'^notifications/(?P<id>[^/]+)/$',
681+ notification_handler, name='notification_handler'),
682 )
683
684