Merge lp:~jtv/maas/show-commissioning-results into lp:~maas-committers/maas/trunk
- show-commissioning-results
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Jeroen T. Vermeulen |
Approved revision: | no longer in the source branch. |
Merged at revision: | 1955 |
Proposed branch: | lp:~jtv/maas/show-commissioning-results |
Merge into: | lp:~maas-committers/maas/trunk |
Diff against target: |
732 lines (+576/-4) 11 files modified
src/maasserver/templates/maasserver/node_view.html (+11/-0) src/maasserver/templates/metadataserver/nodecommissionresult.html (+38/-0) src/maasserver/templates/metadataserver/nodecommissionresult_list.html (+60/-0) src/maasserver/urls.py (+13/-1) src/maasserver/views/nodecommissionresult.py (+44/-0) src/maasserver/views/nodes.py (+14/-1) src/maasserver/views/tests/test_nodecommissionresults.py (+248/-0) src/maasserver/views/tests/test_nodes.py (+105/-0) src/maastesting/factory.py (+6/-0) src/metadataserver/models/nodecommissionresult.py (+9/-1) src/metadataserver/models/tests/test_nodecommissionresult.py (+28/-1) |
To merge this branch: | bzr merge lp:~jtv/maas/show-commissioning-results |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Raphaël Badin (community) | Approve | ||
Review via email:
|
Commit message
UI for displaying node commissioning output (still hidden behind feature flag).
Description of the change
This is something I mentioned last week. It's a bit larger than usual because it's basically the result of a spike. I couldn't take the lack of visibility any more, and hacked up a UI. After that it was a matter of cleaning up and writing tests. It's not quite good enough to be proud of, which is why it's behind a feature flag for now. See ENABLE_
Hopefully with this so close to being fully operational, we'll find ourselves motivated to make the final improvements it needs and get something out to our users that will make their lives (and ours) a little better.
With the feature flag enabled, administrators will see a link on the Node page — provided the node in question has commissioning results of course — which shows a count of results, and links to a listing. The listing is actually a generic global listing, but in this case filtered to a specific node. I did not bother building a link to "show all commissioning results," though we could easily do it now if we decide it's useful. A notice of failed commissioning scripts does not link to those results, but of course it would be nice if we could add that. It's just that getting something now was more important to me than getting something good later.
The results listing again is not particularly sophisticated. It sorts by node ID for basic grouping (primitive, but remember the UI right now only ever shows results for one node at a time), then by date so you get to see a reverse chronological listing even if the node has results from multiple commissioning rounds, and finally by the name of the output file — in case two results are reported by the same API call.
Layout of the new code is awkward. The NodeCommissionR
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Raphaël Badin (rvb) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Raphaël Badin (rvb) wrote : | # |
Couple of remarks about the listing page:
[4]
It would be nice to see, possibly in the title of the page, a mention of the node used to filter the results. (I know the node is part of the fields listed in the listing itself.)
[5]
It's weird having to click on the status to access the result page. It's weird because I'd expect having to click on the name of the file and it's weird because I'd expect the link to be on the left-hand side, like with all the other listings in MAAS.
Couple of remarks about the result page:
[6]
"Time" says "Result was registered at Feb. 17, 2014, 10:16 a.m.".
I'd get rid of the "Result was registered at" and put it in the title instead. I think it's best to keep the value of the fields to exactly what's in the database for clarity. The explanation of what the field is should be in the title.
Same remark for "Return value" (example value: "Script returned 2").
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Raphaël Badin (rvb) wrote : | # |
Thanks for the fixes, let's get this landed so we can both work on tweaking the display.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Jeroen T. Vermeulen (jtv) wrote : | # |
Thanks for the review!
Preview Diff
1 | === modified file 'src/maasserver/templates/maasserver/node_view.html' |
2 | --- src/maasserver/templates/maasserver/node_view.html 2013-12-18 17:35:45 +0000 |
3 | +++ src/maasserver/templates/maasserver/node_view.html 2014-02-17 12:29:17 +0000 |
4 | @@ -188,6 +188,17 @@ |
5 | <span>{{ node.routers|join:", " }}</span> |
6 | </li> |
7 | {% endif %} |
8 | + {% if user.is_superuser and nodecommissionresults > 0 %} |
9 | + <li class="block first size5 separate" id="nodecommissionresults"> |
10 | + <h4>Commissioning output</h4> |
11 | + <span> |
12 | + <a href="{% url 'nodecommissionresult-list' %}?node={{node.system_id}}"> |
13 | + {{ nodecommissionresults }} |
14 | + output file{{ nodecommissionresults|pluralize }} |
15 | + </a> |
16 | + </span> |
17 | + </li> |
18 | + {% endif %} |
19 | {% if probed_details %} |
20 | <li class="block first separate"> |
21 | <h2>Raw discovery data</h2> |
22 | |
23 | === added directory 'src/maasserver/templates/metadataserver' |
24 | === added file 'src/maasserver/templates/metadataserver/nodecommissionresult.html' |
25 | --- src/maasserver/templates/metadataserver/nodecommissionresult.html 1970-01-01 00:00:00 +0000 |
26 | +++ src/maasserver/templates/metadataserver/nodecommissionresult.html 2014-02-17 12:29:17 +0000 |
27 | @@ -0,0 +1,38 @@ |
28 | +{% extends "maasserver/base.html" %} |
29 | + |
30 | +{% block nav-active-settings %}active{% endblock %} |
31 | +{% block title %}Commissioning result for node {{ object.node.hostname }}, script {{ object.name }}{% endblock %} |
32 | +{% block page-title %}Commissioning result for node: {{ object.node.hostname }}{% endblock %} |
33 | + |
34 | +{% block content %} |
35 | + <ul class="data-list"> |
36 | + <li class="block size7 first" id="name"> |
37 | + <h4>Output file</h4> |
38 | + <span>{{ object.name }}</span> |
39 | + </li> |
40 | + <li class="block size8" id="node"> |
41 | + <h4>Node</h4> |
42 | + <span> |
43 | + <a href="{% url 'node-view' object.node.system_id %}"> |
44 | + {{ object.node.hostname }} |
45 | + </a> |
46 | + </span> |
47 | + </li> |
48 | + <li class="block size7 first" id="time"> |
49 | + <h4>Time</h4> |
50 | + <span>Result was registered at {{ object.created }}</span> |
51 | + </li> |
52 | + <li class="block size3" id="script-result"> |
53 | + <h4>Return value</h4> |
54 | + <span>Script returned {{ object.script_result }}</span> |
55 | + </li> |
56 | + |
57 | + <li class="block first separate" id="output"> |
58 | + <h2>Output</h2> |
59 | + <pre> |
60 | +{{ object.get_data_as_html }} |
61 | + </pre> |
62 | + </li> |
63 | + </ul> |
64 | + |
65 | +{% endblock %} |
66 | |
67 | === added file 'src/maasserver/templates/metadataserver/nodecommissionresult_list.html' |
68 | --- src/maasserver/templates/metadataserver/nodecommissionresult_list.html 1970-01-01 00:00:00 +0000 |
69 | +++ src/maasserver/templates/metadataserver/nodecommissionresult_list.html 2014-02-17 12:29:17 +0000 |
70 | @@ -0,0 +1,60 @@ |
71 | +{% extends "maasserver/base.html" %} |
72 | + |
73 | +{% block nav-active-zone-list %}active{% endblock %} |
74 | +{% block title %}Node commissioning results{% endblock %} |
75 | +{% block page-title %} |
76 | +{{ paginator.count }} result{{ paginator.count|pluralize }} |
77 | +in {% include "maasserver/site_title.html" %} |
78 | +{% endblock %} |
79 | + |
80 | +{% block html_includes %}{% include "maasserver/snippets.html" %} |
81 | +{% endblock %} |
82 | + |
83 | +{% block content %} |
84 | + <div id="results"> |
85 | + <h2>Commissioning results</h2> |
86 | + {% if paginator.count == 0 %} |
87 | + <p>No matching commissioning results.</p> |
88 | + {% else %} |
89 | + <table class="list"> |
90 | + <thead> |
91 | + <tr> |
92 | + <th>Time</th> |
93 | + <th>Node</th> |
94 | + <th>Output file</th> |
95 | + <th>Script result<th> |
96 | + </tr> |
97 | + </thead> |
98 | + <tbody> |
99 | + {% for result_item in results_list %} |
100 | + <tr class="result {% cycle 'even' 'odd' %}"> |
101 | + <td>{{ result_item.created }}</td> |
102 | + <td> |
103 | + <a href="{% url 'node-view' result_item.node.system_id %}"> |
104 | + {{ result_item.node.system_id }} |
105 | + </a> |
106 | + </td> |
107 | + <td> |
108 | + {{ result_item.name }} |
109 | + </td> |
110 | + <td> |
111 | + <a href="{% url 'nodecommissionresult-view' result_item.id %}"> |
112 | + {% if result_item.script_result == 0 %} |
113 | + OK: {{ result_item.script_result }} |
114 | + {% else %} |
115 | + <span class="warning"> |
116 | + <img src="{{ STATIC_URL }}img/warning.png" title="Failed" /> |
117 | + FAILED: {{ result_item.script_result }} |
118 | + </span> |
119 | + {% endif %} |
120 | + </a> |
121 | + </td> |
122 | + </tr> |
123 | + {% endfor %} |
124 | + </tbody> |
125 | + </table> |
126 | + {% endif %} |
127 | + <div class="clear"></div> |
128 | + </div> |
129 | + |
130 | +{% endblock %} |
131 | |
132 | === modified file 'src/maasserver/urls.py' |
133 | --- src/maasserver/urls.py 2014-02-06 10:14:43 +0000 |
134 | +++ src/maasserver/urls.py 2014-02-17 12:29:17 +0000 |
135 | @@ -1,4 +1,4 @@ |
136 | -# Copyright 2012 Canonical Ltd. This software is licensed under the |
137 | +# Copyright 2012-2014 Canonical Ltd. This software is licensed under the |
138 | # GNU Affero General Public License version 3 (see the file LICENSE). |
139 | |
140 | """URL routing configuration.""" |
141 | @@ -34,6 +34,10 @@ |
142 | NetworkListView, |
143 | NetworkView, |
144 | ) |
145 | +from maasserver.views.nodecommissionresult import ( |
146 | + NodeCommissionResultListView, |
147 | + NodeCommissionResultView, |
148 | + ) |
149 | from maasserver.views.nodes import ( |
150 | enlist_preseed_view, |
151 | MacAdd, |
152 | @@ -200,6 +204,14 @@ |
153 | r'^commissioning-scripts/add/$', |
154 | CommissioningScriptCreate.as_view(), |
155 | name='commissioning-script-add'), |
156 | + adminurl( |
157 | + r'^commissioning-results/$', |
158 | + NodeCommissionResultListView.as_view(), |
159 | + name='nodecommissionresult-list'), |
160 | + adminurl( |
161 | + r'^commissioning-results/(?P<id>[0-9]+)/$', |
162 | + NodeCommissionResultView.as_view(), |
163 | + name='nodecommissionresult-view'), |
164 | ) |
165 | |
166 | # Tag views. |
167 | |
168 | === added file 'src/maasserver/views/nodecommissionresult.py' |
169 | --- src/maasserver/views/nodecommissionresult.py 1970-01-01 00:00:00 +0000 |
170 | +++ src/maasserver/views/nodecommissionresult.py 2014-02-17 12:29:17 +0000 |
171 | @@ -0,0 +1,44 @@ |
172 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
173 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
174 | + |
175 | +"""Views for node commissioning results.""" |
176 | + |
177 | +from __future__ import ( |
178 | + absolute_import, |
179 | + print_function, |
180 | + unicode_literals, |
181 | + ) |
182 | + |
183 | +str = None |
184 | + |
185 | +__metaclass__ = type |
186 | +__all__ = [ |
187 | + 'NodeCommissionResultListView', |
188 | + ] |
189 | + |
190 | +from django.shortcuts import get_object_or_404 |
191 | +from django.views.generic import DetailView |
192 | +from maasserver.views import PaginatedListView |
193 | +from metadataserver.models import NodeCommissionResult |
194 | + |
195 | + |
196 | +class NodeCommissionResultListView(PaginatedListView): |
197 | + |
198 | + template_name = 'maasserver/nodecommissionresult-list.html' |
199 | + context_object_name = 'results_list' |
200 | + |
201 | + def get_queryset(self): |
202 | + results = NodeCommissionResult.objects.all() |
203 | + system_ids = self.request.GET.getlist('node') |
204 | + if system_ids is not None and len(system_ids) > 0: |
205 | + results = results.filter(node__system_id__in=system_ids) |
206 | + return results.order_by('node', '-created', 'name') |
207 | + |
208 | + |
209 | +class NodeCommissionResultView(DetailView): |
210 | + |
211 | + template_name = 'metadataserver/nodecommissionresult.html' |
212 | + |
213 | + def get_object(self): |
214 | + result_id = self.kwargs.get('id') |
215 | + return get_object_or_404(NodeCommissionResult, id=result_id) |
216 | |
217 | === modified file 'src/maasserver/views/nodes.py' |
218 | --- src/maasserver/views/nodes.py 2014-01-21 06:08:12 +0000 |
219 | +++ src/maasserver/views/nodes.py 2014-02-17 12:29:17 +0000 |
220 | @@ -1,4 +1,4 @@ |
221 | -# Copyright 2012, 2013 Canonical Ltd. This software is licensed under the |
222 | +# Copyright 2012-2014 Canonical Ltd. This software is licensed under the |
223 | # GNU Affero General Public License version 3 (see the file LICENSE). |
224 | |
225 | """Nodes views.""" |
226 | @@ -83,6 +83,7 @@ |
227 | HelpfulDeleteView, |
228 | PaginatedListView, |
229 | ) |
230 | +from metadataserver.models import NodeCommissionResult |
231 | from provisioningserver.tags import merge_details_cleanly |
232 | |
233 | |
234 | @@ -399,6 +400,11 @@ |
235 | """) |
236 | |
237 | |
238 | +# Feature flag: link to node commissioning results? |
239 | +# XXX jtv 2014-02-13: Remove this when this UI feature is good enough. |
240 | +ENABLE_NODECOMMISSIONRESULTS = False |
241 | + |
242 | + |
243 | class NodeView(NodeViewMixin, UpdateView): |
244 | """View class to display a node's information and buttons for the actions |
245 | which can be performed on this node. |
246 | @@ -440,6 +446,13 @@ |
247 | else: |
248 | context["probed_details"] = etree.tostring( |
249 | probed_details, encoding=unicode, pretty_print=True) |
250 | + |
251 | + if ENABLE_NODECOMMISSIONRESULTS: |
252 | + results = NodeCommissionResult.objects.filter(node=node).count() |
253 | + else: |
254 | + results = 0 |
255 | + context['nodecommissionresults'] = results |
256 | + |
257 | return context |
258 | |
259 | def dispatch(self, *args, **kwargs): |
260 | |
261 | === added file 'src/maasserver/views/tests/test_nodecommissionresults.py' |
262 | --- src/maasserver/views/tests/test_nodecommissionresults.py 1970-01-01 00:00:00 +0000 |
263 | +++ src/maasserver/views/tests/test_nodecommissionresults.py 2014-02-17 12:29:17 +0000 |
264 | @@ -0,0 +1,248 @@ |
265 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
266 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
267 | + |
268 | +"""Tests for the `NodeCommissionResult` views.""" |
269 | + |
270 | +from __future__ import ( |
271 | + absolute_import, |
272 | + print_function, |
273 | + unicode_literals, |
274 | + ) |
275 | + |
276 | +str = None |
277 | + |
278 | +__metaclass__ = type |
279 | +__all__ = [] |
280 | + |
281 | +import httplib |
282 | +from operator import attrgetter |
283 | +from random import randint |
284 | + |
285 | +from django.core.urlresolvers import reverse |
286 | +from django.http.request import QueryDict |
287 | +from lxml import html |
288 | +from maasserver.testing import extract_redirect |
289 | +from maasserver.testing.factory import factory |
290 | +from maasserver.testing.testcase import MAASServerTestCase |
291 | +from maasserver.utils.orm import get_one |
292 | +from maasserver.views.nodecommissionresult import NodeCommissionResultListView |
293 | +from mock import Mock |
294 | +from testtools.matchers import HasLength |
295 | + |
296 | + |
297 | +class TestNodeCommissionResultView(MAASServerTestCase): |
298 | + |
299 | + def request_page(self, result): |
300 | + """Request and parse the page for the given `NodeCommissionResult`. |
301 | + |
302 | + :return: The page's main content as an `lxml.html.HtmlElement`. |
303 | + """ |
304 | + link = reverse('nodecommissionresult-view', args=[result.id]) |
305 | + response = self.client.get(link) |
306 | + self.assertEqual(httplib.OK, response.status_code, response.content) |
307 | + doc = html.fromstring(response.content) |
308 | + return get_one(doc.cssselect('#content')) |
309 | + |
310 | + def extract_field(self, doc, field_name, containing_tag='span'): |
311 | + """Get the content text from one of the <li> fields on the page. |
312 | + |
313 | + This works on the basis that each of the fields has an `id` attribute |
314 | + which is unique on the page, and contains exactly one tag of the type |
315 | + given as `containing_tag`, which holds the field value. |
316 | + """ |
317 | + field = get_one(doc.cssselect('#' + field_name)) |
318 | + value = get_one(field.cssselect(containing_tag)) |
319 | + return value.text_content().strip() |
320 | + |
321 | + def test_requires_admin(self): |
322 | + self.client_log_in(as_admin=False) |
323 | + result = factory.make_node_commission_result() |
324 | + response = self.client.get( |
325 | + reverse('nodecommissionresult-view', args=[result.id])) |
326 | + self.assertEqual(reverse('login'), extract_redirect(response)) |
327 | + |
328 | + def test_displays_result(self): |
329 | + self.client_log_in(as_admin=True) |
330 | + result = factory.make_node_commission_result( |
331 | + data=factory.getRandomString().encode('ascii')) |
332 | + doc = self.request_page(result) |
333 | + |
334 | + self.assertEqual(result.name, self.extract_field(doc, 'name')) |
335 | + self.assertEqual( |
336 | + result.node.hostname, |
337 | + self.extract_field(doc, 'node')) |
338 | + self.assertEqual( |
339 | + "Script returned %d" % result.script_result, |
340 | + self.extract_field(doc, 'script-result')) |
341 | + self.assertEqual(result.data, self.extract_field(doc, 'output', 'pre')) |
342 | + |
343 | + def test_escapes_html_in_output(self): |
344 | + self.client_log_in(as_admin=True) |
345 | + # It's actually surprisingly hard to test for this, because lxml |
346 | + # un-escapes on parsing, and is very tolerant of malformed input. |
347 | + # Parsing an un-escaped A<B>C, however, would produce text "AC" |
348 | + # (because the <B> looks like a tag). |
349 | + result = factory.make_node_commission_result(data=b'A<B>C') |
350 | + doc = self.request_page(result) |
351 | + self.assertEqual('A<B>C', self.extract_field(doc, 'output', 'pre')) |
352 | + |
353 | + def test_escapes_binary_in_output(self): |
354 | + self.client_log_in(as_admin=True) |
355 | + result = factory.make_node_commission_result(data=b'A\xffB') |
356 | + doc = self.request_page(result) |
357 | + self.assertEqual('A\ufffdB', self.extract_field(doc, 'output', 'pre')) |
358 | + |
359 | + |
360 | +class TestNodeCommissionResultListView(MAASServerTestCase): |
361 | + |
362 | + def make_query_string(self, nodes): |
363 | + """Compose a URL query string to filter for the given nodes.""" |
364 | + return '&'.join('node=%s' % node.system_id for node in nodes) |
365 | + |
366 | + def request_page(self, nodes=None): |
367 | + """Request and parse the page for the given `NodeCommissionResult`. |
368 | + |
369 | + :param node: Optional list of `Node` for which results should be |
370 | + displayed. If not given, all results are displayed. |
371 | + :return: The page's main content as an `lxml.html.HtmlElement`. |
372 | + """ |
373 | + link = reverse('nodecommissionresult-list') |
374 | + if nodes is not None: |
375 | + link += '?' + self.make_query_string(nodes) |
376 | + response = self.client.get(link) |
377 | + self.assertEqual(httplib.OK, response.status_code, response.content) |
378 | + return get_one(html.fromstring(response.content).cssselect('#content')) |
379 | + |
380 | + def make_view(self, nodes=None): |
381 | + if nodes is None: |
382 | + query_string = '' |
383 | + else: |
384 | + query_string = self.make_query_string(nodes) |
385 | + view = NodeCommissionResultListView() |
386 | + view.request = Mock() |
387 | + view.request.GET = QueryDict(query_string) |
388 | + return view |
389 | + |
390 | + def test_requires_admin(self): |
391 | + self.client_log_in(as_admin=False) |
392 | + response = self.client.get(reverse('nodecommissionresult-list')) |
393 | + self.assertEqual(reverse('login'), extract_redirect(response)) |
394 | + |
395 | + def test_lists_empty_page(self): |
396 | + self.client_log_in(as_admin=True) |
397 | + content = self.request_page() |
398 | + self.assertIn( |
399 | + "No matching commissioning results.", |
400 | + content.text_content().strip()) |
401 | + self.assertEqual([], content.cssselect('.result')) |
402 | + |
403 | + def test_lists_results(self): |
404 | + self.client_log_in(as_admin=True) |
405 | + result = factory.make_node_commission_result(script_result=0) |
406 | + content = self.request_page() |
407 | + result_row = get_one(content.cssselect('.result')) |
408 | + fields = result_row.cssselect('td') |
409 | + |
410 | + [time, node, output_file, script_result] = fields |
411 | + self.assertIn('%d' % result.created.year, time.text_content()) |
412 | + self.assertEqual(result.node.system_id, node.text_content().strip()) |
413 | + self.assertEqual( |
414 | + reverse('node-view', args=[result.node.system_id]), |
415 | + get_one(node.cssselect('a')).get('href')) |
416 | + self.assertEqual(result.name, output_file.text_content().strip()) |
417 | + self.assertEqual('OK: 0', script_result.text_content().strip()) |
418 | + |
419 | + def test_shows_failure(self): |
420 | + self.client_log_in(as_admin=True) |
421 | + result = factory.make_node_commission_result( |
422 | + script_result=randint(1, 100)) |
423 | + content = self.request_page() |
424 | + result_row = get_one(content.cssselect('.result')) |
425 | + fields = result_row.cssselect('td') |
426 | + [_, _, _, script_result] = fields |
427 | + self.assertEqual( |
428 | + "FAILED: %d" % result.script_result, |
429 | + script_result.text_content().strip()) |
430 | + self.assertNotEqual([], script_result.find_class('warning')) |
431 | + |
432 | + def test_links_to_result(self): |
433 | + self.client_log_in(as_admin=True) |
434 | + result = factory.make_node_commission_result( |
435 | + script_result=randint(1, 100)) |
436 | + content = self.request_page() |
437 | + result_row = get_one(content.cssselect('.result')) |
438 | + fields = result_row.cssselect('td') |
439 | + [_, _, _, script_result] = fields |
440 | + link = get_one(script_result.cssselect('a')) |
441 | + self.assertEqual( |
442 | + reverse('nodecommissionresult-view', args=[result.id]), |
443 | + link.get('href')) |
444 | + |
445 | + def test_groups_by_node(self): |
446 | + nodes = [factory.make_node() for _ in range(3)] |
447 | + # Create two results per node, but interleave them so the results for |
448 | + # any given node are unlikely to occur side by side by accident. |
449 | + for _ in range(2): |
450 | + for node in nodes: |
451 | + factory.make_node_commission_result(node=node) |
452 | + sorted_results = self.make_view().get_queryset() |
453 | + self.assertEqual(sorted_results[0].node, sorted_results[1].node) |
454 | + self.assertEqual(sorted_results[2].node, sorted_results[3].node) |
455 | + self.assertEqual(sorted_results[4].node, sorted_results[5].node) |
456 | + |
457 | + def test_sorts_by_creation_time_for_same_node(self): |
458 | + node = factory.make_node() |
459 | + results = [ |
460 | + factory.make_node_commission_result(node=node) |
461 | + for _ in range(3) |
462 | + ] |
463 | + for result in results: |
464 | + result.created -= factory.make_timedelta() |
465 | + result.save() |
466 | + |
467 | + self.assertEqual( |
468 | + sorted(results, key=attrgetter('created'), reverse=True), |
469 | + list(self.make_view().get_queryset())) |
470 | + |
471 | + def test_sorts_by_name_for_same_node_and_creation_time(self): |
472 | + node = factory.make_node() |
473 | + results = { |
474 | + factory.make_node_commission_result( |
475 | + node=node, name=factory.make_name().lower()) |
476 | + for _ in range(5) |
477 | + } |
478 | + self.assertEqual( |
479 | + sorted(results, key=attrgetter('name')), |
480 | + list(self.make_view().get_queryset())) |
481 | + |
482 | + def test_filters_by_node(self): |
483 | + factory.make_node_commission_result() |
484 | + node = factory.make_node() |
485 | + node_results = { |
486 | + factory.make_node_commission_result(node=node) for _ in range(3) |
487 | + } |
488 | + factory.make_node_commission_result() |
489 | + |
490 | + self.assertEqual( |
491 | + node_results, |
492 | + set(self.make_view(nodes=[node]).get_queryset())) |
493 | + |
494 | + def test_combines_node_filters(self): |
495 | + # The nodes are passed as GET parameters, which means there is some |
496 | + # subtlety to how they are passed to the application. Reading them |
497 | + # naively would ignore all but the first node passed, so make sure we |
498 | + # process all of them. |
499 | + self.client_log_in(as_admin=True) |
500 | + results = [factory.make_node_commission_result() for _ in range(3)] |
501 | + matching_results = results[1:3] |
502 | + content = self.request_page( |
503 | + nodes=[result.node for result in matching_results]) |
504 | + rows = content.cssselect('.result') |
505 | + self.assertThat(rows, HasLength(len(matching_results))) |
506 | + matching_names = set() |
507 | + for row in rows: |
508 | + [_, _, name, _] = row.cssselect('td') |
509 | + matching_names.add(name.text_content().strip()) |
510 | + self.assertEqual( |
511 | + {result.name for result in matching_results}, |
512 | + matching_names) |
513 | |
514 | === modified file 'src/maasserver/views/tests/test_nodes.py' |
515 | --- src/maasserver/views/tests/test_nodes.py 2014-02-11 22:00:31 +0000 |
516 | +++ src/maasserver/views/tests/test_nodes.py 2014-02-17 12:29:17 +0000 |
517 | @@ -17,6 +17,7 @@ |
518 | import httplib |
519 | from operator import attrgetter |
520 | import os |
521 | +from random import randint |
522 | from textwrap import dedent |
523 | from urlparse import ( |
524 | parse_qsl, |
525 | @@ -947,6 +948,110 @@ |
526 | self.assertEqual(params.items(), query_string_params) |
527 | |
528 | |
529 | +class NodeCommissionResultsDisplayTest(MAASServerTestCase): |
530 | + """Tests for the link to node commissioning results on the Node page.""" |
531 | + |
532 | + def enable_display(self): |
533 | + """Enable the feature flag behind which this display is hidden.""" |
534 | + # XXX jtv 2014-02-13: Remove this once feature is good enough. |
535 | + self.patch(nodes_views, 'ENABLE_NODECOMMISSIONRESULTS', True) |
536 | + |
537 | + def request_results_display(self, node): |
538 | + """Request the page for `node`, and extract the results display. |
539 | + |
540 | + Fails if generating, loading or parsing the page failed; or if |
541 | + there was more than one section of commissioning results. |
542 | + |
543 | + :return: An `lxml.html.HtmlElement` representing the commissioning |
544 | + results portion of the page; or `None` if it was not present on |
545 | + the page. |
546 | + """ |
547 | + node_link = reverse('node-view', args=[node.system_id]) |
548 | + response = self.client.get(node_link) |
549 | + self.assertEqual(httplib.OK, response.status_code, response.content) |
550 | + doc = fromstring(response.content) |
551 | + results_display = doc.cssselect('#nodecommissionresults') |
552 | + if len(results_display) == 0: |
553 | + return None |
554 | + elif len(results_display) == 1: |
555 | + return results_display[0] |
556 | + else: |
557 | + self.fail("Found more than one matching tag: %s" % results_display) |
558 | + |
559 | + def get_results_link(self, display): |
560 | + """Find the results link in `display`. |
561 | + |
562 | + :param display: Results display section for a node, as returned by |
563 | + `request_results_display`. |
564 | + :return: `lxml.html.HtmlElement` for the link to the node's |
565 | + commissioning results, as found in `display`; or `None` if it was |
566 | + not present. |
567 | + """ |
568 | + links = display.cssselect('a') |
569 | + if len(links) == 0: |
570 | + return None |
571 | + elif len(links) == 1: |
572 | + return links[0] |
573 | + else: |
574 | + self.fail("Found more than one link: %s" % links) |
575 | + |
576 | + def normalise_whitespace(self, text): |
577 | + """Return a version of `text` where all whitespace is single spaces.""" |
578 | + return ' '.join(text.split()) |
579 | + |
580 | + def test_view_node_links_to_commissioning_results_if_appropriate(self): |
581 | + self.enable_display() |
582 | + self.client_log_in(as_admin=True) |
583 | + result = factory.make_node_commission_result() |
584 | + section = self.request_results_display(result.node) |
585 | + link = self.get_results_link(section) |
586 | + results_list = reverse('nodecommissionresult-list') |
587 | + self.assertEqual( |
588 | + results_list + '?node=%s' % result.node.system_id, |
589 | + link.get('href')) |
590 | + |
591 | + def test_view_node_hides_commissioning_results_by_default(self): |
592 | + # XXX jtv 2014-02-13: Remove this test once we get rid of feature flag. |
593 | + self.client_log_in(as_admin=True) |
594 | + result = factory.make_node_commission_result() |
595 | + self.assertIsNone(self.request_results_display(result.node)) |
596 | + |
597 | + def test_view_node_shows_commissioning_results_only_if_present(self): |
598 | + self.enable_display() |
599 | + self.client_log_in(as_admin=True) |
600 | + node = factory.make_node() |
601 | + self.assertIsNone(self.request_results_display(node)) |
602 | + |
603 | + def test_view_node_shows_commissioning_results_only_to_superuser(self): |
604 | + self.enable_display() |
605 | + self.client_log_in(as_admin=False) |
606 | + result = factory.make_node_commission_result() |
607 | + self.assertIsNone(self.request_results_display(result.node)) |
608 | + |
609 | + def test_view_node_shows_single_commissioning_result(self): |
610 | + self.enable_display() |
611 | + self.client_log_in(as_admin=True) |
612 | + result = factory.make_node_commission_result() |
613 | + section = self.request_results_display(result.node) |
614 | + link = self.get_results_link(section) |
615 | + self.assertEqual( |
616 | + "1 output file", |
617 | + self.normalise_whitespace(link.text_content())) |
618 | + |
619 | + def test_view_node_shows_multiple_commissioning_results(self): |
620 | + self.enable_display() |
621 | + self.client_log_in(as_admin=True) |
622 | + node = factory.make_node() |
623 | + num_results = randint(2, 5) |
624 | + for _ in range(num_results): |
625 | + factory.make_node_commission_result(node=node) |
626 | + section = self.request_results_display(node) |
627 | + link = self.get_results_link(section) |
628 | + self.assertEqual( |
629 | + "%d output files" % num_results, |
630 | + self.normalise_whitespace(link.text_content())) |
631 | + |
632 | + |
633 | class NodeListingSelectionJSControls(SeleniumTestCase): |
634 | |
635 | def test_node_list_js_control_select_all(self): |
636 | |
637 | === modified file 'src/maastesting/factory.py' |
638 | --- src/maastesting/factory.py 2014-02-10 10:15:07 +0000 |
639 | +++ src/maastesting/factory.py 2014-02-17 12:29:17 +0000 |
640 | @@ -180,6 +180,12 @@ |
641 | stamp = random.randrange(start, end) |
642 | return datetime.datetime.fromtimestamp(stamp) |
643 | |
644 | + def make_timedelta(self): |
645 | + return datetime.timedelta( |
646 | + days=random.randint(0, 3 * 365), |
647 | + seconds=random.randint(0, 24 * 60 * 60 - 1), |
648 | + microseconds=random.randint(0, 999999)) |
649 | + |
650 | def make_file(self, location, name=None, contents=None): |
651 | """Create a file, and write data to it. |
652 | |
653 | |
654 | === modified file 'src/metadataserver/models/nodecommissionresult.py' |
655 | --- src/metadataserver/models/nodecommissionresult.py 2013-10-07 09:12:40 +0000 |
656 | +++ src/metadataserver/models/nodecommissionresult.py 2014-02-17 12:29:17 +0000 |
657 | @@ -1,4 +1,4 @@ |
658 | -# Copyright 2012 Canonical Ltd. This software is licensed under the |
659 | +# Copyright 2012-2014 Canonical Ltd. This software is licensed under the |
660 | # GNU Affero General Public License version 3 (see the file LICENSE). |
661 | |
662 | """:class:`NodeCommissionResult` model.""" |
663 | @@ -24,6 +24,7 @@ |
664 | Manager, |
665 | ) |
666 | from django.shortcuts import get_object_or_404 |
667 | +from django.utils.html import escape |
668 | from maasserver.models.cleansave import CleanSave |
669 | from maasserver.models.timestampedmodel import TimestampedModel |
670 | from metadataserver import DefaultMeta |
671 | @@ -97,3 +98,10 @@ |
672 | data = BinaryField( |
673 | max_length=1024 * 1024, editable=True, blank=True, default=b'', |
674 | null=False) |
675 | + |
676 | + def __unicode__(self): |
677 | + return "%s/%s" % (self.node.system_id, self.name) |
678 | + |
679 | + def get_data_as_html(self): |
680 | + """More-or-less human-readable HTML representation of the output.""" |
681 | + return escape(self.data.decode('utf-8', 'replace')) |
682 | |
683 | === modified file 'src/metadataserver/models/tests/test_nodecommissionresult.py' |
684 | --- src/metadataserver/models/tests/test_nodecommissionresult.py 2013-10-07 11:08:54 +0000 |
685 | +++ src/metadataserver/models/tests/test_nodecommissionresult.py 2014-02-17 12:29:17 +0000 |
686 | @@ -1,4 +1,4 @@ |
687 | -# Copyright 2012 Canonical Ltd. This software is licensed under the |
688 | +# Copyright 2012-2014 Canonical Ltd. This software is licensed under the |
689 | # GNU Affero General Public License version 3 (see the file LICENSE). |
690 | |
691 | """Tests for the :class:`NodeCommissionResult` model.""" |
692 | @@ -27,6 +27,12 @@ |
693 | class TestNodeCommissionResult(DjangoTestCase): |
694 | """Test the NodeCommissionResult model.""" |
695 | |
696 | + def test_unicode_represents_result(self): |
697 | + result = factory.make_node_commission_result() |
698 | + self.assertEqual( |
699 | + '%s/%s' % (result.node.system_id, result.name), |
700 | + unicode(result)) |
701 | + |
702 | def test_can_store_data(self): |
703 | node = factory.make_node() |
704 | name = factory.getRandomString() |
705 | @@ -52,6 +58,27 @@ |
706 | ncr2 = factory.make_node_commission_result(node=node2, name="foo") |
707 | self.assertEqual(ncr1.name, ncr2.name) |
708 | |
709 | + def test_get_data_as_html_returns_output(self): |
710 | + output = factory.getRandomString() |
711 | + result = factory.make_node_commission_result( |
712 | + data=output.encode('ascii')) |
713 | + self.assertEqual(output, result.get_data_as_html()) |
714 | + |
715 | + def test_get_data_as_html_escapes_binary(self): |
716 | + output = b'\x00\xff' |
717 | + result = factory.make_node_commission_result(data=output) |
718 | + html = result.get_data_as_html() |
719 | + self.assertIsInstance(html, unicode) |
720 | + # The nul byte turns into the zero character. The 0xff is an invalid |
721 | + # character and so becomes the Unicode "replacement character" 0xfffd. |
722 | + self.assertEqual('\x00\ufffd', html) |
723 | + |
724 | + def test_get_data_as_html_escapes_for_html(self): |
725 | + output = '<&>' |
726 | + result = factory.make_node_commission_result( |
727 | + data=output.encode('ascii')) |
728 | + self.assertEqual('<&>', result.get_data_as_html()) |
729 | + |
730 | |
731 | class TestNodeCommissionResultManager(DjangoTestCase): |
732 | """Test the manager utility for NodeCommissionResult.""" |
First of all, thanks a lot for doing this. And congrats for getting something done in such a short time!
[0]
I like the design you've chosen. I like the choice to have a top-level nodecommissioni ngresults url that you can then filter using query parameters. It's somewhat similar to the API and it's pretty flexible.
> I couldn't take the lack of visibility any more, and hacked up a UI.
It was a problem for our users so it's definitely a worthwhile addition to the UI.
[1]
> Layout of the new code is awkward.
The Django app 'maasserver' depends on the Django app 'metadataserver'; now it happens to provide UI for models defined in the 'metadataserver', that's really fine even if it's a tiny bit awkward. The only thing we could do is more the NodeCommissioning model back into maasserver but I don't think it's worth it.
[2]
+++ src/maasserver/ views/nodecommi ssionresult. py
202 + results = NodeCommissionR esult.objects. all() GET.get( 'node') filter( node__system_ id__in= node)
203 + node = self.request.
204 + if node is not None:
205 + results = results.
This can't work: the 'node' object (which I'd rename 'system_ids' if I where you) is the *first* value of the parameter 'node'; what I mean is this: if you use request. GET.get( 'zz') and the request is "?zz=aa&zz=bb" you'll get 'aa'. You need to use getlist() here (see https:/ /docs.djangopro ject.com/ en/dev/ ref/request- response/ #querydict- objects) to get the list of all the values. I'm guessing that's what you had in mind given that you're using that in conjunction with 'system_id__in'.
[3]
Let's add a couple of fake NodeCommissioni ngResult objects to the sample data (http:// paste.ubuntu. com/6947847/). It will make the diff bigger but it will help us a great deal to see if this is working or not (I did that manually and that's how I found the problem I described in [2]).