Merge lp:~jtv/maas/bug-986185-conditional-node-actions into lp:~maas-committers/maas/trunk

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 522
Proposed branch: lp:~jtv/maas/bug-986185-conditional-node-actions
Merge into: lp:~maas-committers/maas/trunk
Prerequisite: lp:~jtv/maas/pre-986185-exception-responses
Diff against target: 496 lines (+233/-63)
5 files modified
src/maasserver/forms.py (+103/-22)
src/maasserver/templates/maasserver/node_view.html (+11/-21)
src/maasserver/tests/test_forms.py (+99/-10)
src/maasserver/tests/test_views_nodes.py (+20/-8)
src/maasserver/views/nodes.py (+0/-2)
To merge this branch: bzr merge lp:~jtv/maas/bug-986185-conditional-node-actions
Reviewer Review Type Date Requested Status
Julian Edwards (community) Approve
Review via email: mp+103540@code.launchpad.net

Commit message

Turn Delete Node into a regular node action.

Description of the change

Actually I split off two preparatory branches, which are independent of one another but both needed for this one. And an MP will only take one prerequisite branch, so review may have to wait until both the pre-986185-* branches are reviewed and landed.

The Delete Node button is special in many ways that set it apart from generic, easily defined “node actions”:
 - It can be visible-but-disabled if the user has the requisite permissions, but circumstances prevent the action.
 - It's actually a link, not a button, even though it's dressed up to look like a button.
 - It requires special treatment in the view in order to control its visibility and availability like a node action's.
 - It forwards to a confirmation page on a different view.

Nevertheless, this branch turns it into a regular node action. To that end I had to shave a few yaks:

1. Inhibiting node actions.

Every node action can now provide an “inhibit” function. This function looks for a reason to disable the action, but leave it visible. It returns None if there is no reason to disable it, or an explanatory text.

As it stands, all actions' inhibitions are calculated on every use of the view. This is actually a bit wasteful: on an action POST the only action that needs to be checked for inhibitions is the one that's being exercised. (The action does a double-check, so that for instance you don't accidentally delete a node that's been acquired since you loaded its overview page.) There shouldn't normally be too many actions though.

2. Compiling actions.

The node-action form had a list of references to NODE_ACTION entries, plus a more compact version of the same information. I unify them into “compiled” actions, which are like the applicable actions but any inhibitions are stored as an extra entry.

3. Disabled buttons.

A disabled action button is still a button, but with some different styling, plus a tooltip showing what's inhibiting the action, such as “you haven't registered an SSH key yet.”

4. Redirection.

The delete-node action returns a redirect to the confirmation page. This is how an action POST can support the same workflow as the first half of the old GET/POST combination. It's probably a bit less inefficient, but if that becomes a problem, we should probably be looking at mass-deletion UIs instead of shaving milliseconds off the individual operation.

