A RESTful password locker with Django and backbone.js part 3

26 May

We left our application loading data via the API. Now we need to support CRUD operations on it.

To support deletions, we need to tweak one setting in our Django settings.py file so. Bootstrap will perform CRUD operations against a URL without a trailing slash, but by default, Django will add a trailing slash to any URLs without one. To disable this behaviour, set `APPEND_SLASH = False` in settings.py. Also update your `apps/api/urls.py` file to remove the trailing slash. It should look like this:

from django.conf.urls.defaults import patterns, url

from djangorestframework.views import ListOrCreateModelView, InstanceModelView
from apps.passwords.resources import PasswordResource

password_list = ListOrCreateModelView.as_view(resource=PasswordResource)
password_instance = InstanceModelView.as_view(resource=PasswordResource)

urlpatterns = patterns('',
    url(r'^passwords/$', password_list, name='passwords_api_root'),
    url(r'^passwords/(?P[0-9]+)$', password_instance, name='passwords_api_instance'),
)

We need to update the template to include an ‘actions’ column to let users edit and delete rows. We’ll also add a modal using Twitter bootstrap that will contain a form to let users add new entries or update existing ones.

Update `templates/passwords/password_list.html` as follows:

{% extends "base.html" %}

{% block content %}</pre>
<h1 class="page-header">Passwords</h1>
<div id="app">
<table class="table table-striped">
<thead>
<tr>
<th>Title</th>
<th>User name</th>
<th>Password</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tfoot>
<tr>
<td colspan="5"><button class="btn btn-primary" data-toggle="modal">Add new password</button></td>
</tr>
</tfoot>
</table>
<div id="passwordModal" class="modal hide fade"><form id="passwordForm" method="post">
<div class="modal-header"><button class="close" data-dismiss="modal">×</button>
<h3>Password Details</h3>
</div>
<div class="modal-body">{{ form }}</div>
<div class="modal-footer"><a class="btn" href="#" data-dismiss="modal">Cancel</a>
 <input class="btn btn-primary" type="submit" value="Save" /></div>
</form></div>
</div>
<pre>
    {% load verbatim %}

    <!-- ICanHaz templates -->
    {% comment %}
    Mustache and django both use {{}} tags for templates, so we need to use
    a custom template tag to output the mustache template exactly as it is.
    {% endcomment %}
    {% verbatim %}
<script id="passwordRowTpl" type="text/html">// <![CDATA[
<td>
            <a href="{{ url }}" target="_blank">
                {{ title }}
            </a></td>


<td>{{ username }}</td>



<td class="password">{{ password }}</td>


<td>{{ notes }}</td>


<td>
            <a href="#" class="edit" title="Edit this entry"><i class="icon-pencil"></i></a>
            <a href="#" class="destroy" title="Delete this entry"><i class="icon-remove"></i></a></td>

    
// ]]></script>
    {% endverbatim %}
{% endblock %}

Since we want to include a form in the template, we need to create a Django view for this page so we can include it.

Edit `apps/passwords/url.py` as follows:

from django.conf.urls.defaults import patterns, url

from models import Password

urlpatterns = patterns('apps.passwords.views',
    url(r'^$', 'password_list', name='password_list'),
)

And create a simple view in `apps/passwords/view.py`:

from django.shortcuts import render_to_response
from django.template import RequestContext

from forms import PasswordForm

def password_list(request):
    context = RequestContext(request)
    form = PasswordForm()
    context.update({'form': form})
    return render_to_response('passwords/password_list.html', context)

Now we need to create the form – just a standard model form will do. Create `apps/passwords/forms.py` and add the following:

from django.forms import ModelForm

from models import Password

class PasswordForm(ModelForm):
    class Meta:
        model = Password

Now we can create our application using backbone.js. Update `staticfiles/js/passwords.js` to contain the following:

// load the following using JQuery's document ready function
$(function(){

    // Password model
    var Password = Backbone.Model.extend({
        remove: function() {
            this.destroy();
        },

        validate: function(attrs) {
            if (attrs.title.length == 0 || attrs.password.length == 0)
            {
                return "Please enter a title and a password";
            }

            if (attrs.url)
            {
                var re = /^(http[s]?:\/\/){0,1}(www\.){0,1}[a-zA-Z0-9\.\-]+\.[a-zA-Z]{2,5}[\.]{0,1}/;
                if (!re.test(attrs.url))
                {
                    return "Please enter a valid URL";
                }
            }
        }
    });

    // set up the view for a password
    var PasswordView = Backbone.View.extend({
        tagName: 'tr',

        events: {
            "click a.edit" : "editPassword",
            "click a.destroy" : "remove"
        },

        editPassword: function(event) {
            event.preventDefault();
            event.stopImmediatePropagation();
            // call back up to the main app passing the current model for it
            // to allow a user to update the details
            this.options.app.editPassword(this.model);
        },

        remove: function(event) {
            event.stopImmediatePropagation();
            event.preventDefault();
            if (confirm("Are you sure you want to delete this entry?"))
            {
                this.model.remove();
            }
        },

        render: function () {
            // template with ICanHaz.js (ich)
            $(this.el).html(ich.passwordRowTpl(this.model.toJSON()));
            return this;
        }

    });

    // define the collection of passwords
    var PasswordCollection = Backbone.Collection.extend({
        model: Password,
        url: '/api/1.0/passwords/',

        // maintain ordering by password title
        comparator: function(obj1, obj2) {
            return obj1.get('title').localeCompare(obj2.get('title'));
        }
    });

    /**
     * Manages the list of passwords and related data. Events are only for
     * child nodes of the generated element.
     */
    var PasswordListView = Backbone.View.extend({
        tagName: 'tbody',

        /**
         * Constructor. Takes a reference to the parent view so we can invoke
         * methods on it.
         */
        initialize: function(options) {
            // instantiate a password collection
            this.passwords = new PasswordCollection();

            this.passwords.bind('all', this.render, this);
            this.passwords.fetch();
        },

        addOne: function(password) {
            // pass a reference to the main application into the password view
            // so it can call methods on it
            this.$el.append(new PasswordView({model: password, app: this.options.app}).render().el);
            return this;
        },

        addNew: function(password) {
            this.passwords.create(password);
            return this;
        },

        updatePassword: function(passwordData) {
            var password = this.passwords.get(passwordData.id);
            if (_.isObject(password))
            {
                // iterate through all the data in passwordData, setting it
                // to the password model
                for (var key in passwordData)
                {
                    // ignore the ID attribute
                    if (key != 'id')
                    {
                        password.set(key, passwordData[key]);
                    }
                }

                // persist the change
                password.save();
                this.passwords.sort();
            }
        },

        render: function() {
            this.$el.html('');
            this.passwords.each(this.addOne, this);
            return this;
        }
    });

    /**
     * View for the overall application. We need this because backbone can only
     * bind events for children of 'el'.
     *
     * In our template our modal is inside #app, so this class handles
     * interaction at the application level rather than strictly with a
     * collection of Passwords (that's the job of the PasswordListView).
     */
    var AppView = Backbone.View.extend({
        el: '#app',
        events: {
            "click #passwordForm :submit": "handleModal",
            "keydown #passwordForm": "handleModalOnEnter",
            "hidden #passwordModal": "prepareForm"
        },

        initialize: function() {
            this.passwordList = new PasswordListView({app: this});
        },

        render: function() {
            this.$el.find('table').append(this.passwordList.render().el);
        },

        /**
         * Allows users to update an existing password
         *
         * @param Password password: A Password Model of the password to edit.
         */
        editPassword: function(password) {
            this.prepareForm(password.toJSON());
            // store the password ID as data on the modal itself
            $('#passwordModal').data('passwordId', password.get('id'));
            $('#passwordModal').modal('show');
        },

        /**
         * Sets up the password form.
         *
         * @param object passwordData: An object containing data to use for the
         * form values. Any fields not present will be set to defaults.
         */
        prepareForm: function(passwordData) {
            passwordData = passwordData || {};

            var data = {
                'title': '',
                'username': '',
                'password': '',
                'url': '',
                'notes': ''
            };

            $.extend(data, passwordData);

            var form = $('#passwordForm');
            $(form).find('#id_title').val(data.title);
            $(form).find('#id_username').val(data.username);
            $(form).find('#id_password').val(data.password);
            $(form).find('#id_url').val(data.url);
            $(form).find('#id_notes').val(data.notes);

            // clear any previous references to passwordId in case the user
            // clicked the cancel button
            $('#passwordModal').data('passwordId', '');
        },

        handleModal: function(event) {
            event.preventDefault();
            event.stopImmediatePropagation();
            var form = $('#passwordForm');

            var passwordData = {
                title: $(form).find('#id_title').val(),
                username: $(form).find('#id_username').val(),
                password: $(form).find('#id_password').val(),
                url: $(form).find('#id_url').val(),
                notes: $(form).find('#id_notes').val()
            };

            if ($('#passwordModal').data('passwordId'))
            {
                passwordData.id = $('#passwordModal').data('passwordId');
                this.passwordList.updatePassword(passwordData);
            }
            else
            {
                // add or update the password
                this.passwordList.addNew(passwordData);
            }

            // hide the modal
            $('#passwordModal').modal('hide');

            return this;
        },

        handleModalOnEnter: function(event) {
            // process the modal if the user pressed the ENTER key
            if (event.keyCode == 13)
            {
                return this.handleModal(event);
            }
        }
    });

    var app = new AppView();
    app.render();
});

