← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~rvb/maas/maas-admin-approve-nodes into lp:maas

 

Raphaël Badin has proposed merging lp:~rvb/maas/maas-admin-approve-nodes into lp:maas.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~rvb/maas/maas-admin-approve-nodes/+merge/100175

This branch features the backend side of "An admin can enlist nodes" story.  I've refactored NODE_TRANSITIONS (into NODE_TRANSITIONS_INFO) to include a method transition information that associates a status change to a method being called on the node.  The goal is to add actions to the UI only by modifying NODE_TRANSITIONS_INFO.

I've added tests to make sure that the structure of NODE_TRANSITIONS_INFO is correct.  We will rely on that structure in the follow-up branch (https://code.launchpad.net/~rvb/maas/maas-admin-approve-nodes-ui/+merge/100176) .Also, don't be surprised by the new permission check that returns always false: Django replies always true when a permission check is done on a super user so we only have to take care of the case where the user is not a super user.
-- 
https://code.launchpad.net/~rvb/maas/maas-admin-approve-nodes/+merge/100175
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rvb/maas/maas-admin-approve-nodes into lp:maas.
=== modified file 'src/maasserver/models.py'
--- src/maasserver/models.py	2012-03-30 10:39:56 +0000
+++ src/maasserver/models.py	2012-03-30 15:45:50 +0000
@@ -18,6 +18,7 @@
     "Config",
     "FileStorage",
     "NODE_STATUS",
+    "NODE_TRANSITIONS_INFO",
     "Node",
     "MACAddress",
     "SSHKey",
@@ -150,55 +151,93 @@
 NODE_STATUS_CHOICES_DICT = OrderedDict(NODE_STATUS_CHOICES)
 
 
