From e0972c8191c88772ce78895cef197d8189a29b2c Mon Sep 17 00:00:00 2001 From: aanish97 Date: Fri, 1 Mar 2024 19:36:25 +0500 Subject: [PATCH 1/5] user register tests added --- DjangoBoilerplate/settings.py | 2 +- templates/create_user.html | 26 +++++++++++++------------- users/tests.py | 35 ++++++++++++++++++++++++++++++++++- users/views.py | 10 ++++++---- 4 files changed, 54 insertions(+), 19 deletions(-) diff --git a/DjangoBoilerplate/settings.py b/DjangoBoilerplate/settings.py index 25c9cd2..48c6b6a 100644 --- a/DjangoBoilerplate/settings.py +++ b/DjangoBoilerplate/settings.py @@ -48,9 +48,9 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', diff --git a/templates/create_user.html b/templates/create_user.html index a618ff2..a761593 100644 --- a/templates/create_user.html +++ b/templates/create_user.html @@ -43,22 +43,22 @@

Create User

-{% if error_message %} - -{% endif %} -{% if messages %} - -{% endif %} + {% if error_message %} + + {% elif success_message %} + + {% endif %} + +
{% csrf_token %} - + diff --git a/users/tests.py b/users/tests.py index 7ce503c..1807b19 100644 --- a/users/tests.py +++ b/users/tests.py @@ -1,3 +1,36 @@ from django.test import TestCase +from django.urls import reverse +from users.models import User -# Create your tests here. + +class BaseTest(TestCase): + def setUp(self): + self.register_url = reverse('create_user') + self.user = { + 'username': 'test_user', + 'email': 'test@test.com', + 'password': 'test_password' + } + self.invalid_user_data = { + 'username': 'azeem1', + 'email': 'test@test.com', + 'password': 'test_password' + } + return super().setUp() + + +class AuthTest(BaseTest): + def test_can_view_register_page_correctly(self): + response = self.client.get(self.register_url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'create_user.html') + + def test_can_register_user_correctly(self): + response = self.client.post(self.register_url, data=self.user) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'User created Successfully.') + + def test_cant_register_user_correctly(self): + response = self.client.post(self.register_url, data=self.invalid_user_data) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'error_message') diff --git a/users/views.py b/users/views.py index 50538a3..4d3bc26 100644 --- a/users/views.py +++ b/users/views.py @@ -1,12 +1,13 @@ import random import string from datetime import datetime, timedelta +from django.urls import reverse from django.contrib.auth.hashers import check_password from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic.detail import DetailView from django.contrib.auth.models import User from django.contrib.auth.views import LogoutView -from django.shortcuts import render +from django.shortcuts import render, redirect from django.contrib import messages from django.views import View from django.conf import settings @@ -40,10 +41,11 @@ def post(self, request): if historical_password_serializer.is_valid(): historical_password_serializer.save() - messages.success(request, 'User created successfully. Please login.') + return render(request, self.template_name, {'success_message': 'User created Successfully.'}) - error_message = serializer.errors - return render(request, self.template_name, {'error_message': error_message}) + for field, errors_list in serializer.errors.items(): + error_message = errors_list[0] + return render(request, self.template_name, {'error_message': error_message}) class CustomLogoutView(LogoutView): From 394da8306523915cbf0a44beb8d172874586c521 Mon Sep 17 00:00:00 2001 From: aanish97 Date: Tue, 5 Mar 2024 11:51:22 +0500 Subject: [PATCH 2/5] adding login testcases --- pytest.ini | 3 + requirements.txt | 1 + templates/create_user.html | 2 +- templates/{auth => registration}/login.html | 12 ++- users/serializers.py | 1 + users/tests.py | 83 ++++++++++++++------- users/urls.py | 4 +- users/views.py | 49 +++++++++--- 8 files changed, 112 insertions(+), 43 deletions(-) create mode 100644 pytest.ini rename templates/{auth => registration}/login.html (90%) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c02b905 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = DjangoBoilerplate.settings +python_files = tests.py test_*.py *_tests.py \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 82f1607..38865c9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ python-dotenv==1.0.0 pytz==2023.3 sqlparse==0.4.4 typing_extensions==4.7.1 +pytest==8.1.0 diff --git a/templates/create_user.html b/templates/create_user.html index a761593..a8726b0 100644 --- a/templates/create_user.html +++ b/templates/create_user.html @@ -58,7 +58,7 @@

Create User

