← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~rvb/maas/maas-add-node into lp:maas

 

Raphaël Badin has proposed merging lp:~rvb/maas/maas-add-node into lp:maas.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~rvb/maas/maas-add-node/+merge/90445

This is the first branch of the "add a node" (in javascript) story.  Its main objective is to create an api endpoint to create a Node and any number of related MAC Addresses.  For that it creates the proper form (NodeWithMACAddressesForm in src/maasserver/forms.py) to take advantage of Django's form validation machinery.

= Details =
The main trick is the creation of the OperationHandlerMetaClass metaclass (src/maasserver/api.py) and the api_exported decorator.  This metaclass dynamically creates a method (called 'create') that is hooked up by Django to POST api requests.  The method re-routes the request to methods decorated with api_exported based on the value of the parameter 'op' (for operation).

This branch also:
- removes the setting PISTON_IGNORE_DUPE_MODELS.  Now, each model class is attached to a single piston handler.
- fixes the resource_uri methods (in NodeMacHandler and NodeHandler).  The documentation about this is bloody confusing (https://bitbucket.org/jespern/django-piston/wiki/Documentation) but here is the catch: piston uses them for two different things: a) to generate the uris in the documentation (http://people.canonical.com/~gavin/docs/lp:maas/api.html) and b) to add to each object returned by the api a resource_uri (cf. test src/maasserver/tests/test_api.py test_node_resource_uri), very much like what lp does.
- removes the duplication that existed: the api documentation is available on a maas server instance (api_doc view in src/maasserver/api.py) and is generated by src/maasserver/management/commands/gen_rst_api_doc.py.  Now they both use the exact same resource: generate_api_doc (src/maasserver/api.py).  The only trick is that I had to import from distutils what's needed to convert the rst generated by piston into html.
-- 
https://code.launchpad.net/~rvb/maas/maas-add-node/+merge/90445
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rvb/maas/maas-add-node into lp:maas.
=== modified file 'src/maas/settings.py'
--- src/maas/settings.py	2012-01-25 14:24:52 +0000
+++ src/maas/settings.py	2012-01-27 15:00:30 +0000
@@ -197,5 +197,3 @@
         },
     }
 }
-
-PISTON_IGNORE_DUPE_MODELS = True

=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py	2012-01-24 18:30:38 +0000
+++ src/maasserver/api.py	2012-01-27 15:00:30 +0000
@@ -10,11 +10,14 @@
 
 __metaclass__ = type
 __all__ = [
+    "api_doc",
+    "generate_api_doc",
     "NodeHandler",
     "NodeMacsHandler",
     ]
 
 from functools import wraps
+import types
 
 from django.core.exceptions import (
     PermissionDenied,
@@ -25,13 +28,18 @@
     render_to_response,
     )
 from django.template import RequestContext
+from docutils import core
+from maasserver.forms import NodeWithMACAddressesForm
 from maasserver.macaddress import validate_mac
 from maasserver.models import (
     MACAddress,
     Node,
     )
 from piston.doc import generate_doc
-from piston.handler import BaseHandler
+from piston.handler import (
+    BaseHandler,
+    HandlerMetaClass,
+    )
 from piston.utils import rc
 
 
@@ -41,9 +49,9 @@
     return resp
 
 
-def format_error_message(error):
+def format_error_message(error_dict):
     messages = []
-    for k, v in error.message_dict.iteritems():
+    for k, v in error_dict.iteritems():
         if isinstance(v, list):
             messages.append("%s: %s" % (k, "".join(v)))
         else:
@@ -57,7 +65,7 @@
         obj.save()
         return obj
     except ValidationError, e:
-        return bad_request(format_error_message(e))
+        return bad_request(format_error_message(e.message_dict))
 
 
 def validate_mac_address(mac_address):
@@ -78,6 +86,58 @@
     return wraps(view_func)(_decorator)
 
 
