Merge lp:~allenap/maas/notifications-api into lp:~maas-committers/maas/trunk
- notifications-api
- Merge into trunk
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 |
Related bugs: |
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.
Description of the change
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.
MAAS Lander (maas-lander) wrote : | # |
The attempt to merge lp:~allenap/maas/notifications-api into lp:maas failed. Below is the output from the failed tests.
Get:1 http://
Hit:2 http://
Get:3 http://
Get:4 http://
Get:5 http://
Get:6 http://
Get:7 http://
Get:8 http://
Get:9 http://
Get:10 http://
Fetched 1,559 kB in 0s (2,495 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 vers...
Blake Rouse (blake-rouse) : | # |
Gavin Panella (allenap) : | # |
Preview Diff
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 |
Looks good. Just a couple of comments.