diff --git a/.devcontainer.json b/.devcontainer.json index 5138b74b..e8da9421 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -19,7 +19,9 @@ "qwtel.sqlite-viewer", "njpwerner.autodocstring", "tamasfe.even-better-toml", - "github.vscode-github-actions" + "github.vscode-github-actions", + "codecov.codecov", + "ritwickdey.liveserver" ] } }, diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2e927468..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: @@ -18,6 +15,7 @@ env: jobs: test-backend: uses: ocadotechnology/codeforlife-workspace/.github/workflows/test-python-code.yaml@main + secrets: inherit with: working-directory: backend diff --git a/.vscode/launch.json b/.vscode/launch.json index 44da6867..dbdb5382 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,6 +9,9 @@ "type": "debugpy" }, { + "env": { + "PYTEST_ADDOPTS": "--no-cov" + }, "justMyCode": false, "name": "Pytest", "presentation": { diff --git a/.vscode/settings.json b/.vscode/settings.json index 765e605f..01ff8e70 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -70,6 +70,8 @@ "python.testing.cwd": "${workspaceFolder}/backend", "python.testing.pytestArgs": [ "-n=auto", + "--cov=.", + "--cov-report=html", "-c=pyproject.toml", "." ], diff --git a/backend/Pipfile b/backend/Pipfile index 0ad95776..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.6", 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.6", 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 0e7473e5..240cfe89 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8ec547ceb0dde09aad74ce2c448c64b647ef8a6f4453be420d32934a3c9d7008" + "sha256": "aebe5fbe238776c581ef4026e3c79beb2103a8aa1f146ee02b63251e7b0f8693" }, "pipfile-spec": 6, "requires": { @@ -168,7 +168,7 @@ }, "codeforlife": { "git": "https://github.com/ocadotechnology/codeforlife-package-python.git", - "ref": "a61b0ed1b7f6eb4d7d564201288b6610513532db" + "ref": "97ff9647c9a212dd276a765ea111a8e08012e625" }, "codeforlife-portal": { "hashes": [ @@ -501,62 +501,54 @@ }, "grpcio": { "hashes": [ - "sha256:07ce1f775d37ca18c7a141300e5b71539690efa1f51fe17f812ca85b5e73262f", - "sha256:112eaa7865dd9e6d7c0556c8b04ae3c3a2dc35d62ad3373ab7f6a562d8199200", - "sha256:162ccf61499c893831b8437120600290a99c0bc1ce7b51f2c8d21ec87ff6af8b", - "sha256:16da954692fd61aa4941fbeda405a756cd96b97b5d95ca58a92547bba2c1624f", - "sha256:17708db5b11b966373e21519c4c73e5a750555f02fde82276ea2a267077c68ad", - "sha256:1bcfe5070e4406f489e39325b76caeadab28c32bf9252d3ae960c79935a4cc36", - "sha256:1c1bb80299bdef33309dff03932264636450c8fdb142ea39f47e06a7153d3063", - "sha256:2507006c8a478f19e99b6fe36a2464696b89d40d88f34e4b709abe57e1337467", - "sha256:262cda97efdabb20853d3b5a4c546a535347c14b64c017f628ca0cc7fa780cc6", - "sha256:26f415f40f4a93579fd648f48dca1c13dfacdfd0290f4a30f9b9aeb745026811", - "sha256:2a0204532aa2f1afd467024b02b4069246320405bc18abec7babab03e2644e75", - "sha256:2e72ddfee62430ea80133d2cbe788e0d06b12f865765cb24a40009668bd8ea05", - "sha256:3abe6838196da518863b5d549938ce3159d809218936851b395b09cad9b5d64a", - "sha256:3ad00f3f0718894749d5a8bb0fa125a7980a2f49523731a9b1fabf2b3522aa43", - "sha256:3c3ed41f4d7a3aabf0f01ecc70d6b5d00ce1800d4af652a549de3f7cf35c4abd", - "sha256:404d3b4b6b142b99ba1cff0b2177d26b623101ea2ce51c25ef6e53d9d0d87bcc", - "sha256:41955b641c34db7d84db8d306937b72bc4968eef1c401bea73081a8d6c3d8033", - "sha256:53d3a59a10af4c2558a8e563aed9f256259d2992ae0d3037817b2155f0341de1", - "sha256:55ddaf53474e8caeb29eb03e3202f9d827ad3110475a21245f3c7712022882a9", - "sha256:589ea8e75de5fd6df387de53af6c9189c5231e212b9aa306b6b0d4f07520fbb9", - "sha256:5dab7ac2c1e7cb6179c6bfad6b63174851102cbe0682294e6b1d6f0981ad7138", - "sha256:65034473fc09628a02fb85f26e73885cf1ed39ebd9cf270247b38689ff5942c5", - "sha256:66344ea741124c38588a664237ac2fa16dfd226964cca23ddc96bd4accccbde5", - "sha256:6e784f60e575a0de554ef9251cbc2ceb8790914fe324f11e28450047f264ee6f", - "sha256:80407bc007754f108dc2061e37480238b0dc1952c855e86a4fc283501ee6bb5d", - "sha256:82af3613a219512a28ee5c95578eb38d44dd03bca02fd918aa05603c41018051", - "sha256:88b4f9ee77191dcdd8810241e89340a12cbe050be3e0d5f2f091c15571cd3930", - "sha256:99701979bcaaa7de8d5f60476487c5df8f27483624f1f7e300ff4669ee44d1f2", - "sha256:a1511a303f8074f67af4119275b4f954189e8313541da7b88b1b3a71425cdb10", - "sha256:a5eb4844e5e60bf2c446ef38c5b40d7752c6effdee882f716eb57ae87255d20a", - "sha256:a75af2fc7cb1fe25785be7bed1ab18cef959a376cdae7c6870184307614caa3f", - "sha256:a90ac47a8ce934e2c8d71e317d2f9e7e6aaceb2d199de940ce2c2eb611b8c0f4", - "sha256:aa787b83a3cd5e482e5c79be030e2b4a122ecc6c5c6c4c42a023a2b581fdf17b", - "sha256:aaae70364a2d1fb238afd6cc9fcb10442b66e397fd559d3f0968d28cc3ac929c", - "sha256:af15e9efa4d776dfcecd1d083f3ccfb04f876d613e90ef8432432efbeeac689d", - "sha256:af7dc3f7a44f10863b1b0ecab4078f0a00f561aae1edbd01fd03ad4dcf61c9e9", - "sha256:b7ec9e2f8ffc8436f6b642a10019fc513722858f295f7efc28de135d336ac189", - "sha256:b94d41b7412ef149743fbc3178e59d95228a7064c5ab4760ae82b562bdffb199", - "sha256:c1624aa686d4b36790ed1c2e2306cc3498778dffaf7b8dd47066cf819028c3ad", - "sha256:c5ffeb269f10cedb4f33142b89a061acda9f672fd1357331dbfd043422c94e9e", - "sha256:c6ad9c39704256ed91a1cffc1379d63f7d0278d6a0bad06b0330f5d30291e3a3", - "sha256:c772f225483905f675cb36a025969eef9712f4698364ecd3a63093760deea1bc", - "sha256:c77618071d96b7a8be2c10701a98537823b9c65ba256c0b9067e0594cdbd954d", - "sha256:c79b518c56dddeec79e5500a53d8a4db90da995dfe1738c3ac57fe46348be049", - "sha256:cfd23ad29bfa13fd4188433b0e250f84ec2c8ba66b14a9877e8bce05b524cf54", - "sha256:d0695ae31a89f1a8fc8256050329a91a9995b549a88619263a594ca31b76d756", - "sha256:d2c1771d0ee3cf72d69bb5e82c6a82f27fbd504c8c782575eddb7839729fbaad", - "sha256:da6a7b6b938c15fa0f0568e482efaae9c3af31963eec2da4ff13a6d8ec2888e4", - "sha256:db068bbc9b1fa16479a82e1ecf172a93874540cb84be69f0b9cb9b7ac3c82670", - "sha256:db707e3685ff16fc1eccad68527d072ac8bdd2e390f6daa97bc394ea7de4acea", - "sha256:e2cc8a308780edbe2c4913d6a49dbdb5befacdf72d489a368566be44cadaef1a", - "sha256:f27246d7da7d7e3bd8612f63785a7b0c39a244cf14b8dd9dd2f2fab939f2d7f1", - "sha256:f4aa94361bb5141a45ca9187464ae81a92a2a135ce2800b2203134f7a1a1d479", - "sha256:fa63245271920786f4cb44dcada4983a3516be8f470924528cf658731864c14b" - ], - "version": "==1.62.2" + "sha256:01799e8649f9e94ba7db1aeb3452188048b0019dc37696b0f5ce212c87c560c3", + "sha256:0697563d1d84d6985e40ec5ec596ff41b52abb3fd91ec240e8cb44a63b895094", + "sha256:08e1559fd3b3b4468486b26b0af64a3904a8dbc78d8d936af9c1cf9636eb3e8b", + "sha256:166e5c460e5d7d4656ff9e63b13e1f6029b122104c1633d5f37eaea348d7356d", + "sha256:1ff737cf29b5b801619f10e59b581869e32f400159e8b12d7a97e7e3bdeee6a2", + "sha256:219bb1848cd2c90348c79ed0a6b0ea51866bc7e72fa6e205e459fedab5770172", + "sha256:259e11932230d70ef24a21b9fb5bb947eb4703f57865a404054400ee92f42f5d", + "sha256:2e93aca840c29d4ab5db93f94ed0a0ca899e241f2e8aec6334ab3575dc46125c", + "sha256:3a6d1f9ea965e750db7b4ee6f9fdef5fdf135abe8a249e75d84b0a3e0c668a1b", + "sha256:50344663068041b34a992c19c600236e7abb42d6ec32567916b87b4c8b8833b3", + "sha256:56cdf96ff82e3cc90dbe8bac260352993f23e8e256e063c327b6cf9c88daf7a9", + "sha256:5c039ef01516039fa39da8a8a43a95b64e288f79f42a17e6c2904a02a319b357", + "sha256:6426e1fb92d006e47476d42b8f240c1d916a6d4423c5258ccc5b105e43438f61", + "sha256:65bf975639a1f93bee63ca60d2e4951f1b543f498d581869922910a476ead2f5", + "sha256:6a1a3642d76f887aa4009d92f71eb37809abceb3b7b5a1eec9c554a246f20e3a", + "sha256:6ef0ad92873672a2a3767cb827b64741c363ebaa27e7f21659e4e31f4d750280", + "sha256:756fed02dacd24e8f488f295a913f250b56b98fb793f41d5b2de6c44fb762434", + "sha256:75f701ff645858a2b16bc8c9fc68af215a8bb2d5a9b647448129de6e85d52bce", + "sha256:8064d986d3a64ba21e498b9a376cbc5d6ab2e8ab0e288d39f266f0fca169b90d", + "sha256:878b1d88d0137df60e6b09b74cdb73db123f9579232c8456f53e9abc4f62eb3c", + "sha256:8f3f6883ce54a7a5f47db43289a0a4c776487912de1a0e2cc83fdaec9685cc9f", + "sha256:91b73d3f1340fefa1e1716c8c1ec9930c676d6b10a3513ab6c26004cb02d8b3f", + "sha256:93a46794cc96c3a674cdfb59ef9ce84d46185fe9421baf2268ccb556f8f81f57", + "sha256:93f45f27f516548e23e4ec3fbab21b060416007dbe768a111fc4611464cc773f", + "sha256:9e350cb096e5c67832e9b6e018cf8a0d2a53b2a958f6251615173165269a91b0", + "sha256:a2d60cd1d58817bc5985fae6168d8b5655c4981d448d0f5b6194bbcc038090d2", + "sha256:a3abfe0b0f6798dedd2e9e92e881d9acd0fdb62ae27dcbbfa7654a57e24060c0", + "sha256:a44624aad77bf8ca198c55af811fd28f2b3eaf0a50ec5b57b06c034416ef2d0a", + "sha256:a7b19dfc74d0be7032ca1eda0ed545e582ee46cd65c162f9e9fc6b26ef827dc6", + "sha256:ad2ac8903b2eae071055a927ef74121ed52d69468e91d9bcbd028bd0e554be6d", + "sha256:b005292369d9c1f80bf70c1db1c17c6c342da7576f1c689e8eee4fb0c256af85", + "sha256:b2e44f59316716532a993ca2966636df6fbe7be4ab6f099de6815570ebe4383a", + "sha256:b3afbd9d6827fa6f475a4f91db55e441113f6d3eb9b7ebb8fb806e5bb6d6bd0d", + "sha256:b416252ac5588d9dfb8a30a191451adbf534e9ce5f56bb02cd193f12d8845b7f", + "sha256:b5194775fec7dc3dbd6a935102bb156cd2c35efe1685b0a46c67b927c74f0cfb", + "sha256:cacdef0348a08e475a721967f48206a2254a1b26ee7637638d9e081761a5ba86", + "sha256:cd1e68776262dd44dedd7381b1a0ad09d9930ffb405f737d64f505eb7f77d6c7", + "sha256:cdcda1156dcc41e042d1e899ba1f5c2e9f3cd7625b3d6ebfa619806a4c1aadda", + "sha256:cf8dae9cc0412cb86c8de5a8f3be395c5119a370f3ce2e69c8b7d46bb9872c8d", + "sha256:d2497769895bb03efe3187fb1888fc20e98a5f18b3d14b606167dacda5789434", + "sha256:e3b77eaefc74d7eb861d3ffbdf91b50a1bb1639514ebe764c47773b833fa2d91", + "sha256:e48cee31bc5f5a31fb2f3b573764bd563aaa5472342860edcc7039525b53e46a", + "sha256:e4cbb2100ee46d024c45920d16e888ee5d3cf47c66e316210bc236d5bebc42b3", + "sha256:f28f8b2db7b86c77916829d64ab21ff49a9d8289ea1564a2b2a3a8ed9ffcccd3", + "sha256:f3023e14805c61bc439fb40ca545ac3d5740ce66120a678a3c6c2c55b70343d1", + "sha256:fdf348ae69c6ff484402cfdb14e18c1b0054ac2420079d575c53a60b9b2853ae" + ], + "version": "==1.63.0" }, "grpcio-status": { "hashes": [ @@ -1037,6 +1029,7 @@ "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd", "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14" ], + "index": "pypi", "markers": "python_version >= '3.7'", "version": "==2.6.0" }, @@ -1515,7 +1508,7 @@ }, "codeforlife": { "git": "https://github.com/ocadotechnology/codeforlife-package-python.git", - "ref": "a61b0ed1b7f6eb4d7d564201288b6610513532db" + "ref": "97ff9647c9a212dd276a765ea111a8e08012e625" }, "codeforlife-portal": { "hashes": [ @@ -1582,6 +1575,7 @@ "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48", "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4" ], + "markers": "python_version >= '3.8'", "version": "==7.4.4" }, "defusedxml": { @@ -2437,6 +2431,7 @@ "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd", "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14" ], + "index": "pypi", "markers": "python_version >= '3.7'", "version": "==2.6.0" }, @@ -2496,7 +2491,7 @@ "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5", "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42" ], - "markers": "python_version >= '3.7'", + "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/fixtures/school_1_teacher_invitations.json b/backend/api/fixtures/school_1_teacher_invitations.json index c1a0ab07..24637ca9 100644 --- a/backend/api/fixtures/school_1_teacher_invitations.json +++ b/backend/api/fixtures/school_1_teacher_invitations.json @@ -36,8 +36,22 @@ "from_teacher": 7, "invited_teacher_first_name": "Invited", "invited_teacher_last_name": "Teacher", - "invited_teacher_email": "teacher@school1.com", - "invited_teacher_is_admin": false, + "invited_teacher_email": "unverified.teacher@noschool.com", + "invited_teacher_is_admin": true, + "expiry": "9999-02-09 20:26:08.298402+00:00" + } + }, + { + "model": "common.schoolteacherinvitation", + "pk": 4, + "fields": { + "token": "pbkdf2_sha256$260000$hbsAadmrRo744BTM6NofUb$ePs/7vi6sSzOPpiWxNhXMZnNnE7aXOpzIhxrAa/rdiU=", + "school": 2, + "from_teacher": 7, + "invited_teacher_first_name": "Invited", + "invited_teacher_last_name": "Independent", + "invited_teacher_email": "indy@email.com", + "invited_teacher_is_admin": true, "expiry": "9999-02-09 20:26:08.298402+00:00" } } diff --git a/backend/api/fixtures/school_2_teacher_invitations.json b/backend/api/fixtures/school_2_teacher_invitations.json new file mode 100644 index 00000000..0c250c39 --- /dev/null +++ b/backend/api/fixtures/school_2_teacher_invitations.json @@ -0,0 +1,30 @@ +[ + { + "model": "common.schoolteacherinvitation", + "pk": 5, + "fields": { + "token": "pbkdf2_sha256$260000$hbsAadmrRo744BTM6NofUb$ePs/7vi6sSzOPpiWxNhXMZnNnE7aXOpzIhxrAa/rdiU=", + "school": 3, + "from_teacher": 8, + "invited_teacher_first_name": "Invited", + "invited_teacher_last_name": "Teacher", + "invited_teacher_email": "invited@teacher.com", + "invited_teacher_is_admin": false, + "expiry": "9999-02-09 20:26:08.298402+00:00" + } + }, + { + "model": "common.schoolteacherinvitation", + "pk": 6, + "fields": { + "token": "pbkdf2_sha256$260000$hbsAadmrRo744BTM6NofUb$ePs/7vi6sSzOPpiWxNhXMZnNnE7aXOpzIhxrAa/rdiU=", + "school": 3, + "from_teacher": 8, + "invited_teacher_first_name": "Invited", + "invited_teacher_last_name": "Teacher", + "invited_teacher_email": "teacher@school1.com", + "invited_teacher_is_admin": false, + "expiry": "9999-02-09 20:26:08.298402+00:00" + } + } +] \ No newline at end of file diff --git a/backend/api/permissions/__init__.py b/backend/api/permissions/__init__.py new file mode 100644 index 00000000..bd8dd228 --- /dev/null +++ b/backend/api/permissions/__init__.py @@ -0,0 +1,6 @@ +""" +© Ocado Group +Created on 24/04/2024 at 11:57:02(+01:00). +""" + +from .is_invited_school_teacher import IsInvitedSchoolTeacher diff --git a/backend/api/permissions/is_invited_school_teacher.py b/backend/api/permissions/is_invited_school_teacher.py new file mode 100644 index 00000000..9724d6f0 --- /dev/null +++ b/backend/api/permissions/is_invited_school_teacher.py @@ -0,0 +1,32 @@ +""" +© Ocado Group +Created on 24/04/2024 at 11:57:38(+01:00). +""" + +import typing as t + +from codeforlife.permissions import BasePermission +from django.contrib.auth.hashers import check_password +from rest_framework.viewsets import ModelViewSet + +from ..models import SchoolTeacherInvitation + + +class IsInvitedSchoolTeacher(BasePermission): + """The request is being made by the teacher invited to join a school.""" + + def has_permission( # type: ignore[override] + self, request, view: ModelViewSet + ): + pk: t.Optional[str] = view.kwargs.get( + view.lookup_url_kwarg or view.lookup_field + ) + + token: t.Optional[str] = request.data.get("token") + + if pk is not None and token is not None: + invitation = SchoolTeacherInvitation.objects.get(pk=int(pk)) + + return check_password(token, invitation.token) + + return False diff --git a/backend/api/serializers/__init__.py b/backend/api/serializers/__init__.py index f105961e..9b72be74 100644 --- a/backend/api/serializers/__init__.py +++ b/backend/api/serializers/__init__.py @@ -6,7 +6,11 @@ from .auth_factor import AuthFactorSerializer from .klass import ClassSerializer from .school import SchoolSerializer -from .school_teacher_invitation import SchoolTeacherInvitationSerializer +from .school_teacher_invitation import ( + AcceptSchoolTeacherInvitationSerializer, + RefreshSchoolTeacherInvitationSerializer, + SchoolTeacherInvitationSerializer, +) from .student import ( CreateStudentSerializer, ReleaseStudentSerializer, @@ -24,4 +28,5 @@ RequestUserPasswordResetSerializer, ResetUserPasswordSerializer, UpdateUserSerializer, + VerifyUserEmailAddressSerializer, ) diff --git a/backend/api/serializers/school_teacher_invitation.py b/backend/api/serializers/school_teacher_invitation.py index 81cd7b55..8e4959ab 100644 --- a/backend/api/serializers/school_teacher_invitation.py +++ b/backend/api/serializers/school_teacher_invitation.py @@ -3,57 +3,129 @@ Created on 09/02/2024 at 16:14:00(+00:00). """ +import typing as t from datetime import timedelta from codeforlife.serializers import ModelSerializer -from codeforlife.user.models import User +from codeforlife.types import DataDict +from codeforlife.user.models import ( + NonSchoolTeacherUser, + SchoolTeacherUser, + User, +) from django.contrib.auth.hashers import make_password from django.utils import timezone from django.utils.crypto import get_random_string from rest_framework import serializers from ..models import SchoolTeacherInvitation +from .teacher import CreateTeacherSerializer +# pylint: disable=missing-class-docstring +# pylint: disable=missing-function-docstring +# pylint: disable=too-many-ancestors -# pylint: disable-next=missing-class-docstring,too-many-ancestors -class SchoolTeacherInvitationSerializer( + +class BaseSchoolTeacherInvitationSerializer( ModelSerializer[User, SchoolTeacherInvitation] ): - first_name = serializers.CharField(source="invited_teacher_first_name") - last_name = serializers.CharField(source="invited_teacher_last_name") - email = serializers.EmailField(source="invited_teacher_email") - is_admin = serializers.BooleanField(source="invited_teacher_is_admin") + expires_at = serializers.DateTimeField(source="expiry", read_only=True) class Meta: model = SchoolTeacherInvitation + fields = ["id", "expires_at"] + extra_kwargs = {"id": {"read_only": True}} + + +class SchoolTeacherInvitationSerializer(BaseSchoolTeacherInvitationSerializer): + class Meta(BaseSchoolTeacherInvitationSerializer.Meta): fields = [ - "id", - "first_name", - "last_name", - "email", - "is_admin", - "expiry", + *BaseSchoolTeacherInvitationSerializer.Meta.fields, + "invited_teacher_first_name", + "invited_teacher_last_name", + "invited_teacher_email", + "invited_teacher_is_admin", ] - extra_kwargs = { - "id": {"read_only": True}, - "expiry": {"read_only": True}, - } def create(self, validated_data): user = self.request.admin_school_teacher_user token = get_random_string(length=32) - validated_data["token"] = make_password(token) - validated_data["school"] = user.teacher.school - validated_data["from_teacher"] = user.teacher - validated_data["expiry"] = timezone.now() + timedelta(days=30) - invitation = super().create(validated_data) + # TODO: move this logic to SchoolTeacherInvitation.objects.create + invitation = SchoolTeacherInvitation.objects.create( + **validated_data, + token=make_password(token), + school=user.teacher.school, + from_teacher=user.teacher, + expiry=timezone.now() + timedelta(days=30), + ) + # pylint: disable-next=protected-access invitation._token = token + return invitation + +class RefreshSchoolTeacherInvitationSerializer( + BaseSchoolTeacherInvitationSerializer +): def update(self, instance, validated_data): instance.expiry = timezone.now() + timedelta(days=30) - instance.save() + instance.save(update_fields=["expiry"]) + return instance + + +class AcceptSchoolTeacherInvitationSerializer( + BaseSchoolTeacherInvitationSerializer +): + @property + def non_school_teacher_user(self) -> t.Optional[NonSchoolTeacherUser]: + return self.context["non_school_teacher_user"] + + user = CreateTeacherSerializer.UserSerializer(required=False) + + class Meta(BaseSchoolTeacherInvitationSerializer.Meta): + fields = [ + *BaseSchoolTeacherInvitationSerializer.Meta.fields, + "user", + ] + + def validate_user(self, value: DataDict): + if self.non_school_teacher_user: + raise serializers.ValidationError( + "Cannot update existing teacher.", + code="cannot_update", + ) + + return value + + def update(self, instance, validated_data): + if self.non_school_teacher_user: + self.non_school_teacher_user.teacher.is_admin = ( + instance.invited_teacher_is_admin + ) + self.non_school_teacher_user.teacher.school = instance.school + self.non_school_teacher_user.teacher.save( + update_fields=["is_admin", "school"] + ) + self.non_school_teacher_user.userprofile.is_verified = True + self.non_school_teacher_user.userprofile.save( + update_fields=["is_verified"] + ) + else: + user_fields = t.cast(DataDict, validated_data["user"]) + add_to_newsletter = user_fields.pop("add_to_newsletter") + + school_teacher_user = SchoolTeacherUser.objects.create_user( + **user_fields, + school=instance.school, + is_admin=instance.invited_teacher_is_admin, + email=instance.invited_teacher_email, + is_verified=True, + ) + + if add_to_newsletter: + school_teacher_user.add_contact_to_dot_digital() + return instance diff --git a/backend/api/serializers/school_teacher_invitation_test.py b/backend/api/serializers/school_teacher_invitation_test.py index 52a7af77..1423aed1 100644 --- a/backend/api/serializers/school_teacher_invitation_test.py +++ b/backend/api/serializers/school_teacher_invitation_test.py @@ -2,50 +2,61 @@ © Ocado Group Created on 13/02/2024 at 13:44:00(+00:00). """ -import datetime -from unittest.mock import patch + +from datetime import timedelta +from unittest.mock import Mock, patch from codeforlife.tests import ModelSerializerTestCase -from codeforlife.user.models import AdminSchoolTeacherUser, User +from codeforlife.user.models import ( + AdminSchoolTeacherUser, + NonSchoolTeacherUser, + TeacherUser, + User, +) +from django.contrib.auth.hashers import make_password from django.utils import timezone from ..models import SchoolTeacherInvitation -from .school_teacher_invitation import SchoolTeacherInvitationSerializer +from .school_teacher_invitation import ( + AcceptSchoolTeacherInvitationSerializer, + RefreshSchoolTeacherInvitationSerializer, + SchoolTeacherInvitationSerializer, +) + +# pylint: disable=missing-class-docstring -# pylint: disable-next=missing-class-docstring class TestSchoolTeacherInvitationSerializer( ModelSerializerTestCase[User, SchoolTeacherInvitation] ): model_serializer_class = SchoolTeacherInvitationSerializer - fixtures = ["school_1", "school_1_teacher_invitations"] + fixtures = ["school_1"] def setUp(self): self.admin_school_teacher_user = AdminSchoolTeacherUser.objects.get( email="admin.teacher@school1.com" ) - self.invitation = SchoolTeacherInvitation.objects.get(pk=1) @patch( "api.serializers.school_teacher_invitation.make_password", return_value="token", ) - def test_create(self, _): + def test_create(self, invitation_make_password: Mock): """Can successfully create.""" now = timezone.now() with patch.object(timezone, "now", return_value=now): self.assert_create( - { + validated_data={ "invited_teacher_first_name": "NewTeacher", "invited_teacher_last_name": "NewTeacher", "invited_teacher_email": "invited@teacher.com", "invited_teacher_is_admin": False, }, new_data={ - "token": "token", + "token": invitation_make_password.return_value, "school": self.admin_school_teacher_user.teacher.school, "from_teacher": self.admin_school_teacher_user.teacher, - "expiry": now + datetime.timedelta(days=30), + "expiry": now + timedelta(days=30), }, context={ "request": self.request_factory.post( @@ -54,14 +65,110 @@ def test_create(self, _): }, ) + invitation_make_password.assert_called_once() + + +# pylint: disable-next=missing-class-docstring +class TestRefreshSchoolTeacherInvitationSerializer( + ModelSerializerTestCase[User, SchoolTeacherInvitation] +): + model_serializer_class = RefreshSchoolTeacherInvitationSerializer + fixtures = ["school_1", "school_1_teacher_invitations"] + + def setUp(self): + self.admin_school_teacher_user = AdminSchoolTeacherUser.objects.get( + email="admin.teacher@school1.com" + ) + self.invitation = SchoolTeacherInvitation.objects.get(pk=1) + def test_update(self): """Can successfully update.""" now = timezone.now() with patch.object(timezone, "now", return_value=now): self.assert_update( - self.invitation, - {}, + instance=self.invitation, + validated_data={}, new_data={ - "expiry": now + datetime.timedelta(days=30), + "expiry": now + timedelta(days=30), }, ) + + +class TestAcceptSchoolTeacherInvitationSerializer( + ModelSerializerTestCase[User, SchoolTeacherInvitation] +): + model_serializer_class = AcceptSchoolTeacherInvitationSerializer + fixtures = [ + "school_1", + "school_1_teacher_invitations", + "non_school_teacher", + ] + + def setUp(self): + self.invitation = SchoolTeacherInvitation.objects.get(pk=1) + self.non_school_teacher_user = NonSchoolTeacherUser.objects.get( + email="unverified.teacher@noschool.com" + ) + + def test_validate_user__cannot_update(self): + """Cannot update an existing user.""" + self.assert_validate_field( + name="user", + error_code="cannot_update", + context={"non_school_teacher_user": self.non_school_teacher_user}, + ) + + @patch.object(TeacherUser, "add_contact_to_dot_digital") + def test_update__new_user(self, add_contact_to_dot_digital: Mock): + """Can accept an invitation for a new user.""" + user_fields = { + "first_name": self.invitation.invited_teacher_first_name, + "last_name": self.invitation.invited_teacher_last_name, + "password": "password", + "add_to_newsletter": True, + } + + with patch( + "django.contrib.auth.models.make_password", + return_value=make_password(user_fields["password"]), + ) as user_make_password: + self.assert_update( + instance=self.invitation, + validated_data={"user": user_fields}, + context={"non_school_teacher_user": None}, + non_model_fields={"user"}, + ) + + user_make_password.assert_called_once_with(user_fields["password"]) + add_contact_to_dot_digital.assert_called_once() + + user = TeacherUser.objects.get( + email=self.invitation.invited_teacher_email + ) + assert user.first_name == user_fields["first_name"] + assert user.last_name == user_fields["last_name"] + assert user.check_password(user_fields["password"]) + assert user.email == self.invitation.invited_teacher_email + assert user.teacher.school == self.invitation.school + assert user.teacher.is_admin == self.invitation.invited_teacher_is_admin + assert user.userprofile.is_verified + + def test_update__existing_user(self): + """Can accept an invitation for a existing user.""" + user = self.non_school_teacher_user + assert not user.userprofile.is_verified + + invitation = SchoolTeacherInvitation.objects.get( + invited_teacher_email=user.email + ) + assert user.teacher.is_admin != invitation.invited_teacher_is_admin + + self.assert_update( + instance=invitation, + validated_data={}, + context={"non_school_teacher_user": user}, + ) + + assert user.teacher.school == invitation.school + assert user.teacher.is_admin == invitation.invited_teacher_is_admin + assert user.userprofile.is_verified diff --git a/backend/api/serializers/user.py b/backend/api/serializers/user.py index c66f3839..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 @@ -77,7 +72,9 @@ def validate_password(self, value: str): try: _validate_password(value, user) except CoreValidationError as ex: - raise serializers.ValidationError(ex.messages, ex.code) from ex + raise serializers.ValidationError( + ex.messages, code="invalid_password" + ) from ex return value @@ -292,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, @@ -356,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 f9a323eb..0c9f0972 100644 --- a/backend/api/serializers/user_test.py +++ b/backend/api/serializers/user_test.py @@ -2,6 +2,7 @@ © Ocado Group Created on 31/01/2024 at 16:07:32(+00:00). """ +import typing as t from datetime import date from unittest.mock import Mock, patch @@ -14,25 +15,18 @@ 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, + HandleIndependentUserJoinClassRequestSerializer, RequestUserPasswordResetSerializer, ResetUserPasswordSerializer, UpdateUserSerializer, + VerifyUserEmailAddressSerializer, ) -# NOTE: type hint to help Intellisense. -password_reset_token_generator: PasswordResetTokenGenerator = ( - default_token_generator -) - - # pylint: disable=missing-class-docstring @@ -51,15 +45,91 @@ def test_validate_email__already_exists(self): error_code="already_exists", ) + def _test_validate_password( + self, + user: User, + instance: t.Optional[User], + context: t.Optional[t.Dict[str, t.Any]] = None, + ): + serializer: BaseUserSerializer = BaseUserSerializer( + instance=instance, context=context or {} + ) + password = "password" + + with patch( + "api.serializers.user._validate_password" + ) as validate_password: + serializer.validate_password(password) + + validate_password.assert_called_once_with(password, user) + + def _test_validate_password__new_user(self, user_type: str) -> User: + user = User() + with patch( + "api.serializers.user.User", return_value=user + ) as user_class: + self._test_validate_password( + user=user, instance=None, context={"user_type": user_type} + ) + user_class.assert_called_once() + + return user + def test_validate_password(self): """ Password is validated using django's installed password-validators. + Validate the password of a new user requires the user type as context. """ - raise NotImplementedError() # TODO + user = User.objects.first() + assert user + + self._test_validate_password(user, user) + + user = self._test_validate_password__new_user(user_type="teacher") + assert user.teacher + user = self._test_validate_password__new_user(user_type="student") + assert user.student + assert user.student.class_field + user = self._test_validate_password__new_user(user_type="independent") + assert user.student + assert not user.student.class_field + + def test_validate_password__invalid_password(self): + """Validation errors are raised as serializer validation errors.""" + user = User.objects.first() + assert user + + self.assert_validate_field( + name="password", + error_code="invalid_password", + value="password", + instance=user, + ) def test_update(self): """Updating a user's password saves the password's hash.""" - raise NotImplementedError() # TODO + user = User.objects.first() + assert user + + password = "new password" + assert not user.check_password(password) + + with patch.object( + user, "set_password", side_effect=user.set_password + ) as set_password: + with patch( + "django.contrib.auth.base_user.make_password", + return_value=make_password(password), + ) as user_make_password: + self.assert_update( + instance=user, + validated_data={"password": password}, + new_data={"password": user_make_password.return_value}, + ) + + set_password.assert_called_once_with(password) + + assert user.check_password(password) class TestCreateTeacherSerializer( @@ -98,7 +168,7 @@ class TestUpdateUserSerializer(ModelSerializerTestCase[User, User]): fixtures = ["independent", "school_1"] def setUp(self): - self.independent = IndependentUser.objects.get( + self.indy_user = IndependentUser.objects.get( email="indy.requester@email.com" ) self.admin_school_teacher_user = AdminSchoolTeacherUser.objects.get( @@ -168,6 +238,87 @@ def test_validate_requesting_to_join_class__no_longer_accepts_requests( error_code="no_longer_accepts_requests", ) + def test_update(self): + """Can update the class an independent user is requesting join.""" + self.assert_update( + instance=self.indy_user, + validated_data={ + "new_student": { + "pending_class_request": self.class_2.access_code + } + }, + new_data={"new_student": {"pending_class_request": self.class_2}}, + ) + + +class TestHandleIndependentUserJoinClassRequestSerializer( + ModelSerializerTestCase[User, IndependentUser] +): + model_serializer_class = HandleIndependentUserJoinClassRequestSerializer + fixtures = ["school_1", "independent"] + + def setUp(self): + self.indy_user = IndependentUser.objects.get( + email="indy.requester@email.com" + ) + assert self.indy_user.student.pending_class_request + + def test_validate_first_name__already_in_class(self): + """ + Cannot join a class with a first name that already belongs to another + student in the class. + """ + student_user = StudentUser.objects.filter( + new_student__class_field=( + self.indy_user.student.pending_class_request + ) + ).first() + assert student_user + + self.assert_validate_field( + name="first_name", + error_code="already_in_class", + value=student_user.first_name, + instance=self.indy_user, + ) + + def test_update__accept(self): + """Can accept join-class requests.""" + user = self.indy_user + assert user.last_name + + with patch.object( + StudentUser, + "get_random_username", + return_value=StudentUser.get_random_username(), + ) as get_random_username: + self.assert_update( + instance=user, + validated_data={ + "accept": True, + "first_name": user.first_name + "NewStudent", + }, + new_data={ + "last_name": "", + "email": "", + "username": get_random_username.return_value, + "student": { + "pending_class_request": None, + "class_field": user.student.pending_class_request, + }, + }, + non_model_fields={"accept"}, + ) + + def test_update__reject(self): + """Can reject join-class requests.""" + self.assert_update( + instance=self.indy_user, + validated_data={"accept": False}, + new_data={"student": {"pending_class_request": None}}, + non_model_fields={"accept"}, + ) + class TestRequestUserPasswordResetSerializer( ModelSerializerTestCase[User, User] @@ -265,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/otp_bypass_token.py b/backend/api/views/otp_bypass_token.py index 027ee14d..faf8d26a 100644 --- a/backend/api/views/otp_bypass_token.py +++ b/backend/api/views/otp_bypass_token.py @@ -16,11 +16,8 @@ class OtpBypassTokenViewSet(ModelViewSet[User, OtpBypassToken]): http_method_names = ["post"] - def get_queryset(self): - return OtpBypassToken.objects.filter(user=self.request.teacher_user) - def get_permissions(self): - if self.action == "create": + if self.action in ["create", "bulk"]: return [AllowNone()] return [IsTeacher()] @@ -28,7 +25,12 @@ def get_permissions(self): # TODO: replace this custom action with bulk create and list serializer. @action(detail=False, methods=["post"]) def generate(self, request: Request): - """Generates some OTP bypass tokens for a user.""" + """ + Generates some OTP bypass tokens for a user. + + NOTE: Cannot use bulk_create action as it expects data fields to be + passed. + """ otp_bypass_tokens = OtpBypassToken.objects.bulk_create( request.auth_user ) diff --git a/backend/api/views/otp_bypass_token_test.py b/backend/api/views/otp_bypass_token_test.py index 3f35287e..05fa08b2 100644 --- a/backend/api/views/otp_bypass_token_test.py +++ b/backend/api/views/otp_bypass_token_test.py @@ -5,8 +5,10 @@ from unittest.mock import call, patch +from codeforlife.permissions import AllowNone from codeforlife.tests import ModelViewSetTestCase from codeforlife.user.models import OtpBypassToken, User +from codeforlife.user.permissions import IsTeacher from rest_framework import status from .otp_bypass_token import OtpBypassTokenViewSet @@ -23,6 +25,31 @@ def setUp(self): assert user self.user = user + # test: get permissions + + def test_get_permissions__create(self): + """No one can create a single otp-bypass-token.""" + self.assert_get_permissions( + permissions=[AllowNone()], + action="create", + ) + + def test_get_permissions__bulk(self): + """No one can bulk-create many otp-bypass-tokens.""" + self.assert_get_permissions( + permissions=[AllowNone()], + action="bulk", + ) + + def test_get_permissions__generate(self): + """Only teachers can generate otp-bypass-tokens.""" + self.assert_get_permissions( + permissions=[IsTeacher()], + action="generate", + ) + + # test: actions + def test_generate(self): """Generate max number of OTP bypass tokens.""" otp_bypass_token_pks = list( diff --git a/backend/api/views/school_teacher_invitation.py b/backend/api/views/school_teacher_invitation.py index d0111ae5..df7c421f 100644 --- a/backend/api/views/school_teacher_invitation.py +++ b/backend/api/views/school_teacher_invitation.py @@ -2,18 +2,26 @@ © Ocado Group Created on 09/02/2024 at 16:14:00(+00:00). """ -from codeforlife.permissions import AllowAny, AllowNone +import typing as t + +from codeforlife.mail import send_mail +from codeforlife.permissions import AllowNone from codeforlife.request import Request -from codeforlife.user.models import User +from codeforlife.response import Response +from codeforlife.user.models import ( + AdminSchoolTeacherUser, + NonSchoolTeacherUser, + User, +) from codeforlife.user.permissions import IsTeacher from codeforlife.views import ModelViewSet, action -from django.contrib.auth.hashers import check_password from rest_framework import status -from rest_framework.response import Response from ..models import SchoolTeacherInvitation +from ..permissions import IsInvitedSchoolTeacher from ..serializers import ( - CreateTeacherSerializer, + AcceptSchoolTeacherInvitationSerializer, + RefreshSchoolTeacherInvitationSerializer, SchoolTeacherInvitationSerializer, ) @@ -22,17 +30,16 @@ class SchoolTeacherInvitationViewSet( ModelViewSet[User, SchoolTeacherInvitation] ): - http_method_names = ["get", "post", "patch", "delete"] - serializer_class = SchoolTeacherInvitationSerializer + http_method_names = ["get", "post", "put", "delete"] def get_permissions(self): - if self.action == "accept": - return [AllowAny()] + if self.action in ["accept", "reject"]: + return [IsInvitedSchoolTeacher()] if self.action in [ "retrieve", "list", "create", - "partial_update", + "refresh", "destroy", ]: return [IsTeacher(is_admin=True)] @@ -43,74 +50,112 @@ def get_permissions(self): def get_queryset(self): queryset = SchoolTeacherInvitation.objects.all() - if self.action == "accept": - return queryset + if self.action in ["accept", "reject"]: + return queryset.filter(pk=self.kwargs["pk"]) return queryset.filter( school=self.request.admin_school_teacher_user.teacher.school ) - # TODO: use serializer and custom update (HTTP PUT) action - @action( - detail=True, methods=["get", "post"], url_path="accept/(?P.+)" - ) - def accept(self, request: Request, token: str, **kwargs: str): - """ - Handles accepting a teacher's invitation to join their school. On GET, - checks validity of the invitation token. On PATCH, rechecks this - param, performs password validation and creates the new Teacher. - """ - invitation = self.get_object() + def get_serializer_class(self): + if self.action == "accept": + return AcceptSchoolTeacherInvitationSerializer + if self.action == "refresh": + return RefreshSchoolTeacherInvitationSerializer - if not check_password(token, invitation.token): - return Response( - {"non_field_errors": ["Incorrect token."]}, - status=status.HTTP_400_BAD_REQUEST, - ) + return SchoolTeacherInvitationSerializer + refresh = ModelViewSet.update_action("refresh") + + @action(detail=True, methods=["delete"]) + def accept(self, request: Request, **kwargs: str): + """The invited teacher accepts the invitation.""" + invitation = self.get_object() if invitation.is_expired: - return Response( - {"non_field_errors": ["The invitation has expired."]}, - status=status.HTTP_400_BAD_REQUEST, + return Response(status=status.HTTP_410_GONE) + + try: + user = User.objects.get( + email__iexact=invitation.invited_teacher_email ) + except User.DoesNotExist: + # Inform the FE to display the new-user form. + if "user" not in request.json_dict: + return Response(status=status.HTTP_404_NOT_FOUND) + + user = None + + if user: + if not user.teacher: + return Response( + "You're already registered as a non-teacher user. You'll" + " need to delete the existing user before accepting this" + " invite.", + status=status.HTTP_409_CONFLICT, + ) + + if user.teacher.school: + return Response( + "You're already in a school. You'll need to leave your" + " current school before accepting this invite.", + status=status.HTTP_409_CONFLICT, + ) + + user = user.as_type(NonSchoolTeacherUser) + + serializer = self.get_serializer( + invitation, + data=request.data, + context={ + **self.get_serializer_context(), + "non_school_teacher_user": user, + "user_type": "teacher", + }, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + invitation.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) - if User.objects.filter( - email__iexact=invitation.invited_teacher_email - ).exists(): - return Response( - { - "non_field_errors": [ - "It looks like an account is already registered with " - "this email address. You will need to delete the other " - "account first or change the email associated with it " - "in order to proceed. You will then be able to access " - "this page." - ] - }, - status=status.HTTP_400_BAD_REQUEST, + @action(detail=True, methods=["delete"]) + def reject(self, request: Request, **kwargs: str): + """The invited teacher rejects the invitation.""" + invitation = self.get_object() + + to_addresses = [invitation.from_teacher.new_user.email] + # TODO: set max admin teacher count per school <= 10 + # TODO: create the following properties on the school model: + # - school.teacher_users + # - school.admin_teachers + # - school.admin_teacher_users + # - school.non_admin_teachers + # - school.non_admin_teacher_users + cc_addresses: t.List[str] = list( + AdminSchoolTeacherUser.objects.filter( + new_teacher__school=invitation.school ) + .exclude(id=invitation.from_teacher.new_user_id) + .values_list("email", flat=True) + ) + # TODO: set the correct bindings. + personalization_values = { + "invited_teacher_email": invitation.invited_teacher_email, + "invited_teacher_first_name": ( + invitation.invited_teacher_first_name + ), + "invited_teacher_last_name": (invitation.invited_teacher_last_name), + } + + invitation.delete() + + send_mail( + # TODO: create email template to explain invitation was rejected. + campaign_id=0, + to_addresses=to_addresses, + cc_addresses=cc_addresses, + personalization_values=personalization_values, + ) - if request.method == "POST": - context = self.get_serializer_context() - context["is_verified"] = True - context["user_type"] = "teacher" - context["school"] = invitation.school - - data = { - "first_name": invitation.invited_teacher_first_name, - "last_name": invitation.invited_teacher_last_name, - "email": invitation.invited_teacher_email, - **request.data, - "teacher": { - "is_admin": invitation.invited_teacher_is_admin, - **request.data.get("teacher", {}), - }, - } - - serializer = CreateTeacherSerializer(data=data, context=context) - serializer.is_valid(raise_exception=True) - serializer.save() - - invitation.delete() - - return Response() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/api/views/school_teacher_invitation_test.py b/backend/api/views/school_teacher_invitation_test.py index 448cd524..e3580932 100644 --- a/backend/api/views/school_teacher_invitation_test.py +++ b/backend/api/views/school_teacher_invitation_test.py @@ -3,56 +3,102 @@ Created on 09/02/2024 at 17:18:00(+00:00). """ +import typing as t +from datetime import timedelta +from unittest.mock import Mock, patch + from codeforlife.permissions import AllowNone from codeforlife.tests import ModelViewSetTestCase -from codeforlife.user.models import User +from codeforlife.user.models import ( + AdminSchoolTeacherUser, + School, + SchoolTeacherUser, + User, +) from codeforlife.user.permissions import IsTeacher +from django.contrib.auth.hashers import make_password +from django.utils import timezone from rest_framework import status from ..models import SchoolTeacherInvitation +from ..permissions import IsInvitedSchoolTeacher +from ..serializers import ( + AcceptSchoolTeacherInvitationSerializer, + RefreshSchoolTeacherInvitationSerializer, + SchoolTeacherInvitationSerializer, +) from .school_teacher_invitation import SchoolTeacherInvitationViewSet -# pylint: disable-next=missing-class-docstring,too-many-ancestors +# pylint: disable-next=missing-class-docstring,too-many-ancestors,too-many-public-methods,too-many-instance-attributes class TestSchoolTeacherInvitationViewSet( ModelViewSetTestCase[User, SchoolTeacherInvitation] ): basename = "school-teacher-invitation" model_view_set_class = SchoolTeacherInvitationViewSet fixtures = [ + "independent", "non_school_teacher", "school_1", "school_1_teacher_invitations", + "school_2", + "school_2_teacher_invitations", ] - school_admin_teacher_email = "admin.teacher@school1.com" - non_school_teacher_email = "teacher@noschool.com" - def _login_admin_school_teacher(self): - return self.client.login_admin_school_teacher( - email=self.school_admin_teacher_email, - password="password", - ) + def invited_user_exists(self, invitation: SchoolTeacherInvitation): + """Check if the invited user exists. + + Args: + invitation: The invitation sent to the user. + + Returns: + A flag designating whether the user exists. + """ + return User.objects.filter( + email__iexact=invitation.invited_teacher_email + ).exists() def setUp(self): + self.school = School.objects.get(name="School 1") + self.school_teacher_user = SchoolTeacherUser.objects.get( + email="teacher@school1.com" + ) + self.admin_school_teacher_user = AdminSchoolTeacherUser.objects.get( + email="admin.teacher@school1.com" + ) + self.expired_invitation = SchoolTeacherInvitation.objects.get(pk=1) assert self.expired_invitation.is_expired self.new_user_invitation = SchoolTeacherInvitation.objects.get(pk=2) assert not self.new_user_invitation.is_expired + assert not self.invited_user_exists(self.new_user_invitation) - with self.assertRaises(User.DoesNotExist): - User.objects.get( - email__iexact=self.new_user_invitation.invited_teacher_email + self.existing_indy_user_invitation = ( + SchoolTeacherInvitation.objects.get( + invited_teacher_email="indy@email.com" ) + ) + assert self.invited_user_exists(self.existing_indy_user_invitation) + + self.existing_school_teacher_invitation = ( + SchoolTeacherInvitation.objects.get( + invited_teacher_email="teacher@school1.com" + ) + ) + assert self.invited_user_exists(self.existing_school_teacher_invitation) - self.existing_user_invitation = SchoolTeacherInvitation.objects.get( - pk=3 + self.existing_non_school_teacher_invitation = ( + SchoolTeacherInvitation.objects.get( + invited_teacher_email="unverified.teacher@noschool.com" + ) ) - assert not self.existing_user_invitation.is_expired - User.objects.get( - email__iexact=self.existing_user_invitation.invited_teacher_email + assert self.invited_user_exists( + self.existing_non_school_teacher_invitation ) + # test: get permissions + def test_get_permissions__bulk(self): """No one is allowed to perform bulk actions.""" self.assert_get_permissions( @@ -67,11 +113,11 @@ def test_get_permissions__create(self): action="create", ) - def test_get_permissions__partial_update(self): - """Only admin-teachers can update an invitation.""" + def test_get_permissions__refresh(self): + """Only admin-teachers can refresh an invitation.""" self.assert_get_permissions( permissions=[IsTeacher(is_admin=True)], - action="partial_update", + action="refresh", ) def test_get_permissions__retrieve(self): @@ -95,132 +141,339 @@ def test_get_permissions__destroy(self): action="destroy", ) + def test_get_permissions__accept(self): + """Only the invited teacher can accept the invitation.""" + self.assert_get_permissions( + permissions=[IsInvitedSchoolTeacher()], + action="accept", + ) + + def test_get_permissions__reject(self): + """Only the invited teacher can reject the invitation.""" + self.assert_get_permissions( + permissions=[IsInvitedSchoolTeacher()], + action="reject", + ) + + # test: get queryset + + def test_get_queryset__refresh(self): + """Can target only target the invitations in the school.""" + self.assert_get_queryset( + values=list(self.school.teacher_invitations.all()), + action="refresh", + request=self.client.request_factory.put( + user=self.school_teacher_user + ), + ) + + def test_get_queryset__retrieve(self): + """Can target only target the invitations in the school.""" + self.assert_get_queryset( + values=list(self.school.teacher_invitations.all()), + action="retrieve", + request=self.client.request_factory.put( + user=self.school_teacher_user + ), + ) + + def test_get_queryset__list(self): + """Can target only target the invitations in the school.""" + self.assert_get_queryset( + values=list(self.school.teacher_invitations.all()), + action="list", + request=self.client.request_factory.put( + user=self.school_teacher_user + ), + ) + + def test_get_queryset__destroy(self): + """Can target only target the invitations in the school.""" + self.assert_get_queryset( + values=list(self.school.teacher_invitations.all()), + action="destroy", + request=self.client.request_factory.put( + user=self.school_teacher_user + ), + ) + + def test_get_queryset__accept(self): + """Can target the invitation the invited teacher has permissions for.""" + invitation = self.new_user_invitation + + self.assert_get_queryset( + values=[invitation], + action="accept", + kwargs={"pk": invitation.pk}, + ) + + def test_get_queryset__reject(self): + """Can target the invitation the invited teacher has permissions for.""" + invitation = self.new_user_invitation + + self.assert_get_queryset( + values=[invitation], + action="reject", + kwargs={"pk": invitation.pk}, + ) + + # test: get serializer class + + def test_get_serializer_class__accept(self): + """Accepting an invitation has a dedicated serializer.""" + self.assert_get_serializer_class( + serializer_class=AcceptSchoolTeacherInvitationSerializer, + action="accept", + ) + + def test_get_serializer_class__refresh(self): + """Refreshing an invitation has a dedicated serializer.""" + self.assert_get_serializer_class( + serializer_class=RefreshSchoolTeacherInvitationSerializer, + action="refresh", + ) + + def test_get_serializer_class__retrieve(self): + """Retrieving an invitation uses the general serializer.""" + self.assert_get_serializer_class( + serializer_class=SchoolTeacherInvitationSerializer, + action="retrieve", + ) + + def test_get_serializer_class__list(self): + """Listing the invitations uses the general serializer.""" + self.assert_get_serializer_class( + serializer_class=SchoolTeacherInvitationSerializer, + action="list", + ) + + def test_get_serializer_class__create(self): + """Creating an invitation uses the general serializer.""" + self.assert_get_serializer_class( + serializer_class=SchoolTeacherInvitationSerializer, + action="create", + ) + + # test: actions + def test_create(self): """Can successfully create an invitation.""" - self._login_admin_school_teacher() + user = self.admin_school_teacher_user + self.client.login_as(user) self.client.create( { - "first_name": "Invited", - "last_name": "Teacher", - "email": "invited@teacher.com", - "is_admin": "False", + "invited_teacher_first_name": "Invited", + "invited_teacher_last_name": "Teacher", + "invited_teacher_email": "invited@teacher.com", + "invited_teacher_is_admin": False, }, ) - def test_partial_update(self): - """Can successfully update an invitation's expiry.""" - self._login_admin_school_teacher() + def test_list(self): + """Can successfully list invitations.""" + user = self.admin_school_teacher_user + self.client.login_as(user) + + self.client.list(models=user.teacher.school.teacher_invitations.all()) + + def test_retrieve(self): + """Can successfully retrieve an invitation.""" + user = self.admin_school_teacher_user + self.client.login_as(user) + + invitation = t.cast( + t.Optional[SchoolTeacherInvitation], + user.teacher.school.teacher_invitations.first(), + ) + assert invitation + + self.client.retrieve(model=invitation) + + def test_refresh(self): + """Can successfully refresh an invitation.""" + user = self.admin_school_teacher_user + self.client.login_as(user) invitation = self.expired_invitation - self.client.partial_update(invitation, {}) + now = timezone.now() + with patch.object(timezone, "now", return_value=now): + self.client.put(self.reverse_action("refresh", invitation)) invitation.refresh_from_db() - - assert not invitation.is_expired + assert invitation.expiry == now + timedelta(days=30) def test_destroy(self): """Can successfully destroy an invitation.""" - self._login_admin_school_teacher() + user = self.admin_school_teacher_user + self.client.login_as(user) - invitation = self.new_user_invitation + invitation = t.cast( + t.Optional[SchoolTeacherInvitation], + user.teacher.school.teacher_invitations.first(), + ) + assert invitation - self.client.destroy(invitation) + self.client.destroy(model=invitation) with self.assertRaises(invitation.DoesNotExist): invitation.refresh_from_db() - def test_accept__get__invalid_token(self): - """Accept invite raises 400 on GET with invalid token""" + def test_accept__invalid_token(self): + """Return 403 status code when user provides an invalid token.""" invitation = self.new_user_invitation - viewname = self.reverse_action( - "accept", - # pylint: disable-next=protected-access - kwargs={"pk": invitation.pk, "token": "whatever"}, + self.client.delete( + self.reverse_action("accept", invitation), + data={"token": "invalid_token"}, + status_code_assertion=status.HTTP_403_FORBIDDEN, ) - response = self.client.get( - viewname, status_code_assertion=status.HTTP_400_BAD_REQUEST + def test_accept__expired(self): + """Return 410 status code when the invitation has expired.""" + invitation = self.expired_invitation + + self.client.delete( + self.reverse_action("accept", invitation), + data={"token": "token"}, + status_code_assertion=status.HTTP_410_GONE, ) - assert response.data["non_field_errors"] == ["Incorrect token."] + def test_accept__non_teacher(self): + """ + Return 409 status code when the invited user already exists as a + non-teacher. + """ + invitation = self.existing_indy_user_invitation + + response = self.client.delete( + self.reverse_action("accept", invitation), + data={"token": "token"}, + status_code_assertion=status.HTTP_409_CONFLICT, + ) - def test_accept__get__expired(self): - """Accept invite raises 400 on GET with expired invite""" - invitation = self.expired_invitation + assert t.cast(str, response.data).startswith( + "You're already registered as a non-teacher user." + ) - viewname = self.reverse_action( - "accept", - # pylint: disable-next=protected-access - kwargs={"pk": invitation.pk, "token": "token"}, + def test_accept__in_school(self): + """ + Return 409 status code when the invited user is already a + school-teacher. + """ + invitation = self.existing_school_teacher_invitation + + response = self.client.delete( + self.reverse_action("accept", invitation), + data={"token": "token"}, + status_code_assertion=status.HTTP_409_CONFLICT, ) - response = self.client.get( - viewname, status_code_assertion=status.HTTP_400_BAD_REQUEST + assert t.cast(str, response.data).startswith( + "You're already in a school." ) - assert response.data["non_field_errors"] == [ - "The invitation has expired." - ] + def test_accept__user_does_not_exist(self): + """ + Return 404 status code when the invited user does not exist and no new + user was specified. + """ + invitation = self.new_user_invitation + + self.client.delete( + self.reverse_action("accept", invitation), + data={"token": "token"}, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) - def test_accept__get__existing_email(self): - """Accept invite raises 400 on GET with pre-existing email""" - invitation = self.existing_user_invitation + def test_accept__existing_non_school_teacher(self): + """An existing non-school-teacher accepts the invite.""" + invitation = self.existing_non_school_teacher_invitation - viewname = self.reverse_action( - "accept", - # pylint: disable-next=protected-access - kwargs={"pk": invitation.pk, "token": "token"}, + self.client.delete( + self.reverse_action("accept", invitation), + data={"token": "token"}, ) - response = self.client.get( - viewname, status_code_assertion=status.HTTP_400_BAD_REQUEST + user = SchoolTeacherUser.objects.get( + email=invitation.invited_teacher_email ) + assert user.teacher.school == invitation.school + assert user.teacher.is_admin == invitation.invited_teacher_is_admin + assert user.userprofile.is_verified - assert response.data["non_field_errors"] == [ - "It looks like an account is already registered with this email " - "address. You will need to delete the other account first or " - "change the email associated with it in order to proceed. You will " - "then be able to access this page." - ] + with self.assertRaises(SchoolTeacherInvitation.DoesNotExist): + invitation.refresh_from_db() - def test_accept__get(self): - """Accept invite GET succeeds""" + @patch.object(SchoolTeacherUser, "add_contact_to_dot_digital") + def test_accept__create_new_user(self, add_contact_to_dot_digital: Mock): + """A new user accepts the invite to become a school-teacher.""" invitation = self.new_user_invitation - viewname = self.reverse_action( - "accept", - # pylint: disable-next=protected-access - kwargs={"pk": invitation.pk, "token": "token"}, - ) + user_fields = { + "first_name": invitation.invited_teacher_first_name, + "last_name": invitation.invited_teacher_last_name, + "password": "Hn87954y97695!@$%&", + "add_to_newsletter": True, + } + + with patch( + "django.contrib.auth.models.make_password", + return_value=make_password(user_fields["password"]), + ) as user_make_password: + self.client.delete( + self.reverse_action("accept", invitation), + data={"token": "token", "user": user_fields}, + ) - self.client.get(viewname) + user_make_password.assert_called_once_with(user_fields["password"]) + add_contact_to_dot_digital.assert_called_once() - # TODO: fix school teacher invitation view set. - def test_accept__post(self): - """Invited teacher can set password and their account is created""" - password = "InvitedPassword1!" + user = SchoolTeacherUser.objects.get( + email=invitation.invited_teacher_email + ) + assert user.first_name == user_fields["first_name"] + assert user.last_name == user_fields["last_name"] + assert user.check_password(user_fields["password"]) + assert user.email == invitation.invited_teacher_email + assert user.teacher.school == invitation.school + assert user.teacher.is_admin == invitation.invited_teacher_is_admin + assert user.userprofile.is_verified - invitation = self.new_user_invitation + with self.assertRaises(SchoolTeacherInvitation.DoesNotExist): + invitation.refresh_from_db() - viewname = self.reverse_action( - "accept", - # pylint: disable-next=protected-access - kwargs={"pk": invitation.pk, "token": "token"}, - ) + @patch("api.views.school_teacher_invitation.send_mail") + def test_reject(self, send_mail: Mock): + """The invited person can reject the invitation.""" + invitation = self.new_user_invitation - self.client.post( - viewname, data={"password": "InvitedPassword1!"}, format="json" + self.client.delete( + self.reverse_action("reject", invitation), + data={"token": "token"}, ) - user = self.client.login( - email=invitation.invited_teacher_email, - password=password, + send_mail.assert_called_once_with( + campaign_id=0, + to_addresses=[invitation.from_teacher.new_user.email], + cc_addresses=list( + AdminSchoolTeacherUser.objects.filter( + new_teacher__school=invitation.school + ) + .exclude(id=invitation.from_teacher.new_user_id) + .values_list("email", flat=True) + ), + personalization_values={ + "invited_teacher_email": invitation.invited_teacher_email, + "invited_teacher_first_name": ( + invitation.invited_teacher_first_name + ), + "invited_teacher_last_name": ( + invitation.invited_teacher_last_name + ), + }, ) - assert user.teacher.school == invitation.school - assert user.teacher.is_admin == invitation.invited_teacher_is_admin - with self.assertRaises(invitation.DoesNotExist): + with self.assertRaises(SchoolTeacherInvitation.DoesNotExist): invitation.refresh_from_db() 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/pyproject.toml b/backend/pyproject.toml index 452997df..1102f668 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -34,3 +34,6 @@ django-settings-module = "service.settings" profile = "black" line_length = 80 skip_glob = ["**/migrations/*.py"] + +[tool.coverage.run] +omit = ["*/test_*.py", "*/*_test.py", "main.py", "manage.py"] diff --git a/backend/service/settings.py b/backend/service/settings.py index 28afd11a..ff762286 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 diff --git a/codecov.yml b/codecov.yml index 640bd899..52179e2c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,5 +1,11 @@ -coverage: - status: - patch: +coverage: # https://docs.codecov.com/docs/codecov-yaml + precision: 2 + round: down + range: 90...90 + status: # https://docs.codecov.com/docs/commit-status + project: default: - target: 100% + target: 90% + threshold: 0% + +comment: false