Merge lp:~mpontillo/maas/node-statistics-bug-1584926--part1 into lp:~maas-committers/maas/trunk
- node-statistics-bug-1584926--part1
- Merge into trunk
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 |
Related bugs: |
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|
now supports the 'count' and 'summary' parameters.
* Add new API to fetch machines using a "group by":
- nodes by-type [summary=<bool> | count=<bool>]
Description of the change
Mike Pontillo (mpontillo) wrote : | # |
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_
summary = get_optional_param(
count = get_optional_param(
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_
if show == SHOW_COUNT:
return query.count()
elif show == SHOW_SUMMARY:
return list(map(
else:
return query.prefetch(...)
There's no need for a framework to support this.
The code added to OperationsHandl
going on. It's convoluted, and OperationsHandl
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_
if count:
return query.count()
elif summary:
query = self._prefetch_
return list(map(
else:
return self._prefetch_
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 OperationsHandl
a get_show_option function, updates the handlers, and fixes a few broken
tests here:
lp~allenap/
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.
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.
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.
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 OperationsHandl
Gavin Panella (allenap) wrote : | # |
> 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|
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 OperationsHandl
> 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.
return {
node.
for node in Node.objects.
}
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...
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:/
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
1 | === modified file 'src/maasserver/api/nodes.py' | |||
2 | --- src/maasserver/api/nodes.py 2016-05-24 13:50:54 +0000 | |||
3 | +++ src/maasserver/api/nodes.py 2016-06-16 19:59:56 +0000 | |||
4 | @@ -15,6 +15,7 @@ | |||
5 | 15 | import bson | 15 | import bson |
6 | 16 | from django.http import HttpResponse | 16 | from django.http import HttpResponse |
7 | 17 | from django.shortcuts import get_object_or_404 | 17 | from django.shortcuts import get_object_or_404 |
8 | 18 | from formencode.validators import StringBool | ||
9 | 18 | from maasserver.api.support import ( | 19 | from maasserver.api.support import ( |
10 | 19 | admin_method, | 20 | admin_method, |
11 | 20 | AnonymousOperationsHandler, | 21 | AnonymousOperationsHandler, |
12 | @@ -47,7 +48,13 @@ | |||
13 | 47 | Node, | 48 | Node, |
14 | 48 | OwnerData, | 49 | OwnerData, |
15 | 49 | ) | 50 | ) |
17 | 50 | from maasserver.models.node import typecast_to_node_type | 51 | from maasserver.models.node import ( |
18 | 52 | Device, | ||
19 | 53 | Machine, | ||
20 | 54 | RackController, | ||
21 | 55 | RegionController, | ||
22 | 56 | typecast_to_node_type, | ||
23 | 57 | ) | ||
24 | 51 | from maasserver.models.nodeprobeddetails import get_single_probed_details | 58 | from maasserver.models.nodeprobeddetails import get_single_probed_details |
25 | 52 | from piston3.utils import rc | 59 | from piston3.utils import rc |
26 | 53 | from provisioningserver.power.schema import UNKNOWN_POWER_TYPE | 60 | from provisioningserver.power.schema import UNKNOWN_POWER_TYPE |
27 | @@ -281,6 +288,21 @@ | |||
28 | 281 | anonymous = AnonNodesHandler | 288 | anonymous = AnonNodesHandler |
29 | 282 | base_model = Node | 289 | base_model = Node |
30 | 283 | 290 | ||
31 | 291 | def prefetch(self, nodes): | ||
32 | 292 | nodes = nodes.prefetch_related('interface_set__node') | ||
33 | 293 | nodes = nodes.prefetch_related('interface_set__ip_addresses') | ||
34 | 294 | nodes = nodes.prefetch_related('tags') | ||
35 | 295 | nodes = nodes.prefetch_related('zone') | ||
36 | 296 | return nodes | ||
37 | 297 | |||
38 | 298 | def _query(self, request): | ||
39 | 299 | """Helper method to filter the list of machines. | ||
40 | 300 | |||
41 | 301 | Based on the request, the list will be filtered based on the currently | ||
42 | 302 | logged in user, and whatever search criteria are supplied. | ||
43 | 303 | """ | ||
44 | 304 | return filtered_nodes_list_from_request(request, model=self.base_model) | ||
45 | 305 | |||
46 | 284 | def read(self, request): | 306 | def read(self, request): |
47 | 285 | """List Nodes visible to the user, optionally filtered by criteria. | 307 | """List Nodes visible to the user, optionally filtered by criteria. |
48 | 286 | 308 | ||
49 | @@ -311,27 +333,42 @@ | |||
50 | 311 | :param agent_name: An optional agent name. Only nodes relating to the | 333 | :param agent_name: An optional agent name. Only nodes relating to the |
51 | 312 | nodes with matching agent names will be returned. | 334 | nodes with matching agent names will be returned. |
52 | 313 | :type agent_name: unicode | 335 | :type agent_name: unicode |
53 | 336 | |||
54 | 337 | :param summary: If True, prints a summary of each node instead of the | ||
55 | 338 | complete object. | ||
56 | 339 | :type summary: bool | ||
57 | 340 | |||
58 | 341 | :param count: If True, prints a count of nodes instead of any | ||
59 | 342 | node details. | ||
60 | 343 | :type count: bool | ||
61 | 314 | """ | 344 | """ |
62 | 345 | summary = get_optional_param( | ||
63 | 346 | request.GET, 'summary', default=False, validator=StringBool) | ||
64 | 347 | count = get_optional_param( | ||
65 | 348 | request.GET, 'count', default=False, validator=StringBool) | ||
66 | 349 | return self.prepare_results( | ||
67 | 350 | self._read( | ||
68 | 351 | request, summary=summary, count=count), | ||
69 | 352 | count=count, summary=summary) | ||
70 | 315 | 353 | ||
72 | 316 | if self.base_model == Node: | 354 | def _read(self, request, summary=False, count=False): |
73 | 355 | """List nodes visible to the user.""" | ||
74 | 356 | if self.base_model == Node and not summary and not count: | ||
75 | 317 | # Avoid circular dependencies | 357 | # Avoid circular dependencies |
76 | 318 | from maasserver.api.devices import DevicesHandler | 358 | from maasserver.api.devices import DevicesHandler |
77 | 319 | from maasserver.api.machines import MachinesHandler | 359 | from maasserver.api.machines import MachinesHandler |
78 | 320 | from maasserver.api.rackcontrollers import RackControllersHandler | 360 | from maasserver.api.rackcontrollers import RackControllersHandler |
79 | 321 | nodes = list(chain( | 361 | nodes = list(chain( |
83 | 322 | DevicesHandler().read(request).order_by("id"), | 362 | DevicesHandler().read(request), |
84 | 323 | MachinesHandler().read(request).order_by("id"), | 363 | MachinesHandler().read(request), |
85 | 324 | RackControllersHandler().read(request).order_by("id"), | 364 | RackControllersHandler().read(request), |
86 | 325 | )) | 365 | )) |
87 | 326 | return nodes | 366 | return nodes |
88 | 327 | else: | 367 | else: |
89 | 328 | nodes = filtered_nodes_list_from_request(request, self.base_model) | 368 | nodes = filtered_nodes_list_from_request(request, self.base_model) |
90 | 329 | # Prefetch related objects that are needed for rendering the | 369 | # Prefetch related objects that are needed for rendering the |
91 | 330 | # result. | 370 | # result. |
96 | 331 | nodes = nodes.prefetch_related('interface_set__node') | 371 | nodes = self.prefetch(nodes) |
93 | 332 | nodes = nodes.prefetch_related('interface_set__ip_addresses') | ||
94 | 333 | nodes = nodes.prefetch_related('tags') | ||
95 | 334 | nodes = nodes.prefetch_related('zone') | ||
97 | 335 | return nodes.order_by('id') | 372 | return nodes.order_by('id') |
98 | 336 | 373 | ||
99 | 337 | @admin_method | 374 | @admin_method |
100 | @@ -356,6 +393,46 @@ | |||
101 | 356 | raise MAASAPIValidationError(form.errors) | 393 | raise MAASAPIValidationError(form.errors) |
102 | 357 | form.save() | 394 | form.save() |
103 | 358 | 395 | ||
104 | 396 | def summary(self, node): | ||
105 | 397 | """Returns a summary a node.""" | ||
106 | 398 | return { | ||
107 | 399 | 'system_id': node.system_id, | ||
108 | 400 | 'hostname': node.hostname | ||
109 | 401 | } | ||
110 | 402 | |||
111 | 403 | @operation(idempotent=True) | ||
112 | 404 | def by_type(self, request): | ||
113 | 405 | """Prints a listing of nodes grouped by their type. | ||
114 | 406 | |||
115 | 407 | Optional arguments: | ||
116 | 408 | summary: If True, prints a summary of each node instead of the complete | ||
117 | 409 | object. | ||
118 | 410 | count: If True, prints a count of each node type instead of any | ||
119 | 411 | node details. | ||
120 | 412 | |||
121 | 413 | Also accepts any filter parameter the 'read' operation accepts. | ||
122 | 414 | """ | ||
123 | 415 | summary = get_optional_param( | ||
124 | 416 | request.GET, 'summary', default=False, validator=StringBool) | ||
125 | 417 | count = get_optional_param( | ||
126 | 418 | request.GET, 'count', default=False, validator=StringBool) | ||
127 | 419 | label_to_model = { | ||
128 | 420 | "machines": Machine, | ||
129 | 421 | "devices": Device, | ||
130 | 422 | "rack-controllers": RackController, | ||
131 | 423 | "region-controllers": RegionController | ||
132 | 424 | } | ||
133 | 425 | if count: | ||
134 | 426 | # We don't have a definition for the displayed fields of a plain | ||
135 | 427 | # node, so only include that if it's a summary or a count. | ||
136 | 428 | label_to_model["nodes"] = Node | ||
137 | 429 | return { | ||
138 | 430 | label: self.prepare_results( | ||
139 | 431 | filtered_nodes_list_from_request(request, model=model), | ||
140 | 432 | count=count, summary=summary) | ||
141 | 433 | for label, model in label_to_model.items() | ||
142 | 434 | } | ||
143 | 435 | |||
144 | 359 | @classmethod | 436 | @classmethod |
145 | 360 | def resource_uri(cls, *args, **kwargs): | 437 | def resource_uri(cls, *args, **kwargs): |
146 | 361 | return ('nodes_handler', []) | 438 | return ('nodes_handler', []) |
147 | 362 | 439 | ||
148 | === modified file 'src/maasserver/api/support.py' | |||
149 | --- src/maasserver/api/support.py 2016-05-20 14:31:34 +0000 | |||
150 | +++ src/maasserver/api/support.py 2016-06-16 19:59:56 +0000 | |||
151 | @@ -13,6 +13,7 @@ | |||
152 | 13 | from functools import wraps | 13 | from functools import wraps |
153 | 14 | 14 | ||
154 | 15 | from django.core.exceptions import PermissionDenied | 15 | from django.core.exceptions import PermissionDenied |
155 | 16 | from django.db.models.query import QuerySet | ||
156 | 16 | from django.http import Http404 | 17 | from django.http import Http404 |
157 | 17 | from maasserver.api.doc import get_api_description_hash | 18 | from maasserver.api.doc import get_api_description_hash |
158 | 18 | from maasserver.exceptions import MAASAPIBadRequest | 19 | from maasserver.exceptions import MAASAPIBadRequest |
159 | @@ -260,6 +261,88 @@ | |||
160 | 260 | else: | 261 | else: |
161 | 261 | return function(self, request, *args, **kwargs) | 262 | return function(self, request, *args, **kwargs) |
162 | 262 | 263 | ||
163 | 264 | def prefetch(self, query): | ||
164 | 265 | """Pre-fetches the specified query. | ||
165 | 266 | |||
166 | 267 | This method should pre-fetch anything anticipated to be recursively | ||
167 | 268 | queried for in the non-summary version of the output. | ||
168 | 269 | |||
169 | 270 | By default, does no prefetching. | ||
170 | 271 | |||
171 | 272 | Override this in a subclass to do prefetching in case result_summary() | ||
172 | 273 | in case non-summary output is requested. | ||
173 | 274 | """ | ||
174 | 275 | return query | ||
175 | 276 | |||
176 | 277 | def summary(self, obj): | ||
177 | 278 | """Return a summarized version of the specified model object. | ||
178 | 279 | |||
179 | 280 | This method is intended to return the object in a condensed format, | ||
180 | 281 | with a minimal amount of useful identifying information. | ||
181 | 282 | |||
182 | 283 | Override this in a subclass to allow result_summary() to work | ||
183 | 284 | correctly. | ||
184 | 285 | """ | ||
185 | 286 | raise NotImplementedError | ||
186 | 287 | |||
187 | 288 | def result_summary(self, result, summary=False, order_by='id'): | ||
188 | 289 | """Utility method to provide a shorter, summary output for the objects | ||
189 | 290 | in the specified query, if desired. | ||
190 | 291 | |||
191 | 292 | :param result: must be either a QuerySet, or an integer. | ||
192 | 293 | If an integer is passed in, it is simply returned. (This means | ||
193 | 294 | the user requested a count instead of the query results.) | ||
194 | 295 | :param summary: if True, returns a smaller object containing only | ||
195 | 296 | essential information, rather than the complete object. | ||
196 | 297 | :param order_by: optional field to order by (defaults to 'id'). | ||
197 | 298 | """ | ||
198 | 299 | if isinstance(result, int): | ||
199 | 300 | # The user chose to count the objects in the query, instead of | ||
200 | 301 | # returning the results of the query. So just give them the result. | ||
201 | 302 | return result | ||
202 | 303 | elif isinstance(result, QuerySet): | ||
203 | 304 | # If this is a QuerySet, we need to ensure we both prefetch related | ||
204 | 305 | # objects, and return it in a consistent order. | ||
205 | 306 | result = self.prefetch(result).order_by(order_by) | ||
206 | 307 | if not summary: | ||
207 | 308 | # Pass this through to Piston if we're not in summary mode. | ||
208 | 309 | return result | ||
209 | 310 | else: | ||
210 | 311 | # It would be nice if we could return a generator expression | ||
211 | 312 | # here, but that doesn't seem to be supported by Piston. | ||
212 | 313 | return [ | ||
213 | 314 | self.summary(obj) for obj in result | ||
214 | 315 | ] | ||
215 | 316 | else: | ||
216 | 317 | # We're not sure what this is. So just let Piston do whatever | ||
217 | 318 | # it was going to do with it. | ||
218 | 319 | return result | ||
219 | 320 | |||
220 | 321 | def query_or_count(self, filter, count=False): | ||
221 | 322 | """Returns either the results of the filter, or a count. | ||
222 | 323 | |||
223 | 324 | :param count: if True, calls count() on the specified filter and | ||
224 | 325 | returns that. Otherwise, returns the filter unchanged. | ||
225 | 326 | """ | ||
226 | 327 | if count: | ||
227 | 328 | return filter.count() | ||
228 | 329 | else: | ||
229 | 330 | return filter | ||
230 | 331 | |||
231 | 332 | def prepare_results(self, query, count=False, summary=False): | ||
232 | 333 | """Prepares the result set for the specified query. | ||
233 | 334 | |||
234 | 335 | Calls the 'summary' and 'prefetch' methods on subclasses, as | ||
235 | 336 | appropriate. | ||
236 | 337 | |||
237 | 338 | :param count: If True, return a count of the objects that would be | ||
238 | 339 | returned, instead of the objects themselves. | ||
239 | 340 | :param summary: If True, return summarized objects, instead of the | ||
240 | 341 | objects themselves. | ||
241 | 342 | """ | ||
242 | 343 | return self.result_summary( | ||
243 | 344 | self.query_or_count(query, count=count), summary=summary) | ||
244 | 345 | |||
245 | 263 | @classmethod | 346 | @classmethod |
246 | 264 | def decorate(cls, func): | 347 | def decorate(cls, func): |
247 | 265 | """Decorate all exported operations with the given function. | 348 | """Decorate all exported operations with the given function. |
248 | 266 | 349 | ||
249 | === modified file 'src/maasserver/api/tests/test_machines.py' | |||
250 | --- src/maasserver/api/tests/test_machines.py 2016-06-09 23:39:53 +0000 | |||
251 | +++ src/maasserver/api/tests/test_machines.py 2016-06-16 19:59:56 +0000 | |||
252 | @@ -208,14 +208,49 @@ | |||
253 | 208 | machine1 = factory.make_Node() | 208 | machine1 = factory.make_Node() |
254 | 209 | machine2 = factory.make_Node( | 209 | machine2 = factory.make_Node( |
255 | 210 | status=NODE_STATUS.ALLOCATED, owner=self.user) | 210 | status=NODE_STATUS.ALLOCATED, owner=self.user) |
256 | 211 | # This device should be ignored. | ||
257 | 212 | factory.make_Device() | ||
258 | 211 | response = self.client.get(reverse('machines_handler')) | 213 | response = self.client.get(reverse('machines_handler')) |
259 | 212 | parsed_result = json.loads( | 214 | parsed_result = json.loads( |
260 | 213 | response.content.decode(settings.DEFAULT_CHARSET)) | 215 | response.content.decode(settings.DEFAULT_CHARSET)) |
266 | 214 | 216 | self.assertEqual(http.client.OK, response.status_code) | |
267 | 215 | self.assertEqual(http.client.OK, response.status_code) | 217 | self.assertItemsEqual( |
268 | 216 | self.assertItemsEqual( | 218 | [machine1.system_id, machine2.system_id], |
269 | 217 | [machine1.system_id, machine2.system_id], | 219 | extract_system_ids(parsed_result)) |
270 | 218 | extract_system_ids(parsed_result)) | 220 | |
271 | 221 | def test_GET_lists_machines_with_summary(self): | ||
272 | 222 | # The api allows for fetching the list of Machines. | ||
273 | 223 | machine1 = factory.make_Node() | ||
274 | 224 | machine2 = factory.make_Node( | ||
275 | 225 | status=NODE_STATUS.ALLOCATED, owner=self.user) | ||
276 | 226 | # This device should be ignored. | ||
277 | 227 | factory.make_Device() | ||
278 | 228 | response = self.client.get(reverse('machines_handler'), { | ||
279 | 229 | 'summary': True | ||
280 | 230 | }) | ||
281 | 231 | parsed_result = json.loads( | ||
282 | 232 | response.content.decode(settings.DEFAULT_CHARSET)) | ||
283 | 233 | self.assertEqual(http.client.OK, response.status_code) | ||
284 | 234 | self.assertItemsEqual( | ||
285 | 235 | [machine1.system_id, machine2.system_id], | ||
286 | 236 | extract_system_ids(parsed_result)) | ||
287 | 237 | # Ensure each result is a summary (only contains two items) | ||
288 | 238 | self.expectThat(len(parsed_result[0]), Equals(2)) | ||
289 | 239 | |||
290 | 240 | def test_GET_lists_machines_with_count(self): | ||
291 | 241 | # The api allows for fetching the list of Machines. | ||
292 | 242 | num_hosts = random.choice([3, 5, 7, 9]) | ||
293 | 243 | for _ in range(num_hosts): | ||
294 | 244 | factory.make_Node() | ||
295 | 245 | # This device should be ignored. | ||
296 | 246 | factory.make_Device() | ||
297 | 247 | response = self.client.get(reverse('machines_handler'), { | ||
298 | 248 | 'count': True | ||
299 | 249 | }) | ||
300 | 250 | parsed_result = json.loads( | ||
301 | 251 | response.content.decode(settings.DEFAULT_CHARSET)) | ||
302 | 252 | self.assertEqual(http.client.OK, response.status_code) | ||
303 | 253 | self.expectThat(parsed_result, Equals(num_hosts)) | ||
304 | 219 | 254 | ||
305 | 220 | def test_GET_machines_issues_constant_number_of_queries(self): | 255 | def test_GET_machines_issues_constant_number_of_queries(self): |
306 | 221 | # XXX: GavinPanella 2014-10-03 bug=1377335 | 256 | # XXX: GavinPanella 2014-10-03 bug=1377335 |
307 | 222 | 257 | ||
308 | === modified file 'src/maasserver/api/tests/test_nodes.py' | |||
309 | --- src/maasserver/api/tests/test_nodes.py 2016-05-24 22:05:45 +0000 | |||
310 | +++ src/maasserver/api/tests/test_nodes.py 2016-06-16 19:59:56 +0000 | |||
311 | @@ -25,6 +25,7 @@ | |||
312 | 25 | from maasserver.utils import ignore_unused | 25 | from maasserver.utils import ignore_unused |
313 | 26 | from maasserver.utils.orm import reload_object | 26 | from maasserver.utils.orm import reload_object |
314 | 27 | from maastesting.djangotestcase import count_queries | 27 | from maastesting.djangotestcase import count_queries |
315 | 28 | from testtools.matchers import Equals | ||
316 | 28 | 29 | ||
317 | 29 | 30 | ||
318 | 30 | class AnonymousIsRegisteredAPITest(APITestCase.ForAnonymous): | 31 | class AnonymousIsRegisteredAPITest(APITestCase.ForAnonymous): |
319 | @@ -272,8 +273,109 @@ | |||
320 | 272 | extract_system_ids_from_nodes(node_list)) | 273 | extract_system_ids_from_nodes(node_list)) |
321 | 273 | 274 | ||
322 | 274 | 275 | ||
325 | 275 | class TestNodesAPI(APITestCase.ForUser): | 276 | class TestAdminNodesAPI(APITestCase.ForAdmin): |
326 | 276 | """Tests for /api/2.0/nodes/.""" | 277 | """Tests for /api/2.0/nodes/. (administrative users)""" |
327 | 278 | |||
328 | 279 | def test_GET_by_type_groups_by_type(self): | ||
329 | 280 | # The api allows for fetching the list of Machines. | ||
330 | 281 | machine = factory.make_Node() | ||
331 | 282 | device = factory.make_Device() | ||
332 | 283 | regionrack = factory.make_RegionRackController() | ||
333 | 284 | rack = factory.make_RackController() | ||
334 | 285 | region = factory.make_RegionController() | ||
335 | 286 | response = self.client.get(reverse('nodes_handler'), { | ||
336 | 287 | 'op': "by_type", | ||
337 | 288 | 'summary': False, | ||
338 | 289 | }) | ||
339 | 290 | result = json.loads( | ||
340 | 291 | response.content.decode(settings.DEFAULT_CHARSET)) | ||
341 | 292 | self.assertEqual(http.client.OK, response.status_code) | ||
342 | 293 | # Since we're testing as a normal user... | ||
343 | 294 | self.assertItemsEqual( | ||
344 | 295 | [ | ||
345 | 296 | machine.system_id, | ||
346 | 297 | ], | ||
347 | 298 | extract_system_ids(result["machines"])) | ||
348 | 299 | self.assertItemsEqual( | ||
349 | 300 | [ | ||
350 | 301 | device.system_id, | ||
351 | 302 | ], | ||
352 | 303 | extract_system_ids(result["devices"])) | ||
353 | 304 | self.assertItemsEqual( | ||
354 | 305 | [ | ||
355 | 306 | regionrack.system_id, | ||
356 | 307 | region.system_id | ||
357 | 308 | ], | ||
358 | 309 | extract_system_ids(result["region-controllers"])) | ||
359 | 310 | self.assertItemsEqual( | ||
360 | 311 | [ | ||
361 | 312 | regionrack.system_id, | ||
362 | 313 | rack.system_id, | ||
363 | 314 | ], | ||
364 | 315 | extract_system_ids(result["rack-controllers"])) | ||
365 | 316 | |||
366 | 317 | def test_GET_by_type_with_summary_groups_by_type(self): | ||
367 | 318 | # The api allows for fetching the list of Machines. | ||
368 | 319 | machine = factory.make_Node() | ||
369 | 320 | device = factory.make_Device() | ||
370 | 321 | regionrack = factory.make_RegionRackController() | ||
371 | 322 | rack = factory.make_RackController() | ||
372 | 323 | region = factory.make_RegionController() | ||
373 | 324 | response = self.client.get(reverse('nodes_handler'), { | ||
374 | 325 | 'op': "by_type", | ||
375 | 326 | 'summary': False, | ||
376 | 327 | }) | ||
377 | 328 | result = json.loads( | ||
378 | 329 | response.content.decode(settings.DEFAULT_CHARSET)) | ||
379 | 330 | self.assertEqual(http.client.OK, response.status_code) | ||
380 | 331 | # Since we're testing as a normal user... | ||
381 | 332 | self.assertItemsEqual( | ||
382 | 333 | [ | ||
383 | 334 | machine.system_id, | ||
384 | 335 | ], | ||
385 | 336 | extract_system_ids(result["machines"])) | ||
386 | 337 | self.assertItemsEqual( | ||
387 | 338 | [ | ||
388 | 339 | device.system_id, | ||
389 | 340 | ], | ||
390 | 341 | extract_system_ids(result["devices"])) | ||
391 | 342 | self.assertItemsEqual( | ||
392 | 343 | [ | ||
393 | 344 | regionrack.system_id, | ||
394 | 345 | region.system_id | ||
395 | 346 | ], | ||
396 | 347 | extract_system_ids(result["region-controllers"])) | ||
397 | 348 | self.assertItemsEqual( | ||
398 | 349 | [ | ||
399 | 350 | regionrack.system_id, | ||
400 | 351 | rack.system_id, | ||
401 | 352 | ], | ||
402 | 353 | extract_system_ids(result["rack-controllers"])) | ||
403 | 354 | |||
404 | 355 | def test_GET_by_type_with_count_groups_by_type(self): | ||
405 | 356 | # The api allows for fetching the list of Machines. | ||
406 | 357 | factory.make_Node() | ||
407 | 358 | factory.make_Device() | ||
408 | 359 | factory.make_RegionRackController() | ||
409 | 360 | factory.make_RackController() | ||
410 | 361 | factory.make_RegionController() | ||
411 | 362 | response = self.client.get(reverse('nodes_handler'), { | ||
412 | 363 | 'op': "by_type", | ||
413 | 364 | 'count': True, | ||
414 | 365 | }) | ||
415 | 366 | result = json.loads( | ||
416 | 367 | response.content.decode(settings.DEFAULT_CHARSET)) | ||
417 | 368 | self.assertEqual(http.client.OK, response.status_code) | ||
418 | 369 | # Since we're testing as an admin, we can see everything. | ||
419 | 370 | self.assertThat(result["nodes"], Equals(5)) | ||
420 | 371 | self.assertThat(result["machines"], Equals(1)) | ||
421 | 372 | self.assertThat(result["devices"], Equals(1)) | ||
422 | 373 | self.assertThat(result["rack-controllers"], Equals(2)) | ||
423 | 374 | self.assertThat(result["region-controllers"], Equals(2)) | ||
424 | 375 | |||
425 | 376 | |||
426 | 377 | class TestUserNodesAPI(APITestCase.ForUser): | ||
427 | 378 | """Tests for /api/2.0/nodes/. (normal users)""" | ||
428 | 277 | 379 | ||
429 | 278 | def test_handler_path(self): | 380 | def test_handler_path(self): |
430 | 279 | self.assertEqual( | 381 | self.assertEqual( |
431 | @@ -284,14 +386,139 @@ | |||
432 | 284 | node1 = factory.make_Node() | 386 | node1 = factory.make_Node() |
433 | 285 | node2 = factory.make_Node( | 387 | node2 = factory.make_Node( |
434 | 286 | status=NODE_STATUS.ALLOCATED, owner=self.user) | 388 | status=NODE_STATUS.ALLOCATED, owner=self.user) |
435 | 389 | device = factory.make_Device() | ||
436 | 287 | response = self.client.get(reverse('nodes_handler')) | 390 | response = self.client.get(reverse('nodes_handler')) |
437 | 288 | parsed_result = json.loads( | 391 | parsed_result = json.loads( |
438 | 289 | response.content.decode(settings.DEFAULT_CHARSET)) | 392 | response.content.decode(settings.DEFAULT_CHARSET)) |
444 | 290 | 393 | self.assertEqual(http.client.OK, response.status_code) | |
445 | 291 | self.assertEqual(http.client.OK, response.status_code) | 394 | self.assertItemsEqual( |
446 | 292 | self.assertItemsEqual( | 395 | [node1.system_id, node2.system_id, device.system_id], |
447 | 293 | [node1.system_id, node2.system_id], | 396 | extract_system_ids(parsed_result)) |
448 | 294 | extract_system_ids(parsed_result)) | 397 | |
449 | 398 | def test_GET_lists_nodes_with_summary(self): | ||
450 | 399 | # The api allows for fetching the list of Machines. | ||
451 | 400 | machine1 = factory.make_Node() | ||
452 | 401 | machine2 = factory.make_Node( | ||
453 | 402 | status=NODE_STATUS.ALLOCATED, owner=self.user) | ||
454 | 403 | device = factory.make_Device() | ||
455 | 404 | response = self.client.get(reverse('nodes_handler'), { | ||
456 | 405 | 'summary': True | ||
457 | 406 | }) | ||
458 | 407 | parsed_result = json.loads( | ||
459 | 408 | response.content.decode(settings.DEFAULT_CHARSET)) | ||
460 | 409 | |||
461 | 410 | self.assertEqual(http.client.OK, response.status_code) | ||
462 | 411 | self.assertItemsEqual( | ||
463 | 412 | [machine1.system_id, machine2.system_id, device.system_id], | ||
464 | 413 | extract_system_ids(parsed_result)) | ||
465 | 414 | # Ensure each result is a summary (only contains two items) | ||
466 | 415 | self.expectThat(len(parsed_result[0]), Equals(2)) | ||
467 | 416 | |||
468 | 417 | def test_GET_lists_nodes_with_count(self): | ||
469 | 418 | # The api allows for fetching the list of Machines. | ||
470 | 419 | num_hosts = random.choice([3, 5, 7, 9]) | ||
471 | 420 | for _ in range(num_hosts): | ||
472 | 421 | factory.make_Node() | ||
473 | 422 | factory.make_Device() | ||
474 | 423 | response = self.client.get(reverse('nodes_handler'), { | ||
475 | 424 | 'count': True | ||
476 | 425 | }) | ||
477 | 426 | parsed_result = json.loads( | ||
478 | 427 | response.content.decode(settings.DEFAULT_CHARSET)) | ||
479 | 428 | self.assertEqual(http.client.OK, response.status_code) | ||
480 | 429 | self.expectThat(parsed_result, Equals(num_hosts * 2)) | ||
481 | 430 | |||
482 | 431 | def test_GET_by_type_groups_by_type(self): | ||
483 | 432 | # The api allows for fetching the list of Machines. | ||
484 | 433 | machine = factory.make_Node() | ||
485 | 434 | device = factory.make_Device() | ||
486 | 435 | # These should be ignored since we're a normal user. | ||
487 | 436 | factory.make_RegionRackController() | ||
488 | 437 | factory.make_RackController() | ||
489 | 438 | factory.make_RegionController() | ||
490 | 439 | response = self.client.get(reverse('nodes_handler'), { | ||
491 | 440 | 'op': "by_type", | ||
492 | 441 | 'summary': False, | ||
493 | 442 | }) | ||
494 | 443 | result = json.loads( | ||
495 | 444 | response.content.decode(settings.DEFAULT_CHARSET)) | ||
496 | 445 | self.assertEqual(http.client.OK, response.status_code) | ||
497 | 446 | self.assertItemsEqual( | ||
498 | 447 | [ | ||
499 | 448 | machine.system_id, | ||
500 | 449 | ], | ||
501 | 450 | extract_system_ids(result["machines"])) | ||
502 | 451 | self.assertItemsEqual( | ||
503 | 452 | [ | ||
504 | 453 | device.system_id, | ||
505 | 454 | ], | ||
506 | 455 | extract_system_ids(result["devices"])) | ||
507 | 456 | # Since we're testing as a normal user, we won't be able to see | ||
508 | 457 | # controllers. | ||
509 | 458 | self.assertItemsEqual( | ||
510 | 459 | [ | ||
511 | 460 | ], | ||
512 | 461 | extract_system_ids(result["region-controllers"])) | ||
513 | 462 | self.assertItemsEqual( | ||
514 | 463 | [ | ||
515 | 464 | ], | ||
516 | 465 | extract_system_ids(result["rack-controllers"])) | ||
517 | 466 | |||
518 | 467 | def test_GET_by_type_with_summary_groups_by_type(self): | ||
519 | 468 | # The api allows for fetching the list of Machines. | ||
520 | 469 | machine = factory.make_Node() | ||
521 | 470 | device = factory.make_Device() | ||
522 | 471 | factory.make_RegionRackController() | ||
523 | 472 | factory.make_RackController() | ||
524 | 473 | factory.make_RegionController() | ||
525 | 474 | response = self.client.get(reverse('nodes_handler'), { | ||
526 | 475 | 'op': "by_type", | ||
527 | 476 | 'summary': False, | ||
528 | 477 | }) | ||
529 | 478 | result = json.loads( | ||
530 | 479 | response.content.decode(settings.DEFAULT_CHARSET)) | ||
531 | 480 | self.assertEqual(http.client.OK, response.status_code) | ||
532 | 481 | # Since we're testing as a normal user... | ||
533 | 482 | self.assertItemsEqual( | ||
534 | 483 | [ | ||
535 | 484 | machine.system_id, | ||
536 | 485 | ], | ||
537 | 486 | extract_system_ids(result["machines"])) | ||
538 | 487 | self.assertItemsEqual( | ||
539 | 488 | [ | ||
540 | 489 | device.system_id, | ||
541 | 490 | ], | ||
542 | 491 | extract_system_ids(result["devices"])) | ||
543 | 492 | self.assertItemsEqual( | ||
544 | 493 | [ | ||
545 | 494 | ], | ||
546 | 495 | extract_system_ids(result["region-controllers"])) | ||
547 | 496 | self.assertItemsEqual( | ||
548 | 497 | [ | ||
549 | 498 | ], | ||
550 | 499 | extract_system_ids(result["rack-controllers"])) | ||
551 | 500 | |||
552 | 501 | def test_GET_by_type_with_count_groups_by_type(self): | ||
553 | 502 | # The api allows for fetching the list of Machines. | ||
554 | 503 | factory.make_Node() | ||
555 | 504 | factory.make_Device() | ||
556 | 505 | factory.make_RegionRackController() | ||
557 | 506 | factory.make_RackController() | ||
558 | 507 | factory.make_RegionController() | ||
559 | 508 | response = self.client.get(reverse('nodes_handler'), { | ||
560 | 509 | 'op': "by_type", | ||
561 | 510 | 'count': True, | ||
562 | 511 | }) | ||
563 | 512 | result = json.loads( | ||
564 | 513 | response.content.decode(settings.DEFAULT_CHARSET)) | ||
565 | 514 | self.assertEqual(http.client.OK, response.status_code) | ||
566 | 515 | # Since we're testing as a normal user, they can only see the nodes | ||
567 | 516 | # and devices. | ||
568 | 517 | self.assertThat(result["nodes"], Equals(2)) | ||
569 | 518 | self.assertThat(result["machines"], Equals(1)) | ||
570 | 519 | self.assertThat(result["devices"], Equals(1)) | ||
571 | 520 | self.assertThat(result["rack-controllers"], Equals(0)) | ||
572 | 521 | self.assertThat(result["region-controllers"], Equals(0)) | ||
573 | 295 | 522 | ||
574 | 296 | def create_nodes(self, nodegroup, nb): | 523 | def create_nodes(self, nodegroup, nb): |
575 | 297 | for _ in range(nb): | 524 | for _ in range(nb): |
576 | 298 | 525 | ||
577 | === modified file 'src/maasserver/testing/testclient.py' | |||
578 | --- src/maasserver/testing/testclient.py 2016-05-24 14:53:56 +0000 | |||
579 | +++ src/maasserver/testing/testclient.py 2016-06-16 19:59:56 +0000 | |||
580 | @@ -30,7 +30,7 @@ | |||
581 | 30 | """A derivative of Django's test client specially for MAAS. | 30 | """A derivative of Django's test client specially for MAAS. |
582 | 31 | 31 | ||
583 | 32 | This ensures that requests are performed in a transaction, and that | 32 | This ensures that requests are performed in a transaction, and that |
585 | 33 | post-commit hooks are alway fired or reset. | 33 | post-commit hooks are always fired or reset. |
586 | 34 | 34 | ||
587 | 35 | It also permits logging-in using just a user object. A password will be | 35 | It also permits logging-in using just a user object. A password will be |
588 | 36 | configured and used to log-in. | 36 | configured and used to log-in. |
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.