← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~rvb/maas/views-bug-973215-2 into lp:maas

 

Raphaël Badin has proposed merging lp:~rvb/maas/views-bug-973215-2 into lp:maas.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~rvb/maas/views-bug-973215-2/+merge/103076

This branch is the second in a series of branches that will refactor src/maasserver/views.py into modules in src/maasserver/views/.

This branch follows up on the work done in https://code.launchpad.net/~rvb/maas/views-bug-973215/+merge/103061.

It moves nodes views (dashboard, node list, node edit/view views) to 
src/maasserver/views/nodes.py.  It also moves the related tests in src/maasserver/tests/test_views_nodes.py.
-- 
https://code.launchpad.net/~rvb/maas/views-bug-973215-2/+merge/103076
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rvb/maas/views-bug-973215-2 into lp:maas.
=== modified file 'src/maasserver/testing/__init__.py'
--- src/maasserver/testing/__init__.py	2012-04-16 10:00:51 +0000
+++ src/maasserver/testing/__init__.py	2012-04-23 11:00:36 +0000
@@ -20,6 +20,7 @@
 import os
 from uuid import uuid1
 
+from lxml.html import fromstring
 from provisioningserver.testing import fakeapi
 
 # Current (singleton) fake provisioning API server.
@@ -106,3 +107,10 @@
     path = os.path.join(
         os.path.dirname(os.path.abspath(__file__)), '..', 'tests', filename)
     return file(path).read()
+
+
+def get_content_links(response, element='#content'):
+    """Extract links from :class:`HttpResponse` #content element."""
+    doc = fromstring(response.content)
+    [content_node] = doc.cssselect(element)
+    return [elem.get('href') for elem in content_node.cssselect('a')]

=== modified file 'src/maasserver/tests/test_views.py'
--- src/maasserver/tests/test_views.py	2012-04-23 10:31:11 +0000
+++ src/maasserver/tests/test_views.py	2012-04-23 11:00:36 +0000
@@ -29,25 +29,20 @@
 from maasserver import (
     components,
     messages,
-    views,
     )
 from maasserver.components import register_persistent_error
-from maasserver.enum import (
-    NODE_AFTER_COMMISSIONING_ACTION,
-    NODE_STATUS,
-    )
+from maasserver.enum import NODE_AFTER_COMMISSIONING_ACTION
 from maasserver.exceptions import (
     ExternalComponentException,
     NoRabbit,
     )
-from maasserver.forms import NodeActionForm
 from maasserver.models import (
     Config,
-    Node,
     SSHKey,
     UserProfile,
     )
 from maasserver.testing import (
+    get_content_links,
     get_data,
     reload_object,
     )
@@ -58,9 +53,12 @@
     TestCase,
     )
 from maasserver.views import (
-    get_longpoll_context,
     get_yui_location,
     HelpfulDeleteView,
+    nodes as nodes_views,
+    )
+from maasserver.views.nodes import (
+    get_longpoll_context,
     NodeEdit,
     )
 from maastesting.rabbit import uses_rabbit_fixture
@@ -213,7 +211,7 @@
 
     def test_get_longpoll_context_empty_if_rabbitmq_publish_is_none(self):
         self.patch(settings, 'RABBITMQ_PUBLISH', None)
-        self.patch(views, 'messaging', messages.get_messaging())
+        self.patch(nodes_views, 'messaging', messages.get_messaging())
         self.assertEqual({}, get_longpoll_context())
 
     def test_get_longpoll_context_returns_empty_if_rabbit_not_running(self):
@@ -229,7 +227,7 @@
 
     def test_get_longpoll_context_empty_if_longpoll_url_is_None(self):
         self.patch(settings, 'LONGPOLL_PATH', None)
-        self.patch(views, 'messaging', messages.get_messaging())
+        self.patch(nodes_views, 'messaging', messages.get_messaging())
         self.assertEqual({}, get_longpoll_context())
 
     @uses_rabbit_fixture
@@ -237,10 +235,10 @@
         longpoll = factory.getRandomString()
         self.patch(settings, 'LONGPOLL_PATH', longpoll)
         self.patch(settings, 'RABBITMQ_PUBLISH', True)