{% csrf_token %} - + diff --git a/templates/auth/login.html b/templates/registration/login.html similarity index 90% rename from templates/auth/login.html rename to templates/registration/login.html index 5440f1d..831f0ac 100644 --- a/templates/auth/login.html +++ b/templates/registration/login.html @@ -38,10 +38,19 @@ button[type="button"]:hover { background-color: #45a049; } + .error-message { + color: red; + }

Login

+ {% if error_message %} + + {% endif %} {% csrf_token %} @@ -53,9 +62,6 @@

Login

- - - diff --git a/users/serializers.py b/users/serializers.py index 50dac0c..30657fb 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -20,6 +20,7 @@ class Meta: extra_kwargs = { 'password': {'write_only': True}, 'email': {'required': True}, + 'username': {'required': True} } def create(self, validated_data): diff --git a/users/tests.py b/users/tests.py index 1807b19..99701f7 100644 --- a/users/tests.py +++ b/users/tests.py @@ -1,36 +1,63 @@ -from django.test import TestCase +import pytest from django.urls import reverse + +from DjangoBoilerplate import settings from users.models import User -class BaseTest(TestCase): - def setUp(self): - self.register_url = reverse('create_user') - self.user = { - 'username': 'test_user', - 'email': 'test@test.com', - 'password': 'test_password' - } - self.invalid_user_data = { - 'username': 'azeem1', - 'email': 'test@test.com', - 'password': 'test_password' - } - return super().setUp() +# @pytest.mark.django_db +# @pytest.mark.parametrize( +# "user_data, expected_status_code, expected_message", +# [ +# ( +# {'username': 'test_user', 'email': 'test@test.com', 'password': 'Testapp1.'}, +# 200, +# 'User created Successfully.' +# ), +# ( +# {'username': '', 'email': 'test@test.com', 'password': 'Testapp1.'}, +# 400, +# 'This field may not be blank.' +# ), +# ( +# {'username': 'duplicate_user', 'email': 'test@test.com', 'password': 'Testapp1.'}, +# 400, +# 'A user with that username already exists.' +# ), +# ( +# {'username': 'test_user', 'email': 'test@test.com', 'password': 'test_app'}, +# 400, +# 'Password must start with an alphabet, contain at least one number, one special character,' +# ' and have a minimum length of 6 characters.' +# ), +# ( +# {'username': 'test_user', 'email': 'testtest.com', 'password': 'Testapp1.'}, +# 400, +# 'Enter a valid email address.' +# ), +# ] +# ) +# def test_user_registration(client, user_data, expected_status_code, expected_message): +# register_url = reverse('create_user') +# if 'username' in user_data and user_data['username'] == 'duplicate_user': +# User.objects.create_user(**user_data) +# +# response = client.post(register_url, data=user_data) +# assert response.status_code == expected_status_code +# assert expected_message in response.content.decode('utf-8') -class AuthTest(BaseTest): - def test_can_view_register_page_correctly(self): - response = self.client.get(self.register_url) - self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'create_user.html') +@pytest.mark.parametrize('username, password, expected_status', [ + ('test_user', 'test_password', 302), - def test_can_register_user_correctly(self): - response = self.client.post(self.register_url, data=self.user) - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'User created Successfully.') +]) +@pytest.mark.django_db +def test_login_view_post_with_user(client, username, password, expected_status): + User.objects.create_user(username='test_user', password='Testpass1.') + url = reverse('login') + data = {'username': username, 'password': password} + response = client.post(url, data) + assert response.status_code == expected_status + if expected_status == 302: + assert response.url == settings.LOGIN_REDIRECT_URL - def test_cant_register_user_correctly(self): - response = self.client.post(self.register_url, data=self.invalid_user_data) - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'error_message') diff --git a/users/urls.py b/users/urls.py index 0c8c175..8f4cb31 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,10 +1,10 @@ from django.urls.conf import path, re_path from django.contrib.auth.views import LoginView -from .views import ProfileView, CustomLogoutView, CreateUserView, ResetPasswordView, ForgotPasswordView +from .views import ProfileView, CustomLogoutView, CreateUserView, ResetPasswordView, ForgotPasswordView,CustomLoginView urlpatterns = [ path('create/', CreateUserView.as_view(), name='create_user'), - path('login/', LoginView.as_view(template_name='auth/login.html'), name='login'), + path('login/', CustomLoginView.as_view(template_name='registration/login.html'), name='login'), path('profile/', ProfileView.as_view(), name='profile'), path('logout/', CustomLogoutView.as_view(), name='logout'), path('forgot-password/', ForgotPasswordView.as_view(), name='reset_password'), diff --git a/users/views.py b/users/views.py index 4d3bc26..6873e4b 100644 --- a/users/views.py +++ b/users/views.py @@ -1,23 +1,23 @@ import random import string from datetime import datetime, timedelta -from django.urls import reverse +import re from django.contrib.auth.hashers import check_password from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic.detail import DetailView from django.contrib.auth.models import User -from django.contrib.auth.views import LogoutView -from django.shortcuts import render, redirect -from django.contrib import messages +from django.contrib.auth.views import LogoutView, LoginView +from django.shortcuts import render from django.views import View from django.conf import settings from django.core.mail import send_mail - +from django.contrib.auth.forms import AuthenticationForm from users.serializers import UserSerializer, HistoricalPasswordSerializer from .models import User, HistoricalPassword +from django.http import HttpResponseRedirect __all__ = [ - 'CustomLogoutView', 'CreateUserView', 'ProfileView', 'ResetPasswordView', 'ForgotPasswordView' + 'CustomLogoutView', 'CreateUserView', 'ProfileView', 'ResetPasswordView', 'ForgotPasswordView', 'CustomLoginView' ] @@ -30,6 +30,14 @@ def get(self, request): return render(request, self.template_name) def post(self, request): + password = request.POST.get('password') + + password_regex = re.compile(r'^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&.])[A-Za-z\d@$!%*#?&.]{6,}$') + + if not password_regex.match(password): + error_message = ('Password must start with an alphabet, contain at least one number, one special character,' + ' and have a minimum length of 6 characters.') + return render(request, self.template_name, {'error_message': error_message}, status=400) serializer = UserSerializer(data=request.POST) if serializer.is_valid(): user = serializer.save() @@ -45,7 +53,25 @@ def post(self, request): for field, errors_list in serializer.errors.items(): error_message = errors_list[0] - return render(request, self.template_name, {'error_message': error_message}) + return render(request, self.template_name, {'error_message': error_message}, status=400) + + +class CustomLoginView(LoginView): + form_class = AuthenticationForm + template_name = 'registration/login.html' + + def form_valid(self, form): + username = form.cleaned_data.get('username') + password = form.cleaned_data.get('password') + + if len(username) < 5: + return render(self.request, self.template_name, + {'error_message': 'Username must be at least 5 characters long'}) + + if not re.match(r'^(?=.*[A-Z])(?=.*[@$!%*#?&.])[A-Za-z\d@$!%*#?&.]{6,}$', password): + return render(self.request, self.template_name, {'error_message': 'Password invalid format'}) + + return super().form_valid(form) class CustomLogoutView(LogoutView): @@ -91,10 +117,15 @@ def post(self, request): message = f"Please click the following link to reset your password: {settings.FRONTEND_URL}/v1/auth/password-reset/{token}" from_email = settings.DEFAULT_FROM_EMAIL recipient_list = [email] - send_mail(subject, message, from_email, recipient_list) + try: + send_mail(subject, message, from_email, recipient_list) + except Exception as e: + error_message = 'Failed to send email' + + return render(request, self.template_name, {'success_message': None, 'error_message': error_message}) return render(request, self.template_name, - {'success_message': 'Password reset instructions sent', 'error_message': None}) + {'success_message': 'Password reset instructions sent'}) class ResetPasswordView(View): From 6d390d6bb8f4da23b23da624c816b6a8063b1544 Mon Sep 17 00:00:00 2001 From: aanish97 Date: Wed, 6 Mar 2024 21:49:57 +0500 Subject: [PATCH 3/5] fixed auth forms --- templates/create_user.html | 13 +++++++-- templates/registration/login.html | 25 ++++++++++++++-- users/tests.py | 17 +++++++++-- users/views.py | 47 +++++++++++++++++++++---------- 4 files changed, 79 insertions(+), 23 deletions(-) diff --git a/templates/create_user.html b/templates/create_user.html index a8726b0..50c22d0 100644 --- a/templates/create_user.html +++ b/templates/create_user.html @@ -58,15 +58,22 @@

Create User

{% csrf_token %} - + + + - + + - + + + {% if error_message %} +

{{ error_message }}

+ {% endif %}
diff --git a/templates/registration/login.html b/templates/registration/login.html index 831f0ac..00c6e8a 100644 --- a/templates/registration/login.html +++ b/templates/registration/login.html @@ -40,11 +40,15 @@ } .error-message { color: red; + font-size: 8px; }