In one of the prep branches I introduced a new exception type, Redirect, which conveys a temporary-redirect response to the exception middleware or, in this case, a wrapper in the view that catches MAASAPIExceptions and returns their HTTP responses. (This was already documented; the prep branch puts the logic for creating the response in the exception itself so that it's easily reusable.)

My final branch in this series will apply the new infrastructure to the Start Node button, so that you won't be able to acquire and start a node unless you have an SSH key registered to be installed on the node.

Jeroen

To post a comment you must log in.
Revision history for this message
Julian Edwards (julian-edwards) wrote :

Thank you for this, it's quite a lot nicer! Just a few comments, and one thing that is a needs-fixing, but not your fault, you just get the straw that broke the reviewer's back I'm afraid. See below ...

-----

The DELETE_ACTION looks really disjoint against the other inline actions now, is there any more refactoring we can do with those so the NODE_ACTIONS stuff looks nicer?

26 + if node.owner is not None:
27 + return "You cannot delete this node because it's in use."
28 + else:
29 + return None
...
135 + if action is None:
136 + return False
137 + else:
138 + return self.user.has_perm(action['permission'], self.node)

You don't need the "else:" on these functions.

176 + def find_action(self, action_name):
177 + """Find the compiled action of the given name, or return None."""
178 + for action in self.action_buttons:
179 + if action['display'] == action_name:
180 + return action
181 + return None

Mostly thinking out loud here, but I wonder if we can turn action_buttons into a dict so you don't need to iterate here. Whenever I see code doing this it just makes me think it's using the wrong data types.

And here's the need-fixing thing: I remember raising this with rvb before and I'd forgotten about it, but these actions in the dicts appearing as strings is terrible, they really need to be enumerated values. Can you please see if you can sort it out in this branch since you're adding a new action? And damn, I wish we had a proper enum type :/

review: Needs Fixing
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :
Download full text (4.1 KiB)

> Thank you for this, it's quite a lot nicer! Just a few comments, and one
> thing that is a needs-fixing, but not your fault, you just get the straw that
> broke the reviewer's back I'm afraid. See below ...

Speaking of which, I'm sorry you had to review this before the prerequisite branches had landed. If it helps any, I positively agonized over which one to specify as the pre-req to minimize your pain!

(Yes, I could have made one of the prereqs artificially depend on the other, but that would have had its complications as well.)

> The DELETE_ACTION looks really disjoint against the other inline actions now,
> is there any more refactoring we can do with those so the NODE_ACTIONS stuff
> looks nicer?

That's only because I broke this job into separate branches. :) My other main branch for this bug adorns the “Start node” action in much the same way.

> 26 + if node.owner is not None:
> 27 + return "You cannot delete this node because it's in use."
> 28 + else:
> 29 + return None
> ...
> 135 + if action is None:
> 136 + return False
> 137 + else:
> 138 + return self.user.has_perm(action['permission'], self.node)
>
> You don't need the "else:" on these functions.

It hadn't escaped my notion! It's mostly a matter of taste. In this case I felt that to a human reader these code flows were better expressed as alternate code paths (if/else) than as an “early escape” followed by a longer “regular” code path (if-return). But for functions this short, who's to say? As a wise man said, long ago: is 0 octal or decimal?

> 176 + def find_action(self, action_name):
> 177 + """Find the compiled action of the given name, or return None."""
> 178 + for action in self.action_buttons:
> 179 + if action['display'] == action_name:
> 180 + return action
> 181 + return None
>
> Mostly thinking out loud here, but I wonder if we can turn action_buttons into
> a dict so you don't need to iterate here. Whenever I see code doing this it
> just makes me think it's using the wrong data types.

Absolutely. It started out as a dict plus a separate list, which bogged down my sweeping changes so I unified them into an OrderedDict. It was the data structure that had everything we needed. But that still complicated things like usage from the template. And with only one lookup ever happening anywhere anyway, it just didn't seem worth the effect it had on the choice of data structure.

If you really like, I could make another attempt for an OrderedDict. But that doesn't seem quite ambitious enough given the following:

> And here's the need-fixing thing: I remember raising this with rvb before and
> I'd forgotten about it, but these actions in the dicts appearing as strings is
> terrible, they really need to be enumerated values. Can you please see if you
> can sort it out in this branch since you're adding a new action? And damn, I
> wish we had a proper enum type :/

Maybe we should nail down the typing of NODE_ACTIONS a bit more. For example, we could compile it out of a single flat list of action classes, relational-style. The current structure w...

Read more...

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Thursday 26 April 2012 06:41:17 you wrote:
> Speaking of which, I'm sorry you had to review this before the prerequisite
> branches had landed. If it helps any, I positively agonized over which one
> to specify as the pre-req to minimize your pain!

No worries, it was a small pre-req!

> That's only because I broke this job into separate branches. :) My other
> main branch for this bug adorns the “Start node” action in much the same
> way.

Ah! I have the memory of a goldfish.

> Maybe we should nail down the typing of NODE_ACTIONS a bit more. For
> example, we could compile it out of a single flat list of action classes,
> relational-style. The current structure would get compiled out of that
> single list. Each action class would have a list of node states it applied
> to, and get inserted into each. That would make it easier and cleaner to
> add mappings such as an enum-to-action mapping.
>
> (This approach would also have the side effect that any two actions that can
> both appear on the same page will always appear there in the same order;
> theoretically there are cases where you might want to design it differently
> but I suspect it's what we want anyway.)
>
> Every action in NODE_ACTIONS would be a class, not a dict, so we don't need
> to test against mis-spelled keys and such. Instead of “compiling” a node
> action we'd instantiate it, passing the state (user, node, request) to its
> initializer. The “execute” and “inhibit” items would become methods, which
> would have access to self.user, self.node etc. Adding more items to the
> signature would affect the base-class initializer only.
>
> I really don't think it's as much work as it sounds, but if you agree, I'd
> prefer to do it in a separate branch.

Yes yes yes, this is massively better! Even though I can't *quite* visualise
it without an example I think I get the gist.

Separate branch is fine, thank you!

Revision history for this message
Julian Edwards (julian-edwards) :
review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :

Attempt to merge into lp:maas failed due to conflicts:

text conflict in src/maasserver/forms.py
text conflict in src/maasserver/tests/test_forms.py

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/forms.py'
2--- src/maasserver/forms.py 2012-04-26 03:27:34 +0000
3+++ src/maasserver/forms.py 2012-04-26 07:54:18 +0000
4@@ -37,6 +37,7 @@
5 PermissionDenied,
6 ValidationError,
7 )
8+from django.core.urlresolvers import reverse
9 from django.core.validators import URLValidator
10 from django.forms import (
11 CharField,
12@@ -51,6 +52,7 @@
13 NODE_PERMISSION,
14 NODE_STATUS,
15 )
16+from maasserver.exceptions import Redirect
17 from maasserver.fields import MACAddressFormField
18 from maasserver.models import (
19 Config,
20@@ -246,6 +248,23 @@
21 node.start_commissioning(user)
22
23
24+def inhibit_delete(node, user, request=None):
25+ """Give me one reason why `user` can't delete `node`."""
26+ if node.owner is not None:
27+ return "You cannot delete this node because it's in use."
28+ else:
29+ return None
30+
31+
32+def delete_node(node, user, request=None):
33+ """Delete-a-node action.
34+
35+ Permissions and inhibitions have already been checked at this point. We
36+ redirect to the deletion view, which asks the user if they're sure.
37+ """
38+ raise Redirect(reverse('node-delete', args=[node.system_id]))
39+
40+
41 # Node actions per status.
42 #
43 # This maps each NODE_STATUS to a list of actions applicable to a node
44@@ -276,8 +295,18 @@
45 # node, favourite_colour(user)),
46 # }
47 #
48+DELETE_ACTION = {
49+ 'display': "Delete node",
50+ 'permission': NODE_PERMISSION.ADMIN,
51+ 'execute': delete_node,
52+ 'message': "Node deleted.",
53+ 'inhibit': inhibit_delete,
54+}
55+
56+
57 NODE_ACTIONS = {
58 NODE_STATUS.DECLARED: [
59+ DELETE_ACTION,
60 {
61 'display': "Accept & commission",
62 'permission': NODE_PERMISSION.ADMIN,
63@@ -286,6 +315,7 @@
64 },
65 ],
66 NODE_STATUS.FAILED_TESTS: [
67+ DELETE_ACTION,
68 {
69 'display': "Retry commissioning",
70 'permission': NODE_PERMISSION.ADMIN,
71@@ -293,7 +323,14 @@
72 'message': "Started a new attempt to commission this node.",
73 },
74 ],
75+ NODE_STATUS.COMMISSIONING: [
76+ DELETE_ACTION,
77+ ],
78+ NODE_STATUS.MISSING: [
79+ DELETE_ACTION,
80+ ],
81 NODE_STATUS.READY: [
82+ DELETE_ACTION,
83 {
84 'display': "Start node",
85 'permission': NODE_PERMISSION.VIEW,
86@@ -303,6 +340,15 @@
87 "It has been asked to start up."),
88 },
89 ],
90+ NODE_STATUS.RESERVED: [
91+ DELETE_ACTION,
92+ ],
93+ NODE_STATUS.ALLOCATED: [
94+ DELETE_ACTION,
95+ ],
96+ NODE_STATUS.RETIRED: [
97+ DELETE_ACTION,
98+ ],
99 }
100
101
102@@ -322,30 +368,62 @@
103 def __init__(self, instance, *args, **kwargs):
104 super(NodeActionForm, self).__init__(*args, **kwargs)
105 self.node = instance
106- self.action_buttons = self.available_action_methods(
107- self.node, self.user)
108- # Create a convenient dict to fetch the action's name and
109- # the permission to be checked from the button name.
110- self.action_dict = {
111- action['display']: (
112- action['permission'], action['execute'], action['message'])
113- for action in self.action_buttons
114- }
115-
116- def available_action_methods(self, node, user):
117+ self.action_buttons = self.compile_actions()
118+
119+ def have_permission(self, action=None):
120+ """Does the current user have the permission required by `action`?
121+
122+ The permission is relative to `self.node`.
123+
124+ :param action: Action to check. If no action is given, the answer is
125+ always False.
126+ :return: Does the user have the required permission to perform
127+ `action` on `self.node`?
128+ """
129+ if action is None:
130+ return False
131+ else:
132+ return self.user.has_perm(action['permission'], self.node)
133+
134+ def compile_action(self, action):
135+ """Return a copy of `action`, but add "inhibition" entry.
136+
137+ The key "inhibition" maps to a unicode string explaining why the
138+ action is not currently available for `self.node`, or None if the
139+ action can be executed.
140+ """
141+ compiled_action = action.copy()
142+ inhibit = action.get('inhibit', lambda node, user, request: None)
143+ compiled_action['inhibition'] = inhibit(
144+ node=self.node, user=self.user, request=self.request)
145+ return compiled_action
146+
147+ def compile_actions(self):
148 """Return the actions that this user is allowed to perform on a node.
149
150+ Each action will be "filled out," i.e. it will have an "inhibit"
151+ callable even if the original action in NODE_ACTIONS did not.
152+
153 :param node: The node for which the check should be performed.
154 :type node: :class:`maasserver.models.Node`
155 :param user: The user who would be performing the action. Only the
156 actions available to this user will be returned.
157 :type user: :class:`django.contrib.auth.models.User`
158- :return: Any applicable action dicts, as found in NODE_ACTIONS.
159+ :return: Any applicable action dicts, as compiled based on
160+ NODE_ACTIONS and the current user and node.
161 :rtype: Sequence
162 """
163 return [
164- action for action in NODE_ACTIONS.get(node.status, ())
165- if user.has_perm(action['permission'], node)]
166+ self.compile_action(action)
167+ for action in NODE_ACTIONS.get(self.node.status, [])
168+ if self.have_permission(action)]
169+
170+ def find_action(self, action_name):
171+ """Find the compiled action of the given name, or return None."""
172+ for action in self.action_buttons:
173+ if action['display'] == action_name:
174+ return action
175+ return None
176
177 def display_message(self, message):
178 """Show `message` as feedback after performing an action."""
179@@ -355,14 +433,17 @@
180 def save(self):
181 """An action was requested. Perform it."""
182 action_name = self.data.get(self.input_name)
183- permission, execute, message = (
184- self.action_dict.get(action_name, (None, None, None)))
185- if execute is None:
186- raise PermissionDenied()
187- if not self.user.has_perm(permission, self.node):
188- raise PermissionDenied()
189- execute(node=self.node, user=self.user, request=self.request)
190- self.display_message(message)
191+ action = self.find_action(action_name)
192+
193+ if not self.have_permission(action):
194+ raise PermissionDenied("Not a permitted action: %s" % action_name)
195+
196+ if action['inhibition'] is not None:
197+ raise PermissionDenied(action['inhibition'])
198+
199+ action['execute'](
200+ node=self.node, user=self.user, request=self.request)
201+ self.display_message(action.get('message'))
202
203
204 def get_action_form(user, request=None):
205
206=== modified file 'src/maasserver/templates/maasserver/node_view.html'
207--- src/maasserver/templates/maasserver/node_view.html 2012-04-16 16:22:32 +0000
208+++ src/maasserver/templates/maasserver/node_view.html 2012-04-26 07:54:18 +0000
209@@ -12,30 +12,20 @@
210 Edit node
211 </a>
212 {% endif %}
213- {% if form.action_buttons or can_delete %}
214+ {% if form.action_buttons %}
215 <h4>Actions</h4>
216- {% endif %}
217- {% if can_delete %}
218- {% if node.owner %}
219- <a href="#"
220- title="You cannot delete this node because it's in use."
221- class="disabled button">
222- Delete node
223- </a>
224- {% else %}
225- <a href="{% url 'node-delete' node.system_id %}"
226- class="button secondary">
227- Delete node
228- </a>
229- {% endif %}
230- {% endif %}
231- {% if form.action_buttons %}
232 <form id="node_actions" method="post" action=".">
233 {% for action in form.action_buttons %}
234- <input class="secondary {% if not forloop.first or can_delete %}space-top{% endif %}"
235- type="submit"
236- name="{{ form.input_name }}"
237- value="{{ action.display }}" />
238+ <input
239+ class="secondary {% if action.inhibition %}disabled{% endif %} {% if not forloop.first %}space-top{% endif %}"
240+ type="submit"
241+ name="{{ form.input_name }}"
242+ value="{{ action.display }}"
243+ {% if action.inhibition %}}
244+ title="{{ action.inhibition }}"
245+ disabled="disabled"
246+ {% endif %}
247+ />
248 {% endfor %}
249 </form>
250 {% endif %}
251
252=== modified file 'src/maasserver/tests/test_forms.py'
253--- src/maasserver/tests/test_forms.py 2012-04-26 06:00:47 +0000
254+++ src/maasserver/tests/test_forms.py 2012-04-26 07:54:18 +0000
255@@ -12,11 +12,15 @@
256 __metaclass__ = type
257 __all__ = []
258
259+import httplib
260+from urlparse import urlparse
261+
262 from django import forms
263 from django.core.exceptions import (
264 PermissionDenied,
265 ValidationError,
266 )
267+from django.core.urlresolvers import reverse
268 from django.http import QueryDict
269 import maasserver.api
270 from maasserver.enum import (
271@@ -26,11 +30,14 @@
272 NODE_STATUS,
273 NODE_STATUS_CHOICES_DICT,
274 )
275+from maasserver.exceptions import MAASAPIException
276 from maasserver.forms import (
277 ConfigForm,
278+ delete_node,
279 EditUserForm,
280 get_action_form,
281 HostnameFormField,
282+ inhibit_delete,
283 NewUserCreationForm,
284 NODE_ACTIONS,
285 NodeActionForm,
286@@ -48,6 +55,7 @@
287 from maasserver.testing.factory import factory
288 from maasserver.testing.testcase import TestCase
289 from provisioningserver.enum import POWER_TYPE_CHOICES
290+from testtools.testcase import ExpectedException
291
292
293 class NodeWithMACAddressesFormTest(TestCase):
294@@ -244,7 +252,6 @@
295
296 def test_NODE_ACTIONS_initial_states(self):
297 allowed_states = set(NODE_STATUS_CHOICES_DICT.keys() + [None])
298-
299 self.assertTrue(set(NODE_ACTIONS.keys()) <= allowed_states)
300
301 def test_NODE_ACTIONS_dict_contains_only_accepted_keys(self):
302@@ -263,28 +270,101 @@
303
304 class TestNodeActionForm(TestCase):
305
306- def test_available_action_methods_for_declared_node_admin(self):
307+ def test_inhibit_delete_allows_deletion_of_unowned_node(self):
308+ admin = factory.make_admin()
309+ node = factory.make_node(status=NODE_STATUS.DECLARED)
310+ self.assertIsNone(inhibit_delete(node, admin))
311+
312+ def test_inhibit_delete_inhibits_deletion_of_owned_node(self):
313+ admin = factory.make_admin()
314+ node = factory.make_node(
315+ status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
316+ self.assertEqual(
317+ "You cannot delete this node because it's in use.",
318+ inhibit_delete(node, admin))
319+
320+ def test_delete_node_redirects_to_confirmation_page(self):
321+ admin = factory.make_admin()
322+ node = factory.make_node(status=NODE_STATUS.DECLARED)
323+ try:
324+ delete_node(node, admin)
325+ except MAASAPIException as e:
326+ pass
327+ else:
328+ self.fail("delete_node should have raised a Redirect.")
329+ response = e.make_http_response()
330+ self.assertEqual(httplib.FOUND, response.status_code)
331+ self.assertEqual(
332+ reverse('node-delete', args=[node.system_id]),
333+ urlparse(response['Location']).path)
334+
335+ def have_permission_returns_False_if_action_is_None(self):
336+ admin = factory.make_admin()
337+ node = factory.make_node(status=NODE_STATUS.DECLARED)
338+ self.assertFalse(get_action_form(admin)(node).have_permission(None))
339+
340+ def have_permission_returns_False_if_lacking_permission(self):
341+ user = factory.make_user()
342+ node = factory.make_node(
343+ status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
344+ action = {
345+ 'permission': NODE_PERMISSION.EDIT,
346+ }
347+ self.assertFalse(get_action_form(user)(node).have_permission(action))
348+
349+ def have_permission_returns_True_given_permission(self):
350+ node = factory.make_node(
351+ status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
352+ action = {
353+ 'permission': NODE_PERMISSION.EDIT,
354+ }
355+ form = get_action_form(node.owner)(node)
356+ self.assertTrue(form.have_permission(action))
357+
358+ def test_compile_action_records_inhibition(self):
359+ node = factory.make_node()
360+ user = factory.make_user()
361+ form = get_action_form(user)(node)
362+ inhibition = factory.getRandomString()
363+
364+ def inhibit(*args, **kwargs):
365+ return inhibition
366+
367+ compiled_action = form.compile_action({'inhibit': inhibit})
368+ self.assertEqual(inhibition, compiled_action['inhibition'])
369+
370+ def test_compile_action_records_None_if_no_inhibition(self):
371+ node = factory.make_node()
372+ user = factory.make_user()
373+ form = get_action_form(user)(node)
374+
375+ def inhibit(*args, **kwargs):
376+ return None
377+
378+ compiled_action = form.compile_action({'inhibit': inhibit})
379+ self.assertIsNone(compiled_action['inhibition'])
380+
381+ def test_compile_actions_for_declared_node_admin(self):
382 # Check which transitions are available for an admin on a
383 # 'Declared' node.
384 admin = factory.make_admin()
385 node = factory.make_node(status=NODE_STATUS.DECLARED)
386 form = get_action_form(admin)(node)
387- actions = form.available_action_methods(node, admin)
388- self.assertEqual(
389- ["Accept & commission"],
390+ actions = form.compile_actions()
391+ self.assertItemsEqual(
392+ ["Accept & commission", "Delete node"],
393 [action['display'] for action in actions])
394 # All permissions should be ADMIN.
395 self.assertEqual(
396 [NODE_PERMISSION.ADMIN] * len(actions),
397 [action['permission'] for actions in actions])
398
399- def test_available_action_methods_for_declared_node_simple_user(self):
400+ def test_compile_actions_for_declared_node_simple_user(self):
401 # A simple user sees no actions for a 'Declared' node.
402 user = factory.make_user()
403 node = factory.make_node(status=NODE_STATUS.DECLARED)
404 form = get_action_form(user)(node)
405- self.assertItemsEqual(
406- [], form.available_action_methods(node, user))
407+ self.assertItemsEqual([], form.compile_actions())
408
409 def test_get_action_form_creates_form_class_with_attributes(self):
410 user = factory.make_admin()
411@@ -306,8 +386,8 @@
412 form = get_action_form(admin)(node)
413
414 self.assertItemsEqual(
415- ["Accept & commission"],
416- form.action_dict)
417+ ["Accept & commission", "Delete node"],
418+ [action['display'] for action in form.action_buttons])
419
420 def test_get_action_form_for_user(self):
421 user = factory.make_user()
422@@ -343,6 +423,15 @@
423
424 self.assertRaises(PermissionDenied, form.save)
425
426+ def test_save_double_checks_for_inhibitions(self):
427+ admin = factory.make_admin()
428+ node = factory.make_node(
429+ status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
430+ form = get_action_form(admin)(
431+ node, {NodeActionForm.input_name: "Delete node"})
432+ with ExpectedException(PermissionDenied, "You cannot delete.*"):
433+ form.save()
434+
435 def test_start_action_acquires_and_starts_ready_node_for_user(self):
436 node = factory.make_node(status=NODE_STATUS.READY)
437 user = factory.make_user()
438
439=== modified file 'src/maasserver/tests/test_views_nodes.py'
440--- src/maasserver/tests/test_views_nodes.py 2012-04-26 06:00:47 +0000
441+++ src/maasserver/tests/test_views_nodes.py 2012-04-26 07:54:18 +0000
442@@ -129,14 +129,6 @@
443 else:
444 self.assertNotIn(help_link, links)
445
446- def test_view_node_shows_link_to_delete_node_for_admin(self):
447- self.become_admin()
448- node = factory.make_node()
449- node_link = reverse('node-view', args=[node.system_id])
450- response = self.client.get(node_link)
451- node_delete_link = reverse('node-delete', args=[node.system_id])
452- self.assertIn(node_delete_link, get_content_links(response))
453-
454 def test_admin_can_delete_nodes(self):
455 self.become_admin()
456 node = factory.make_node()
457@@ -256,6 +248,26 @@
458 content_text = doc.cssselect('#content')[0].text_content()
459 self.assertNotIn("Error output", content_text)
460
461+ def test_view_node_POST_admin_can_delete_unused_node(self):
462+ self.become_admin()
463+ node = factory.make_node(status=NODE_STATUS.READY)
464+ response = self.client.post(
465+ reverse('node-view', args=[node.system_id]),
466+ data={NodeActionForm.input_name: "Delete node"})
467+ self.assertEqual(httplib.FOUND, response.status_code)
468+ self.assertEqual(
469+ reverse('node-delete', args=[node.system_id]),
470+ urlparse(response['Location']).path)
471+
472+ def test_view_node_POST_admin_cannot_delete_used_node(self):
473+ self.become_admin()
474+ node = factory.make_node(
475+ status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
476+ response = self.client.post(
477+ reverse('node-view', args=[node.system_id]),
478+ data={NodeActionForm.input_name: "Delete node"})
479+ self.assertEqual(httplib.FORBIDDEN, response.status_code)
480+
481 def test_view_node_POST_admin_can_start_commissioning_node(self):
482 self.become_admin()
483 node = factory.make_node(status=NODE_STATUS.DECLARED)
484
485=== modified file 'src/maasserver/views/nodes.py'
486--- src/maasserver/views/nodes.py 2012-04-25 16:41:15 +0000
487+++ src/maasserver/views/nodes.py 2012-04-26 07:54:18 +0000
488@@ -107,8 +107,6 @@
489 node = self.get_object()
490 context['can_edit'] = self.request.user.has_perm(
491 NODE_PERMISSION.EDIT, node)
492- context['can_delete'] = self.request.user.has_perm(
493- NODE_PERMISSION.ADMIN, node)
494 if node.status in (NODE_STATUS.COMMISSIONING, NODE_STATUS.READY):
495 messages.info(self.request, NODE_BOOT_INFO)
496 context['error_text'] = (