How to create a fullstack application using Django and Python Part 18

Wednesday, September 18, 2024 at 12:15 PM | 15 min read

Last modified on Monday, May 25, 2026 at 1:40 PM

#fullstack development, #django, #macOS, #python3, #password reset, #security, #series, #beautifulsoup4, #html5lib, #html parser, #soupsieve, #coverage.py, #test coverage, #tests, #unittest

Combination protection

Photo by Towfiqu barbhuiya on unsplash.com

Important Note: Before committing anything to Git or pushing anything to remote, please visit How to create a fullstack application using Django and Python Part 4 where I discuss how to add the python-dotenv package to the Django site and why it is crucial to do it. This article assumes you have a working knowledge of Git.

Table of Contents

Displaying the contents of emails in the Terminal console

During development, there are two ways that we can "send" emails. Instead of sending real emails, we can log them. We can write dummy emails to a .txt file, or we can display them as stdout to Terminal. It is more convenient to display them in Terminal.

Adding the EMAIL_BACKEND setting to settings.py

Next, we add the EMAIL_BACKEND setting to django_boards/settings.py file:

# django_boards/settings.py EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

Configuring the routes

The password reset process requires four views:

1, A page with a form to start the reset process.

  1. A success page saying the process has been initiated, instructing the user to check their spam directory, etc.
  2. A page to check the token sent via email.
  3. A page to tell the user if the reset was successful or not.

The views are built-in, so we don't need to implement anything. All we need to do is add the routes to the urls.py and create the templates.

In django_boards/urls.py:

urlpatterns = [ path("", views.index, name="index"), path("signup/", accounts_views.signup, name="signup"), path("login/", auth_views.LoginView.as_view(template_name='login.html'), name='login'), path("logout/", auth_views.LogoutView.as_view(), name='logout'), path('password-reset/', auth_views.PasswordResetView.as_view( template_name='password_reset.html', email_template_name='password_reset_email.html', subject_template_name='password_reset_subject.txt'), name='password_reset'), path('password-reset/done/', auth_views.PasswordResetDoneView.as_view(template_name='password_reset_done.html'), name='password_reset_done'), path('password-reset/<uidb64>/<token>/', auth_views.PasswordResetConfirmView.as_view(template_name='password_reset_confirm.html'), name='password_reset_confirm'), path('password-reset/complete/', auth_views.PasswordResetCompleteView.as_view(template_name='password_reset_complete.html'), name='password_reset_complete'), path("boards/<str:id>/", views.board_topics, name="board_topics"), path("boards/<str:id>/new/", views.new_topic, name="new_topic"), path("admin/", admin.site.urls), ]

It is important to pass in the template_name so that we know which password-reset path is associated with which template. If a template_name is not passed in, then Django defaults to registration/password_reset_form.html to render the associated template for the view, but as I already mentioned, we want to explicitly tell Django which template the URL is associated with.

Creating the password_reset templates

Next, inside the templates directory, we create the following templates:

  1. registration/password_reset_form.html:
  2. password_reset_email.html: template for when user receives email after requesting a password reset. The actual Django template can be found at registration/password_reset_form.html.
  3. registration/password_reset_subject.txt: will override the subject of the password reset email. In order for it to be able to override the subject of the password reset email, however, it has to be placed in the (global) templates directory. Otherwise, it will not work. Please visit the thread on stackoverflow entitled Django override password_reset_subject.txt does not change subject which covers this issue in detail. This also should only contain a single line.
  4. password_reset_done.html: text content of template is the following: 'We’ve emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly. If you don’t receive an email, please make sure you’ve entered the address you registered with, and check your spam folder.'
  5. password_reset_confirm.html: In this template, the user types the new email, types it again to confirm it, and then resets the password. But if the password reset link is invalid, the user has to request a new password reset.
  6. password_reset_complete.html: In this template, the user is told something like "Your password has been set." and then something like "Go to Login". This page confirms that the password has been set successfully.

Creating a test_view_password_reset.py file in accounts/tests/

Next, I create a test_view_password_reset.py file in the accounts/tests/ directory.

The password_reset view

<!-- templates/password_reset.html --> {% extends "base_accounts.html" %} {% block title %}Reset your password{% endblock title %} {% block content %} <div class="row justify-content-center"> <div class="col-lg-4 col-md-6 col-sm-8"> <div class="card"> <div class="card-body"> <h3 class="card-title">Reset your password</h3> <p>Enter your email address and we will send you a link to reset your password.</p> <form method="post" novalidate> {% csrf_token %} {% include "includes/form.html" %} <button type="submit" class="btn btn-primary btn-block">Send password reset email</button> </form> </div> </div> </div> </div> {% endblock %}

Creating accounts/tests/test_view_password_reset.py

# accounts/tests/test_view_password_reset.py from django.contrib.auth import views as auth_views from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.models import User from django.core import mail from django.urls import reverse from django.urls import resolve from django.test import TestCase class PasswordResetTests(TestCase): def setUp(self): url = reverse('password_reset') self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/password-reset/') self.assertEqual(view.func.view_class, auth_views.PasswordResetView) def test_csrf(self): self.assertContains(self.response, 'csrfmiddlewaretoken') def test_contains_form(self): form = self.response.context.get('form') self.assertIsInstance(form, PasswordResetForm) def test_form_inputs(self): ''' The view must contain two inputs: csrf and email ''' self.assertContains(self.response, '<input', 2) self.assertContains(self.response, 'type="email"', 1) class SuccessfulPasswordResetTests(TestCase): def setUp(self): email = 'john@doe.com' User.objects.create_user(username='john', email=email, password='123abcdef') url = reverse('password_reset') self.response = self.client.post(url, {'email': email}) def test_redirection(self): ''' A valid form submission should redirect the user to `password_reset_done` view ''' url = reverse('password_reset_done') self.assertRedirects(self.response, url) def test_send_password_reset_email(self): self.assertEqual(1, len(mail.outbox)) class InvalidPasswordResetTests(TestCase): def setUp(self): url = reverse('password_reset') self.response = self.client.post(url, {'email': 'donotexist@email.com'}) def test_redirection(self): ''' Even invalid emails in the database should redirect the user to `password_reset_done` view ''' url = reverse('password_reset_done') self.assertRedirects(self.response, url) def test_no_reset_email_sent(self): self.assertEqual(0, len(mail.outbox))

Let's break up this file. It's already a bit big. We will break it up by class.

First, let's create a file called test_password_reset_tests.py and place it inside accounts/tests. Then let's place the imports at the top of the file followed by the class PasswordResetTests contents. it should then look like the following:

# accounts/tests/test_password_reset_tests.py from django.contrib.auth import views as auth_views from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.models import User from django.core import mail from django.urls import reverse from django.urls import resolve from django.test import TestCase class PasswordResetTests(TestCase): def setUp(self): url = reverse('password_reset') self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/password-reset/') self.assertEqual(view.func.view_class, auth_views.PasswordResetView) def test_csrf(self): self.assertContains(self.response, 'csrfmiddlewaretoken') def test_contains_form(self): form = self.response.context.get('form') self.assertIsInstance(form, PasswordResetForm) def test_form_inputs(self): ''' The view must contain two inputs: csrf and email ''' self.assertContains(self.response, '<input', 2) self.assertContains(self.response, 'type="email"', 1)

Next, let's create a file called test_successful_password_reset_tests.py and place it in the accounts/tests/ directory. Then it should look like the following:

# accounts/tests/test_successful_password_reset_tests.py from django.contrib.auth import views as auth_views from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.models import User from django.core import mail from django.urls import reverse from django.urls import resolve from django.test import TestCase class SuccessfulPasswordResetTests(TestCase): def setUp(self): email = 'john@doe.com' User.objects.create_user(username='john', email=email, password='123abcdef') url = reverse('password_reset') self.response = self.client.post(url, {'email': email}) def test_redirection(self): ''' A valid form submission should redirect the user to `password_reset_done` view ''' url = reverse('password_reset_done') self.assertRedirects(self.response, url) def test_send_password_reset_email(self): self.assertEqual(1, len(mail.outbox))

Lastly, let's create a file called test_invalid_password_reset_tests.py and place it in the accounts/tests/ directory.

# accounts/tests/test_invalid_password_reset_tests.py from django.contrib.auth import views as auth_views from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.models import User from django.core import mail from django.urls import reverse from django.urls import resolve from django.test import TestCase class InvalidPasswordResetTests(TestCase): def setUp(self): url = reverse('password_reset') self.response = self.client.post(url, {'email': 'donotexist@email.com'}) def test_redirection(self): ''' Even invalid emails in the database should redirect the user to `password_reset_done` view ''' url = reverse('password_reset_done') self.assertRedirects(self.response, url) def test_no_reset_email_sent(self): self.assertEqual(0, len(mail.outbox))

Creating templates/password_reset_subject.txt

<!-- templates/password_reset_subject.txt --> [Django Boards] Please reset your password

Creating templates/password_reset_email.html

Hi there, Someone asked for a password reset for the email address {{ email }}. Follow the link below: {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} In case you forgot your Django Boards username: {{ user.username }} If clicking the link above doesn't work, please copy and paste the URL in a new browser window instead. If you've received this mail in error, it's likely that another user entered your email address by mistake while trying to reset a password. If you didn't initiate the request, you don't need to take any further action and can safely disregard this email. Thanks, The Django Boards Team

Creating accounts/tests/test_mail_password_reset.py

test_mail_password_reset.py tests the email message:

