This website is made possible by displaying online advertisements to our visitors.
Please consider supporting us by disabling your ad blocker.

Sponsored

Django blog tutorial part 3: Authentication and Profile Page

April 14 2021 Yacine Rouizi
Blog Django Authentication Testing
Django blog tutorial part 3: Authentication and Profile Page

In this part, we are going to explore Django's authentication system and how to create a profile page for users.

So we are going to implement the following pages: sign up, log in, log out, reset password, change password, profile, and finally an edit page.

Below, I included a list of the articles in this series:

  1. Django blog tutorial part 1: Project Configuration
  2. Django blog tutorial part 2: Model View Template
  3. Django blog tutorial part 3: Authentication and Profile Page (This post)
  4. Django blog tutorial 4: Posts and Comments
  5. Django blog tutorial part 5: Deployment on Heroku
  6. Django blog tutorial part 6: Setting Up an Email Service
  7. Django blog tutorial part 7: Serving Media Files From Amazon S3

If you'd like to follow along with me, go download the source code of the project at this link: https://github.com/Rouizi/django-blog/tree/v0.2 and let's get started!

Sponsored

Sign Up

Let's start with the signup view. Open the file users/views.py:

# users/views.py
from django.shortcuts import render
from django.contrib.auth.forms import UserCreationForm

def signup(request):
    form = UserCreationForm()
    return render(request, 'users/signup.html', {'form': form})

Create a new folder named users inside the templates directory as well as a new file signup.html inside the users folder:

<!-- templates/users/signup.html -->
{% extends 'base.html' %}

{% block content %}
<h2>Sign Up</h2>
<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit" class="btn btn-primary">Sign Up</button>
</form>
{% endblock content %}

and now create a new route in the users/urls.py file:

# users/urls.py         <-- create this file
from django.urls import path

from .views import signup

urlpatterns = [
    path('signup/', signup, name='signup'),
]

# blog/urls.py
# ...

urlpatterns = [
    # ...
    # We include the new urls.py file
    path('users/', include(('users.urls', 'users'), namespace='users')),
]

# ...

Open the URL http://127.0.0.1:8000/users/signup/:

Sign Up

This is good enough, but let's add a custom form to have a better styling:

<!-- templates/users/signup.html -->
{% extends 'base.html' %}