Explanation of the backbone.js code

We’ve added validation to the model although we don’t currently display the error messages to the user. We’ll address this at a future stage.

We’ve also added a method that allows us to delete instances.

The PasswordView listens to events for the  ‘actions’ we added to the password template and allows objects to be edited and deleted.

We’ve added a comparator to the PasswordCollection so it stays nicely ordered by password title.

The PasswordListView handles updates to passwords as well as adding new ones.

Finally, the AppView listens to events related to submitting the password form and resetting the form when the modal is closed.

When passwords are edited, we keep track of which password is being edited by setting a data attribute on the modal itself with the ID of the model to edit. This isn’t set if we need to add a new password. Every time the modal is hidden, we reset the form and remove this ID data attribute in case users cancel the modal.

The modal is displayed and hidden thanks to Twitter Bootstrap purely due to classes and data attributes in the HTML.

One final tweak

Run the server with `./manage.py runserver` and everything should work – except updates. Bootstrap submits the complete model, but in our Django model we’ve defined several fields as uneditable. So the final thing we need to do is to edit `apps/passwords/resources.py` and make it drop the uneditable fields (created_at, updated_at and id) before validating the form, otherwise Django REST framework will complain that extra fields have been submitted. Edit the file so it contains the following:

from djangorestframework.resources import ModelResource
from django.core.urlresolvers import reverse

from models import Password

class PasswordResource(ModelResource):
    model = Password
    # by default, django rest framework won't return the ID - backbone.js
    # needs it though, so don't exclude it
    exclude = None
    ordering = ('-title',)
    # django rest framework will overwrite our 'url' attribute with its own
    # that points to the resource, so we need to provide an alternative.
    include = ('resource_url',)
    ignore_fields = ('created_at', 'updated_at', 'id')

    def url(self, instance):
        """
        Return the instance URL. If we don't specify this, django rest
        framework will return a generated URL to the resource
        """
        return instance.url

    def resource_url(self, instance):
        """
        An alternative to the 'url' attribute django rest framework will
        add to the model.
        """
        return reverse('passwords_api_instance',
                       kwargs={'id': instance.id})

    def validate_request(self, data, files=None):
        """
        Backbone.js will submit all fields in the model back to us, but
        some fields are set as uneditable in our Django model. So we need
        to remove those extra fields before performing validation.
        """
        for key in self.ignore_fields:
            if key in data:
                del data[key]

        return super(PasswordResource, self).validate_request(data, files)

Editing data now works:

Summary

At this point our app supports the following:

  • When we load the page http://localhost:8000/passwords/ backbone.js loads our passwords via the API and renders a table containing the data.
  • We’re able to add new entries with AJAX via our API.
  • We can update entries
  • We can delete entries

What’s not supported:

  • We are validating our backbone.js model on the client side and server side, but if validation fails we don’t inform the user. We should provide them with this feedback.
  • There’s no authentication and passwords aren’t associated with specific users.
  • We need to support sharing passwords between users since that’s the purpose of this app.
  • It’d be nice to be able to put passwords into categories or to tag them.
  • It’d also be nice to mask passwords, and only reveal them when users hover over them.
  • Users’ saved passwords are stored in plain text in the database. This is really bad, but if we encrypt them, how do we decrypt them when they’re shared between users? We’ll solve this later.

In our next iteration we’ll relate passwords with users and add authentication to our API.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: