Merge lp:~rvb/maas/cluster-own-ui4 into lp:~maas-committers/maas/trunk

Proposed by Raphaël Badin
Status: Merged
Approved by: Raphaël Badin
Approved revision: no longer in the source branch.
Merged at revision: 2223
Proposed branch: lp:~rvb/maas/cluster-own-ui4
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 959 lines (+412/-205)
18 files modified
docs/man/maas-import-pxe-files.8.rst (+1/-1)
man/maas-import-pxe-files.8 (+2/-2)
src/maasserver/api.py (+1/-2)
src/maasserver/templates/maasserver/base.html (+5/-0)
src/maasserver/templates/maasserver/cluster_listing.html (+54/-58)
src/maasserver/templates/maasserver/cluster_listing_head.html (+10/-0)
src/maasserver/templates/maasserver/cluster_listing_row.html (+16/-3)
src/maasserver/templates/maasserver/no-bootimages-warning.html (+1/-1)
src/maasserver/templates/maasserver/nodegroup_confirm_delete.html (+1/-1)
src/maasserver/templates/maasserver/nodegroup_edit.html (+1/-1)
src/maasserver/templates/maasserver/settings.html (+0/-5)
src/maasserver/tests/test_api.py (+3/-1)
src/maasserver/urls.py (+22/-9)
src/maasserver/views/clusters.py (+108/-3)
src/maasserver/views/settings.py (+1/-38)
src/maasserver/views/tests/test_boot_image_list.py (+1/-1)
src/maasserver/views/tests/test_clusters.py (+185/-6)
src/maasserver/views/tests/test_settings.py (+0/-73)
To merge this branch: bzr merge lp:~rvb/maas/cluster-own-ui4
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) Approve
Review via email: mp+213848@code.launchpad.net

Commit message

This branch makes the cluster listings deal gracefully with a large number of clusters. Additionally, it makes the cluster listing pages more accessible.

In details:
- Move the cluster listings to their own url (instead of displaying them on the 'settings' page).
- Split the cluster listings into 3 separate listings (one page for each possible cluster status).
- Add a link to the cluster listing page next to 'Nodes' at the top of the page.
- Add pagination to all the listings.

Description of the change

There is a live canonistack instance available with the change from this branch available at http://162.213.35.95:5240/clusters/ (access it with the usual credentials).

On the face if it, this branch appears to be huge but please consider that a lot of it is tests/code being simply moved.

Things that where brought up by people during the design phase and that I left for later (because I wanted to get the main change landed first):
- Add a notification in the 'Clusters' tab when a MAAS instance contains clusters that need attention.
- Add a warning for pending/rejected clusters that are linked to nodes.

To post a comment you must log in.
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Wonderful! And of course we've gone over the design in depth already.

Unfortunately I don't have time for a full review right now; I'll just do what I can and hit the button when I run out of time.

.

A nitpick: why not make status_link an OrderedDict?

.

Might it help clarity to say in get_view_title's docstring that the title basically implements tabs?

.

Also in get_view_title, line 401 of the diff:

                title_chunk = '<a href=%s>%s</a>' % (link, title_chunk)

Shouldn't the value for the href attribute be quoted? This may be a consequence of the method being slightly too big for comfortable detailed testing.

.

It may be helpful, therefore, to extract get_view_title's loop body: “make me a title entry for this category of cluster.” Then get_view_title would just

        return mark_safe(
            ' / '.join(
                self.make_title_entry(status, link_name))
                for status, link_name in self.status_links)

(Of course if status_links were an OrderedDict, then the new function could just take a status argument and look up the link name for itself.)

.

In get_context_data, lines 410—411 of the diff:

        # Do no display a warning for each cluster for which the images
        # are missing unless this is the display of the 'accepted' clusters.

There's a typo in the “not,” but more importantly, this could be simpler without the double negation. How about "Display warnings for clusters that have no images, but only for the display of 'accepted' clusters”?

.

In ClusterListView.post, since each stanza is meant to return, I wonder if it might be clearer to structure the ‘if’ blocks as if/elif blocks. The final return could do with a comment.

.

I'm not too comfortable about the bulk accept/reject buttons applying to all pending clusters... somebody could attempt to register a malicious cluster after the admin loaded the page, but before they hit the “accept all” button. Or conversely, though less harmfully I guess, a good cluster could present itself after the admin loaded the page but just before they hit the “reject all” button.

.

The scenarios list for ClusterListingTest is pretty daunting... why not compute the ‘url’ value in a method? The definition in the scenarios list looks completely regular.

.

In test_listing_is_paginated you use self.assertEquals(1, len(...)). I *finally* bothered to check: testtools has a HasLength matcher! Might make for nicer test output.

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

> Wonderful! And of course we've gone over the design in depth already.

Thanks a lot for the review!.

> [...]
>
> Also in get_view_title, line 401 of the diff:
>
> title_chunk = '<a href=%s>%s</a>' % (link, title_chunk)
>
> Shouldn't the value for the href attribute be quoted? This may be a
> consequence of the method being slightly too big for comfortable detailed
> testing.

That's correct, I'm splitting that method into smaller bit now.

> I'm not too comfortable about the bulk accept/reject buttons applying to all
> pending clusters... somebody could attempt to register a malicious cluster
> after the admin loaded the page, but before they hit the “accept all” button.
> Or conversely, though less harmfully I guess, a good cluster could present
> itself after the admin loaded the page but just before they hit the “reject
> all” button.

It's a bit dangerous indeed. This is not something this branch changed though. Let's fix this later.

> [...]

All your other remarks: done.

Thanks again.

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

> This may be a consequence of the method being slightly too big for comfortable detailed testing.

All done now. I've added a new utility method which is unit-tested.

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

The change in the man page is a good catch! I just wrote that, and I hadn't made the connection. In general, thanks for the changes you've made. I know I'm being difficult as always. :) I'll make up for that by letting you have your way with the en-masse accept/reject actions.

In no-bootimages-warning.html, pre-existing text says “cluster's listing page” — should that apostrophe be there?

Could you give make_title_entry a quick docstring? Doesn't have to be much, just enough to place it in context.

