diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8acab35b..820966c3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,11 +1,8 @@ name: Main on: - pull_request: push: - paths-ignore: - - "**/*.md" - - "**/.*" + pull_request: workflow_dispatch: env: diff --git a/backend/Pipfile b/backend/Pipfile index 607e42a7..144998be 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -2,6 +2,7 @@ url = "https://pypi.org/simple" verify_ssl = true name = "pypi" + ## ℹ️ HOW-TO: Make the python-package editable. # # 1. Comment out the git-codeforlife package under [packages]. @@ -22,7 +23,7 @@ name = "pypi" # 5. Run `pipenv install --dev` in your terminal. [packages] -codeforlife = {ref = "v0.16.7", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"} +codeforlife = {ref = "v0.16.8", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"} # 🚫 Don't add [packages] below that are inhertited from the CFL package. # TODO: check if we need the below packages whitenoise = "==6.5.0" @@ -45,9 +46,10 @@ google-cloud-logging = "==1.*" google-auth = "==2.*" google-cloud-container = "==2.3.0" # "django-anymail[amazon_ses]" = "==7.0.*" +pyjwt = "==2.6.0" # TODO: upgrade to latest version [dev-packages] -codeforlife = {ref = "v0.16.7", git = "https://github.com/ocadotechnology/codeforlife-package-python.git", extras = ["dev"]} +codeforlife = {ref = "v0.16.8", git = "https://github.com/ocadotechnology/codeforlife-package-python.git", extras = ["dev"]} # codeforlife = {file = "../../codeforlife-package-python", editable = true, extras = ["dev"]} # 🚫 Don't add [dev-packages] below that are inhertited from the CFL package. # TODO: check if we need the below packages diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index dbaf2c46..240cfe89 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "32046062a10700f18e0f09affd931b0b23d76e405421906d8ebe47873c999b1e" + "sha256": "aebe5fbe238776c581ef4026e3c79beb2103a8aa1f146ee02b63251e7b0f8693" }, "pipfile-spec": 6, "requires": { @@ -168,7 +168,7 @@ }, "codeforlife": { "git": "https://github.com/ocadotechnology/codeforlife-package-python.git", - "ref": "887670a14ea84100b5b7465b149394deaac0292c" + "ref": "97ff9647c9a212dd276a765ea111a8e08012e625" }, "codeforlife-portal": { "hashes": [ @@ -1029,6 +1029,7 @@ "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd", "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14" ], + "index": "pypi", "markers": "python_version >= '3.7'", "version": "==2.6.0" }, @@ -1507,7 +1508,7 @@ }, "codeforlife": { "git": "https://github.com/ocadotechnology/codeforlife-package-python.git", - "ref": "887670a14ea84100b5b7465b149394deaac0292c" + "ref": "97ff9647c9a212dd276a765ea111a8e08012e625" }, "codeforlife-portal": { "hashes": [ @@ -1574,6 +1575,7 @@ "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48", "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4" ], + "markers": "python_version >= '3.8'", "version": "==7.4.4" }, "defusedxml": { @@ -2429,6 +2431,7 @@ "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd", "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14" ], + "index": "pypi", "markers": "python_version >= '3.7'", "version": "==2.6.0" }, @@ -2488,6 +2491,7 @@ "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5", "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42" ], + "markers": "python_version < '3.10'", "version": "==7.2.1" }, "pytest-cov": { diff --git a/backend/api/auth/__init__.py b/backend/api/auth/__init__.py new file mode 100644 index 00000000..1ee44986 --- /dev/null +++ b/backend/api/auth/__init__.py @@ -0,0 +1,9 @@ +""" +© Ocado Group +Created on 10/05/2024 at 14:37:11(+01:00). +""" + +from .token_generators import ( + email_verification_token_generator, + password_reset_token_generator, +) diff --git a/backend/api/auth/token_generators.py b/backend/api/auth/token_generators.py new file mode 100644 index 00000000..73e5340e --- /dev/null +++ b/backend/api/auth/token_generators.py @@ -0,0 +1,82 @@ +""" +© Ocado Group +Created on 10/05/2024 at 14:37:36(+01:00). +""" + +import typing as t +from datetime import timedelta + +import jwt +from codeforlife.user.models import User +from django.conf import settings +from django.contrib.auth.tokens import ( + PasswordResetTokenGenerator, + default_token_generator, +) +from django.utils import timezone + +# NOTE: type hint to help Intellisense. +password_reset_token_generator: PasswordResetTokenGenerator = ( + default_token_generator +) + + +class EmailVerificationTokenGenerator: + """Custom token generator used to verify a user's email address.""" + + def _get_audience(self, user_or_pk: t.Union[User, t.Any]): + pk = user_or_pk.pk if isinstance(user_or_pk, User) else user_or_pk + return f"user:{pk}" + + def make_token(self, user_or_pk: t.Union[User, t.Any]): + """Generate a token used to verify user's email address. + + https://pyjwt.readthedocs.io/en/stable/usage.html + + Args: + user: The user to generate a token for. + + Returns: + A token used to verify user's email address. + """ + return jwt.encode( + payload={ + "exp": ( + timezone.now() + + timedelta(seconds=settings.EMAIL_VERIFICATION_TIMEOUT) + ), + "aud": [self._get_audience(user_or_pk)], + }, + key=settings.SECRET_KEY, + algorithm="HS256", + ) + + def check_token(self, user_or_pk: t.Union[User, t.Any], token: str): + """Check the token belongs to the user and has not expired. + + Args: + user: The user to check. + token: The token to check. + + Returns: + A flag designating whether the token belongs to the user and has not + expired. + """ + try: + jwt.decode( + jwt=token, + key=settings.SECRET_KEY, + audience=self._get_audience(user_or_pk), + algorithms=["HS256"], + ) + except ( + jwt.DecodeError, + jwt.ExpiredSignatureError, + jwt.InvalidAudienceError, + ): + return False + + return True + + +email_verification_token_generator = EmailVerificationTokenGenerator() diff --git a/backend/api/serializers/__init__.py b/backend/api/serializers/__init__.py index 44be9e58..9b72be74 100644 --- a/backend/api/serializers/__init__.py +++ b/backend/api/serializers/__init__.py @@ -28,4 +28,5 @@ RequestUserPasswordResetSerializer, ResetUserPasswordSerializer, UpdateUserSerializer, + VerifyUserEmailAddressSerializer, ) diff --git a/backend/api/serializers/user.py b/backend/api/serializers/user.py index 13306ef6..e4580db7 100644 --- a/backend/api/serializers/user.py +++ b/backend/api/serializers/user.py @@ -23,20 +23,15 @@ from django.contrib.auth.password_validation import ( validate_password as _validate_password, ) -from django.contrib.auth.tokens import ( - PasswordResetTokenGenerator, - default_token_generator, -) from django.core.exceptions import ValidationError as CoreValidationError from django.utils import timezone from rest_framework import serializers -# NOTE: type hint to help Intellisense. -password_reset_token_generator: PasswordResetTokenGenerator = ( - default_token_generator +from ..auth import ( + email_verification_token_generator, + password_reset_token_generator, ) - # pylint: disable=missing-class-docstring # pylint: disable=too-many-ancestors # pylint: disable=missing-function-docstring @@ -294,7 +289,7 @@ def update(self, instance, validated_data): return instance -class RequestUserPasswordResetSerializer(_UserSerializer): +class RequestUserPasswordResetSerializer(_UserSerializer[User]): class Meta(_UserSerializer.Meta): extra_kwargs = { **_UserSerializer.Meta.extra_kwargs, @@ -358,3 +353,32 @@ def update(self, instance: User, validated_data: DataDict): instance.save(update_fields=["password"]) return instance + + +class VerifyUserEmailAddressSerializer(_UserSerializer[User]): + token = serializers.CharField(write_only=True) + + class Meta(_UserSerializer.Meta): + fields = [*_UserSerializer.Meta.fields, "token"] + + def validate_token(self, value: str): + if not self.instance: + raise serializers.ValidationError( + "Can only verify the email address of an existing user.", + code="user_does_not_exist", + ) + if not email_verification_token_generator.check_token( + self.instance, value + ): + raise serializers.ValidationError( + "Does not match the given user.", + code="does_not_match", + ) + + return value + + def update(self, instance, validated_data): + instance.userprofile.is_verified = True + instance.userprofile.save(update_fields=["is_verified"]) + + return instance diff --git a/backend/api/serializers/user_test.py b/backend/api/serializers/user_test.py index c4faddda..0c9f0972 100644 --- a/backend/api/serializers/user_test.py +++ b/backend/api/serializers/user_test.py @@ -15,11 +15,8 @@ User, ) from django.contrib.auth.hashers import make_password -from django.contrib.auth.tokens import ( - PasswordResetTokenGenerator, - default_token_generator, -) +from ..auth import password_reset_token_generator from .user import ( BaseUserSerializer, CreateUserSerializer, @@ -27,14 +24,9 @@ RequestUserPasswordResetSerializer, ResetUserPasswordSerializer, UpdateUserSerializer, + VerifyUserEmailAddressSerializer, ) -# NOTE: type hint to help Intellisense. -password_reset_token_generator: PasswordResetTokenGenerator = ( - default_token_generator -) - - # pylint: disable=missing-class-docstring @@ -424,3 +416,37 @@ def test_update(self): user_make_password.assert_called_once_with(password) assert self.user.check_password(password) + + +class TestVerifyUserEmailAddressSerializer(ModelSerializerTestCase[User, User]): + model_serializer_class = VerifyUserEmailAddressSerializer + # fixtures = ["school_1"] + + def setUp(self): + user = User.objects.filter(userprofile__is_verified=False).first() + assert user + self.user = user + + def test_validate_token__user_does_not_exist(self): + """Cannot validate the token of a user that does not exist.""" + self.assert_validate_field( + name="token", + error_code="user_does_not_exist", + ) + + def test_validate_token__does_not_match(self): + """The token must match the user's tokens.""" + self.assert_validate_field( + name="token", + error_code="does_not_match", + value="invalid-token", + instance=self.user, + ) + + def test_update(self): + """Can successfully reset a user's password.""" + self.assert_update( + instance=self.user, + validated_data={}, + new_data={"userprofile": {"is_verified": True}}, + ) diff --git a/backend/api/views/cron/__init__.py b/backend/api/views/cron/__init__.py deleted file mode 100644 index e8d2671f..00000000 --- a/backend/api/views/cron/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -# from rest_framework.test import APIClient, APITestCase - - -# class CronTestClient(APIClient): -# def __init__(self, *args, **kwargs): -# super().__init__(*args, **kwargs, HTTP_X_APPENGINE_CRON="true") - -# def generic( -# self, -# method, -# path, -# data="", -# content_type="application/octet-stream", -# secure=False, -# **extra, -# ): -# wsgi_response = super().generic( -# method, path, data, content_type, secure, **extra -# ) -# assert ( -# 200 <= wsgi_response.status_code < 300 -# ), f"Response has error status code: {wsgi_response.status_code}" - -# return wsgi_response - - -# class CronTestCase(APITestCase): -# client_class = CronTestClient - -# TODO: clean up diff --git a/backend/api/views/cron/test_user.py b/backend/api/views/cron/test_user.py deleted file mode 100644 index 209aa220..00000000 --- a/backend/api/views/cron/test_user.py +++ /dev/null @@ -1,277 +0,0 @@ -# pylint: skip-file - -# from datetime import timedelta -# from unittest.mock import patch, Mock, ANY - -# from common.helpers.emails import NOTIFICATION_EMAIL -# from common.models import UserProfile, Student, Teacher -# from common.tests.utils.classes import create_class_directly -# from common.tests.utils.organisation import create_organisation_directly -# from common.tests.utils.student import ( -# create_school_student_directly, -# create_independent_student_directly, -# ) -# from common.tests.utils.teacher import signup_teacher_directly -# from django.contrib.auth.models import User -# from django.urls import reverse -# from django.utils import timezone - -# from . import CronTestCase -# from ....emails import NOTIFICATION_EMAIL -# from ....views.cron import USER_DELETE_UNVERIFIED_ACCOUNT_DAYS - - -# class TestUser(CronTestCase): -# # TODO: use fixtures -# def setUp(self): -# teacher_email, _ = signup_teacher_directly(preverified=False) -# create_organisation_directly(teacher_email) -# _, _, access_code = create_class_directly(teacher_email) -# _, _, student = create_school_student_directly(access_code) -# indy_email, _, _ = create_independent_student_directly() - -# self.teacher_user = User.objects.get(email=teacher_email) -# self.teacher_user_profile = UserProfile.objects.get( -# user=self.teacher_user -# ) - -# self.indy_user = User.objects.get(email=indy_email) -# self.indy_user_profile = UserProfile.objects.get(user=self.indy_user) - -# self.student_user: User = student.new_user - -# def send_verify_email_reminder( -# self, -# days: int, -# is_verified: bool, -# view_name: str, -# send_email: Mock, -# assert_called: bool, -# ): -# self.teacher_user.date_joined = timezone.now() - timedelta( -# days=days, hours=12 -# ) -# self.teacher_user.save() -# self.student_user.date_joined = timezone.now() - timedelta( -# days=days, hours=12 -# ) -# self.student_user.save() -# self.indy_user.date_joined = timezone.now() - timedelta( -# days=days, hours=12 -# ) -# self.indy_user.save() - -# self.teacher_user_profile.is_verified = is_verified -# self.teacher_user_profile.save() -# self.indy_user_profile.is_verified = is_verified -# self.indy_user_profile.save() - -# self.client.get(reverse(view_name)) - -# if assert_called: -# send_email.assert_any_call( -# sender=NOTIFICATION_EMAIL, -# recipients=[self.teacher_user.email], -# subject=ANY, -# title=ANY, -# text_content=ANY, -# replace_url=ANY, -# ) - -# send_email.assert_any_call( -# sender=NOTIFICATION_EMAIL, -# recipients=[self.indy_user.email], -# subject=ANY, -# title=ANY, -# text_content=ANY, -# replace_url=ANY, -# ) - -# # Check only two emails are sent - the student should never be included. -# assert send_email.call_count == 2 -# else: -# send_email.assert_not_called() - -# send_email.reset_mock() - -# @patch("portal.views.cron.user.send_email") -# def test_first_verify_email_reminder_view(self, send_email: Mock): -# self.send_verify_email_reminder( -# days=6, -# is_verified=False, -# view_name="first-verify-email-reminder", -# send_email=send_email, -# assert_called=False, -# ) -# self.send_verify_email_reminder( -# days=7, -# is_verified=False, -# view_name="first-verify-email-reminder", -# send_email=send_email, -# assert_called=True, -# ) -# self.send_verify_email_reminder( -# days=7, -# is_verified=True, -# view_name="first-verify-email-reminder", -# send_email=send_email, -# assert_called=False, -# ) -# self.send_verify_email_reminder( -# days=8, -# is_verified=False, -# view_name="first-verify-email-reminder", -# send_email=send_email, -# assert_called=False, -# ) - -# @patch("portal.views.cron.user.send_email") -# def test_second_verify_email_reminder_view(self, send_email: Mock): -# self.send_verify_email_reminder( -# days=13, -# is_verified=False, -# view_name="second-verify-email-reminder", -# send_email=send_email, -# assert_called=False, -# ) -# self.send_verify_email_reminder( -# days=14, -# is_verified=False, -# view_name="second-verify-email-reminder", -# send_email=send_email, -# assert_called=True, -# ) -# self.send_verify_email_reminder( -# days=14, -# is_verified=True, -# view_name="second-verify-email-reminder", -# send_email=send_email, -# assert_called=False, -# ) -# self.send_verify_email_reminder( -# days=15, -# is_verified=False, -# view_name="second-verify-email-reminder", -# send_email=send_email, -# assert_called=False, -# ) - -# def test_delete_unverified_accounts_view(self): -# now = timezone.now() - -# for user in [self.teacher_user, self.indy_user, self.student_user]: -# user.date_joined = now - timedelta( -# days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS + 1 -# ) -# user.save() - -# for user_profile in [self.teacher_user_profile, self.indy_user_profile]: -# user_profile.is_verified = True -# user_profile.save() - -# def delete_unverified_users( -# days: int, -# is_verified: bool, -# assert_exists: bool, -# ): -# date_joined = now - timedelta(days=days, hours=12) - -# # Create teacher. -# teacher_user = User.objects.create( -# first_name="Unverified", -# last_name="Teacher", -# username="unverified.teacher@codeforlife.com", -# email="unverified.teacher@codeforlife.com", -# date_joined=date_joined, -# ) -# teacher_user_profile = UserProfile.objects.create( -# user=teacher_user, -# is_verified=is_verified, -# ) -# Teacher.objects.create( -# user=teacher_user_profile, -# new_user=teacher_user, -# school=self.teacher_user.new_teacher.school, -# ) - -# # Create dependent student. -# student_user = User.objects.create( -# first_name="Unverified", -# last_name="DependentStudent", -# username="UnverifiedDependentStudent", -# date_joined=date_joined, -# ) -# student_user_profile = UserProfile.objects.create( -# user=student_user, -# ) -# Student.objects.create( -# user=student_user_profile, -# new_user=student_user, -# class_field=self.student_user.new_student.class_field, -# ) - -# # Create independent student. -# indy_user = User.objects.create( -# first_name="Unverified", -# last_name="IndependentStudent", -# username="unverified.independentstudent@codeforlife.com", -# email="unverified.independentstudent@codeforlife.com", -# date_joined=date_joined, -# ) -# indy_user_profile = UserProfile.objects.create( -# user=indy_user, -# is_verified=is_verified, -# ) -# Student.objects.create( -# user=indy_user_profile, -# new_user=indy_user, -# ) - -# self.client.get(reverse("delete-unverified-accounts")) - -# # Assert the verified users and teach -# assert User.objects.filter(id=self.teacher_user.id).exists() -# assert User.objects.filter(id=self.student_user.id).exists() -# assert User.objects.filter(id=self.indy_user.id).exists() - -# teacher_user_exists = User.objects.filter( -# id=teacher_user.id -# ).exists() -# indy_user_exists = User.objects.filter(id=indy_user.id).exists() -# student_user_exists = User.objects.filter( -# id=student_user.id -# ).exists() - -# assert teacher_user_exists == assert_exists -# assert indy_user_exists == assert_exists -# assert student_user_exists - -# if teacher_user_exists: -# teacher_user.delete() -# if indy_user_exists: -# indy_user.delete() -# if student_user_exists: -# student_user.delete() - -# delete_unverified_users( -# days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS - 1, -# is_verified=False, -# assert_exists=True, -# ) -# delete_unverified_users( -# days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS, -# is_verified=False, -# assert_exists=False, -# ) -# delete_unverified_users( -# days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS, -# is_verified=True, -# assert_exists=True, -# ) -# delete_unverified_users( -# days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS + 1, -# is_verified=False, -# assert_exists=False, -# ) - -# TODO: clean up diff --git a/backend/api/views/user.py b/backend/api/views/user.py index 94e72dc7..597ca976 100644 --- a/backend/api/views/user.py +++ b/backend/api/views/user.py @@ -2,22 +2,37 @@ © Ocado Group Created on 23/01/2024 at 17:53:44(+00:00). """ -from codeforlife.permissions import OR, AllowAny, AllowNone +import logging +from datetime import timedelta +from urllib.parse import urlencode + +from codeforlife.mail import send_mail +from codeforlife.permissions import ( + OR, + AllowAny, + AllowNone, + IsCronRequestFromGoogle, +) from codeforlife.request import Request from codeforlife.response import Response from codeforlife.user.models import User from codeforlife.user.permissions import IsIndependent, IsTeacher from codeforlife.user.views import UserViewSet as _UserViewSet -from codeforlife.views import action +from codeforlife.views import action, cron_job +from django.conf import settings +from django.db.models import F +from django.utils import timezone from rest_framework import status from rest_framework.serializers import ValidationError +from ..auth import email_verification_token_generator from ..serializers import ( CreateUserSerializer, HandleIndependentUserJoinClassRequestSerializer, RequestUserPasswordResetSerializer, ResetUserPasswordSerializer, UpdateUserSerializer, + VerifyUserEmailAddressSerializer, ) @@ -33,6 +48,7 @@ def get_permissions(self): "create", "request_password_reset", "reset_password", + "verify_email_address", ]: return [AllowAny()] if self.action == "handle_join_class_request": @@ -42,6 +58,12 @@ def get_permissions(self): and "requesting_to_join_class" in self.request.data ): return [IsIndependent()] + if self.action in [ + "send_1st_verify_email_reminder", + "send_2nd_verify_email_reminder", + "anonymize_unverified_accounts", + ]: + return [IsCronRequestFromGoogle()] return super().get_permissions() @@ -52,6 +74,7 @@ def get_serializer_context(self): return context + # pylint: disable-next=too-many-return-statements def get_serializer_class(self): if self.action == "create": return CreateUserSerializer @@ -63,11 +86,13 @@ def get_serializer_class(self): return ResetUserPasswordSerializer if self.action == "handle_join_class_request": return HandleIndependentUserJoinClassRequestSerializer + if self.action == "verify_email_address": + return VerifyUserEmailAddressSerializer return super().get_serializer_class() def get_queryset(self, user_class=User): - if self.action == "reset_password": + if self.action in ["reset_password", "verify_email_address"]: return User.objects.filter(pk=self.kwargs["pk"]) if self.action == "handle_join_class_request": return self.request.school_teacher_user.teacher.indy_users @@ -113,3 +138,141 @@ def request_password_reset(self, request: Request): handle_join_class_request = _UserViewSet.update_action( "handle_join_class_request" ) + verify_email_address = _UserViewSet.update_action("verify_email_address") + + def _get_unverified_users(self, days: int, same_day: bool): + now = timezone.now() + + # All expired unverified users. + user_queryset = User.objects.filter( + date_joined__lte=now - timedelta(days=days), + userprofile__is_verified=False, + ) + if same_day: + user_queryset = user_queryset.filter( + date_joined__gt=now - timedelta(days=days + 1) + ) + + teacher_queryset = user_queryset.filter( + new_teacher__isnull=False, + new_student__isnull=True, + ) + independent_student_queryset = user_queryset.filter( + new_teacher__isnull=True, + new_student__class_field__isnull=True, + ) + + return teacher_queryset, independent_student_queryset + + def _send_verify_email_reminder(self, days: int, campaign_name: str): + teacher_queryset, indy_queryset = self._get_unverified_users( + days, same_day=True + ) + + user_queryset = teacher_queryset.union(indy_queryset) + user_count = user_queryset.count() + + logging.info("%d emails unverified.", user_count) + + if user_count > 0: + sent_email_count = 0 + for user_fields in user_queryset.values("id", "email").iterator( + chunk_size=500 + ): + url = f"{settings.SERVICE_BASE_URL}/?" + urlencode( + { + "token": email_verification_token_generator.make_token( + user_fields["id"] + ) + } + ) + + try: + send_mail( + campaign_id=settings.DOTDIGITAL_CAMPAIGN_IDS[ + campaign_name + ], + to_addresses=[user_fields["email"]], + personalization_values={"VERIFICATION_LINK": url}, + ) + + sent_email_count += 1 + # pylint: disable-next=broad-exception-caught + except Exception as ex: + logging.exception(ex) + + logging.info("Sent %d/%d emails.", sent_email_count, user_count) + + return Response() + + @cron_job + def send_1st_verify_email_reminder(self, request: Request): + """ + Send the first reminder email to all users who have not verified their + email address. + """ + return self._send_verify_email_reminder( + days=7, campaign_name="verify_email_address_1st_reminder" + ) + + @cron_job + def send_2nd_verify_email_reminder(self, request: Request): + """ + Send the second reminder email to all users who have not verified their + email address. + """ + return self._send_verify_email_reminder( + days=14, campaign_name="verify_email_address_2nd_reminder" + ) + + @cron_job + def anonymize_unverified_accounts(self, request: Request): + """Anonymize all users who have not verified their email address.""" + user_queryset = User.objects.filter(is_active=True) + user_count = user_queryset.count() + + teacher_queryset, indy_queryset = self._get_unverified_users( + days=int(request.query_params.get("days", 19)), + same_day=False, + ) + teacher_count = teacher_queryset.count() + indy_count = indy_queryset.count() + + for user in teacher_queryset.union(indy_queryset).iterator( + chunk_size=100 + ): + try: + user.anonymize() + # pylint: disable-next=broad-exception-caught + except Exception as ex: + logging.error("Failed to anonymise user with id: %d", user.id) + logging.exception(ex) + + logging.info( + "%d unverified users anonymised.", + user_count - user_queryset.count(), + ) + + # Use data warehouse in new system. + # pylint: disable-next=import-outside-toplevel + from common.models import ( # type: ignore[import-untyped] + DailyActivity, + TotalActivity, + ) + + activity_today = DailyActivity.objects.get_or_create( + date=timezone.now().date() + )[0] + activity_today.anonymised_unverified_teachers = teacher_count + activity_today.anonymised_unverified_independents = indy_count + activity_today.save() + TotalActivity.objects.update( + anonymised_unverified_teachers=F("anonymised_unverified_teachers") + + teacher_count, + anonymised_unverified_independents=F( + "anonymised_unverified_independents" + ) + + indy_count, + ) + + return Response() diff --git a/backend/api/views/user_test.py b/backend/api/views/user_test.py index 8862523c..7069d0b9 100644 --- a/backend/api/views/user_test.py +++ b/backend/api/views/user_test.py @@ -3,8 +3,15 @@ Created on 20/01/2024 at 10:58:52(+00:00). """ import typing as t - -from codeforlife.permissions import OR, AllowAny, AllowNone +from datetime import timedelta +from unittest.mock import call, patch + +from codeforlife.permissions import ( + OR, + AllowAny, + AllowNone, + IsCronRequestFromGoogle, +) from codeforlife.tests import ModelViewSetTestCase from codeforlife.user.models import ( AdminSchoolTeacherUser, @@ -12,17 +19,26 @@ IndependentUser, NonAdminSchoolTeacherUser, NonSchoolTeacherUser, + School, SchoolTeacherUser, + Student, + StudentUser, + Teacher, + TeacherUser, TypedUser, User, + UserProfile, ) from codeforlife.user.permissions import IsIndependent, IsTeacher from codeforlife.user.serializers import UserSerializer +from django.conf import settings from django.contrib.auth.tokens import ( PasswordResetTokenGenerator, default_token_generator, ) +from django.utils import timezone +from ..auth import email_verification_token_generator from ..serializers import ( CreateUserSerializer, HandleIndependentUserJoinClassRequestSerializer, @@ -119,6 +135,27 @@ def test_get_permissions__reset_password(self): action="reset_password", ) + def test_get_permissions__send_1st_verify_email_reminder(self): + """Only Google can send the 1st verify email reminder.""" + self.assert_get_permissions( + permissions=[IsCronRequestFromGoogle()], + action="send_1st_verify_email_reminder", + ) + + def test_get_permissions__send_2nd_verify_email_reminder(self): + """Only Google can send the 2nd verify email reminder.""" + self.assert_get_permissions( + permissions=[IsCronRequestFromGoogle()], + action="send_2nd_verify_email_reminder", + ) + + def test_get_permissions__anonymize_unverified_accounts(self): + """Only Google can anonymize unverified accounts.""" + self.assert_get_permissions( + permissions=[IsCronRequestFromGoogle()], + action="anonymize_unverified_accounts", + ) + # test: get queryset def _test_get_queryset__handle_join_class_request( @@ -321,6 +358,20 @@ def test_reset_password__token_and_password(self): self._test_reset_password(user, password) self.client.login_as(user, password) + def test_verify_email_address(self): + """Can verify the user's email address.""" + user = User.objects.filter(userprofile__is_verified=False).first() + assert user + + self.client.update( + user, + action="verify_email_address", + data={"token": email_verification_token_generator.make_token(user)}, + ) + + user.refresh_from_db() + assert user.userprofile.is_verified + # test: generic actions def test_partial_update(self): @@ -385,6 +436,20 @@ def test_partial_update__indy__revoke_join_request(self): self.indy_user, {"requesting_to_join_class": None} ) + def is_anonymized(self, user: User): + """Check if a user is anonymized. + + Args: + user: The user to check. + """ + user.refresh_from_db() + return ( + user.first_name == "" + and user.last_name == "" + and user.email == "" + and not user.is_active + ) + def test_destroy(self): """Independent-users can anonymize themselves.""" user = self.indy_user @@ -392,8 +457,177 @@ def test_destroy(self): self.client.login_as(user) self.client.destroy(user, make_assertions=False) - user.refresh_from_db() - assert user.first_name == "" - assert user.last_name == "" - assert user.email == "" - assert not user.is_active + assert self.is_anonymized(user) + + # test: cron actions + + def _test_send_verify_email_reminder( + self, action: str, days: int, campaign_name: str + ): + def test_send_verify_email_reminder( + days: int, is_verified: bool, mail_sent: bool + ): + date_joined = timezone.now() - timedelta(days, hours=12) + + assert StudentUser.objects.update(date_joined=date_joined) + + teacher_users = list(TeacherUser.objects.all()) + assert teacher_users + indy_users = list(IndependentUser.objects.all()) + assert indy_users + for user in teacher_users + indy_users: + user.date_joined = date_joined + user.save() + user.userprofile.is_verified = is_verified + user.userprofile.save() + + with patch( + "api.views.user.email_verification_token_generator.make_token", + side_effect=lambda user_id: user_id, + ) as make_token: + with patch("api.views.user.send_mail") as send_mail: + self.client.cron_job(action) + + if mail_sent: + make_token.assert_has_calls( + [ + call(user.id) + for user in teacher_users + indy_users + ], + any_order=True, + ) + send_mail.assert_has_calls( + [ + call( + campaign_id=( + settings.DOTDIGITAL_CAMPAIGN_IDS[ + campaign_name + ] + ), + to_addresses=[user.email], + personalization_values={ + # pylint: disable-next=line-too-long + "VERIFICATION_LINK": f"http://localhost:8000/?token={user.id}" + }, + ) + for user in teacher_users + indy_users + ], + any_order=True, + ) + else: + make_token.assert_not_called() + send_mail.assert_not_called() + + test_send_verify_email_reminder( + days=days - 1, + is_verified=False, + mail_sent=False, + ) + test_send_verify_email_reminder( + days=days, + is_verified=False, + mail_sent=True, + ) + test_send_verify_email_reminder( + days=days, + is_verified=True, + mail_sent=False, + ) + test_send_verify_email_reminder( + days=days + 1, + is_verified=False, + mail_sent=False, + ) + + def test_send_1st_verify_email_reminder(self): + """Can send the 1st verify email reminder.""" + self._test_send_verify_email_reminder( + action="send_1st_verify_email_reminder", + days=7, + campaign_name="verify_email_address_1st_reminder", + ) + + def test_send_2nd_verify_email_reminder(self): + """Can send the 2nd verify email reminder.""" + self._test_send_verify_email_reminder( + action="send_2nd_verify_email_reminder", + days=14, + campaign_name="verify_email_address_2nd_reminder", + ) + + def test_anonymize_unverified_accounts(self): + """Can anonymize unverified accounts.""" + + def anonymize_unverified_users( + days: int, is_verified: bool, is_anonymized: bool + ): + date_joined = timezone.now() - timedelta(days=days, hours=12) + + assert StudentUser.objects.update(date_joined=date_joined) + + # Create teacher user. + teacher_user = User.objects.create( + first_name="Unverified", + last_name="Teacher", + username="unverified.teacher@codeforlife.com", + email="unverified.teacher@codeforlife.com", + date_joined=date_joined, + ) + teacher_user_profile = UserProfile.objects.create( + user=teacher_user, + is_verified=is_verified, + ) + Teacher.objects.create( + user=teacher_user_profile, + new_user=teacher_user, + school=School.objects.get(name="School 1"), + ) + + # Create independent user. + indy_user = User.objects.create( + first_name="Unverified", + last_name="IndependentStudent", + username="unverified.independentstudent@codeforlife.com", + email="unverified.independentstudent@codeforlife.com", + date_joined=date_joined, + ) + indy_user_profile = UserProfile.objects.create( + user=indy_user, + is_verified=is_verified, + ) + Student.objects.create( + user=indy_user_profile, + new_user=indy_user, + ) + + self.client.cron_job("anonymize_unverified_accounts") + + for student_user in StudentUser.objects.all(): + assert not self.is_anonymized(student_user) + + assert is_anonymized == self.is_anonymized(teacher_user) + assert is_anonymized == self.is_anonymized(indy_user) + + teacher_user.delete() + indy_user.delete() + + anonymize_unverified_users( + days=18, + is_verified=False, + is_anonymized=False, + ) + anonymize_unverified_users( + days=19, + is_verified=False, + is_anonymized=True, + ) + anonymize_unverified_users( + days=19, + is_verified=True, + is_anonymized=False, + ) + anonymize_unverified_users( + days=20, + is_verified=False, + is_anonymized=True, + ) diff --git a/backend/service/settings.py b/backend/service/settings.py index 8d79df3a..2a3155d7 100644 --- a/backend/service/settings.py +++ b/backend/service/settings.py @@ -12,6 +12,15 @@ import os from pathlib import Path +# Custom + +EMAIL_VERIFICATION_TIMEOUT = 60 * 60 * 24 + +DOTDIGITAL_CAMPAIGN_IDS = { + "verify_email_address_1st_reminder": 0, # TODO: set correct id + "verify_email_address_2nd_reminder": 0, # TODO: set correct id +} + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent