Merge lp:~jameinel/maas/tag-list-api into lp:maas/trunk

Proposed by John A Meinel on 2012-09-21
Status: Merged
Approved by: John A Meinel on 2012-09-25
Approved revision: 1054
Merged at revision: 1070
Proposed branch: lp:~jameinel/maas/tag-list-api
Merge into: lp:maas/trunk
Diff against target: 495 lines (+300/-4)
8 files modified
src/maasserver/api.py (+99/-0)
src/maasserver/forms.py (+13/-0)
src/maasserver/models/node.py (+3/-0)
src/maasserver/models/tag.py (+28/-1)
src/maasserver/testing/factory.py (+3/-1)
src/maasserver/tests/test_api.py (+147/-1)
src/maasserver/tests/test_tag.py (+1/-1)
src/maasserver/urls_api.py (+6/-0)
To merge this branch: bzr merge lp:~jameinel/maas/tag-list-api
Reviewer Review Type Date Requested Status
John A Meinel Approve on 2012-09-25
Jeroen T. Vermeulen (community) 2012-09-21 Approve on 2012-09-24
Review via email: mp+125697@code.launchpad.net

Commit Message

Expose '/tags/' for CRUD. And expose tags as an attribute of Node.

Description of the Change

This adds 2 primary features

'/tags/' to the api, with the ability to create new tags, and PUT/DELETE to update and remove them.

This also tries to expose tags as an attribute of Node, but it seems to be exposing more than I really want. (I don't need the whole tag definition exposed, just the basic name.)

Note that this doesn't actually apply tags to nodes based on the definition, because we need the lshw in the table for that.

To post a comment you must log in.
Jeroen T. Vermeulen (jtv) wrote :

You left this TODO comment in src/maasserver/api.py:

89 +# TODO: Add AnonTagsHandler/AnonTagHandler?

Don't land TODO comments. Some of us use a bzr plugin that warns about such comments as signs that the branch is not ready for review. Resolve it if you can, or if it's serious and has to be deferred, make it an XXX with a bug number.

As for the issue that the comment is about, it seems to me that exposing the tagging of nodes (e.g. the "nodes" operation) reveals inventory information that administrators may want to keep private. These things are often worth their own tests.

Jeroen T. Vermeulen (jtv) wrote :

May I recommend factory.make_name() as a nicer way to generate randomized names?

Also, it's a bit pointless to assert that the row count of a query is zero -- just self.assertFalse(query.exists()). If you're checking that the row count is not zero, self.assertTrue(query.exists()).

In the tests where you assert a row count of 1, there are two different concerns: that the count be nonzero, and that there be no more than one row. The latter is probably guarded by a unique-constraint in the database, in which case you might as well use self.assertTrue(query.exists()). Or if there is a real worry about having multiple rows, consider specifying exactly what you expect to see, so that an unexpected result gets printed in more detail:

    self.assertItemsEqual([tag], Tag.objects.filter(...))

-

Then, a small note about this test:

339 + def test_GET_returns_tag(self):
340 + # The api allows for fetching a single Node (using system_id).
341 + tag = factory.make_tag('tag-name')
342 + response = self.client.get(self.get_uri('tags/tag-name/'))
343 + parsed_result = json.loads(response.content)
344 +
345 + self.assertEqual(httplib.OK, response.status_code)
346 + self.assertEqual(tag.name, parsed_result['name'])
347 + self.assertEqual(tag.definition, parsed_result['definition'])
348 + self.assertEqual(tag.comment, parsed_result['comment'])

Better test the status code _before_ you decode the contents, otherwise an unexpected result will get you a traceback for non-JSON contents before you even get to the status-code check.

Jeroen T. Vermeulen (jtv) wrote :

As per IRC: I had one big question mark for the "nodes" method being a POST, but that's an infrastructure limitation. Otherwise, once my notes are addressed, I'm quite happy with this branch.

review: Approve
MAAS Lander (maas-lander) wrote :

There are additional revisions which have not been approved in review. Please seek review and approval of these new revisions.

John A Meinel (jameinel) wrote :

I'm tweaking.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api.py'
2--- src/maasserver/api.py 2012-09-24 11:19:14 +0000
3+++ src/maasserver/api.py 2012-09-25 11:23:22 +0000
4@@ -69,6 +69,8 @@
5 "NodeMacHandler",
6 "NodeMacsHandler",
7 "NodesHandler",
8+ "TagHandler",
9+ "TagsHandler",
10 "pxeconfig",
11 "render_api_docs",
12 ]
13@@ -133,6 +135,7 @@
14 get_node_edit_form,
15 NodeGroupInterfaceForm,
16 NodeGroupWithInterfacesForm,
17+ TagForm,
18 )
19 from maasserver.models import (
20 BootImage,
21@@ -143,6 +146,7 @@
22 Node,
23 NodeGroup,
24 NodeGroupInterface,
25+ Tag,
26 )
27 from maasserver.preseed import (
28 compose_enlistment_preseed_url,
29@@ -429,6 +433,7 @@
30 'netboot',
31 'power_type',
32 'power_parameters',
33+ 'tag_names',
34 )
35
36
37@@ -1247,6 +1252,100 @@
38
39
40 @api_operations
41+class TagHandler(BaseHandler):
42+ """Manage individual Tags."""
43+ allowed_methods = ('GET', 'DELETE', 'POST', 'PUT')
44+ model = Tag
45+ fields = (
46+ 'name',
47+ 'definition',
48+ 'comment',
49+ )
50+
51+ def read(self, request, name):
52+ """Read a specific Node."""
53+ return Tag.objects.get_tag_or_404(name=name, user=request.user)
54+
55+ def update(self, request, name):
56+ """Update a specific `Tag`.
57+ """
58+ tag = Tag.objects.get_tag_or_404(name=name, user=request.user,
59+ to_edit=True)
60+ data = get_overrided_query_dict(model_to_dict(tag), request.data)
61+ form = TagForm(data, instance=tag)
62+ if form.is_valid():
63+ return form.save()
64+ else:
65+ raise ValidationError(form.errors)
66+
67+ def delete(self, request, name):
68+ """Delete a specific Node."""
69+ tag = Tag.objects.get_tag_or_404(name=name,
70+ user=request.user, to_edit=True)
71+ tag.delete()
72+ return rc.DELETED
73+
74+ # XXX: JAM 2012-09-25 This is currently a POST because of bug:
75+ # http://pad.lv/1049933
76+ # Essentially, if you have one 'GET' op, then you can no longer get
77+ # the Tag object itself from a plain 'GET' without op.
78+ @api_exported('POST')
79+ def nodes(self, request, name):
80+ """Get the list of nodes that have this tag."""
81+ tag = Tag.objects.get_tag_or_404(name=name, user=request.user)
82+ # XXX: JAM 2012-09-25 We need to filter the node set returned by the
83+ # visibility defined by the user.
84+ return tag.node_set.all()
85+
86+ @classmethod
87+ def resource_uri(cls, tag=None):
88+ # See the comment in NodeHandler.resource_uri
89+ tag_name = 'tag_name'
90+ if tag is not None:
91+ tag_name = tag.name
92+ return ('tag_handler', (tag_name, ))
93+
94+
95+@api_operations
96+class TagsHandler(BaseHandler):
97+ """Manage collection of Tags."""
98+ allowed_methods = ('GET', 'POST')
99+
100+ @api_exported('POST')
101+ def new(self, request):
102+ """Create a new `Tag`.
103+ """
104+ return create_tag(request)
105+
106+ @api_exported('GET')
107+ def list(self, request):
108+ """List Tags.
109+ """
110+ return Tag.objects.all()
111+
112+ @classmethod
113+ def resource_uri(cls, *args, **kwargs):
114+ return ('tags_handler', [])
115+
116+
117+def create_tag(request):
118+ """Service an http request to create a tag.
119+
120+ :param request: The http request for this node to be created.
121+ :return: A `Tag`.
122+ :rtype: :class:`maasserver.models.Tag`.
123+ :raises: ValidationError
124+ """
125+ if not request.user.is_superuser:
126+ raise PermissionDenied()
127+ form = TagForm(request.data)
128+ if form.is_valid():
129+ return form.save()
130+ else:
131+ raise ValidationError(form.errors)
132+
133+
134+@api_operations
135 class MAASHandler(BaseHandler):
136 """Manage the MAAS' itself."""
137 allowed_methods = ('POST', 'GET')
138
139=== modified file 'src/maasserver/forms.py'
140--- src/maasserver/forms.py 2012-09-21 09:06:21 +0000
141+++ src/maasserver/forms.py 2012-09-25 11:23:22 +0000
142@@ -25,6 +25,7 @@
143 "SSHKeyForm",
144 "UbuntuForm",
145 "AdminNodeForm",
146+ "TagForm",
147 ]
148
149 import collections
150@@ -73,6 +74,7 @@
151 NodeGroup,
152 NodeGroupInterface,
153 SSHKey,
154+ Tag,
155 )
156 from maasserver.node_action import compile_node_actions
157 from maasserver.power_parameters import POWER_TYPE_PARAMETERS
158@@ -743,3 +745,14 @@
159 nodegroup.status = NODEGROUP_STATUS.PENDING
160 nodegroup.save()
161 return nodegroup
162+
163+
164+class TagForm(ModelForm):
165+
166+ class Meta:
167+ model = Tag
168+ fields = (
169+ 'name',
170+ 'comment',
171+ 'definition',
172+ )
173
174=== modified file 'src/maasserver/models/node.py'
175--- src/maasserver/models/node.py 2012-09-24 11:19:14 +0000
176+++ src/maasserver/models/node.py 2012-09-25 11:23:22 +0000
177@@ -420,6 +420,9 @@
178 else:
179 return self.system_id
180
181+ def tag_names(self):
182+ return self.tags.values_list('name', flat=True)
183+
184 def clean_status(self):
185 """Check a node's status transition against the node-status FSM."""
186 old_status = get_db_state(self, 'status')
187
188=== modified file 'src/maasserver/models/tag.py'
189--- src/maasserver/models/tag.py 2012-09-21 06:37:02 +0000
190+++ src/maasserver/models/tag.py 2012-09-25 11:23:22 +0000
191@@ -14,19 +14,46 @@
192 "Tag",
193 ]
194
195+from django.core.exceptions import (
196+ PermissionDenied,
197+ )
198 from django.db.models import (
199 CharField,
200 Manager,
201 TextField,
202 )
203+from django.shortcuts import get_object_or_404
204 from maasserver import DefaultMeta
205 from maasserver.models.cleansave import CleanSave
206 from maasserver.models.timestampedmodel import TimestampedModel
207
208
209+# Permission model for tags. Everyone can see all tags, but only superusers can
210+# edit tags.
211 class TagManager(Manager):
212 """A utility to manage the collection of Tags."""
213- pass
214+
215+ def get_tag_or_404(self, name, user, to_edit=False):
216+ """Fetch a `Tag` by name. Raise exceptions if no `Tag` with
217+ this name exist.
218+
219+ :param name: The Tag.name.
220+ :type name: str
221+ :param user: The user that should be used in the permission check.
222+ :type user: django.contrib.auth.models.User
223+ :param to_edit: Are we going to edit this tag, or just view it?
224+ :type to_edit: bool
225+ :raises: django.http.Http404_,
226+ :class:`maasserver.exceptions.PermissionDenied`.
227+
228+ .. _django.http.Http404: https://
229+ docs.djangoproject.com/en/dev/topics/http/views/
230+ #the-http404-exception
231+ """
232+ if to_edit and not user.is_superuser:
233+ raise PermissionDenied()
234+ tag = get_object_or_404(Tag, name=name)
235+ return tag
236
237
238 class Tag(CleanSave, TimestampedModel):
239
240=== modified file 'src/maasserver/testing/factory.py'
241--- src/maasserver/testing/factory.py 2012-09-20 13:16:13 +0000
242+++ src/maasserver/testing/factory.py 2012-09-25 11:23:22 +0000
243@@ -225,8 +225,10 @@
244 key.save()
245 return key
246
247- def make_tag(self, name, definition=None, comment='', created=None,
248+ def make_tag(self, name=None, definition=None, comment='', created=None,
249 updated=None):
250+ if name is None:
251+ name = self.make_name('tag')
252 if definition is None:
253 # Is there a 'node' in this xml?
254 definition = '//node'
255
256=== modified file 'src/maasserver/tests/test_api.py'
257--- src/maasserver/tests/test_api.py 2012-09-24 11:19:14 +0000
258+++ src/maasserver/tests/test_api.py 2012-09-25 11:23:22 +0000
259@@ -66,6 +66,7 @@
260 Node,
261 NodeGroup,
262 NodeGroupInterface,
263+ Tag,
264 )
265 from maasserver.models.user import (
266 create_auth_token,
267@@ -496,6 +497,7 @@
268 'netboot',
269 'power_type',
270 'power_parameters',
271+ 'tag_names',
272 ],
273 list(parsed_result))
274
275@@ -570,6 +572,7 @@
276 'power_type',
277 'power_parameters',
278 'resource_uri',
279+ 'tag_names',
280 ],
281 list(parsed_result))
282
283@@ -710,6 +713,7 @@
284 'power_type',
285 'power_parameters',
286 'resource_uri',
287+ 'tag_names',
288 ],
289 list(parsed_result))
290
291@@ -852,12 +856,22 @@
292 # The api allows for fetching a single Node (using system_id).
293 node = factory.make_node(set_hostname=True)
294 response = self.client.get(self.get_node_uri(node))
295- parsed_result = json.loads(response.content)
296
297 self.assertEqual(httplib.OK, response.status_code)
298+ parsed_result = json.loads(response.content)
299 self.assertEqual(node.hostname, parsed_result['hostname'])
300 self.assertEqual(node.system_id, parsed_result['system_id'])
301
302+ def test_GET_returns_associated_tag(self):
303+ node = factory.make_node(set_hostname=True)
304+ tag = factory.make_tag()
305+ node.tags.add(tag)
306+ response = self.client.get(self.get_node_uri(node))
307+
308+ self.assertEqual(httplib.OK, response.status_code)
309+ parsed_result = json.loads(response.content)
310+ self.assertEqual([tag.name], parsed_result['tag_names'])
311+
312 def test_GET_refuses_to_access_invisible_node(self):
313 # The request to fetch a single node is denied if the node isn't
314 # visible by the user.
315@@ -2160,6 +2174,138 @@
316 self.assertEqual("File not found", response.content)
317
318
319+class TestTagAPI(APITestCase):
320+ """Tests for /api/1.0/tags/<tagname>/."""
321+
322+ def get_tag_uri(self, tag):
323+ """Get the API URI for `tag`."""
324+ return self.get_uri('tags/%s/') % tag.name
325+
326+ def test_DELETE_requires_admin(self):
327+ tag = factory.make_tag()
328+ response = self.client.delete(self.get_tag_uri(tag))
329+ self.assertEqual(httplib.FORBIDDEN, response.status_code)
330+ self.assertItemsEqual([tag], Tag.objects.filter(id=tag.id))
331+
332+ def test_DELETE_removes_tag(self):
333+ self.become_admin()
334+ tag = factory.make_tag()
335+ response = self.client.delete(self.get_tag_uri(tag))
336+ self.assertEqual(httplib.NO_CONTENT, response.status_code)
337+ self.assertFalse(Tag.objects.filter(id=tag.id).exists())
338+
339+ def test_DELETE_404(self):
340+ self.become_admin()
341+ response = self.client.delete(self.get_uri('tags/no-tag/'))
342+ self.assertEqual(httplib.NOT_FOUND, response.status_code)
343+
344+ def test_GET_returns_tag(self):
345+ # The api allows for fetching a single Node (using system_id).
346+ tag = factory.make_tag('tag-name')
347+ response = self.client.get(self.get_uri('tags/tag-name/'))
348+
349+ self.assertEqual(httplib.OK, response.status_code)
350+ parsed_result = json.loads(response.content)
351+ self.assertEqual(tag.name, parsed_result['name'])
352+ self.assertEqual(tag.definition, parsed_result['definition'])
353+ self.assertEqual(tag.comment, parsed_result['comment'])
354+
355+ def test_GET_refuses_to_access_nonexistent_node(self):
356+ # When fetching a Tag, the api returns a 'Not Found' (404) error
357+ # if no tag is found.
358+ response = self.client.get(self.get_uri('tags/no-such-tag/'))
359+ self.assertEqual(httplib.NOT_FOUND, response.status_code)
360+
361+ def test_PUT_refuses_non_superuser(self):
362+ tag = factory.make_tag()
363+ response = self.client.put(self.get_tag_uri(tag),
364+ {'comment': 'A special comment'})
365+ self.assertEqual(httplib.FORBIDDEN, response.status_code)
366+
367+ def test_PUT_invalid_field(self):
368+ self.become_admin()
369+ tag = factory.make_tag()
370+ response = self.client.put(self.get_tag_uri(tag),
371+ {'not-a-field': 'content'})
372+ self.assertEqual(httplib.OK, response.status_code)
373+
374+ def test_PUT_updates_tag(self):
375+ self.become_admin()
376+ tag = factory.make_tag()
377+ # Note that 'definition' is not being sent
378+ response = self.client.put(self.get_tag_uri(tag),
379+ {'name': 'new-tag-name', 'comment': 'A random comment'})
380+
381+ self.assertEqual(httplib.OK, response.status_code)
382+ parsed_result = json.loads(response.content)
383+ self.assertEqual('new-tag-name', parsed_result['name'])
384+ self.assertEqual('A random comment', parsed_result['comment'])
385+ self.assertEqual(tag.definition, parsed_result['definition'])
386+ self.assertFalse(Tag.objects.filter(name=tag.name).exists())
387+ self.assertTrue(Tag.objects.filter(name='new-tag-name').exists())
388+
389+ def test_POST_nodes_with_no_nodes(self):
390+ tag = factory.make_tag()
391+ response = self.client.post(self.get_tag_uri(tag), {'op': 'nodes'})
392+
393+ self.assertEqual(httplib.OK, response.status_code)
394+ parsed_result = json.loads(response.content)
395+ self.assertEqual([], parsed_result)
396+
397+ def test_POST_nodes_returns_nodes(self):
398+ tag = factory.make_tag()
399+ node1 = factory.make_node()
400+ # Create a second node that isn't tagged.
401+ factory.make_node()
402+ node1.tags.add(tag)
403+ response = self.client.post(self.get_tag_uri(tag), {'op': 'nodes'})
404+
405+ self.assertEqual(httplib.OK, response.status_code)
406+ parsed_result = json.loads(response.content)
407+ self.assertEqual([node1.system_id],
408+ [r['system_id'] for r in parsed_result])
409+
410+
411+class TestTagsAPI(APITestCase):
412+
413+ def test_GET_list_without_tags_returns_empty_list(self):
414+ response = self.client.get(self.get_uri('tags/'), {'op': 'list'})
415+ self.assertItemsEqual([], json.loads(response.content))
416+
417+ def test_POST_new_refuses_non_admin(self):
418+ name = factory.getRandomString()
419+ response = self.client.post(
420+ self.get_uri('tags/'),
421+ {
422+ 'op': 'new',
423+ 'name': name,
424+ 'comment': factory.getRandomString(),
425+ 'definition': factory.getRandomString(),
426+ })
427+ self.assertEqual(httplib.FORBIDDEN, response.status_code)
428+ self.assertFalse(Tag.objects.filter(name=name).exists())
429+
430+ def test_POST_new_creates_tag(self):
431+ self.become_admin()
432+ name = factory.getRandomString()
433+ definition = '//node'
434+ comment = factory.getRandomString()
435+ response = self.client.post(
436+ self.get_uri('tags/'),
437+ {
438+ 'op': 'new',
439+ 'name': name,
440+ 'comment': comment,
441+ 'definition': definition,
442+ })
443+ self.assertEqual(httplib.OK, response.status_code)
444+ parsed_result = json.loads(response.content)
445+ self.assertEqual(name, parsed_result['name'])
446+ self.assertEqual(comment, parsed_result['comment'])
447+ self.assertEqual(definition, parsed_result['definition'])
448+ self.assertTrue(Tag.objects.filter(name=name).exists())
449+
450+
451 class MAASAPIAnonTest(APIv10TestMixin, TestCase):
452 # The MAAS' handler is not accessible to anon users.
453
454
455=== modified file 'src/maasserver/tests/test_tag.py'
456--- src/maasserver/tests/test_tag.py 2012-09-23 13:06:49 +0000
457+++ src/maasserver/tests/test_tag.py 2012-09-25 11:23:22 +0000
458@@ -32,7 +32,7 @@
459
460 def test_add_tag_to_node(self):
461 node = factory.make_node()
462- tag = factory.make_tag('tag-name')
463+ tag = factory.make_tag()
464 tag.save()
465 node.tags.add(tag)
466 self.assertEqual([tag.id], [t.id for t in node.tags.all()])
467
468=== modified file 'src/maasserver/urls_api.py'
469--- src/maasserver/urls_api.py 2012-09-18 07:51:29 +0000
470+++ src/maasserver/urls_api.py 2012-09-25 11:23:22 +0000
471@@ -32,6 +32,8 @@
472 NodeMacHandler,
473 NodeMacsHandler,
474 NodesHandler,
475+ TagHandler,
476+ TagsHandler,
477 pxeconfig,
478 RestrictedResource,
479 )
480@@ -51,6 +53,8 @@
481 NodeGroupsHandler, authentication=api_auth)
482 boot_images_handler = RestrictedResource(
483 BootImagesHandler, authentication=api_auth)
484+tag_handler = RestrictedResource(TagHandler, authentication=api_auth)
485+tags_handler = RestrictedResource(TagsHandler, authentication=api_auth)
486
487
488 # Admin handlers.
489@@ -92,6 +96,8 @@
490 url(r'files/$', files_handler, name='files_handler'),
491 url(r'account/$', account_handler, name='account_handler'),
492 url(r'boot-images/$', boot_images_handler, name='boot_images_handler'),
493+ url(r'tags/(?P<name>[\w\-]+)/$', tag_handler, name='tag_handler'),
494+ url(r'tags/$', tags_handler, name='tags_handler'),
495 )
496
497