Login

+ + + {% if error_message %} - {% elif success_message %} - - {% endif %}
{% csrf_token %} - - - - - - - - + {% for field in form %} +
+ {{ field.label_tag }} + {{ field }} + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + {% if field.help_text and not field.errors %} + {{ field.help_text }} + {% endif %} +
+ {% endfor %} - {% if error_message %} -

{{ error_message }}

- {% endif %}
diff --git a/users/tests.py b/users/tests.py index c9568f7..b3949ba 100644 --- a/users/tests.py +++ b/users/tests.py @@ -4,46 +4,40 @@ from users.models import User -# @pytest.mark.django_db -# @pytest.mark.parametrize( -# "user_data, expected_status_code, expected_message", -# [ -# ( -# {'username': 'test_user', 'email': 'test@test.com', 'password': 'Testapp1.'}, -# 200, -# 'User created Successfully.' -# ), -# ( -# {'username': '', 'email': 'test@test.com', 'password': 'Testapp1.'}, -# 400, -# 'This field may not be blank.' -# ), -# ( -# {'username': 'duplicate_user', 'email': 'test@test.com', 'password': 'Testapp1.'}, -# 400, -# 'A user with that username already exists.' -# ), -# ( -# {'username': 'test_user', 'email': 'test@test.com', 'password': 'test_app'}, -# 400, -# 'Password must start with an alphabet, contain at least one number, one special character,' -# ' and have a minimum length of 6 characters.' -# ), -# ( -# {'username': 'test_user', 'email': 'testtest.com', 'password': 'Testapp1.'}, -# 400, -# 'Enter a valid email address.' -# ), -# ] -# ) -# def test_user_registration(client, user_data, expected_status_code, expected_message): -# register_url = reverse('create_user') -# if 'username' in user_data and user_data['username'] == 'duplicate_user': -# User.objects.create_user(**user_data) -# -# response = client.post(register_url, data=user_data) -# assert response.status_code == expected_status_code -# assert expected_message in response.content.decode('utf-8') +@pytest.mark.django_db +@pytest.mark.parametrize( + "username, email, password1, password2, expected_status", + [ + ('test_user', 'test@test.com', 'Testappp1.', 'Testappp1.', 302), + ('abc', 'test@test.com', 'Testappp1.', 'Testappp1.', 400), + ('duplicate_user', 'test@test.com', 'Testappp1.', 'Testappp1.', 400), + ('test_user', 'duplicate@test.com', 'Testappp1.', 'Testappp1.', 400), + + ] +) +def test_user_registration(client, username, email, password1, password2, expected_status): + register_url = reverse('create_user') + data = {'username': username, 'email': email, 'password1': password1, 'password2': password2} + + if username == 'duplicate_user': + data = {'username': username, 'email': email, 'password': password1} + User.objects.create_user(**data) + if email == 'duplicate@test.com': + data = {'username': username, 'email': email, 'password': password1} + User.objects.create_user(**data) + + response = client.post(register_url, data) + + if expected_status == 302: + assert response.url == settings.REGISTRATION_REDIRECT_URL + if expected_status == 400: + if username == 'abc': + assert ("Username must be longer than 3 characters." + in response.content.decode('utf-8')) + if username == 'duplicate_user': + assert ("A user with that username already exists") in response.content.decode('utf-8') + if username == 'duplicate@test.com': + assert "User with this Email already exists." in response.content.decode('utf-8') @pytest.mark.parametrize('username, password, expected_status', [ @@ -67,8 +61,7 @@ def test_login_view_post_with_user(client, username, password, expected_status): assert ("Please enter a correct username and password. Note that both fields may be case-sensitive." in response.content.decode('utf-8')) if username == 'test_user' and password == 'Testpass': - assert ("Password must start with an alphabet, contain at least one number, one special character,and have" - " a minimum length of 6 characters.") in response.content.decode('utf-8') + assert ("Password must start with an alphabet, contain at least one number, one special character," + " and have a minimum length of 6 characters.") in response.content.decode('utf-8') if username == 'tes': - assert "Message for test case 4" in response.content.decode('utf-8') - assert "Username must be at least 5 characters long" in response.content.decode('utf-8') + assert "Username must be at least 5 characters long" in response.content.decode('utf-8') diff --git a/users/views.py b/users/views.py index 3c63436..4ae8740 100644 --- a/users/views.py +++ b/users/views.py @@ -8,7 +8,7 @@ from django.views.generic.detail import DetailView from django.contrib.auth.models import User from django.contrib.auth.views import LogoutView, LoginView -from django.shortcuts import render +from django.shortcuts import render, redirect from django.views import View from django.conf import settings from django.core.mail import send_mail @@ -16,6 +16,8 @@ from django.http import HttpResponseBadRequest from users.serializers import UserSerializer, HistoricalPasswordSerializer from .models import User, HistoricalPassword +from django import forms +from django.contrib.auth.forms import UserCreationForm __all__ = [ 'CustomLogoutView', 'CreateUserView', 'ProfileView', 'ResetPasswordView', 'ForgotPasswordView', 'CustomLoginView' @@ -23,29 +25,66 @@ ] +class CustomUserCreationForm(UserCreationForm): + email = forms.EmailField(required=True) + + username = forms.CharField( + help_text=False, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Username'}), + + ) + password1 = forms.CharField( + label="Password", + strip=False, + widget=forms.PasswordInput(attrs={'class': 'form-control', 'placeholder': 'Password'}), + help_text=False + + ) + password2 = forms.CharField( + label="Confirm Password", + widget=forms.PasswordInput(attrs={'class': 'form-control', 'placeholder': 'Confirm Password'}), + strip=False, + ) + + class Meta(UserCreationForm.Meta): + model = User + fields = ('username', 'email', 'password1', 'password2') + + widgets = { + 'email': forms.EmailInput(attrs={'class': 'form-control'}), + + } + + def clean_username(self): + username = self.cleaned_data.get('username') + if len(username) <= 3: + raise forms.ValidationError("Username must be longer than 3 characters.") + return username + + # def clean_password2(self): + # password2 = self.cleaned_data.get("password2") + # password_regex = re.compile(r'^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&.])[A-Za-z\d@$!%*#?&.]{6,}$') + # if not password_regex.match(password2): + # raise forms.ValidationError( + # "Password must start with an alphabet, contain at least one number, one special character, " + # "and have a minimum length of 6 characters." + # ) + # return password2 + + class CreateUserView(View): template_name = 'create_user.html' def get(self, request): + form = CustomUserCreationForm() - return render(request, self.template_name) + return render(request, self.template_name, {'form': form}) def post(self, request): - username = request.POST.get('username') - email = request.POST.get('email') - password = request.POST.get('password') - - password_regex = re.compile(r'^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&.])[A-Za-z\d@$!%*#?&.]{6,}$') - - if not password_regex.match(password): - error_message = ('Password must start with an alphabet, contain at least one number, one special character,' - ' and have a minimum length of 6 characters.') - return render(request, self.template_name, - {'error_message': error_message, 'username': username, 'email': email, 'password': password}, - status=400) - serializer = UserSerializer(data=request.POST) - if serializer.is_valid(): - user = serializer.save() + form = CustomUserCreationForm(request.POST) + if form.is_valid(): + user = form.save() + print("User ID:", user) historical_password_data = { 'user': user.id, 'password': user.password, @@ -53,14 +92,8 @@ def post(self, request): historical_password_serializer = HistoricalPasswordSerializer(data=historical_password_data) if historical_password_serializer.is_valid(): historical_password_serializer.save() - - return render(request, self.template_name, {'success_message': 'User created Successfully.'}) - - for field, errors_list in serializer.errors.items(): - error_message = errors_list[0] - return render(request, self.template_name, - {'error_message': error_message, 'username': username, 'email': email, 'password': password}, - status=400) + return redirect('/v1/auth/login') + return HttpResponseBadRequest(render(request, self.template_name, {'form': form})) class CustomAuthenticationForm(AuthenticationForm): From 3e39aeb681b947fbc2f0c3a57ec2990729728f55 Mon Sep 17 00:00:00 2001 From: aanish97 Date: Tue, 12 Mar 2024 14:46:31 +0500 Subject: [PATCH 5/5] test cases done and few ui fixes --- templates/registration/login.html | 15 +- .../registration/password_reset_complete.html | 41 ++++ .../registration/password_reset_confirm.html | 100 +++++++++ .../registration/password_reset_done.html | 42 ++++ .../registration/password_reset_form.html | 86 ++++++++ templates/reset_password.html | 61 ------ users/forms.py | 98 +++++++++ users/tests.py | 45 +++- users/urls.py | 14 +- users/views.py | 202 +++--------------- 10 files changed, 452 insertions(+), 252 deletions(-) create mode 100644 templates/registration/password_reset_complete.html create mode 100644 templates/registration/password_reset_confirm.html create mode 100644 templates/registration/password_reset_done.html create mode 100644 templates/registration/password_reset_form.html delete mode 100644 templates/reset_password.html create mode 100644 users/forms.py diff --git a/templates/registration/login.html b/templates/registration/login.html index 00c6e8a..d8b2403 100644 --- a/templates/registration/login.html +++ b/templates/registration/login.html @@ -73,15 +73,20 @@

