← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~jtv/maas/doris-please-spit-out-your-old-node-actions into lp:maas

 

Jeroen T. Vermeulen has proposed merging lp:~jtv/maas/doris-please-spit-out-your-old-node-actions into lp:maas with lp:~jtv/maas/doris-would-you-like-nicer-node-actions as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jtv/maas/doris-please-spit-out-your-old-node-actions/+merge/103743

This is all part of an undertaking I discussed with Julian: re-organized node actions.  In my prerequisite branch I implemented a new node-actions structure.  In this branch, I move the code over from the old structure to the new one.

You'll note that tests no longer need to spell out the full UI-visible text of the action buttons. Unit tests happen at a very low level on the new module, so the integration tests only need one example per behaviour.  It also frees the unit tests of a lot of the tedium of simulating a request etc.  The view code gets a bit simpler and the template, remarkably, does not need to change at all.  (Actually there are a few attribute names that I'm tempted to change, but it's enough for one day).


Jeroen
-- 
https://code.launchpad.net/~jtv/maas/doris-please-spit-out-your-old-node-actions/+merge/103743
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/maas/doris-please-spit-out-your-old-node-actions into lp:maas.
=== modified file 'src/maasserver/forms.py'
--- src/maasserver/forms.py	2012-04-26 07:57:26 +0000
+++ src/maasserver/forms.py	2012-04-26 18:00:32 +0000
@@ -23,8 +23,6 @@
     "UINodeEditForm",
     ]
 
-from textwrap import dedent
-
 from django import forms
 from django.contrib import messages
 from django.contrib.auth.forms import (
@@ -39,7 +37,6 @@
     PermissionDenied,
     ValidationError,
     )
-from django.core.urlresolvers import reverse
 from django.core.validators import URLValidator
 from django.forms import (
     CharField,
@@ -51,10 +48,7 @@
     ARCHITECTURE_CHOICES,
     NODE_AFTER_COMMISSIONING_ACTION,
     NODE_AFTER_COMMISSIONING_ACTION_CHOICES,
-    NODE_PERMISSION,
-    NODE_STATUS,
     )
-from maasserver.exceptions import Redirect
 from maasserver.fields import MACAddressFormField
 from maasserver.models import (
     Config,
@@ -62,6 +56,7 @@
     Node,
     SSHKey,
     )
+from maasserver.node_action import compile_node_actions
 
 
 def compose_invalid_choice_text(choice_of_what, valid_choices):
@@ -236,145 +231,6 @@
         return node
 
 
