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:
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!
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/:
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:
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:
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:
Ok good!
Now submit a valid form and see if a new user is created (we should be redirected to the home page):
Related article: Django: Custom User Model Extending AbstractUser
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:
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">×</button>
</div>
{% endfor %}
<!-- ... -->
</body>
<!-- ... -->
You can see the message in the image below:
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.
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">×</button>
</div>
<!--...-->
Let's try to log in with invalid data:
Now fill the form and submit it, check if the user is redirected to the home page:
I am not going to test the login view because it is basically similar to the signup view.
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>
<!-- ... -->
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_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_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:
and if not:
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 %}
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 %}
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:
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>
<!-- ... -->
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)
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.
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>
<!-- .. -->
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:
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:
# 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())
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 %}
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
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.
i have the same problem as akib when running the first test (after alter role create db.) full output: Found 5 test(s). Using existing test database for alias 'default'... System check identified some issues: WARNINGS: ?: (urls.W005) URL namespace 'users' isn't unique. You may not be able to reverse all URLs in this namespace System check identified 1 issue (0 silenced). ..F.. ====================================================================== FAIL: test_redirect_if_user_is_authenticated (users.tests.test_views.TestSignUpView) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/x/myproject/users/tests/test_views.py", line 50, in test_redirect_if_user_is_authenticated self.assertRedirects(response, reverse('core:home')) File "/Users/x/myproject/venv/lib/python3.9/site-packages/django/test/testcases.py", line 392, in assertRedirects self.assertEqual( AssertionError: 200 != 302 : Response didn't redirect as expected: Response code was 200 (expected 302) ---------------------------------------------------------------------- Ran 5 tests in 1.812s FAILED (failures=1) Preserving test database for alias 'default'... (venv) (base)x@As-MacBook-Pro myproject %
June 30, 2022, 4:41 p.m.
hi, thanks for this info, its really helping me get some problems sorted. a couple of issues ive found, when setting the project up, you missed out the part about physically creating the media directory, or uploading / copying any media files. at the making a login page, if youre logged into the admin site, you will never see the login page as youre automatically redirected to home. tell people to make sure theyre logged out. i still have the same issue as akib, hoping youll be able to help me later! again, brilliant site, well described and thanks again!
June 30, 2022, 5:59 p.m.
Hi andy, Thanks for the positive feedback, I really appreciate it! I noticed some of these issues but I couldn't make the changes as this will require editing all the series and this will take me too much time. Thanks for pointing this out though. Regarding the AssertionError you got when running the test `test_redirect_if_user_is_authenticated`, I think the problem is on your end because I just downloaded the source code and run the tests and everything is ok. For your reference here are the exact same steps that I did: - Downloaded the code for this part: https://github.com/Rouizi/django-blog/tree/v0.3 - used sqlite instead of PostgreSQL because I had a problem installing the requirements, so I removed this in settings.py: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'blog', 'USER': 'test', 'PASSWORD': '1234', 'HOST': '', 'PORT': 5432 } } and added this: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', } } - commented psycopg2 inside requirements.txt (# psycopg2==2.8.6) - installed the virtual environment and activated it: $ python3 -m venv venv $ source venv/bin/activate - Installed the requirements: $ pip install -r requirements.txt - Run the tests: $ python manage.py test users And everything was fine: Creating test database for alias 'default'... System check identified no issues (0 silenced). .............. ---------------------------------------------------------------------- Ran 14 tests in 2.808s OK Destroying test database for alias 'default'... Hope this will help, please let me know if it worked for you too.
June 30, 2022, 9:06 p.m.
I am not sure where you’re getting your info, but good topic. I needs to spend some time learning more or understanding more. Thanks for great info I was looking for this information for my mission.
July 27, 2022, 2 p.m.
HI @RichardMaype, Thanks for the positive feedback. I appreciate it!
July 28, 2022, 9:44 p.m.
I apologise, but, in my opinion, you are not right. I am assured. Let's discuss it.
Oct. 24, 2022, 10:16 a.m.
Hi @avenue18, yeah we can discuss it. Can you please tell me where the error is? Thank you.
Oct. 24, 2022, 11:55 a.m.
Nov. 29, 2021, 12:25 p.m.