+def api_exported(operation_name=True):
+    def _decorator(func):
+        if operation_name == 'create':
+            raise Exception("Cannot define a 'create' operation.")
+        func._api_exported = operation_name
+        return func
+    return _decorator
+
+
+# The parameter used to specify the requested operation for POST API calls.
+OP_PARAM = 'op'
+
+
+class OperationHandlerMetaClass(HandlerMetaClass):
+    def __init__(cls, name, bases, dct):
+        super(OperationHandlerMetaClass, cls).__init__(name, bases, dct)
+        # Register all the exported methods.
+        cls._available_api_methods = {}
+        api_docs = ['\n']
+        for name, slot in dct.iteritems():
+            if isinstance(slot, types.FunctionType):
+                if name == 'create':
+                    raise Exception("Cannot define a 'create' method.")
+                operation_name = getattr(slot, '_api_exported', None)
+                if operation_name is not None:
+                    api_name = (
+                        name if operation_name is True else operation_name)
+                    cls._available_api_methods[api_name] = slot
+                    doc = "Method '%s' (op=%s):\n\t%s" % (
+                        api_name, api_name, slot.__doc__)
+                    api_docs.append(doc)
+
+        # Define a 'create' method that will route requests to the methods
+        # registered in _available_api_methods.
+        def create(cls, request, *args, **kwargs):
+            op = request.data.get(OP_PARAM, None)
+            if not isinstance(op, unicode):
+                return bad_request('Unknown operation.')
+            elif op not in cls._available_api_methods:
+                return bad_request('Unknown operation: %s.' % op)
+            else:
+                method = cls._available_api_methods[op]
+                return method(cls, request, *args, **kwargs)
+
+        # Update the __doc__ for the 'create' method.
+        doc = "Operate on nodes collection. The actual method to execute\
+               depends on the value of the '%s' parameter." % OP_PARAM
+        create.__doc__ = doc + '\n- '.join(api_docs)
+        # Add the 'create' method.
+        setattr(cls, 'create', create)
+
+
 class NodeHandler(BaseHandler):
     """Manage individual Nodes."""
     allowed_methods = ('GET', 'DELETE', 'PUT')
@@ -108,27 +168,31 @@
         return rc.DELETED
 
     @classmethod
-    def resource_uri(cls, *args, **kwargs):
-        return ('node_handler', ['system_id'])
+    def resource_uri(cls, node=None):
+        node_system_id = "system_id"
+        if node:
+            node_system_id = node.system_id
+        return ('node_handler', (node_system_id, ))
 
 
 class NodesHandler(BaseHandler):
     """Manage collection of Nodes / Create Nodes."""
+    __metaclass__ = OperationHandlerMetaClass
     allowed_methods = ('GET', 'POST',)
-    model = Node
-    fields = ('system_id', 'hostname', ('macaddress_set', ('mac_address',)))
 
     def read(self, request):
         """Read all Nodes."""
         return Node.objects.get_visible_nodes(request.user).order_by('id')
 
-    def create(self, request):
+    @api_exported('new')
+    def new(self, request):
         """Create a new Node."""
-        if 'status' in request.data:
-            return bad_request('Cannot set the status for a node.')
-
-        node = Node(**dict(request.data.items()))
-        return validate_and_save(node)
+        form = NodeWithMACAddressesForm(request.data)
+        if form.is_valid():
+            node = form.save()
+            return node
+        else:
+            return bad_request(format_error_message(form.errors))
 
     @classmethod
     def resource_uri(cls, *args, **kwargs):