# accounts/tests/test_mail_password_reset.py from django.core import mail from django.contrib.auth.models import User from django.urls import reverse from django.test import TestCase class PasswordResetMailTests(TestCase): def setUp(self): User.objects.create_user(username='john', email='john@doe.com', password='123') self.response = self.client.post(reverse('password_reset'), { 'email': 'john@doe.com' }) self.email = mail.outbox[0] def test_email_subject(self): self.assertEqual('[Django Boards] Please reset your password', self.email.subject) def test_email_body(self): context = self.response.context token = context.get('token') uid = context.get('uid') password_reset_token_url = reverse('password_reset_confirm', kwargs={ 'uidb64': uid, 'token': token }) self.assertIn(password_reset_token_url, self.email.body) self.assertIn('john', self.email.body) self.assertIn('john@doe.com', self.email.body) def test_email_to(self): self.assertEqual(['john@doe.com',], self.email.to)

The PasswordResetMailTests test grabs the email sent by Django Boards, checks out the subject line, the body of the email, and who received the email.

To maintain consistency, we will change the name of this file to test_password_reset_mail_tests.py and place the above code into it. It should look like the following:

# accounts/tests/test_password_reset_mail_tests.py from django.core import mail from django.contrib.auth.models import User from django.urls import reverse from django.test import TestCase class PasswordResetMailTests(TestCase): def setUp(self): User.objects.create_user(username='john', email='john@doe.com', password='123') self.response = self.client.post(reverse('password_reset'), { 'email': 'john@doe.com' }) self.email = mail.outbox[0] def test_email_subject(self): self.assertEqual('[Django Boards] Please reset your password', self.email.subject) def test_email_body(self): context = self.response.context token = context.get('token') uid = context.get('uid') password_reset_token_url = reverse('password_reset_confirm', kwargs={ 'uidb64': uid, 'token': token }) self.assertIn(password_reset_token_url, self.email.body) self.assertIn('john', self.email.body) self.assertIn('john@doe.com', self.email.body) def test_email_to(self): self.assertEqual(['john@doe.com',], self.email.to)

The password_reset_done view

templates/password_reset_done.html

<!-- templates/password_reset_done.html --> {% extends "base_accounts.html" %} {% block title %}Reset your password{% endblock title %} {% block content %} <div class="row justify-content-center"> <div class="col-lg-4 col-md-6 col-sm-8"> <div class="card"> <div class="card-body"> <h3 class="card-title">Reset your password</h3> <p>Check your email for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder.</p> <a href="{% url 'login' %}" class="btn btn-secondary btn-block">Return to log in</a> </div> </div> </div> </div> {% endblock content %}

Which looks like the following in the browser:

Password reset done view

Password reset done view

accounts/tests/test_password_reset_done_tests.py

# accounts/tests/test_password_reset_done_tests.py from django.contrib.auth import views as auth_views from django.urls import reverse from django.urls import resolve from django.test import TestCase class PasswordResetDoneTests(TestCase): def setUp(self): url = reverse('password_reset_done') self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/password-reset/done/') self.assertEqual(view.func.view_class, auth_views.PasswordResetDoneView)

password_reset_confirm view

templates/password_reset_confirm.html

<!-- templates/password_reset_confirm.html --> {% extends "base_accounts.html" %} {% block title %} {% if validlink %} Change password for {{ form.user.username }} {% else %} Reset your password {% endif %} {% endblock title %} {% block content %} <div class="row justify-content-center"> <div class="col-lg-6 col-md-8 col-sm-10"> <div class="card"> <div class="card-body"> {% if validlink %} <h3 class="card-title">Change password for @{{ form.user.username }}</h3> <form method="post" novalidate> {% csrf_token %} {% include "includes/form.html" %} <button type="submit" class="btn btn-success btn-block">Change password</button> </form> {% else %} <h3 class="card-title">Reset your password</h3> <div class="alert alert-danger" role="alert"> It looks like you clicked on an invalid password reset link. Please try again. </div> <a href="{% url 'password_reset' %}" class="btn btn-secondary btn-block">Request a new password reset link</a> {% endif %} </div> </div> </div> </div> {% endblock content %}

The above template can only be accessed with the link sent in the email. The URL looks something like the following: http://127.0.0.1:8000/password-reset/Mw/4po-2b5f2d47c19966e294a1/

