Django from first principles, part 14

MP 107: Building a registration page for new users.

Note: This is the 14th post in a series about building a full Django project, starting with a single file. This series will be free to everyone, as soon as each post comes out.

In the last post we built a login page, and implemented a logout button. That's helpful for users who already have accounts, but it doesn't do anything for people who want to create a new account.

In this post we'll build a registration page, where new users can create their own accounts. This will involve processing a user creation form that Django provides. We'll be writing our own forms shortly to allow users to create blogs and write posts, so this work will be good practice for the work that follows.

Building a registration page

Django provides a form that lets new users register an account, but it's up to us to specify a URL, write a view to process the form, and create a template for displaying the form. Let's do each of these in turn.

The register URL

The URL for the registration page should be similar to the one for the login page:

http://localhost:8000/accounts/register/

We'll define this URL pattern in accounts/urls.py:

from django.urls import path, include
from django.contrib.auth import urls as auth_urls

from . import views

app_name = "accounts"
urlpatterns = [
    path("", include(auth_urls)),
    path("register/", views.register, name="register"),
]
accounts/urls.py

We'll need to write a view that processes the user creation form, so we import the views module we're about to create. We point the register URL pattern to the view function we're about to write, which we'll call register().

The register() view function

Make a new file called views.py in the accounts/ directory. Here's what that file should look like:

from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import render, redirect
from django.contrib.auth import login

def register(request):
    if request.method != "POST":
        form = UserCreationForm()
    else:
        form = UserCreationForm(data=request.POST)

        if form.is_valid():
            new_user = form.save()
            login(request, new_user)
            return redirect("blogs:index")

    context = {"form": form}
    return render(request, "registration/register.html", context)
accounts/views.py

We import the UserCreationForm that Django provides for registering new users. We also import the login function, which allows you to programmatically log a user in.

Users will end up viewing this page either through a GET request, or a POST request. If you're not familiar with those terms, a browser issues a GET request when it only wants to receive information from a server. Browsers issue POST requests when they need to send information to the server. When working with a form in a view, a GET request indicates the browser needs an empty form, that the user will then fill in. A POST request indicates the user has already filled in the form, and is submitting the information they entered.

Returning a blank form

With that in mind, it's helpful to look at the register() view function in two ways. First, let's consider a user who just loaded the registration page. They need a blank copy of the registration form.

Here's the part of the view function that's relevant in this situation:

def register(request):
    if request.method != "POST":
        form = UserCreationForm()
    else:
        ...

    context = {"form": form}
    return render(request, "registration/register.html", context)
accounts/views.py