@@ -142,8 +206,6 @@
 
     """
     allowed_methods = ('GET', 'POST',)
-    fields = ('mac_address',)
-    model = MACAddress
 
     @perm_denied_handler
     def read(self, request, system_id):
@@ -161,7 +223,7 @@
             mac = node.add_mac_address(request.data.get('mac_address', None))
             return mac
         except ValidationError, e:
-            return bad_request(format_error_message(e))
+            return bad_request(format_error_message(e.message_dict))
 
     @classmethod
     def resource_uri(cls, *args, **kwargs):
@@ -201,19 +263,49 @@
         return rc.DELETED
 
     @classmethod
-    def resource_uri(cls, *args, **kwargs):
-        return ('node_mac_handler', ['system_id', 'mac_address'])
-
-
-docs = (
-    generate_doc(NodesHandler),
-    generate_doc(NodeHandler),
-    generate_doc(NodeMacsHandler),
-    generate_doc(NodeMacHandler),
-    )
+    def resource_uri(cls, mac=None):
+        node_system_id = "system_id"
+        mac_address = "mac_address"
+        if mac is not None:
+            node_system_id = mac.node.system_id
+            mac_address = mac.mac_address
+        return ('node_mac_handler', [node_system_id, mac_address])
+
+
+def generate_api_doc():
+    docs = (
+        generate_doc(NodesHandler),
+        generate_doc(NodeHandler),
+        generate_doc(NodeMacsHandler),
+        generate_doc(NodeMacHandler),
+        )
+
+    messages = ['MaaS API\n========\n\n']
+    for doc in docs:
+        for method in doc.get_methods():
+            messages.append(
+                "%s %s\n  %s\n\n" % (
+                    method.http_name, doc.resource_uri_template,
+                    method.doc))
+    return ''.join(messages)
+
+
+def reST_to_html_fragment(a_str):
+    parts = core.publish_parts(source=a_str, writer_name='html')
+    return parts['body_pre_docinfo'] + parts['fragment']
+
+
+_API_DOC = None
 
 
 def api_doc(request):
+    # Generate the documentation and keep it cached.  Note that we can't do
+    # that at the module level because the API doc generation needs Django
+    # fully initialized.
+    global _API_DOC
+    if _API_DOC is None:
+        _API_DOC = generate_api_doc()
     return render_to_response(
-        'maasserver/api_doc.html', {'docs': docs},
+        'maasserver/api_doc.html',
+        {'doc': reST_to_html_fragment(generate_api_doc())},
         context_instance=RequestContext(request))

=== added file 'src/maasserver/forms.py'
--- src/maasserver/forms.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/forms.py	2012-01-27 15:00:30 +0000
@@ -0,0 +1,69 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Forms."""
+
+from __future__ import (
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    "NodeForm",
+    "MACAddressForm",
+    ]
+
+from django import forms
+from django.forms import ModelForm
+from maasserver.macaddress import MACAddressFormField
+from maasserver.models import (
+    MACAddress,
+    Node,
+    )
+
+
+class NodeForm(ModelForm):
+    system_id = forms.CharField(
+        widget=forms.TextInput(attrs={'readonly': 'readonly'}),
+        required=False)
+
+    class Meta:
+        model = Node
+        fields = ('hostname', 'system_id')
+
+
+class MACAddressForm(ModelForm):
+    class Meta:
+        model = MACAddress
+
+
+class MACAddressForm(ModelForm):
+    class Meta:
+        model = MACAddress
+
+
+class MultiplecMACAddressField(forms.MultiValueField):
+    def __init__(self, nb_macs=1, *args, **kwargs):
+        fields = [MACAddressFormField() for i in xrange(nb_macs)]
+        super(MultiplecMACAddressField, self).__init__(fields, *args, **kwargs)
+
+    def compress(self, data_list):
+        if data_list:
+            return data_list
+        return []
+
+
+class NodeWithMACAddressesForm(NodeForm):
+
+    def __init__(self, *args, **kwargs):
+        super(NodeWithMACAddressesForm, self).__init__(*args, **kwargs)
+        macs = self.data.getlist('macaddresses')
+        self.fields['macaddresses'] = MultiplecMACAddressField(len(macs))
+        self.data['macaddresses'] = macs
+
+    def save(self):
+        node = super(NodeWithMACAddressesForm, self).save()
+        for mac in self.cleaned_data['macaddresses']:
+            node.add_mac_address(mac)
+        return node

=== modified file 'src/maasserver/macaddress.py'
--- src/maasserver/macaddress.py	2012-01-24 12:44:48 +0000
+++ src/maasserver/macaddress.py	2012-01-27 15:00:30 +0000
@@ -9,20 +9,33 @@
     )
 
 __metaclass__ = type
-__all__ = []
+__all__ = [
+    "MACAddressField",
+    "MACAddressFormField",
+    ]
 
 import re
 
 from django.core.validators import RegexValidator
 from django.db.models import Field
+from django.forms import RegexField
 import psycopg2.extensions
 
 
 mac_re = re.compile(r'^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$')
 
-validate_mac = RegexValidator(
-    regex=mac_re,
-    message=u"Enter a valid MAC address (e.g. AA:BB:CC:DD:EE:FF).")
+
+mac_error_msg = u"Enter a valid MAC address (e.g. AA:BB:CC:DD:EE:FF)."
+
+
+validate_mac = RegexValidator(regex=mac_re, message=mac_error_msg)
+
+
+class MACAddressFormField(RegexField):
+
+    def __init__(self, *args, **kwargs):
+        super(MACAddressFormField, self).__init__(
+            regex=mac_re, error_message=mac_error_msg, *args, **kwargs)
 
 
 class MACAddressField(Field):

