The full user-authentication lifecycle in Django, with testing at every step — The step-by-step tutorial I wish I had (part two)

In part one, we created the model for the "trivial website" in our authentication-and-testing tutorial. In this second part, we’ll create the first of our two views: The user’s profile page, which is only viewable to logged-in users. We’ll also be writing our first substantial tests.

[TOC: one, two, three, four, five, six, seven, eight, nine, ten]

screenshot

This displays all (non-password) fields in the default Django User model and the only field in our UserProfile model: birth year. If no user is logged in, this redirects to the login page (which will be created in a future post).

Warning: The website cannot be viewed, and the tests can’t be run (successfully!), until the end of part three. Patience, Grasshopper.

The view

Save the following as
    /home/myname/django_auth_lifecycle/djauth_root/auth_lifecycle/view__profile.py

"""Renders web pages for the user-authentication-lifecycle project."""
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers       import reverse_lazy
from django.shortcuts               import render
from django.template                import RequestContext

TMPL_BASE_DIR = 'auth_lifecycle/'
"""
The root of the (relative) template paths used in this file, as exists
in

/home/myname/django_files/django_auth_lifecycle/auth_lifecycle/templates/

Equal to 'auth_lifecycle/'

This rendundant 'auth_lifecycle' directory is recommended, because it
allows the templates to be 'namespaced'.
- https://docs.djangoproject.com/en/1.7/topics/http/urls/#url-namespaces
- https://docs.djangoproject.com/en/1.7/topics/http/urls/#topics-http-reversing-url-namespaces
"""

PROFILE_LOGGED_OUT_REDIRECT_URL_NAME='main_page'
"""
TEMPORARY VALUE, only while the login view does not exist. Once it's
created, change this to 'login', and eliminate this comment. This
is also used by the tests.

The 'main_page' will be created in the next post. The 'login' page
will be created in the post after that.
"""
@login_required(login_url=reverse_lazy(PROFILE_LOGGED_OUT_REDIRECT_URL_NAME))
def get_rendered(request):
    """
    Displays information unique to the logged-in user.

    This blindly passes the request context back to the template.

    Before the login functionality exists, you can only view page in a
    browser by commenting out the 'login_required' decorator, and adding
    following three lines before the return:

    from django.contrib.auth import authenticate, login
    user = authenticate(username='admin', password='admin')
    login(request, user)

    - https://docs.djangoproject.com/en/1.7/topics/auth/default/#how-to-log-a-user-in
    - https://docs.djangoproject.com/en/1.7/topics/auth/default/#django.contrib.auth.decorators.login_required

    Regarding `reverse_lazy`:
    - http://stackoverflow.com/questions/26446718/decorated-view-causing-a-viewdoesnotexist-error
    """
    return  render(request, TMPL_BASE_DIR + 'user_profile.html',
                   context_instance=RequestContext(request))

The template

Save the following as
/home/myname/django_auth_lifecycle/djauth_root/auth_lifecycle/templates/auth_lifecycle/user_profile.html