-def inhibit_acquisition(node, user, request=None):
-    """Give me one reason why `user` can't acquire and start `node`."""
-    if SSHKey.objects.get_keys_for_user(user).exists():
-        return None
-    else:
-        return dedent("""
-            You have no means of accessing the node after starting it.
-            Register an SSH key first.  Do this on your Preferences screen:
-            click on the menu with your name at the top of the page, select
-            Preferences, and look for the "SSH keys" section.
-            """)
-
-
-def acquire_and_start_node(node, user, request=None):
-    """Acquire and start a node from the UI.  It will have no user_data."""
-    # Avoid circular imports.
-    from maasserver.api import get_oauth_token
-
-    node.acquire(get_oauth_token(request))
-    Node.objects.start_nodes([node.system_id], user)
-
-
-def start_commissioning_node(node, user, request=None):
-    """Start the commissioning process for a node."""
-    node.start_commissioning(user)
-
-
-def inhibit_delete(node, user, request=None):
-    """Give me one reason why `user` can't delete `node`."""
-    if node.owner is not None:
-        return "You cannot delete this node because it's in use."
-    else:
-        return None
-
-
-def delete_node(node, user, request=None):
-    """Delete-a-node action.
-
-    Permissions and inhibitions have already been checked at this point.  We
-    redirect to the deletion view, which asks the user if they're sure.
-    """
-    raise Redirect(reverse('node-delete', args=[node.system_id]))
-
-
-# Node actions per status.
-#
-# This maps each NODE_STATUS to a list of actions applicable to a node
-# in that state:
-#
-# {
-#     NODE_STATUS.<statusX>: [action1, action2],
-#     NODE_STATUS.<statusY>: [action1],
-#     NODE_STATUS.<statusZ>: [action1, action2, action3],
-# }
-#
-# The available actions (insofar as the user has privileges to use them)
-# show up in the user interface as buttons on the node page.
-#
-# Each action is a dict:
-#
-# {
-#     # Action's display name; will be the button's label.
-#     'display': "Paint node",
-#
-#     # Permission required on the node to perform the action.
-#     'permission': NODE_PERMISSION.EDIT,
-#
-#     # Function that looks for reasons to disable the action.
-#     # If it returns None, the action is available; otherwise, it is
-#     # shown but disabled.  A tooltip shows the inhibiting reason.
-#     'inhibit': lambda node, user, request=None: None,
-#
-#     # Callable that performs action.
-#     # Takes parameters (node, user, request).
-#     # Even though this is not the API, the action may raise a
-#     # MAASAPIException if it wants to convey failure with a specific
-#     # http status code.
-#     'execute': lambda node, user, request=None: paint_node(
-#                    node, favourite_colour(user)),
-# }
-#
-DELETE_ACTION = {
-    'display': "Delete node",
-    'permission': NODE_PERMISSION.ADMIN,
-    'execute': delete_node,
-    'message': "Node deleted.",
-    'inhibit': inhibit_delete,
-}
-
-
-NODE_ACTIONS = {
-    NODE_STATUS.DECLARED: [
-        DELETE_ACTION,
-        {
-            'display': "Accept & commission",
-            'permission': NODE_PERMISSION.ADMIN,
-            'execute': start_commissioning_node,
-            'message': "Node commissioning started.",
-        },
-    ],
-    NODE_STATUS.FAILED_TESTS: [
-        DELETE_ACTION,
-        {
-            'display': "Retry commissioning",
-            'permission': NODE_PERMISSION.ADMIN,
-            'execute': start_commissioning_node,
-            'message': "Started a new attempt to commission this node.",
-        },
-    ],
-    NODE_STATUS.COMMISSIONING: [
-        DELETE_ACTION,
-    ],
-    NODE_STATUS.MISSING: [
-        DELETE_ACTION,
-    ],
-    NODE_STATUS.READY: [
-        DELETE_ACTION,
-        {
-            'display': "Start node",
-            'permission': NODE_PERMISSION.VIEW,
-            'execute': acquire_and_start_node,
-            'message': (
-                "This node is now allocated to you.  "
-                "It has been asked to start up."),
-            'inhibit': inhibit_acquisition,
-        },
-    ],
-    NODE_STATUS.RESERVED: [
-        DELETE_ACTION,
-    ],
-    NODE_STATUS.ALLOCATED: [
-        DELETE_ACTION,
-    ],
-    NODE_STATUS.RETIRED: [
-        DELETE_ACTION,
-    ],
-}
-
-
 class NodeActionForm(forms.Form):
     """Base form for performing a node action.
 
@@ -391,62 +247,8 @@
     def __init__(self, instance, *args, **kwargs):
         super(NodeActionForm, self).__init__(*args, **kwargs)
         self.node = instance
-        self.action_buttons = self.compile_actions()
-
-    def have_permission(self, action=None):
-        """Does the current user have the permission required by `action`?
-
-        The permission is relative to `self.node`.
-
-        :param action: Action to check.  If no action is given, the answer is
-            always False.
-        :return: Does the user have the required permission to perform
-            `action` on `self.node`?
-        """
-        if action is None:
-            return False
-        else:
-            return self.user.has_perm(action['permission'], self.node)
-
-    def compile_action(self, action):
-        """Return a copy of `action`, but add "inhibition" entry.
-
-        The key "inhibition" maps to a unicode string explaining why the
-        action is not currently available for `self.node`, or None if the
-        action can be executed.
-        """
-        compiled_action = action.copy()
-        inhibit = action.get('inhibit', lambda node, user, request: None)
-        compiled_action['inhibition'] = inhibit(
-                node=self.node, user=self.user, request=self.request)
-        return compiled_action
-
-    def compile_actions(self):
-        """Return the actions that this user is allowed to perform on a node.
-
-        Each action will be "filled out," i.e. it will have an "inhibit"
-        callable even if the original action in NODE_ACTIONS did not.
-
-        :param node: The node for which the check should be performed.
-        :type node: :class:`maasserver.models.Node`
-        :param user: The user who would be performing the action.  Only the
-            actions available to this user will be returned.
-        :type user: :class:`django.contrib.auth.models.User`
-        :return: Any applicable action dicts, as compiled based on
-            NODE_ACTIONS and the current user and node.
-        :rtype: Sequence
-        """
-        return [
-            self.compile_action(action)
-            for action in NODE_ACTIONS.get(self.node.status, [])
-                if self.have_permission(action)]
-
-    def find_action(self, action_name):
-        """Find the compiled action of the given name, or return None."""
-        for action in self.action_buttons:
-            if action['display'] == action_name:
-                return action
-        return None
+        self.actions = compile_node_actions(instance, self.user, self.request)
+        self.action_buttons = self.actions.values()
 
     def display_message(self, message):
         """Show `message` as feedback after performing an action."""
@@ -456,17 +258,13 @@
     def save(self):
         """An action was requested.  Perform it."""
         action_name = self.data.get(self.input_name)
-        action = self.find_action(action_name)
-
-        if not self.have_permission(action):
+        action = self.actions.get(action_name)
+        if action is None or not action.is_permitted():
             raise PermissionDenied("Not a permitted action: %s" % action_name)
-
-        if action['inhibition'] is not None:
-            raise PermissionDenied(action['inhibition'])
-
-        action['execute'](
-            node=self.node, user=self.user, request=self.request)
-        self.display_message(action.get('message'))
+        if action.inhibition is not None:
+            raise PermissionDenied(action.inhibition)
+        message = action.perform()
+        self.display_message(message)
 
 
 def get_action_form(user, request=None):

=== added file 'src/maasserver/node_action.py'
--- src/maasserver/node_action.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/node_action.py	2012-04-26 18:00:32 +0000
@@ -0,0 +1,225 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Node actions.
+
+These are actions that appear as buttons no the UI's Node page, depending
+on the node's state, the user's privileges etc.
+
+To define a new node action, derive a class for it from :class:`NodeAction`,
+provide the missing pieces documented in the class, and add it to
+`ACTION_CLASSES`.  The actions will always appear on the page in the same
+order as they do in `ACTION_CLASSES`.
+"""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    'compile_node_actions',
+    ]
+
+from abc import (
+    ABCMeta,
+    abstractmethod,
+    abstractproperty,
+    )
+from collections import OrderedDict
+from textwrap import dedent
+
+from django.core.urlresolvers import reverse
+from maasserver.enum import (
+    NODE_PERMISSION,
+    NODE_STATUS,
+    NODE_STATUS_CHOICES_DICT,
+    )
+from maasserver.exceptions import Redirect
+from maasserver.models import (
+    Node,
+    SSHKey,
+    )
+
+# All node statuses.
+ALL_STATUSES = set(NODE_STATUS_CHOICES_DICT.keys())
+
+
+class NodeAction:
+    """Base class for node actions."""
+
+    __metaclass__ = ABCMeta
+
+    display = abstractproperty("""
+        Action name.
+
+        Will be used as the label for the action's button.
+        """)
+
+    statuses = abstractproperty("""
+        Node states that this action makes sense for.
+
+        A collection of NODE_STATUS values.  The action will be available
+        only if `node.status in action.statuses`.
+        """)
+
+    permission = abstractproperty("""
+        Required permission.
+
+        A NODE_PERMISSION value.  The action will be available only if the
+        user has this given permission on the subject node.
+        """)
+
+    def __init__(self, node, user, request=None):
+        """Initialize a node action.
+
+        All node actions' initializers must accept these same arguments,
+        without variations.
+        """
+        self.node = node
+        self.user = user
+        self.request = request
+
+    def inhibit(self):
+        """Overridable: is there any reason not to offer this action?
+
+        This property may return a reason to inhibit this action, in which
+        case its button may still be visible in the UI, but disabled.  A
+        tooltip will provide the reason, as returned by this method.
+
+        :return: A human-readable reason to inhibit the action, or None if
+            the action is valid.
+        """
+        return None
+
+    @abstractmethod
+    def perform(self):
+        """Perform this action.
+
+        Even though this is not the API, the action may raise
+        :class:`MAASAPIException` exceptions.  When this happens, the view
+        will return to the client an http response reflecting the exception.
+
+        :return: A human-readable message confirming that the action has been
+            performed.  It will be shown as an informational notice on the
+            Node page.
+        """
+
+    def is_permitted(self):
+        """Does the current user have the permission required?"""
+        return self.user.has_perm(self.permission, self.node)
+
+    # Uninitialized inhibititions cache.
+    _cached_inhibition = object()
+
+    @property
+    def inhibition(self):
+        """Caching version of `inhibit`."""
+        if self._cached_inhibition == NodeAction._cached_inhibition:
+            self._cached_inhibition = self.inhibit()
+        return self._cached_inhibition
+
+
+class Delete(NodeAction):
+    """Delete a node."""
+    display = "Delete node"
+    statuses = ALL_STATUSES
+    permission = NODE_PERMISSION.ADMIN
+
+    def inhibit(self):
+        if self.node.owner is not None:
+            return "You cannot delete this node because it's in use."
+        return None
+
+    def perform(self):
+        """Redirect to the delete view's confirmation page.
+
+        The rest of deletion is handled by a specialized deletion view.
+        All that the action really does is get you to its are-you-sure
+        page.
+        """
+        raise Redirect(reverse('node-delete', args=[self.node.system_id]))
+
+
+class AcceptAndCommission(NodeAction):
+    """Accept a node into the MAAS, and start the commissioning process."""
+    display = "Accept & commission"
+    statuses = (NODE_STATUS.DECLARED, )
+    permission = NODE_PERMISSION.ADMIN
+
+    def perform(self):
+        self.node.start_commissioning(self.user)
+        return "Node commissioning started."
+
+
+class RetryCommissioning(NodeAction):
+    """Retry commissioning of a node that failed previously."""
+    display = "Retry commissioning"
+    statuses = (NODE_STATUS.FAILED_TESTS, )
+    permission = NODE_PERMISSION.ADMIN
+
+    def perform(self):
+        self.node.start_commissioning(self.user)
+        return "Started a new attempt to commission this node."
+
+
+class StartNode(NodeAction):
+    """Acquire and start a node."""
+    display = "Start node"
+    statuses = (NODE_STATUS.READY, )
+    permission = NODE_PERMISSION.VIEW
+
+    def inhibit(self):
+        """The user must have an SSH key, so that they access the node."""
+        if not SSHKey.objects.get_keys_for_user(self.user).exists():
+            return dedent("""
+                You have no means of accessing the node after starting it.
+                Register an SSH key first.  Do this on your Preferences
+                screen: click on the menu with your name at the top of the
+                page, select Preferences, and look for the "SSH keys" section.
+                """)
+        return None
+
+    def perform(self):
+        # Avoid circular imports.
+        from maasserver.api import get_oauth_token
+
+        # Be sure to acquire before starting, or start_nodes will think
+        # the node ineligible based on its un-acquired status.
+        self.node.acquire(get_oauth_token(self.request))
+        Node.objects.start_nodes([self.node.system_id], self.user)
+        return dedent("""
+            This node is now allocated to you.
+            It has been asked to start up.
+            """)
+
+
+ACTION_CLASSES = (
+    Delete,
+    AcceptAndCommission,
+    RetryCommissioning,
+    StartNode,
+    )
+
+
+def compile_node_actions(node, user, request=None, classes=ACTION_CLASSES):
+    """Provide :class:`NodeAction` objects for given request.
+
+    :param node: The :class:`Node` that the request pertains to.
+    :param user: The :class:`User` making the request.
+    :param request: The :class:`HttpRequest` being serviced.  It may be used
+        to obtain information about the OAuth token being used.
+    :return: An :class:`OrderedDict` mapping applicable actions' display names
+        to corresponding :class:`NodeAction` instances.  The dict is ordered
+        for consistent display.
+    """
+    applicable_actions = (
+        action_class(node, user, request)
+        for action_class in classes
+            if node.status in action_class.statuses)
+    return OrderedDict(
+        (action.display, action)
+        for action in applicable_actions
+            if action.is_permitted())

