Merge lp:~jtv/maas/show-commissioning-results into lp:~maas-committers/maas/trunk

Proposed by Jeroen T. Vermeulen
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
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve
Review via email: mp+206661@code.launchpad.net

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_NODECOMMISSIONRESULTS. No fancy framework or infrastructure for feature flags, or even a setting or config-file entry; just a dumb variable that we can edit.

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 NodeCommissionResult model is defined in the metadataserver app, but the new views are in maasserver because that's where the page lives, and then the templates are in src/maasserver/metadataserver/. Suggestions welcome — though I hope we can do as much as possible in separate branches.

To post a comment you must log in.
Revision history for this message
Raphaël Badin (rvb) wrote :

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 nodecommissioningresults 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/nodecommissionresult.py

202 + results = NodeCommissionResult.objects.all()
203 + node = self.request.GET.get('node')
204 + if node is not None:
205 + results = results.filter(node__system_id__in=node)

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.djangoproject.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 NodeCommissioningResult 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]).

Revision history for this message
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").

Revision history for this message
Raphaël Badin (rvb) wrote :

Thanks for the fixes, let's get this landed so we can both work on tweaking the display.

review: Approve
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Thanks for the review!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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('&lt;&amp;&gt;', result.get_data_as_html())
729+
730
731 class TestNodeCommissionResultManager(DjangoTestCase):
732 """Test the manager utility for NodeCommissionResult."""