From a6a20d6a52952c25480d728484ded44d8c9a691d Mon Sep 17 00:00:00 2001 From: Daniel Gray Date: Fri, 19 Jul 2024 08:05:59 +0200 Subject: [PATCH] updating user management updated code updated code --- .env.example | 1 + app/accounts/forms.py | 22 +++++- 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 | 10 +++ app/accounts/urls.py | 10 ++- app/accounts/views.py | 77 +++++++++++++++++-- app/app/urls.py | 4 +- app/templates/accounts/activate.html | 13 ++++ .../accounts/activation_invalid.html | 20 +++++ app/templates/accounts/activation_sent.html | 22 ++++++ .../accounts/email/activation_email.html | 60 +++++++++++++++ app/templates/accounts/login.html | 6 +- .../accounts/password_reset_confirm.html | 5 +- .../accounts/password_reset_form.html | 49 ++++++------ app/templates/accounts/resend_activation.html | 31 ++++++++ .../registration/password_reset_email.html | 68 ++++++++++++++++ app/users/admin.py | 16 +++- 19 files changed, 501 insertions(+), 41 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 436b34ff..4bfdc644 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,4 @@ EMAIL_HOST_USER='' EMAIL_HOST_PASSWORD='' EMAIL_BACKEND_CONSOLE='True/False' EMAIL_USE_TLS=True +DEFAULT_FROM_EMAIL='your-email@example.com' diff --git a/app/accounts/forms.py b/app/accounts/forms.py index c58c33a4..d2904474 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,18 @@ 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): + User = get_user_model() + email = self.cleaned_data["email"] + users = User.objects.filter(email=email) + if not users.exists(): + raise forms.ValidationError("No user is associated with this email address.") + + for user in users: + if not user.is_active or not user.is_staff: + raise forms.ValidationError("This account is inactive or not a staff account.") + + 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..05503120 --- /dev/null +++ b/app/accounts/tokens.py @@ -0,0 +1,10 @@ +import six +from django.contrib.auth.tokens import PasswordResetTokenGenerator + + +class AccountActivationTokenGenerator(PasswordResetTokenGenerator): + def _make_hash_value(self, user, timestamp): + return six.text_type(user.pk) + six.text_type(timestamp) + six.text_type(user.is_active) + + +account_activation_token = AccountActivationTokenGenerator() diff --git a/app/accounts/urls.py b/app/accounts/urls.py index eb6d59cb..d6a9939a 100644 --- a/app/accounts/urls.py +++ b/app/accounts/urls.py @@ -2,13 +2,15 @@ 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"), + CustomPasswordResetView.as_view(), name="password_reset", ), path( @@ -19,7 +21,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 +33,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..e07c81fe 100644 --- a/app/accounts/views.py +++ b/app/accounts/views.py @@ -1,8 +1,18 @@ -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.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 +20,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 +44,56 @@ 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"] + try: + 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": "Email address active."} + ) + + except User.DoesNotExist: + # Handle the case where the email does not exist + return render( + request, "accounts/resend_activation.html", {"error": "Email address not found."} + ) + return render(request, "accounts/resend_activation.html") + + +class CustomPasswordResetView(PasswordResetView): + form_class = CustomPasswordResetForm + template_name = "accounts/password_reset_form.html" + success_url = "/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 a9986360..235141d0 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("subject//", views.subject_detail, name="subject_detail"), 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..5c5fdd15 --- /dev/null +++ b/app/templates/accounts/activate.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% load static %} +{% load i18n %} + +{% block title %}{% trans "Activated" %}{% endblock %} + +{% block content %} +
+

Account Activated

+

Your account has been successfully activated. You can now log in using your credentials.

+ Login +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/accounts/activation_invalid.html b/app/templates/accounts/activation_invalid.html new file mode 100644 index 00000000..1b662209 --- /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 %} \ No newline at end of file diff --git a/app/templates/accounts/activation_sent.html b/app/templates/accounts/activation_sent.html new file mode 100644 index 00000000..94c40155 --- /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 %} \ No newline at end of file diff --git a/app/templates/accounts/email/activation_email.html b/app/templates/accounts/email/activation_email.html new file mode 100644 index 00000000..990ea691 --- /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

+
+ + \ No newline at end of file diff --git a/app/templates/accounts/login.html b/app/templates/accounts/login.html index b782503b..4f06a937 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,11 +66,11 @@

{% trans "Log In" %}


-

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

+

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

-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/accounts/password_reset_confirm.html b/app/templates/accounts/password_reset_confirm.html index 89a7d6cb..93f3cc52 100644 --- a/app/templates/accounts/password_reset_confirm.html +++ b/app/templates/accounts/password_reset_confirm.html @@ -41,10 +41,9 @@

{% blocktrans %}The password reset link was invalid, possibly because it has already been used. Please request a new password reset.{% endblocktrans %}

- {% endif %} - -{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/accounts/password_reset_form.html b/app/templates/accounts/password_reset_form.html index a23fafcf..00ad274e 100644 --- a/app/templates/accounts/password_reset_form.html +++ b/app/templates/accounts/password_reset_form.html @@ -5,30 +5,33 @@ {% block title %}{% trans "Forgot Your Password?" %}{% endblock %} {% block content %} -
-
- -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/accounts/resend_activation.html b/app/templates/accounts/resend_activation.html new file mode 100644 index 00000000..73023714 --- /dev/null +++ b/app/templates/accounts/resend_activation.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% load static %} +{% load i18n %} + +{% block title %}{% trans "Resend Activation Email" %}{% endblock %} + +{% block content %} +
+
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/registration/password_reset_email.html b/app/templates/registration/password_reset_email.html new file mode 100644 index 00000000..88bafad8 --- /dev/null +++ b/app/templates/registration/password_reset_email.html @@ -0,0 +1,68 @@ + + + + + Password Reset + + + +
+

Password Reset

+ +

Hello {{ user.get_username }},

+

You requested a password reset. Click the button below to reset your password:

+

+ + Reset my password + +

+

If you didn't request this, please ignore this email.

+

Thanks,

+

Your website team

+
+ + diff --git a/app/users/admin.py b/app/users/admin.py index 19ed5e5e..8fced67f 100644 --- a/app/users/admin.py +++ b/app/users/admin.py @@ -2,6 +2,8 @@ from django.contrib.auth.admin import UserAdmin from simple_history.admin import SimpleHistoryAdmin +from accounts.service.active_email import SendActiveEmailService + from .models import CustomUser @@ -22,9 +24,21 @@ class CustomUserAdmin(UserAdmin, SimpleHistoryAdmin): "email", ] + add_fieldsets = UserAdmin.add_fieldsets + ((None, {"fields": ("email",)}),) + fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("institution", "languages", "subject")}),) - add_fieldsets = UserAdmin.add_fieldsets + # add_fieldsets = UserAdmin.add_fieldsets history_list_display = ["username", "email", "first_name", "last_name", "is_staff", "is_active"] + def save_model(self, request, obj, form, change): + if not change: # Only send the email when a new user is created + obj.is_active = False # Deactivate account until it is confirmed + obj.save() + + SendActiveEmailService.send_activation_email(request, obj) + + else: + obj.save() + admin.site.register(CustomUser, CustomUserAdmin)