← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~rvb/maas/maas-oauth-user-page into lp:maas

 

Raphaël Badin has proposed merging lp:~rvb/maas/maas-oauth-user-page into lp:maas.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~rvb/maas/maas-oauth-user-page/+merge/92280

This branch adds a user preferences page composed of 4 tabs:
- a user profile tab (to change the user's first name, email, etc)
- a user API tab to display/regenerate the user's API tokens (consumer key, token key and token secret)
- a tab to change the user's password
- a tab to manage the user's ssh key (not yet done)

Notes:
- AccountHandler is the handler responsible for handling the API requests to manage the logged-in user.
- I've changed UserProfile.reset_authorisation_token to also return the consumer because we need to display the consumer's key in the UI.
-- 
https://code.launchpad.net/~rvb/maas/maas-oauth-user-page/+merge/92280
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rvb/maas/maas-oauth-user-page into lp:maas.
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py	2012-02-08 15:20:37 +0000
+++ src/maasserver/api.py	2012-02-09 14:53:18 +0000
@@ -351,6 +351,29 @@
         return ('node_mac_handler', [node_system_id, mac_address])
 
 
+@api_operations
+class AccountHandler(BaseHandler):
+    """Manage the current logged-in user."""
+    allowed_methods = ('POST',)
+
+    @api_exported('reset_authorisation_token')
+    def reset_authorisation_token(self, request):
+        """Regenerate the token and the secret of the OAuth Token.
+
+        :return: A json dict with three keys: 'token_key',
+            'token_secret' and 'consumer_key' (e.g.
+            {token_key: 's65244576fgqs', token_secret: 'qsdfdhv34',
+             consumer_key: '68543fhj854fg'}).
+
+        """
+        profile = request.user.get_profile()
+        consumer, token = profile.reset_authorisation_token()
+        return {
+            'token_key': token.key, 'token_secret': token.secret,
+            'consumer_key': consumer.key,
+            }
+
+
 def generate_api_doc():
     docs = (
         generate_doc(NodesHandler),

=== modified file 'src/maasserver/forms.py'
--- src/maasserver/forms.py	2012-01-31 17:57:34 +0000
+++ src/maasserver/forms.py	2012-02-09 14:53:18 +0000
@@ -15,6 +15,7 @@
     ]
 
 from django import forms
+from django.contrib.auth.models import User
 from django.forms import ModelForm
 from maasserver.macaddress import MACAddressFormField
 from maasserver.models import (
@@ -85,3 +86,9 @@
         for mac in self.cleaned_data['mac_addresses']:
             node.add_mac_address(mac)
         return node
+
+
+class ProfileForm(ModelForm):
+    class Meta:
+        model = User
+        fields = ('first_name', 'last_name', 'email')

=== modified file 'src/maasserver/models.py'
--- src/maasserver/models.py	2012-02-08 13:45:45 +0000
+++ src/maasserver/models.py	2012-02-09 14:53:18 +0000
@@ -290,8 +290,8 @@
         """Create (if necessary) and regenerate the keys for the Consumer and
         the related Token of the OAuth authorisation.
 
-        :return: The token that was reset.
-        :rtype: piston.models.Token
+        :return: A tuple containing the Consumer and the Token that were reset.
+        :rtype: tuple
 
         """
         consumer, _ = Consumer.objects.get_or_create(
@@ -306,7 +306,7 @@
             user=self.user, token_type=Token.ACCESS, consumer=consumer,
             defaults={'is_approved': True})
         token.generate_random_codes()
-        return token
+        return consumer, token
 
     def get_authorisation_consumer(self):
         """Returns the OAuth Consumer attached to the related User_.

=== added file 'src/maasserver/static/img/maybe.png'
Binary files src/maasserver/static/img/maybe.png	1970-01-01 00:00:00 +0000 and src/maasserver/static/img/maybe.png	2012-02-09 14:53:18 +0000 differ
=== added file 'src/maasserver/static/js/prefs.js'
--- src/maasserver/static/js/prefs.js	1970-01-01 00:00:00 +0000
+++ src/maasserver/static/js/prefs.js	2012-02-09 14:53:18 +0000
@@ -0,0 +1,101 @@
+/* Copyright 2012 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Utilities for the user preferences page.
+ *
+ * @module Y.mass.prefs
+ */
+
+YUI.add('maas.prefs', function(Y) {
+
+Y.log('loading mass.prefs');
+var module = Y.namespace('maas.prefs');
+
+// Only used to mockup io in tests.
+module._io = Y;
+
+var TokenWidget = function() {
+    TokenWidget.superclass.constructor.apply(this, arguments);
+};
+
+TokenWidget.NAME = 'profile-widget';
+
+Y.extend(TokenWidget, Y.Widget, {
+
+    displayError: function(message) {
+        this.status_node.set('text', message);
+    },
+
+    initializer: function(cfg) {
+        this.regenerate_link = Y.Node.create('<a />')
+            .set('href', '#')
+            .set('id','regenerate_tokens')
+            .set('text', "Regenerate the tokens");
+        this.status_node = Y.Node.create('<span />')
+            .set('id','regenerate_error');
+        this.spinnerNode = Y.Node.create('<img />')
+            .set('src', MAAS_config.uris.statics + 'img/spinner.gif');
+        this.get('srcNode').one('#tokens_regeneration_placeholer')
+            .append(this.regenerate_link)
+            .append(this.status_node);
+    },
+
+    bindUI: function() {
+        var self = this;
+        this.regenerate_link.on('click', function(e) {
+            e.preventDefault();
+            self.regenerateTokens();
+        });
+    },
+
+    showSpinner: function() {
+        this.displayError('');
+        this.status_node.insert(this.spinnerNode, 'after');
+    },
+
+    hideSpinner: function() {
+        this.spinnerNode.remove();
+    },
+
+    regenerateTokens: function() {
+        var self = this;
+        var cfg = {
+            method: 'POST',
+            data: 'op=reset_authorisation_token',
+            sync: false,
+            on: {
+                start: Y.bind(self.showSpinner, self),
+                end: Y.bind(self.hideSpinner, self),
+                success: function(id, out) {
+                    var token;
+                    try {
+                        token = JSON.parse(out.response);
+                    }
+                    catch(e) {
+                        // Parsing error.
+                        displayError('Unable to regenerate the tokens.');
+                    }
+                    self.get(
+                        'srcNode').one(
+                            '#token_key').set('text', token.token_key);
+                    self.get(
+                        'srcNode').one(
+                            '#token_secret').set('text', token.token_secret);
+                    self.get(
+                        'srcNode').one(
+                            '#consumer_key').set('text', token.consumer_key);
+                 },
+                failure: function(id, out) {
+                    displayError('Unable to regenerate the tokens.');
+                }
+            }
+        };
+        var request = module._io.io(
+            MAAS_config.uris.account_handler, cfg);
+    }
+});
+
+module.TokenWidget = TokenWidget;
+
+}, '0.1', {'requires': ['widget', 'io']}
+);

=== added file 'src/maasserver/static/js/tests/test_prefs.html'
--- src/maasserver/static/js/tests/test_prefs.html	1970-01-01 00:00:00 +0000
+++ src/maasserver/static/js/tests/test_prefs.html	2012-02-09 14:53:18 +0000
@@ -0,0 +1,40 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd";>
+<html xmlns="http://www.w3.org/1999/xhtml"; xml:lang="en" lang="en">
+  <head>
+    <title>Test maas.prefs</title>
+
+    <!-- YUI and test setup -->
+    <script type="text/javascript" src="../yui/tests/yui/yui.js"></script>
+    <script type="text/javascript" src="../testing/testrunner.js"></script>
+    <script type="text/javascript" src="../testing/testing.js"></script>
+    <!-- The module under test -->
+    <script type="text/javascript" src="../prefs.js"></script>
+    <!-- The test suite -->
+    <script type="text/javascript" src="test_prefs.js"></script>
+    <script type="text/javascript">
+    <!--
+    var MAAS_config = {
+      uris: {
+        statics: '/static/',
+        account_handler: '/api/accounts/prefs/'
+      }
+    };
+    // -->
+    </script>
+  </head>
+  <body>
+  <span id="suite">maas.prefs.tests</span>
+  <script type="text/x-template" id="api-template">
+    <div id="placeholder">
+      <div id="consumer_key">
+      </div>
+      <div id="token_key">
+      </div>
+      <div id="token_secret">
+      </div>
+      <div id="tokens_regeneration_placeholer">
+    </div>
+    </div>
+  </script>
+  </body>
+</html>

=== added file 'src/maasserver/static/js/tests/test_prefs.js'
--- src/maasserver/static/js/tests/test_prefs.js	1970-01-01 00:00:00 +0000
+++ src/maasserver/static/js/tests/test_prefs.js	2012-02-09 14:53:18 +0000
@@ -0,0 +1,76 @@
+/* Copyright 2012 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ */
+
+YUI({ useBrowserConsole: true }).add('maas.prefs.tests', function(Y) {
+
+Y.log('loading maas.prefs.tests');
+var namespace = Y.namespace('maas.prefs.tests');
+
+var module = Y.maas.prefs;
+var suite = new Y.Test.Suite("maas.prefs Tests");
+
+var api_template = Y.one('#api-template').getContent();
+
+suite.add(new Y.maas.testing.TestCase({
+    name: 'test-prefs',
+
+    setUp: function() {
+        Y.one("body").append(Y.Node.create(api_template));
+     },
+
+    testInitializer: function() {
+        var widget = new module.TokenWidget({srcNode: '#placeholder'});
+        this.addCleanup(function() { widget.destroy(); });
+        widget.render();
+        var regenerate_link = widget.get('srcNode').one('#regenerate_tokens');
+        Y.Assert.isNotNull(regenerate_link);
+        Y.Assert.areEqual(
+            "Regenerate the tokens", regenerate_link.get('text'));
+        var status_node = widget.get('srcNode').one('#regenerate_error');
+        Y.Assert.isNotNull(status_node);
+    },
+
+    testRegenerateTokensCall: function() {
+        var mockXhr = Y.Mock();
+        Y.Mock.expect(mockXhr, {
+            method: 'io',
+            args: [MAAS_config.uris.account_handler, Y.Mock.Value.Any]
+        });
+        this.mockIO(mockXhr, module);
+        var widget = new module.TokenWidget({srcNode: '#placeholder'});
+        this.addCleanup(function() { widget.destroy(); });
+        widget.render();
+        var link = widget.get('srcNode').one('#regenerate_tokens');
+        link.simulate('click');
+        Y.Mock.verify(mockXhr);
+    },
+
+    testRegenerateTokensUpdatesTokens: function() {
+        var mockXhr = new Y.Base();
+        mockXhr.io = function(url, cfg) {
+            var response = {
+                token_key: 'token_key', token_secret: 'token_secret',
+                consumer_key: 'consumer_key'};
+            cfg.on.success(3, {response: Y.JSON.stringify(response)});
+        };
+        this.mockIO(mockXhr, module);
+        var widget = new module.TokenWidget({srcNode: '#placeholder'});
+        widget.render();
+        var link = widget.get('srcNode').one('#regenerate_tokens');
+        link.simulate('click');
+        var src_node = widget.get('srcNode');
+        Y.Assert.areEqual('token_key', src_node.one('#token_key').get('text'));
+        Y.Assert.areEqual(
+            'token_secret', src_node.one('#token_secret').get('text'));
+        Y.Assert.areEqual(
+            'consumer_key', src_node.one('#consumer_key').get('text'));
+     }
+
+}));
+
+namespace.suite = suite;
+
+}, '0.1', {'requires': [
+    'node-event-simulate', 'node', 'test', 'maas.testing', 'maas.prefs']}
+);

=== modified file 'src/maasserver/templates/maasserver/js-conf.html'
--- src/maasserver/templates/maasserver/js-conf.html	2012-02-03 14:39:50 +0000
+++ src/maasserver/templates/maasserver/js-conf.html	2012-02-09 14:53:18 +0000
@@ -8,7 +8,8 @@
 var MAAS_config = {
     uris: {
         statics: '{{ STATIC_URL }}',
-        nodes_handler: '{% url "nodes_handler" %}'
+        nodes_handler: '{% url "nodes_handler" %}',
+        account_handler: '{% url "account_handler" %}'
         },
     debug: {% if YUI_DEBUG %}true{% else %}false{% endif %}
 };
@@ -19,4 +20,5 @@
 </script>
 <script src="{{ STATIC_URL }}js/node_add.js"></script>
 <script src="{{ STATIC_URL }}js/node.js"></script>
+<script src="{{ STATIC_URL }}js/prefs.js"></script>
 <script src="{{ STATIC_URL }}js/node_views.js"></script>

=== added file 'src/maasserver/templates/maasserver/prefs.html'
--- src/maasserver/templates/maasserver/prefs.html	1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/maasserver/prefs.html	2012-02-09 14:53:18 +0000
@@ -0,0 +1,96 @@
+{% extends "maasserver/base.html" %}
+
+{% block nav-active-nodes %}active{% endblock %}
+
+{% block head %}
+  <script type="text/javascript">
+  <!--
+  YUI().use('tabview', 'maas.prefs', function (Y) {
+    var tabview = new Y.TabView({
+      srcNode: '#prefs'
+    });
+    tabview.render();
+    // Select the tab with index {{ tab }}.
+    var tab_index = parseInt('{{ tab }}');
+    var valid_tab_index = (
+        Y.Lang.isValue(tab_index) &&
+        tab_index >= 0 &&
+        tab_index < tabview.size());
+    if (valid_tab_index) {
+        tabview.selectChild(tab_index);
+    }
+
+    var profile_widget = new Y.maas.prefs.TokenWidget(
+        {'srcNode': Y.one('#api')});
+    profile_widget.render();
+  });
+  // -->
+  </script>
+{% endblock %}
+
+{% block content %}
+  <h2>User preferences for {{ user.username }}</h2>
+
+  <div id="prefs">
+    <ul>
+      <li><a href="#profile">Profile</a></li>
+      <li><a href="#api">API access</a></li>
+      <li><a href="#password">Change password</a></li>
+      <li><a href="#keys">Manage keys</a></li>
+    </ul>
+    <div>
+      <div id="profile">
+        <form action="." method="post">
+          {{ profile_form.as_p }}
+         <input type="hidden" name="profile_submit" value="1" />
+         <input type="submit" value="Save" />
+        </form>
+      </div>
+      <div id="api">
+          <p>
+            This is the required information you need to provide to a third
+            party application for it to be able to access the MaaS server
+            <a href="{% url 'api-doc' %}">API</a> on your behalf.
+          </p>
+          <p>
+            <label>API consumer key:
+            <img src="{{ STATIC_URL }}img/maybe.png"
+                 title="Note that the API consumer secret is the empty string"
+             />
+            </label>
+            <div id="consumer_key">
+              {{ user.get_profile.get_authorisation_consumer.key }}
+            </div>
+          </p>
+          <p>
+            <label>API consumer secret:</label>
+            <div>'' (the empty string)</div>
+          </p>
+          <p>
+            <label>API token key:</label>
+            <div id="token_key">
+              {{ user.get_profile.get_authorisation_token.key }}
+            </div>
+          </p>
+          <p>
+            <label>API token secret:</label>
+            <div id="token_secret">
+              {{ user.get_profile.get_authorisation_token.secret }}
+            </div>
+          </p>
+          <p id="tokens_regeneration_placeholer" />
+      </div>
+      <div id="password">
+        <form action="." method="post">
+          {{ password_form.as_p }}
+         <input type="hidden" name="password_submit" value="1" />
+         <input type="submit" value="Change password" />
+        </form>
+      </div>
+      <div id="keys">
+          TODO manage keys.
+      </div>
+    </div>
+  </div>
+
+{% endblock %}

=== modified file 'src/maasserver/templates/maasserver/user-nav.html'
--- src/maasserver/templates/maasserver/user-nav.html	2012-01-25 13:33:25 +0000
+++ src/maasserver/templates/maasserver/user-nav.html	2012-02-09 14:53:18 +0000
@@ -1,7 +1,7 @@
 <ul id="user-nav" class="nav">
   {% if user.is_authenticated %}
     <li><a href="{% url 'logout' %}">Logout</a></li>
-    <li>{{ user.username }}</li>
+    <li><a href="{% url 'prefs' %}">{{ user.username }}</a></li>
   {% else %}
     <li><a href="{% url 'login' %}">Login</a></li>
   {% endif %}

=== modified file 'src/maasserver/tests/test_models.py'
--- src/maasserver/tests/test_models.py	2012-02-08 13:45:45 +0000
+++ src/maasserver/tests/test_models.py	2012-02-09 14:53:18 +0000
@@ -144,6 +144,8 @@
         user = factory.make_user()
         consumers = Consumer.objects.filter(user=user, name=GENERIC_CONSUMER)
         self.assertEqual([user], [consumer.user for consumer in consumers])
+        self.assertEqual(
+            consumers[0], user.get_profile().get_authorisation_consumer())
         self.assertEqual(GENERIC_CONSUMER, consumers[0].name)
         self.assertEqual(KEY_SIZE, len(consumers[0].key))
         # The generic consumer has an empty secret.
@@ -154,6 +156,25 @@
         user = factory.make_user()
         tokens = Token.objects.filter(user=user)
         self.assertEqual([user], [token.user for token in tokens])
+        self.assertEqual(
+            tokens[0], user.get_profile().get_authorisation_token())
         self.assertIsInstance(tokens[0].key, unicode)
         self.assertEqual(KEY_SIZE, len(tokens[0].key))
         self.assertEqual(Token.ACCESS, tokens[0].token_type)
+
+    def test_token_reset(self):
+        # The OAuth Consumer keys and the related Token keys can be
+        # regenerated.
+        profile = factory.make_user().get_profile()
+        consumer_key = profile.get_authorisation_consumer().key
+        token_key = profile.get_authorisation_token().key
+        token_secret = profile.get_authorisation_token().secret
+
+        new_consumer, new_token = profile.reset_authorisation_token()
+        self.assertEqual('', new_consumer.secret)
+        self.assertNotEqual(consumer_key, new_consumer.key)
+        self.assertNotEqual(token_key, new_token.key)
+        self.assertNotEqual(token_secret, new_token.secret)
+
+        self.assertEqual(KEY_SIZE, len(new_token.key))
+        self.assertEqual(KEY_SIZE, len(new_consumer.key))

=== added file 'src/maasserver/tests/test_views.py'
--- src/maasserver/tests/test_views.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_views.py	2012-02-09 14:53:18 +0000
@@ -0,0 +1,96 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test maasserver API."""
+
+from __future__ import (
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+import httplib
+
+from django.contrib.auth.models import User
+from lxml.html import fromstring
+from maasserver.testing import LoggedInTestCase
+
+
+class UserPrefsViewTest(LoggedInTestCase):
+
+    def test_prefs_GET_profile(self):
+        # The preferences page (profile tab) displays a form with the
+        # user's personal information.
+        user = self.logged_in_user
+        user.first_name = 'Steve'
+        user.last_name = 'Bam'
+        user.save()
+        response = self.client.get('/accounts/prefs/')
+        doc = fromstring(response.content)
+        self.assertSequenceEqual(
+            ['User preferences for %s' % user.username],
+            [elem.text for elem in doc.cssselect('h2')])
+        self.assertSequenceEqual(
+            ['Bam'],
+            [elem.value for elem in
+                doc.cssselect('input#id_profile-last_name')])
+        self.assertSequenceEqual(
+            ['Steve'],
+            [elem.value for elem in
+                doc.cssselect('input#id_profile-first_name')])
+
+    def test_prefs_GET_api(self):
+        # The preferences page (api tab) displays the API access tokens.
+        user = self.logged_in_user
+        response = self.client.get('/accounts/prefs/?tab=1')
+        doc = fromstring(response.content)
+        # The consumer key and the token key/secret are displayed.
+        consumer = user.get_profile().get_authorisation_consumer()
+        token = user.get_profile().get_authorisation_token()
+        self.assertSequenceEqual(
+            [consumer.key],
+            [elem.text.strip() for elem in
+                doc.cssselect('div#consumer_key')])
+        self.assertSequenceEqual(
+            [token.key],
+            [elem.text.strip() for elem in
+                doc.cssselect('div#token_key')])
+        self.assertSequenceEqual(
+            [token.secret],
+            [elem.text.strip() for elem in
+                doc.cssselect('div#token_secret')])
+
+    def test_prefs_POST_profile(self):
+        # The preferences page allows the user the update its profile
+        # information.
+        response = self.client.post(
+            '/accounts/prefs/',
+            {
+                'profile_submit': 1, 'profile-first_name': 'John',
+                'profile-last_name': 'Doe', 'profile-email': 'jon@xxxxxxxxxxx'
+            })
+
+        self.assertEqual(httplib.FOUND, response.status_code)
+        user = User.objects.get(id=self.logged_in_user.id)
+        self.assertEqual('John', user.first_name)
+        self.assertEqual('Doe', user.last_name)
+        self.assertEqual('jon@xxxxxxxxxxx', user.email)
+
+    def test_prefs_POST_password(self):
+        # The preferences page allows the user to change his password.
+        self.logged_in_user.set_password('password')
+        old_pw = self.logged_in_user.password
+        response = self.client.post(
+            '/accounts/prefs/',
+            {
+                'password_submit': 1,
+                'password-old_password': 'test',
+                'password-new_password1': 'new',
+                'password-new_password2': 'new',
+            })
+        self.assertEqual(httplib.FOUND, response.status_code)
+        user = User.objects.get(id=self.logged_in_user.id)
+        # The password is SHA1ized, we just make sure that it has changed.
+        self.assertNotEqual(old_pw, user.password)

=== modified file 'src/maasserver/urls.py'
--- src/maasserver/urls.py	2012-02-08 15:20:37 +0000
+++ src/maasserver/urls.py	2012-02-09 14:53:18 +0000
@@ -21,6 +21,7 @@
     redirect_to,
     )
 from maasserver.api import (
+    AccountHandler,
     api_doc,
     MaasAPIAuthentication,
     NodeHandler,
@@ -33,11 +34,13 @@
     logout,
     NodeListView,
     NodesCreateView,
+    userprefsview,
     )
 from piston.resource import Resource
 
 # URLs accessible to anonymous users.
 urlpatterns = patterns('maasserver.views',
+    url(r'^accounts/prefs/$', userprefsview, name='prefs'),
     url(r'^accounts/login/$', login, name='login'),
     url(r'^accounts/logout/$', logout, name='logout'),
     url(
@@ -67,6 +70,7 @@
 nodes_handler = Resource(NodesHandler, authentication=auth)
 node_mac_handler = Resource(NodeMacHandler, authentication=auth)
 node_macs_handler = Resource(NodeMacsHandler, authentication=auth)
+account_handler = Resource(AccountHandler, authentication=auth)
 
 # API URLs accessible to anonymous users.
 urlpatterns += patterns('',
@@ -86,4 +90,5 @@
         r'^api/nodes/(?P<system_id>[\w\-]+)/$', node_handler,
         name='node_handler'),
     url(r'^api/nodes/$', nodes_handler, name='nodes_handler'),
+    url(r'^api/account/$', account_handler, name='account_handler'),
 )

=== modified file 'src/maasserver/views.py'
--- src/maasserver/views.py	2012-01-27 08:52:59 +0000
+++ src/maasserver/views.py	2012-02-09 14:53:18 +0000
@@ -16,17 +16,22 @@
     ]
 
 from django.contrib import messages
+from django.contrib.auth.forms import PasswordChangeForm as PasswordForm
 from django.contrib.auth.views import logout as dj_logout
 from django.core.urlresolvers import reverse
+from django.http import HttpResponseRedirect
+from django.shortcuts import render_to_response
+from django.template import RequestContext
 from django.views.generic import (
     CreateView,
     ListView,
     )
+from maasserver.forms import ProfileForm
 from maasserver.models import Node
 
 
 def logout(request):
-    messages.info(request, 'You have been logged out.')
+    messages.info(request, "You have been logged out.")
     return dj_logout(request, next_page=reverse('login'))
 
 
@@ -44,3 +49,40 @@
 
     def get_success_url(self):
         return reverse('index')
+
+
+def userprefsview(request):
+    user = request.user
+    tab = request.GET.get('tab', 0)
+    # Process the profile update form.
+    if 'profile_submit' in request.POST:
+        tab = 0
+        profile_form = ProfileForm(
+            request.POST, instance=user, prefix='profile')
+        if profile_form.is_valid():
+            messages.info(request, "Profile updated.")
+            profile_form.save()
+            return HttpResponseRedirect('%s?tab=%d' % (reverse('prefs'), tab))
+    else:
+        profile_form = ProfileForm(instance=user, prefix='profile')
+
+    # Process the password change form.
+    if 'password_submit' in request.POST:
+        tab = 2
+        password_form = PasswordForm(
+            data=request.POST, user=user, prefix='password')
+        if password_form.is_valid():
+            messages.info(request, "Password updated.")
+            password_form.save()
+            return HttpResponseRedirect('%s?tab=%d' % (reverse('prefs'), tab))
+    else:
+        password_form = PasswordForm(user=user, prefix='password')
+
+    return render_to_response(
+        'maasserver/prefs.html',
+        {
+            'profile_form': profile_form,
+            'password_form': password_form,
+            'tab': tab  # Tab index to display.
+        },
+        context_instance=RequestContext(request))


Follow ups