In the ‘post’ method, maybe move the comments about what case each ‘if’ block handles, into the block? You'd get something like:

        if 'mass_accept_submit' in request.POST:
            # Accept clusters en masse.
            number = NodeGroup.objects.accept_all_pending()
            messages.info(request, "Accepted %d cluster(s)." % number)
            return HttpResponseRedirect(reverse('cluster-list'))

This way the if/elif/else structure comes out more clearly, because those statements are the only thing at their indentation level. It also means that the reader doesn't have to add a mental “if applicable” proviso to each comment. Once you're past the ‘if,’ the situation is clear and unambiguous.

Finally, I don't actually see the unit tests for make_title_entry. Missing commit? Not pushed? Diff not updated..?

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

> The change in the man page is a good catch! I just wrote that, and I hadn't
> made the connection. In general, thanks for the changes you've made. I know
> I'm being difficult as always. :) I'll make up for that by letting you have
> your way with the en-masse accept/reject actions.
>
> In no-bootimages-warning.html, pre-existing text says “cluster's listing page”
> — should that apostrophe be there?

Right, changed to "Cluster listing page."

> Could you give make_title_entry a quick docstring? Doesn't have to be much,
> just enough to place it in context.

Done.

> In the ‘post’ method, maybe move the comments about what case each ‘if’ block
> handles, into the block? You'd get something like:
>
> if 'mass_accept_submit' in request.POST:
> # Accept clusters en masse.
> number = NodeGroup.objects.accept_all_pending()
> messages.info(request, "Accepted %d cluster(s)." % number)
> return HttpResponseRedirect(reverse('cluster-list'))

Good point, done.

> Finally, I don't actually see the unit tests for make_title_entry. Missing
> commit? Not pushed? Diff not updated..?

I guess I forgot to push the revision, done now.

