From 3f32bb15260a89169e238c4ab06c817444d3de53 Mon Sep 17 00:00:00 2001 From: Daniel Gray Date: Wed, 17 Jul 2024 20:33:45 +0200 Subject: [PATCH] Updating user Managerment --- app/accounts/service/__init__.py | 0 app/accounts/service/active_email.py | 32 ++++++++ app/accounts/tests/test_active_email.py | 54 +++++++++++++ app/accounts/tests/test_token.py | 40 ++++++++++ app/accounts/tokens.py | 10 +++ app/accounts/urls.py | 12 ++- app/accounts/views.py | 62 +++++++++++++- app/app/urls.py | 4 +- app/templates/accounts/activate.html | 14 ++++ .../accounts/activation_invalid.html | 21 +++++ app/templates/accounts/activation_sent.html | 22 +++++ .../accounts/email/activation_email.html | 60 ++++++++++++++ app/templates/accounts/login.html | 4 +- .../accounts/password_reset_confirm.html | 3 +- .../accounts/password_reset_form.html | 47 ++++++----- app/templates/accounts/resend_activation.html | 31 +++++++ .../registration/password_reset_email.html | 80 +++++++++++++++++++ app/users/admin.py | 16 +++- ...mail_alter_customuser_username_and_more.py | 33 ++++++++ app/users/models.py | 2 + 20 files changed, 511 insertions(+), 36 deletions(-) create mode 100644 app/accounts/service/__init__.py 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 create mode 100644 app/users/migrations/0004_alter_customuser_email_alter_customuser_username_and_more.py diff --git a/app/accounts/service/__init__.py b/app/accounts/service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/accounts/service/active_email.py b/app/accounts/service/active_email.py new file mode 100644 index 00000000..d43181a9 --- /dev/null +++ b/app/accounts/service/active_email.py @@ -0,0 +1,32 @@ +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, "your-email@example.com", [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..47ea7482 100644 --- a/app/accounts/urls.py +++ b/app/accounts/urls.py @@ -3,12 +3,16 @@ from . import views +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"), + auth_views.PasswordResetView.as_view( + template_name="accounts/password_reset_form.html", + success_url="/accounts/password_reset/done/", + ), name="password_reset", ), path( @@ -19,7 +23,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 +35,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..eac5524f 100644 --- a/app/accounts/views.py +++ b/app/accounts/views.py @@ -1,8 +1,13 @@ -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.shortcuts import redirect, render +from django.utils.encoding import force_str +from django.utils.http import urlsafe_base64_decode + +from accounts.service.active_email import SendActiveEmailService from .forms import CustomAuthenticationForm, CustomUserCreationForm +from .tokens import account_activation_token def register(request): @@ -10,10 +15,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 +39,48 @@ 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_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") diff --git a/app/app/urls.py b/app/app/urls.py index 649d0d9b..3d39fbf6 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 _ @@ -40,8 +39,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..e95030dc --- /dev/null +++ b/app/templates/accounts/activate.html @@ -0,0 +1,14 @@ + +{% 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 %} diff --git a/app/templates/accounts/activation_invalid.html b/app/templates/accounts/activation_invalid.html new file mode 100644 index 00000000..0a96086e --- /dev/null +++ b/app/templates/accounts/activation_invalid.html @@ -0,0 +1,21 @@ +{% 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..1a55a8ee --- /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_confirm.html b/app/templates/accounts/password_reset_confirm.html index 89a7d6cb..e883a713 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 %} diff --git a/app/templates/accounts/password_reset_form.html b/app/templates/accounts/password_reset_form.html index a23fafcf..b0bc2821 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 %} diff --git a/app/templates/accounts/resend_activation.html b/app/templates/accounts/resend_activation.html new file mode 100644 index 00000000..14ceb3e8 --- /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 %} diff --git a/app/templates/registration/password_reset_email.html b/app/templates/registration/password_reset_email.html new file mode 100644 index 00000000..2d462c42 --- /dev/null +++ b/app/templates/registration/password_reset_email.html @@ -0,0 +1,80 @@ + + + + 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) diff --git a/app/users/migrations/0004_alter_customuser_email_alter_customuser_username_and_more.py b/app/users/migrations/0004_alter_customuser_email_alter_customuser_username_and_more.py new file mode 100644 index 00000000..5b8ff408 --- /dev/null +++ b/app/users/migrations/0004_alter_customuser_email_alter_customuser_username_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.6 on 2024-07-18 13:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_historicalcustomuser'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='email', + field=models.EmailField(max_length=254, unique=True), + ), + migrations.AlterField( + model_name='customuser', + name='username', + field=models.CharField(unique=True), + ), + migrations.AlterField( + model_name='historicalcustomuser', + name='email', + field=models.EmailField(db_index=True, max_length=254), + ), + migrations.AlterField( + model_name='historicalcustomuser', + name='username', + field=models.CharField(db_index=True), + ), + ] diff --git a/app/users/models.py b/app/users/models.py index 52adb0d1..d2c21c3b 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -6,6 +6,8 @@ class CustomUser(AbstractUser): + username = models.CharField(unique=True) + email = models.EmailField(unique=True) institution = models.ForeignKey(Institution, on_delete=models.CASCADE, null=True, blank=True) languages = models.ManyToManyField(Language, blank=True) subject = models.ManyToManyField(Subject, blank=True)