-        self.patch(views, 'messaging', messages.get_messaging())
+        self.patch(nodes_views, 'messaging', messages.get_messaging())
         context = get_longpoll_context()
         self.assertItemsEqual(
-            ['LONGPOLL_PATH', 'longpoll_queue'], list(context))
+            ['LONGPOLL_PATH', 'longpoll_queue'], context)
         self.assertEqual(longpoll, context['LONGPOLL_PATH'])
 
 
@@ -554,6 +552,7 @@
         self.logged_in_user.save()
 
 
+<<<<<<< TREE
 def get_content_links(response, element='#content'):
     """Extract links from :class:`HttpResponse` #content element."""
     doc = fromstring(response.content)
@@ -820,6 +819,8 @@
             [message.message for message in response.context['messages']])
 
 
+=======
+>>>>>>> MERGE-SOURCE
 class MAASExceptionHandledInView(LoggedInTestCase):
 
     def test_raised_MAASException_redirects(self):

=== added file 'src/maasserver/tests/test_views_nodes.py'
--- src/maasserver/tests/test_views_nodes.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_views_nodes.py	2012-04-23 11:00:36 +0000
@@ -0,0 +1,288 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test maasserver nodes views."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+import httplib
+
+from django.core.urlresolvers import reverse
+from lxml.html import fromstring
+from maasserver.enum import NODE_STATUS
+from maasserver.forms import NodeActionForm
+from maasserver.models import Node
+from maasserver.testing import (
+    get_content_links,
+    reload_object,
+    )
+from maasserver.testing.enum import map_enum
+from maasserver.testing.factory import factory
+from maasserver.testing.testcase import LoggedInTestCase
+
+
+class NodeViewsTest(LoggedInTestCase):
+
+    def test_node_list_contains_link_to_node_view(self):
+        node = factory.make_node()
+        response = self.client.get(reverse('node-list'))
+        node_link = reverse('node-view', args=[node.system_id])
+        self.assertIn(node_link, get_content_links(response))
+
+    def test_node_list_displays_sorted_list_of_nodes(self):
+        # Nodes are sorted on the node list page, newest first.
+        nodes = [factory.make_node() for i in range(3)]
+        nodes.reverse()
+        # Modify one node to make sure that the default db ordering
+        # (by modification date) is not used.
+        node = nodes[1]
+        node.hostname = factory.getRandomString()
+        node.save()
+        response = self.client.get(reverse('node-list'))
+        node_links = [
+            reverse('node-view', args=[node.system_id])
+            for node in nodes]
+        self.assertEqual(
+            node_links,
+            [link for link in get_content_links(response)
+                if link.startswith('/nodes')])
+
+    def test_view_node_displays_node_info(self):
+        # The node page features the basic information about the node.
+        node = factory.make_node(owner=self.logged_in_user)
+        node_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_link)
+        doc = fromstring(response.content)
+        content_text = doc.cssselect('#content')[0].text_content()
+        self.assertIn(node.hostname, content_text)
+        self.assertIn(node.display_status(), content_text)
+        self.assertIn(self.logged_in_user.username, content_text)
+
+    def test_view_node_displays_node_info_no_owner(self):
+        # If the node has no owner, the Owner 'slot' does not exist.
+        node = factory.make_node()
+        node_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_link)
+        doc = fromstring(response.content)
+        content_text = doc.cssselect('#content')[0].text_content()
+        self.assertNotIn('Owner', content_text)
+
+    def test_view_node_displays_link_to_edit_if_user_owns_node(self):
+        node = factory.make_node(owner=self.logged_in_user)
+        node_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_link)
+        node_edit_link = reverse('node-edit', args=[node.system_id])
+        self.assertIn(node_edit_link, get_content_links(response))
+
+    def test_view_node_does_not_show_link_to_delete_node(self):
+        # Only admin users can delete nodes.
+        node = factory.make_node(owner=self.logged_in_user)
+        node_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_link)
+        node_delete_link = reverse('node-delete', args=[node.system_id])
+        self.assertNotIn(node_delete_link, get_content_links(response))
+
+    def test_user_cannot_delete_node(self):
+        node = factory.make_node(owner=self.logged_in_user)
+        node_delete_link = reverse('node-delete', args=[node.system_id])
+        response = self.client.get(node_delete_link)
+        self.assertEqual(httplib.FORBIDDEN, response.status_code)
+
+    def test_view_node_shows_message_for_commissioning_node(self):
+        statuses_with_message = (
+            NODE_STATUS.READY, NODE_STATUS.COMMISSIONING)
+        help_link = "https://wiki.ubuntu.com/ServerTeam/MAAS/AvahiBoot";
+        for status in map_enum(NODE_STATUS).values():
+            node = factory.make_node(status=status)
+            node_link = reverse('node-view', args=[node.system_id])
+            response = self.client.get(node_link)
+            links = get_content_links(response, '#flash-messages')
+            if status in statuses_with_message:
+                self.assertIn(help_link, links)
+            else:
+                self.assertNotIn(help_link, links)
+
+    def test_view_node_shows_link_to_delete_node_for_admin(self):
+        self.become_admin()
+        node = factory.make_node()
+        node_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_link)
+        node_delete_link = reverse('node-delete', args=[node.system_id])
+        self.assertIn(node_delete_link, get_content_links(response))
+
+    def test_admin_can_delete_nodes(self):
+        self.become_admin()
+        node = factory.make_node()
+        node_delete_link = reverse('node-delete', args=[node.system_id])
+        response = self.client.post(node_delete_link, {'post': 'yes'})
+        self.assertEqual(httplib.FOUND, response.status_code)
+        self.assertFalse(Node.objects.filter(id=node.id).exists())
+
+    def test_allocated_node_view_page_says_node_cannot_be_deleted(self):
+        self.become_admin()
+        node = factory.make_node(
+            status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
+        node_view_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_view_link)
+        node_delete_link = reverse('node-delete', args=[node.system_id])
+
+        self.assertEqual(httplib.OK, response.status_code)
+        self.assertNotIn(node_delete_link, get_content_links(response))
+        self.assertIn(
+            "You cannot delete this node because it's in use.",
+            response.content)
+
+    def test_allocated_node_cannot_be_deleted(self):
+        self.become_admin()
+        node = factory.make_node(
+            status=NODE_STATUS.ALLOCATED, owner=factory.make_user())
+        node_delete_link = reverse('node-delete', args=[node.system_id])
+        response = self.client.get(node_delete_link)
+
+        self.assertEqual(httplib.FORBIDDEN, response.status_code)
+
+    def test_user_cannot_view_someone_elses_node(self):
+        node = factory.make_node(owner=factory.make_user())
+        node_view_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_view_link)
+        self.assertEqual(httplib.FORBIDDEN, response.status_code)
+
+    def test_user_cannot_edit_someone_elses_node(self):
+        node = factory.make_node(owner=factory.make_user())
+        node_edit_link = reverse('node-edit', args=[node.system_id])
+        response = self.client.get(node_edit_link)
+        self.assertEqual(httplib.FORBIDDEN, response.status_code)
+
+    def test_admin_can_view_someonelses_node(self):
+        self.become_admin()
+        node = factory.make_node(owner=factory.make_user())
+        node_view_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_view_link)
+        self.assertEqual(httplib.OK, response.status_code)
+
+    def test_admin_can_edit_someonelses_node(self):
+        self.become_admin()
+        node = factory.make_node(owner=factory.make_user())
+        node_edit_link = reverse('node-edit', args=[node.system_id])
+        response = self.client.get(node_edit_link)
+        self.assertEqual(httplib.OK, response.status_code)
+
+    def test_user_can_access_the_edition_page_for_his_nodes(self):
+        node = factory.make_node(owner=self.logged_in_user)
+        node_edit_link = reverse('node-edit', args=[node.system_id])
+        response = self.client.get(node_edit_link)
+        self.assertEqual(httplib.OK, response.status_code)
+
+    def test_user_can_edit_his_nodes(self):
+        node = factory.make_node(owner=self.logged_in_user)
+        node_edit_link = reverse('node-edit', args=[node.system_id])
+        params = {
+            'hostname': factory.getRandomString(),
+            # XXX JeroenVermeulen 2012-04-12, bug=979539: re-enable.
+            #'after_commissioning_action': factory.getRandomEnum(
+            #    NODE_AFTER_COMMISSIONING_ACTION),
+        }
+        response = self.client.post(node_edit_link, params)
+
+        node = reload_object(node)
+        self.assertEqual(httplib.FOUND, response.status_code)
+        self.assertAttributes(node, params)
+
+    def test_view_node_has_button_to_accept_enlistement_for_user(self):
+        # A simple user can't see the button to enlist a declared node.
+        node = factory.make_node(status=NODE_STATUS.DECLARED)
+        node_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_link)
+        doc = fromstring(response.content)
+
+        self.assertEqual(0, len(doc.cssselect('form#node_actions input')))
+
+    def test_view_node_shows_console_output_if_error_set(self):
+        # When node.error is set but the node's status does not indicate an
+        # error condition, the contents of node.error are displayed as console
+        # output.
+        node = factory.make_node(
+            owner=self.logged_in_user, error=factory.getRandomString(),
+            status=NODE_STATUS.READY)
+        node_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_link)
+        console_output = fromstring(response.content).xpath(
+            '//h4[text()="Console output"]/following-sibling::span/text()')
+        self.assertEqual([node.error], console_output)
+
+    def test_view_node_shows_error_output_if_error_set(self):
+        # When node.error is set and the node's status indicates an error
+        # condition, the contents of node.error are displayed as error output.
+        node = factory.make_node(
+            owner=self.logged_in_user, error=factory.getRandomString(),
+            status=NODE_STATUS.FAILED_TESTS)
+        node_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_link)
+        error_output = fromstring(response.content).xpath(
+            '//h4[text()="Error output"]/following-sibling::span/text()')
+        self.assertEqual([node.error], error_output)
+
+    def test_view_node_shows_no_error_if_no_error_set(self):
+        node = factory.make_node(owner=self.logged_in_user)
+        node_link = reverse('node-view', args=[node.system_id])
+        response = self.client.get(node_link)
+        doc = fromstring(response.content)
+        content_text = doc.cssselect('#content')[0].text_content()
+        self.assertNotIn("Error output", content_text)
+
+    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 perform_action_and_get_node_page(self, node, action_name):
+        node_link = reverse('node-view', args=[node.system_id])
+        self.client.post(
+            node_link,
+            data={
+                NodeActionForm.input_name: action_name,
+            })
+        response = self.client.get(node_link)
+        return response
+
+    def test_start_commisionning_displays_message(self):
+        self.become_admin()
+        node = factory.make_node(status=NODE_STATUS.DECLARED)
+        response = self.perform_action_and_get_node_page(
+            node, "Accept & commission")
+        self.assertIn(
+            "Node commissioning started.",
+            [message.message for message in response.context['messages']])
+
+    def test_start_node_from_ready_displays_message(self):
+        node = factory.make_node(
+            status=NODE_STATUS.READY, owner=self.logged_in_user)
+        response = self.perform_action_and_get_node_page(
+            node, "Start node")
+        self.assertIn(
+            "Node started.",
+            [message.message for message in response.context['messages']])
+
+    def test_start_node_from_allocated_displays_message(self):
+        node = factory.make_node(
+            status=NODE_STATUS.ALLOCATED, owner=self.logged_in_user)
+        response = self.perform_action_and_get_node_page(
+            node, "Start node")
+        self.assertEqual(
+            ["Node started."],
+            [message.message for message in response.context['messages']])

