From 7bc0ac2c6967a40f1cdc292c7f8eb19d81f989e4 Mon Sep 17 00:00:00 2001 From: Daniel Gray Date: Fri, 19 Jul 2024 08:05:59 +0200 Subject: [PATCH] updating user management - added templates - updated templates - updated forms - updated View add translations wrapping --- .env.example | 2 +- app/accounts/forms.py | 42 ++++++++- app/accounts/service/active_email.py | 34 ++++++++ app/accounts/tests/test_active_email.py | 54 ++++++++++++ app/accounts/tests/test_token.py | 40 +++++++++ app/accounts/tokens.py | 24 ++++++ app/accounts/urls.py | 14 +-- app/accounts/views.py | 85 +++++++++++++++++-- app/app/urls.py | 4 +- app/templates/accounts/activate.html | 19 +++++ .../accounts/activation_invalid.html | 20 +++++ app/templates/accounts/activation_sent.html | 22 +++++ .../accounts/email/activation_email.html | 60 +++++++++++++ app/templates/accounts/login.html | 4 +- .../accounts/password_reset_complete.html | 2 +- .../accounts/password_reset_confirm.html | 30 +++++-- .../accounts/password_reset_done.html | 2 +- .../accounts/password_reset_form.html | 47 +++++----- app/templates/accounts/resend_activation.html | 31 +++++++ app/templates/app/search.html | 5 ++ .../registration/password_reset_email.html | 68 +++++++++++++++ app/users/admin.py | 15 +++- 22 files changed, 573 insertions(+), 51 deletions(-) create mode 100644 app/accounts/service/active_email.py create mode 100644 app/accounts/tests/test_active_email.py create mode 100644 app/accounts/tests/test_token.py create mode 100644 app/accounts/tokens.py create mode 100644 app/templates/accounts/activate.html create mode 100644 app/templates/accounts/activation_invalid.html create mode 100644 app/templates/accounts/activation_sent.html create mode 100644 app/templates/accounts/email/activation_email.html create mode 100644 app/templates/accounts/resend_activation.html create mode 100644 app/templates/registration/password_reset_email.html diff --git a/.env.example b/.env.example index 3b276aba..bf4f6721 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,3 @@ -SECRET_KEY='' DEBUG=True DB_HOST=db DB_PORT=5432 @@ -19,3 +18,4 @@ EMAIL_HOST_PASSWORD='' EMAIL_BACKEND_CONSOLE='True/False' EMAIL_USE_TLS=True SECRET_KEY='' +DEFAULT_FROM_EMAIL='your-email@example.com' diff --git a/app/accounts/forms.py b/app/accounts/forms.py index c58c33a4..7c84f221 100644 --- a/app/accounts/forms.py +++ b/app/accounts/forms.py @@ -1,5 +1,10 @@ from django import forms -from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +from django.contrib.auth import get_user_model +from django.contrib.auth.forms import ( + AuthenticationForm, + PasswordResetForm, + UserCreationForm, +) from django.utils.translation import gettext_lazy as _ from users.models import CustomUser @@ -40,3 +45,38 @@ def __init__(self, *args, **kwargs): super(CustomAuthenticationForm, self).__init__(*args, **kwargs) for field in self.fields.values(): field.widget.attrs.update({"class": "form-control"}) + + +class CustomPasswordResetForm(PasswordResetForm): + def clean_email(self): + """ + + Cleaning an Email + + This method is used to clean an email address provided by the user. It performs the following operations: + + 1. Retrieves the user model using the "get_user_model()" function. + 2. Fetches the email from the "cleaned_data" dictionary. + 3. Queries the user model to find all users with the same email address. + 4. Checks if any users exist with the given email address. If not, it raises a "forms.ValidationError" with a specific error message. + 5. Iterates through each user found with the given email address. + 6. Checks if the user is both active and a staff member. If not, it raises a "forms.ValidationError" with a specific error message. + 7. Finally, the cleaned email address is returned. + + Please note that this method assumes the presence of the "forms.ValidationError" class and the "get_user_model()" function. If any of these are missing, this method will not work properly. + + Reason for the Override: + The "clean_email" method is overridden to ensure that only active staff members can reset their passwords. This is done to prevent unauthorized users from resetting their passwords and gaining access to the system. + and prevent users getting an email to reset their password if they are not active or staff members. + """ + User = get_user_model() + email = self.cleaned_data["email"] + users = User.objects.filter(email=email) + if not users.exists(): + raise forms.ValidationError("There is a error please contact the administrator.") + + for user in users: + if not user.is_active or not user.is_staff: + raise forms.ValidationError("There is a error please contact the administrator.") + + return email diff --git a/app/accounts/service/active_email.py b/app/accounts/service/active_email.py new file mode 100644 index 00000000..affaaae0 --- /dev/null +++ b/app/accounts/service/active_email.py @@ -0,0 +1,34 @@ +import os + +from django.contrib.sites.shortcuts import get_current_site +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + +from accounts.tokens import account_activation_token + + +class SendActiveEmailService: + @staticmethod + def send_activation_email(request, user): + if user and request: + current_site = get_current_site(request) + mail_subject = "Activate your account." + message = render_to_string( + "accounts/email/activation_email.html", + { + "user": user, + "domain": current_site.domain, + "uid": urlsafe_base64_encode(force_bytes(user.pk)), + "token": account_activation_token.make_token(user), + }, + ) + text_content = ( + "Please activate your account by clicking the link provided in the email." + ) + email = EmailMultiAlternatives( + mail_subject, text_content, os.environ.get("DEFAULT_FROM_EMAIL"), [user.email] + ) + email.attach_alternative(message, "text/html") + email.send() diff --git a/app/accounts/tests/test_active_email.py b/app/accounts/tests/test_active_email.py new file mode 100644 index 00000000..64312842 --- /dev/null +++ b/app/accounts/tests/test_active_email.py @@ -0,0 +1,54 @@ +from unittest.mock import MagicMock, patch + +from django.contrib.sites.shortcuts import get_current_site +from django.core import mail +from django.test import RequestFactory, TestCase +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + +from accounts.service.active_email import ( # Adjust the import path as necessary + SendActiveEmailService, +) +from accounts.tokens import account_activation_token +from users.models import CustomUser # Import your custom user model + + +class SendActiveEmailServiceTest(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.user = CustomUser.objects.create_user( + username="testuser", email="test@example.com", password="password123" + ) + self.request = self.factory.get("/fake-path") + self.service = SendActiveEmailService() + + def test_send_activation_email(self): + with patch("accounts.service.active_email.render_to_string") as mock_render: + # Set up the mocks + mock_render.return_value = "mocked template" + + # # Call the method + self.service.send_activation_email(self.request, self.user) + + # Check that render_to_string was called with the correct parameters + mock_render.assert_called_once_with( + "accounts/email/activation_email.html", + { + "user": self.user, + "domain": get_current_site(self.request).domain, + "uid": urlsafe_base64_encode(force_bytes(self.user.pk)), + "token": account_activation_token.make_token(self.user), + }, + ) + + # Check that an email was sent + self.assertEqual(len(mail.outbox), 1) + sent_email = mail.outbox[0] + self.assertEqual(sent_email.subject, "Activate your account.") + self.assertEqual(sent_email.to, [self.user.email]) + self.assertIn("mocked template", sent_email.alternatives[0][0]) + self.assertEqual(sent_email.alternatives[0][1], "text/html") + self.assertIn( + "Please activate your account by clicking the link provided in the email.", + sent_email.body, + ) diff --git a/app/accounts/tests/test_token.py b/app/accounts/tests/test_token.py new file mode 100644 index 00000000..30165e06 --- /dev/null +++ b/app/accounts/tests/test_token.py @@ -0,0 +1,40 @@ +import unittest +from datetime import datetime + +import six +from django.contrib.auth.tokens import PasswordResetTokenGenerator + +from accounts.tokens import AccountActivationTokenGenerator + + +# Speculate user model class for test abstraction +class User: + def __init__(self, id, is_active): + self.pk = id + self.is_active = is_active + + +class TestAccountActivationTokenGenerator(unittest.TestCase): + def setUp(self): + self.generator = AccountActivationTokenGenerator() + self.timestamp = datetime.now() + + def test_make_hash_value_active_user(self): + user = User(1, True) + hash_val = self.generator._make_hash_value(user, self.timestamp) + expected_val = ( + six.text_type(user.pk) + six.text_type(self.timestamp) + six.text_type(user.is_active) + ) + self.assertEqual(hash_val, expected_val) + + def test_make_hash_value_inactive_user(self): + user = User(1, False) + hash_val = self.generator._make_hash_value(user, self.timestamp) + expected_val = ( + six.text_type(user.pk) + six.text_type(self.timestamp) + six.text_type(user.is_active) + ) + self.assertEqual(hash_val, expected_val) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/accounts/tokens.py b/app/accounts/tokens.py new file mode 100644 index 00000000..042191d9 --- /dev/null +++ b/app/accounts/tokens.py @@ -0,0 +1,24 @@ +from django.contrib.auth.tokens import PasswordResetTokenGenerator + + +class AccountActivationTokenGenerator(PasswordResetTokenGenerator): + """ + AccountActivationTokenGenerator class is a subclass of PasswordResetTokenGenerator. + + Methods: + + _make_hash_value(self, user, timestamp): + This method takes in a user object and a timestamp and returns a hash value + that is used to generate an account activation token. The hash value is + calculated by concatenating the user's primary key, timestamp, and active + status. + + DO NOT MODIFY THIS METHOD. UNLESS YOU GET PERMISSION FROM THE PROJECT OWNER. + + """ + + def _make_hash_value(self, user, timestamp): + return f"{user.pk}{timestamp}{user.is_active}" + + +account_activation_token = AccountActivationTokenGenerator() diff --git a/app/accounts/urls.py b/app/accounts/urls.py index eb6d59cb..7aa3303e 100644 --- a/app/accounts/urls.py +++ b/app/accounts/urls.py @@ -2,15 +2,13 @@ from django.urls import path from . import views +from .views import CustomPasswordResetView +app_name = "accounts" urlpatterns = [ path("register/", views.register, name="accounts_register"), path("login/", auth_views.LoginView.as_view(template_name="accounts/login.html"), name="login"), - path( - "password_reset/", - auth_views.PasswordResetView.as_view(template_name="accounts/password_reset_form.html"), - name="password_reset", - ), + path("password_reset/", CustomPasswordResetView.as_view(), name="password_reset"), path( "password_reset/done/", auth_views.PasswordResetDoneView.as_view(template_name="accounts/password_reset_done.html"), @@ -19,7 +17,8 @@ path( "reset///", auth_views.PasswordResetConfirmView.as_view( - template_name="accounts/password_reset_confirm.html" + template_name="accounts/password_reset_confirm.html", + success_url="/accounts/reset/done/", ), name="password_reset_confirm", ), @@ -30,4 +29,7 @@ ), name="password_reset_complete", ), + path("activate///", views.activate, name="activate"), + path("activation_sent/", views.activation_sent, name="activation_sent"), + path("resend_activation/", views.resend_activation, name="resend_activation"), ] diff --git a/app/accounts/views.py b/app/accounts/views.py index e02657fa..e295dc69 100644 --- a/app/accounts/views.py +++ b/app/accounts/views.py @@ -1,8 +1,19 @@ -from django.contrib.auth import authenticate +from django.contrib.auth import get_user_model from django.contrib.auth import login as auth_login +from django.contrib.auth.views import PasswordResetView from django.shortcuts import redirect, render +from django.urls import reverse_lazy +from django.utils.encoding import force_str +from django.utils.http import urlsafe_base64_decode -from .forms import CustomAuthenticationForm, CustomUserCreationForm +from accounts.service.active_email import SendActiveEmailService + +from .forms import ( + CustomAuthenticationForm, + CustomPasswordResetForm, + CustomUserCreationForm, +) +from .tokens import account_activation_token def register(request): @@ -10,10 +21,14 @@ def register(request): form = CustomUserCreationForm(request.POST) if form.is_valid(): user = form.save(commit=False) - user.is_staff = True + user.is_staff = False + user.is_active = False user.save() - auth_login(request, user) - return redirect("home") + + SendActiveEmailService.send_activation_email(request, user) + + return redirect("accounts:activation_sent") + else: form = CustomUserCreationForm() return render(request, "accounts/register.html", {"form": form}) @@ -30,3 +45,63 @@ def user_login(request): form = CustomAuthenticationForm() return render(request, "accounts/login.html", {"form": form}) + + +def activate(request, uidb64, token): + User = get_user_model() # Get the custom user model + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + user = None + + if user is not None and account_activation_token.check_token(user, token): + user.is_staff = True + user.is_active = True + user.save() + auth_login(request, user) + return render(request, "accounts/activate.html") + else: + return render(request, "accounts/activation_invalid.html") + + +def activation_sent(request): + return render(request, "accounts/activation_sent.html") + + +def resend_activation(request): + User = get_user_model() # + if request.method == "POST": + user_email = request.POST["email"] + + user = User.objects.get(email=user_email) + if not user.is_active: + SendActiveEmailService.send_activation_email(request, user) + + return redirect("accounts:activation_sent") + + else: + return render( + request, + "accounts/resend_activation.html", + {"error": "There is a error please contact the administrator."}, + ) + + +class CustomPasswordResetView(PasswordResetView): + """ + This class represents a custom password reset view for user accounts. + + Attributes: + form_class (CustomPasswordResetForm): The form class for the password reset form. + template_name (str): The name of the template for rendering the password reset form. + success_url (reverse_lazy): The URL to redirect to after a successful password reset. + html_email_template_name (str): The name of the template for sending the password reset email. + + + """ + + form_class = CustomPasswordResetForm + template_name = "accounts/password_reset_form.html" + success_url = reverse_lazy("accounts:password_reset_done") + html_email_template_name = "registration/password_reset_email.html" diff --git a/app/app/urls.py b/app/app/urls.py index 7ba0c260..f50fe796 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -18,7 +18,6 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin -from django.contrib.auth import views as auth_views from django.urls import include, path from django.utils.translation import gettext_lazy as _ @@ -41,8 +40,7 @@ path("subjects/", views.subjects, name="subjects"), path("search/", views.search, name="search"), path("i18n/", include("django.conf.urls.i18n")), - path("accounts/", include("accounts.urls")), - path("accounts/", include("django.contrib.auth.urls")), + path("accounts/", include("accounts.urls"), name="accounts"), ] if settings.DEBUG: diff --git a/app/templates/accounts/activate.html b/app/templates/accounts/activate.html new file mode 100644 index 00000000..21735b51 --- /dev/null +++ b/app/templates/accounts/activate.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% load static %} +{% load i18n %} + +{% block title %}{% trans "Activated" %}{% endblock %} + +{% block content %} +
+
+
+ +
+
+
+{% endblock %} diff --git a/app/templates/accounts/activation_invalid.html b/app/templates/accounts/activation_invalid.html new file mode 100644 index 00000000..c261ad75 --- /dev/null +++ b/app/templates/accounts/activation_invalid.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% load static %} +{% load i18n %} + +{% block title %}{% trans "Sign Up" %}{% endblock %} + +{% block content %} +
+
+
+ +
+
+
+{% endblock %} diff --git a/app/templates/accounts/activation_sent.html b/app/templates/accounts/activation_sent.html new file mode 100644 index 00000000..71809c1a --- /dev/null +++ b/app/templates/accounts/activation_sent.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% load static %} +{% load i18n %} + +{% block title %}{% trans "Activation Email Sent" %}{% endblock %} + +{% block content %} +
+
+
+ +
+
+
+{% endblock %} diff --git a/app/templates/accounts/email/activation_email.html b/app/templates/accounts/email/activation_email.html new file mode 100644 index 00000000..afa50e9b --- /dev/null +++ b/app/templates/accounts/email/activation_email.html @@ -0,0 +1,60 @@ + + + + + Activate Your Account + + + +
+

Activate Your Account

+

Hi {{ user.username }},

+

Thank you for signing up for our service. Please click the link below to activate your account:

+

+ + Activate your account + +

+

If you did not sign up for this account, please ignore this email.

+

Thank you,

+

The Team

+
+ + diff --git a/app/templates/accounts/login.html b/app/templates/accounts/login.html index b782503b..af1f81a7 100644 --- a/app/templates/accounts/login.html +++ b/app/templates/accounts/login.html @@ -48,7 +48,7 @@

{% trans "Log In" %}

- {% trans "Forgot Password?" %} + {% trans "Forgot Password?" %} {% if form.non_field_errors %}
@@ -66,7 +66,7 @@

{% trans "Log In" %}


-

{% trans "Don't have an account?" %} {% trans "Create one" %}

+

{% trans "Don't have an account?" %} {% trans "Create one" %}

diff --git a/app/templates/accounts/password_reset_complete.html b/app/templates/accounts/password_reset_complete.html index 47d75de7..8a6fcb06 100644 --- a/app/templates/accounts/password_reset_complete.html +++ b/app/templates/accounts/password_reset_complete.html @@ -6,7 +6,7 @@ {% block content %}
-
+
-
{% endblock %} diff --git a/app/templates/accounts/password_reset_done.html b/app/templates/accounts/password_reset_done.html index 3bb37be6..64dd8813 100644 --- a/app/templates/accounts/password_reset_done.html +++ b/app/templates/accounts/password_reset_done.html @@ -6,7 +6,7 @@ {% block content %}
-
+