The password-reset view (http://127.0.0.1:8000/password-reset/) looks like the following in the browser:

The password-reset view in the browser

The password-reset view in the browser

During_ development, grab this link from the email in the console.

If the link is valid, the view should look something like the following:

Password reset link is valid

Password reset link is valid

If the link has expired:

Password reset link has expired

Password reset link has expired

accounts/tests/test_password_reset_confirm_tests.py

# accounts/tests/test_password_reset_confirm_tests.py from django.contrib.auth.tokens import default_token_generator from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode from django.contrib.auth import views as auth_views from django.contrib.auth.forms import SetPasswordForm from django.contrib.auth.models import User from django.urls import reverse from django.urls import resolve from django.test import TestCase class PasswordResetConfirmTests(TestCase): def setUp(self): user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef') ''' create a valid password reset token based on how django creates the token internally: https://github.com/django/django/blob/1.11.5/django/contrib/auth/forms.py#L280 ''' self.uid = urlsafe_base64_encode(force_bytes(user.pk)).decode() self.token = default_token_generator.make_token(user) url = reverse('password_reset_confirm', kwargs={'uidb64': self.uid, 'token': self.token}) self.response = self.client.get(url, follow=True) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/password-reset/{uidb64}/{token}/'.format(uidb64=self.uid, token=self.token)) self.assertEqual(view.func.view_class, auth_views.PasswordResetConfirmView) def test_csrf(self): self.assertContains(self.response, 'csrfmiddlewaretoken') def test_contains_form(self): form = self.response.context.get('form') self.assertIsInstance(form, SetPasswordForm) def test_form_inputs(self): ''' The view must contain two inputs: csrf and two password fields ''' self.assertContains(self.response, '<input', 3) self.assertContains(self.response, 'type="password"', 2)

accounts/tests/test_invalid_password_reset_confirm_tests.py

# accounts/tests/test_invalid_password_reset_confirm_tests.py from django.contrib.auth.tokens import default_token_generator from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode from django.contrib.auth import views as auth_views from django.contrib.auth.forms import SetPasswordForm from django.contrib.auth.models import User from django.urls import reverse from django.urls import resolve from django.test import TestCase class InvalidPasswordResetConfirmTests(TestCase): def setUp(self): user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef') uid = urlsafe_base64_encode(force_bytes(user.id)).encode() token = default_token_generator.make_token(user) ''' invalidate the token by changing the password ''' user.set_password('abcdef123') user.save() url = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token}) self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_html(self): password_reset_url = reverse('password_reset') self.assertContains(self.response, 'invalid password reset link') self.assertContains(self.response, 'href="{0}"'.format(password_reset_url))

password_reset_complete view

templates/password_reset_complete.html

<!-- templates/password_reset_complete.html --> {% extends "base_accounts.html" %} {% block title %}Password changed!{% endblock title %} {% block content %} <div class="row justify-content-center"> <div class="col-lg-6 col-md-8 col-sm-10"> <div class="card"> <div class="card-body"> <h3 class="card-title">Password changed!</h3> <div class="alert alert-success" role="alert"> You have successfully changed your password! You may now proceed to log in. </div> <a href="{% url 'login' %}" class="btn btn-secondary btn-block">Return to log in</a> </div> </div> </div> </div> {% endblock content %}

templates/password_reset_complete.html looks like the following in the browser:

Password reset complete view in browser

Password reset complete view in browser

accounts/tests/test_password_reset_complete_tests.py

# accounts/tests/test_password_reset_complete_tests.py from django.contrib.auth import views as auth_views from django.urls import reverse from django.urls import resolve from django.test import TestCase class PasswordResetCompleteTests(TestCase): def setUp(self): url = reverse('password_reset_complete') self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/password-reset/complete/') self.assertEqual(view.func.view_class, auth_views.PasswordResetCompleteView)

password_change view

This view is meant to be used by logged in users that want to change their password. Usually, those forms are composed of three fields: old password, new password, and new password confirmation.

django_boards/urls.py

urlpatterns = [ path("", views.index, name="index"), path("signup/", accounts_views.signup, name="signup"), path( "login/", auth_views.LoginView.as_view(template_name="login.html"), name="login" ), path("logout/", auth_views.LogoutView.as_view(), name="logout"), path( "password-reset/", auth_views.PasswordResetView.as_view( template_name="password_reset.html", email_template_name="password_reset_email.html", subject_template_name="password_reset_subject.txt", ), name="password_reset", ), path( "password-reset/done/", auth_views.PasswordResetDoneView.as_view( template_name="password_reset_done.html" ), name="password_reset_done", ), path( "password-reset/<uidb64>/<token>/", auth_views.PasswordResetConfirmView.as_view( template_name="password_reset_confirm.html" ), name="password_reset_confirm", ), path( "password-reset/complete/", auth_views.PasswordResetCompleteView.as_view( template_name="password_reset_complete.html" ), name="password_reset_complete", ), path( "settings/password/", auth_views.PasswordChangeView.as_view(template_name="password_change.html"), name="password_change", ), path( "settings/password/done/", auth_views.PasswordChangeDoneView.as_view( template_name="password_change_done.html" ), name="password_change_done", ), path("boards/<str:id>/", views.board_topics, name="board_topics"), path("boards/<str:id>/new/", views.new_topic, name="new_topic"), path("admin/", admin.site.urls), ]

The "password change" URLs are meant only for logged in users. They will not work for anyone else. They make use of a view decorator named @login_required. This decorator prevents non-authorized users from accessing the page. If the user is not logged in, Django will redirect them to the login page.

Defining the login URL of our application in settings.py

Now we have to define the login URL of our application in settings.py:

# in django_baords/settings.py below DEFAULT_AUTO_FIELD LOGIN_URL = 'login'

templates/password_change.html

<!-- templates/password_change.html --> {% extends "base.html" %} {% block title %}Change password{% endblock title %} {% block breadcrumb %} <li class="breadcrumb-item active">Change password</li> {% endblock breadcrumb %} {% block content %} <div class="row"> <div class="col-lg-6 col-md-8 col-sm-10"> <form method="post" novalidate> {% csrf_token %} {% include "includes/form.html" %} <button type="submit" class="btn btn-success">Change password</button> </form> </div> </div> {% endblock content %}

The password_change view looks something like the following in the browser:

Password change view in the browser for logged in users

Password change view in the browser for logged in users

templates/password_change_done.html

<!-- templates/password_change_done.html --> {% extends "base.html" %} {% block title %}Change password successful{% endblock title %} {% block breadcrumb %} <li class="breadcrumb-item"><a href="{% url 'password_change' %}">Change password</a></li> <li class="breadcrumb-item active">Success</li> {% endblock breadcrumb %} {% block content %} <div class="alert alert-success" role="alert"> <strong>Success!</strong> Your password has been changed! </div> <a href="{% url 'index' %}" class="btn btn-secondary">Return to home page</a> {% endblock content %}

The password_change_done view looks something like the following in the browser:

password change done view in the browser

password change done view in the browser

accounts/tests/test_login_required_password_change_tests.py

# accounts/tests/test_login_required_password_change_tests.py from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.models import User from django.contrib.auth import views as auth_views from django.urls import reverse from django.urls import resolve from django.test import TestCase class LoginRequiredPasswordChangeTests(TestCase): def test_redirection(self): url = reverse('password_change') login_url = reverse('login') response = self.client.get(url) self.assertRedirects(response, f'{login_url}?next={url}')

The above test tries to access the password_change view without being logged in. The expected behavior is to redirect the user to the login page.

accounts/tests/test_password_change_test_case.py

# accounts/tests/test_password_change_test_case.py from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.models import User from django.contrib.auth import views as auth_views from django.urls import reverse from django.urls import resolve from django.test import TestCase class PasswordChangeTestCase(TestCase): def setUp(self, data={}): self.user = User.objects.create_user(username='john', email='john@doe.com', password='old_password') self.url = reverse('password_change') self.client.login(username='john', password='old_password') self.response = self.client.post(self.url, data)

Here we define a new class named PasswordChangeTestCase. It does a basic setup, creates a user and makes a POST request to the password_change view. In the next set of test cases, we are going to use this class instead of the TestCase class and test a successful request and an invalid request.

accounts/tests/test_successful_password_change_tests.py

# accounts/tests/test_successful_password_change_tests.py from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.models import User from django.contrib.auth import views as auth_views from django.urls import reverse from django.urls import resolve from django.test import TestCase from .test_password_change_test_case import PasswordChangeTestCase class SuccessfulPasswordChangeTests(PasswordChangeTestCase): def setUp(self): super().setUp({ 'old_password': 'old_password', 'new_password1': 'new_password', 'new_password2': 'new_password', }) def test_redirection(self): ''' A valid form submission should redirect the user ''' self.assertRedirects(self.response, reverse('password_change_done')) def test_password_changed(self): ''' refresh the user instance from database to get the new password hash updated by the change password view. ''' self.user.refresh_from_db() self.assertTrue(self.user.check_password('new_password')) def test_user_authentication(self): ''' Create a new request to an arbitrary page. The resulting response should now have an `user` to its context, after a successful sign up. ''' response = self.client.get(reverse('index')) user = response.context.get('user') self.assertTrue(user.is_authenticated)

accounts/tests/test_invalid_password_change_tests.py

# accounts/tests/test_invalid_password_change_tests.py from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.models import User from django.contrib.auth import views as auth_views from django.urls import reverse from django.urls import resolve from django.test import TestCase class InvalidPasswordChangeTests(PasswordChangeTestCase): def test_status_code(self): ''' An invalid form submission should return to the same page ''' self.assertEqual(self.response.status_code, 200) def test_form_errors(self): form = self.response.context.get('form') self.assertTrue(form.errors) def test_didnt_change_password(self): ''' refresh the user instance from the database to make sure we have the latest data. ''' self.user.refresh_from_db() self.assertTrue(self.user.check_password('old_password'))

The .refresh_from_db() method makes sure we have the latest state of the data. It forces Django to query the database again to update the data. We have to do this because the change_password view updates the password in the database. To test if the password really changed, we have to grab the latest data from the database.

Resetting password

First, I go to the url 127.0.0.1:8000/reset-password and type in my (dummy) email address to have an "email" sent to me with a reset link so I can reset my password, followed by clicking on "Send password reset email".

Then I go to the Terminal console, and the following is returned:

[19/Sep/2024 14:29:23] "GET /password-reset/ HTTP/1.1" 200 2261 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit Subject: [Django Boards] Please reset your password From: webmaster@localhost To: interglobalmedia@example.com Date: Thu, 19 Sep 2024 14:29:34 -0000 Message-ID: <172675617454.27621.3807416516098472307@151.1.168.192.in-addr.arpa> Hi there, Someone asked for a password reset for the email address interglobalmedia@example.com. Follow the link below: http://127.0.0.1:8000/password-reset/MQ/cdluxa-58aa430ce75d6ae32cf33a87bd68e757/ In case you forgot your Django Boards username: interglobalmedia If clicking the link above doesn't work, please copy and paste the URL in a new browser window instead. If you've received this mail in error, it's likely that another user entered your email address by mistake while trying to reset a password. If you didn't initiate the request, you don't need to take any further action and can safely disregard this email. Thanks, The Django Boards Team ------------------------------------------------------------------------------- [19/Sep/2024 14:29:34] "POST /password-reset/ HTTP/1.1" 302 0 [19/Sep/2024 14:29:34] "GET /password-reset/done/ HTTP/1.1" 200 1864

Then I place the http://127.0.0.1:8000/password-reset/MQ/cdluxa-58aa430ce75d6ae32cf33a87bd68e757/ URL in the browser, and the following is rendered:

password_reset_done view

password_reset_done view

To complete the (dummy) password_reset process, I go to the http://127.0.0.1:8000/password-reset/complete/ URL, which, as shown previously, looks something like the following:

password_reset-complete view

password_reset-complete view

Then I click on the "Return to login" button, and I am taken to the following:

login view

login view

Running our tests

Next, we want to make sure that our tests pass. To run all our tests using the coverage command, we run the following in Terminal:

coverage run manage.py test

Which returns the following:

Found 57 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). .............FFF......................................... ====================================================================== FAIL: test_contains_form (accounts.tests.test_password_reset_confirm_tests.PasswordResetConfirmTests.test_contains_form) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/mariacam/Python-Development/django-boards/django_boards/accounts/tests/test_password_reset_confirm_tests.py", line 38, in test_contains_form self.assertIsInstance(form, SetPasswordForm) AssertionError: None is not an instance of <class 'django.contrib.auth.forms.SetPasswordForm'> ====================================================================== FAIL: test_csrf (accounts.tests.test_password_reset_confirm_tests.PasswordResetConfirmTests.test_csrf) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/mariacam/Python-Development/django-boards/django_boards/accounts/tests/test_password_reset_confirm_tests.py", line 34, in test_csrf self.assertContains(self.response, 'csrfmiddlewaretoken') File "/Users/mariacam/.pyenv/versions/3.12.5/lib/python3.12/site-packages/django/test/testcases.py", line 623, in assertContains self.assertTrue( AssertionError: False is not true : Couldn't find 'csrfmiddlewaretoken' in the following response b'\n<!DOCTYPE html>\n<html lang="en">\n <head>\n <meta charset="utf-8">\n <meta name="description" content="A forum dedicated to all things Django" />\n <meta name="keywords" content="django, python3" />\n <title>\n \n \n Reset your password\n \n\n </title>\n <link rel="stylesheet" href="/static/css/bootstrap.min.css">\n <link rel="stylesheet" href="/static/css/app.css">\n \n <link rel="stylesheet" href="/static/css/accounts.css">\n\n </head>\n <body>\n \n <div class="container">\n <h1 class="text-center logo my-4">\n <a href="/">Django Boards</a>\n </h1>\n \n <div class="row justify-content-center">\n <div class="col-lg-6 col-md-8 col-sm-10">\n <div class="card">\n <div class="card-body">\n \n <h3 class="card-title">Reset your password</h3>\n <div class="alert alert-danger" role="alert">\n It looks like you clicked on an invalid password reset link. Please try again.\n </div>\n <a href="/password-reset/" class="btn btn-secondary btn-block">Request a new password reset link</a>\n \n </div>\n </div>\n </div>\n </div>\n\n </div>\n\n <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.9.2/umd/popper.min.js"\n integrity="sha512-2rNj2KJ+D8s1ceNasTIex6z4HWyOnEYLVC3FigGOmyQCZc2eBXKgOxQmo3oKLHyfcj53uz4QMsRCWNbLd32Q1g=="\n crossorigin="anonymous"\n referrerpolicy="no-referrer"></script>\n <script src="https://code.jquery.com/jquery-3.7.1.min.js"\n integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="\n crossorigin="anonymous"></script>\n <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js"\n integrity="sha512-ykZ1QQr0Jy/4ZkvKuqWn4iF3lqPZyij9iRv6sGqLRdTPkY69YX6+7wvVGmsdBbiIfN/8OdsI7HABjvEok6ZopQ=="\n crossorigin="anonymous"\n referrerpolicy="no-referrer"></script>\n </body>\n</html>\n' ====================================================================== FAIL: test_form_inputs (accounts.tests.test_password_reset_confirm_tests.PasswordResetConfirmTests.test_form_inputs) The view must contain two inputs: csrf and two password fields ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/mariacam/Python-Development/django-boards/django_boards/accounts/tests/test_password_reset_confirm_tests.py", line 44, in test_form_inputs self.assertContains(self.response, '<input', 3) File "/Users/mariacam/.pyenv/versions/3.12.5/lib/python3.12/site-packages/django/test/testcases.py", line 614, in assertContains self.assertEqual( AssertionError: 0 != 3 : Found 0 instances of '<input' (expected 3) in the following response b'\n<!DOCTYPE html>\n<html lang="en">\n <head>\n <meta charset="utf-8">\n <meta name="description" content="A forum dedicated to all things Django" />\n <meta name="keywords" content="django, python3" />\n <title>\n \n \n Reset your password\n \n\n </title>\n <link rel="stylesheet" href="/static/css/bootstrap.min.css">\n <link rel="stylesheet" href="/static/css/app.css">\n \n <link rel="stylesheet" href="/static/css/accounts.css">\n\n </head>\n <body>\n \n <div class="container">\n <h1 class="text-center logo my-4">\n <a href="/">Django Boards</a>\n </h1>\n \n <div class="row justify-content-center">\n <div class="col-lg-6 col-md-8 col-sm-10">\n <div class="card">\n <div class="card-body">\n \n <h3 class="card-title">Reset your password</h3>\n <div class="alert alert-danger" role="alert">\n It looks like you clicked on an invalid password reset link. Please try again.\n </div>\n <a href="/password-reset/" class="btn btn-secondary btn-block">Request a new password reset link</a>\n \n </div>\n </div>\n </div>\n </div>\n\n </div>\n\n <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.9.2/umd/popper.min.js"\n integrity="sha512-2rNj2KJ+D8s1ceNasTIex6z4HWyOnEYLVC3FigGOmyQCZc2eBXKgOxQmo3oKLHyfcj53uz4QMsRCWNbLd32Q1g=="\n crossorigin="anonymous"\n referrerpolicy="no-referrer"></script>\n <script src="https://code.jquery.com/jquery-3.7.1.min.js"\n integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="\n crossorigin="anonymous"></script>\n <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js"\n integrity="sha512-ykZ1QQr0Jy/4ZkvKuqWn4iF3lqPZyij9iRv6sGqLRdTPkY69YX6+7wvVGmsdBbiIfN/8OdsI7HABjvEok6ZopQ=="\n crossorigin="anonymous"\n referrerpolicy="no-referrer"></script>\n </body>\n</html>\n' ---------------------------------------------------------------------- Ran 57 tests in 8.959s FAILED (failures=3) Destroying test database for alias 'default'...

The tests that have failed are inside the test_password_reset_confirm_tests.py file. Specifically, the test_csrf(self) function, and the test_form_inputs(self) function. We encountered these same errors when we were running the tests inside the SignUpTests class (part 15).

You can opt to go with the original test_form_inputs(self) function and keep .assertContains(). Then you just have to find out which three inputs the test is looking for within the output returned from running the coverage run manage.py test command, or searching for them in the browser via inspect element in the password_reset_confirm view (password_reset_confirm.html). OR, we can find the classes we want to target located inside password_reset_confirm.html by using Beautiful Soup 4 and Soup Sieve to locate and grab them. I am going with Beautiful Soup 4 and Soup Sieve again.

Creating our Test Client using Beautiful Soup 4 and Soup Sieve

inside the test_password_reset_confirm_tests.py file, we have to import bs4 and soupsieve as sv at the top of the file along with the rest of the imports:

# test_password_reset_confirm_tests.py import bs4 import soupsieve as sv

Next, we have to recreate the test_form_inputs function. I will comment out the original one for reference, and recreate it below the commented out one.

However, before I re-create the test, I have to add a class to the form inside password_reset_confirm.html so I can target it and locate the inputs within the form needed to pass the test. I added a class called "password-reset-confirm". Now the form looks like the following:

<!-- password_reset_confirm.html --> <form method="post" novalidate class="password-reset-confirm"> {% csrf_token %} {% include "includes/form.html" %} <button type="submit" class="btn btn-success btn-block">Change password</button> </form>

Then I have to go through the password reset process in order to grab the URL containing the uuid64 and csrf token and input fields needed for the test.

The link containing the uuid64 and the csrf token:

http://127.0.0.1:8000/password-reset/MQ/cdmf2o-4a2a585ea7ef5129dbe30487b9f8cdb0/

The URL takes me to the following:

Change password for @interglobalmedia

Change password for @interglobalmedia

I select inspect element in the browser to find out what the ids are of the two inputs displayed in the Change password form, and I find the following:

id_new_password1 id_new_password2

Then I click on the "Change password" button, and I am taken to the following:

Password changed

Password changed

# test_password_reset_confirm_tests.py def test_form_inputs(self): self.response = self.client.get(reverse('password_reset')) text = """ <form class="password-reset-confirm"> </form> """ soup = bs4.BeautifulSoup(text, 'html5lib') sv.select('input:is(#id_new_password1, #id_new_password2, .form-control)', soup)

Then I replace 'csrfmiddlewaretoken' in the test_csrf function with the csrf token in the password-reset URL I got back from my initial failed tests in the Terminal console when I ran coverage run manage.py test:

sha512-2rNj2KJ+D8s1ceNasTIex6z4HWyOnEYLVC3FigGOmyQCZc2eBXKgOxQmo3oKLHyfcj53uz4QMsRCWNbLd32Q1g

However, after these changes, I still have one test that is failing:

Found 57 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). .............F........................................... ====================================================================== FAIL: test_contains_form (accounts.tests.test_password_reset_confirm_tests.PasswordResetConfirmTests.test_contains_form) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/mariacam/Python-Development/django-boards/django_boards/accounts/tests/test_password_reset_confirm_tests.py", line 40, in test_contains_form self.assertIsInstance(form, SetPasswordForm) AssertionError: None is not an instance of <class 'django.contrib.auth.forms.SetPasswordForm'> ---------------------------------------------------------------------- Ran 57 tests in 8.956s FAILED (failures=1) Destroying test database for alias 'default'...

