Merge lp:~allenap/maas/notifications-model 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: 5621
Proposed branch: lp:~allenap/maas/notifications-model
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 357 lines (+325/-0)
4 files modified
src/maasserver/migrations/builtin/maasserver/0103_notifications.py (+44/-0)
src/maasserver/models/__init__.py (+2/-0)
src/maasserver/models/notification.py (+106/-0)
src/maasserver/models/tests/test_notification.py (+173/-0)
To merge this branch: bzr merge lp:~allenap/maas/notifications-model
Reviewer Review Type Date Requested Status
Lee Trager (community) Approve
Review via email: mp+314074@code.launchpad.net

Commit message

Simple model for notifications.

Notifications can be targeted at an individual user, all users, or all admins, or combinations thereof.

Description of the change

Methods to query notifications will come in a subsequent branch, along with the means for users to dismiss notifications.

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

LGTM!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'src/maasserver/migrations/builtin/maasserver/0103_notifications.py'
2--- src/maasserver/migrations/builtin/maasserver/0103_notifications.py 1970-01-01 00:00:00 +0000
3+++ src/maasserver/migrations/builtin/maasserver/0103_notifications.py 2017-01-05 09:51:33 +0000
4@@ -0,0 +1,44 @@
5+# -*- coding: utf-8 -*-
6+from __future__ import unicode_literals
7+
8+from django.conf import settings
9+from django.db import (
10+ migrations,
11+ models,
12+)
13+import maasserver.fields
14+import maasserver.models.cleansave
15+
16+
17+class Migration(migrations.Migration):
18+
19+ dependencies = [
20+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
21+ ('maasserver', '0102_remove_space_from_subnet'),
22+ ]
23+
24+ operations = [
25+ migrations.CreateModel(
26+ name='Notification',
27+ fields=[
28+ ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)),
29+ ('created', models.DateTimeField(editable=False)),
30+ ('updated', models.DateTimeField(editable=False)),
31+ ('ident', models.CharField(null=True, blank=True, max_length=40)),
32+ ('users', models.BooleanField(default=False)),
33+ ('admins', models.BooleanField(default=False)),
34+ ('message', models.TextField(blank=True)),
35+ ('context', maasserver.fields.JSONObjectField(default=dict, blank=True)),
36+ ('user', models.ForeignKey(null=True, to=settings.AUTH_USER_MODEL, blank=True)),
37+ ],
38+ bases=(maasserver.models.cleansave.CleanSave, models.Model, object),
39+ ),
40+ migrations.RunSQL(
41+ # Forwards.
42+ "CREATE UNIQUE INDEX maasserver_notification_ident "
43+ "ON maasserver_notification (ident) "
44+ "WHERE ident IS NOT NULL",
45+ # Reverse.
46+ "DROP INDEX maasserver_notification_ident",
47+ )
48+ ]
49
50=== modified file 'src/maasserver/models/__init__.py'
51--- src/maasserver/models/__init__.py 2016-12-07 12:46:14 +0000
52+++ src/maasserver/models/__init__.py 2017-01-05 09:51:33 +0000
53@@ -46,6 +46,7 @@
54 'Neighbour',
55 'Node',
56 'NodeGroupToRackController',
57+ 'Notification',
58 'OwnerData',
59 'PackageRepository',
60 'Partition',
61@@ -147,6 +148,7 @@
62 RegionController,
63 Storage,
64 )
65+from maasserver.models.notification import Notification
66 from maasserver.models.ownerdata import OwnerData
67 from maasserver.models.packagerepository import PackageRepository
68 from maasserver.models.partition import Partition
69
70=== added file 'src/maasserver/models/notification.py'
71--- src/maasserver/models/notification.py 1970-01-01 00:00:00 +0000
72+++ src/maasserver/models/notification.py 2017-01-05 09:51:33 +0000
73@@ -0,0 +1,106 @@
74+# Copyright 2015-2017 Canonical Ltd. This software is licensed under the
75+# GNU Affero General Public License version 3 (see the file LICENSE).
76+
77+"""Model for a notification message."""
78+
79+__all__ = [
80+ 'Notification',
81+]
82+
83+from django.contrib.auth.models import User
84+from django.db.models import (
85+ BooleanField,
86+ CharField,
87+ ForeignKey,
88+ Manager,
89+ TextField,
90+)
91+from maasserver import DefaultMeta
92+from maasserver.fields import JSONObjectField
93+from maasserver.models.cleansave import CleanSave
94+from maasserver.models.timestampedmodel import TimestampedModel
95+
96+
97+class NotificationManager(Manager):
98+ """Manager for `Notification` class."""
99+
100+ def create_for_user(self, message, user, *, context=None, ident=None):
101+ """Create a notification for a specific user."""
102+ if ident is not None:
103+ self.filter(ident=ident).update(ident=None)
104+
105+ notification = self._create(
106+ user, False, False, ident, message, context)
107+ notification.save()
108+
109+ return notification
110+
111+ def create_for_users(self, message, *, context=None, ident=None):
112+ """Create a notification for all users and admins."""
113+ if ident is not None:
114+ self.filter(ident=ident).update(ident=None)
115+
116+ notification = self._create(
117+ None, True, True, ident, message, context)
118+ notification.save()
119+
120+ return notification
121+
122+ def create_for_admins(self, message, *, context=None, ident=None):
123+ """Create a notification for all admins, but not users."""
124+ if ident is not None:
125+ self.filter(ident=ident).update(ident=None)
126+
127+ notification = self._create(
128+ None, False, True, ident, message, context)
129+ notification.save()
130+
131+ return notification
132+
133+ def _create(self, user, users, admins, ident, message, context):
134+ return self.model(
135+ ident=ident, user=user, users=users, admins=admins,
136+ message=message, context={} if context is None else context)
137+
138+
139+class Notification(CleanSave, TimestampedModel):
140+ """A notification message.
141+
142+ :ivar ident: Unique identifier for the notification. Not required but is
143+ used to make sure messages of the same type are not posted multiple
144+ times.
145+
146+ :ivar user: Specific user who can see the message.
147+ :ivar users: If true, this message can be seen by all ordinary users.
148+ :ivar admins: If true, this message can be seen by all administrators.
149+
150+ :ivar message: Message that is viewable by the user. This is used as a
151+ format-style template; see `context`.
152+ :ivar context: A dict (that can be serialised to JSON) that's used with
153+ `message`.
154+ """
155+
156+ class Meta(DefaultMeta):
157+ """Needed for South to recognize this model."""
158+
159+ objects = NotificationManager()
160+
161+ # The ident column *is* unique, but uniqueness will be ensured using a
162+ # partial index in PostgreSQL. These cannot be expressed using Django. See
163+ # migrations for the SQL used to create this index.
164+ ident = CharField(max_length=40, null=True, blank=True)
165+
166+ user = ForeignKey(User, null=True, blank=True)
167+ users = BooleanField(default=False)
168+ admins = BooleanField(default=False)
169+
170+ message = TextField(null=False, blank=True)
171+ context = JSONObjectField(null=False, blank=True, default=dict)
172+
173+ def render(self):
174+ """Render this notification's message using its context."""
175+ return self.message.format(**self.context)
176+
177+ def clean(self):
178+ super(Notification, self).clean()
179+ self.render() # Just check it works.
180
181=== added file 'src/maasserver/models/tests/test_notification.py'
182--- src/maasserver/models/tests/test_notification.py 1970-01-01 00:00:00 +0000
183+++ src/maasserver/models/tests/test_notification.py 2017-01-05 09:51:33 +0000
184@@ -0,0 +1,173 @@
185+# Copyright 2015-2017 Canonical Ltd. This software is licensed under the
186+# GNU Affero General Public License version 3 (see the file LICENSE).
187+
188+"""Tests for `Notification`."""
189+
190+__all__ = []
191+
192+import random
193+
194+from maasserver.models.notification import Notification
195+from maasserver.testing.factory import factory
196+from maasserver.testing.testcase import MAASServerTestCase
197+from maasserver.utils.orm import reload_object
198+from testtools.matchers import (
199+ Equals,
200+ HasLength,
201+ Is,
202+ MatchesStructure,
203+ Not,
204+)
205+
206+
207+class TestNotificationManager(MAASServerTestCase):
208+ """Tests for the `NotificationManager`."""
209+
210+ def test_create_new_notification_for_user(self):
211+ user = factory.make_User()
212+ message = factory.make_name("message")
213+ notification = Notification.objects.create_for_user(message, user)
214+ self.assertThat(
215+ reload_object(notification), MatchesStructure(
216+ ident=Is(None), user=Equals(user), users=Is(False),
217+ admins=Is(False), message=Equals(message), context=Equals({}),
218+ ))
219+
220+ def test_create_new_notification_for_user_with_ident(self):
221+ user = factory.make_User()
222+ ident = factory.make_name("ident")
223+ message = factory.make_name("message")
224+ notification = Notification.objects.create_for_user(
225+ message, user, ident=ident)
226+ self.assertThat(
227+ reload_object(notification), MatchesStructure(
228+ ident=Equals(ident), user=Equals(user), users=Is(False),
229+ admins=Is(False), message=Equals(message), context=Equals({}),
230+ ))
231+
232+ def test_create_new_notification_for_user_with_reused_ident(self):
233+ # A new notification is created, and the ident is moved.
234+ user = factory.make_User()
235+ ident = factory.make_name("ident")
236+ message = factory.make_name("message")
237+ n1 = Notification.objects.create_for_user(message, user, ident=ident)
238+ n2 = Notification.objects.create_for_user(message, user, ident=ident)
239+ self.assertThat(n2, Not(Equals(n1)))
240+ self.assertThat(
241+ reload_object(n1), MatchesStructure(
242+ ident=Is(None), user=Equals(user), users=Is(False),
243+ admins=Is(False), message=Equals(message), context=Equals({}),
244+ ))
245+ self.assertThat(
246+ reload_object(n2), MatchesStructure(
247+ ident=Equals(ident), user=Equals(user), users=Is(False),
248+ admins=Is(False), message=Equals(message), context=Equals({}),
249+ ))
250+ self.assertThat(
251+ Notification.objects.filter(ident=ident),
252+ HasLength(1))
253+
254+ def test_create_new_notification_for_users(self):
255+ message = factory.make_name("message")
256+ notification = Notification.objects.create_for_users(message)
257+ self.assertThat(
258+ reload_object(notification), MatchesStructure(
259+ ident=Is(None), user=Is(None), users=Is(True),
260+ admins=Is(True), message=Equals(message), context=Equals({}),
261+ ))
262+
263+ def test_create_new_notification_for_users_with_ident(self):
264+ message = factory.make_name("message")
265+ ident = factory.make_name("ident")
266+ notification = Notification.objects.create_for_users(
267+ message, ident=ident)
268+ self.assertThat(
269+ reload_object(notification), MatchesStructure(
270+ ident=Equals(ident), user=Is(None), users=Is(True),
271+ admins=Is(True), message=Equals(message), context=Equals({}),
272+ ))
273+
274+ def test_create_new_notification_for_users_with_reused_ident(self):
275+ # A new notification is created, and the ident is moved.
276+ ident = factory.make_name("ident")
277+ message = factory.make_name("message")
278+ n1 = Notification.objects.create_for_users(message, ident=ident)
279+ n2 = Notification.objects.create_for_users(message, ident=ident)
280+ self.assertThat(n2, Not(Equals(n1)))
281+ self.assertThat(
282+ reload_object(n1), MatchesStructure(
283+ ident=Is(None), user=Is(None), users=Is(True),
284+ admins=Is(True), message=Equals(message), context=Equals({}),
285+ ))
286+ self.assertThat(
287+ reload_object(n2), MatchesStructure(
288+ ident=Equals(ident), user=Is(None), users=Is(True),
289+ admins=Is(True), message=Equals(message), context=Equals({}),
290+ ))
291+ self.assertThat(
292+ Notification.objects.filter(ident=ident),
293+ HasLength(1))
294+
295+ def test_create_new_notification_for_admins(self):
296+ message = factory.make_name("message")
297+ notification = Notification.objects.create_for_admins(message)
298+ self.assertThat(
299+ reload_object(notification), MatchesStructure(
300+ ident=Is(None), user=Is(None), users=Is(False),
301+ admins=Is(True), message=Equals(message), context=Equals({}),
302+ ))
303+
304+ def test_create_new_notification_for_admins_with_ident(self):
305+ message = factory.make_name("message")
306+ ident = factory.make_name("ident")
307+ notification = Notification.objects.create_for_admins(
308+ message, ident=ident)
309+ self.assertThat(
310+ reload_object(notification), MatchesStructure(
311+ ident=Equals(ident), user=Is(None), users=Is(False),
312+ admins=Is(True), message=Equals(message), context=Equals({}),
313+ ))
314+
315+ def test_create_new_notification_for_admins_with_reused_ident(self):
316+ # A new notification is created, and the ident is moved.
317+ ident = factory.make_name("ident")
318+ message = factory.make_name("message")
319+ n1 = Notification.objects.create_for_admins(message, ident=ident)
320+ n2 = Notification.objects.create_for_admins(message, ident=ident)
321+ self.assertThat(n2, Not(Equals(n1)))
322+ self.assertThat(
323+ reload_object(n1), MatchesStructure(
324+ ident=Is(None), user=Is(None), users=Is(False),
325+ admins=Is(True), message=Equals(message), context=Equals({}),
326+ ))
327+ self.assertThat(
328+ reload_object(n2), MatchesStructure(
329+ ident=Equals(ident), user=Is(None), users=Is(False),
330+ admins=Is(True), message=Equals(message), context=Equals({}),
331+ ))
332+ self.assertThat(
333+ Notification.objects.filter(ident=ident),
334+ HasLength(1))
335+
336+
337+class TestNotification(MAASServerTestCase):
338+ """Tests for the `Notification`."""
339+
340+ def test_render_combines_message_with_context(self):
341+ thing_a = factory.make_name("a")
342+ thing_b = random.randrange(1000)
343+ message = "There are {b:d} of {a} in my suitcase."
344+ context = {"a": thing_a, "b": thing_b}
345+ notification = Notification(message=message, context=context)
346+ self.assertThat(
347+ notification.render(), Equals(
348+ "There are " + str(thing_b) + " of " +
349+ thing_a + " in my suitcase."))
350+
351+ def test_save_checks_that_rendering_works(self):
352+ message = "Dude, where's my {thing}?"
353+ notification = Notification(message=message)
354+ error = self.assertRaises(KeyError, notification.save)
355+ self.assertThat(str(error), Equals(repr("thing")))
356+ self.assertThat(notification.id, Is(None))
357+ self.assertThat(Notification.objects.all(), HasLength(0))