=== modified file 'src/maasserver/tests/test_forms.py'
--- src/maasserver/tests/test_forms.py	2012-04-26 07:57:26 +0000
+++ src/maasserver/tests/test_forms.py	2012-04-26 18:00:32 +0000
@@ -12,34 +12,23 @@
 __metaclass__ = type
 __all__ = []
 
-import httplib
-from urlparse import urlparse
-
 from django import forms
 from django.core.exceptions import (
     PermissionDenied,
     ValidationError,
     )
-from django.core.urlresolvers import reverse
 from django.http import QueryDict
-import maasserver.api
 from maasserver.enum import (
     ARCHITECTURE,
     NODE_AFTER_COMMISSIONING_ACTION_CHOICES,
-    NODE_PERMISSION,
     NODE_STATUS,
-    NODE_STATUS_CHOICES_DICT,
     )
-from maasserver.exceptions import MAASAPIException
 from maasserver.forms import (
     ConfigForm,
-    delete_node,
     EditUserForm,
     get_action_form,
     HostnameFormField,
-    inhibit_delete,
     NewUserCreationForm,
-    NODE_ACTIONS,
     NodeActionForm,
     NodeWithMACAddressesForm,
     ProfileForm,
@@ -51,7 +40,10 @@
     Config,
     DEFAULT_CONFIG,
     )