Login

{{ error|striptags }}

{% endfor %} {% endif %} - - - - - {% if form.errors %} + {% if form.errors %} {% for error in form.non_field_errors %}

{{ error }}

{% endfor %} {% endif %} + + + + +{% if messages %} + {% for message in messages %} + {{ message }} + {% endfor %} +{% endif %} diff --git a/templates/registration/password_reset_complete.html b/templates/registration/password_reset_complete.html new file mode 100644 index 0000000..2d9f01d --- /dev/null +++ b/templates/registration/password_reset_complete.html @@ -0,0 +1,41 @@ + + + + + Password Reset Email Sent + + + +
+

Password Reset Completed

+

Please login with new password.

+
+ + diff --git a/templates/registration/password_reset_confirm.html b/templates/registration/password_reset_confirm.html new file mode 100644 index 0000000..fbb1ab3 --- /dev/null +++ b/templates/registration/password_reset_confirm.html @@ -0,0 +1,100 @@ + + + + + Password Reset Confirm + + + +
+

Password Reset

+
+ {% csrf_token %} + {% if form.new_password1.errors %} +
    + {% for error in form.new_password1.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + + {{ form.new_password1 }} +
+ + {{ form.new_password2 }} +
+ {% if form.new_password2.errors %} +
    + {% for error in form.new_password2.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + + +
+
+ + diff --git a/templates/registration/password_reset_done.html b/templates/registration/password_reset_done.html new file mode 100644 index 0000000..1c328f6 --- /dev/null +++ b/templates/registration/password_reset_done.html @@ -0,0 +1,42 @@ + + + + + Password Reset Email Sent + + + +
+

Password Reset Link Sent!

+

A password reset link has been sent to your email address. Please check your inbox.

+

If you didn't receive the email, please check your spam folder.

+
+ + diff --git a/templates/registration/password_reset_form.html b/templates/registration/password_reset_form.html new file mode 100644 index 0000000..6205354 --- /dev/null +++ b/templates/registration/password_reset_form.html @@ -0,0 +1,86 @@ + + + + + + + + Password Reset + + + +
+

Password Reset

+
+ {% csrf_token %} + + + {% if form.errors %} + {% for error in form.errors.email %} +

{{ error|striptags }}

+ {% endfor %} + {% endif %} + +
+
+ + diff --git a/templates/reset_password.html b/templates/reset_password.html deleted file mode 100644 index 00aa435..0000000 --- a/templates/reset_password.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - Reset Password - - - -

Reset Password

- {% if error_message %} - - {% elif success_message %} - - {% endif %} -

Please enter your email address below to receive instructions for resetting your password:

-
- {% csrf_token %} - - - -
- - diff --git a/users/forms.py b/users/forms.py new file mode 100644 index 0000000..4daced5 --- /dev/null +++ b/users/forms.py @@ -0,0 +1,98 @@ +import re +from django import forms +from django.contrib.auth.hashers import check_password +from django.contrib.auth.forms import UserCreationForm, AuthenticationForm +from django.contrib.auth.forms import SetPasswordForm +from django.core.exceptions import ValidationError +from .models import User, HistoricalPassword + + +class CustomUserCreationForm(UserCreationForm): + email = forms.EmailField(required=True) + username = forms.CharField( + help_text=False, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Username'}), + + ) + password1 = forms.CharField( + label="Password", + strip=False, + widget=forms.PasswordInput(attrs={'class': 'form-control', 'placeholder': 'Password'}), + help_text=False + + ) + password2 = forms.CharField( + label="Confirm Password", + widget=forms.PasswordInput(attrs={'class': 'form-control', 'placeholder': 'Confirm Password'}), + strip=False, + ) + + class Meta(UserCreationForm.Meta): + model = User + fields = ('username', 'email', 'password1', 'password2') + + widgets = { + 'email': forms.EmailInput(attrs={'class': 'form-control'}), + + } + + def clean_username(self): + username = self.cleaned_data.get('username') + if len(username) <= 3: + raise forms.ValidationError("Username must be longer than 3 characters.") + return username + + def clean_password2(self): + password2 = self.cleaned_data.get("password2") + password_regex = re.compile(r'^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&.])[A-Za-z\d@$!%*#?&.]*$') + if not password_regex.match(password2): + raise forms.ValidationError( + "Password must start with an alphabet, contain at least one number, one special character" + + ) + return password2 + + +class CustomAuthenticationForm(AuthenticationForm): + def clean_username(self): + username = self.cleaned_data.get('username') + if len(username) < 4: + raise ValidationError("Username must be at least 5 characters long") + return username + + def clean_password(self): + password = self.cleaned_data.get('password') + password_regex = re.compile(r'^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&.])[A-Za-z\d@$!%*#?&.]*$') + + if not password_regex.match(password): + raise ValidationError( + "Password must start with an alphabet, contain at least one number, one special character.") + + return password + + +class CustomSetPasswordForm(SetPasswordForm): + password_validator = re.compile(r'^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&.])[A-Za-z\d@$!%*#?&.]*$') + + def clean_new_password2(self): + password2 = self.cleaned_data.get('new_password2') + if not self.password_validator.match(password2): + raise ValidationError( + "Password must start with an alphabet, contain at least one number, one special character" + ) + password2 = super().clean_new_password2() + + user = self.user + if user is not None: + last_five_passwords = HistoricalPassword.objects.filter(user=user.id).order_by('-created_at')[:5] + + password_matches = False + for password_entry in last_five_passwords: + if check_password(password2, password_entry.password): + password_matches = True + break + + if password_matches: + raise ValidationError("Password cannot be one of the last 5 passwords.") + + return password2 diff --git a/users/tests.py b/users/tests.py index b3949ba..560b9ed 100644 --- a/users/tests.py +++ b/users/tests.py @@ -11,7 +11,11 @@ ('test_user', 'test@test.com', 'Testappp1.', 'Testappp1.', 302), ('abc', 'test@test.com', 'Testappp1.', 'Testappp1.', 400), ('duplicate_user', 'test@test.com', 'Testappp1.', 'Testappp1.', 400), - ('test_user', 'duplicate@test.com', 'Testappp1.', 'Testappp1.', 400), + ('test_user', 'duplicatee@test.com', 'Testappp1.', 'Testappp1.', 400), + ('test_user', 'test@test.com', 'Testp1.', 'Testp1.', 400), + ('tuser', 'test@test.com', 'Tuser1.', 'Tuser1.', 400), + ('testapp', 'test@test.com', 'Testapp1.', 'Testapp1.', 400), + ('tuser', 'duplicate@test.com', 'Tuser1.', 'Tuser1.', 400), ] ) @@ -20,11 +24,14 @@ def test_user_registration(client, username, email, password1, password2, expect data = {'username': username, 'email': email, 'password1': password1, 'password2': password2} if username == 'duplicate_user': - data = {'username': username, 'email': email, 'password': password1} - User.objects.create_user(**data) - if email == 'duplicate@test.com': - data = {'username': username, 'email': email, 'password': password1} - User.objects.create_user(**data) + user_data = {'username': username, 'email': email, 'password': password1} + User.objects.create_user(**user_data) + if email == 'duplicatee@test.com': + user_data = {'username': username, 'email': email, 'password': password1} + User.objects.create_user(**user_data) + if username == 'tuser' and email == 'duplicate@test.com': + user_data = {'username': username, 'email': email, 'password': password1} + User.objects.create_user(**user_data) response = client.post(register_url, data) @@ -35,9 +42,25 @@ def test_user_registration(client, username, email, password1, password2, expect assert ("Username must be longer than 3 characters." in response.content.decode('utf-8')) if username == 'duplicate_user': - assert ("A user with that username already exists") in response.content.decode('utf-8') - if username == 'duplicate@test.com': + assert "A user with that username already exists." in response.content.decode('utf-8') + if username == 'duplicatee@test.com': assert "User with this Email already exists." in response.content.decode('utf-8') + if password1 == 'Testp1.': + assert "This password is too short. It must contain at least 8 characters." in response.content.decode( + 'utf-8') + if username == 'Tuser1.': + assert "The password is too similar to the username. This password is too short. It must contain at least 8 characters." in response.content.decode( + 'utf-8') + if username == 'tuser' and email == 'duplicate@test.com': + expected_error_messages = [ + "A user with that username already exists.", + "User with this Email already exists.", + "The password is too similar to the username.", + "This password is too short. It must contain at least 8 characters." + ] + decoded_content = response.content.decode('utf-8') + for error_message in expected_error_messages: + assert error_message in decoded_content @pytest.mark.parametrize('username, password, expected_status', [ @@ -61,7 +84,9 @@ def test_login_view_post_with_user(client, username, password, expected_status): assert ("Please enter a correct username and password. Note that both fields may be case-sensitive." in response.content.decode('utf-8')) if username == 'test_user' and password == 'Testpass': - assert ("Password must start with an alphabet, contain at least one number, one special character," - " and have a minimum length of 6 characters.") in response.content.decode('utf-8') + assert ("Password must start with an alphabet, contain at least one number, one special character" + in response.content.decode('utf-8')) if username == 'tes': assert "Username must be at least 5 characters long" in response.content.decode('utf-8') + + diff --git a/users/urls.py b/users/urls.py index 8f4cb31..46ca204 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,12 +1,16 @@ -from django.urls.conf import path, re_path -from django.contrib.auth.views import LoginView -from .views import ProfileView, CustomLogoutView, CreateUserView, ResetPasswordView, ForgotPasswordView,CustomLoginView +from django.urls.conf import path +from django.contrib.auth import views as auth_views +from .views import ProfileView, CustomLogoutView, CreateUserView, CustomPasswordResetView, CustomLoginView, \ + CustomPasswordResetConfirmView urlpatterns = [ path('create/', CreateUserView.as_view(), name='create_user'), path('login/', CustomLoginView.as_view(template_name='registration/login.html'), name='login'), path('profile/', ProfileView.as_view(), name='profile'), path('logout/', CustomLogoutView.as_view(), name='logout'), - path('forgot-password/', ForgotPasswordView.as_view(), name='reset_password'), - re_path(r'^password-reset/(?P[\w-]+)/$', ResetPasswordView.as_view(), name='password-reset'), + path('reset_password/', CustomPasswordResetView.as_view(), name='reset_password'), + path('password_reset_done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'), + path('reset///', CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm'), + path('reset_password_completed/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'), + ] diff --git a/users/views.py b/users/views.py index 4ae8740..b2dc617 100644 --- a/users/views.py +++ b/users/views.py @@ -1,75 +1,51 @@ -import random -import string -from datetime import datetime, timedelta -import re -from django.core.exceptions import ValidationError -from django.contrib.auth.hashers import check_password from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic.detail import DetailView from django.contrib.auth.models import User -from django.contrib.auth.views import LogoutView, LoginView +from django.contrib import messages +from django.contrib.auth.views import LogoutView, LoginView, PasswordResetConfirmView from django.shortcuts import render, redirect from django.views import View -from django.conf import settings -from django.core.mail import send_mail -from django.contrib.auth.forms import AuthenticationForm from django.http import HttpResponseBadRequest -from users.serializers import UserSerializer, HistoricalPasswordSerializer -from .models import User, HistoricalPassword -from django import forms -from django.contrib.auth.forms import UserCreationForm +from users.serializers import HistoricalPasswordSerializer +from .models import User +from .forms import CustomUserCreationForm, CustomAuthenticationForm, CustomSetPasswordForm +from django.contrib.auth.views import PasswordResetView +from django.core.validators import validate_email +from django.core.exceptions import ValidationError __all__ = [ - 'CustomLogoutView', 'CreateUserView', 'ProfileView', 'ResetPasswordView', 'ForgotPasswordView', 'CustomLoginView' + 'CustomLogoutView', 'CreateUserView', 'ProfileView', 'CustomPasswordResetView', + 'CustomLoginView', 'CustomPasswordResetConfirmView' ] -class CustomUserCreationForm(UserCreationForm): - email = forms.EmailField(required=True) - - username = forms.CharField( - help_text=False, - widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Username'}), +class CustomPasswordResetConfirmView(PasswordResetConfirmView): + form_class = CustomSetPasswordForm + template_name = 'registration/password_reset_confirm.html' - ) - password1 = forms.CharField( - label="Password", - strip=False, - widget=forms.PasswordInput(attrs={'class': 'form-control', 'placeholder': 'Password'}), - help_text=False - - ) - password2 = forms.CharField( - label="Confirm Password", - widget=forms.PasswordInput(attrs={'class': 'form-control', 'placeholder': 'Confirm Password'}), - strip=False, - ) + def form_invalid(self, form): + return HttpResponseBadRequest(render(self.request, self.template_name, {'form': form})) - class Meta(UserCreationForm.Meta): - model = User - fields = ('username', 'email', 'password1', 'password2') - widgets = { - 'email': forms.EmailInput(attrs={'class': 'form-control'}), +class CustomPasswordResetView(PasswordResetView): + template_name = 'registration/password_reset_form.html' - } + def form_valid(self, form): + # Custom validation logic + email = form.cleaned_data['email'] + try: + validate_email(email) + except ValidationError as e: + form.add_error('email', e) + return self.form_invalid(form) - def clean_username(self): - username = self.cleaned_data.get('username') - if len(username) <= 3: - raise forms.ValidationError("Username must be longer than 3 characters.") - return username + if not User.objects.filter(email=email).exists(): + form.add_error('email', "There is no account associated with this email address.") + return self.form_invalid(form) - # def clean_password2(self): - # password2 = self.cleaned_data.get("password2") - # password_regex = re.compile(r'^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&.])[A-Za-z\d@$!%*#?&.]{6,}$') - # if not password_regex.match(password2): - # raise forms.ValidationError( - # "Password must start with an alphabet, contain at least one number, one special character, " - # "and have a minimum length of 6 characters." - # ) - # return password2 + # Proceed with default behavior if validation passes + return super().form_valid(form) class CreateUserView(View): @@ -84,7 +60,6 @@ def post(self, request): form = CustomUserCreationForm(request.POST) if form.is_valid(): user = form.save() - print("User ID:", user) historical_password_data = { 'user': user.id, 'password': user.password, @@ -92,35 +67,16 @@ def post(self, request): historical_password_serializer = HistoricalPasswordSerializer(data=historical_password_data) if historical_password_serializer.is_valid(): historical_password_serializer.save() + messages.success(request, 'User created successfully. Please log in.') return redirect('/v1/auth/login') return HttpResponseBadRequest(render(request, self.template_name, {'form': form})) -class CustomAuthenticationForm(AuthenticationForm): - def clean_username(self): - username = self.cleaned_data.get('username') - if len(username) < 4: - raise ValidationError("Username must be at least 5 characters long") - return username - - def clean_password(self): - password = self.cleaned_data.get('password') - password_regex = re.compile(r'^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&.])[A-Za-z\d@$!%*#?&.]{6,}$') - - if not password_regex.match(password): - raise ValidationError( - "Password must start with an alphabet, contain at least one number, one special character," - " and have a minimum length of 6 characters.") - - return password - - class CustomLoginView(LoginView): form_class = CustomAuthenticationForm template_name = 'registration/login.html' def form_invalid(self, form): - # return render(self.request, "registration/login.html", {'form': form}) return HttpResponseBadRequest(render(self.request, "registration/login.html", {'form': form})) @@ -141,99 +97,3 @@ def get_context_data(self, **kwargs): context['user_email'] = user.email context['user_id'] = user.id return context - - -class ForgotPasswordView(View): - template_name = 'reset_password.html' - - def get(self, request): - return render(request, self.template_name, {'error_message': None, 'success_message': None}) - - def post(self, request): - email = request.POST.get('email') - - try: - user = User.objects.get(email=email) - except User.DoesNotExist: - return render(request, self.template_name, {'error_message': 'User not found', 'success_message': None}) - - token = ''.join(random.choices(string.ascii_letters + string.digits, k=20)) - user.reset_password_token = token - user.token_created_at = datetime.now() - - user.save() - - subject = f"Password Reset from BoilerPlate" - message = f"Please click the following link to reset your password: {settings.FRONTEND_URL}/v1/auth/password-reset/{token}" - from_email = settings.DEFAULT_FROM_EMAIL - recipient_list = [email] - try: - send_mail(subject, message, from_email, recipient_list) - except Exception as e: - error_message = 'Failed to send email' - - return render(request, self.template_name, {'success_message': None, 'error_message': error_message}) - - return render(request, self.template_name, - {'success_message': 'Password reset instructions sent'}) - - -class ResetPasswordView(View): - template_name = 'forgot_password.html' - - def get(self, request, token): - return render(request, self.template_name, {'error_message': None, 'success_message': None}) - - def post(self, request, token): - try: - - user = User.objects.get(reset_password_token=token) - - except User.DoesNotExist: - return render(request, self.template_name, {'error_message': 'Invalid token'}) - - new_password = request.POST.get('new_password') - - if new_password is None: - return render(request, self.template_name, {'error_message': 'password not sent'}) - - if not self.is_reset_token_valid(user): - user.token_created_at = None - user.save() - return render(request, self.template_name, {'error_message': 'token has been expired'}) - - last_five_passwords = HistoricalPassword.objects.filter(user=user.id).order_by('-created_at')[:5] - - password_matches = False - for password_entry in last_five_passwords: - if check_password(new_password, password_entry.password): - password_matches = True - break - - if password_matches: - return render(request, self.template_name, - {'error_message': 'Password cannot be one of the last 5 passwords.'}) - - user.set_password(new_password) - user.reset_password_token = None - user.token_created_at = None - user.save() - historical_password_data = { - 'user': user.id, - 'password': user.password, - } - historical_password_serializer = HistoricalPasswordSerializer(data=historical_password_data) - if historical_password_serializer.is_valid(): - historical_password_serializer.save() - - return render(request, self.template_name, {'success_message': 'password reset successfully.'}) - - def is_reset_token_valid(self, user, expiration_hours=2): - - if user.token_created_at is None: - return False - - expiration_time = user.token_created_at + timedelta(hours=expiration_hours) - current_time = datetime.now() - - return current_time.time() <= expiration_time.time()