Five Days to a Django Web App: Day Three, Coding

Thanks for coming back for Day Three!

[Note: Sorry this post is a day late. It was all ready to go late yesterday, but some of the code included below triggered a bug either in WordPress or ScribeFire and the whole post got mangled. I managed to resurrect it today from drafts, and I think it's coherent, but if you find some problem with it please drop me a note.]

Progress So Far

Yesterday we built some mockups, just HTML and CSS — nothing active. Hopefully you’ve had a chance to run your mockups past a couple of people for feedback and ideas.

Armed with these mockups, we’re ready to get started coding. Today’s post is the longest in the series. Take it in chunks if you need to. (I didn’t write it all at once either.)

Foundation

MySQL

First, go to your web host’s control panel and create two MySQL databases for this project. I created "resumandb" and "test_resumandb". This latter database is needed for running the built-in test system.

You’ll get an error/warning if the test database exists when you first run tests. However, the control panel needs to set user privileges and it seems like the only way it knows how to do this is by creating the database.

Set up the same databases on your local system: bash$ mysql -u root Welcome to the MySQL monitor. Commands end with ; or \g. Your MySQL connection id is 1969 Server version: 5.0.32-Debian_7etch8-log Debian etch distributionType 'help;' or '\h' for help. Type '\c' to clear the buffer.mysql> CREATE DATABASE resumandb; Query OK, 1 row affected (0.00 sec)mysql> GRANT ALL ON resumandb.* TO 'resuman'@'localhost' identified by 'reallY_baD_9passworD!'; Query OK, 0 rows affected (0.11 sec)mysql> GRANT ALL ON test_resumandb.* TO 'resuman'@'localhost' identified by 'reallY_baD_9passworD!'; Query OK, 0 rows affected (0.0 sec) Notice that you don't need to create the test database. As mentioned above, this will happen when you run tests. ### Project Skeleton What follows is a bunch of steps: do-this, do-that. At the end of this section you should have a runnable (but empty) project that is ready to start hanging functionality onto.

Then generate a skeleton for your project: django-admin.py startproject YOURPROJECT.

Then dive right in to settings.py and change a few things:

  • Set the admin email to your email address.
  • Set the MySQL database according to your web host's settings.
  • Timezone.
  • MEDIA_ROOT, MEDIA_URL and variants — see note below.
  • TEMPLATE_DIRS — see note below.

To make it easier to use the same settings file to test both locally and on your host, I add the following to my settings.py to set MEDIA_ROOT and TEMPLATE_DIRS:

import osdef full_path_to(path):
    '''This makes this settings file relocatable.'''
    return os.path.join(os.getcwd(), path)MEDIA_ROOT = full_path_to('static/')
TEMPLATE_DIRS = (
    full_path_to('templates')
)

Then you just have to make sure to cd to the directory where your settings.py lives whenever the app runs (more on this when we deploy to the host). I find this easier than monkeying with PYTHONPATH.

Similarly, the following will change your URLs based on whether you're running locally or on the host. (Just make sure you test for a directory that only exists locally! My paths are a little different on the host.)

MEDIA_URL = 'http://media.resuman.com/resuman/static/'
if os.path.exists('/home/brian/projects/resuman'):
    MEDIA_URL = 'http://localhost/apache2-default/resuman-static/'ADMIN_MEDIA_PREFIX = 'http://media.example.com/admin_media/'
if os.path.exists('/home/brian/projects/resuman'):
    ADMIN_MEDIA_PREFIX = 'http://localhost/apache2-default/resuman-admin/'

Now it's time to generate the app: ./manage.py startapp YOURAPP. Notice that the app name should be different from the project name (otherwise it gets too confusing later on).

Edit your YOURPROJECT/urls.py to include a reference to YOURAPP:

    (r'^funnel/$', include('resuman.jobfunnel.urls')),

Also uncomment the admin lines so you can use the built-in admin app.

Now it's time to generate the app: ./manage.py startapp YOURAPP.

Now edit YOURAPP/urls.py -- paste in the needed bits from the existing urls.py, but drop the admin lines and the include().

In your settings file, add the admin and admindoc apps and "YOURPROJECT.YOURAPP" to INSTALLED_APPS.

Under the YOURPROJECT directory, create a templates directory and a static directory. Copy your HTML files from the mockup to the templates directory. Copy the CSS file to the static directory.

My system is configured by default to serve from /var/www/apache2-default/, and we specified above to fetch static files from http://localhost/resuman-static/, so we need to do the following to make this possible (substituting your paths, of course): bash$ ln -s /home/brian/projects/resuman/static /var/www/apache2-default/resuman-static bash$ ln -s /home/brian/projects/django/git/django/contrib/admin/media/ /var/www/apache2-default/resuman-admin Restart apache.

Change directory to YOURPROJECT and ./manage.py syncdb; ./manage.py runserver.

Browse to http://127.0.0.1:8000/admin/. Log in, and you should see the admin app in all its glory. If the stylesheet didn't load (ie. it looks really ugly), View Source on the page. At the top, find the URL to that ends something like .../resuman-admin/css/base.css. Copy-paste this into the address bar. It probably doesn't load. Verify that it is the right URL — if not then change your settings.py to have the right URL base. If the URL is right, then you need to fix your symlink, server config, or permissions (I often get bit by having the wrong permissions).

Adding Some Meat

This wouldn't be a bad time to push a snapshot into your version control system (e.g. git init; git add .; git commit -m'YOURPROJECT skeleton done').

Now we're finally ready to write the first view for this app. Edit YOURAPP/urls.py. Lay out the URL map that you want to use for your app. We're on a tight five day schedule, so don't go nuts! There's only time to get a couple of pages done. Don't worry, you can add more later. Here's what mine looks like:

from django.conf.urls.defaults import *urlpatterns = patterns(
    '',
    (r'^$', 'resuman.jobfunnel.views.dashboard'),
    (r'^add/$', 'resuman.jobfunnel.views.add_job'),
    (r'^edit/$', 'resuman.jobfunnel.views.edit_job'),
)

Remember that my toplevel urls.py includes this based on the ^funnel/$ pattern, so each of the patterns above will have …funnel/ as a prefix in the URL.

Now let’s write our first couple of tests. Edit YOURAPP/tests.py and add something similar to the code below. You’ll have to change URLs and logins. The class ViewTestCase is defined in viewtestcase.py, a convenience TestCase subclass I wrote for testing Django views. Copy that file into YOURAPPNAME directory.

from viewtestcase import ViewTestCase
class DashboardViewTestBase(ViewTestCase):
    # Override in subclass to use post/head/etc. Must match a
    # method defined in django.test.TestCase.
    TESTMETHOD = 'get'    # Override.
    TESTURL = '/funnel/'
    TESTARGS = {}
    TEMPLATE = 'dashboard.html'class DashboardViewLoginTest(DashboardViewTestBase):
    # We're expecting an error, so set TEMPLATE to None to avoid getting a bogus test failure.
    TEMPLATE = None    def test_login_required(self):
        """
        Tests that a login is required to view the page.
        """
        self.expect_login_redirect()
        returnclass DashboardViewTest(DashboardViewTestBase):
    # This uses TEMPLATE from the parent class.    # Set username and password and the base class will automagically login the test client.
    USERNAME = 'brian'
    TESTLOGIN = (USERNAME, 'a')    def test_logged_in_ok(self):
        pass

Now run the test: ./manage.py test. You should see exactly two failures. If a bunch of stuff fails (like built-in django tests), fix whatever is wrong before continuing.

bash$ ./manage.py test
Creating test database...
Creating table django_admin_log
Creating table auth_permission
Creating table auth_group
Creating table auth_user
Creating table auth_message
Creating table django_content_type
Creating table django_session
Creating table django_site
Creating table jobfunnel_job
Installing index for admin.LogEntry model
Installing index for auth.Permission model
Installing index for auth.Message model
Installing index for jobfunnel.Job model
Installing json fixture 'initial_data' from '/home/brian/projects/resuman/../resuman/jobfunnel/fixtures'.
Installed 1 object(s) from 1 fixture(s)
..........EE......
======================================================================
ERROR: test_login_required (resuman.jobfunnel.tests.DashboardViewLoginTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/brian/projects/resuman/../resuman/jobfunnel/viewtestcase.py", line 98, in setUp
    self.fetch_view(self.TESTMETHOD, self.TESTURL, self.TESTARGS)
  File "/home/brian/projects/resuman/../resuman/jobfunnel/viewtestcase.py", line 79, in fetch_view
    self.response = function(testurl, testargs, **extra)
  File "/usr/lib/python2.5/site-packages/django/test/client.py", line 277, in get
    return self.request(**r)
  File "/usr/lib/python2.5/site-packages/django/core/handlers/base.py", line 77, in get_response
    request.path_info)
  File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py", line 183, in resolve
    sub_match = pattern.resolve(new_path)
  File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py", line 183, in resolve
    sub_match = pattern.resolve(new_path)
  File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py", line 124, in resolve
    return self.callback, args, kwargs
  File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py", line 136, in _get_callback
    raise ViewDoesNotExist, "Tried %s in module %s. Error was: %s" % (func_name, mod_name, str(e))
ViewDoesNotExist: Tried dashboard in module resuman.jobfunnel.views. Error was: 'module' object has no attribute 'dashboard'======================================================================
ERROR: test_logged_in_ok (resuman.jobfunnel.tests.DashboardViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/brian/projects/resuman/../resuman/jobfunnel/viewtestcase.py", line 98, in setUp
    self.fetch_view(self.TESTMETHOD, self.TESTURL, self.TESTARGS)
  File "/home/brian/projects/resuman/../resuman/jobfunnel/viewtestcase.py", line 79, in fetch_view
    self.response = function(testurl, testargs, **extra)
  File "/usr/lib/python2.5/site-packages/django/test/client.py", line 277, in get
    return self.request(**r)
  File "/usr/lib/python2.5/site-packages/django/core/handlers/base.py", line 77, in get_response
    request.path_info)
  File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py", line 183, in resolve
    sub_match = pattern.resolve(new_path)
  File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py", line 183, in resolve
    sub_match = pattern.resolve(new_path)
  File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py", line 124, in resolve
    return self.callback, args, kwargs
  File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py", line 136, in _get_callback
    raise ViewDoesNotExist, "Tried %s in module %s. Error was: %s" % (func_name, mod_name, str(e))
ViewDoesNotExist: Tried dashboard in module resuman.jobfunnel.views. Error was: 'module' object has no attribute 'dashboard'----------------------------------------------------------------------
Ran 18 tests in 4.364sFAILED (errors=2)
Destroying test database...

Let’s write the view so the test will pass. Edit views.py:

@login_required
def dashboard(request):
    return render_to_response('dashboard.html',
                              {'title': 'Dashboard'},
                              context_instance=RequestContext(request))

This view uses a template called dashboard.html. Let’s make that template. Go back to the mockup for the dashboard. Copy everything into templates/base.html, then rip out the content so all you have left is the generic skeleton of a page in your app. Something like this (notice that this is using the title variable passed into the context by the view):

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><!--
# Resuman: keep track of job applications, cover letters, and resumes
#
# Copyright (c) 2009, Blakita Software LLC
# All rights reserved.
--><html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
  <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />  <title>
    {{ title }}
  </title>  <style type="text/css">
    @import "{{ MEDIA_URL }}base.css";
  </style>  {% block scripts %}
  {% endblock scripts %}</head><body><div id="container"><div id="menu">
TBD
</div> <!-- end div=menu --><div id="header">
<h1>{{ title }}</h1>
</div> <!-- end div=header --><div id="content">{% block content %}
{% endblock content %}</div> <!-- end div=content --><div id="footer">
<div class="nav">
  <ul>
    <li><a href="/">Home</a></li>
    <li><a href="/blog/about">About</a></li>
    <li><a href="/privacy.html">Privacy</a></li>
  </ul>
</div><div class="copyright">Copyright © 2009, Blakita Software LLC</div>
</div> <!-- end div=footer --></div> <!-- end div=container --></body>
</html>

Now take the content you ripped out and put it into dashboard.html:

{% extends "base.html" %}{% block content %}
<ul id="funnel">
  <li class="phase first">
    <div class="phase">Applied</div>
    <div class="companies">
    <ul class="companies">
        <li class="company">AAA</li>
    </ul>
    </div>
  </li>
  <li class="phase">
    <div class="phase">Confirmed</div>
    <div class="companies">
    <ul class="companies">
        <li class="company">BBB</li>
        <li class="company">CCC</li>
    </ul>
    </div>
  </li>
  <li class="phase">
    <div class="phase">Screen</div>
    <div class="companies">
    <ul class="companies">
        <li class="company">Fubar</li>
    </ul>
    </div>
  </li>
  <li class="phase">
    <div class="phase">Interview</div>
    <div class="companies">
    <ul class="companies">
        <li class="company">Rabuf</li>
    </ul>
    </div>
  </li>
  <li class="phase">
    <div class="phase">Offer</div>
    <div class="companies">
    <ul class="companies">
        <li class="company">Oof Rab</li>
    </ul>
    </div>
  </li>
  <li class="phase last">
    <div class="phase">Start!</div>
    <div class="companies">
    <ul class="companies">
        <li class="company">Rab Oof</li>
    </ul>
    </div>
  </li>
</ul>{% endblock content %}

Obviously this still just has dummy static data. We'll get some active data in there very soon, but now we're ready to rerun the test. It should pass this time. If not, fix the problem. When you get all tests passing, celebrate!

For real data, we need to write the model(s) used by this app. Let's add another test:

class DashboardViewTest(DashboardViewTestBase):
    USERNAME = 'brian'
    TESTLOGIN = (USERNAME, 'a')    def test_company_list(self):
        self.expect_div_content('content', 'Foobar Corp')
        return

Edit models.py to add the model:

from django.contrib.auth.models import User
from django.db import modelsclass Job(models.Model):
    applicant = models.ForeignKey(User)
    company = models.CharField(max_length=80)

The test depends on having a job in the database. Let's set up a test fixture. Edit YOURAPPNAME/fixtures/initial_data.json:

[{"pk": 1, "model": "auth.user", "fields":
    {"username": "brian", "first_name": "", "last_name": "",
     "is_active": 1, "is_superuser": 0, "is_staff": 0,
     "last_login": "2009-02-06 13:50:02", "groups": [], "user_permissions": [],
     "password": "sha1$0fcc8$c4cf5184f5c005f90165e782fb090e7d75b72986",
     "email": "brian@example.com", "date_joined": "2009-02-06 13:44:04"}},
 {"pk": 2, "model": "auth.user", "fields":
    {"username": "alan", "first_name": "", "last_name": "",
     "is_active": 1, "is_superuser": 0, "is_staff": 0,
     "last_login": "2009-02-06 13:50:02", "groups": [], "user_permissions": [],
     "password": "sha1$0fcc8$c4cf5184f5c005f90165e782fb090e7d75b72986",
     "email": "alan@example.org", "date_joined": "2009-02-06 13:44:04"}},
 {"pk": 1, "model": "jobfunnel.job", "fields":
    {"position_url": "http://example.com/career/", "title": "Foobar Eng",
     "company_url": "http://example.com/", "company": "Foobar Corp", "applicant": 1,
     "phase": "Apply", "date": "2009-02-11", "position": "Engineer",
     "notes": "Applied via website"}}
]

This will populate the database with a couple of users, both with password "a", and a job before each test runs.

Run this test, expecting exactly one failure — the content div does not contain the expected string. Let's grab the list of jobs in the view and pass it into the template:

@login_required
def dashboard(request):
    jobs = models.Job.objects.all()
    return render_to_response('dashboard.html',
                              {'title': 'Dashboard',
                               'jobs': jobs,
                               },
                              context_instance=RequestContext(request))

I won't paste all of the code here again, but we need to edit the template to use the jobs list:

    <ul class="companies">
      {% for job in jobs %}
        <li class="company">{{ job.company }}</li>
      {% empty %}
        <li class="company">No jobs in this phase.</li>
      {% endfor %}
    </ul>

Now rerun the test and expect it to pass. Hooray!

One last refinement before we quit for today: users shouldn't be able to see each others applications. The way this is coded, all jobs are going to show up on everybody's dashboards. Not good. Here's another test that checks that the application for Foobar Corp only shows up on Brian's dashboard, not on Alan's.

class PrivateDashboardViewTest(DashboardViewTestBase): USERNAME = 'alan' TESTLOGIN = (USERNAME, 'a') def test_private_applications(self): assert('Foobar' not in self.get_div_content('content')) return

Run the test, watch it fail, and then change one line in the view:

jobs = models.Job.objects.filter(applicant=request.user)

Now the all the tests should pass.

Push a copy of your code into your version control tool. Take a break, you deserve it.

For "homework", put together your other views in the same way as this one. We’ll look at deployment tomorrow.

Posted on 2009-02-11 by brian in django .
Comments on this post are closed. If you have something to share, please send me email.