-from maasserver.provisioning import get_provisioning_api_proxy
+from maasserver.node_action import (
+    AcceptAndCommission,
+    Delete,
+    )
 from maasserver.testing.factory import factory
 from maasserver.testing.testcase import TestCase
 from provisioningserver.enum import POWER_TYPE_CHOICES
@@ -247,125 +239,8 @@
         self.assertEqual(power_type, node.power_type)
 
 
-class NodeActionsTests(TestCase):
-    """Test the structure of NODE_ACTIONS."""
-
-    def test_NODE_ACTIONS_initial_states(self):
-        allowed_states = set(NODE_STATUS_CHOICES_DICT.keys() + [None])
-        self.assertTrue(set(NODE_ACTIONS.keys()) <= allowed_states)
-
-    def test_NODE_ACTIONS_dict_contains_only_accepted_keys(self):
-        actions = sum(NODE_ACTIONS.values(), [])
-        accepted_keys = set([
-            'permission',
-            'inhibit',
-            'display',
-            'execute',
-            'message',
-            ])
-        actual_keys = set(sum([action.keys() for action in actions], []))
-        unknown_keys = actual_keys - accepted_keys
-        self.assertEqual(set(), unknown_keys)
-
-
 class TestNodeActionForm(TestCase):
 
-    def test_inhibit_delete_allows_deletion_of_unowned_node(self):
-        admin = factory.make_admin()
-        node = factory.make_node(status=NODE_STATUS.DECLARED)
-        self.assertIsNone(inhibit_delete(node, admin))
-
-    def test_inhibit_delete_inhibits_deletion_of_owned_node(self):
-        admin = factory.make_admin()
-        node = factory.make_node(
-            status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
-        self.assertEqual(
-            "You cannot delete this node because it's in use.",
-            inhibit_delete(node, admin))
-
-    def test_delete_node_redirects_to_confirmation_page(self):
-        admin = factory.make_admin()
-        node = factory.make_node(status=NODE_STATUS.DECLARED)
-        try:
-            delete_node(node, admin)
-        except MAASAPIException as e:
-            pass
-        else:
-            self.fail("delete_node should have raised a Redirect.")
-        response = e.make_http_response()
-        self.assertEqual(httplib.FOUND, response.status_code)
-        self.assertEqual(
-            reverse('node-delete', args=[node.system_id]),
-            urlparse(response['Location']).path)
-
-    def have_permission_returns_False_if_action_is_None(self):
-        admin = factory.make_admin()
-        node = factory.make_node(status=NODE_STATUS.DECLARED)
-        self.assertFalse(get_action_form(admin)(node).have_permission(None))
-
-    def have_permission_returns_False_if_lacking_permission(self):
-        user = factory.make_user()
-        node = factory.make_node(
-            status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
-        action = {
-            'permission': NODE_PERMISSION.EDIT,
-        }
-        self.assertFalse(get_action_form(user)(node).have_permission(action))
-
-    def have_permission_returns_True_given_permission(self):
-        node = factory.make_node(
-            status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
-        action = {
-            'permission': NODE_PERMISSION.EDIT,
-        }
-        form = get_action_form(node.owner)(node)
-        self.assertTrue(form.have_permission(action))
-
-    def test_compile_action_records_inhibition(self):
-        node = factory.make_node()
-        user = factory.make_user()
-        form = get_action_form(user)(node)
-        inhibition = factory.getRandomString()
-
-        def inhibit(*args, **kwargs):
-            return inhibition
-
-        compiled_action = form.compile_action({'inhibit': inhibit})
-        self.assertEqual(inhibition, compiled_action['inhibition'])
-
-    def test_compile_action_records_None_if_no_inhibition(self):
-        node = factory.make_node()
-        user = factory.make_user()
-        form = get_action_form(user)(node)
-
-        def inhibit(*args, **kwargs):
-            return None
-
-        compiled_action = form.compile_action({'inhibit': inhibit})
-        self.assertIsNone(compiled_action['inhibition'])
-
-    def test_compile_actions_for_declared_node_admin(self):
-        # Check which transitions are available for an admin on a
-        # 'Declared' node.
-        admin = factory.make_admin()
-        node = factory.make_node(status=NODE_STATUS.DECLARED)
-        form = get_action_form(admin)(node)
-        actions = form.compile_actions()
-        self.assertItemsEqual(
-            ["Accept & commission", "Delete node"],
-            [action['display'] for action in actions])
-        # All permissions should be ADMIN.
-        self.assertEqual(
-            [NODE_PERMISSION.ADMIN] * len(actions),
-            [action['permission'] for actions in actions])
-
-    def test_compile_actions_for_declared_node_simple_user(self):
-        # A simple user sees no actions for a 'Declared' node.
-        user = factory.make_user()
-        node = factory.make_node(status=NODE_STATUS.DECLARED)
-        form = get_action_form(user)(node)
-        self.assertItemsEqual([], form.compile_actions())
-
     def test_get_action_form_creates_form_class_with_attributes(self):
         user = factory.make_admin()
         form_class = get_action_form(user)
@@ -386,8 +261,8 @@
         form = get_action_form(admin)(node)
 
         self.assertItemsEqual(
-            ["Accept & commission", "Delete node"],
-            [action['display'] for action in form.action_buttons])
+            [AcceptAndCommission.display, Delete.display],
+            form.actions)
 
     def test_get_action_form_for_user(self):
         user = factory.make_user()
@@ -396,31 +271,28 @@
 
         self.assertIsInstance(form, NodeActionForm)
         self.assertEqual(node, form.node)
-        self.assertItemsEqual({}, form.action_buttons)
+        self.assertItemsEqual({}, form.actions)
 
-    def test_get_action_form_node_for_admin_save(self):
+    def test_save_performs_requested_action(self):
         admin = factory.make_admin()
         node = factory.make_node(status=NODE_STATUS.DECLARED)
         form = get_action_form(admin)(
-            node, {NodeActionForm.input_name: "Accept & commission"})
+            node, {NodeActionForm.input_name: AcceptAndCommission.display})
         form.save()
-
         self.assertEqual(NODE_STATUS.COMMISSIONING, node.status)
 
-    def test_get_action_form_for_user_save(self):
+    def test_save_refuses_disallowed_action(self):
         user = factory.make_user()
         node = factory.make_node(status=NODE_STATUS.DECLARED)
         form = get_action_form(user)(
-            node, {NodeActionForm.input_name: "Enlist node"})
-
+            node, {NodeActionForm.input_name: AcceptAndCommission.display})
         self.assertRaises(PermissionDenied, form.save)
 
-    def test_get_action_form_for_user_save_unknown_trans(self):
+    def test_save_refuses_unknown_action(self):
         user = factory.make_user()
         node = factory.make_node(status=NODE_STATUS.DECLARED)
         form = get_action_form(user)(
             node, {NodeActionForm.input_name: factory.getRandomString()})
-
         self.assertRaises(PermissionDenied, form.save)
 
     def test_save_double_checks_for_inhibitions(self):
@@ -428,52 +300,10 @@
         node = factory.make_node(
             status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
         form = get_action_form(admin)(
-            node, {NodeActionForm.input_name: "Delete node"})
+            node, {NodeActionForm.input_name: Delete.display})
         with ExpectedException(PermissionDenied, "You cannot delete.*"):
             form.save()
 
-    def test_start_action_acquires_and_starts_ready_node_for_user(self):
-        node = factory.make_node(status=NODE_STATUS.READY)
-        user = factory.make_user()
-        factory.make_sshkey(user)
-        consumer, token = user.get_profile().create_authorisation_token()
-        self.patch(maasserver.api, 'get_oauth_token', lambda request: token)
-        form = get_action_form(user)(
-            node, {NodeActionForm.input_name: "Start node"})
-        form.save()
-
-        power_status = get_provisioning_api_proxy().power_status
-        self.assertEqual('start', power_status.get(node.system_id))
-        self.assertEqual(NODE_STATUS.ALLOCATED, node.status)
-        self.assertEqual(user, node.owner)
-
-    def test_start_action_is_enabled_for_user_with_key(self):
-        node = factory.make_node(status=NODE_STATUS.READY)
-        user = factory.make_user()
-        factory.make_sshkey(user)
-        consumer, token = user.get_profile().create_authorisation_token()
-        self.patch(maasserver.api, 'get_oauth_token', lambda request: token)
-        form = get_action_form(user)(node)
-        # The user has an SSH key, so there is no inhibition to stop
-        # them from starting a node.
-        self.assertIsNone(form.find_action("Start node")['inhibition'])
-
-    def test_start_action_is_disabled_for_keyless_user(self):
-        node = factory.make_node(status=NODE_STATUS.READY)
-        user = factory.make_user()
-        consumer, token = user.get_profile().create_authorisation_token()
-        self.patch(maasserver.api, 'get_oauth_token', lambda request: token)
-        form = get_action_form(user)(node)
-        self.assertIn("SSH key", form.find_action("Start node")['inhibition'])
-
-    def test_accept_and_commission_starts_commissioning(self):
-        admin = factory.make_admin()
-        node = factory.make_node(status=NODE_STATUS.DECLARED)
-        form = get_action_form(admin)(
-            node, {NodeActionForm.input_name: "Accept & commission"})
-        form.save()
-        self.assertEqual(NODE_STATUS.COMMISSIONING, node.status)
-
 
 class TestHostnameFormField(TestCase):
 

=== added file 'src/maasserver/tests/test_node_action.py'
--- src/maasserver/tests/test_node_action.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_node_action.py	2012-04-26 18:00:32 +0000
@@ -0,0 +1,232 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for node actions."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+from urlparse import urlparse
+
+from django.core.urlresolvers import reverse
+import maasserver.api
+from maasserver.enum import (
+    NODE_PERMISSION,
+    NODE_STATUS,
+    NODE_STATUS_CHOICES_DICT,
+    )
+from maasserver.exceptions import Redirect
+from maasserver.node_action import (
+    AcceptAndCommission,
+    compile_node_actions,
+    Delete,
+    NodeAction,
+    RetryCommissioning,
+    StartNode,
+    )
+from maasserver.provisioning import get_provisioning_api_proxy
+from maasserver.testing.factory import factory
+from maasserver.testing.testcase import TestCase
+
+
+ALL_STATUSES = NODE_STATUS_CHOICES_DICT.keys()
+
+
+class FakeNodeAction(NodeAction):
+    display = "Action label"
+    statuses = ALL_STATUSES
+    permission = NODE_PERMISSION.VIEW
+
+    # For testing: an inhibition for inhibit() to return.
+    fake_inhibition = None
+
+    def inhibit(self):
+        return self.fake_inhibition
+
+    def perform(self):
+        pass
+
+
+class TestNodeAction(TestCase):
+
+    def test_compile_node_actions_returns_available_actions(self):
+
+        class MyAction(FakeNodeAction):
+            display = factory.getRandomString()
+
+        actions = compile_node_actions(
+            factory.make_node(), factory.make_admin(), classes=[MyAction])
+        self.assertEqual([MyAction.display], actions.keys())
+
+    def test_compile_node_actions_checks_node_status(self):
+
+        class MyAction(FakeNodeAction):
+            statuses = (NODE_STATUS.READY, )
+
+        node = factory.make_node(status=NODE_STATUS.DECLARED)
+        actions = compile_node_actions(
+            node, factory.make_admin(), classes=[MyAction])
+        self.assertEqual({}, actions)
+
+    def test_compile_node_actions_checks_permission(self):
+
+        class MyAction(FakeNodeAction):
+            permission = NODE_PERMISSION.EDIT
+
+        node = factory.make_node(status=NODE_STATUS.COMMISSIONING)
+        actions = compile_node_actions(
+            node, factory.make_user(), classes=[MyAction])
+        self.assertEqual({}, actions)
+
+    def test_compile_node_actions_includes_inhibited_actions(self):
+
+        class MyAction(FakeNodeAction):
+            fake_inhibition = factory.getRandomString()
+
+        actions = compile_node_actions(
+            factory.make_node(), factory.make_admin(), classes=[MyAction])
+        self.assertEqual([MyAction.display], actions.keys())
+
+    def test_compile_node_actions_maps_display_names(self):
+
+        class Action1(FakeNodeAction):
+            display = factory.getRandomString()
+
+        class Action2(FakeNodeAction):
+            display = factory.getRandomString()
+
+        actions = compile_node_actions(
+            factory.make_node(), factory.make_admin(),
+            classes=[Action1, Action2])
+        for label, action in actions.items():
+            self.assertEqual(label, action.display)
+
+    def test_compile_node_actions_maintains_order(self):
+        labels = [factory.getRandomString() for counter in range(4)]
+        classes = [
+            type(b"Action%d" % counter, (FakeNodeAction,), {'display': label})
+            for counter, label in enumerate(labels)]
+        actions = compile_node_actions(
+            factory.make_node(), factory.make_admin(), classes=classes)
+        self.assertSequenceEqual(labels, actions.keys())
+        self.assertSequenceEqual(
+            labels, [action.display for action in actions.values()])
+
+    def test_is_permitted_allows_if_user_has_permission(self):
+
+        class MyAction(FakeNodeAction):
+            permission = NODE_PERMISSION.EDIT
+
+        node = factory.make_node(
+            status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
+        self.assertTrue(MyAction(node, node.owner).is_permitted())
+
+    def test_is_permitted_disallows_if_user_lacks_permission(self):
+
+        class MyAction(FakeNodeAction):
+            permission = NODE_PERMISSION.EDIT
+
+        node = factory.make_node(
+            status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
+        self.assertFalse(MyAction(node, factory.make_user()).is_permitted())
+
+    def test_inhibition_wraps_inhibit(self):
+        inhibition = factory.getRandomString()
+        action = FakeNodeAction(factory.make_node(), factory.make_user())
+        action.fake_inhibition = inhibition
+        self.assertEqual(inhibition, action.inhibition)
+
+    def test_inhibition_caches_inhibition(self):
+        # The inhibition property will call inhibit() only once.  We can
+        # prove this by changing the string inhibit() returns; it won't
+        # affect the value of the property.
+        inhibition = factory.getRandomString()
+        action = FakeNodeAction(factory.make_node(), factory.make_user())
+        action.fake_inhibition = inhibition
+        self.assertEqual(inhibition, action.inhibition)
+        action.fake_inhibition = factory.getRandomString()
+        self.assertEqual(inhibition, action.inhibition)
+
+    def test_inhibition_caches_None(self):
+        # An inhibition of None is also faithfully cached.  In other
+        # words, it doesn't get mistaken for an uninitialized cache or
+        # anything.
+        action = FakeNodeAction(factory.make_node(), factory.make_user())
+        action.fake_inhibition = None
+        self.assertIsNone(action.inhibition)
+        action.fake_inhibition = factory.getRandomString()
+        self.assertIsNone(action.inhibition)
+
+    def test_Delete_inhibit_allows_if_node_has_no_owner(self):
+        unowned_node = factory.make_node(status=NODE_STATUS.READY)
+        self.assertIsNone(
+            Delete(unowned_node, factory.make_admin()).inhibit())
+
+    def test_Delete_inhibit_disallows_if_node_has_owner(self):
+        owned_node = factory.make_node(
+            status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
+        action = Delete(owned_node, factory.make_admin())
+        inhibition = action.inhibit()
+        self.assertIsNotNone(inhibition)
+        self.assertEqual(
+            "You cannot delete this node because it's in use.", inhibition)
+
+    def test_Delete_redirects_to_node_delete_view(self):
+        node = factory.make_node()
+        action = Delete(node, factory.make_admin())
+        try:
+            action.perform()
+        except Redirect as e:
+            pass
+        self.assertEqual(
+            reverse('node-delete', args=[node.system_id]),
+            urlparse(unicode(e)).path)
+
+    def test_AcceptAndCommission_starts_commissioning(self):
+        node = factory.make_node(status=NODE_STATUS.DECLARED)
+        action = AcceptAndCommission(node, factory.make_admin())
+        action.perform()
+        self.assertEqual(NODE_STATUS.COMMISSIONING, node.status)
+        self.assertEqual(
+            'start',
+            get_provisioning_api_proxy().power_status.get(node.system_id))
+
+    def test_RetryCommissioning_starts_commissioning(self):
+        node = factory.make_node(status=NODE_STATUS.FAILED_TESTS)
+        action = RetryCommissioning(node, factory.make_admin())
+        action.perform()
+        self.assertEqual(NODE_STATUS.COMMISSIONING, node.status)
+        self.assertEqual(
+            'start',
+            get_provisioning_api_proxy().power_status.get(node.system_id))
+
+    def test_StartNode_inhibit_allows_user_with_SSH_key(self):
+        user_with_key = factory.make_user()
+        factory.make_sshkey(user_with_key)
+        self.assertIsNone(
+            StartNode(factory.make_node(), user_with_key).inhibit())
+
+    def test_StartNode_inhibit_disallows_user_without_SSH_key(self):
+        user_without_key = factory.make_user()
+        action = StartNode(factory.make_node(), user_without_key)
+        inhibition = action.inhibit()
+        self.assertIsNotNone(inhibition)
+        self.assertIn("SSH key", inhibition)
+
+    def test_StartNode_acquires_and_starts_node(self):
+        node = factory.make_node(status=NODE_STATUS.READY)
+        user = factory.make_user()
+        consumer, token = user.get_profile().create_authorisation_token()
+        self.patch(maasserver.api, 'get_oauth_token', lambda request: token)
+        StartNode(node, user).perform()
+        self.assertEqual(NODE_STATUS.ALLOCATED, node.status)
+        self.assertEqual(user, node.owner)
+        self.assertEqual(
+            'start',
+            get_provisioning_api_proxy().power_status.get(node.system_id))

=== modified file 'src/maasserver/tests/test_views_nodes.py'
--- src/maasserver/tests/test_views_nodes.py	2012-04-26 07:57:26 +0000
+++ src/maasserver/tests/test_views_nodes.py	2012-04-26 18:00:32 +0000
@@ -27,6 +27,7 @@
 from maasserver.exceptions import NoRabbit
 from maasserver.forms import NodeActionForm
 from maasserver.models import Node
+from maasserver.node_action import StartNode
 from maasserver.testing import (
     get_content_links,
     reload_object,
@@ -47,6 +48,12 @@
 
 class NodeViewsTest(LoggedInTestCase):
 
+    def set_up_oauth_token(self):
+        """Set up an oauth token to be used for requests."""
+        profile = self.logged_in_user.get_profile()
+        consumer, token = profile.create_authorisation_token()
+        self.patch(maasserver.api, 'get_oauth_token', lambda request: token)
+
     def test_node_list_contains_link_to_node_view(self):
         node = factory.make_node()
         response = self.client.get(reverse('node-list'))
@@ -248,95 +255,44 @@
         content_text = doc.cssselect('#content')[0].text_content()
         self.assertNotIn("Error output", content_text)
 
-    def test_view_node_POST_admin_can_delete_unused_node(self):
-        self.become_admin()
+    def test_view_node_POST_performs_action(self):
+        factory.make_sshkey(self.logged_in_user)
+        self.set_up_oauth_token()
         node = factory.make_node(status=NODE_STATUS.READY)
-        response = self.client.post(
-            reverse('node-view', args=[node.system_id]),
-            data={NodeActionForm.input_name: "Delete node"})
-        self.assertEqual(httplib.FOUND, response.status_code)
-        self.assertEqual(
-            reverse('node-delete', args=[node.system_id]),
-            urlparse(response['Location']).path)
-
-    def test_view_node_POST_admin_cannot_delete_used_node(self):
-        self.become_admin()
-        node = factory.make_node(
-            status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
-        response = self.client.post(
-            reverse('node-view', args=[node.system_id]),
-            data={NodeActionForm.input_name: "Delete node"})
-        self.assertEqual(httplib.FORBIDDEN, response.status_code)
-
-    def test_view_node_POST_admin_can_start_commissioning_node(self):
-        self.become_admin()
-        node = factory.make_node(status=NODE_STATUS.DECLARED)
-        node_link = reverse('node-view', args=[node.system_id])
-        response = self.client.post(
-            node_link,
-            data={
-                NodeActionForm.input_name: "Accept & commission",
-            })
-        self.assertEqual(httplib.FOUND, response.status_code)
-        self.assertEqual(
-            NODE_STATUS.COMMISSIONING, reload_object(node).status)
-
-    def test_view_node_POST_admin_can_retry_failed_commissioning(self):
-        self.become_admin()
-        node = factory.make_node(status=NODE_STATUS.FAILED_TESTS)
-        node_link = reverse('node-view', args=[node.system_id])
-        response = self.client.post(
-            node_link,
-            data={NodeActionForm.input_name: "Retry commissioning"})
-        self.assertEqual(httplib.FOUND, response.status_code)
-        self.assertEqual(
-            NODE_STATUS.COMMISSIONING, reload_object(node).status)
+        node_link = reverse('node-view', args=[node.system_id])
+        response = self.client.post(
+            node_link, data={NodeActionForm.input_name: StartNode.display})
+        self.assertEqual(httplib.FOUND, response.status_code)
+        self.assertEqual(NODE_STATUS.ALLOCATED, reload_object(node).status)
 
     def perform_action_and_get_node_page(self, node, action_name):
         """POST to perform a node action, then load the resulting page."""
         node_link = reverse('node-view', args=[node.system_id])
         response = self.client.post(
-            node_link,
-            data={
-                NodeActionForm.input_name: action_name,
-            })
+            node_link, data={NodeActionForm.input_name: action_name})
         if response.status_code != httplib.FOUND:
-            self.fail(
-                "POST failed with code %d: '%s'"
-                % (response.status_code, response.content))
+            self.fail("%d: '%s'" % (response.status_code, response.content))
         redirect = urlparse(response['Location']).path
         if redirect != node_link:
-            self.fail(
-                "Odd: POST on %s redirected to %s." % (node_link, redirect))
+            self.fail("Odd: %s redirected to %s." % (node_link, redirect))
         return self.client.get(redirect)
 
-    def test_start_commisioning_displays_message(self):
-        self.become_admin()
-        node = factory.make_node(status=NODE_STATUS.DECLARED)
+    def test_view_node_POST_action_displays_message(self):
+        factory.make_sshkey(self.logged_in_user)
+        self.set_up_oauth_token()
+        node = factory.make_node(status=NODE_STATUS.READY)
         response = self.perform_action_and_get_node_page(
-            node, "Accept & commission")
+            node, StartNode.display)
         self.assertIn(
-            "Node commissioning started.",
-            [message.message for message in response.context['messages']])
-
-    def test_start_node_displays_message(self):
-        factory.make_sshkey(self.logged_in_user)
-        profile = self.logged_in_user.get_profile()
-        consumer, token = profile.create_authorisation_token()
-        self.patch(maasserver.api, 'get_oauth_token', lambda request: token)
-        node = factory.make_node(status=NODE_STATUS.READY)
-        response = self.perform_action_and_get_node_page(node, "Start node")
-        notices = '\n'.join(
-            message.message for message in response.context['messages'])
-        self.assertIn("This node is now allocated to you.", notices)
-        self.assertIn("asked to start up.", notices)
-
-    def test_start_node_without_auth_returns_Unauthorized(self):
+            "This node is now allocated to you.",
+            '\n'.join(msg.message for msg in response.context['messages']))
+
+    def test_view_node_POST_without_oauth_returns_Unauthorized(self):
         factory.make_sshkey(self.logged_in_user)
         node = factory.make_node(status=NODE_STATUS.READY)
         response = self.client.post(
             reverse('node-view', args=[node.system_id]),
-            data={NodeActionForm.input_name: "Start node"})
+            data={NodeActionForm.input_name: StartNode.display})
         self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
 
 


Follow ups