{% block head_title %}Sign Up{% endblock %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-12">
      <h2 class="my-5">Sign up</h2>
      <form method="post">
        {% csrf_token %}
        <div class="form-group">
          {{ form.username.label_tag }}
          <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" id="id_username"
            name="username" value='{{ form.username.value|default:"" }}'>
          <small class="text-muted">{{ form.username.help_text }}</small>
          {% if form.username.errors %}
          <div class="invalid-feedback">{{ form.username.errors }}</div>
          {% endif %}
        </div>

        <div class="form-group">
          {{ form.password1.label_tag }}
          <input type="password" class="form-control {% if form.password1.errors %}is-invalid{% endif %}"
            id="id_password1" name="password1" value='{{ form.password1.value|default:"" }}'>
          <small class="text-muted">{{ form.password1.help_text }}</small>
          {% if form.password1.errors %}
          <div class="invalid-feedback">{{ form.password1.errors }}</div>
          {% endif %}
        </div>

        <div class="form-group">
          {{ form.password2.label_tag }}
          <input type="password" class="form-control {% if form.password2.errors %}is-invalid{% endif %}"
            id="id_password2" name="password2" value='{{ form.password2.value|default:"" }}'>
          <small class="text-muted">{{ form.password2.help_text }}</small>
          {% if form.password2.errors %}
          <div class="invalid-feedback">{{ form.password2.errors }}</div>
          {% endif %}
        </div>
        <button type="submit" class="btn btn-primary">Sign up</button>
      </form>
    </div>
  </div>
</div>
{% endblock content %}

Check the signup page again:

Sign Up

Now let's come back to the signup view to handle the form submission:

# users/views.py
from django.shortcuts import render, redirect
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth import login


def signup(request):
    if request.method == 'POST':
        form = UserCreationForm(request.POST)
        if form.is_valid():
            user = form.save()
            login(request, user)
            return redirect('core:home')
    else:
        form = UserCreationForm()
    return render(request, 'users/signup.html', {'form': form})

When the form is saved, a User instance is created. The created user is then logged in with the login() function.

Let's try to submit the form and see what happens:

Sign Up

Ok, Django is not happy! Do you know why?

The UserCreationForm uses the built-in User model. But if you've been following this tutorial series from part 1, you know that we are using a custom User model, hence the error.

So we need to tell Django to use our User model instead of the built-in one. Create a new file forms.py in the users app:

# users/forms.py
from django import forms
from django.contrib.auth.forms import UserCreationForm

from.models import User

class SignUpForm(UserCreationForm):
    class Meta:
        model = User        
        fields = ("username",)

and use this new form in the signup view:

# users/views.py
from django.shortcuts import render, redirect
from django.contrib.auth import login

from  .forms import SignUpForm

def signup(request):
    if request.method == 'POST':
        form = SignUpForm(request.POST) # replace UserCreationForm with SignUpForm
        if form.is_valid():
            user = form.save()
            login(request, user)
            return redirect('core:home')
    else:
        form = SignUpForm()   # replace UserCreationForm with SignUpForm
    return render(request, 'users/signup.html', {'form': form})

Let's try submitting the form again:

Sign Up

Ok good!

Now submit a valid form and see if a new user is created (we should be redirected to the home page):

Sign Up

New User

Sponsored

Adding an Email Field to the User Model

Since we are going to implement the reset password feature, we need the user's email address.

To do so, we are going to extend the User model to include the email field:

# users/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    email = models.EmailField('email address', unique=True)
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ["username"]

We need to delete the database and recreate it:

(venv) $ sudo -u postgres psql
password for user postgres : 
psql (12.4 (Ubuntu 12.4-1.pgdg20.04+1))
Type "help" for help.

postgres=# DROP DATABASE blog;
postgres=# CREATE DATABASE blog;

Run the migrations: 

(venv) $ python manage.py makemigrations
(venv) $ python manage.py migrate

Add the email field in the form and the template:

# users/forms.py
# ...
class SignUpForm(UserCreationForm):
    class Meta:
        model = User
        fields = ("username", "email")
<!-- templates/users/signup.html -->
<!--  removed for brevity  -->
<div class="form-group">
  {{ form.username.label_tag }}
  <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" id="id_username"
    name="username" value='{{ form.username.value|default:"" }}'>
  {% if form.username.errors %}
  <div class="invalid-feedback">{{ form.username.errors }}</div>
  {% endif %}
  <small class="text-muted">{{ form.username.help_text }}</small>
</div>

<div class="form-group">
  {{ form.email.label_tag }}
  <input type="email" class="form-control {% if form.email.errors %}is-invalid{% endif %}" id="id_email"
    name="email" value='{{ form.email.value|default:"" }}'>
  {% if form.email.errors %}
  <div class="invalid-feedback">{{ form.email.errors }}</div>
  {% endif %}
</div>

<div class="form-group">
  {{ form.password1.label_tag }}
  <input type="password" class="form-control {% if form.password1.errors %}is-invalid{% endif %}"
    id="id_password1" name="password1" value='{{ form.password1.value|default:"" }}'>
  {% if form.password1.errors %}
  <div class="invalid-feedback">{{ form.password1.errors }}</div>
  {% endif %}
  <small class="text-muted">{{ form.password1.help_text }}</small>
</div>
<!--  removed for brevity  -->

Now go to the signup page, you can see the email field:

Sign Up

Finally, let's improve the signup view a bit:

# users/signup.py
# ...
from django.contrib import messages

def signup(request):
    # redirect a user to the home page if he is already logged in
    if request.user.is_authenticated:
        return redirect('core:home')
    if request.method == 'POST':
        form = SignUpForm(request.POST)
        if form.is_valid():
            user = form.save()
            login(request, user)
            # display a nice message when a new user is registered
            messages.success(request, "Congratulations, you are now a registered user!")
            return redirect('core:home')
    else:
        form = SignUpForm()
    return render(request, 'users/signup.html', {'form': form})
<!-- templates/base.html -->
<!-- ... -->

<body>
    <nav class="navbar navbar-expand-sm navbar-light bg-light">
     <!-- ... -->
    </nav>

    <!-- Adding the message in the template -->
    {% for message in messages %}
    <div class="alert alert-success alert-dismissible fade show">
      {{ message }}
      <button type="button" class="close" data-dismiss="alert">&times;</button>
    </div>
    {% endfor %}
    <!-- ... -->
</body>

<!-- ... -->

You can see the message in the image below:

Sign Up

Sponsored

Testing the Sign Up View

Create a new folder named tests within the users folder. Inside the tests folder create an empty file __init__.py.

Now remove the file users/tests.py and create a new file named test_views.py inside the tests folder.

In the end, you should have the following structure:

|── myproject/
|    |── blog/
|    |── core/    
|    |── users/               
|    |    |── migrations/
|    |    |── admin.py
|    |    |── apps.py
|    |    |── __init__.py
|    |    |── models.py
|    |    |── tests/                <-- create this folder
|    |    |    |── __init__.py      <-- create this file
|    |    |    |── test_views.py    <-- create this file
|    |    |── views.py
|    |──venv/
|    |── manage.py

Now, let's add some test:

# users/tests/test_views.py
from django.test import TestCase
from django.urls import reverse

from ..models import User
from ..forms import SignUpForm


class TestSignUpView(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username='user1', email='user1@gmail.com', password='1234'
        )
        self.data = {
            'username': 'test',
            'email': 'test@hotmail.com',
            'password1': 'test12345',
            'password2': 'test12345'
        }

    def test_signup_returns_200(self):
        response = self.client.get(reverse('users:signup'))
        self.assertEqual(response.status_code, 200)
        # Check we used correct template
        self.assertTemplateUsed(response, 'users/signup.html')

    def test_user_is_logged_in(self):
        response = self.client.post(
            reverse('users:signup'), self.data, follow=True
        )
        user = response.context.get('user')

        self.assertTrue(user.is_authenticated)

    def test_new_user_is_registered(self):
        # We can check that a user has been registered by trying to find
        # it in the database but I prefer the method with count()
        nb_old_users = User.objects.count()  # count users before a request
        self.client.post(reverse('users:signup'), self.data)
        nb_new_users = User.objects.count()  # count users after
        # make sure 1 user was added
        self.assertEqual(nb_new_users, nb_old_users + 1)

    def test_redirect_if_user_is_authenticated(self):
        # If the user is authenticated and try to access
        # the signup page, he is redirected to the home page
        login = self.client.login(email='user1@gmail.com', password='1234')
        response = self.client.get(reverse('users:signup'))

        self.assertRedirects(response, reverse('core:home'))

    def test_invalid_form(self):
        # We don't give a username
        response = self.client.post(reverse('users:signup'), {
            "email": "test@admin.com",
            "password1": "test12345",
            "password2": "test12345",
        })
        form = response.context.get('form')

        self.assertFalse(form.is_valid())

I think the tests are pretty straightforward to understand. Nothing special.

Now if you run the test, you will have an error:

(venv) $ python manage.py test users
Creating test database for alias 'default'...
Got an error creating the test database: permission denied to create database

This is because when Django runs the test suite, it creates a new database, in our case test_blog. Our user (test) does not have permission to create a database, hence the error message.

Let's add the createdb permission to our user:

(venv) $ sudo -u postgres psql
# ...
postgres=# ALTER USER test CREATEDB;
ALTER ROLE

Let's run the test again:

(venv) $ python manage.py test users --keepdb
Using existing test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 1.016s

OK
Preserving test database for alias 'default'...

Notice the use of the --keepdb parameter, this is useful for running the tests faster.

Sponsored

Login

The login view is going to be pretty similar to the signup view. First, let's create a login form:

# users/forms.py

# ...

class LoginForm(forms.Form):
    email = forms.CharField()
    password = forms.CharField()

Now we can use this form in the login view:

# users/views.py
from django.contrib.auth import login, authenticate
from .forms import SignUpForm, LoginForm

# ...

def log_in(request):
    if request.user.is_authenticated:
        return redirect('core:home')
    if request.method == "POST":
        form = LoginForm(request.POST)
        if form.is_valid():
            email = form.cleaned_data["email"]
            password = form.cleaned_data["password"]
            # We check if the data is correct
            user = authenticate(email=email, password=password)
            if user:  # If the returned object is not None
                login(request, user)  # we connect the user
                return redirect('core:home')
            else:  # otherwise an error will be displayed
                messages.error(request, 'Invalid email or password')
    else:
        form = LoginForm()

    return render(request, 'users/login.html', {'form': form})

Don't forget to create a new route in the urls.py file: 

# users/urls.py
from django.urls import path
from .views import signup, log_in

urlpatterns = [
    path('signup/', signup, name='signup'),
    path('login/', log_in, name='login'),
]

and a new template login.html:

<!-- templates/users/login.html -->
{% extends 'base.html' %}

{% block head_title %}Log In{% endblock %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-12">
      <h2 class=" my-5">Log in</h2>
      <form method="post">
        {% csrf_token %}

        <div class="form-group">
          {{ form.email.label_tag }}
          <input type="email" class="form-control {% if form.email.errors %}is-invalid{% endif %}" id="id_email"
            name="email" value='{{ form.email.value|default:"" }}'>
          {% if form.email.errors %}
          <div class="invalid-feedback">{{ form.email.errors }}</div>
          {% endif %}
        </div>

        <div class="form-group">
          {{ form.password.label_tag }}
          <input type="password" class="form-control {% if form.password.errors %}is-invalid{% endif %}"
            id="id_password" name="password" value='{{ form.password.value|default:"" }}'>
          {% if form.password.errors %}
          <div class="invalid-feedback">{{ form.password.errors }}</div>
          {% endif %}
        </div>

        <button type="submit" class="btn btn-primary">Sign up</button>
      </form>
    </div>
  </div>
</div>
{% endblock content %}

To show the error message we need to edit the base.html file:

<!-- templates/base.html -->

<!--...-->

<div class="alert alert-{% if message.tags == 'error' %}danger{% else %}{{ message.tags }}{% endif %} alert-dismissible fade show">
    {{ message }}
    <button type="button" class="close" data-dismiss="alert">&times;</button>
</div>

<!--...-->

Let's try to log in with invalid data:

Login

Now fill the form and submit it, check if the user is redirected to the home page:

Login

I am not going to test the login view because it is basically similar to the signup view.

Sponsored

Logout

Now that users can sign up and log in, we need to offer them the logout option. This can be done with the logout() function:

# users/views.py
from django.contrib.auth import login, authenticate, logout 
from django.urls import reverse

# ...

def log_out(request):
    logout(request)
    return redirect(reverse('users:login'))

Edit the urls.py to add a new route:

# users/urls.py
from django.urls import path
from .views import signup, log_in, log_out

urlpatterns = [
    path('signup/', signup, name='signup'),
    path('login/', log_in, name='login'),
    path('logout/', log_out, name='logout'),
]

If you are logged in, just access the URL 127.0.0.1:8000/users/logout/, and you will be logged out.

This is not very handy. We can add some useful links in the navigation bar:

<!-- templates/base.html -->

<!-- ... -->

<body>
    <nav class="navbar navbar-expand-sm navbar-light bg-light">
      <div class="container">
        <a href="{% url 'core:home' %}" class="navbar-brand">Django blog</a>
        <button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#navbarCollapse">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse justify-content-between" id="navbarCollapse">
          <div class="navbar-nav">
            <a href="{% url 'core:home' %}"
              class="nav-item nav-link  {% if request.path == '/' %}active{% endif %}">Home</a>
          </div>

          {% if request.user.is_authenticated %}
          <div class="navbar-nav ml-auto">
            <a href="#" class="nav-item nav-link">Profile</a>
            <a href="{% url 'users:logout' %}" class="nav-item nav-link">Log out</a>
          </div>
          {% else %}
          <div class="navbar-nav ml-auto">
            <a href="{% url 'users:login' %}"
              class="nav-item nav-link {% if request.path == '/users/login/' %}active{% endif %}">Log in</a>
            <a href="{% url 'users:signup' %}"
              class="nav-item nav-link {% if request.path == '/users/signup/' %}active{% endif %}">Sign up</a>
          </div>
          {% endif %}
        </div>
      </div>
    </nav>

<!-- ... -->

Sponsored

Password Reset

To allow users to request a password reset, we need to send emails. Since we are in the development phase, we will simply display the email in the console.

We are going to use Django's built-in views for the password reset process.

Add the EMAIL_BACKEND variable to the end of the file settings.py:

# blog/settings.py

# ...

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

Now we need to add the routes in the urls.py file:

# users/urls.py
from django.urls import path
from django.contrib.auth.views import (
    PasswordResetView,
    PasswordResetDoneView,
    PasswordResetConfirmView,
    PasswordResetCompleteView
)

from .views import signup, log_in, log_out


urlpatterns = [
    path('signup/', signup, name='signup'),
    path('login/', log_in, name='login'),
    path('logout/', log_out, name='logout'),
    path('password_reset/', PasswordResetView.as_view(
        template_name='users/password_reset.html',
        email_template_name='users/password_reset_email.html',
        subject_template_name='users/password_reset_subject.txt',
        success_url='/users/password_reset/done/'),
        name='password_reset'
    ),
    path('password_reset/done/', PasswordResetDoneView.as_view(
        template_name='users/password_reset_done.html'),
        name='password_reset_done'
    ),
    path('reset/<uidb64>/<token>/', PasswordResetConfirmView.as_view(
        template_name='users/password_reset_confirm.html',
        success_url='/users/reset/done/'),
        name='password_reset_confirm'
    ),
    path('reset/done/', PasswordResetCompleteView.as_view(
        template_name='users/password_reset_complete.html'),
        name='password_reset_complete'
    ),
]

We are using our HTML files instead of the default ones, so we have to create these files:

password_reset.html

<!-- templates/users/password_reset.html -->
{% extends 'base.html' %}

{% block head_title %}Password reset{% endblock head_title %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-4">
      <h2 class=" my-5">Password reset</h2>
      <form method="post">
        {% csrf_token %}
        <div class="form-group">
          {{ form.email.label_tag }}
          <input type="email" class="form-control" id="id_email" name="email" value='{{ form.email.value|default:"" }}'>
        </div>

        <button type="submit" class="btn btn-primary">Reset Password</button>
      </form>
    </div>
  </div>
</div>
{% endblock content %}

password_reset_subject.txt

# templates/users/password_reset_subject.txt
Dajngo Blog Reset password

password_reset_email.html

<!-- templates/users/password_reset_email.html -->
Hi there,

To reset your password for the user {{ user.username }}, click the link below:

{{ protocol }}://{{ domain }}{% url 'users:password_reset_confirm' uidb64=uid token=token %}


If the above link does not work, please copy and paste the URL into a new window.

Regards,
The Django Blog team

Password Reset Email

password_reset_done.html

<!-- templates/users/password_reset_done.html -->
{% extends 'base.html' %}

{% block head_title %}Reset your password{% endblock head_title %}

{% block content %}
<div class="container">
  <div class="row">
    <div class=" col-12">
      <h2 class="my-5">Password reset</h2>
      <p>
        We have sent you instructions for setting your password, if an account
        exists with the email you entered you should receive them shortly.
      </p>
      <p>
        If you haven't received an email, make sure you've entered the address you
        signed up with and check your spam folder.
      </p>
    </div>
  </div>
</div>
{% endblock content %}

Password Reset Done

password_reset_confirm.html

<!-- templates/users/password_reset_confirm.html -->
{% extends 'base.html' %}

{% block head_title %}New password{% endblock head_title %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-12">
      <h2 class=" my-5">Change password</h2>
      {% if validlink %}
      <div class="row">
        <form class="col-4" method="post">
          {% csrf_token %}
          <div class="form-group">
            {{ form.new_password1.label_tag }}
            <input type="password" class="form-control {% if form.new_password1.errors %}is-invalid{% endif %}"
              id="id_new_password1" name="new_password1" value='{{ form.new_password1.value|default:"" }}'>
            {% if form.new_password1.errors %}
            <div class="invalid-feedback">{{ form.new_password1.errors }}</div>
            {% endif %}
          </div>

          <div class="form-group">
            {{ form.new_password2.label_tag }}
            <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}"
              id="id_new_password2" name="new_password2" value='{{ form.new_password2.value|default:"" }}'>
            {% if form.new_password2.errors %}
            <div class="invalid-feedback">{{ form.new_password2.errors }}</div>
            {% endif %}
          </div>
          <button type="submit" class="btn btn-primary">Change password</button>
        </form>
      </div>
      {% else %}
      <p>
        The password reset link was not valid, possibly because it has already been used,
        please request a <a href="{% url 'users:password_reset' %}">new password reset.</a>
      </p>
      {% endif %}
    </div>
  </div>
</div>
{% endblock content %}

If the link is valid:

Password Reset Confirm Valid

and if not:

Password Reset Confirm Invalid

password_reset_complete.html

<!-- templates/users/password_reset_complete.html -->
{% extends 'base.html' %}

{% block head_title %}Password changed successfully{% endblock head_title %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-12">
      <h2 class="my-5">Password Changed successfully</h2>
      <p>
        Your password has been changed successfully. You may now proceed to
        <a href="{% url 'users:login' %}">log in.</a>
      </p>
    </div>
  </div>
</div>
{% endblock content %}

Password Reset Complete

The last thing we want to do is adding a link in the login page to the password reset page:

<!-- templates/users/login.html -->
<!-- ... -->
{% block content %}
<div class="container">
  <div class="row">
    <div class="col-12">
      <h2 class=" my-5">Log in</h2>
      <form method="post">
        <!-- ... -->
      </form>
      <br>
      <p>Forgot your password? <a href="{% url 'users:password_reset' %}">Click here</a></p>
    </div>
  </div>
</div>
{% endblock content %}

Sponsored

Profile Page

To create a users profile page, let's start with the model:

# users/models.py
# ...

class Profile(models.Model):
    about_me = models.TextField()
    image = models.ImageField(upload_to='profile_image', null=True, blank=True)
    user = models.OneToOneField(User, on_delete=models.CASCADE)

    def __str__(self):
        return self.user.username

We could have extended the User model to include these fields, but I preferred to create a new model to see how signals work in Django.

Note that this method results in additional queries, so in a real project, it is preferable to extend the User model.

Run the migrations:

(venv) $ python manage.py makemigrations users
Migrations for 'users':
  users/migrations/0003_profile.py
    - Create model Profile
(venv) $ python manage.py migrate users
Operations to perform:
  Apply all migrations: users
Running migrations:
  Applying users.0003_profile... OK

Now what we want to do is to automatically create a Profile instance every time a User instance is created. To do this, we need to use signals.

Create a new file signals.py and add the following:

# users/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import User, Profile

@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)

and import the signals module:

# users/apps.py
from django.apps import AppConfig

class UsersConfig(AppConfig):
    name = 'users'

    # add this function
    def ready(self):
        from . import signals

# users/__init__.py 
default_app_config = 'users.apps.UsersConfig'

With this simple code, a Profile instance is automatically created each time a User instance is created.

views.py

# users/views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from .models import User, Profile

# ...

@login_required
def profile(request, username):
    user = get_object_or_404(User, username=username)
    profile = get_object_or_404(Profile, user=user)
    return render(request, 'users/profile.html', {'profile': profile, 'user': user})

settings.py

# blog/settings.py

# ...

LOGIN_URL = "/users/login/"

urls.py

# users/urls.py
from .views import signup, log_in, log_out, profile
# ...

urlpatterns = [
    # ...
    path('profile/<username>/', profile, name='profile'),
]

and finally the template:

<!-- templates/users/profile.html -->
{% extends 'base.html' %}
{% load static %}

{% block head_title %}{{ user.username }} | Django Blog{% endblock head_title %}

{% block content %}
<div class="container-fluid mt-5">
  <div class="row">
    <div class="card col-lg-6 offset-lg-3 mb-3">
      <div class="row">
        {% if user.profile.image %}
        <img class="card-img-top col-sm-3 col-4 my-3" src="{{ profile.image.url }}" alt="{{ user.username }}">
        {% else %}
        <img class="card-img-top col-sm-3 col-4 my-3" src="{% static 'img/avatar.svg' %}" alt="{{ user.username }}">
        {% endif %}
        <div class="card-body col-9">
          <h2 class="card-title my-3">{{ user.username }}</h2>
          <p>Last login on: {{ user.last_login }}</p>
          <p class="card-text">{{ user.profile.about_me }}</p>
        </div>
      </div>
    </div>
  </div>
</div>
{% endblock %}

We need to tell Django where to find the static files in case a user doesn't have a profile image:

# blog/settings.py

# ...

STATICFILES_DIRS = (BASE_DIR / 'static',)

Create a new folder named static in the root directory of the project and an img folder inside the static folder. Now put an image inside the img folder that you want to use as the default avatar for the user's profile.

You can see the result in the image below:

Profile

To make it easy for users to check their profile, let's add a link in the navigation bar:

<!-- templates/base.html -->

<nav class="navbar navbar-expand-sm navbar-light bg-light">
  <div class="container">
    <!-- ... -->
    <div class="collapse navbar-collapse justify-content-between" id="navbarCollapse">
      <!-- ... -->
      {% if request.user.is_authenticated %}
      <div class="navbar-nav ml-auto">
        <a href="{% url 'users:profile' request.user.username %}"
            class="nav-item nav-link {% if request.user.username in request.path %}active{% endif %}">
            Profile
         </a>
        <a href="{% url 'users:logout' %}" class="nav-item nav-link">Log out</a>
      </div>
      {% else %}
      <!-- ... -->
      {% endif %}
    </div>
  </div>
</nav>

and a link in the post detail page:

<!-- templates/core/post.html -->
<!-- ... -->

by
<a class="badge badge-secondary" href="{% url 'users:profile' post.author.username %}">
  {{ post.author }}
</a>
<!-- ... -->

Sponsored

Testing the Profile View

Let's write some test for the profile view:

# users/tests/test_views.py

# ...

class ProfileViewTest(TestCase):
    def setUp(self):
        self.user1 = User.objects.create_user(
            username="user1", email="user1@gmail.com", password="1234"
        )
        self.user2 = User.objects.create_user(
            username="user2", email="user2@gmail.com", password="1234"
        )

    def test_redirect_if_not_logged_in(self):
        response = self.client.get(reverse(
            "users:profile", kwargs=({"username": self.user1.username}))
        )

        self.assertRedirects(
            response, "/users/login/?next=/users/profile/user1/")

    def test_returns_200(self):
        self.client.login(email="user1@gmail.com", password="1234")
        response = self.client.get(reverse(
            "users:profile", kwargs=({"username": self.user1.username})
        ))

        self.assertEqual(response.status_code, 200)

    def test_view_returns_profile_of_current_user(self):
        self.client.login(email="user1@gmail.com", password="1234")
        response = self.client.get(reverse(
            "users:profile", kwargs=({"username": self.user1.username}))
        )
        # Check we got the profile of the current user
        self.assertEqual(response.context["user"], self.user1)
        self.assertEqual(response.context["profile"], self.user1.profile)

    def test_view_returns_profile_of_a_given_user(self):
        self.client.login(email="user1@gmail.com", password="1234")
        # access the profile of the user 'user'
        response = self.client.get(reverse(
            "users:profile", kwargs=({"username": self.user2.username}))
        )
        self.assertEqual(response.context["user"], self.user2)
        # Check we got the profile of the user 'user2'
        self.assertEqual(response.context["profile"], self.user2.profile)

Sponsored

Edit Profile

Now that we have a profile page, we need to allow users to add some information about themselves. To do so, we're going to create a form to let users  change their username and write something about themselves.

Add the following code in the forms.py file:

# users/forms.py

# ...

class EditProfileForm(forms.Form):
    username = forms.CharField()
    about_me = forms.CharField(widget=forms.Textarea())
    image = forms.ImageField(required=False)

The view that uses this form is shown below:

# users/views.py
# ...

from .forms import SignUpForm, LoginForm, EditProfileForm

# ...

@login_required
def edit_profile(request):
    if request.method == "POST":
        form = EditProfileForm(request.POST, request.FILES)
        if form.is_valid():
            about_me = form.cleaned_data["about_me"]
            username = form.cleaned_data["username"]
            image = form.cleaned_data["image"]

            user = User.objects.get(id=request.user.id)
            profile = Profile.objects.get(user=user)
            user.username = username
            user.save()
            profile.about_me = about_me
            if image:
                profile.image = image
            profile.save()
            return redirect("users:profile", username=user.username)
    else:
        form = EditProfileForm()
    return render(request, "users/edit_profile.html", {'form': form})

The argument request.POST contains textual data only. So to handle files we added a second argument: request.FILE.

Add a new route in the urls.py file:

# users/urls.py
from .views import signup, log_in, log_out, profile, edit_profile
# ...

urlpatterns = [
    # ...
    path('edit-profile/', edit_profile, name='edit_profile'),
    # ...
]

And finally, the template:

<!-- templates/users/edit_profile.html -->
{% extends 'base.html' %}

{% block head_title %}Edit profile{% endblock %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-6">
      <h2 class="my-5">Edit profile</h2>
      <form method="post" enctype="multipart/form-data">
        {% csrf_token %}
        <div class="form-group">
          {{ form.username.label_tag }}
          <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" id="id_username"
            name="username" value='{{ form.username.value|default:user.username }}'>
          {% if form.username.errors %}
          <div class="invalid-feedback">{{ form.username.errors }}</div>
          {% endif %}
        </div>

        <div class="form-group">
          {{ form.about_me.label_tag }}
          <textarea type="text" class="form-control {% if form.about_me.errors %}is-invalid{% endif %}" id="id_about_me"
            name="about_me" value='{{ form.about_me.value }}' rows="5"></textarea>
          {% if form.about_me.errors %}
          <div class="invalid-feedback">{{ form.about_me.errors }}</div>
          {% endif %}
        </div>

        <div class="form-group">
          {{ form.image.label_tag }}<br>
          <input type="file" class="{% if form.image.errors %}is-invalid{% endif %}" id="id_image" name="image"
            accept="image/*">
          {% if form.image.errors %}
          <div class="invalid-feedback">{{ form.image.errors }}</div>
          {% endif %}
        </div>

        <button type="submit" class="btn btn-primary">Submit</button>
      </form>
      <br>
    </div>
  </div>
</div>
{% endblock content %}

Note the new attribute of the form: enctype="multipart/form-data". Without it, the browser will not send files to the web server.

Edit Profile

Let's add a link to this page from the profile page:

<!-- templates/users/profile.html -->
<!-- ... -->
          <p><a href="{% url 'users:edit_profile' %}">Edit your profile</a></p>
<!-- .. -->

Profile

Sponsored

Fixing a Bug in the Edit Profile View

I don't know if you noticed, but our view has a bug. Indeed, what happens if a user tries to change their username to that of another registered user?

You can see in the image below that Django throws an error:

Edit Profile

The error comes from the database because the username column is defined with the parameter unique=True. Our application allows users to change their username but does not verify that the new username is not taken by another user.

To fix this, we have to add a clean method in the EditProfileForm to make sure that the username entered in the form does not exist in the database.

The username validation method is shown below:

# users/forms.py
# ...

class EditProfileForm(forms.Form):
    username = forms.CharField()
    about_me = forms.CharField(widget=forms.Textarea())
    image = forms.ImageField(required=False)

    def __init__(self, original_username, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.original_username = original_username

    def clean_username(self):
        """
        This function throws an exception if the username has already been 
        taken by another user
        """

        username = self.cleaned_data['username']
        if username != self.original_username:
            if User.objects.filter(username=username).exists():
                raise forms.ValidationError(
                    'A user with that username already exists.')
        return username

Now, when the form is created we need to pass him the original_username argument:

# users/views.py

# ...

@login_required
def edit_profile(request):
    if request.method == "POST":
        # request.user.username is the original username
        form = EditProfileForm(request.user.username, request.POST, request.FILES)
    # ...
    else:
        form = EditProfileForm(request.user.username)   # <-- add also here 
    return render(request, "users/edit_profile.html", {'form': form})

In the image below you can see that the form return an error if I try to reproduce the bug:

Edit Profile

Sponsored

Testing the Edit Profile View

# users/tests/test_views.py
import tempfile
from PIL import Image
from django.test import TestCase, override_settings
from ..models import User, Profile

# ...

class EditProfileViewTest(TestCase):
    def setUp(self):
        self.user1 = User.objects.create_user(
            username='user1', email='user1@gmail.com', password='1234'
        )

    def test_edit_profile_returns_200(self):
        self.client.login(email='user1@gmail.com', password='1234')
        response = self.client.get(reverse('users:edit_profile'))
        self.assertEqual(response.status_code, 200)

    def test_edit_profile_redirects_if_not_logged_in(self):
        response = self.client.get(reverse('users:edit_profile'))
        self.assertRedirects(
            response, '/users/login/?next=/users/edit-profile/')

    def test_edit_profile_change_username(self):
        self.client.login(email='user1@gmail.com', password='1234')
        response = self.client.post(reverse('users:edit_profile'), {
            'username': 'user2',
            'about_me': 'Hello world'
        })

        # Check that the username 'user1' becomes 'user2'
        user2 = User.objects.filter(email='user1@gmail.com')[0]
        self.assertEqual(user2.username, 'user2')

    # override settings for media dir to avoid filling up your disk
    @override_settings(MEDIA_ROOT=tempfile.gettempdir())
    def test_upload_image(self):
        login = self.client.login(email='user1@gmail.com', password='1234')
        image = self._create_image()
        profile = Profile.objects.get(user=self.user1)

        # check that no image exists before the request
        self.assertFalse(bool(profile.image))

        with open(image.name, 'rb') as f:
            response = self.client.post(reverse('users:edit_profile'), {
                'username': 'user1',
                'about_me': 'Hello world',
                'image': f
            })
        profile.refresh_from_db()

        self.assertTrue(bool(profile.image))

    def _create_image(self):
        """Create a temporary image to test with it"""

        f = tempfile.NamedTemporaryFile(delete=False, suffix='.png')
        image = Image.new('RGB', (200, 200), 'white')
        image.save(f, 'PNG')

        return f

In the test_upload_image test, we are using the override_settings decorator to use a temporary folder. This ensures that the uploaded files will be cleaned up by the operating system.

To reload data from the database, we use the refresh_from_db() method.

Now let's create a new test file, to test the new form validation method. Create a new file test_forms.py inside the users/tests directory and add the following code:

# users/tests/test_forms.py
from django.test import TestCase

from ..models import User
from ..forms import EditProfileForm


class EditProfileFormTest(TestCase):
    def test_username_already_taken(self):
        User.objects.create_user(
            username='user1', email='user1@gmail.com', password='1234')

        form = EditProfileForm(
            data={
                'username': 'user1',
                'about_me': 'somthing about me'
            },
            original_username='user'
        )

        self.assertFalse(form.is_valid())

Sponsored

Password Change

The built-in password change view is protected by the login_required decorator, so only logged in users can access to it.

Add the following in the users/urls.py file:

# users/urls.py
from django.urls import path
from django.contrib.auth.views import (
    # ...
    PasswordChangeView,
    PasswordChangeDoneView
)
from django.urls import reverse_lazy

urlpatterns = [
    # ...
    path('password_change/', PasswordChangeView.as_view(
        template_name='users/password_change.html',
        success_url=reverse_lazy('users:password_change_done')),
        name='password_change'),
    path('password_change/done/', PasswordChangeDoneView.as_view(
        template_name='users/password_change_done.html'),
        name='password_change_done'),
]

And now the template files:

password_change.html

<!-- templates/users/password_change.html -->
{% extends 'base.html' %}

{% block head_title %}Change password{% endblock head_title %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-7">
      <h2 class=" my-5">Change password</h2>
      <form method="post">
        {% csrf_token %}
        <div class="form-group">
          {{ form.old_password.label_tag }}
          <input type="password" class="form-control {% if form.old_password.errors %}is-invalid{% endif %}"
            id="id_old_password" name="old_password" value='{{ form.old_password.value|default:"" }}'>
          {% if form.old_password.errors %}
          <div class="invalid-feedback">{{ form.old_password.errors }}</div>
          {% endif %}
        </div>
        <div class="form-group">
          {{ form.new_password1.label_tag }}
          <input type="password" class="form-control {% if form.new_password1.errors %}is-invalid{% endif %}"
            id="id_new_password1" name="new_password1" value='{{ form.new_password1.value|default:"" }}'>
          {% if form.new_password1.errors %}
          <div class="invalid-feedback">{{ form.new_password1.errors }}</div>
          {% endif %}
          <small class="text-muted">{{ form.new_password1.help_text }}</small>
        </div>
        <div class="form-group">
          {{ form.new_password2.label_tag }}
          <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}"
            id="id_new_password2" name="new_password2" value='{{ form.new_password2.value|default:"" }}'>
          {% if form.new_password2.errors %}
          <div class="invalid-feedback">{{ form.new_password2.errors }}</div>
          {% endif %}
        </div>
        <button type="submit" class="btn btn-primary">Change password</button>
    </div>
  </div>
</div>
{% endblock content %}

password_change_done.html

<!-- templates/users/password_change_done.html -->
{% extends 'base.html' %}

{% block head_title %}Password changed successfully{% endblock head_title %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-12">
      <div class="alert alert-success mt-5" role="alert">
        Your password has been changed successfully!
      </div>
      <a href="{% url 'core:home' %}" class="btn btn-secondary">Return to home page</a>
    </div>
  </div>
</div>
{% endblock content %}

Password Change

Password Change Done

Finally, let's add a link to change password in the profile page:

<!-- templates/users/profile.html -->
<!-- ... -->
          <p><a href="{% url 'users:edit_profile' %}">Edit your profile</a></p>
          <p><a href="{% url 'users:password_change' %}">Change password</a></p>
<!-- ... -->

Our implementation of the authentication system is complete. In the next chapter, we'll see how to allow users to create posts and leave comments on posts.

You can find the source code of the project, including this chapter, on GitHub at this link: https://github.com/Rouizi/django-blog/tree/v0.3

Previous Article
Django blog tutorial part 2: Model View Template

Django blog tutorial part 2: Model View Template

Next Article
7 Best Python Courses for Beginners

7 Best Python Courses for Beginners

Subscribe

Join the mailing list to be notified about new posts and updates.

Comments 2
Avatar akib said
While testing, got an error,
AssertionError: 200 != 302 : Response didn't redirect as expected: Response code was 200 (expected 302)
 how to solve this?
Thank you.

Nov. 29, 2021, 12:25 p.m.

Avatar Yacine said
Hi akib,
Thank you for asking for help. But can you please provide more information about this error? I mean at which step did you get this error? and what URL triggered this error?

Nov. 29, 2021, 9:55 p.m.

Leave a comment

(Your email address will not be published)