Merge lp:~mpontillo/maas/node-statistics-bug-1584926--part1 into lp:~maas-committers/maas/trunk

Proposed by Mike Pontillo
Status: Rejected
Rejected by: MAAS Lander
Proposed branch: lp:~mpontillo/maas/node-statistics-bug-1584926--part1
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 588 lines (+444/-22)
5 files modified
src/maasserver/api/nodes.py (+86/-9)
src/maasserver/api/support.py (+83/-0)
src/maasserver/api/tests/test_machines.py (+40/-5)
src/maasserver/api/tests/test_nodes.py (+234/-7)
src/maasserver/testing/testclient.py (+1/-1)
To merge this branch: bzr merge lp:~mpontillo/maas/node-statistics-bug-1584926--part1
Reviewer Review Type Date Requested Status
Gavin Panella (community) Needs Fixing
Review via email: mp+297679@code.launchpad.net

Commit message

Node API usability enhancements, part 1.

 * Refactor APIs to support summaries and counts more easily.
   - <nodes|machines|devices|region-controllers|rack-controllers> read:
     now supports the 'count' and 'summary' parameters.
 * Add new API to fetch machines using a "group by":
   - nodes by-type [summary=<bool> | count=<bool>]

To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote :

I moved the changes for the devices and machines API to a separate branch. This branch is now just the refactoring needed to support summaries and counts, and the change in the nodes API to use that refactoring.

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

This is useful functionality but the implementation is overcomplicated.

Almost all that's needed is a function to figure out what to show:

  SHOW_COUNT, SHOW_SUMMARY = "count", "summary"

  def get_show_option(request):
      summary = get_optional_param(
          request.GET, 'summary', default=False, validator=StringBool)
      count = get_optional_param(
          request.GET, 'count', default=False, validator=StringBool)
      if count:
          return SHOW_COUNT
      elif summary:
          return SHOW_SUMMARY
      else:
          return None

(I've said elsewhere that I think a single `show` parameter would be
better than two separate parameters, but that's a separate point.)

Then, in each handler method:

  def read(self, request):
      query = ...
      show = get_show_option(request)
      if show == SHOW_COUNT:
          return query.count()
      elif show == SHOW_SUMMARY:
          return list(map(self.summarize, query))
      else:
          return query.prefetch(...)

There's no need for a framework to support this.

The code added to OperationsHandlerMixin makes it hard to follow what's
going on. It's convoluted, and OperationsHandlerMixin would be the wrong
place for it anyway.

One day it might conceivably be necessary to have some more frameworky
support for this behaviour, but not now, and this wouldn't be it. As is,
this ties us closer to Piston, and it's not well structured.

The prefetch and summary methods are not too bad, but where they're
placed is too general. Prefetching can vary; it's unlikely that a whole
handler, which can process GET, POST, DELETE, PUT and custom operations,
is always going to have the same needs for prefetching. Same goes for
summaries.

result_summary, query_or_count, and prepare_results don't fit together
well. The first especially, which has two isinstance checks to see if it
should actually get out of the way, begging the question: why is it
being called at all? Both query_or_count and result_summary could be
eliminated by small changes to prepare_results:

  def prepare_results(self, query, count=False, summary=False):
      if count:
          return query.count()
      elif summary:
          query = self._prefetch_for_summary_dump(query)
          return list(map(self._summarize, query))
      else:
          return self._prefetch_for_full_dump(query)

Even now prepare_results would be in the wrong place; every handler in
MAAS would get this method, but it's not so generally useful. And it's
so simple that it's not necessary; each handler method can figure this
stuff out on its own and not have to adopt this API. If a very clear
pattern emerges then maybe it's worth extracting a new method to do it,
but today is not that day.

I've put up a branch that removes the OperationsHandlerMixin code, adds
a get_show_option function, updates the handlers, and fixes a few broken
tests here:

  lp~allenap/maas/mpontillo--node-statistics-bug-1584926--part1

I did this to prove to myself that I wasn't talking nonsense above, but
it's good enough to use, and I think you should use it.

review: Needs Fixing
Revision history for this message
Blake Rouse (blake-rouse) wrote :

I also think this is getting rather large and not something that we should land in MAAS 2.0. We need to get rc1 today, this will just add more bug surface.

Lets target this for MAAS 2.1. Since this is a feature and not a bug, and it was requested basically in the MAAS 2.1 cycle we should be fixing it there and not in MAAS 2.0.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

I'm fine with landing this in 2.1.

Gavin, did you look at the part2 branch? Because that was where the pattern emerged that led me to the refactoring (which I thought could one day be generally useful). But I'm fine with tabling this until we can have that design discussion. That was the main reason I asked you to look at this branch, to be honest.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Gavin, thanks for doing the derived branch.

I like what you did with get_show_option(), and I think it's debatable what the exact format is. We should agree on something future-proof because we'll have to live with it in 2.1, and any future APIs that mimic this functionality.

I also like your suggestion to simplify prepare_results.

I don't agree that my changes to the Mixin were too general. I think, while the code you wrote for by-type is easier to read, it's highly redundant if repeated 4 more times in the machines API (again, see part2).

So the question in my mind is, if OperationsHandlerMixin isn't the right place for it (and I somewhat suspected it wasn't), what is? Can we create another mixin class? I was on the fence, since I do feel it could be generally useful throughout the entire API.

Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (3.2 KiB)