{% load i18n %}       {# For the "trans" tag #}
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>{% trans "My profile" %}</title>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
        <meta name="viewport" content="width=device-width"/>
    </head>
<body>
    <h1>{% trans "My profile" %}</h1>

    <ul>
        <li>{% trans "Username" %}: {{ user.username }}</li>
        <li>{% trans "Email" %}: {{ user.email }}</li>
        <li>{% trans "Name" %}: {{ user.first_name }} {{ user.last_name }}</li>
        <li>{% trans "Year of birth" %}: {{ user.profile.birth_year }}</li>
    </ul>

    <h2>{% trans "Available actions" %}</h2>
    <ul>
        <li>{% trans "Go back to the" %} <a href="{% url 'main_page' %}">{% trans "main page" %}</a></li>
        <li>...{% trans "Logout" %}...</li>
        <li>...{% trans "Change-your-password link here" %}...</li>
    </ul>
</body>
</html>

Translation (internationalization) in all templates will be done by the i18n tag, which gives us access to the trans tag.

Our first substantial tests

When no user is logged in, the profile page should redirect them to the login page. Temporarily, until the login page is created, they’ll instead be redirected to the main page (which will be created in the next post, at which point we can actually run the tests).

When a user is logged in, all their private attributes, aside from the password, should exist somewhere in the rendered html. Test users are defined in test__utilities.py at the bottom of part one.

Save the following as
    /home/myname/django_auth_lifecycle/djauth_root/auth_lifecycle/test__view_profile.py

(The login_get_test_user used in future tests. And a reminder: The tests cannot be run and the website can’t be viewed, until the end of part three.)

"""
Tests for the user-profile view.

DEPENDS ON TEST:     test__utilities.py
DEPENDED ON BY TEST: None

To run the tests in this file:
    1. source /home/myname/django_files/django_auth_lifecycle/djauth_venv/bin/activate
    2. cd /home/myname/django_files/django_auth_lifecycle/djauth_root/
    3. python -Wall manage.py test auth_lifecycle.test__view_profile

See the top of <link to .test__utilities> for more information.
"""
from .test__utilities         import assert_attr_val_in_content
from .test__utilities         import create_insert_test_users, debug_test_user
from .test__utilities         import login_get_next_user, TEST_PASSWORD
from .test__utilities         import UserFactory
from .view__profile           import PROFILE_LOGGED_OUT_REDIRECT_URL_NAME
from django.core.urlresolvers import reverse
from django.test              import TestCase

def _subtest_next_logged_in_user(test_instance):
    """
    All private information for a single *already-logged-in* test user,
    should be displayed somewhere on the page.

    Private function for this file only.
    """

    #Log in and get the test user, and assert the login succeeded.
    test_user = login_get_next_user(test_instance)

    #The page should load successfully.
    response = test_instance.client.get(reverse('user_profile'))
    test_instance.assertEqual(200, response.status_code)

    #Convert bytes to string.
    content_str = str(response.content)

    #test_user's attributes should exist somewhere on the page
    assert_attr_val_in_content(test_instance, 'username', test_user.username, content_str)
    assert_attr_val_in_content(test_instance, 'birth_year', test_user.profile.birth_year, content_str)
    assert_attr_val_in_content(test_instance, 'email', test_user.email, content_str)
    assert_attr_val_in_content(test_instance, 'first_name', test_user.first_name, content_str)
    assert_attr_val_in_content(test_instance, 'last_name', test_user.last_name, content_str)

class ProfilePageTestCase(TestCase):
    """Tests for the user profile page."""
    def setUp(self_ignored):
        """Insert test users."""
        create_insert_test_users()

    def test_profile_redirects_when_not_logged_in(self):
        """
        Page should redirect when a non-logged-in user attempts to access
        it.
        """
        self.client.logout()
        #"follow=True" is required because we're testing a redirect.
        #- https://code.djangoproject.com/ticket/10971
        response = self.client.get(reverse('user_profile'), follow=True)

        #http://stackoverflow.com/questions/7949089/how-to-find-the-location-url-in-a-django-response-object/22073938#22073938

        #Exactly one redirect expected
        self.assertTrue(len(response.redirect_chain) == 1)

        #Get the first (which, since there's exactly one, could also be
        #'[-1]')
        last_url, status_code = response.redirect_chain[0]
        expected_url_mid = reverse(PROFILE_LOGGED_OUT_REDIRECT_URL_NAME) + '?next='
        self.assertTrue(expected_url_mid in last_url)

        #http://en.wikipedia.org/wiki/HTTP_302
        self.assertEqual(status_code, 302)

    def test_profile_logged_in(self):
        """
        Private information for logged-in users should be somewhere
        on the page.
        """

        #Test the first two users
        for  n in range(2):
            _subtest_next_logged_in_user(self)

Configure the urls

Replace the contents of
    /home/myname/django_auth_lifecycle/djauth_root/django_auth_lifecycle/urls.py
with

"""Passes browser requests to the proper view, based on the url."""
from django.conf.urls import patterns, include, url
from django.contrib   import admin

urlpatterns = patterns('',
    #Adding the namespace attribute to this element would force all
    #references to be prefixed with "auth_lifecycle:". In the template:
    #    {% url 'auth_lifecycle:url_name' %}
    #Elsewhere:
    #    reverse('auth_lifecycle:url_name')
    #
    #- https://docs.djangoproject.com/en/1.7/topics/http/urls/#url-namespaces
    #- https://docs.djangoproject.com/en/1.7/topics/http/urls/#topics-http-reversing-url-namespaces
    #
    #(Unrelated note: If this were a multi-line comment, it would cause
    # a syntax error.)
    url(r'^auth_lifecycle/', include('auth_lifecycle.urls')),
        # namespace="auth_lifecycle")),
    url(r'^admin/', include(admin.site.urls)),
)

Save the following as
    /home/myname/django_auth_lifecycle/djauth_root/auth_lifecycle/urls.py
with

from django.conf.urls import patterns, url
urlpatterns = patterns('',
    url(r'^user_profile/$', 'auth_lifecycle.view__profile.get_rendered',
        name='user_profile'),
)

In the next post, we’ll implement and test the second of two views: The main, publicly-available, aggregate-information page. And finally, we’ll actually use and test our trivial demo website. Then it’s onto authentication!

[TOC: one, two, three, four, five, six, seven, eight, nine, ten]

A final reminder to backup your files.

…to be continued…

(cue cliffhanger segue music)

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