=== modified file 'src/maasserver/urls.py'
--- src/maasserver/urls.py	2012-04-23 09:08:05 +0000
+++ src/maasserver/urls.py	2012-04-23 11:00:36 +0000
@@ -33,11 +33,6 @@
     AccountsEdit,
     AccountsView,
     combo_view,
-    NodeDelete,
-    NodeEdit,
-    NodeListView,
-    NodesCreateView,
-    NodeView,
     settings,
     settings_add_archive,
     SSHKeyCreateView,
@@ -48,6 +43,12 @@
     login,
     logout,
     )
+from maasserver.views.nodes import (
+    NodeDelete,
+    NodeEdit,
+    NodeListView,
+    NodeView,
+    )
 
 
 def adminurl(regexp, view, *args, **kwargs):
@@ -55,7 +56,7 @@
     return url(regexp, view, *args, **kwargs)
 
 
-# URLs accessible to anonymous users.
+## URLs accessible to anonymous users.
 urlpatterns = patterns('maasserver.views',
     url(
         r'^%s' % re.escape(django_settings.YUI_COMBO_URL), combo_view,
@@ -70,7 +71,8 @@
         name='favicon'),
 )
 
-# URLs for logged-in users.
+## URLs for logged-in users.
+# Preferences views.
 urlpatterns += patterns('maasserver.views',
     url(r'^account/prefs/$', userprefsview, name='prefs'),
     url(
@@ -79,7 +81,15 @@
     url(
         r'^account/prefs/sshkey/delete/(?P<keyid>\d*)/$',
         SSHKeyDeleteView.as_view(), name='prefs-delete-sshkey'),
+    )
+
+# Logout view.
+urlpatterns += patterns('maasserver.views',
     url(r'^accounts/logout/$', logout, name='logout'),
+)
+
+# Nodes views.
+urlpatterns += patterns('maasserver.views',
     url(
         r'^$',
         NodeListView.as_view(template_name="maasserver/index.html"),
@@ -94,12 +104,11 @@
     url(
         r'^nodes/(?P<system_id>[\w\-]+)/delete/$', NodeDelete.as_view(),
         name='node-delete'),
-     url(
-        r'^nodes/create/$', NodesCreateView.as_view(), name='node-create'),
 )
 
 
-# URLs for admin users.
+## URLs for admin users.
+# Settings views.
 urlpatterns += patterns('maasserver.views',
     adminurl(r'^settings/$', settings, name='settings'),
     adminurl(

=== modified file 'src/maasserver/views/__init__.py'
--- src/maasserver/views/__init__.py	2012-04-23 09:08:05 +0000
+++ src/maasserver/views/__init__.py	2012-04-23 11:00:36 +0000
@@ -16,10 +16,6 @@
     "AccountsEdit",
     "AccountsView",
     "combo_view",
-    "NodeListView",
-    "NodesCreateView",
-    "NodeView",
-    "NodeEdit",
     "settings",
     "settings_add_archive",
     "SSHKeyCreateView",
@@ -30,7 +26,6 @@
     ABCMeta,
     abstractmethod,
     )
-from logging import getLogger
 import os
 
 from convoy.combo import (
@@ -58,41 +53,26 @@
     render_to_response,
     )
 from django.template import RequestContext
-from django.utils.safestring import mark_safe
 from django.views.generic import (
     CreateView,
     DeleteView,
     DetailView,
-    ListView,
-    UpdateView,
     )
 from django.views.generic.base import TemplateView
 from django.views.generic.detail import SingleObjectTemplateResponseMixin
 from django.views.generic.edit import ModelFormMixin
-from maasserver.enum import (
-    NODE_PERMISSION,
-    NODE_STATUS,
-    )
-from maasserver.exceptions import (
-    CannotDeleteUserException,
-    NoRabbit,
-    )
+from maasserver.exceptions import CannotDeleteUserException
 from maasserver.forms import (
     AddArchiveForm,
     CommissioningForm,
     EditUserForm,
-    get_action_form,
     MAASAndNetworkForm,
     NewUserCreationForm,
     ProfileForm,
     SSHKeyForm,
     UbuntuForm,
-    UIAdminNodeEditForm,
-    UINodeEditForm,
     )
-from maasserver.messages import messaging
 from maasserver.models import (
-    Node,
     SSHKey,
     UserProfile,
     )
@@ -176,135 +156,6 @@
         return HttpResponseRedirect(self.get_next_url())
 
 
-# Info message displayed on the node page for COMMISSIONING
-# or READY nodes.
-NODE_BOOT_INFO = mark_safe("""
-You can boot this node using Avahi enabled boot media or an
-adequately configured dhcp server, see
-<a href="https://wiki.ubuntu.com/ServerTeam/MAAS/AvahiBoot";>
-https://wiki.ubuntu.com/ServerTeam/MAAS/AvahiBoot</a> for
-details.
-""")
-
-
-class NodeView(UpdateView):
-
-    template_name = 'maasserver/node_view.html'
-
-    context_object_name = 'node'
-
-    def get_object(self):
-        system_id = self.kwargs.get('system_id', None)
-        node = Node.objects.get_node_or_404(
-            system_id=system_id, user=self.request.user,
-            perm=NODE_PERMISSION.VIEW)
-        return node
-
-    def get_form_class(self):
-        return get_action_form(self.request.user, self.request)
-
-    def get_context_data(self, **kwargs):
-        context = super(NodeView, self).get_context_data(**kwargs)
-        node = self.get_object()
-        context['can_edit'] = self.request.user.has_perm(
-            NODE_PERMISSION.EDIT, node)
-        context['can_delete'] = self.request.user.has_perm(
-            NODE_PERMISSION.ADMIN, node)
-        if node.status in (NODE_STATUS.COMMISSIONING, NODE_STATUS.READY):
-            messages.info(self.request, NODE_BOOT_INFO)
-        context['error_text'] = (
-            node.error if node.status == NODE_STATUS.FAILED_TESTS else None)
-        context['status_text'] = (
-            node.error if node.status != NODE_STATUS.FAILED_TESTS else None)
-        return context
-
-    def get_success_url(self):
-        return reverse('node-view', args=[self.get_object().system_id])
-
-
-class NodeEdit(UpdateView):
-
-    template_name = 'maasserver/node_edit.html'
-
-    def get_object(self):
-        system_id = self.kwargs.get('system_id', None)
-        node = Node.objects.get_node_or_404(
-            system_id=system_id, user=self.request.user,
-            perm=NODE_PERMISSION.EDIT)
-        return node
-
-    def get_form_class(self):
-        if self.request.user.is_superuser:
-            return UIAdminNodeEditForm
-        else:
-            return UINodeEditForm
-
-    def get_success_url(self):
-        return reverse('node-view', args=[self.get_object().system_id])
-
-
-class NodeDelete(HelpfulDeleteView):
-
-    template_name = 'maasserver/node_confirm_delete.html'
-    context_object_name = 'node_to_delete'
-    model = Node
-
-    def get_object(self):
-        system_id = self.kwargs.get('system_id', None)
-        node = Node.objects.get_node_or_404(
-            system_id=system_id, user=self.request.user,
-            perm=NODE_PERMISSION.ADMIN)
-        if node.status == NODE_STATUS.ALLOCATED:
-            raise PermissionDenied()
-        return node
-
-    def get_next_url(self):
-        return reverse('node-list')
-
-    def name_object(self, obj):
-        """See `HelpfulDeleteView`."""
-        return "Node %s" % obj.system_id
-
-
-def get_longpoll_context():
-    if messaging is not None and django_settings.LONGPOLL_PATH is not None:
-        try:
-            return {
-                'longpoll_queue': messaging.getQueue().name,
-                'LONGPOLL_PATH': django_settings.LONGPOLL_PATH,
-                }
-        except NoRabbit as e:
-            getLogger('maasserver').warn(
-                "Could not connect to RabbitMQ: %s", e)
-            return {}
-    else:
-        return {}
-
-
-class NodeListView(ListView):
-
-    context_object_name = "node_list"
-
-    def get_queryset(self):
-        # Return node list sorted, newest first.
-        return Node.objects.get_nodes(
-            user=self.request.user,
-            perm=NODE_PERMISSION.VIEW).order_by('-id')
-
-    def get_context_data(self, **kwargs):
-        context = super(NodeListView, self).get_context_data(**kwargs)
-        context.update(get_longpoll_context())
-        return context
-
-
-class NodesCreateView(CreateView):
-
-    model = Node
-
-    def get_success_url(self):
-        return reverse('index')
-
-
 class SSHKeyCreateView(CreateView):
 
     form_class = SSHKeyForm

=== added file 'src/maasserver/views/nodes.py'
--- src/maasserver/views/nodes.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/views/nodes.py	2012-04-23 11:00:36 +0000
@@ -0,0 +1,163 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Nodes views."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    'NodeListView',
+    'NodeView',
+    'NodeEdit',
+    ]
+
+from logging import getLogger
+
+from django.conf import settings as django_settings
+from django.contrib import messages
+from django.core.exceptions import PermissionDenied
+from django.core.urlresolvers import reverse
+from django.utils.safestring import mark_safe
+from django.views.generic import (
+    ListView,
+    UpdateView,
+    )
+from maasserver.enum import (
+    NODE_PERMISSION,
+    NODE_STATUS,
+    )
+from maasserver.exceptions import NoRabbit
+from maasserver.forms import (
+    get_action_form,
+    UIAdminNodeEditForm,
+    UINodeEditForm,
+    )
+from maasserver.messages import messaging
+from maasserver.models import Node
+from maasserver.views import HelpfulDeleteView
+
+
+def get_longpoll_context():
+    if messaging is not None and django_settings.LONGPOLL_PATH is not None:
+        try:
+            return {
+                'longpoll_queue': messaging.getQueue().name,
+                'LONGPOLL_PATH': django_settings.LONGPOLL_PATH,
+                }
+        except NoRabbit as e:
+            getLogger('maasserver').warn(
+                "Could not connect to RabbitMQ: %s", e)
+            return {}
+    else:
+        return {}
+
+
+class NodeListView(ListView):
+
+    context_object_name = "node_list"
+
+    def get_queryset(self):
+        # Return node list sorted, newest first.
+        return Node.objects.get_nodes(
+            user=self.request.user,
+            perm=NODE_PERMISSION.VIEW).order_by('-id')
+
+    def get_context_data(self, **kwargs):
+        context = super(NodeListView, self).get_context_data(**kwargs)
+        context.update(get_longpoll_context())
+        return context
+
+
+# Info message displayed on the node page for COMMISSIONING
+# or READY nodes.
+NODE_BOOT_INFO = mark_safe("""
+You can boot this node using Avahi enabled boot media or an
+adequately configured dhcp server, see
+<a href="https://wiki.ubuntu.com/ServerTeam/MAAS/AvahiBoot";>
+https://wiki.ubuntu.com/ServerTeam/MAAS/AvahiBoot</a> for
+details.
+""")
+
+
+class NodeView(UpdateView):
+
+    template_name = 'maasserver/node_view.html'
+
+    context_object_name = 'node'
+
+    def get_object(self):
+        system_id = self.kwargs.get('system_id', None)
+        node = Node.objects.get_node_or_404(
+            system_id=system_id, user=self.request.user,
+            perm=NODE_PERMISSION.VIEW)
+        return node
+
+    def get_form_class(self):
+        return get_action_form(self.request.user, self.request)
+
+    def get_context_data(self, **kwargs):
+        context = super(NodeView, self).get_context_data(**kwargs)
+        node = self.get_object()
+        context['can_edit'] = self.request.user.has_perm(
+            NODE_PERMISSION.EDIT, node)
+        context['can_delete'] = self.request.user.has_perm(
+            NODE_PERMISSION.ADMIN, node)
+        if node.status in (NODE_STATUS.COMMISSIONING, NODE_STATUS.READY):
+            messages.info(self.request, NODE_BOOT_INFO)
+        context['error_text'] = (
+            node.error if node.status == NODE_STATUS.FAILED_TESTS else None)
+        context['status_text'] = (
+            node.error if node.status != NODE_STATUS.FAILED_TESTS else None)
+        return context
+
+    def get_success_url(self):
+        return reverse('node-view', args=[self.get_object().system_id])
+
+
+class NodeEdit(UpdateView):
+
+    template_name = 'maasserver/node_edit.html'
+
+    def get_object(self):
+        system_id = self.kwargs.get('system_id', None)
+        node = Node.objects.get_node_or_404(
+            system_id=system_id, user=self.request.user,
+            perm=NODE_PERMISSION.EDIT)
+        return node
+
+    def get_form_class(self):
+        if self.request.user.is_superuser:
+            return UIAdminNodeEditForm
+        else:
+            return UINodeEditForm
+
+    def get_success_url(self):
+        return reverse('node-view', args=[self.get_object().system_id])
+
+
+class NodeDelete(HelpfulDeleteView):
+
+    template_name = 'maasserver/node_confirm_delete.html'
+    context_object_name = 'node_to_delete'
+    model = Node
+
+    def get_object(self):
+        system_id = self.kwargs.get('system_id', None)
+        node = Node.objects.get_node_or_404(
+            system_id=system_id, user=self.request.user,
+            perm=NODE_PERMISSION.ADMIN)
+        if node.status == NODE_STATUS.ALLOCATED:
+            raise PermissionDenied()
+        return node
+
+    def get_next_url(self):
+        return reverse('node-list')
+
+    def name_object(self, obj):
+        """See `HelpfulDeleteView`."""
+        return "Node %s" % obj.system_id