> Gavin, did you look at the part2 branch? Because that was where the
> pattern emerged that led me to the refactoring (which I thought could
> one day be generally useful). But I'm fine with tabling this until we
> can have that design discussion. That was the main reason I asked you
> to look at this branch, to be honest.

I did look at it briefly and didn't see anything that really changed my
mind. I have since looked at it in greater depth and seen something that
makes it even clearer that it's not a good pattern...

>
> ...
>
> I like what you did with get_show_option(), and I think it's debatable
> what the exact format is. We should agree on something future-proof
> because we'll have to live with it in 2.1, and any future APIs that
> mimic this functionality.

As I've said, I prefer a single parameter like show=[full|summary|count]
for example, because it's clearer (versus 2 booleans = 4 possibilities
to select only 3 options) and more concise. But, I grant it's only a
little thing, and it'll be wrapped up behind a client library for most
folk anyway.

>
> I also like your suggestion to simplify prepare_results.
>
> I don't agree that my changes to the Mixin were too general. I think,
> while the code you wrote for by-type is easier to read, it's highly
> redundant if repeated 4 more times in the machines API (again, see
> part2).

Putting it in a mix-in at all is too general; it is relevant only to GET
and POST+op, not to DELETE, PUT, or POST.

> So the question in my mind is, if OperationsHandlerMixin isn't the
> right place for it (and I somewhat suspected it wasn't), what is? Can
> we create another mixin class? I was on the fence, since I do feel it
> could be generally useful throughout the entire API.

The problematic thing I discovered is related to performance.

Dumping a node of any shade to JSON is inefficient already. Despite
prefetching there are a still a lot of queries performed for each node.
(There's plenty of work needed already to fix these kinds of issues,
should anyone want to do it.)

The prepare_results() pattern makes this worse. An example from
DevicesHandler.by_parent:

  return {
      node.system_id if by_system_id else node.hostname:
          self.prepare_results(
              self._query(request).filter(parent=node),
              count=count, summary=summary)
      for node in Node.objects.filter(children__isnull=False)
  }

Given a single machine with two child devices, a call to by_parent (not
summary, not count) needs 37 queries.

But given _two_ machines with two child devices each, a similar call to
by_parent needs 57 queries. That's 20 extra queries for 3 nodes. A MAAS
install of 100+ nodes might need 500 queries or more to service
by_parent.

For a summary the overhead is 8 queries per extra machine with two
children, and the overhead for a count is 2 queries.

I have spent some time trying to improve these figures but Django and
Piston do get in the way. Sadly, so does prepare_results, including the
alternate version I wrote. It's not a good fit however it's implemented.

I strongly think we need to start by writing these methods out the long
way, paying attention to performance too (with tests...

Read more...

Revision history for this message
MAAS Lander (maas-lander) wrote :

Transitioned to Git.

lp:maas has now moved from Bzr to Git.
Please propose your branches with Launchpad using Git.

git clone https://git.launchpad.net/maas

Unmerged revisions

5141. By Mike Pontillo

Revert changes to devices.py and machines.py. (they'll be moved to another branch)

5140. By Mike Pontillo

Fix lint, review comments.

5139. By Mike Pontillo

More tests.

5138. By Mike Pontillo

Start adding test cases.

5137. By Mike Pontillo

Format imports.

5136. By Mike Pontillo

Add 'devices by-parent [by_system_id=<bool>'.

5135. By Mike Pontillo

Refactor result_summary() to be a little more defensive.

5134. By Mike Pontillo

Clean up API docs.

5133. By Mike Pontillo

Missed an occurance of ModelCountMixin.

5132. By Mike Pontillo

Remove ModelCountMixin. (using read with a parameter seems to make more sense)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/maasserver/api/nodes.py'
--- src/maasserver/api/nodes.py 2016-05-24 13:50:54 +0000
+++ src/maasserver/api/nodes.py 2016-06-16 19:59:56 +0000
@@ -15,6 +15,7 @@
15import bson15import bson
16from django.http import HttpResponse16from django.http import HttpResponse
17from django.shortcuts import get_object_or_40417from django.shortcuts import get_object_or_404
18from formencode.validators import StringBool
18from maasserver.api.support import (19from maasserver.api.support import (
19 admin_method,20 admin_method,
20 AnonymousOperationsHandler,21 AnonymousOperationsHandler,
@@ -47,7 +48,13 @@
47 Node,48 Node,
48 OwnerData,49 OwnerData,
49)50)
50from maasserver.models.node import typecast_to_node_type51from maasserver.models.node import (
52 Device,
53 Machine,
54 RackController,
55 RegionController,
56 typecast_to_node_type,
57)
51from maasserver.models.nodeprobeddetails import get_single_probed_details58from maasserver.models.nodeprobeddetails import get_single_probed_details
52from piston3.utils import rc59from piston3.utils import rc
53from provisioningserver.power.schema import UNKNOWN_POWER_TYPE60from provisioningserver.power.schema import UNKNOWN_POWER_TYPE
@@ -281,6 +288,21 @@
281 anonymous = AnonNodesHandler288 anonymous = AnonNodesHandler
282 base_model = Node289 base_model = Node
283290
291 def prefetch(self, nodes):
292 nodes = nodes.prefetch_related('interface_set__node')
293 nodes = nodes.prefetch_related('interface_set__ip_addresses')
294 nodes = nodes.prefetch_related('tags')
295 nodes = nodes.prefetch_related('zone')
296 return nodes
297
298 def _query(self, request):
299 """Helper method to filter the list of machines.
300
301 Based on the request, the list will be filtered based on the currently
302 logged in user, and whatever search criteria are supplied.
303 """
304 return filtered_nodes_list_from_request(request, model=self.base_model)
305
284 def read(self, request):306 def read(self, request):
285 """List Nodes visible to the user, optionally filtered by criteria.307 """List Nodes visible to the user, optionally filtered by criteria.
286308
@@ -311,27 +333,42 @@
311 :param agent_name: An optional agent name. Only nodes relating to the333 :param agent_name: An optional agent name. Only nodes relating to the
312 nodes with matching agent names will be returned.334 nodes with matching agent names will be returned.
313 :type agent_name: unicode335 :type agent_name: unicode
336
337 :param summary: If True, prints a summary of each node instead of the
338 complete object.
339 :type summary: bool
340
341 :param count: If True, prints a count of nodes instead of any
342 node details.
343 :type count: bool
314 """344 """
345 summary = get_optional_param(
346 request.GET, 'summary', default=False, validator=StringBool)
347 count = get_optional_param(
348 request.GET, 'count', default=False, validator=StringBool)
349 return self.prepare_results(
350 self._read(
351 request, summary=summary, count=count),
352 count=count, summary=summary)
315353
316 if self.base_model == Node:354 def _read(self, request, summary=False, count=False):
355 """List nodes visible to the user."""
356 if self.base_model == Node and not summary and not count:
317 # Avoid circular dependencies357 # Avoid circular dependencies
318 from maasserver.api.devices import DevicesHandler358 from maasserver.api.devices import DevicesHandler
319 from maasserver.api.machines import MachinesHandler359 from maasserver.api.machines import MachinesHandler
320 from maasserver.api.rackcontrollers import RackControllersHandler360 from maasserver.api.rackcontrollers import RackControllersHandler
321 nodes = list(chain(361 nodes = list(chain(
322 DevicesHandler().read(request).order_by("id"),362 DevicesHandler().read(request),
323 MachinesHandler().read(request).order_by("id"),363 MachinesHandler().read(request),
324 RackControllersHandler().read(request).order_by("id"),364 RackControllersHandler().read(request),
325 ))365 ))
326 return nodes366 return nodes
327 else:367 else:
328 nodes = filtered_nodes_list_from_request(request, self.base_model)368 nodes = filtered_nodes_list_from_request(request, self.base_model)
329 # Prefetch related objects that are needed for rendering the369 # Prefetch related objects that are needed for rendering the
330 # result.370 # result.
331 nodes = nodes.prefetch_related('interface_set__node')371 nodes = self.prefetch(nodes)
332 nodes = nodes.prefetch_related('interface_set__ip_addresses')
333 nodes = nodes.prefetch_related('tags')
334 nodes = nodes.prefetch_related('zone')
335 return nodes.order_by('id')372 return nodes.order_by('id')
336373
337 @admin_method374 @admin_method
@@ -356,6 +393,46 @@
356 raise MAASAPIValidationError(form.errors)393 raise MAASAPIValidationError(form.errors)
357 form.save()394 form.save()
358395
396 def summary(self, node):
397 """Returns a summary a node."""
398 return {
399 'system_id': node.system_id,
400 'hostname': node.hostname
401 }
402
403 @operation(idempotent=True)
404 def by_type(self, request):
405 """Prints a listing of nodes grouped by their type.
406
407 Optional arguments:
408 summary: If True, prints a summary of each node instead of the complete
409 object.
410 count: If True, prints a count of each node type instead of any
411 node details.
412
413 Also accepts any filter parameter the 'read' operation accepts.
414 """
415 summary = get_optional_param(
416 request.GET, 'summary', default=False, validator=StringBool)
417 count = get_optional_param(
418 request.GET, 'count', default=False, validator=StringBool)
419 label_to_model = {
420 "machines": Machine,
421 "devices": Device,
422 "rack-controllers": RackController,
423 "region-controllers": RegionController
424 }
425 if count:
426 # We don't have a definition for the displayed fields of a plain
427 # node, so only include that if it's a summary or a count.
428 label_to_model["nodes"] = Node
429 return {
430 label: self.prepare_results(
431 filtered_nodes_list_from_request(request, model=model),
432 count=count, summary=summary)
433 for label, model in label_to_model.items()
434 }
435
359 @classmethod436 @classmethod
360 def resource_uri(cls, *args, **kwargs):437 def resource_uri(cls, *args, **kwargs):
361 return ('nodes_handler', [])438 return ('nodes_handler', [])
362439
=== modified file 'src/maasserver/api/support.py'
--- src/maasserver/api/support.py 2016-05-20 14:31:34 +0000
+++ src/maasserver/api/support.py 2016-06-16 19:59:56 +0000
@@ -13,6 +13,7 @@
13from functools import wraps13from functools import wraps
1414
15from django.core.exceptions import PermissionDenied15from django.core.exceptions import PermissionDenied
16from django.db.models.query import QuerySet
16from django.http import Http40417from django.http import Http404
17from maasserver.api.doc import get_api_description_hash18from maasserver.api.doc import get_api_description_hash
18from maasserver.exceptions import MAASAPIBadRequest19from maasserver.exceptions import MAASAPIBadRequest
@@ -260,6 +261,88 @@
260 else:261 else:
261 return function(self, request, *args, **kwargs)262 return function(self, request, *args, **kwargs)
262263
264 def prefetch(self, query):
265 """Pre-fetches the specified query.
266
267 This method should pre-fetch anything anticipated to be recursively
268 queried for in the non-summary version of the output.
269
270 By default, does no prefetching.
271
272 Override this in a subclass to do prefetching in case result_summary()
273 in case non-summary output is requested.
274 """
275 return query
276
277 def summary(self, obj):
278 """Return a summarized version of the specified model object.
279
280 This method is intended to return the object in a condensed format,
281 with a minimal amount of useful identifying information.
282
283 Override this in a subclass to allow result_summary() to work
284 correctly.
285 """
286 raise NotImplementedError
287
288 def result_summary(self, result, summary=False, order_by='id'):
289 """Utility method to provide a shorter, summary output for the objects
290 in the specified query, if desired.
291
292 :param result: must be either a QuerySet, or an integer.
293 If an integer is passed in, it is simply returned. (This means
294 the user requested a count instead of the query results.)
295 :param summary: if True, returns a smaller object containing only
296 essential information, rather than the complete object.
297 :param order_by: optional field to order by (defaults to 'id').
298 """
299 if isinstance(result, int):
300 # The user chose to count the objects in the query, instead of
301 # returning the results of the query. So just give them the result.
302 return result
303 elif isinstance(result, QuerySet):
304 # If this is a QuerySet, we need to ensure we both prefetch related
305 # objects, and return it in a consistent order.
306 result = self.prefetch(result).order_by(order_by)
307 if not summary:
308 # Pass this through to Piston if we're not in summary mode.
309 return result
310 else:
311 # It would be nice if we could return a generator expression
312 # here, but that doesn't seem to be supported by Piston.
313 return [
314 self.summary(obj) for obj in result
315 ]
316 else:
317 # We're not sure what this is. So just let Piston do whatever
318 # it was going to do with it.
319 return result
320
321 def query_or_count(self, filter, count=False):
322 """Returns either the results of the filter, or a count.
323
324 :param count: if True, calls count() on the specified filter and
325 returns that. Otherwise, returns the filter unchanged.
326 """
327 if count:
328 return filter.count()
329 else:
330 return filter
331
332 def prepare_results(self, query, count=False, summary=False):
333 """Prepares the result set for the specified query.
334
335 Calls the 'summary' and 'prefetch' methods on subclasses, as
336 appropriate.
337
338 :param count: If True, return a count of the objects that would be
339 returned, instead of the objects themselves.
340 :param summary: If True, return summarized objects, instead of the
341 objects themselves.
342 """
343 return self.result_summary(
344 self.query_or_count(query, count=count), summary=summary)
345
263 @classmethod346 @classmethod
264 def decorate(cls, func):347 def decorate(cls, func):
265 """Decorate all exported operations with the given function.348 """Decorate all exported operations with the given function.
266349
=== modified file 'src/maasserver/api/tests/test_machines.py'
--- src/maasserver/api/tests/test_machines.py 2016-06-09 23:39:53 +0000
+++ src/maasserver/api/tests/test_machines.py 2016-06-16 19:59:56 +0000
@@ -208,14 +208,49 @@
208 machine1 = factory.make_Node()208 machine1 = factory.make_Node()
209 machine2 = factory.make_Node(209 machine2 = factory.make_Node(
210 status=NODE_STATUS.ALLOCATED, owner=self.user)210 status=NODE_STATUS.ALLOCATED, owner=self.user)
211 # This device should be ignored.
212 factory.make_Device()
211 response = self.client.get(reverse('machines_handler'))213 response = self.client.get(reverse('machines_handler'))
212 parsed_result = json.loads(214 parsed_result = json.loads(
213 response.content.decode(settings.DEFAULT_CHARSET))215 response.content.decode(settings.DEFAULT_CHARSET))
214216 self.assertEqual(http.client.OK, response.status_code)
215 self.assertEqual(http.client.OK, response.status_code)217 self.assertItemsEqual(
216 self.assertItemsEqual(218 [machine1.system_id, machine2.system_id],
217 [machine1.system_id, machine2.system_id],219 extract_system_ids(parsed_result))
218 extract_system_ids(parsed_result))220
221 def test_GET_lists_machines_with_summary(self):
222 # The api allows for fetching the list of Machines.
223 machine1 = factory.make_Node()
224 machine2 = factory.make_Node(
225 status=NODE_STATUS.ALLOCATED, owner=self.user)
226 # This device should be ignored.
227 factory.make_Device()
228 response = self.client.get(reverse('machines_handler'), {
229 'summary': True
230 })
231 parsed_result = json.loads(
232 response.content.decode(settings.DEFAULT_CHARSET))
233 self.assertEqual(http.client.OK, response.status_code)
234 self.assertItemsEqual(
235 [machine1.system_id, machine2.system_id],
236 extract_system_ids(parsed_result))
237 # Ensure each result is a summary (only contains two items)
238 self.expectThat(len(parsed_result[0]), Equals(2))
239
240 def test_GET_lists_machines_with_count(self):
241 # The api allows for fetching the list of Machines.
242 num_hosts = random.choice([3, 5, 7, 9])
243 for _ in range(num_hosts):
244 factory.make_Node()
245 # This device should be ignored.
246 factory.make_Device()
247 response = self.client.get(reverse('machines_handler'), {
248 'count': True
249 })
250 parsed_result = json.loads(
251 response.content.decode(settings.DEFAULT_CHARSET))
252 self.assertEqual(http.client.OK, response.status_code)
253 self.expectThat(parsed_result, Equals(num_hosts))
219254
220 def test_GET_machines_issues_constant_number_of_queries(self):255 def test_GET_machines_issues_constant_number_of_queries(self):
221 # XXX: GavinPanella 2014-10-03 bug=1377335256 # XXX: GavinPanella 2014-10-03 bug=1377335
222257
=== modified file 'src/maasserver/api/tests/test_nodes.py'
--- src/maasserver/api/tests/test_nodes.py 2016-05-24 22:05:45 +0000
+++ src/maasserver/api/tests/test_nodes.py 2016-06-16 19:59:56 +0000
@@ -25,6 +25,7 @@
25from maasserver.utils import ignore_unused25from maasserver.utils import ignore_unused
26from maasserver.utils.orm import reload_object26from maasserver.utils.orm import reload_object
27from maastesting.djangotestcase import count_queries27from maastesting.djangotestcase import count_queries
28from testtools.matchers import Equals
2829
2930
30class AnonymousIsRegisteredAPITest(APITestCase.ForAnonymous):31class AnonymousIsRegisteredAPITest(APITestCase.ForAnonymous):
@@ -272,8 +273,109 @@
272 extract_system_ids_from_nodes(node_list))273 extract_system_ids_from_nodes(node_list))
273274
274275
275class TestNodesAPI(APITestCase.ForUser):276class TestAdminNodesAPI(APITestCase.ForAdmin):
276 """Tests for /api/2.0/nodes/."""277 """Tests for /api/2.0/nodes/. (administrative users)"""
278
279 def test_GET_by_type_groups_by_type(self):
280 # The api allows for fetching the list of Machines.
281 machine = factory.make_Node()
282 device = factory.make_Device()
283 regionrack = factory.make_RegionRackController()
284 rack = factory.make_RackController()
285 region = factory.make_RegionController()
286 response = self.client.get(reverse('nodes_handler'), {
287 'op': "by_type",
288 'summary': False,
289 })
290 result = json.loads(
291 response.content.decode(settings.DEFAULT_CHARSET))
292 self.assertEqual(http.client.OK, response.status_code)
293 # Since we're testing as a normal user...
294 self.assertItemsEqual(
295 [
296 machine.system_id,
297 ],
298 extract_system_ids(result["machines"]))
299 self.assertItemsEqual(
300 [
301 device.system_id,
302 ],
303 extract_system_ids(result["devices"]))
304 self.assertItemsEqual(
305 [
306 regionrack.system_id,
307 region.system_id
308 ],
309 extract_system_ids(result["region-controllers"]))
310 self.assertItemsEqual(
311 [
312 regionrack.system_id,
313 rack.system_id,
314 ],
315 extract_system_ids(result["rack-controllers"]))
316
317 def test_GET_by_type_with_summary_groups_by_type(self):
318 # The api allows for fetching the list of Machines.
319 machine = factory.make_Node()
320 device = factory.make_Device()
321 regionrack = factory.make_RegionRackController()
322 rack = factory.make_RackController()
323 region = factory.make_RegionController()
324 response = self.client.get(reverse('nodes_handler'), {
325 'op': "by_type",
326 'summary': False,
327 })
328 result = json.loads(
329 response.content.decode(settings.DEFAULT_CHARSET))
330 self.assertEqual(http.client.OK, response.status_code)
331 # Since we're testing as a normal user...
332 self.assertItemsEqual(
333 [
334 machine.system_id,
335 ],
336 extract_system_ids(result["machines"]))
337 self.assertItemsEqual(
338 [
339 device.system_id,
340 ],
341 extract_system_ids(result["devices"]))
342 self.assertItemsEqual(
343 [
344 regionrack.system_id,
345 region.system_id
346 ],
347 extract_system_ids(result["region-controllers"]))
348 self.assertItemsEqual(
349 [
350 regionrack.system_id,
351 rack.system_id,
352 ],
353 extract_system_ids(result["rack-controllers"]))
354
355 def test_GET_by_type_with_count_groups_by_type(self):
356 # The api allows for fetching the list of Machines.
357 factory.make_Node()
358 factory.make_Device()
359 factory.make_RegionRackController()
360 factory.make_RackController()
361 factory.make_RegionController()
362 response = self.client.get(reverse('nodes_handler'), {
363 'op': "by_type",
364 'count': True,
365 })
366 result = json.loads(
367 response.content.decode(settings.DEFAULT_CHARSET))
368 self.assertEqual(http.client.OK, response.status_code)
369 # Since we're testing as an admin, we can see everything.
370 self.assertThat(result["nodes"], Equals(5))
371 self.assertThat(result["machines"], Equals(1))
372 self.assertThat(result["devices"], Equals(1))
373 self.assertThat(result["rack-controllers"], Equals(2))
374 self.assertThat(result["region-controllers"], Equals(2))
375
376
377class TestUserNodesAPI(APITestCase.ForUser):
378 """Tests for /api/2.0/nodes/. (normal users)"""
277379
278 def test_handler_path(self):380 def test_handler_path(self):
279 self.assertEqual(381 self.assertEqual(
@@ -284,14 +386,139 @@
284 node1 = factory.make_Node()386 node1 = factory.make_Node()
285 node2 = factory.make_Node(387 node2 = factory.make_Node(
286 status=NODE_STATUS.ALLOCATED, owner=self.user)388 status=NODE_STATUS.ALLOCATED, owner=self.user)
389 device = factory.make_Device()
287 response = self.client.get(reverse('nodes_handler'))390 response = self.client.get(reverse('nodes_handler'))
288 parsed_result = json.loads(391 parsed_result = json.loads(
289 response.content.decode(settings.DEFAULT_CHARSET))392 response.content.decode(settings.DEFAULT_CHARSET))
290393 self.assertEqual(http.client.OK, response.status_code)
291 self.assertEqual(http.client.OK, response.status_code)394 self.assertItemsEqual(
292 self.assertItemsEqual(395 [node1.system_id, node2.system_id, device.system_id],
293 [node1.system_id, node2.system_id],396 extract_system_ids(parsed_result))
294 extract_system_ids(parsed_result))397
398 def test_GET_lists_nodes_with_summary(self):
399 # The api allows for fetching the list of Machines.
400 machine1 = factory.make_Node()
401 machine2 = factory.make_Node(
402 status=NODE_STATUS.ALLOCATED, owner=self.user)
403 device = factory.make_Device()
404 response = self.client.get(reverse('nodes_handler'), {
405 'summary': True
406 })
407 parsed_result = json.loads(
408 response.content.decode(settings.DEFAULT_CHARSET))
409
410 self.assertEqual(http.client.OK, response.status_code)
411 self.assertItemsEqual(
412 [machine1.system_id, machine2.system_id, device.system_id],
413 extract_system_ids(parsed_result))
414 # Ensure each result is a summary (only contains two items)
415 self.expectThat(len(parsed_result[0]), Equals(2))
416
417 def test_GET_lists_nodes_with_count(self):
418 # The api allows for fetching the list of Machines.
419 num_hosts = random.choice([3, 5, 7, 9])
420 for _ in range(num_hosts):
421 factory.make_Node()
422 factory.make_Device()
423 response = self.client.get(reverse('nodes_handler'), {
424 'count': True
425 })
426 parsed_result = json.loads(
427 response.content.decode(settings.DEFAULT_CHARSET))
428 self.assertEqual(http.client.OK, response.status_code)
429 self.expectThat(parsed_result, Equals(num_hosts * 2))
430
431 def test_GET_by_type_groups_by_type(self):
432 # The api allows for fetching the list of Machines.
433 machine = factory.make_Node()
434 device = factory.make_Device()
435 # These should be ignored since we're a normal user.
436 factory.make_RegionRackController()
437 factory.make_RackController()
438 factory.make_RegionController()
439 response = self.client.get(reverse('nodes_handler'), {
440 'op': "by_type",
441 'summary': False,
442 })
443 result = json.loads(
444 response.content.decode(settings.DEFAULT_CHARSET))
445 self.assertEqual(http.client.OK, response.status_code)
446 self.assertItemsEqual(
447 [
448 machine.system_id,
449 ],
450 extract_system_ids(result["machines"]))
451 self.assertItemsEqual(
452 [
453 device.system_id,
454 ],
455 extract_system_ids(result["devices"]))
456 # Since we're testing as a normal user, we won't be able to see
457 # controllers.
458 self.assertItemsEqual(
459 [
460 ],
461 extract_system_ids(result["region-controllers"]))
462 self.assertItemsEqual(
463 [
464 ],
465 extract_system_ids(result["rack-controllers"]))
466
467 def test_GET_by_type_with_summary_groups_by_type(self):
468 # The api allows for fetching the list of Machines.
469 machine = factory.make_Node()
470 device = factory.make_Device()
471 factory.make_RegionRackController()
472 factory.make_RackController()
473 factory.make_RegionController()
474 response = self.client.get(reverse('nodes_handler'), {
475 'op': "by_type",
476 'summary': False,
477 })
478 result = json.loads(
479 response.content.decode(settings.DEFAULT_CHARSET))
480 self.assertEqual(http.client.OK, response.status_code)
481 # Since we're testing as a normal user...
482 self.assertItemsEqual(
483 [
484 machine.system_id,
485 ],
486 extract_system_ids(result["machines"]))
487 self.assertItemsEqual(
488 [
489 device.system_id,
490 ],
491 extract_system_ids(result["devices"]))
492 self.assertItemsEqual(
493 [
494 ],
495 extract_system_ids(result["region-controllers"]))
496 self.assertItemsEqual(
497 [
498 ],
499 extract_system_ids(result["rack-controllers"]))
500
501 def test_GET_by_type_with_count_groups_by_type(self):
502 # The api allows for fetching the list of Machines.
503 factory.make_Node()
504 factory.make_Device()
505 factory.make_RegionRackController()
506 factory.make_RackController()
507 factory.make_RegionController()
508 response = self.client.get(reverse('nodes_handler'), {
509 'op': "by_type",
510 'count': True,
511 })
512 result = json.loads(
513 response.content.decode(settings.DEFAULT_CHARSET))
514 self.assertEqual(http.client.OK, response.status_code)
515 # Since we're testing as a normal user, they can only see the nodes
516 # and devices.
517 self.assertThat(result["nodes"], Equals(2))
518 self.assertThat(result["machines"], Equals(1))
519 self.assertThat(result["devices"], Equals(1))
520 self.assertThat(result["rack-controllers"], Equals(0))
521 self.assertThat(result["region-controllers"], Equals(0))
295522
296 def create_nodes(self, nodegroup, nb):523 def create_nodes(self, nodegroup, nb):
297 for _ in range(nb):524 for _ in range(nb):
298525
=== modified file 'src/maasserver/testing/testclient.py'
--- src/maasserver/testing/testclient.py 2016-05-24 14:53:56 +0000
+++ src/maasserver/testing/testclient.py 2016-06-16 19:59:56 +0000
@@ -30,7 +30,7 @@
30 """A derivative of Django's test client specially for MAAS.30 """A derivative of Django's test client specially for MAAS.
3131
32 This ensures that requests are performed in a transaction, and that32 This ensures that requests are performed in a transaction, and that
33 post-commit hooks are alway fired or reset.33 post-commit hooks are always fired or reset.
3434
35 It also permits logging-in using just a user object. A password will be35 It also permits logging-in using just a user object. A password will be
36 configured and used to log-in.36 configured and used to log-in.