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..b6ddc217 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.get(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..a4b266ec --- /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 %} diff --git a/app/templates/accounts/activation_invalid.html b/app/templates/accounts/activation_invalid.html new file mode 100644 index 00000000..d7e58076 --- /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..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..7ce50383 100644 --- a/app/templates/accounts/password_reset_confirm.html +++ b/app/templates/accounts/password_reset_confirm.html @@ -41,10 +41,8 @@

{% 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..d41c4a02 --- /dev/null +++ b/app/templates/accounts/resend_activation.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% load static %} +{% load i18n %} + +{% block title %}{% trans "Resend Activation Email" %}{% endblock %} + +{% block content %} +
+
+
+ +
+
+
+{% endblock %} + diff --git a/app/templates/app/search.html b/app/templates/app/search.html index 910fa73f..fdc491a3 100644 --- a/app/templates/app/search.html +++ b/app/templates/app/search.html @@ -19,6 +19,11 @@
{% trans "Search a term" %}

{% for result in search_results %} +
+ {{ result }} +
+ +

{{ result.heading }}

{{ result.description | truncatewords:10 }}

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..a585be2e 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,20 @@ 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 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)