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
=== modified file 'docs/man/maas-import-pxe-files.8.rst'
--- docs/man/maas-import-pxe-files.8.rst 2014-04-01 07:59:47 +0000
+++ docs/man/maas-import-pxe-files.8.rst 2014-04-03 11:20:25 +0000
@@ -20,7 +20,7 @@
20An easier way to run the script is to trigger it from the MAAS web user20An easier way to run the script is to trigger it from the MAAS web user
21interface. To do that, log in to your MAAS as an administrator using a21interface. To do that, log in to your MAAS as an administrator using a
22web browser, click the cogwheel icon in the top right of the page to go22web browser, click the cogwheel icon in the top right of the page to go
23to the Settings page, and click "Import boot images." This will start23to the Clusters page, and click "Import boot images." This will start
24imports on all cluster controllers simultaneously. The same thing can24imports on all cluster controllers simultaneously. The same thing can
25also be done through the region-controller API, or through the25also be done through the region-controller API, or through the
26command-line interface.26command-line interface.
2727
=== modified file 'man/maas-import-pxe-files.8'
--- man/maas-import-pxe-files.8 2014-04-01 07:59:47 +0000
+++ man/maas-import-pxe-files.8 2014-04-03 11:20:25 +0000
@@ -1,6 +1,6 @@
1.\" Man page generated from reStructuredText.1.\" Man page generated from reStructuredText.
2.2.
3.TH "MAAS-IMPORT-PXE-FILES" "8" "April 01, 2014" "1.5" "MAAS"3.TH "MAAS-IMPORT-PXE-FILES" "8" "April 03, 2014" "1.5" "MAAS"
4.SH NAME4.SH NAME
5maas-import-pxe-files \- MAAS helper script5maas-import-pxe-files \- MAAS helper script
6.6.
@@ -46,7 +46,7 @@
46An easier way to run the script is to trigger it from the MAAS web user46An easier way to run the script is to trigger it from the MAAS web user
47interface. To do that, log in to your MAAS as an administrator using a47interface. To do that, log in to your MAAS as an administrator using a
48web browser, click the cogwheel icon in the top right of the page to go48web browser, click the cogwheel icon in the top right of the page to go
49to the Settings page, and click "Import boot images." This will start49to the Clusters page, and click "Import boot images." This will start
50imports on all cluster controllers simultaneously. The same thing can50imports on all cluster controllers simultaneously. The same thing can
51also be done through the region\-controller API, or through the51also be done through the region\-controller API, or through the
52command\-line interface.52command\-line interface.
5353
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py 2014-03-27 17:24:02 +0000
+++ src/maasserver/api.py 2014-04-03 11:20:25 +0000
@@ -2492,8 +2492,7 @@
2492 nodegroups_without_images = nodegroups_without_images.filter(2492 nodegroups_without_images = nodegroups_without_images.filter(
2493 status=NODEGROUP_STATUS.ACCEPTED)2493 status=NODEGROUP_STATUS.ACCEPTED)
2494 if nodegroups_without_images.exists():2494 if nodegroups_without_images.exists():
2495 accepted_clusters_url = (2495 accepted_clusters_url = absolute_reverse("cluster-list")
2496 "%s#accepted-clusters" % absolute_reverse("settings"))
2497 warning = dedent("""\2496 warning = dedent("""\
2498 Some cluster controllers are missing boot images. Either the2497 Some cluster controllers are missing boot images. Either the
2499 import task has not been initiated (for each cluster, the task2498 import task has not been initiated (for each cluster, the task
25002499
=== modified file 'src/maasserver/templates/maasserver/base.html'
--- src/maasserver/templates/maasserver/base.html 2014-02-25 09:57:24 +0000
+++ src/maasserver/templates/maasserver/base.html 2014-04-03 11:20:25 +0000
@@ -72,6 +72,11 @@
72 <li class="{% block nav-active-node-list %}{% endblock %}">72 <li class="{% block nav-active-node-list %}{% endblock %}">
73 <a href="{% url 'node-list' %}">Nodes</a>73 <a href="{% url 'node-list' %}">Nodes</a>
74 </li>74 </li>
75 {% if user.is_superuser %}
76 <li class="{% block nav-active-cluster-list %}{% endblock %}">
77 <a href="{% url 'cluster-list' %}">Clusters</a>
78 </li>
79 {% endif %}
75 <li class="{% block nav-active-zone-list %}{% endblock %}">80 <li class="{% block nav-active-zone-list %}{% endblock %}">
76 <a href="{% url 'zone-list' %}">Zones</a>81 <a href="{% url 'zone-list' %}">Zones</a>
77 </li>82 </li>
7883
=== renamed file 'src/maasserver/templates/maasserver/settings_cluster_listing.html' => 'src/maasserver/templates/maasserver/cluster_listing.html'
--- src/maasserver/templates/maasserver/settings_cluster_listing.html 2012-11-07 10:39:19 +0000
+++ src/maasserver/templates/maasserver/cluster_listing.html 2014-04-03 11:20:25 +0000
@@ -1,58 +1,54 @@
1<h2 id="accepted-clusters">Cluster controllers</h2>1{% extends "maasserver/base.html" %}
2<table class="list" id="accepted-clusters-list">2
3 <tbody>3{% block nav-active-cluster-list %}active{% endblock %}
4 {% for cluster in accepted_clusters %}4{% block title %}{{ status_name }} clusters{% endblock %}
5 {% cycle 'even' 'odd' as cycle silent %}5{% block page-title %}{{ current_count }}{% if input_query %} matching{% endif %} {{ status_name|lower }} cluster{{ current_count|pluralize }} in {% include "maasserver/site_title.html" %}{% endblock %}
6 {% include "maasserver/settings_cluster_listing_row.html" with cycle=cycle %}6{% block site-switcher %}{% endblock %}
7 {% endfor %}7{% block header-search %}{% endblock %}
8 </tbody>8
9</table>9{% block content %}
10<div>10<div id="clusters" style="position: relative;">
11 <form method="POST"11
12 action="{% url 'settings' %}">12<h2 id="clusters">{{ title }}</h2>
13 {% csrf_token %}13<table class="list" id="clusters-list">
14 <input type="hidden" name="import_all_boot_images" value="1" />14 {% include "maasserver/cluster_listing_head.html" %}
15 <input type="submit" class="button right" value="Import boot images" />15 <tbody>
16 </form>16 {% for cluster in cluster_list %}
17</div>17 {% cycle 'even' 'odd' as cycle silent %}
18<div class="clear"></div>18 {% include "maasserver/cluster_listing_row.html" with cycle=cycle warning_no_images=warn_no_images %}
19{% if pending_clusters %}19 {% endfor %}
20<h3 id="pending-clusters">Pending clusters</h3>20 </tbody>
21<table class="list" id="pending-clusters-list">21</table>
22 <tbody>22{% include "maasserver/pagination.html" %}
23 {% for cluster in pending_clusters %}23<div>
24 {% cycle 'even' 'odd' as cycle silent %}24 {% if status == statuses.ACCEPTED %}
25 {% include "maasserver/settings_cluster_listing_row.html" with cycle=cycle %}25 <form method="POST"
26 {% endfor %}26 action="{% url 'cluster-list' %}">
27 </tbody>27 {% csrf_token %}
28</table>28 <input type="hidden" name="import_all_boot_images" value="1" />
29<div>29 <input type="submit" class="button right" value="Import boot images" />
30 <form id="reject_all_pending_nodegroups"30 </form>
31 method="POST"31 {% endif %}
32 action="{% url 'settings' %}">32 {% if status == statuses.PENDING %}
33 {% csrf_token %}33 <form id="reject_all_pending_nodegroups"
34 <input type="hidden" name="mass_accept_submit" value="1" />34 method="POST"
35 <input type="submit" class="button right" value="Accept all" />35 action="{% url 'cluster-list' %}">
36 </form>36 {% csrf_token %}
37 <form id="accept_all_pending_nodegroups"37 <input type="hidden" name="mass_accept_submit" value="1" />
38 method="POST"38 <input type="submit" class="button right" value="Accept all" />
39 action="{% url 'settings' %}">39 </form>
40 {% csrf_token %}40 <form id="accept_all_pending_nodegroups"
41 <input type="hidden" name="mass_reject_submit" value="1" />41 method="POST"
42 <input type="submit" class="button right space-right-small"42 action="{% url 'cluster-list' %}">
43 value="Reject all" />43 {% csrf_token %}
44 </form>44 <input type="hidden" name="mass_reject_submit" value="1" />
45</div>45 <input type="submit" class="button right space-right-small"
46<div class="clear"></div>46 value="Reject all" />
47{% endif %}47 </form>
48{% if rejected_clusters %}48 {% endif %}
49<h3 id="rejected-clusters">Rejected clusters</h3>49
50<table class="list" id="rejected-clusters-list">50</div>
51 <tbody>51<div class="clear"></div>
52 {% for cluster in rejected_clusters %}52
53 {% cycle 'even' 'odd' as cycle silent %}53
54 {% include "maasserver/settings_cluster_listing_row.html" with cycle=cycle %}54{% endblock %}
55 {% endfor %}
56 </tbody>
57</table>
58{% endif %}
59\ No newline at end of file55\ No newline at end of file
6056
=== added file 'src/maasserver/templates/maasserver/cluster_listing_head.html'
--- src/maasserver/templates/maasserver/cluster_listing_head.html 1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/maasserver/cluster_listing_head.html 2014-04-03 11:20:25 +0000
@@ -0,0 +1,10 @@
1<thead>
2 <tr>
3 <th>Name</th>
4 <th>Managed interfaces</th>
5 <th>Boot images</th>
6 <th>Nodes</th>
7 {% comment %} Action buttons {% endcomment %}
8 <th></th>
9 </tr>
10</thead>
0\ No newline at end of file11\ No newline at end of file
112
=== renamed file 'src/maasserver/templates/maasserver/settings_cluster_listing_row.html' => 'src/maasserver/templates/maasserver/cluster_listing_row.html'
--- src/maasserver/templates/maasserver/settings_cluster_listing_row.html 2012-12-06 04:22:51 +0000
+++ src/maasserver/templates/maasserver/cluster_listing_row.html 2014-04-03 11:20:25 +0000
@@ -1,11 +1,24 @@
1<tr class="cluster {{ cycle }}"1<tr class="cluster {{ cycle }} {% if warning_no_images and cluster.bootimage_set.count == 0 %}warning{% endif %}"
2 id="{{ cluster.uuid }}">2 id="{{ cluster.uuid }}">
3 <td>3 <td>
4 {{ cluster.cluster_name }}4 <a href="{% url 'cluster-edit' cluster.uuid %}">{{ cluster.cluster_name }}</a>
5 </td>
6 <td>
7 {{ cluster.get_managed_interfaces|length }}
8 </td>
9 <td>
10 {% if warning_no_images and cluster.bootimage_set.count == 0 %}
11 0 <img src="{{ STATIC_URL }}img/warning.png" title="Warning: this cluster has no boot images."/>
12 {% else %}
5 {% if cluster.bootimage_set.count == 0 %}13 {% if cluster.bootimage_set.count == 0 %}
6 <span class="warning">(Warning: this cluster has no boot images.)</span>14 0
15 {% else %}
16 <a title="View boot images"
17 href="{% url 'cluster-bootimages-list' cluster.uuid %}">{{ cluster.bootimage_set.count }}</a>
7 {% endif %}18 {% endif %}
19 {% endif %}
8 </td>20 </td>
21 <td>{{ cluster.node_set.count }}</td>
9 <td class="icon-controls">22 <td class="icon-controls">
10 <a href="{% url 'cluster-edit' cluster.uuid %}"23 <a href="{% url 'cluster-edit' cluster.uuid %}"
11 class="icon"24 class="icon"
1225
=== modified file 'src/maasserver/templates/maasserver/no-bootimages-warning.html'
--- src/maasserver/templates/maasserver/no-bootimages-warning.html 2014-03-27 10:51:28 +0000
+++ src/maasserver/templates/maasserver/no-bootimages-warning.html 2014-04-03 11:20:25 +0000
@@ -5,5 +5,5 @@
5 <span class="console">/etc/maas/bootresources.yaml</span> on the cluster's5 <span class="console">/etc/maas/bootresources.yaml</span> on the cluster's
6 machine) suits the configuration of the machines this cluster will have to6 machine) suits the configuration of the machines this cluster will have to
7 handle and then run the import script by clicking the "Import boot images"7 handle and then run the import script by clicking the "Import boot images"
8 button on the <a href="{% url 'settings' %}#clusters">cluster's listing page</a>.8 button on the <a href="{% url 'cluster-list' %}">cluster listing page</a>.
9</p>9</p>
1010
=== modified file 'src/maasserver/templates/maasserver/nodegroup_confirm_delete.html'
--- src/maasserver/templates/maasserver/nodegroup_confirm_delete.html 2012-10-08 12:50:32 +0000
+++ src/maasserver/templates/maasserver/nodegroup_confirm_delete.html 2014-04-03 11:20:25 +0000
@@ -17,7 +17,7 @@
17 <input type="hidden" name="post" value="yes" />17 <input type="hidden" name="post" value="yes" />
18 <input type="submit" value="Delete cluster controller"18 <input type="submit" value="Delete cluster controller"
19 class="right" />19 class="right" />
20 <a href="{% url 'settings' %}">Cancel</a>20 <a href="{% url 'cluster-list' %}">Cancel</a>
21 </form>21 </form>
22 </p>22 </p>
23 </div>23 </div>
2424
=== modified file 'src/maasserver/templates/maasserver/nodegroup_edit.html'
--- src/maasserver/templates/maasserver/nodegroup_edit.html 2014-03-25 10:35:14 +0000
+++ src/maasserver/templates/maasserver/nodegroup_edit.html 2014-04-03 11:20:25 +0000
@@ -15,7 +15,7 @@
15 </ul>15 </ul>
16 <input type="submit" value="Save cluster controller"16 <input type="submit" value="Save cluster controller"
17 class="button right" />17 class="button right" />
18 <a class="link-button" href="{% url 'settings' %}">Cancel</a>18 <a class="link-button" href="{% url 'cluster-list' %}">Cancel</a>
19 <h2>Interfaces</h2>19 <h2>Interfaces</h2>
20 {% with nb_interfaces=cluster.nodegroupinterface_set.count %}20 {% with nb_interfaces=cluster.nodegroupinterface_set.count %}
21 This cluster controller has {{ nb_interfaces }}21 This cluster controller has {{ nb_interfaces }}
2222
=== modified file 'src/maasserver/templates/maasserver/settings.html'
--- src/maasserver/templates/maasserver/settings.html 2012-11-30 17:21:28 +0000
+++ src/maasserver/templates/maasserver/settings.html 2014-04-03 11:20:25 +0000
@@ -70,11 +70,6 @@
70 <div class="clear"></div>70 <div class="clear"></div>
71 </div>71 </div>
72 <div class="divider"></div>72 <div class="divider"></div>
73 <div id="clusters" class="block size11 first">
74 {% include "maasserver/settings_cluster_listing.html" %}
75 <div class="clear"></div>
76 </div>
77 <div class="divider"></div>
78 <div id="commissioning_scripts" class="block size11 first">73 <div id="commissioning_scripts" class="block size11 first">
79 {% include "maasserver/settings_commissioning_scripts.html" %}74 {% include "maasserver/settings_commissioning_scripts.html" %}
80 <div class="clear"></div>75 <div class="clear"></div>
8176
=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py 2014-03-19 13:54:20 +0000
+++ src/maasserver/tests/test_api.py 2014-04-03 11:20:25 +0000
@@ -53,6 +53,7 @@
53from maasserver.testing.oauthclient import OAuthAuthenticatedClient53from maasserver.testing.oauthclient import OAuthAuthenticatedClient
54from maasserver.testing.testcase import MAASServerTestCase54from maasserver.testing.testcase import MAASServerTestCase
55from maasserver.tests.test_forms import make_interface_settings55from maasserver.tests.test_forms import make_interface_settings
56from maasserver.utils import absolute_reverse
56from maasserver.utils.orm import get_one57from maasserver.utils.orm import get_one
57from maastesting.djangotestcase import TransactionTestCase58from maastesting.djangotestcase import TransactionTestCase
58from maastesting.matchers import MockCalledOnceWith59from maastesting.matchers import MockCalledOnceWith
@@ -754,7 +755,8 @@
754 [args[0][0] for args in recorder.call_args_list])755 [args[0][0] for args in recorder.call_args_list])
755 # The persistent error message links to the clusters listing.756 # The persistent error message links to the clusters listing.
756 self.assertIn(757 self.assertIn(
757 "/settings/#accepted-clusters", recorder.call_args_list[0][0][1])758 absolute_reverse("cluster-list"),
759 recorder.call_args_list[0][0][1])
758760
759 def test_warns_if_any_nodegroup_has_no_images(self):761 def test_warns_if_any_nodegroup_has_no_images(self):
760 factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED)762 factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED)
761763
=== modified file 'src/maasserver/urls.py'
--- src/maasserver/urls.py 2014-03-25 10:35:14 +0000
+++ src/maasserver/urls.py 2014-04-03 11:20:25 +0000
@@ -21,12 +21,22 @@
21 url,21 url,
22 )22 )
23from django.contrib.auth.decorators import user_passes_test23from django.contrib.auth.decorators import user_passes_test
24from maasserver.enum import NODEGROUP_STATUS
24from maasserver.models import Node25from maasserver.models import Node
25from maasserver.views import TextTemplateView26from maasserver.views import TextTemplateView
26from maasserver.views.account import (27from maasserver.views.account import (
27 login,28 login,
28 logout,29 logout,
29 )30 )
31from maasserver.views.clusters import (
32 BootImagesListView,
33 ClusterDelete,
34 ClusterEdit,
35 ClusterInterfaceCreate,
36 ClusterInterfaceDelete,
37 ClusterInterfaceEdit,
38 ClusterListView,
39 )
30from maasserver.views.networks import (40from maasserver.views.networks import (
31 NetworkAdd,41 NetworkAdd,
32 NetworkDelete,42 NetworkDelete,
@@ -60,14 +70,6 @@
60 AccountsView,70 AccountsView,
61 settings,71 settings,
62 )72 )
63from maasserver.views.settings_clusters import (
64 BootImagesListView,
65 ClusterDelete,
66 ClusterEdit,
67 ClusterInterfaceCreate,
68 ClusterInterfaceDelete,
69 ClusterInterfaceEdit,
70 )
71from maasserver.views.settings_commissioning_scripts import (73from maasserver.views.settings_commissioning_scripts import (
72 CommissioningScriptCreate,74 CommissioningScriptCreate,
73 CommissioningScriptDelete,75 CommissioningScriptDelete,
@@ -116,7 +118,6 @@
116 r'^account/prefs/sshkey/delete/(?P<keyid>\d*)/$',118 r'^account/prefs/sshkey/delete/(?P<keyid>\d*)/$',
117 SSHKeyDeleteView.as_view(), name='prefs-delete-sshkey'),119 SSHKeyDeleteView.as_view(), name='prefs-delete-sshkey'),
118 )120 )
119
120# Logout view.121# Logout view.
121urlpatterns += patterns(122urlpatterns += patterns(
122 'maasserver.views',123 'maasserver.views',
@@ -159,6 +160,18 @@
159urlpatterns += patterns(160urlpatterns += patterns(
160 'maasserver.views',161 'maasserver.views',
161 adminurl(162 adminurl(
163 r'^clusters/$',
164 ClusterListView.as_view(status=NODEGROUP_STATUS.ACCEPTED),
165 name='cluster-list'),
166 adminurl(
167 r'^clusters/pending/$',
168 ClusterListView.as_view(status=NODEGROUP_STATUS.PENDING),
169 name='cluster-list-pending'),
170 adminurl(
171 r'^clusters/rejected/$',
172 ClusterListView.as_view(status=NODEGROUP_STATUS.REJECTED),
173 name='cluster-list-rejected'),
174 adminurl(
162 r'^clusters/(?P<uuid>[\w\-]+)/edit/$', ClusterEdit.as_view(),175 r'^clusters/(?P<uuid>[\w\-]+)/edit/$', ClusterEdit.as_view(),
163 name='cluster-edit'),176 name='cluster-edit'),
164 adminurl(177 adminurl(
165178
=== renamed file 'src/maasserver/views/settings_clusters.py' => 'src/maasserver/views/clusters.py'
--- src/maasserver/views/settings_clusters.py 2014-03-25 10:35:14 +0000
+++ src/maasserver/views/clusters.py 2014-04-03 11:20:25 +0000
@@ -1,7 +1,7 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the1# Copyright 2012 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"""Cluster Settings views."""4"""Cluster views."""
55
6from __future__ import (6from __future__ import (
7 absolute_import,7 absolute_import,
@@ -19,17 +19,29 @@
19 "ClusterInterfaceCreate",19 "ClusterInterfaceCreate",
20 "ClusterInterfaceDelete",20 "ClusterInterfaceDelete",
21 "ClusterInterfaceEdit",21 "ClusterInterfaceEdit",
22 "ClusterListView",
22 ]23 ]
2324
25from collections import OrderedDict
26
24from django.contrib import messages27from django.contrib import messages
25from django.core.urlresolvers import reverse28from django.core.urlresolvers import reverse
26from django.http import HttpResponseRedirect29from django.http import HttpResponseRedirect
27from django.shortcuts import get_object_or_40430from django.shortcuts import get_object_or_404
31from django.utils.safestring import mark_safe
28from django.views.generic import (32from django.views.generic import (
29 CreateView,33 CreateView,
30 DeleteView,34 DeleteView,
31 UpdateView,35 UpdateView,
32 )36 )
37from django.views.generic.edit import (
38 FormMixin,
39 ProcessFormView,
40 )
41from maasserver.enum import (
42 NODEGROUP_STATUS,
43 NODEGROUP_STATUS_CHOICES,
44 )
33from maasserver.forms import (45from maasserver.forms import (
34 NodeGroupEdit,46 NodeGroupEdit,
35 NodeGroupInterfaceForm,47 NodeGroupInterfaceForm,
@@ -41,6 +53,99 @@
41from maasserver.views import PaginatedListView53from maasserver.views import PaginatedListView
4254
4355
56class ClusterListView(PaginatedListView, FormMixin, ProcessFormView):
57 template_name = 'maasserver/cluster_listing.html'
58 context_object_name = "cluster_list"
59 status = None
60
61 def get_queryset(self):
62 return NodeGroup.objects.filter(
63 status=self.status).order_by('cluster_name')
64
65 # A record of the urls used to reach the clusters of different
66 # statuses.
67 status_links = OrderedDict((
68 (NODEGROUP_STATUS.ACCEPTED, 'cluster-list'),
69 (NODEGROUP_STATUS.PENDING, 'cluster-list-pending'),
70 (NODEGROUP_STATUS.REJECTED, 'cluster-list-rejected'),
71 ))
72
73 def make_title_entry(self, status, link_name):
74 """Generate an entry as used by make_cluster_listing_title().
75
76 This is a utility method only used by make_cluster_listing_title.
77 It is a separate method for clarity and to help testing."""
78 link = reverse(link_name)
79 status_name = NODEGROUP_STATUS_CHOICES[status][1]
80 nb_clusters = NodeGroup.objects.filter(
81 status=status).count()
82 entry = "%d %s cluster%s" % (
83 nb_clusters,
84 status_name.lower(),
85 's' if nb_clusters != 1 else '')
86 if nb_clusters != 0 and status != self.status:
87 entry = '<a href="%s">%s</a>' % (link, entry)
88 return entry
89
90 def make_cluster_listing_title(self):
91 """Generate this view's title with "tabs" for each cluster status.
92
93 Generate a title for this view with the number of clusters for each
94 possible status. The title includes the links to the other listings
95 (i.e. if this is the listing for the accepted clusters, include links
96 to the listings of the pending/rejected clusters). The title will be
97 of the form: "3 accepted clusters / 1 pending cluster / 2 rejected
98 clusters". (This is simpler to do in the view rather than write this
99 using the template language.)
100 """
101 return mark_safe(
102 ' / '.join(
103 self.make_title_entry(status, link_name)
104 for status, link_name in self.status_links.items()))
105
106 def get_context_data(self, **kwargs):
107 context = super(ClusterListView, self).get_context_data(**kwargs)
108 context['current_count'] = NodeGroup.objects.filter(
109 status=self.status).count()
110 context['title'] = self.make_cluster_listing_title()
111 # Display warnings for clusters that have no images, but only for the
112 # display of 'accepted' clusters.
113 context['warn_no_images'] = self.status == NODEGROUP_STATUS.ACCEPTED
114 context['status'] = self.status
115 context['statuses'] = NODEGROUP_STATUS
116 context['status_name'] = NODEGROUP_STATUS_CHOICES[self.status][1]
117 return context
118
119 def post(self, request, *args, **kwargs):
120 """Handle a POST request."""
121 if 'mass_accept_submit' in request.POST:
122 # Process accept clusters en masse.
123 number = NodeGroup.objects.accept_all_pending()
124 messages.info(request, "Accepted %d cluster(s)." % number)
125 return HttpResponseRedirect(reverse('cluster-list'))
126
127 elif 'mass_reject_submit' in request.POST:
128 # Process reject clusters en masse.
129 number = NodeGroup.objects.reject_all_pending()
130 messages.info(request, "Rejected %d cluster(s)." % number)
131 return HttpResponseRedirect(reverse('cluster-list'))
132
133 elif 'import_all_boot_images' in request.POST:
134 # Import PXE files for all the accepted clusters.
135 NodeGroup.objects.import_boot_images_accepted_clusters()
136 message = (
137 "Import of boot images started on all cluster controllers. "
138 "Importing the boot images can take a long time depending on "
139 "the available bandwidth.")
140 messages.info(request, message)
141 return HttpResponseRedirect(reverse('cluster-list'))
142
143 else:
144 # Unknown action: redirect to the cluster listing page (this
145 # shouldn't happen).
146 return HttpResponseRedirect(reverse('cluster-list'))
147
148
44class ClusterEdit(UpdateView):149class ClusterEdit(UpdateView):
45 model = NodeGroup150 model = NodeGroup
46 template_name = 'maasserver/nodegroup_edit.html'151 template_name = 'maasserver/nodegroup_edit.html'
@@ -54,7 +159,7 @@
54 return context159 return context
55160
56 def get_success_url(self):161 def get_success_url(self):
57 return reverse('settings')162 return reverse('cluster-list')
58163
59 def get_object(self):164 def get_object(self):
60 uuid = self.kwargs.get('uuid', None)165 uuid = self.kwargs.get('uuid', None)
@@ -75,7 +180,7 @@
75 return get_object_or_404(NodeGroup, uuid=uuid)180 return get_object_or_404(NodeGroup, uuid=uuid)
76181
77 def get_next_url(self):182 def get_next_url(self):
78 return reverse('settings')183 return reverse('cluster-list')
79184
80 def delete(self, request, *args, **kwargs):185 def delete(self, request, *args, **kwargs):
81 cluster = self.get_object()186 cluster = self.get_object()
82187
=== modified file 'src/maasserver/views/settings.py'
--- src/maasserver/views/settings.py 2013-10-07 09:12:40 +0000
+++ src/maasserver/views/settings.py 2014-04-03 11:20:25 +0000
@@ -38,7 +38,6 @@
38from django.views.generic.base import TemplateView38from django.views.generic.base import TemplateView
39from django.views.generic.detail import SingleObjectTemplateResponseMixin39from django.views.generic.detail import SingleObjectTemplateResponseMixin
40from django.views.generic.edit import ModelFormMixin40from django.views.generic.edit import ModelFormMixin
41from maasserver.enum import NODEGROUP_STATUS
42from maasserver.exceptions import CannotDeleteUserException41from maasserver.exceptions import CannotDeleteUserException
43from maasserver.forms import (42from maasserver.forms import (
44 CommissioningForm,43 CommissioningForm,
@@ -48,10 +47,7 @@
48 NewUserCreationForm,47 NewUserCreationForm,
49 UbuntuForm,48 UbuntuForm,
50 )49 )
51from maasserver.models import (50from maasserver.models import UserProfile
52 NodeGroup,
53 UserProfile,
54 )
55from maasserver.views import process_form51from maasserver.views import process_form
56from metadataserver.models import CommissioningScript52from metadataserver.models import CommissioningScript
5753
@@ -186,36 +182,6 @@
186 if response is not None:182 if response is not None:
187 return response183 return response
188184
189 # Process accept clusters en masse.
190 if 'mass_accept_submit' in request.POST:
191 number = NodeGroup.objects.accept_all_pending()
192 messages.info(request, "Accepted %d cluster(s)." % number)
193 return HttpResponseRedirect(reverse('settings'))
194
195 # Process reject clusters en masse.
196 if 'mass_reject_submit' in request.POST:
197 number = NodeGroup.objects.reject_all_pending()
198 messages.info(request, "Rejected %d cluster(s)." % number)
199 return HttpResponseRedirect(reverse('settings'))
200
201 # Import PXE files for all the accepted clusters.
202 if 'import_all_boot_images' in request.POST:
203 NodeGroup.objects.import_boot_images_accepted_clusters()
204 message = (
205 "Import of boot images started on all cluster controllers. "
206 "Importing the boot images can take a long time depending on "
207 "the available bandwidth.")
208 messages.info(request, message)
209 return HttpResponseRedirect(reverse('settings'))
210
211 # Cluster listings.
212 accepted_clusters = NodeGroup.objects.filter(
213 status=NODEGROUP_STATUS.ACCEPTED).order_by('cluster_name')
214 pending_clusters = NodeGroup.objects.filter(
215 status=NODEGROUP_STATUS.PENDING).order_by('cluster_name')
216 rejected_clusters = NodeGroup.objects.filter(
217 status=NODEGROUP_STATUS.REJECTED).order_by('cluster_name')
218
219 # Commissioning scripts.185 # Commissioning scripts.
220 commissioning_scripts = CommissioningScript.objects.all()186 commissioning_scripts = CommissioningScript.objects.all()
221187
@@ -224,9 +190,6 @@
224 {190 {
225 'user_list': user_list,191 'user_list': user_list,
226 'commissioning_scripts': commissioning_scripts,192 'commissioning_scripts': commissioning_scripts,
227 'accepted_clusters': accepted_clusters,
228 'pending_clusters': pending_clusters,
229 'rejected_clusters': rejected_clusters,
230 'maas_and_network_form': maas_and_network_form,193 'maas_and_network_form': maas_and_network_form,
231 'commissioning_form': commissioning_form,194 'commissioning_form': commissioning_form,
232 'ubuntu_form': ubuntu_form,195 'ubuntu_form': ubuntu_form,
233196
=== modified file 'src/maasserver/views/tests/test_boot_image_list.py'
--- src/maasserver/views/tests/test_boot_image_list.py 2014-03-27 07:39:38 +0000
+++ src/maasserver/views/tests/test_boot_image_list.py 2014-04-03 11:20:25 +0000
@@ -21,7 +21,7 @@
21from lxml.html import fromstring21from lxml.html import fromstring
22from maasserver.testing.factory import factory22from maasserver.testing.factory import factory
23from maasserver.testing.testcase import MAASServerTestCase23from maasserver.testing.testcase import MAASServerTestCase
24from maasserver.views.settings_clusters import BootImagesListView24from maasserver.views.clusters import BootImagesListView
25from testtools.matchers import ContainsAll25from testtools.matchers import ContainsAll
2626
2727
2828
=== renamed file 'src/maasserver/views/tests/test_settings_clusters.py' => 'src/maasserver/views/tests/test_clusters.py'
--- src/maasserver/views/tests/test_settings_clusters.py 2014-03-25 12:53:32 +0000
+++ src/maasserver/views/tests/test_clusters.py 2014-04-03 11:20:25 +0000
@@ -20,10 +20,12 @@
20from lxml.html import fromstring20from lxml.html import fromstring
21from maasserver.enum import (21from maasserver.enum import (
22 NODEGROUP_STATUS,22 NODEGROUP_STATUS,
23 NODEGROUP_STATUS_CHOICES,
23 NODEGROUPINTERFACE_MANAGEMENT,24 NODEGROUPINTERFACE_MANAGEMENT,
24 )25 )
25from maasserver.models import (26from maasserver.models import (
26 NodeGroup,27 NodeGroup,
28 nodegroup as nodegroup_module,
27 NodeGroupInterface,29 NodeGroupInterface,
28 )30 )
29from maasserver.testing import (31from maasserver.testing import (
@@ -33,25 +35,42 @@
33 )35 )
34from maasserver.testing.factory import factory36from maasserver.testing.factory import factory
35from maasserver.testing.testcase import MAASServerTestCase37from maasserver.testing.testcase import MAASServerTestCase
38from maasserver.utils import map_enum
39from maasserver.views.clusters import ClusterListView
40from mock import (
41 ANY,
42 call,
43 )
36from testtools.matchers import (44from testtools.matchers import (
37 AllMatch,45 AllMatch,
38 Contains,46 Contains,
39 ContainsAll,47 ContainsAll,
40 Equals,48 Equals,
49 HasLength,
41 MatchesStructure,50 MatchesStructure,
42 )51 )
4352
4453
45class ClusterListingTest(MAASServerTestCase):54class ClusterListingTest(MAASServerTestCase):
4655
47 def test_settings_contains_links_to_edit_and_delete_clusters(self):56 scenarios = [
57 ('accepted-clusters', {'status': NODEGROUP_STATUS.ACCEPTED}),
58 ('pending-clusters', {'status': NODEGROUP_STATUS.PENDING}),
59 ('rejected-clusters', {'status': NODEGROUP_STATUS.REJECTED}),
60 ]
61
62 def get_url(self):
63 """Return the listing url used in this scenario."""
64 return reverse(ClusterListView.status_links[
65 self.status])
66
67 def test_cluster_listing_contains_links_to_manipulate_clusters(self):
48 self.client_log_in(as_admin=True)68 self.client_log_in(as_admin=True)
49 nodegroups = {69 nodegroups = {
50 factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED),70 factory.make_node_group(status=self.status)
51 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),71 for _ in range(3)
52 factory.make_node_group(status=NODEGROUP_STATUS.REJECTED),
53 }72 }
54 links = get_content_links(self.client.get(reverse('settings')))73 links = get_content_links(self.client.get(self.get_url()))
55 nodegroup_edit_links = [74 nodegroup_edit_links = [
56 reverse('cluster-edit', args=[nodegroup.uuid])75 reverse('cluster-edit', args=[nodegroup.uuid])
57 for nodegroup in nodegroups]76 for nodegroup in nodegroups]
@@ -62,6 +81,166 @@
62 links,81 links,
63 ContainsAll(nodegroup_edit_links + nodegroup_delete_links))82 ContainsAll(nodegroup_edit_links + nodegroup_delete_links))
6483
84 def make_listing_view(self, status):
85 view = ClusterListView()
86 view.status = status
87 return view
88
89 def test_make_title_entry_returns_link_for_other_status(self):
90 # If the entry's status is different from the view's status,
91 # the returned entry is a link.
92 other_status = factory.getRandomChoice(
93 NODEGROUP_STATUS_CHOICES, but_not=[self.status])
94 factory.make_node_group(status=other_status)
95 link_name = ClusterListView.status_links[other_status]
96 view = self.make_listing_view(self.status)
97 entry = view.make_title_entry(other_status, link_name)
98 status_name = NODEGROUP_STATUS_CHOICES[other_status][1]
99 self.assertEqual(
100 '<a href="%s">1 %s cluster</a>' % (
101 reverse(link_name), status_name.lower()),
102 entry)
103
104 def test_make_title_entry_returns_title_if_no_cluster(self):
105 # If no cluster correspond to the entry's status, the returned
106 # entry is not a link: it's a simple mention '0 <status> clusters'.
107 other_status = factory.getRandomChoice(
108 NODEGROUP_STATUS_CHOICES, but_not=[self.status])
109 link_name = ClusterListView.status_links[other_status]
110 view = self.make_listing_view(self.status)
111 entry = view.make_title_entry(other_status, link_name)
112 status_name = NODEGROUP_STATUS_CHOICES[other_status][1]
113 self.assertEqual(
114 '0 %s clusters' % status_name.lower(), entry)
115
116 def test_title_displays_number_of_clusters(self):
117 for _ in range(3):
118 factory.make_node_group(status=self.status)
119 view = self.make_listing_view(self.status)
120 status_name = NODEGROUP_STATUS_CHOICES[self.status][1]
121 title = view.make_cluster_listing_title()
122 self.assertIn("3 %s clusters" % status_name.lower(), title)
123
124 def test_title_contains_links_to_other_listings(self):
125 view = self.make_listing_view(self.status)
126 other_statuses = []
127 # Compute a list with the statuses of the clusters not being
128 # displayed by the 'view'. Create clusters with these statuses.
129 for status in map_enum(NODEGROUP_STATUS).values():
130 if status != self.status:
131 other_statuses.append(status)
132 factory.make_node_group(status=status)
133 for status in other_statuses:
134 link_name = ClusterListView.status_links[status]
135 title = view.make_cluster_listing_title()
136 self.assertIn(reverse(link_name), title)
137
138 def test_listing_is_paginated(self):
139 self.patch(ClusterListView, "paginate_by", 2)
140 self.client_log_in(as_admin=True)
141 for _ in range(3):
142 factory.make_node_group(status=self.status)
143 response = self.client.get(self.get_url())
144 self.assertEqual(httplib.OK, response.status_code)
145 doc = fromstring(response.content)
146 self.assertThat(
147 doc.cssselect('div.pagination'),
148 HasLength(1),
149 "Couldn't find pagination tag.")
150
151
152class ClusterListingAccess(MAASServerTestCase):
153
154 def test_admin_sees_cluster_tab(self):
155 self.client_log_in(as_admin=True)
156 links = get_content_links(
157 self.client.get(reverse('index')), element='#main-nav')
158 self.assertIn(reverse('cluster-list'), links)
159
160 def test_non_admin_doesnt_see_cluster_tab(self):
161 self.client_log_in(as_admin=False)
162 links = get_content_links(
163 self.client.get(reverse('index')), element='#main-nav')
164 self.assertNotIn(reverse('cluster-list'), links)
165
166
167class ClusterPendingListingTest(MAASServerTestCase):
168
169 def test_pending_listing_contains_form_to_accept_all_nodegroups(self):
170 self.client_log_in(as_admin=True)
171 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
172 response = self.client.get(reverse('cluster-list-pending'))
173 doc = fromstring(response.content)
174 forms = doc.cssselect('form#accept_all_pending_nodegroups')
175 self.assertEqual(1, len(forms))
176
177 def test_pending_listing_contains_form_to_reject_all_nodegroups(self):
178 self.client_log_in(as_admin=True)
179 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
180 response = self.client.get(reverse('cluster-list-pending'))
181 doc = fromstring(response.content)
182 forms = doc.cssselect('form#reject_all_pending_nodegroups')
183 self.assertEqual(1, len(forms))
184
185 def test_pending_listing_accepts_all_pending_nodegroups_POST(self):
186 self.client_log_in(as_admin=True)
187 nodegroups = {
188 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
189 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
190 }
191 response = self.client.post(
192 reverse('cluster-list-pending'), {'mass_accept_submit': 1})
193 self.assertEqual(httplib.FOUND, response.status_code)
194 self.assertEqual(
195 [reload_object(nodegroup).status for nodegroup in nodegroups],
196 [NODEGROUP_STATUS.ACCEPTED] * 2)
197
198 def test_pending_listing_rejects_all_pending_nodegroups_POST(self):
199 self.client_log_in(as_admin=True)
200 nodegroups = {
201 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
202 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
203 }
204 response = self.client.post(
205 reverse('cluster-list-pending'), {'mass_reject_submit': 1})
206 self.assertEqual(httplib.FOUND, response.status_code)
207 self.assertEqual(
208 [reload_object(nodegroup).status for nodegroup in nodegroups],
209 [NODEGROUP_STATUS.REJECTED] * 2)
210
211
212class ClusterAcceptedListingTest(MAASServerTestCase):
213
214 def test_accepted_listing_import_boot_images_calls_tasks(self):
215 self.client_log_in(as_admin=True)
216 recorder = self.patch(nodegroup_module, 'import_boot_images')
217 accepted_nodegroups = [
218 factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED),
219 factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED),
220 ]
221 response = self.client.post(
222 reverse('cluster-list'), {'import_all_boot_images': 1})
223 self.assertEqual(httplib.FOUND, response.status_code)
224 calls = [
225 call(queue=nodegroup.work_queue, kwargs=ANY)
226 for nodegroup in accepted_nodegroups
227 ]
228 self.assertItemsEqual(calls, recorder.apply_async.call_args_list)
229
230 def test_a_warning_is_displayed_if_the_cluster_has_no_boot_images(self):
231 self.client_log_in(as_admin=True)
232 nodegroup = factory.make_node_group(
233 status=NODEGROUP_STATUS.ACCEPTED)
234 response = self.client.get(reverse('cluster-list'))
235 document = fromstring(response.content)
236 nodegroup_row = document.xpath("//tr[@id='%s']" % nodegroup.uuid)[0]
237 self.assertIn('warning', nodegroup_row.get('class'))
238 warning_elems = (
239 nodegroup_row.xpath(
240 "//img[@title='Warning: this cluster has no boot images.']"))
241 self.assertEqual(
242 1, len(warning_elems), "No warning about missing boot images.")
243
65244
66class ClusterDeleteTest(MAASServerTestCase):245class ClusterDeleteTest(MAASServerTestCase):
67246
@@ -71,7 +250,7 @@
71 delete_link = reverse('cluster-delete', args=[nodegroup.uuid])250 delete_link = reverse('cluster-delete', args=[nodegroup.uuid])
72 response = self.client.post(delete_link, {'post': 'yes'})251 response = self.client.post(delete_link, {'post': 'yes'})
73 self.assertEqual(252 self.assertEqual(
74 (httplib.FOUND, reverse('settings')),253 (httplib.FOUND, reverse('cluster-list')),
75 (response.status_code, extract_redirect(response)))254 (response.status_code, extract_redirect(response)))
76 self.assertFalse(255 self.assertFalse(
77 NodeGroup.objects.filter(uuid=nodegroup.uuid).exists())256 NodeGroup.objects.filter(uuid=nodegroup.uuid).exists())
78257
=== modified file 'src/maasserver/views/tests/test_settings.py'
--- src/maasserver/views/tests/test_settings.py 2014-04-01 06:50:01 +0000
+++ src/maasserver/views/tests/test_settings.py 2014-04-03 11:20:25 +0000
@@ -23,11 +23,9 @@
23from maasserver.enum import (23from maasserver.enum import (
24 COMMISSIONING_DISTRO_SERIES_CHOICES,24 COMMISSIONING_DISTRO_SERIES_CHOICES,
25 DISTRO_SERIES,25 DISTRO_SERIES,
26 NODEGROUP_STATUS,
27 )26 )
28from maasserver.models import (27from maasserver.models import (
29 Config,28 Config,
30 nodegroup as nodegroup_module,
31 UserProfile,29 UserProfile,
32 )30 )
33from maasserver.testing import (31from maasserver.testing import (
@@ -37,10 +35,6 @@
37 )35 )
38from maasserver.testing.factory import factory36from maasserver.testing.factory import factory
39from maasserver.testing.testcase import MAASServerTestCase37from maasserver.testing.testcase import MAASServerTestCase
40from mock import (
41 ANY,
42 call,
43 )
4438
4539
46class SettingsTest(MAASServerTestCase):40class SettingsTest(MAASServerTestCase):
@@ -185,73 +179,6 @@
185 new_kernel_opts,179 new_kernel_opts,
186 Config.objects.get_config('kernel_opts'))180 Config.objects.get_config('kernel_opts'))
187181
188 def test_settings_contains_form_to_accept_all_nodegroups(self):
189 self.client_log_in(as_admin=True)
190 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
191 response = self.client.get(reverse('settings'))
192 doc = fromstring(response.content)
193 forms = doc.cssselect('form#accept_all_pending_nodegroups')
194 self.assertEqual(1, len(forms))
195
196 def test_settings_contains_form_to_reject_all_nodegroups(self):
197 self.client_log_in(as_admin=True)
198 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
199 response = self.client.get(reverse('settings'))
200 doc = fromstring(response.content)
201 forms = doc.cssselect('form#reject_all_pending_nodegroups')
202 self.assertEqual(1, len(forms))
203
204 def test_settings_accepts_all_pending_nodegroups_POST(self):
205 self.client_log_in(as_admin=True)
206 nodegroups = {
207 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
208 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
209 }
210 response = self.client.post(
211 reverse('settings'), {'mass_accept_submit': 1})
212 self.assertEqual(httplib.FOUND, response.status_code)
213 self.assertEqual(
214 [reload_object(nodegroup).status for nodegroup in nodegroups],
215 [NODEGROUP_STATUS.ACCEPTED] * 2)
216
217 def test_settings_rejects_all_pending_nodegroups_POST(self):
218 self.client_log_in(as_admin=True)
219 nodegroups = {
220 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
221 factory.make_node_group(status=NODEGROUP_STATUS.PENDING),
222 }
223 response = self.client.post(
224 reverse('settings'), {'mass_reject_submit': 1})
225 self.assertEqual(httplib.FOUND, response.status_code)
226 self.assertEqual(
227 [reload_object(nodegroup).status for nodegroup in nodegroups],
228 [NODEGROUP_STATUS.REJECTED] * 2)
229
230 def test_settings_import_boot_images_calls_tasks(self):
231 self.client_log_in(as_admin=True)
232 recorder = self.patch(nodegroup_module, 'import_boot_images')
233 accepted_nodegroups = [
234 factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED),
235 factory.make_node_group(status=NODEGROUP_STATUS.ACCEPTED),
236 ]
237 response = self.client.post(
238 reverse('settings'), {'import_all_boot_images': 1})
239 self.assertEqual(httplib.FOUND, response.status_code)
240 calls = [
241 call(queue=nodegroup.work_queue, kwargs=ANY)
242 for nodegroup in accepted_nodegroups
243 ]
244 self.assertItemsEqual(calls, recorder.apply_async.call_args_list)
245
246 def test_cluster_no_boot_images_message_displayed_if_no_boot_images(self):
247 self.client_log_in(as_admin=True)
248 nodegroup = factory.make_node_group(
249 status=NODEGROUP_STATUS.ACCEPTED)
250 response = self.client.get(reverse('settings'))
251 document = fromstring(response.content)
252 nodegroup_row = document.xpath("//tr[@id='%s']" % nodegroup.uuid)[0]
253 self.assertIn('no boot images', nodegroup_row.text_content())
254
255182
256class NonAdminSettingsTest(MAASServerTestCase):183class NonAdminSettingsTest(MAASServerTestCase):
257184