Thanks again for the review.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'docs/man/maas-import-pxe-files.8.rst'
2--- docs/man/maas-import-pxe-files.8.rst 2014-04-01 07:59:47 +0000
3+++ docs/man/maas-import-pxe-files.8.rst 2014-04-03 11:20:25 +0000
4@@ -20,7 +20,7 @@
5 An easier way to run the script is to trigger it from the MAAS web user
6 interface. To do that, log in to your MAAS as an administrator using a
7 web browser, click the cogwheel icon in the top right of the page to go
8-to the Settings page, and click "Import boot images." This will start
9+to the Clusters page, and click "Import boot images." This will start
10 imports on all cluster controllers simultaneously. The same thing can
11 also be done through the region-controller API, or through the
12 command-line interface.
13
14=== modified file 'man/maas-import-pxe-files.8'
15--- man/maas-import-pxe-files.8 2014-04-01 07:59:47 +0000
16+++ man/maas-import-pxe-files.8 2014-04-03 11:20:25 +0000
17@@ -1,6 +1,6 @@
18 .\" Man page generated from reStructuredText.
19 .
20-.TH "MAAS-IMPORT-PXE-FILES" "8" "April 01, 2014" "1.5" "MAAS"
21+.TH "MAAS-IMPORT-PXE-FILES" "8" "April 03, 2014" "1.5" "MAAS"
22 .SH NAME
23 maas-import-pxe-files \- MAAS helper script
24 .
25@@ -46,7 +46,7 @@
26 An easier way to run the script is to trigger it from the MAAS web user
27 interface. To do that, log in to your MAAS as an administrator using a
28 web browser, click the cogwheel icon in the top right of the page to go
29-to the Settings page, and click "Import boot images." This will start
30+to the Clusters page, and click "Import boot images." This will start
31 imports on all cluster controllers simultaneously. The same thing can
32 also be done through the region\-controller API, or through the
33 command\-line interface.
34
35=== modified file 'src/maasserver/api.py'
36--- src/maasserver/api.py 2014-03-27 17:24:02 +0000
37+++ src/maasserver/api.py 2014-04-03 11:20:25 +0000
38@@ -2492,8 +2492,7 @@
39 nodegroups_without_images = nodegroups_without_images.filter(
40 status=NODEGROUP_STATUS.ACCEPTED)
41 if nodegroups_without_images.exists():
42- accepted_clusters_url = (
43- "%s#accepted-clusters" % absolute_reverse("settings"))
44+ accepted_clusters_url = absolute_reverse("cluster-list")
45 warning = dedent("""\
46 Some cluster controllers are missing boot images. Either the
47 import task has not been initiated (for each cluster, the task
48
49=== modified file 'src/maasserver/templates/maasserver/base.html'
50--- src/maasserver/templates/maasserver/base.html 2014-02-25 09:57:24 +0000
51+++ src/maasserver/templates/maasserver/base.html 2014-04-03 11:20:25 +0000
52@@ -72,6 +72,11 @@
53 <li class="{% block nav-active-node-list %}{% endblock %}">
54 <a href="{% url 'node-list' %}">Nodes</a>
55 </li>
56+ {% if user.is_superuser %}
57+ <li class="{% block nav-active-cluster-list %}{% endblock %}">
58+ <a href="{% url 'cluster-list' %}">Clusters</a>
59+ </li>
60+ {% endif %}
61 <li class="{% block nav-active-zone-list %}{% endblock %}">
62 <a href="{% url 'zone-list' %}">Zones</a>
63 </li>
64
65=== renamed file 'src/maasserver/templates/maasserver/settings_cluster_listing.html' => 'src/maasserver/templates/maasserver/cluster_listing.html'
66--- src/maasserver/templates/maasserver/settings_cluster_listing.html 2012-11-07 10:39:19 +0000
67+++ src/maasserver/templates/maasserver/cluster_listing.html 2014-04-03 11:20:25 +0000
68@@ -1,58 +1,54 @@
69-<h2 id="accepted-clusters">Cluster controllers</h2>
70-<table class="list" id="accepted-clusters-list">
71- <tbody>
72- {% for cluster in accepted_clusters %}
73- {% cycle 'even' 'odd' as cycle silent %}
74- {% include "maasserver/settings_cluster_listing_row.html" with cycle=cycle %}
75- {% endfor %}
76- </tbody>
77-</table>
78-<div>
79- <form method="POST"
80- action="{% url 'settings' %}">
81- {% csrf_token %}
82- <input type="hidden" name="import_all_boot_images" value="1" />
83- <input type="submit" class="button right" value="Import boot images" />
84- </form>
85-</div>
86-<div class="clear"></div>
87-{% if pending_clusters %}
88-<h3 id="pending-clusters">Pending clusters</h3>
89-<table class="list" id="pending-clusters-list">
90- <tbody>
91- {% for cluster in pending_clusters %}
92- {% cycle 'even' 'odd' as cycle silent %}
93- {% include "maasserver/settings_cluster_listing_row.html" with cycle=cycle %}
94- {% endfor %}
95- </tbody>
96-</table>
97-<div>
98- <form id="reject_all_pending_nodegroups"
99- method="POST"
100- action="{% url 'settings' %}">
101- {% csrf_token %}
102- <input type="hidden" name="mass_accept_submit" value="1" />
103- <input type="submit" class="button right" value="Accept all" />
104- </form>
105- <form id="accept_all_pending_nodegroups"
106- method="POST"
107- action="{% url 'settings' %}">
108- {% csrf_token %}
109- <input type="hidden" name="mass_reject_submit" value="1" />
110- <input type="submit" class="button right space-right-small"
111- value="Reject all" />
112- </form>
113-</div>
114-<div class="clear"></div>
115-{% endif %}
116-{% if rejected_clusters %}
117-<h3 id="rejected-clusters">Rejected clusters</h3>
118-<table class="list" id="rejected-clusters-list">
119- <tbody>
120- {% for cluster in rejected_clusters %}
121- {% cycle 'even' 'odd' as cycle silent %}
122- {% include "maasserver/settings_cluster_listing_row.html" with cycle=cycle %}
123- {% endfor %}
124- </tbody>
125-</table>
126-{% endif %}
127+{% extends "maasserver/base.html" %}
128+
129+{% block nav-active-cluster-list %}active{% endblock %}
130+{% block title %}{{ status_name }} clusters{% endblock %}
131+{% block page-title %}{{ current_count }}{% if input_query %} matching{% endif %} {{ status_name|lower }} cluster{{ current_count|pluralize }} in {% include "maasserver/site_title.html" %}{% endblock %}
132+{% block site-switcher %}{% endblock %}
133+{% block header-search %}{% endblock %}
134+
135+{% block content %}
136+<div id="clusters" style="position: relative;">
137+
138+<h2 id="clusters">{{ title }}</h2>
139+<table class="list" id="clusters-list">
140+ {% include "maasserver/cluster_listing_head.html" %}
141+ <tbody>
142+ {% for cluster in cluster_list %}
143+ {% cycle 'even' 'odd' as cycle silent %}
144+ {% include "maasserver/cluster_listing_row.html" with cycle=cycle warning_no_images=warn_no_images %}
145+ {% endfor %}
146+ </tbody>
147+</table>
148+{% include "maasserver/pagination.html" %}
149+<div>
150+ {% if status == statuses.ACCEPTED %}
151+ <form method="POST"
152+ action="{% url 'cluster-list' %}">
153+ {% csrf_token %}
154+ <input type="hidden" name="import_all_boot_images" value="1" />
155+ <input type="submit" class="button right" value="Import boot images" />
156+ </form>
157+ {% endif %}
158+ {% if status == statuses.PENDING %}
159+ <form id="reject_all_pending_nodegroups"
160+ method="POST"
161+ action="{% url 'cluster-list' %}">
162+ {% csrf_token %}
163+ <input type="hidden" name="mass_accept_submit" value="1" />
164+ <input type="submit" class="button right" value="Accept all" />
165+ </form>
166+ <form id="accept_all_pending_nodegroups"
167+ method="POST"
168+ action="{% url 'cluster-list' %}">
169+ {% csrf_token %}
170+ <input type="hidden" name="mass_reject_submit" value="1" />
171+ <input type="submit" class="button right space-right-small"
172+ value="Reject all" />
173+ </form>
174+ {% endif %}
175+
176+</div>
177+<div class="clear"></div>
178+
179+
180+{% endblock %}
181\ No newline at end of file
182
183=== added file 'src/maasserver/templates/maasserver/cluster_listing_head.html'
184--- src/maasserver/templates/maasserver/cluster_listing_head.html 1970-01-01 00:00:00 +0000
185+++ src/maasserver/templates/maasserver/cluster_listing_head.html 2014-04-03 11:20:25 +0000
186@@ -0,0 +1,10 @@
187+<thead>
188+ <tr>
189+ <th>Name</th>
190+ <th>Managed interfaces</th>
191+ <th>Boot images</th>
192+ <th>Nodes</th>
193+ {% comment %} Action buttons {% endcomment %}
194+ <th></th>
195+ </tr>
196+</thead>
197\ No newline at end of file
198
199=== renamed file 'src/maasserver/templates/maasserver/settings_cluster_listing_row.html' => 'src/maasserver/templates/maasserver/cluster_listing_row.html'
200--- src/maasserver/templates/maasserver/settings_cluster_listing_row.html 2012-12-06 04:22:51 +0000
201+++ src/maasserver/templates/maasserver/cluster_listing_row.html 2014-04-03 11:20:25 +0000
202@@ -1,11 +1,24 @@
203-<tr class="cluster {{ cycle }}"
204+<tr class="cluster {{ cycle }} {% if warning_no_images and cluster.bootimage_set.count == 0 %}warning{% endif %}"
205 id="{{ cluster.uuid }}">
206 <td>
207- {{ cluster.cluster_name }}
208+ <a href="{% url 'cluster-edit' cluster.uuid %}">{{ cluster.cluster_name }}</a>
209+ </td>
210+ <td>
211+ {{ cluster.get_managed_interfaces|length }}
212+ </td>
213+ <td>
214+ {% if warning_no_images and cluster.bootimage_set.count == 0 %}
215+ 0 <img src="{{ STATIC_URL }}img/warning.png" title="Warning: this cluster has no boot images."/>
216+ {% else %}
217 {% if cluster.bootimage_set.count == 0 %}
218- <span class="warning">(Warning: this cluster has no boot images.)</span>
219+ 0
220+ {% else %}
221+ <a title="View boot images"
222+ href="{% url 'cluster-bootimages-list' cluster.uuid %}">{{ cluster.bootimage_set.count }}</a>
223 {% endif %}
224+ {% endif %}
225 </td>
226+ <td>{{ cluster.node_set.count }}</td>
227 <td class="icon-controls">
228 <a href="{% url 'cluster-edit' cluster.uuid %}"
229 class="icon"
230
231=== modified file 'src/maasserver/templates/maasserver/no-bootimages-warning.html'
232--- src/maasserver/templates/maasserver/no-bootimages-warning.html 2014-03-27 10:51:28 +0000
233+++ src/maasserver/templates/maasserver/no-bootimages-warning.html 2014-04-03 11:20:25 +0000
234@@ -5,5 +5,5 @@
235 <span class="console">/etc/maas/bootresources.yaml</span> on the cluster's
236 machine) suits the configuration of the machines this cluster will have to
237 handle and then run the import script by clicking the "Import boot images"
238- button on the <a href="{% url 'settings' %}#clusters">cluster's listing page</a>.
239+ button on the <a href="{% url 'cluster-list' %}">cluster listing page</a>.
240 </p>
241
242=== modified file 'src/maasserver/templates/maasserver/nodegroup_confirm_delete.html'
243--- src/maasserver/templates/maasserver/nodegroup_confirm_delete.html 2012-10-08 12:50:32 +0000
244+++ src/maasserver/templates/maasserver/nodegroup_confirm_delete.html 2014-04-03 11:20:25 +0000
245@@ -17,7 +17,7 @@
246 <input type="hidden" name="post" value="yes" />
247 <input type="submit" value="Delete cluster controller"
248 class="right" />
249- <a href="{% url 'settings' %}">Cancel</a>
250+ <a href="{% url 'cluster-list' %}">Cancel</a>
251 </form>
252 </p>
253 </div>
254
255=== modified file 'src/maasserver/templates/maasserver/nodegroup_edit.html'
256--- src/maasserver/templates/maasserver/nodegroup_edit.html 2014-03-25 10:35:14 +0000
257+++ src/maasserver/templates/maasserver/nodegroup_edit.html 2014-04-03 11:20:25 +0000
258@@ -15,7 +15,7 @@
259 </ul>
260 <input type="submit" value="Save cluster controller"
261 class="button right" />
262- <a class="link-button" href="{% url 'settings' %}">Cancel</a>
263+ <a class="link-button" href="{% url 'cluster-list' %}">Cancel</a>
264 <h2>Interfaces</h2>
265 {% with nb_interfaces=cluster.nodegroupinterface_set.count %}
266 This cluster controller has {{ nb_interfaces }}
267
268=== modified file 'src/maasserver/templates/maasserver/settings.html'
269--- src/maasserver/templates/maasserver/settings.html 2012-11-30 17:21:28 +0000
270+++ src/maasserver/templates/maasserver/settings.html 2014-04-03 11:20:25 +0000
271@@ -70,11 +70,6 @@
272 <div class="clear"></div>
273 </div>
274 <div class="divider"></div>
275- <div id="clusters" class="block size11 first">
276- {% include "maasserver/settings_cluster_listing.html" %}
277- <div class="clear"></div>
278- </div>
279- <div class="divider"></div>
280 <div id="commissioning_scripts" class="block size11 first">
281 {% include "maasserver/settings_commissioning_scripts.html" %}
282 <div class="clear"></div>
283
284=== modified file 'src/maasserver/tests/test_api.py'
285--- src/maasserver/tests/test_api.py 2014-03-19 13:54:20 +0000
286+++ src/maasserver/tests/test_api.py 2014-04-03 11:20:25 +0000
287@@ -53,6 +53,7 @@
288 from maasserver.testing.oauthclient import OAuthAuthenticatedClient
289 from maasserver.testing.testcase import MAASServerTestCase
290 from maasserver.tests.test_forms import make_interface_settings
291+from maasserver.utils import absolute_reverse
292 from maasserver.utils.orm import get_one
293 from maastesting.djangotestcase import TransactionTestCase
294 from maastesting.matchers import MockCalledOnceWith
295@@ -754,7 +755,8 @@
296 [args[0][0] for args in recorder.call_args_list])
297 # The persistent error message links to the clusters listing.
298 self.assertIn(
299- "/settings/#accepted-clusters", recorder.call_args_list[0][0][1])
300+ absolute_reverse("cluster-list"),
301+ recorder.call_args_list[0][0][1])
302
303 def test_warns_if_any_nodegroup_has_no_images(self):
304 factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED)
305
306=== modified file 'src/maasserver/urls.py'
307--- src/maasserver/urls.py 2014-03-25 10:35:14 +0000
308+++ src/maasserver/urls.py 2014-04-03 11:20:25 +0000
309@@ -21,12 +21,22 @@
310 url,
311 )
312 from django.contrib.auth.decorators import user_passes_test
313+from maasserver.enum import NODEGROUP_STATUS
314 from maasserver.models import Node
315 from maasserver.views import TextTemplateView
316 from maasserver.views.account import (
317 login,
318 logout,
319 )
320+from maasserver.views.clusters import (
321+ BootImagesListView,
322+ ClusterDelete,
323+ ClusterEdit,
324+ ClusterInterfaceCreate,
325+ ClusterInterfaceDelete,
326+ ClusterInterfaceEdit,
327+ ClusterListView,
328+ )
329 from maasserver.views.networks import (
330 NetworkAdd,
331 NetworkDelete,
332@@ -60,14 +70,6 @@
333 AccountsView,
334 settings,
335 )
336-from maasserver.views.settings_clusters import (
337- BootImagesListView,
338- ClusterDelete,
339- ClusterEdit,
340- ClusterInterfaceCreate,
341- ClusterInterfaceDelete,
342- ClusterInterfaceEdit,
343- )
344 from maasserver.views.settings_commissioning_scripts import (
345 CommissioningScriptCreate,
346 CommissioningScriptDelete,
347@@ -116,7 +118,6 @@
348 r'^account/prefs/sshkey/delete/(?P<keyid>\d*)/$',
349 SSHKeyDeleteView.as_view(), name='prefs-delete-sshkey'),
350 )
351-
352 # Logout view.
353 urlpatterns += patterns(
354 'maasserver.views',
355@@ -159,6 +160,18 @@
356 urlpatterns += patterns(
357 'maasserver.views',
358 adminurl(
359+ r'^clusters/$',
360+ ClusterListView.as_view(status=NODEGROUP_STATUS.ACCEPTED),
361+ name='cluster-list'),
362+ adminurl(
363+ r'^clusters/pending/$',
364+ ClusterListView.as_view(status=NODEGROUP_STATUS.PENDING),
365+ name='cluster-list-pending'),
366+ adminurl(
367+ r'^clusters/rejected/$',
368+ ClusterListView.as_view(status=NODEGROUP_STATUS.REJECTED),
369+ name='cluster-list-rejected'),
370+ adminurl(
371 r'^clusters/(?P<uuid>[\w\-]+)/edit/$', ClusterEdit.as_view(),
372 name='cluster-edit'),
373 adminurl(
374
375=== renamed file 'src/maasserver/views/settings_clusters.py' => 'src/maasserver/views/clusters.py'
376--- src/maasserver/views/settings_clusters.py 2014-03-25 10:35:14 +0000
377+++ src/maasserver/views/clusters.py 2014-04-03 11:20:25 +0000
378@@ -1,7 +1,7 @@
379 # Copyright 2012 Canonical Ltd. This software is licensed under the
380 # GNU Affero General Public License version 3 (see the file LICENSE).
381
382-"""Cluster Settings views."""
383+"""Cluster views."""
384
385 from __future__ import (
386 absolute_import,
387@@ -19,17 +19,29 @@
388 "ClusterInterfaceCreate",
389 "ClusterInterfaceDelete",
390 "ClusterInterfaceEdit",
391+ "ClusterListView",
392 ]
393
394+from collections import OrderedDict
395+
396 from django.contrib import messages
397 from django.core.urlresolvers import reverse
398 from django.http import HttpResponseRedirect
399 from django.shortcuts import get_object_or_404
400+from django.utils.safestring import mark_safe
401 from django.views.generic import (
402 CreateView,
403 DeleteView,
404 UpdateView,
405 )
406+from django.views.generic.edit import (
407+ FormMixin,
408+ ProcessFormView,
409+ )
410+from maasserver.enum import (
411+ NODEGROUP_STATUS,
412+ NODEGROUP_STATUS_CHOICES,
413+ )
414 from maasserver.forms import (
415 NodeGroupEdit,
416 NodeGroupInterfaceForm,
417@@ -41,6 +53,99 @@
418 from maasserver.views import PaginatedListView
419
420
421+class ClusterListView(PaginatedListView, FormMixin, ProcessFormView):
422+ template_name = 'maasserver/cluster_listing.html'
423+ context_object_name = "cluster_list"
424+ status = None
425+
426+ def get_queryset(self):
427+ return NodeGroup.objects.filter(
428+ status=self.status).order_by('cluster_name')
429+
430+ # A record of the urls used to reach the clusters of different
431+ # statuses.
432+ status_links = OrderedDict((
433+ (NODEGROUP_STATUS.ACCEPTED, 'cluster-list'),
434+ (NODEGROUP_STATUS.PENDING, 'cluster-list-pending'),
435+ (NODEGROUP_STATUS.REJECTED, 'cluster-list-rejected'),
436+ ))
437+
438+ def make_title_entry(self, status, link_name):
439+ """Generate an entry as used by make_cluster_listing_title().
440+
441+ This is a utility method only used by make_cluster_listing_title.
442+ It is a separate method for clarity and to help testing."""
443+ link = reverse(link_name)
444+ status_name = NODEGROUP_STATUS_CHOICES[status][1]
445+ nb_clusters = NodeGroup.objects.filter(
446+ status=status).count()
447+ entry = "%d %s cluster%s" % (
448+ nb_clusters,
449+ status_name.lower(),
450+ 's' if nb_clusters != 1 else '')
451+ if nb_clusters != 0 and status != self.status:
452+ entry = '<a href="%s">%s</a>' % (link, entry)
453+ return entry
454+
455+ def make_cluster_listing_title(self):
456+ """Generate this view's title with "tabs" for each cluster status.
457+
458+ Generate a title for this view with the number of clusters for each
459+ possible status. The title includes the links to the other listings
460+ (i.e. if this is the listing for the accepted clusters, include links
461+ to the listings of the pending/rejected clusters). The title will be
462+ of the form: "3 accepted clusters / 1 pending cluster / 2 rejected
463+ clusters". (This is simpler to do in the view rather than write this
464+ using the template language.)
465+ """
466+ return mark_safe(
467+ ' / '.join(
468+ self.make_title_entry(status, link_name)
469+ for status, link_name in self.status_links.items()))
470+
471+ def get_context_data(self, **kwargs):
472+ context = super(ClusterListView, self).get_context_data(**kwargs)
473+ context['current_count'] = NodeGroup.objects.filter(
474+ status=self.status).count()
475+ context['title'] = self.make_cluster_listing_title()
476+ # Display warnings for clusters that have no images, but only for the
477+ # display of 'accepted' clusters.
478+ context['warn_no_images'] = self.status == NODEGROUP_STATUS.ACCEPTED
479+ context['status'] = self.status
480+ context['statuses'] = NODEGROUP_STATUS
481+ context['status_name'] = NODEGROUP_STATUS_CHOICES[self.status][1]
482+ return context
483+
484+ def post(self, request, *args, **kwargs):
485+ """Handle a POST request."""
486+ if 'mass_accept_submit' in request.POST:
487+ # Process accept clusters en masse.
488+ number = NodeGroup.objects.accept_all_pending()
489+ messages.info(request, "Accepted %d cluster(s)." % number)
490+ return HttpResponseRedirect(reverse('cluster-list'))
491+
492+ elif 'mass_reject_submit' in request.POST:
493+ # Process reject clusters en masse.
494+ number = NodeGroup.objects.reject_all_pending()
495+ messages.info(request, "Rejected %d cluster(s)." % number)
496+ return HttpResponseRedirect(reverse('cluster-list'))
497+
498+ elif 'import_all_boot_images' in request.POST:
499+ # Import PXE files for all the accepted clusters.
500+ NodeGroup.objects.import_boot_images_accepted_clusters()
501+ message = (
502+ "Import of boot images started on all cluster controllers. "
503+ "Importing the boot images can take a long time depending on "
504+ "the available bandwidth.")
505+ messages.info(request, message)
506+ return HttpResponseRedirect(reverse('cluster-list'))
507+
508+ else:
509+ # Unknown action: redirect to the cluster listing page (this
510+ # shouldn't happen).
511+ return HttpResponseRedirect(reverse('cluster-list'))
512+
513+
514 class ClusterEdit(UpdateView):
515 model = NodeGroup
516 template_name = 'maasserver/nodegroup_edit.html'
517@@ -54,7 +159,7 @@
518 return context
519
520 def get_success_url(self):
521- return reverse('settings')
522+ return reverse('cluster-list')
523
524 def get_object(self):
525 uuid = self.kwargs.get('uuid', None)
526@@ -75,7 +180,7 @@
527 return get_object_or_404(NodeGroup, uuid=uuid)
528
529 def get_next_url(self):
530- return reverse('settings')
531+ return reverse('cluster-list')
532
533 def delete(self, request, *args, **kwargs):
534 cluster = self.get_object()
535
536=== modified file 'src/maasserver/views/settings.py'
537--- src/maasserver/views/settings.py 2013-10-07 09:12:40 +0000
538+++ src/maasserver/views/settings.py 2014-04-03 11:20:25 +0000
539@@ -38,7 +38,6 @@
540 from django.views.generic.base import TemplateView
541 from django.views.generic.detail import SingleObjectTemplateResponseMixin
542 from django.views.generic.edit import ModelFormMixin
543-from maasserver.enum import NODEGROUP_STATUS
544 from maasserver.exceptions import CannotDeleteUserException
545 from maasserver.forms import (
546 CommissioningForm,
547@@ -48,10 +47,7 @@
548 NewUserCreationForm,
549 UbuntuForm,
550 )
551-from maasserver.models import (
552- NodeGroup,
553- UserProfile,
554- )
555+from maasserver.models import UserProfile
556 from maasserver.views import process_form
557 from metadataserver.models import CommissioningScript
558
559@@ -186,36 +182,6 @@
560 if response is not None:
561 return response
562
563- # Process accept clusters en masse.
564- if 'mass_accept_submit' in request.POST:
565- number = NodeGroup.objects.accept_all_pending()
566- messages.info(request, "Accepted %d cluster(s)." % number)
567- return HttpResponseRedirect(reverse('settings'))
568-
569- # Process reject clusters en masse.
570- if 'mass_reject_submit' in request.POST:
571- number = NodeGroup.objects.reject_all_pending()
572- messages.info(request, "Rejected %d cluster(s)." % number)
573- return HttpResponseRedirect(reverse('settings'))
574-
575- # Import PXE files for all the accepted clusters.
576- if 'import_all_boot_images' in request.POST:
577- NodeGroup.objects.import_boot_images_accepted_clusters()
578- message = (
579- "Import of boot images started on all cluster controllers. "
580- "Importing the boot images can take a long time depending on "
581- "the available bandwidth.")
582- messages.info(request, message)
583- return HttpResponseRedirect(reverse('settings'))
584-
585- # Cluster listings.
586- accepted_clusters = NodeGroup.objects.filter(
587- status=NODEGROUP_STATUS.ACCEPTED).order_by('cluster_name')
588- pending_clusters = NodeGroup.objects.filter(
589- status=NODEGROUP_STATUS.PENDING).order_by('cluster_name')
590- rejected_clusters = NodeGroup.objects.filter(
591- status=NODEGROUP_STATUS.REJECTED).order_by('cluster_name')
592-
593 # Commissioning scripts.
594 commissioning_scripts = CommissioningScript.objects.all()
595
596@@ -224,9 +190,6 @@
597 {
598 'user_list': user_list,
599 'commissioning_scripts': commissioning_scripts,
600- 'accepted_clusters': accepted_clusters,
601- 'pending_clusters': pending_clusters,
602- 'rejected_clusters': rejected_clusters,
603 'maas_and_network_form': maas_and_network_form,
604 'commissioning_form': commissioning_form,
605 'ubuntu_form': ubuntu_form,
606
607=== modified file 'src/maasserver/views/tests/test_boot_image_list.py'
608--- src/maasserver/views/tests/test_boot_image_list.py 2014-03-27 07:39:38 +0000
609+++ src/maasserver/views/tests/test_boot_image_list.py 2014-04-03 11:20:25 +0000
610@@ -21,7 +21,7 @@
611 from lxml.html import fromstring
612 from maasserver.testing.factory import factory
613 from maasserver.testing.testcase import MAASServerTestCase
614-from maasserver.views.settings_clusters import BootImagesListView
615+from maasserver.views.clusters import BootImagesListView
616 from testtools.matchers import ContainsAll
617
618
619
620=== renamed file 'src/maasserver/views/tests/test_settings_clusters.py' => 'src/maasserver/views/tests/test_clusters.py'
621--- src/maasserver/views/tests/test_settings_clusters.py 2014-03-25 12:53:32 +0000
622+++ src/maasserver/views/tests/test_clusters.py 2014-04-03 11:20:25 +0000
623@@ -20,10 +20,12 @@
624 from lxml.html import fromstring
625 from maasserver.enum import (
626 NODEGROUP_STATUS,
627+ NODEGROUP_STATUS_CHOICES,
628 NODEGROUPINTERFACE_MANAGEMENT,
629 )
630 from maasserver.models import (
631 NodeGroup,
632+ nodegroup as nodegroup_module,
633 NodeGroupInterface,
634 )
635 from maasserver.testing import (
636@@ -33,25 +35,42 @@
637 )
638 from maasserver.testing.factory import factory
639 from maasserver.testing.testcase import MAASServerTestCase
640+from maasserver.utils import map_enum
641+from maasserver.views.clusters import ClusterListView
642+from mock import (
643+ ANY,
644+ call,
645+ )
646 from testtools.matchers import (
647 AllMatch,
648 Contains,
649 ContainsAll,
650 Equals,
651+ HasLength,
652 MatchesStructure,
653 )
654
655
656 class ClusterListingTest(MAASServerTestCase):
657
658- def test_settings_contains_links_to_edit_and_delete_clusters(self):
659+ scenarios = [
660+ ('accepted-clusters', {'status': NODEGROUP_STATUS.ACCEPTED}),
661+ ('pending-clusters', {'status': NODEGROUP_STATUS.PENDING}),
662+ ('rejected-clusters', {'status': NODEGROUP_STATUS.REJECTED}),
663+ ]
664+
665+ def get_url(self):
666+ """Return the listing url used in this scenario."""
667+ return reverse(ClusterListView.status_links[
668+ self.status])
669+
670+ def test_cluster_listing_contains_links_to_manipulate_clusters(self):
671 self.client_log_in(as_admin=True)
672 nodegroups = {
673- factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED),
674- factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
675- factory.make_node_group(status=NODEGROUP_STATUS.REJECTED),
676+ factory.make_node_group(status=self.status)
677+ for _ in range(3)
678 }
679- links = get_content_links(self.client.get(reverse('settings')))
680+ links = get_content_links(self.client.get(self.get_url()))
681 nodegroup_edit_links = [
682 reverse('cluster-edit', args=[nodegroup.uuid])
683 for nodegroup in nodegroups]
684@@ -62,6 +81,166 @@
685 links,
686 ContainsAll(nodegroup_edit_links + nodegroup_delete_links))
687
688+ def make_listing_view(self, status):
689+ view = ClusterListView()
690+ view.status = status
691+ return view
692+
693+ def test_make_title_entry_returns_link_for_other_status(self):
694+ # If the entry's status is different from the view's status,
695+ # the returned entry is a link.
696+ other_status = factory.getRandomChoice(
697+ NODEGROUP_STATUS_CHOICES, but_not=[self.status])
698+ factory.make_node_group(status=other_status)
699+ link_name = ClusterListView.status_links[other_status]
700+ view = self.make_listing_view(self.status)
701+ entry = view.make_title_entry(other_status, link_name)
702+ status_name = NODEGROUP_STATUS_CHOICES[other_status][1]
703+ self.assertEqual(
704+ '<a href="%s">1 %s cluster</a>' % (
705+ reverse(link_name), status_name.lower()),
706+ entry)
707+
708+ def test_make_title_entry_returns_title_if_no_cluster(self):
709+ # If no cluster correspond to the entry's status, the returned
710+ # entry is not a link: it's a simple mention '0 <status> clusters'.
711+ other_status = factory.getRandomChoice(
712+ NODEGROUP_STATUS_CHOICES, but_not=[self.status])
713+ link_name = ClusterListView.status_links[other_status]
714+ view = self.make_listing_view(self.status)
715+ entry = view.make_title_entry(other_status, link_name)
716+ status_name = NODEGROUP_STATUS_CHOICES[other_status][1]
717+ self.assertEqual(
718+ '0 %s clusters' % status_name.lower(), entry)
719+
720+ def test_title_displays_number_of_clusters(self):
721+ for _ in range(3):
722+ factory.make_node_group(status=self.status)
723+ view = self.make_listing_view(self.status)
724+ status_name = NODEGROUP_STATUS_CHOICES[self.status][1]
725+ title = view.make_cluster_listing_title()
726+ self.assertIn("3 %s clusters" % status_name.lower(), title)
727+
728+ def test_title_contains_links_to_other_listings(self):
729+ view = self.make_listing_view(self.status)
730+ other_statuses = []
731+ # Compute a list with the statuses of the clusters not being
732+ # displayed by the 'view'. Create clusters with these statuses.
733+ for status in map_enum(NODEGROUP_STATUS).values():
734+ if status != self.status:
735+ other_statuses.append(status)
736+ factory.make_node_group(status=status)
737+ for status in other_statuses:
738+ link_name = ClusterListView.status_links[status]
739+ title = view.make_cluster_listing_title()
740+ self.assertIn(reverse(link_name), title)
741+
742+ def test_listing_is_paginated(self):
743+ self.patch(ClusterListView, "paginate_by", 2)
744+ self.client_log_in(as_admin=True)
745+ for _ in range(3):
746+ factory.make_node_group(status=self.status)
747+ response = self.client.get(self.get_url())
748+ self.assertEqual(httplib.OK, response.status_code)
749+ doc = fromstring(response.content)
750+ self.assertThat(
751+ doc.cssselect('div.pagination'),
752+ HasLength(1),
753+ "Couldn't find pagination tag.")
754+
755+
756+class ClusterListingAccess(MAASServerTestCase):
757+
758+ def test_admin_sees_cluster_tab(self):
759+ self.client_log_in(as_admin=True)
760+ links = get_content_links(
761+ self.client.get(reverse('index')), element='#main-nav')
762+ self.assertIn(reverse('cluster-list'), links)
763+
764+ def test_non_admin_doesnt_see_cluster_tab(self):
765+ self.client_log_in(as_admin=False)
766+ links = get_content_links(
767+ self.client.get(reverse('index')), element='#main-nav')
768+ self.assertNotIn(reverse('cluster-list'), links)
769+
770+
771+class ClusterPendingListingTest(MAASServerTestCase):
772+
773+ def test_pending_listing_contains_form_to_accept_all_nodegroups(self):
774+ self.client_log_in(as_admin=True)
775+ factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
776+ response = self.client.get(reverse('cluster-list-pending'))
777+ doc = fromstring(response.content)
778+ forms = doc.cssselect('form#accept_all_pending_nodegroups')
779+ self.assertEqual(1, len(forms))
780+
781+ def test_pending_listing_contains_form_to_reject_all_nodegroups(self):
782+ self.client_log_in(as_admin=True)
783+ factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
784+ response = self.client.get(reverse('cluster-list-pending'))
785+ doc = fromstring(response.content)
786+ forms = doc.cssselect('form#reject_all_pending_nodegroups')
787+ self.assertEqual(1, len(forms))
788+
789+ def test_pending_listing_accepts_all_pending_nodegroups_POST(self):
790+ self.client_log_in(as_admin=True)
791+ nodegroups = {
792+ factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
793+ factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
794+ }
795+ response = self.client.post(
796+ reverse('cluster-list-pending'), {'mass_accept_submit': 1})
797+ self.assertEqual(httplib.FOUND, response.status_code)
798+ self.assertEqual(
799+ [reload_object(nodegroup).status for nodegroup in nodegroups],
800+ [NODEGROUP_STATUS.ACCEPTED] * 2)
801+
802+ def test_pending_listing_rejects_all_pending_nodegroups_POST(self):
803+ self.client_log_in(as_admin=True)
804+ nodegroups = {
805+ factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
806+ factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
807+ }
808+ response = self.client.post(
809+ reverse('cluster-list-pending'), {'mass_reject_submit': 1})
810+ self.assertEqual(httplib.FOUND, response.status_code)
811+ self.assertEqual(
812+ [reload_object(nodegroup).status for nodegroup in nodegroups],
813+ [NODEGROUP_STATUS.REJECTED] * 2)
814+
815+
816+class ClusterAcceptedListingTest(MAASServerTestCase):
817+
818+ def test_accepted_listing_import_boot_images_calls_tasks(self):
819+ self.client_log_in(as_admin=True)
820+ recorder = self.patch(nodegroup_module, 'import_boot_images')
821+ accepted_nodegroups = [
822+ factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED),
823+ factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED),
824+ ]
825+ response = self.client.post(
826+ reverse('cluster-list'), {'import_all_boot_images': 1})
827+ self.assertEqual(httplib.FOUND, response.status_code)
828+ calls = [
829+ call(queue=nodegroup.work_queue, kwargs=ANY)
830+ for nodegroup in accepted_nodegroups
831+ ]
832+ self.assertItemsEqual(calls, recorder.apply_async.call_args_list)
833+
834+ def test_a_warning_is_displayed_if_the_cluster_has_no_boot_images(self):
835+ self.client_log_in(as_admin=True)
836+ nodegroup = factory.make_node_group(
837+ status=NODEGROUP_STATUS.ACCEPTED)
838+ response = self.client.get(reverse('cluster-list'))
839+ document = fromstring(response.content)
840+ nodegroup_row = document.xpath("//tr[@id='%s']" % nodegroup.uuid)[0]
841+ self.assertIn('warning', nodegroup_row.get('class'))
842+ warning_elems = (
843+ nodegroup_row.xpath(
844+ "//img[@title='Warning: this cluster has no boot images.']"))
845+ self.assertEqual(
846+ 1, len(warning_elems), "No warning about missing boot images.")
847+
848
849 class ClusterDeleteTest(MAASServerTestCase):
850
851@@ -71,7 +250,7 @@
852 delete_link = reverse('cluster-delete', args=[nodegroup.uuid])
853 response = self.client.post(delete_link, {'post': 'yes'})
854 self.assertEqual(
855- (httplib.FOUND, reverse('settings')),
856+ (httplib.FOUND, reverse('cluster-list')),
857 (response.status_code, extract_redirect(response)))
858 self.assertFalse(
859 NodeGroup.objects.filter(uuid=nodegroup.uuid).exists())
860
861=== modified file 'src/maasserver/views/tests/test_settings.py'
862--- src/maasserver/views/tests/test_settings.py 2014-04-01 06:50:01 +0000
863+++ src/maasserver/views/tests/test_settings.py 2014-04-03 11:20:25 +0000
864@@ -23,11 +23,9 @@
865 from maasserver.enum import (
866 COMMISSIONING_DISTRO_SERIES_CHOICES,
867 DISTRO_SERIES,
868- NODEGROUP_STATUS,
869 )
870 from maasserver.models import (
871 Config,
872- nodegroup as nodegroup_module,
873 UserProfile,
874 )
875 from maasserver.testing import (
876@@ -37,10 +35,6 @@
877 )
878 from maasserver.testing.factory import factory
879 from maasserver.testing.testcase import MAASServerTestCase
880-from mock import (
881- ANY,
882- call,
883- )
884
885
886 class SettingsTest(MAASServerTestCase):
887@@ -185,73 +179,6 @@
888 new_kernel_opts,
889 Config.objects.get_config('kernel_opts'))
890
891- def test_settings_contains_form_to_accept_all_nodegroups(self):
892- self.client_log_in(as_admin=True)
893- factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
894- response = self.client.get(reverse('settings'))
895- doc = fromstring(response.content)
896- forms = doc.cssselect('form#accept_all_pending_nodegroups')
897- self.assertEqual(1, len(forms))
898-
899- def test_settings_contains_form_to_reject_all_nodegroups(self):
900- self.client_log_in(as_admin=True)
901- factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
902- response = self.client.get(reverse('settings'))
903- doc = fromstring(response.content)
904- forms = doc.cssselect('form#reject_all_pending_nodegroups')
905- self.assertEqual(1, len(forms))
906-
907- def test_settings_accepts_all_pending_nodegroups_POST(self):
908- self.client_log_in(as_admin=True)
909- nodegroups = {
910- factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
911- factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
912- }
913- response = self.client.post(
914- reverse('settings'), {'mass_accept_submit': 1})
915- self.assertEqual(httplib.FOUND, response.status_code)
916- self.assertEqual(
917- [reload_object(nodegroup).status for nodegroup in nodegroups],
918- [NODEGROUP_STATUS.ACCEPTED] * 2)
919-
920- def test_settings_rejects_all_pending_nodegroups_POST(self):
921- self.client_log_in(as_admin=True)
922- nodegroups = {
923- factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
924- factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
925- }
926- response = self.client.post(
927- reverse('settings'), {'mass_reject_submit': 1})
928- self.assertEqual(httplib.FOUND, response.status_code)
929- self.assertEqual(
930- [reload_object(nodegroup).status for nodegroup in nodegroups],
931- [NODEGROUP_STATUS.REJECTED] * 2)
932-
933- def test_settings_import_boot_images_calls_tasks(self):
934- self.client_log_in(as_admin=True)
935- recorder = self.patch(nodegroup_module, 'import_boot_images')
936- accepted_nodegroups = [
937- factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED),
938- factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED),
939- ]
940- response = self.client.post(
941- reverse('settings'), {'import_all_boot_images': 1})
942- self.assertEqual(httplib.FOUND, response.status_code)
943- calls = [
944- call(queue=nodegroup.work_queue, kwargs=ANY)
945- for nodegroup in accepted_nodegroups
946- ]
947- self.assertItemsEqual(calls, recorder.apply_async.call_args_list)
948-
949- def test_cluster_no_boot_images_message_displayed_if_no_boot_images(self):
950- self.client_log_in(as_admin=True)
951- nodegroup = factory.make_node_group(
952- status=NODEGROUP_STATUS.ACCEPTED)
953- response = self.client.get(reverse('settings'))
954- document = fromstring(response.content)
955- nodegroup_row = document.xpath("//tr[@id='%s']" % nodegroup.uuid)[0]
956- self.assertIn('no boot images', nodegroup_row.text_content())
957-
958
959 class NonAdminSettingsTest(MAASServerTestCase):
960