I changed the line self.assertIsInstance(form, PasswordResetForm) with self.assertIn('form', self.response.context). The whole test function now looks like the following:

# test_password_reset_confirm_tests.py def test_contains_form(self): form = self.response.context.get('form') # replaced self.assertIsInstance(form, PasswordResetForm) line with below because original line returned None. But below, response has a context attribute that contains the context used to render the template. self.assertIn('form', self.response.context)

I replaced self.assertIsInstance(form, PasswordResetForm) line with below because original line returned None. But with self.assertIn('form', self.response.context), response has a context attribute that contains the context used to render the template.

Current file/directory structure of Django Boards

- django-boards/ # root directory of the application - django_boards/ - accounts/ - __pycache__/ - migrations/ - tests/ - __pyccache__/ - __init__.py - test_invalid_password_change_tests.py - test_invalid_password_reset_confirm_tests.py - test_invalid_password_reset_tests.py - test_invalid_signup_tests.py - test_login_required_password_change_tests.py - test_password_change_test_case.py - test_password_reset_complete_tests.py - test_password_reset_confirm_tests.py - test_password_reset_done_tests.py - test_password_reset_mail_tests.py - test_password_reset_tests.py - test_sign_up_form_test.py # formerly test_view_signup.py which we commented out - test_signup_tests.py - test_successful_password_change_tests.py - test_successful_password_reset_tests.py - test_successful_signup_tests.py - __init__.py - admin.py - apps.py - forms.py - models.py - views.py - boards/ - __pycache__/ - migrations/ - templatetags/ - __pycache__/ - form_tags.py - tests/ - __pycache__/ - __init__.py - test_board_topics_tests.py - test_index_tests.py - test_new_topic_tests.py - test_templatetags.py - __init__.py - admin.py - apps.py - forms.py - models.py - views.py - django_boards/ - __pycache__/ - __init__.py - asgi.py - settings.py - urls.py - wsgi.py - htmlcov/ - static/ - css/ - accounts.css - app.css - bootstrap.min.css - templates/ - includes/ - form.html - base_accounts.html - base.html - index.html - login.html - new_topic.html - password_change_done.html - password_change.html - password_reset_complete.html - password_reset_confirm.html - password_reset_done.html - password_reset_email.html - password_reset_subject.txt - password_reset.html - signup.html - topics.html - .coverage - db.sqlite3 - manage.py - .git - venv/ - .env - .gitignore

Conclusion

In this section, I created the password_reset routes, views, tests, and templates. The results of the password reset process was output to the Terminal console. Just as in part 15, I had to debug the password_reset_confirm_tests tests to find out what the actual csrf token was and hard code it into the test_csrf (function) test. I re-used the Beautiful Soup 4 and Soup Sieve packages to refactor the test_form_inputs (function) test.