=== modified file 'src/maasserver/management/commands/gen_rst_api_doc.py'
--- src/maasserver/management/commands/gen_rst_api_doc.py	2012-01-19 15:42:18 +0000
+++ src/maasserver/management/commands/gen_rst_api_doc.py	2012-01-27 15:00:30 +0000
@@ -1,15 +1,7 @@
 from django.core.management.base import BaseCommand
-from maasserver.api import docs
+from maasserver.api import generate_api_doc
 
 
 class Command(BaseCommand):
-
     def handle(self, *args, **options):
-        messages = ['MaaS API\n========\n\n']
-        for doc in docs:
-            for method in doc.get_methods():
-                messages.append(
-                    "%s %s\n  %s\n\n" % (
-                        method.http_name, doc.resource_uri_template,
-                        method.doc))
-        self.stdout.write(''.join(messages))
+        self.stdout.write(generate_api_doc())

=== modified file 'src/maasserver/models.py'
--- src/maasserver/models.py	2012-01-24 18:30:38 +0000
+++ src/maasserver/models.py	2012-01-27 15:00:30 +0000
@@ -10,6 +10,7 @@
 
 __metaclass__ = type
 __all__ = [
+    "generate_node_system_id",
     "NODE_STATUS",
     "Node",
     "MACAddress",
@@ -105,8 +106,8 @@
 class Node(CommonInfo):
     """A `Node` represents a physical machine used by the MaaS Server."""
     system_id = models.CharField(
-        max_length=41, unique=True, editable=False,
-        default=generate_node_system_id)
+        max_length=41, unique=True, default=generate_node_system_id,
+        editable=False)
     hostname = models.CharField(max_length=255, default='', blank=True)
     status = models.IntegerField(
         max_length=10, choices=NODE_STATUS_CHOICES, editable=False,
@@ -144,7 +145,7 @@
     <http://en.wikipedia.org/wiki/MAC_address>`_ attached to a `Node`.
     """
     mac_address = MACAddressField()
-    node = models.ForeignKey(Node)
+    node = models.ForeignKey(Node, editable=False)
 
     class Meta:
         verbose_name_plural = "MAC addresses"

=== modified file 'src/maasserver/templates/maasserver/api_doc.html'
--- src/maasserver/templates/maasserver/api_doc.html	2012-01-19 16:42:25 +0000
+++ src/maasserver/templates/maasserver/api_doc.html	2012-01-27 15:00:30 +0000
@@ -1,21 +1,8 @@
 {% extends "maasserver/base.html" %}
 
 {% block content %}
-  <h2>MaaS Server API Documentation</h2>
-  <table>
-    <thead>
-      <tr><td>Resource</td><td>Description</td></tr>
-    </thead>
-    <tbody>
-    {% for doc in docs %}
-    {% for method in doc.get_methods %}
-      <tr>
-        <td>{{ method.http_name }} {{ doc.resource_uri_template }}</td>
-        <td>{{ method.doc }}</td>
-      </tr>
-    {% endfor %}
-    {% endfor %}
-    </tbody>
-  </table>
+  {% autoescape on %}
+    {{ doc|safe }}
+  {% endautoescape %}
 {% endblock %}
 

=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py	2012-01-25 14:16:04 +0000
+++ src/maasserver/tests/test_api.py	2012-01-27 15:00:30 +0000
@@ -105,16 +105,41 @@
 
     def test_nodes_POST(self):
         """
-        The api allows to create a Node.
+        The api allows to create a Node associated with MAC Addresses.
 
         """
         response = self.client.post(
-            '/api/nodes/', {'hostname': 'diane'})
+                '/api/nodes/',
+                {
+                    'op': 'new',
+                    'hostname': 'diane',
+                    'macaddresses': ['aa:bb:cc:dd:ee:ff', '22:bb:cc:dd:ee:ff']
+                })
         parsed_result = json.loads(response.content)
 
         self.assertEqual(httplib.OK, response.status_code)
         self.assertEqual('diane', parsed_result['hostname'])
+        self.assertEqual(41, len(parsed_result.get('system_id')))
         self.assertEqual(1, Node.objects.filter(hostname='diane').count())
+        node = Node.objects.get(hostname='diane')
+        self.assertSequenceEqual(
+            ['aa:bb:cc:dd:ee:ff', '22:bb:cc:dd:ee:ff'],
+            [mac.mac_address for mac in node.macaddress_set.all()])
+
+    def test_nodes_POST_invalid(self):
+        """
+        If the data provided to create a node with MAC Addresse is invalid,
+        a 'Bad request' response is returned.
+
+        """
+        response = self.client.post(
+                '/api/nodes/',
+                {
+                    'hostname': 'diane',
+                    'macaddresses': ['aa:bb:cc:dd:ee:ff', 'invalid']
+                })
+
+        self.assertEqual(httplib.BAD_REQUEST, response.status_code)
 
     def test_node_PUT(self):
         """
@@ -131,6 +156,21 @@
         self.assertEqual(0, Node.objects.filter(hostname='diane').count())
         self.assertEqual(1, Node.objects.filter(hostname='francis').count())
 
+    def test_node_resource_uri(self):
+        """
+        When a Node is returned by the api, the field 'resource_uri' provides
+        the uri for this Node.
+
+        """
+        node = factory.make_node(hostname='diane')
+        response = self.client.put(
+            '/api/nodes/%s/' % node.system_id, {'hostname': 'francis'})
+        parsed_result = json.loads(response.content)
+
+        self.assertEqual(
+            '/api/nodes/%s/' % (parsed_result['system_id']),
+            parsed_result['resource_uri'])
+
     def test_node_PUT_invalid(self):
         """
         If the data provided to update a node is invalid, a 'Bad request'
@@ -165,19 +205,6 @@
 
         self.assertEqual(httplib.NOT_FOUND, response.status_code)
 
-    def test_nodes_POST_set_status_invalid(self):
-        """
-        The status of a newly created Node cannot be set (if will default to
-        0 (new)).
-
-        """
-        response = self.client.post(
-            '/api/nodes/', {'status': 'new'})
-
-        self.assertEqual(400, response.status_code)
-        self.assertEqual(
-            'Bad Request: Cannot set the status for a node.', response.content)
-
     def test_node_DELETE_non_visible_node(self):
         """
         The request to delete a single node is denied if the node isn't visible

=== added file 'src/maasserver/tests/test_forms.py'
--- src/maasserver/tests/test_forms.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_forms.py	2012-01-27 15:00:30 +0000
@@ -0,0 +1,57 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test forms."""
+
+from __future__ import (
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+from django.http import QueryDict
+from maasserver.forms import NodeWithMACAddressesForm
+from maasserver.testing import TestCase
+
+
+class NodeWithMACAddressesFormTest(TestCase):
+
+    def get_QueryDict(self, params):
+        query_dict = QueryDict('', mutable=True)
+        for k, v in params.items():
+            if isinstance(v, list):
+                query_dict.setlist(k, v)
+            else:
+                query_dict[k] = v
+        return query_dict
+
+    def test_NodeWithMACAddressesForm_valid(self):
+
+        form = NodeWithMACAddressesForm(
+            self.get_QueryDict(
+                {'macaddresses': ['aa:bb:cc:dd:ee:ff', '9a:bb:c3:33:e5:7f']}))
+
+        self.assertTrue(form.is_valid())
+        self.assertEqual(
+            ['aa:bb:cc:dd:ee:ff', '9a:bb:c3:33:e5:7f'],
+            form.cleaned_data['macaddresses'])
+
+    def test_NodeWithMACAddressesForm_invalid(self):
+        form = NodeWithMACAddressesForm(
+            self.get_QueryDict(
+                {'macaddresses': ['aa:bb:cc:dd:ee:ff', 'z_invalid']}))
+
+        self.assertFalse(form.is_valid())
+
+    def test_NodeWithMACAddressesForm_save(self):
+        form = NodeWithMACAddressesForm(
+            self.get_QueryDict(
+                {'macaddresses': ['aa:bb:cc:dd:ee:ff', '9a:bb:c3:33:e5:7f']}))
+        node = form.save()
+
+        self.assertIsNotNone(node.id)  # The node is persisted.
+        self.assertSequenceEqual(
+            ['aa:bb:cc:dd:ee:ff', '9a:bb:c3:33:e5:7f'],
+            [mac.mac_address for mac in node.macaddress_set.all()])