We actually detect GET requests by catching any request that's not a POST request. There are a few other request types, and anything that's not a POST request should receive the same response as a GET request. (Nobody should be trying to send a DELETE request to our registration page; if they do, we'll just send them a blank version of the form.)

When a GET request is received, we create an instance of the UserCreationForm class inside the if block. If you call UserCreationForm() with no data, Django returns a blank instance of the form.1

Notice the last two lines of this function are outside of the entire if-else block. These two lines run for all requests. The blank form that's created for a GET request is packed into the context dictionary, and passed to the register.html template to render the page. The user will see a blank form, where they can enter their data.

Processing a completed form

Now consider a user who has filled out the form, and clicked the submit button. Their browser submits a POST request, which includes the information they entered in the form.

Here's the execution path that's followed in that case:

def register(request):
    if request.method != "POST":
        ...
    else:
        form = UserCreationForm(data=request.POST)

        if form.is_valid():
            new_user = form.save()
            login(request, new_user)
            return redirect("blogs:index")

    context = {"form": form}
    return render(request, "registration/register.html", context)
accounts/views.py

There's more code here, because there's more work to do when the user has submitted some data. We create an instance of UserCreationForm again, but this time we pass it the POST data from the request. Django returns a form object, but this time the form object has extra attributes that include the data submitted by the user.

The form has three fields: a username, a password, and the same password a second time. The form.is_valid() method verifies that all this data is correct. Django verifies that the username meets appropriate criteria for a valid username, and that the passwords match and have an appropriate level of complexity. If the form is valid, we continue processing it. We save the data from the form, which stores the new user's account information in the database. We then log them in, and redirect them to the home page. They should see the authenticated version of the home page, which shows them their registration was successful.

If the form is not valid, the is_valid() method adds an error message to the form. This includes suggestions for how the user can correct their submission. Execution then drops to the last two lines of the function. The form is packed into the context dictionary, and the user sees a refreshed version of the registration page with a helpful error message.

The register template

Here's the template, which should be saved as register.html, in the same directory as login.html:

{% extends "blogs/base.html" %}

{% block content %}

  <form action="{% url 'accounts:register' %}" method='post'>
    {% csrf_token %}
    {{ form.as_div }}

    <button name="submit" class="button primary">Register</button>
  </form>
  
{% endblock content %}
accounts/templates/registration/register.html

This template is similar to the login template. It displays a form provided by Django, within the content block of the base template. The form's action field sends the form to the register() view function when it's submitted, and the method field ensures that it's submitted as a POST request.

Linking to the registration page

Finally, we need to add a link to the registration page. This should only be visible to users who aren't currently authenticated. To make sure that happens, it's placed alongside the login link in the {% else %} block of the navigation bar, in base.html:

<div class="nav-right">
  {% if user.is_authenticated %}
    ...
  {% else %}
    <a href="{% url 'accounts:login' %}">Log in</a>
    <a href="{% url 'accounts:register' %}">Register</a>
  {% endif %}
</div>
blogs/templates/blogs/base.html

Now users who aren't authenticated can click the login link if they already have an account, or the register link if they haven't made an account yet.

Using the registration page

If you load the site and log out of any existing user sessions, you should see the following:

BlogMaker Lite home page, with a link labeled "Register"
The site now shows a link to the registration page to all unauthenticated users.

If you click the registration link, you should see a form where you can enter information for a new account:

"eric" in username field, and obscured passwords entered in password and password confirmation fields
The registration page, with the three fields filled in for a new user account.

If you enter the required information and click Register, you should be brought back to the home page as an authenticated user:

home page showing message "Hello, eric." and logout button
The home page shows that the registration process was successful.

Now new users can create accounts, and everyone can log in and log out.

Looking at an invalid form

There are a number of reasons a submitted registration form can be considered invalid. The username may already have been claimed, the passwords might not match or have the appropriate level of complexity, or the user may have left one of the fields blank.

Let's make one of these mistakes, and see what the resulting page looks like:

form with only username entered ("eric"), and message "A User with that username already exists."
The registration page, after submitting a username that's already been claimed.

I submitted the same username that I used to make an account earlier. The is_valid() method recognized this, and returned False. But it also added the error message A user with that username already exists. That message is included as part of the form, and we don't have to do anything special to display it.

Looking at the POST data

Things like request.POST can seem like a bit of magic that Django performs to give you access to the information you need on the backend of a web site. Behind all that magic is a set of Python structures that you probably learned a while ago. Let's take a look at request.POST, and see how the data from the form is actually stored.

If you don't already have it running, open a terminal tab and start the development server with the runserver command. Click a few links in the BlogMaker Lite site, and watch the terminal output. You should see something like this:

$ python manage.py runserver
...
[09/Jul/2024 10:08:55] "GET / HTTP/1.1" 200 1318
[09/Jul/2024 10:08:57] "GET /blogs/ HTTP/1.1" 200 3320
[09/Jul/2024 10:08:58] "GET /blogs/1/ HTTP/1.1" 200 3949
[09/Jul/2024 10:08:59] "GET / HTTP/1.1" 200 1318

This is the output after clicking on the home page, the blogs page, a specific blog page, and back to the home page. Notice that these are all GET requests. You can see the timestamp for each request, and you can see that they all returned a 200 status code.

We can add a print() call anywhere we want inside a Django project, and the output will show up in the development server's output. You shouldn't leave print() calls in your production code, but you should feel free to learn more about Django by examining anything in the request-response cycle that you're curious about.

Let's look at the request.POST object when a new user submits a registration form:

def register(request):
    if request.method != "POST":
        ...
    else:
        form = UserCreationForm(data=request.POST)
        print("POST data:", request.POST)

        if form.is_valid():
            ...

    context = {"form": form}
    return render(request, "registration/register.html", context)
accounts/views.py

Add this print() call to the else block of the register() function, and submit a valid user registration form. You should see something like this in the terminal tab where runserver is active:

[09/Jul/2024 11:25:04] "GET /accounts/register/ HTTP/1.1" 200 1897
POST data: <QueryDict: {
  'csrfmiddlewaretoken': ['qnl8G0...'],
  'username': ['fake_user'],
  'password1': ['fake_pw'],
  'password2': ['fake_pw'],
  'submit': ['']
}>
[09/Jul/2024 11:25:24] "POST /accounts/register/ HTTP/1.1" 302 0
[09/Jul/2024 11:25:24] "GET / HTTP/1.1" 200 1318

We can see the initial GET request for the registration page. We then see the request.POST data. It's a QueryDict object, which is a Python dictionary that's been customized for working with form-based data. We can see one key-value pair for each of the fields in the form, along with the data that was submitted: fake_user, fake_pw, and fake_pw. You'll also see the value of the CSRF token, and an empty field for the submit button. Finally, we can see that the POST request resulted in a redirect (302) to the home page, which was then processed as a GET request.

Django, just like any modern web framework, can seem like it's doing a bunch of mysterious work behind the scenes. But it almost always comes back to the request-response cycle discussed earlier in this series. Most of Django's work centers around receiving information, unpacking it, processing it, repackaging it, and returning an appropriate response. Processing a form is one way way this cycle can play out.

Conclusions

Building a registration page follows the same process as building any other page in a standard Django project: define a URL pattern for the page, write a view, and write a template. The main difference between this page and the pages we made earlier in this series is the form-processing logic in the view. We'll use that same kind of logic in the next phase of work, where we let users create blogs and write their own posts.

Resources

You can find the code files from this post in the django-first-principles GitHub repository. The commits from this post are on the part_14 branch. There's only one commit for this branch.

We're not going to implement more account-focused pages in this project. If you're interested in building out more of these pages, see the documentation page Using the Django authentication system, and specifically the section Authentication Views.


1

A form is a block of HTML code that renders input fields on a web page, allowing users to enter data. Because of the name, it can seem like UserCreationForm is a form. The UserCreationForm we import is actually a class written in Python. How you make an instance of the class affects how the class is used. It can be used to generate the HTML necessary to render a blank form, or a form that shows the data that's already been entered. It can also be used to process the data that's been submitted.

Remember, all Django source code is public, and it can be helpful to take a look at the source code even if you don't fully understand it. Here's the source for UserCreationForm. It gets most of its functionality from the BaseUserCreationForm class, which in turn relies on the SetPasswordMixin class, and a hierarchy of more general form classes.