4
\$\begingroup\$

I am writing a simple Django project which has no authentication at all. It's going to be used as the basis of a tutorial I'm writing, on the full authentication lifecycle (create account, login, change/reset password, delete account, etc.), where testing will be emphasized at every step. Beyond testing, the goal of this tutorial is to create as little of a website as possible, before moving onto the authentication parts.

This first question-post contains only the model, its tests, the urls, and settings. After these are improved, I'll follow up with a single view and its tests.

These are the only two views:

screenshot

The "main" page which is publicly viewable, but shows extra information when logged in. This screenshot demonstrates being logged in (the login functionality doesn't exist yet, but a few days ago I manually logged a user in with authenticate and then login, and the cookie is still in my browser).

It shows some aggregate information, compares their information to the aggregate, and provides a link to their profile page.

screenshot

Displays all their available information, aside from password, and links back to the main page.


I've built a few demo Django websites, nothing production. However, testing is completely new to me (both in Python and Django). While comments on anything and everything would be appreciated, I'm mostly interested in improving the tests and ensuring the Django code is Django-y. In particular, I'm wondering if there might be a more standard/concise way to do the "unit-test-required blocks" (<!--UNITRQD: start...) as you'll see in the templates (in a follow up post).

I come from backend (non-GUI) Java. I've learned both Python and Django in the past few months, hand in hand, at the same time.

pylint has been run on everything, and all tests pass.


Model

Beyond the User object, the only extra piece of information in the model is their birth year, which is in a UserProfile model.

models.py

"""Defines a single extra user-profile field for the user-authentication
    lifecycle demo project:

    - Birth year, which must be between <link to MIN_BIRTH_YEAR> and
    <link to MAX_BIRTH_YEAR>, inclusive.
"""
from datetime                   import datetime
from django.contrib.auth.models import User
from django.core.exceptions     import ValidationError
from django.db                  import models

OLDEST_EVER_AGE     = 127  #:Equal to `127`
YOUNGEST_ALLOWED_IN_SYSTEM_AGE = 13   #:Equal to `13`
MAX_BIRTH_YEAR      = datetime.now().year - YOUNGEST_ALLOWED_IN_SYSTEM_AGE
"""Most recent allowed birth year for (youngest) users."""
MIN_BIRTH_YEAR      = datetime.now().year - OLDEST_EVER_AGE
"""Most distant allowed birth year for (oldest) users."""

def _validate_birth_year(value):
    """Validator for <link to UserProfile.birth_year>, ensuring the
        selected year is between <link to OLDEST_EVER_AGE> and
        <link to MAX_BIRTH_YEAR>, inclusive.
        Raises:
            ValidationError: When the selected year is invalid.
        See:
            https://docs.djangoproject.com/en/1.7/ref/validators/
    """
    intval = -1
    try:
        intval = int(str(value).strip())
    except TypeError:
        raise ValidationError(u'"{0}" is not an integer'.format(value))

    if  intval < MIN_BIRTH_YEAR  or  intval > MAX_BIRTH_YEAR:
        message = (u'{0} is an invalid birth year.'
                   u'Must be between {1} and {2}, inclusive')
        raise ValidationError(message.format(
            value, MIN_BIRTH_YEAR, MAX_BIRTH_YEAR))
    #It's all good.

model.py continued. The model class:

class UserProfile(models.Model):
    """Extra information about a user: Birth year and profile picture. See
        the package doc for more info.

        ---NOTES---

        Useful related SQL:
            - `select id from auth_user where username <> 'admin';`
            - `select * from auth_lifecycle_userprofile where user_id=(x,x,...);`
    """
    # This line is required. Links UserProfile to a User model instance.
    user = models.OneToOneField(User, related_name="profile")

    # The additional attributes we wish to include.
    birth_year = models.IntegerField(
        blank=True,
        verbose_name="Year you were born",
        validators=[_validate_birth_year])

    # Override the __str__() method to return out something meaningful
    def __str__(self):
        return self.user.username

test__models.py

"""Tests for models.py.

    DEPENDS ON:     *nothing* (must not depend on any test_*.py file)
    DEPENDED ON BY: test_view_birth_stats.py

pylint auth_lifecycle.test__models > pylint_output.txt
pylint auth_lifecycle.test__view_birth_stats > pylint_output.txt
pylint auth_lifecycle.test__view_user_profile > pylint_output.txt
"""
from django.test                import TestCase
from auth_lifecycle.models      import UserProfile
from django.contrib.auth.models import User

TEST_USERS = [
    {"username":   "kermit", "password":  "timrek",
     "first_name": "Kermit", "last_name": "The Frog",
     "birth_year": 1955,     "email":     "kermit@muppets.com"},
    {"username":   "fozzie", "password":  "eizzof",
     "first_name": "Fozzie", "last_name": "Bear",
     "birth_year": 1976,     "email":     "fozzie@muppets.com"}
]
"""An array of users for testing purposes only. Each element is a
    dictionary containing all non-id attributes in both the User and
    UserProfile models.

    Creating the test users in this way allows us to centralize all their
    attributes, so we don't have hard-coded passwords in multiple places
    throughout the testing code, for example.

    User model attributes:

    https://docs.djangoproject.com/en/1.7/topics/auth/default/#user-objects
"""
def _insert_test_user(test_user):
    """Insert and save a single test user.
        Private function for this file only.
    """

    """Creating the user with
            user = User(...)
            user.save()
        does not properly hash and salt the password. Although it does
        save it to the database, attempting
            self.client.login(username='theusername', password='thepassword')
        fails (returns False)

        See

        https://docs.djangoproject.com/en/1.7/ref/contrib/auth/#django.contrib.auth.models.UserManager.create_user
        http://stackoverflow.com/questions/26306424/cant-login-a-just-created-user-in-a-django-test
    """
    user = User.objects.create_user(
        username=test_user['username'], password=test_user['password'],
        first_name=test_user['first_name'],
        last_name=test_user['last_name'], email=test_user['email'])

    #The user's id is automatically created by the database. To function
    #as the foreign key, it must be duplicated to the profile.

    profile = UserProfile(user_id=user.id, birth_year=test_user['birth_year'])
    profile.save()

def insert_all_test_users():
    """Utility function for inserting all test users in <link to TEST_USERS>."""
    for  test_user in TEST_USERS:
        #print(test_user)
        _insert_test_user(test_user)

test_models.py, continued. The test class:

class ModelsTestCase(TestCase):
    """Tests for models.py."""
    def setUp(self_ignored):
        """Insert test users."""
        insert_all_test_users()

    def test_all_demo_users_inserted(self):
        """Verify the database birth year equals the local test-user
            birth year.
        """
        for  test_user in TEST_USERS:
            user = User.objects.get(username=test_user['username'])
            self.assertEqual(test_user['birth_year'], user.profile.birth_year)

The project's urls.py

.../django_auth_lifecycle/django_auth_lifecycle/urls.py

from django.conf.urls import patterns, include, url
from django.contrib   import admin

urlpatterns = patterns('',
    #Adding the namespace attribute to this element would for all
    #references to be prefixed with "auth_lifecycle:". In the template:
    #    {% url 'auth_lifecycle:url_name' %}
    #Elsewhere:
    #    reverse('auth_lifecycle:url_name'))
    #See:
    #https://docs.djangoproject.com/en/1.7/topics/http/urls/#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)),
)

The sub-app's urls.py

.../django_auth_lifecycle/auth_lifecycle/urls.py

from django.conf.urls import patterns, url

urlpatterns = patterns('',
    url(r"^$", "auth_lifecycle.views.birth_year_stats", name="birth_year_stats"),
    url(r"^user_profile/$", "auth_lifecycle.views.user_profile", name="user_profile"),
)

settings.py

"""
Django settings for django_auth_lifecycle project.

For more information on this file, see
https://docs.djangoproject.com/en/1.7/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.7/ref/settings/
"""

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
BASE_DIR = os.path.dirname(os.path.dirname(__file__))


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '+dqj*v^5^3dqow9%zuc06p3mym4t353u1098%)zv!&^o(y$r1_'

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

TEMPLATE_DEBUG = True

ALLOWED_HOSTS = []


# Application definition

INSTALLED_APPS = (
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'auth_lifecycle',
)

MIDDLEWARE_CLASSES = (
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
)

TEMPLATE_DIRS = (
    BASE_DIR + '/auth_lifecycle/templates/',
)

ROOT_URLCONF = 'django_auth_lifecycle.urls'

WSGI_APPLICATION = 'django_auth_lifecycle.wsgi.application'


# Database
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases

DATABASES = {
    'default': {
        # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'.
        # Or path to database file if using sqlite3.
        'ENGINE': 'django.db.backends.postgresql_psycopg2',

        'NAME': 'auth_lifecycle_db',

        # The following settings are not used with sqlite3:
        'USER': 'auth_lifecycle_db_user',
        'PASSWORD': '5*HlH3`U}$km7aIz3{HADFji4c',
        'HOST': "localhost",  # Empty for localhost through domain sockets or
                              #'127.0.0.1' for localhost through TCP.
        'PORT': '',           # Set to empty string for default.
    }
}

# Internationalization
# https://docs.djangoproject.com/en/1.7/topics/i18n/

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_L10N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.7/howto/static-files/

STATIC_URL = '/static/'
STATIC_ROOT = ""
MEDIA_ROOT="/home/jeffy/django_files/django_auth_lifecycle/uploaded_files/"
"""Put the following in
        `/etc/nginx/sites-available/django_auth_lifecycle`
    ----------------------------------------
    server {
        server_name my.websites.ip.or.domain;

        access_log on;

        #Django User-Authentication Lifecycle Demo...START

        #Static images for the admin
        location /static/admin/ {
            alias /home/jeffy/django_files/django_auth_lifecycle_venv/lib/python3.4/site-packages/django/contrib/admin/static/admin/;
        }

        #Django User-Authentication Lifecycle Demo...END

        location / {
            proxy_pass http://127.0.0.1:8001;
            proxy_set_header X-Forwarded-Host $server_name;
            proxy_set_header X-Real-IP $remote_addr;
            add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
       }
    }
    ----------------------------------------

    This prevents nginx passing the request to the WSGI/Django app
    server. Static files need no processing, so nginx handles them
    directly.
"""
\$\endgroup\$
  • \$\begingroup\$ (In case anyone was in the middle of answering my original post when I deleted it: I could not be more sorry. I should have just put a note in it instead.) \$\endgroup\$ – aliteralmind Oct 15 '14 at 14:56
3
\$\begingroup\$
def _validate_birth_year(value):
    """Validator for <link to UserProfile.birth_year>, ensuring the
        selected year is between <link to OLDEST_EVER_AGE> and
        <link to MAX_BIRTH_YEAR>, inclusive.
        Raises:
            ValidationError: When the selected year is invalid.
        See:
            https://docs.djangoproject.com/en/1.7/ref/validators/
    """
    intval = -1
    try:
        intval = int(str(value).strip())
    except TypeError:
        raise ValidationError(u'"{0}" is not an integer'.format(value))

    if  intval < MIN_BIRTH_YEAR  or  intval > MAX_BIRTH_YEAR:
        message = (u'{0} is an invalid birth year.'
                   u'Must be between {1} and {2}, inclusive')
        raise ValidationError(message.format(
            value, MIN_BIRTH_YEAR, MAX_BIRTH_YEAR))
    #It's all good.

Range checks are better done with the following syntax:

if min <= value and value <= max

This makes it more readable.

You are checking whether it's not in range. So I'd wrap it like this:

if !(MIN_BIRTH_YEAR <= intval and intval <= MAX_BIRTH_YEAR)

But we're not quite there yet. Why are we comparing intval with MIN_BIRTH_YEAR? The values have no relation to each other. So let's rename intval to birthyear.

    birthyear = -1
    try:
        birthyear = int(str(value).strip())
    except TypeError:
        raise ValidationError(u'"{0}" is not an integer'.format(value))

    if  not (MIN_BIRTH_YEAR <= birthyear <= MAX_BIRTH_YEAR):
        message = (u'{0} is an invalid birth year.'
                   u'Must be between {1} and {2}, inclusive')
        raise ValidationError(message.format(
            value, MIN_BIRTH_YEAR, MAX_BIRTH_YEAR))
\$\endgroup\$
  • \$\begingroup\$ Do you prefer the longer form !(MIN_BIRTH_YEAR <= birthyear and birthyear <= MAX_BIRTH_YEAR) over !(MIN_BIRTH_YEAR <= birthyear <= MAX_BIRTH_YEAR)? \$\endgroup\$ – aliteralmind Oct 16 '14 at 14:24
  • 1
    \$\begingroup\$ The second one is way better if the language supports it. \$\endgroup\$ – Pimgd Oct 16 '14 at 14:31
  • \$\begingroup\$ Actually has to be not (MIN_BIRTH_YEAR <= birthyear <= MAX_BIRTH_YEAR). \$\endgroup\$ – aliteralmind Oct 18 '14 at 15:38

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.