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
=== modified file 'src/maasserver/templates/maasserver/node_view.html'
--- src/maasserver/templates/maasserver/node_view.html 2013-12-18 17:35:45 +0000
+++ src/maasserver/templates/maasserver/node_view.html 2014-02-17 12:29:17 +0000
@@ -188,6 +188,17 @@
188 <span>{{ node.routers|join:", " }}</span>188 <span>{{ node.routers|join:", " }}</span>
189 </li>189 </li>
190 {% endif %}190 {% endif %}
191 {% if user.is_superuser and nodecommissionresults > 0 %}
192 <li class="block first size5 separate" id="nodecommissionresults">
193 <h4>Commissioning output</h4>
194 <span>
195 <a href="{% url 'nodecommissionresult-list' %}?node={{node.system_id}}">
196 {{ nodecommissionresults }}
197 output file{{ nodecommissionresults|pluralize }}
198 </a>
199 </span>
200 </li>
201 {% endif %}
191 {% if probed_details %}202 {% if probed_details %}
192 <li class="block first separate">203 <li class="block first separate">
193 <h2>Raw discovery data</h2>204 <h2>Raw discovery data</h2>
194205
=== added directory 'src/maasserver/templates/metadataserver'
=== added file 'src/maasserver/templates/metadataserver/nodecommissionresult.html'
--- src/maasserver/templates/metadataserver/nodecommissionresult.html 1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/metadataserver/nodecommissionresult.html 2014-02-17 12:29:17 +0000
@@ -0,0 +1,38 @@
1{% extends "maasserver/base.html" %}
2
3{% block nav-active-settings %}active{% endblock %}
4{% block title %}Commissioning result for node {{ object.node.hostname }}, script {{ object.name }}{% endblock %}
5{% block page-title %}Commissioning result for node: {{ object.node.hostname }}{% endblock %}
6
7{% block content %}
8 <ul class="data-list">
9 <li class="block size7 first" id="name">
10 <h4>Output file</h4>
11 <span>{{ object.name }}</span>
12 </li>
13 <li class="block size8" id="node">
14 <h4>Node</h4>
15 <span>
16 <a href="{% url 'node-view' object.node.system_id %}">
17 {{ object.node.hostname }}
18 </a>
19 </span>
20 </li>
21 <li class="block size7 first" id="time">
22 <h4>Time</h4>
23 <span>Result was registered at {{ object.created }}</span>
24 </li>
25 <li class="block size3" id="script-result">
26 <h4>Return value</h4>
27 <span>Script returned {{ object.script_result }}</span>
28 </li>
29
30 <li class="block first separate" id="output">
31 <h2>Output</h2>
32 <pre>
33{{ object.get_data_as_html }}
34 </pre>
35 </li>
36 </ul>
37
38{% endblock %}
039
=== added file 'src/maasserver/templates/metadataserver/nodecommissionresult_list.html'
--- src/maasserver/templates/metadataserver/nodecommissionresult_list.html 1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/metadataserver/nodecommissionresult_list.html 2014-02-17 12:29:17 +0000
@@ -0,0 +1,60 @@
1{% extends "maasserver/base.html" %}
2
3{% block nav-active-zone-list %}active{% endblock %}
4{% block title %}Node commissioning results{% endblock %}
5{% block page-title %}
6{{ paginator.count }} result{{ paginator.count|pluralize }}
7in {% include "maasserver/site_title.html" %}
8{% endblock %}
9
10{% block html_includes %}{% include "maasserver/snippets.html" %}
11{% endblock %}
12
13{% block content %}
14 <div id="results">
15 <h2>Commissioning results</h2>
16 {% if paginator.count == 0 %}
17 <p>No matching commissioning results.</p>
18 {% else %}
19 <table class="list">
20 <thead>
21 <tr>
22 <th>Time</th>
23 <th>Node</th>
24 <th>Output file</th>
25 <th>Script result<th>
26 </tr>
27 </thead>
28 <tbody>
29 {% for result_item in results_list %}
30 <tr class="result {% cycle 'even' 'odd' %}">
31 <td>{{ result_item.created }}</td>
32 <td>
33 <a href="{% url 'node-view' result_item.node.system_id %}">
34 {{ result_item.node.system_id }}
35 </a>
36 </td>
37 <td>
38 {{ result_item.name }}
39 </td>
40 <td>
41 <a href="{% url 'nodecommissionresult-view' result_item.id %}">
42 {% if result_item.script_result == 0 %}
43 OK: {{ result_item.script_result }}
44 {% else %}
45 <span class="warning">
46 <img src="{{ STATIC_URL }}img/warning.png" title="Failed" />
47 FAILED: {{ result_item.script_result }}
48 </span>
49 {% endif %}
50 </a>
51 </td>
52 </tr>
53 {% endfor %}
54 </tbody>
55 </table>
56 {% endif %}
57 <div class="clear"></div>
58 </div>
59
60{% endblock %}
061
=== modified file 'src/maasserver/urls.py'
--- src/maasserver/urls.py 2014-02-06 10:14:43 +0000
+++ src/maasserver/urls.py 2014-02-17 12:29:17 +0000
@@ -1,4 +1,4 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the1# Copyright 2012-2014 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""URL routing configuration."""4"""URL routing configuration."""
@@ -34,6 +34,10 @@
34 NetworkListView,34 NetworkListView,
35 NetworkView,35 NetworkView,
36 )36 )
37from maasserver.views.nodecommissionresult import (
38 NodeCommissionResultListView,
39 NodeCommissionResultView,
40 )
37from maasserver.views.nodes import (41from maasserver.views.nodes import (
38 enlist_preseed_view,42 enlist_preseed_view,
39 MacAdd,43 MacAdd,
@@ -200,6 +204,14 @@
200 r'^commissioning-scripts/add/$',204 r'^commissioning-scripts/add/$',
201 CommissioningScriptCreate.as_view(),205 CommissioningScriptCreate.as_view(),
202 name='commissioning-script-add'),206 name='commissioning-script-add'),
207 adminurl(
208 r'^commissioning-results/$',
209 NodeCommissionResultListView.as_view(),
210 name='nodecommissionresult-list'),
211 adminurl(
212 r'^commissioning-results/(?P<id>[0-9]+)/$',
213 NodeCommissionResultView.as_view(),
214 name='nodecommissionresult-view'),
203)215)
204216
205# Tag views.217# Tag views.
206218
=== added file 'src/maasserver/views/nodecommissionresult.py'
--- src/maasserver/views/nodecommissionresult.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/views/nodecommissionresult.py 2014-02-17 12:29:17 +0000
@@ -0,0 +1,44 @@
1# Copyright 2014 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Views for node commissioning results."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12str = None
13
14__metaclass__ = type
15__all__ = [
16 'NodeCommissionResultListView',
17 ]
18
19from django.shortcuts import get_object_or_404
20from django.views.generic import DetailView
21from maasserver.views import PaginatedListView
22from metadataserver.models import NodeCommissionResult
23
24
25class NodeCommissionResultListView(PaginatedListView):
26
27 template_name = 'maasserver/nodecommissionresult-list.html'
28 context_object_name = 'results_list'
29
30 def get_queryset(self):
31 results = NodeCommissionResult.objects.all()
32 system_ids = self.request.GET.getlist('node')
33 if system_ids is not None and len(system_ids) > 0:
34 results = results.filter(node__system_id__in=system_ids)
35 return results.order_by('node', '-created', 'name')
36
37
38class NodeCommissionResultView(DetailView):
39
40 template_name = 'metadataserver/nodecommissionresult.html'
41
42 def get_object(self):
43 result_id = self.kwargs.get('id')
44 return get_object_or_404(NodeCommissionResult, id=result_id)
045
=== modified file 'src/maasserver/views/nodes.py'
--- src/maasserver/views/nodes.py 2014-01-21 06:08:12 +0000
+++ src/maasserver/views/nodes.py 2014-02-17 12:29:17 +0000
@@ -1,4 +1,4 @@
1# Copyright 2012, 2013 Canonical Ltd. This software is licensed under the1# Copyright 2012-2014 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Nodes views."""4"""Nodes views."""
@@ -83,6 +83,7 @@
83 HelpfulDeleteView,83 HelpfulDeleteView,
84 PaginatedListView,84 PaginatedListView,
85 )85 )
86from metadataserver.models import NodeCommissionResult
86from provisioningserver.tags import merge_details_cleanly87from provisioningserver.tags import merge_details_cleanly
8788
8889
@@ -399,6 +400,11 @@
399""")400""")
400401
401402
403# Feature flag: link to node commissioning results?
404# XXX jtv 2014-02-13: Remove this when this UI feature is good enough.
405ENABLE_NODECOMMISSIONRESULTS = False
406
407
402class NodeView(NodeViewMixin, UpdateView):408class NodeView(NodeViewMixin, UpdateView):
403 """View class to display a node's information and buttons for the actions409 """View class to display a node's information and buttons for the actions
404 which can be performed on this node.410 which can be performed on this node.
@@ -440,6 +446,13 @@
440 else:446 else:
441 context["probed_details"] = etree.tostring(447 context["probed_details"] = etree.tostring(
442 probed_details, encoding=unicode, pretty_print=True)448 probed_details, encoding=unicode, pretty_print=True)
449
450 if ENABLE_NODECOMMISSIONRESULTS:
451 results = NodeCommissionResult.objects.filter(node=node).count()
452 else:
453 results = 0
454 context['nodecommissionresults'] = results
455
443 return context456 return context
444457
445 def dispatch(self, *args, **kwargs):458 def dispatch(self, *args, **kwargs):
446459
=== added file 'src/maasserver/views/tests/test_nodecommissionresults.py'
--- src/maasserver/views/tests/test_nodecommissionresults.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/views/tests/test_nodecommissionresults.py 2014-02-17 12:29:17 +0000
@@ -0,0 +1,248 @@
1# Copyright 2014 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for the `NodeCommissionResult` views."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12str = None
13
14__metaclass__ = type
15__all__ = []
16
17import httplib
18from operator import attrgetter
19from random import randint
20
21from django.core.urlresolvers import reverse
22from django.http.request import QueryDict
23from lxml import html
24from maasserver.testing import extract_redirect
25from maasserver.testing.factory import factory
26from maasserver.testing.testcase import MAASServerTestCase
27from maasserver.utils.orm import get_one
28from maasserver.views.nodecommissionresult import NodeCommissionResultListView
29from mock import Mock
30from testtools.matchers import HasLength
31
32
33class TestNodeCommissionResultView(MAASServerTestCase):
34
35 def request_page(self, result):
36 """Request and parse the page for the given `NodeCommissionResult`.
37
38 :return: The page's main content as an `lxml.html.HtmlElement`.
39 """
40 link = reverse('nodecommissionresult-view', args=[result.id])
41 response = self.client.get(link)
42 self.assertEqual(httplib.OK, response.status_code, response.content)
43 doc = html.fromstring(response.content)
44 return get_one(doc.cssselect('#content'))
45
46 def extract_field(self, doc, field_name, containing_tag='span'):
47 """Get the content text from one of the <li> fields on the page.
48
49 This works on the basis that each of the fields has an `id` attribute
50 which is unique on the page, and contains exactly one tag of the type
51 given as `containing_tag`, which holds the field value.
52 """
53 field = get_one(doc.cssselect('#' + field_name))
54 value = get_one(field.cssselect(containing_tag))
55 return value.text_content().strip()
56
57 def test_requires_admin(self):
58 self.client_log_in(as_admin=False)
59 result = factory.make_node_commission_result()
60 response = self.client.get(
61 reverse('nodecommissionresult-view', args=[result.id]))
62 self.assertEqual(reverse('login'), extract_redirect(response))
63
64 def test_displays_result(self):
65 self.client_log_in(as_admin=True)
66 result = factory.make_node_commission_result(
67 data=factory.getRandomString().encode('ascii'))
68 doc = self.request_page(result)
69
70 self.assertEqual(result.name, self.extract_field(doc, 'name'))
71 self.assertEqual(
72 result.node.hostname,
73 self.extract_field(doc, 'node'))
74 self.assertEqual(
75 "Script returned %d" % result.script_result,
76 self.extract_field(doc, 'script-result'))
77 self.assertEqual(result.data, self.extract_field(doc, 'output', 'pre'))
78
79 def test_escapes_html_in_output(self):
80 self.client_log_in(as_admin=True)
81 # It's actually surprisingly hard to test for this, because lxml
82 # un-escapes on parsing, and is very tolerant of malformed input.
83 # Parsing an un-escaped A<B>C, however, would produce text "AC"
84 # (because the <B> looks like a tag).
85 result = factory.make_node_commission_result(data=b'A<B>C')
86 doc = self.request_page(result)
87 self.assertEqual('A<B>C', self.extract_field(doc, 'output', 'pre'))
88
89 def test_escapes_binary_in_output(self):
90 self.client_log_in(as_admin=True)
91 result = factory.make_node_commission_result(data=b'A\xffB')
92 doc = self.request_page(result)
93 self.assertEqual('A\ufffdB', self.extract_field(doc, 'output', 'pre'))
94
95
96class TestNodeCommissionResultListView(MAASServerTestCase):
97
98 def make_query_string(self, nodes):
99 """Compose a URL query string to filter for the given nodes."""
100 return '&'.join('node=%s' % node.system_id for node in nodes)
101
102 def request_page(self, nodes=None):
103 """Request and parse the page for the given `NodeCommissionResult`.
104
105 :param node: Optional list of `Node` for which results should be
106 displayed. If not given, all results are displayed.
107 :return: The page's main content as an `lxml.html.HtmlElement`.
108 """
109 link = reverse('nodecommissionresult-list')
110 if nodes is not None:
111 link += '?' + self.make_query_string(nodes)
112 response = self.client.get(link)
113 self.assertEqual(httplib.OK, response.status_code, response.content)
114 return get_one(html.fromstring(response.content).cssselect('#content'))
115
116 def make_view(self, nodes=None):
117 if nodes is None:
118 query_string = ''
119 else:
120 query_string = self.make_query_string(nodes)
121 view = NodeCommissionResultListView()
122 view.request = Mock()
123 view.request.GET = QueryDict(query_string)
124 return view
125
126 def test_requires_admin(self):
127 self.client_log_in(as_admin=False)
128 response = self.client.get(reverse('nodecommissionresult-list'))
129 self.assertEqual(reverse('login'), extract_redirect(response))
130
131 def test_lists_empty_page(self):
132 self.client_log_in(as_admin=True)
133 content = self.request_page()
134 self.assertIn(
135 "No matching commissioning results.",
136 content.text_content().strip())
137 self.assertEqual([], content.cssselect('.result'))
138
139 def test_lists_results(self):
140 self.client_log_in(as_admin=True)
141 result = factory.make_node_commission_result(script_result=0)
142 content = self.request_page()
143 result_row = get_one(content.cssselect('.result'))
144 fields = result_row.cssselect('td')
145
146 [time, node, output_file, script_result] = fields
147 self.assertIn('%d' % result.created.year, time.text_content())
148 self.assertEqual(result.node.system_id, node.text_content().strip())
149 self.assertEqual(
150 reverse('node-view', args=[result.node.system_id]),
151 get_one(node.cssselect('a')).get('href'))
152 self.assertEqual(result.name, output_file.text_content().strip())
153 self.assertEqual('OK: 0', script_result.text_content().strip())
154
155 def test_shows_failure(self):
156 self.client_log_in(as_admin=True)
157 result = factory.make_node_commission_result(
158 script_result=randint(1, 100))
159 content = self.request_page()
160 result_row = get_one(content.cssselect('.result'))
161 fields = result_row.cssselect('td')
162 [_, _, _, script_result] = fields
163 self.assertEqual(
164 "FAILED: %d" % result.script_result,
165 script_result.text_content().strip())
166 self.assertNotEqual([], script_result.find_class('warning'))
167
168 def test_links_to_result(self):
169 self.client_log_in(as_admin=True)
170 result = factory.make_node_commission_result(
171 script_result=randint(1, 100))
172 content = self.request_page()
173 result_row = get_one(content.cssselect('.result'))
174 fields = result_row.cssselect('td')
175 [_, _, _, script_result] = fields
176 link = get_one(script_result.cssselect('a'))
177 self.assertEqual(
178 reverse('nodecommissionresult-view', args=[result.id]),
179 link.get('href'))
180
181 def test_groups_by_node(self):
182 nodes = [factory.make_node() for _ in range(3)]
183 # Create two results per node, but interleave them so the results for
184 # any given node are unlikely to occur side by side by accident.
185 for _ in range(2):
186 for node in nodes:
187 factory.make_node_commission_result(node=node)
188 sorted_results = self.make_view().get_queryset()
189 self.assertEqual(sorted_results[0].node, sorted_results[1].node)
190 self.assertEqual(sorted_results[2].node, sorted_results[3].node)
191 self.assertEqual(sorted_results[4].node, sorted_results[5].node)
192
193 def test_sorts_by_creation_time_for_same_node(self):
194 node = factory.make_node()
195 results = [
196 factory.make_node_commission_result(node=node)
197 for _ in range(3)
198 ]
199 for result in results:
200 result.created -= factory.make_timedelta()
201 result.save()
202
203 self.assertEqual(
204 sorted(results, key=attrgetter('created'), reverse=True),
205 list(self.make_view().get_queryset()))
206
207 def test_sorts_by_name_for_same_node_and_creation_time(self):
208 node = factory.make_node()
209 results = {
210 factory.make_node_commission_result(
211 node=node, name=factory.make_name().lower())
212 for _ in range(5)
213 }
214 self.assertEqual(
215 sorted(results, key=attrgetter('name')),
216 list(self.make_view().get_queryset()))
217
218 def test_filters_by_node(self):
219 factory.make_node_commission_result()
220 node = factory.make_node()
221 node_results = {
222 factory.make_node_commission_result(node=node) for _ in range(3)
223 }
224 factory.make_node_commission_result()
225
226 self.assertEqual(
227 node_results,
228 set(self.make_view(nodes=[node]).get_queryset()))
229
230 def test_combines_node_filters(self):
231 # The nodes are passed as GET parameters, which means there is some
232 # subtlety to how they are passed to the application. Reading them
233 # naively would ignore all but the first node passed, so make sure we
234 # process all of them.
235 self.client_log_in(as_admin=True)
236 results = [factory.make_node_commission_result() for _ in range(3)]
237 matching_results = results[1:3]
238 content = self.request_page(
239 nodes=[result.node for result in matching_results])
240 rows = content.cssselect('.result')
241 self.assertThat(rows, HasLength(len(matching_results)))
242 matching_names = set()
243 for row in rows:
244 [_, _, name, _] = row.cssselect('td')
245 matching_names.add(name.text_content().strip())
246 self.assertEqual(
247 {result.name for result in matching_results},
248 matching_names)
0249
=== modified file 'src/maasserver/views/tests/test_nodes.py'
--- src/maasserver/views/tests/test_nodes.py 2014-02-11 22:00:31 +0000
+++ src/maasserver/views/tests/test_nodes.py 2014-02-17 12:29:17 +0000
@@ -17,6 +17,7 @@
17import httplib17import httplib
18from operator import attrgetter18from operator import attrgetter
19import os19import os
20from random import randint
20from textwrap import dedent21from textwrap import dedent
21from urlparse import (22from urlparse import (
22 parse_qsl,23 parse_qsl,
@@ -947,6 +948,110 @@
947 self.assertEqual(params.items(), query_string_params)948 self.assertEqual(params.items(), query_string_params)
948949
949950
951class NodeCommissionResultsDisplayTest(MAASServerTestCase):
952 """Tests for the link to node commissioning results on the Node page."""
953
954 def enable_display(self):
955 """Enable the feature flag behind which this display is hidden."""
956 # XXX jtv 2014-02-13: Remove this once feature is good enough.
957 self.patch(nodes_views, 'ENABLE_NODECOMMISSIONRESULTS', True)
958
959 def request_results_display(self, node):
960 """Request the page for `node`, and extract the results display.
961
962 Fails if generating, loading or parsing the page failed; or if
963 there was more than one section of commissioning results.
964
965 :return: An `lxml.html.HtmlElement` representing the commissioning
966 results portion of the page; or `None` if it was not present on
967 the page.
968 """
969 node_link = reverse('node-view', args=[node.system_id])
970 response = self.client.get(node_link)
971 self.assertEqual(httplib.OK, response.status_code, response.content)
972 doc = fromstring(response.content)
973 results_display = doc.cssselect('#nodecommissionresults')
974 if len(results_display) == 0:
975 return None
976 elif len(results_display) == 1:
977 return results_display[0]
978 else:
979 self.fail("Found more than one matching tag: %s" % results_display)
980
981 def get_results_link(self, display):
982 """Find the results link in `display`.
983
984 :param display: Results display section for a node, as returned by
985 `request_results_display`.
986 :return: `lxml.html.HtmlElement` for the link to the node's
987 commissioning results, as found in `display`; or `None` if it was
988 not present.
989 """
990 links = display.cssselect('a')
991 if len(links) == 0:
992 return None
993 elif len(links) == 1:
994 return links[0]
995 else:
996 self.fail("Found more than one link: %s" % links)
997
998 def normalise_whitespace(self, text):
999 """Return a version of `text` where all whitespace is single spaces."""
1000 return ' '.join(text.split())
1001
1002 def test_view_node_links_to_commissioning_results_if_appropriate(self):
1003 self.enable_display()
1004 self.client_log_in(as_admin=True)
1005 result = factory.make_node_commission_result()
1006 section = self.request_results_display(result.node)
1007 link = self.get_results_link(section)
1008 results_list = reverse('nodecommissionresult-list')
1009 self.assertEqual(
1010 results_list + '?node=%s' % result.node.system_id,
1011 link.get('href'))
1012
1013 def test_view_node_hides_commissioning_results_by_default(self):
1014 # XXX jtv 2014-02-13: Remove this test once we get rid of feature flag.
1015 self.client_log_in(as_admin=True)
1016 result = factory.make_node_commission_result()
1017 self.assertIsNone(self.request_results_display(result.node))
1018
1019 def test_view_node_shows_commissioning_results_only_if_present(self):
1020 self.enable_display()
1021 self.client_log_in(as_admin=True)
1022 node = factory.make_node()
1023 self.assertIsNone(self.request_results_display(node))
1024
1025 def test_view_node_shows_commissioning_results_only_to_superuser(self):
1026 self.enable_display()
1027 self.client_log_in(as_admin=False)
1028 result = factory.make_node_commission_result()
1029 self.assertIsNone(self.request_results_display(result.node))
1030
1031 def test_view_node_shows_single_commissioning_result(self):
1032 self.enable_display()
1033 self.client_log_in(as_admin=True)
1034 result = factory.make_node_commission_result()
1035 section = self.request_results_display(result.node)
1036 link = self.get_results_link(section)
1037 self.assertEqual(
1038 "1 output file",
1039 self.normalise_whitespace(link.text_content()))
1040
1041 def test_view_node_shows_multiple_commissioning_results(self):
1042 self.enable_display()
1043 self.client_log_in(as_admin=True)
1044 node = factory.make_node()
1045 num_results = randint(2, 5)
1046 for _ in range(num_results):
1047 factory.make_node_commission_result(node=node)
1048 section = self.request_results_display(node)
1049 link = self.get_results_link(section)
1050 self.assertEqual(
1051 "%d output files" % num_results,
1052 self.normalise_whitespace(link.text_content()))
1053
1054
950class NodeListingSelectionJSControls(SeleniumTestCase):1055class NodeListingSelectionJSControls(SeleniumTestCase):
9511056
952 def test_node_list_js_control_select_all(self):1057 def test_node_list_js_control_select_all(self):
9531058
=== modified file 'src/maastesting/factory.py'
--- src/maastesting/factory.py 2014-02-10 10:15:07 +0000
+++ src/maastesting/factory.py 2014-02-17 12:29:17 +0000
@@ -180,6 +180,12 @@
180 stamp = random.randrange(start, end)180 stamp = random.randrange(start, end)
181 return datetime.datetime.fromtimestamp(stamp)181 return datetime.datetime.fromtimestamp(stamp)
182182
183 def make_timedelta(self):
184 return datetime.timedelta(
185 days=random.randint(0, 3 * 365),
186 seconds=random.randint(0, 24 * 60 * 60 - 1),
187 microseconds=random.randint(0, 999999))
188
183 def make_file(self, location, name=None, contents=None):189 def make_file(self, location, name=None, contents=None):
184 """Create a file, and write data to it.190 """Create a file, and write data to it.
185191
186192
=== modified file 'src/metadataserver/models/nodecommissionresult.py'
--- src/metadataserver/models/nodecommissionresult.py 2013-10-07 09:12:40 +0000
+++ src/metadataserver/models/nodecommissionresult.py 2014-02-17 12:29:17 +0000
@@ -1,4 +1,4 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the1# Copyright 2012-2014 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4""":class:`NodeCommissionResult` model."""4""":class:`NodeCommissionResult` model."""
@@ -24,6 +24,7 @@
24 Manager,24 Manager,
25 )25 )
26from django.shortcuts import get_object_or_40426from django.shortcuts import get_object_or_404
27from django.utils.html import escape
27from maasserver.models.cleansave import CleanSave28from maasserver.models.cleansave import CleanSave
28from maasserver.models.timestampedmodel import TimestampedModel29from maasserver.models.timestampedmodel import TimestampedModel
29from metadataserver import DefaultMeta30from metadataserver import DefaultMeta
@@ -97,3 +98,10 @@
97 data = BinaryField(98 data = BinaryField(
98 max_length=1024 * 1024, editable=True, blank=True, default=b'',99 max_length=1024 * 1024, editable=True, blank=True, default=b'',
99 null=False)100 null=False)
101
102 def __unicode__(self):
103 return "%s/%s" % (self.node.system_id, self.name)
104
105 def get_data_as_html(self):
106 """More-or-less human-readable HTML representation of the output."""
107 return escape(self.data.decode('utf-8', 'replace'))
100108
=== modified file 'src/metadataserver/models/tests/test_nodecommissionresult.py'
--- src/metadataserver/models/tests/test_nodecommissionresult.py 2013-10-07 11:08:54 +0000
+++ src/metadataserver/models/tests/test_nodecommissionresult.py 2014-02-17 12:29:17 +0000
@@ -1,4 +1,4 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the1# Copyright 2012-2014 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Tests for the :class:`NodeCommissionResult` model."""4"""Tests for the :class:`NodeCommissionResult` model."""
@@ -27,6 +27,12 @@
27class TestNodeCommissionResult(DjangoTestCase):27class TestNodeCommissionResult(DjangoTestCase):
28 """Test the NodeCommissionResult model."""28 """Test the NodeCommissionResult model."""
2929
30 def test_unicode_represents_result(self):
31 result = factory.make_node_commission_result()
32 self.assertEqual(
33 '%s/%s' % (result.node.system_id, result.name),
34 unicode(result))
35
30 def test_can_store_data(self):36 def test_can_store_data(self):
31 node = factory.make_node()37 node = factory.make_node()
32 name = factory.getRandomString()38 name = factory.getRandomString()
@@ -52,6 +58,27 @@
52 ncr2 = factory.make_node_commission_result(node=node2, name="foo")58 ncr2 = factory.make_node_commission_result(node=node2, name="foo")
53 self.assertEqual(ncr1.name, ncr2.name)59 self.assertEqual(ncr1.name, ncr2.name)
5460
61 def test_get_data_as_html_returns_output(self):
62 output = factory.getRandomString()
63 result = factory.make_node_commission_result(
64 data=output.encode('ascii'))
65 self.assertEqual(output, result.get_data_as_html())
66
67 def test_get_data_as_html_escapes_binary(self):
68 output = b'\x00\xff'
69 result = factory.make_node_commission_result(data=output)
70 html = result.get_data_as_html()
71 self.assertIsInstance(html, unicode)
72 # The nul byte turns into the zero character. The 0xff is an invalid
73 # character and so becomes the Unicode "replacement character" 0xfffd.
74 self.assertEqual('\x00\ufffd', html)
75
76 def test_get_data_as_html_escapes_for_html(self):
77 output = '<&>'
78 result = factory.make_node_commission_result(
79 data=output.encode('ascii'))
80 self.assertEqual('&lt;&amp;&gt;', result.get_data_as_html())
81
5582
56class TestNodeCommissionResultManager(DjangoTestCase):83class TestNodeCommissionResultManager(DjangoTestCase):
57 """Test the manager utility for NodeCommissionResult."""84 """Test the manager utility for NodeCommissionResult."""