-NODE_TRANSITIONS = {
+# Information about valid node status transitions and optional associated
+# transition methods.
+# The format is:
+# {
+#  old_status1: [
+#      [new_status11, {
+#          'name': transition_name11,
+#          'method': method_name11,   # Name of the method to call on Node
+#                                     # to perform that transition.
+#          'permission': permission_required11
+#      }],
+#      [new_status12, {
+#       'name': transition_name12,
+#       'method': method_name12,
+#       'permission': permission_required12
+#      }],
+#      [new_status13],  # Having a transition method is optional.
+#      [new_status14],
+# ...
+#  old_status2: [
+# ...
+# }
+NODE_TRANSITIONS_INFO = {
     None: [
-        NODE_STATUS.DECLARED,
-        NODE_STATUS.MISSING,
-        NODE_STATUS.RETIRED,
+        [NODE_STATUS.DECLARED],  # Anonymous enlistment.
+        [NODE_STATUS.READY],  # Authenticated enlistement.
+        [NODE_STATUS.MISSING],
+        [NODE_STATUS.RETIRED],
         ],
     NODE_STATUS.DECLARED: [
-        NODE_STATUS.COMMISSIONING,
-        NODE_STATUS.MISSING,
-        NODE_STATUS.READY,
-        NODE_STATUS.RETIRED,
+        [NODE_STATUS.COMMISSIONING],
+        [NODE_STATUS.MISSING],
+        [NODE_STATUS.READY, {
+            'name':"Enlist node", 'method': 'accept_enlistment',
+            'permission':'admin'}],
+        [NODE_STATUS.RETIRED],
         ],
     NODE_STATUS.COMMISSIONING: [
-        NODE_STATUS.FAILED_TESTS,
-        NODE_STATUS.READY,
-        NODE_STATUS.RETIRED,
-        NODE_STATUS.MISSING,
+        [NODE_STATUS.FAILED_TESTS],
+        [NODE_STATUS.READY],
+        [NODE_STATUS.RETIRED],
+        [NODE_STATUS.MISSING],
         ],
     NODE_STATUS.READY: [
-        NODE_STATUS.ALLOCATED,
-        NODE_STATUS.RESERVED,
-        NODE_STATUS.RETIRED,
-        NODE_STATUS.MISSING,
+        [NODE_STATUS.ALLOCATED],
+        [NODE_STATUS.RESERVED],
+        [NODE_STATUS.RETIRED],
+        [NODE_STATUS.MISSING],
         ],
     NODE_STATUS.RESERVED: [
-        NODE_STATUS.READY,
-        NODE_STATUS.ALLOCATED,
-        NODE_STATUS.RETIRED,
-        NODE_STATUS.MISSING,
+        [NODE_STATUS.READY],
+        [NODE_STATUS.ALLOCATED],
+        [NODE_STATUS.RETIRED],
+        [NODE_STATUS.MISSING],
         ],
     NODE_STATUS.ALLOCATED: [
-        NODE_STATUS.READY,
-        NODE_STATUS.RETIRED,
-        NODE_STATUS.MISSING,
+        [NODE_STATUS.READY],
+        [NODE_STATUS.RETIRED],
+        [NODE_STATUS.MISSING],
         ],
     NODE_STATUS.MISSING: [
-        NODE_STATUS.DECLARED,
-        NODE_STATUS.READY,
-        NODE_STATUS.ALLOCATED,
-        NODE_STATUS.COMMISSIONING,
+        [NODE_STATUS.DECLARED],
+        [NODE_STATUS.READY],
+        [NODE_STATUS.ALLOCATED],
+        [NODE_STATUS.COMMISSIONING],
         ],
     NODE_STATUS.RETIRED: [
-        NODE_STATUS.DECLARED,
-        NODE_STATUS.READY,
-        NODE_STATUS.MISSING,
+        [NODE_STATUS.DECLARED],
+        [NODE_STATUS.READY],
+        [NODE_STATUS.MISSING],
         ],
     }
 
 
+# Valid node status transitions:
+# The format is:
+# {
+#     old_status1: [new_status11, new_status12, ...],
+#     old_status2: [new_status21, new_status22, ...],
+# ...
+# }
+NODE_TRANSITIONS = dict(
+    (old_status, [info[0] for info in new_statuses_infos])
+    for old_status, new_statuses_infos in NODE_TRANSITIONS_INFO.items()
+    )
+
+
 class NODE_AFTER_COMMISSIONING_ACTION:
     """The vocabulary of a `Node`'s possible value for its field
     after_commissioning_action.
@@ -516,6 +555,30 @@
             self.full_clean()
         return super(Node, self).save(*args, **kwargs)
 
+    def available_transition_methods(self, user):
+        """Return the transitions that this user is allowed to perform on
+        this node.
+
+        :param user: The user used to perform the permission checks.  Only the
+            transitions available to this user will be returned.
+        :type user: :class:`django.contrib.auth.models.User`
+        :return: A list of transition dicts (each dict contains 3 values:
+            'name': the name of the transition, 'permission': the permission
+            required to perform this transition, 'method': the name of the
+            method to execute on the node to perform the transition).
+        :rtype: Sequence
+        """
+        valid_transitions = []
+        node_transitions = NODE_TRANSITIONS_INFO.get(self.status, ())
+        for node_transition in node_transitions:
+            if len(node_transition) == 2:
+                # The transition has a method associated with it.
+                new_status, transition_info = node_transition
+                if user.has_perm(transition_info['permission'], self):
+                    # The user can perform the transition.
+                    valid_transitions.append(transition_info)
+        return valid_transitions
+
     def display_status(self):
         """Return status text as displayed to the user.
 
@@ -536,7 +599,7 @@
         """Add a new MAC Address to this `Node`.
 
         :param mac_address: The MAC Address to be added.
-        :type mac_address: str
+        :type mac_address: basestring
         :raises: django.core.exceptions.ValidationError_
 
         .. _django.core.exceptions.ValidationError: https://
@@ -1176,6 +1239,9 @@
             return obj.owner in (None, user)
         elif perm == 'edit':
             return obj.owner == user
+        elif perm == 'admin':
+            # 'admin' permission is solely granted to superusers.
+            return False
         else:
             raise NotImplementedError(
                 'Invalid permission check (invalid permission name).')

=== modified file 'src/maasserver/tests/test_auth.py'
--- src/maasserver/tests/test_auth.py	2012-03-22 11:35:03 +0000
+++ src/maasserver/tests/test_auth.py	2012-03-30 15:45:50 +0000
@@ -124,6 +124,11 @@
         self.assertTrue(backend.has_perm(
             user, 'edit', make_allocated_node(owner=user)))
 
+    def test_user_has_not_admin_permission_on_node(self):
+        backend = MAASAuthorizationBackend()
+        user = factory.make_user()
+        self.assertFalse(backend.has_perm(user, 'admin', factory.make_node()))
+
 
 class TestNodeVisibility(TestCase):
 

=== modified file 'src/maasserver/tests/test_models.py'
--- src/maasserver/tests/test_models.py	2012-03-30 10:39:56 +0000
+++ src/maasserver/tests/test_models.py	2012-03-30 15:45:50 +0000
@@ -28,6 +28,7 @@
     NodeStateViolation,
     PermissionDenied,
     )
+import inspect
 from maasserver.models import (
     Config,
     create_auth_token,
@@ -48,6 +49,7 @@
     SYSTEM_USERS,
     UserProfile,
     validate_ssh_public_key,
+    NODE_TRANSITIONS_INFO,
     )
 from maasserver.provisioning import get_provisioning_api_proxy
 from maasserver.testing import get_data
@@ -301,6 +303,65 @@
         # The test is that this does not raise an error.
         pass
 
+    def test_available_transition_methods_for_declared_node_admin(self):
+        # An admin has access to the "Enlist node" transition for a
+        # 'Declared' node.
+        admin = factory.make_admin()
+        node = factory.make_node(status=NODE_STATUS.DECLARED)
+        self.assertItemsEqual(
+            [{
+                'method': 'accept_enlistment',
+                'name': 'Enlist node',
+                'permission': 'admin',
+            }],
+            node.available_transition_methods(admin))
+
+    def test_available_transition_methods_for_declared_node_simple_user(self):
+        # A simple user sees not transition for a 'Declared' node.
+        user = factory.make_user()
+        node = factory.make_node(status=NODE_STATUS.DECLARED)
+        self.assertItemsEqual([], node.available_transition_methods(user))
+
+
+class NodeTransitionsInfoTests(TestCase):
+    """Test the structure of NODE_TRANSITIONS_INFO."""
+
+    def test_NODE_TRANSITIONS_INFO_initial_states(self):
+        for initial_state in NODE_TRANSITIONS_INFO:
+            self.assertIn(
+                initial_state, NODE_STATUS_CHOICES_DICT.keys() + [None])
+
+    def test_NODE_TRANSITIONS_INFO_destination_state(self):
+        for transitions in NODE_TRANSITIONS_INFO.values():
+            for transition in transitions:
+                destination_state = transition[0]
+                self.assertIn(
+                    destination_state, NODE_STATUS_CHOICES_DICT.keys())
+
+    def test_NODE_TRANSITIONS_INFO_transitions(self):
+        node = factory.make_node()
+        for transitions in NODE_TRANSITIONS_INFO.values():
+            for transition in transitions:
+                has_method_transition = len(transition) == 2
+                if has_method_transition:
+                    method_transition = transition[1]
+                    self.assertItemsEqual(
+                        ['name', 'method', 'permission'],
+                        method_transition.keys())
+                    # The 'method' corresponds to the name of a method
+                    # on a Node.
+                    self.assertTrue(hasattr(node, method_transition['method']))
+                    self.assertTrue(
+                        inspect.ismethod(
+                            getattr(node, method_transition['method'])))
+                    # The 'permission' is a valid Node permission.
+                    self.assertIn(
+                        method_transition['permission'],
+                        ['edit', 'view', 'admin'])
+                    # The 'name' is a basestring.
+                    self.assertIsInstance(
+                        method_transition['name'], basestring)
+
 
 class GetDbStateTest(TestCase):
     """Testing for the method `get_db_state`."""


Follow ups