From b9945a1232c40ae5ff33496955fae595f3861c1d Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 10 Feb 2026 14:37:51 +0000 Subject: [PATCH 01/58] initial --- Pipfile | 7 - Pipfile.lock | 946 ++++-------------- codeforlife/settings/django.py | 8 +- codeforlife/user/fixtures/google_users.json | 6 +- codeforlife/user/fixtures/independent.json | 12 +- .../user/fixtures/non_school_teacher.json | 14 +- codeforlife/user/fixtures/school_1.json | 32 +- codeforlife/user/fixtures/school_2.json | 20 +- codeforlife/user/fixtures/school_3.json | 16 +- codeforlife/user/migrations/0001_initial.py | 627 ++++++++++-- codeforlife/user/models/__init__.py | 1 + codeforlife/user/models/klass.py | 111 +- codeforlife/user/models/other.py | 91 ++ codeforlife/user/models/school.py | 55 +- codeforlife/user/models/student.py | 73 +- codeforlife/user/models/teacher.py | 92 +- codeforlife/user/models/user/google.py | 3 +- codeforlife/user/models/user/independent.py | 8 +- codeforlife/user/models/user/student.py | 11 +- codeforlife/user/models/user/teacher.py | 8 +- codeforlife/user/models/user/user.py | 56 +- settings.py | 3 - 22 files changed, 1274 insertions(+), 926 deletions(-) create mode 100644 codeforlife/user/models/other.py diff --git a/Pipfile b/Pipfile index 2e0a87d9..e43b8293 100644 --- a/Pipfile +++ b/Pipfile @@ -26,13 +26,6 @@ gunicorn = "==23.0.0" uvicorn-worker = "==0.2.0" pyjwt = "==2.6.0" # TODO: upgrade to latest version. psutil = "==7.0.0" -importlib-metadata = "==4.13.0" # TODO: remove. needed by old portal -django-formtools = "==2.5.1" # TODO: remove. needed by old portal -# https://pypi.org/user/codeforlife/ -cfl-common = "==8.9.19" # TODO: remove -codeforlife-portal = "==8.9.19" # TODO: remove -rapid-router = "==7.6.18" # TODO: remove -phonenumbers = "==8.12.12" # TODO: remove google-auth = "==2.40.3" google-cloud-bigquery = "==3.38.0" diff --git a/Pipfile.lock b/Pipfile.lock index 84e2fb5c..0c7242d3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e7f16b2d14b73ca8af83232206b594726dcda0856f51945cdf2410edc86f4472" + "sha256": "89ff1d169339b93c750837aed9cf2af6808f5fd41db2092b6c926b57228205e4" }, "pipfile-spec": 6, "requires": { @@ -26,19 +26,11 @@ }, "asgiref": { "hashes": [ - "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", - "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d" + "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", + "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133" ], "markers": "python_version >= '3.9'", - "version": "==3.11.0" - }, - "asttokens": { - "hashes": [ - "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", - "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7" - ], - "markers": "python_version >= '3.8'", - "version": "==3.0.1" + "version": "==3.11.1" }, "billiard": { "hashes": [ @@ -182,14 +174,6 @@ "markers": "python_version >= '3.9'", "version": "==2.0.0" }, - "cfl-common": { - "hashes": [ - "sha256:02a7f5d44cd8495c7b252bb6980896d45b413f645da42f805f17e7851e1d51db", - "sha256:eba4f98e6b569f2851fda9bde0ca61a46585112494f2319ed1503b47a4de2b9e" - ], - "index": "pypi", - "version": "==8.9.19" - }, "charset-normalizer": { "hashes": [ "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", @@ -340,14 +324,6 @@ "markers": "python_version >= '3.6'", "version": "==0.3.0" }, - "codeforlife-portal": { - "hashes": [ - "sha256:6a181a6742ef189da84fe66a13c01d4136130a0b57c9c90ddcff444c7f9e7fb6", - "sha256:ab894bd62d9b6cb05207f4661fdb78eac0c07384b72c6c83e9a9d00f94aa563f" - ], - "index": "pypi", - "version": "==8.9.19" - }, "cryptography": { "hashes": [ "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", @@ -386,14 +362,6 @@ "markers": "python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'", "version": "==44.0.1" }, - "decorator": { - "hashes": [ - "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", - "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a" - ], - "markers": "python_version >= '3.8'", - "version": "==5.2.1" - }, "diff-match-patch": { "hashes": [ "sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782", @@ -411,14 +379,6 @@ "markers": "python_version >= '3.10'", "version": "==5.1.15" }, - "django-classy-tags": { - "hashes": [ - "sha256:1c784cf1bac49c20a77b8f7d1541867c64076642a160a847ff449588d4e01e55", - "sha256:c8d9d1aa2fa6e71c4d866df4dd11d23a69b8d25bbb750b2490a17b161774ee59" - ], - "markers": "python_version >= '3.8'", - "version": "==4.1.0" - }, "django-cors-headers": { "hashes": [ "sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b", @@ -458,7 +418,6 @@ "sha256:47cb34552c6efca088863d693284d04fc36eaaf350eb21e1a1d935e0df523c93", "sha256:bce9b64eda52cc1eef6961cc649cf75aacd1a707c2fff08d6c3efcbc8e7e761a" ], - "index": "pypi", "markers": "python_version >= '3.8'", "version": "==2.5.1" }, @@ -487,51 +446,6 @@ "markers": "python_version >= '3.10'", "version": "==8.4.0" }, - "django-pipeline": { - "hashes": [ - "sha256:0ab1190d9dc64e2f7b72be3f7b023c06aca7cc1cc61e7dc9f0343838e29bbc88", - "sha256:90e50c15387a6e051ee1a6ce2aaca333823ccfb23695028790f74412bde7d7db" - ], - "markers": "python_version >= '3.9'", - "version": "==4.0.0" - }, - "django-preventconcurrentlogins": { - "hashes": [ - "sha256:9cb45fcd63edeec55e5ac29bbd2ee96974dc2a72d74ab88088dbf6a1f52978e9" - ], - "version": "==0.8.2" - }, - "django-ratelimit": { - "hashes": [ - "sha256:73223d860abd5c5d7b9a807fabb39a6220068129b514be8d78044b52607ab154", - "sha256:857e797f23de948b204a31dba9d88aea3ce731b7a5d926d0240c772e19b5486f" - ], - "markers": "python_version >= '3.4'", - "version": "==3.0.1" - }, - "django-recaptcha": { - "hashes": [ - "sha256:0d912d5c7c009df4e47accd25029133d47a74342dbd2a8edc2877b6bffa971a3", - "sha256:5316438f97700c431d65351470d1255047e3f2cd9af0f2f13592b637dad9213e" - ], - "version": "==4.0.0" - }, - "django-reverse-js": { - "hashes": [ - "sha256:42739b2d955704cb723467655c67278602695d2aae6e7595ff80db323c73e958", - "sha256:e9e604aaf8c5cda7c1b1cb9a1a78cff395f7937935085d22bcd484016512a069" - ], - "markers": "python_version >= '3.10'", - "version": "==0.1.8" - }, - "django-sekizai": { - "hashes": [ - "sha256:2aca36cbae0b5c0cefed9565416ec442335767fb3145bff11e58622fc653cdad", - "sha256:aa12e66ba0335fbe726b7d74cf4e8716b89a0be99a1304a9b9e8b191229e2e4a" - ], - "markers": "python_version >= '3.8'", - "version": "==4.1.0" - }, "django-storages": { "extras": [ "s3" @@ -543,14 +457,6 @@ "markers": "python_version >= '3.7'", "version": "==1.14.6" }, - "django-treebeard": { - "hashes": [ - "sha256:846e462904b437155f76e04907ba4e48480716855f88b898df4122bdcfbd6e98", - "sha256:995c7120153ab999898fe3043bbdcd8a0fc77cc106eb94de7350e9d02c885135" - ], - "markers": "python_version >= '3.8'", - "version": "==4.7.1" - }, "django-two-factor-auth": { "hashes": [ "sha256:622e78b0d6cf12eeafa239665d99c1221c399228f2f902fe478aea7759995e0e", @@ -569,14 +475,6 @@ "markers": "python_version >= '3.9'", "version": "==3.16.0" }, - "executing": { - "hashes": [ - "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", - "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017" - ], - "markers": "python_version >= '3.8'", - "version": "==2.2.1" - }, "google-api-core": { "extras": [ "grpc" @@ -671,78 +569,78 @@ }, "grpcio": { "hashes": [ - "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3", - "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", - "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b", - "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", - "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", - "sha256:0aaa82d0813fd4c8e589fac9b65d7dd88702555f702fb10417f96e2a2a6d4c0f", - "sha256:0b7604868b38c1bfd5cf72d768aedd7db41d78cb6a4a18585e33fb0f9f2363fd", - "sha256:0c37db8606c258e2ee0c56b78c62fc9dee0e901b5dbdcf816c2dd4ad652b8b0c", - "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", - "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", - "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", - "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", - "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", - "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", - "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", - "sha256:3bf0f392c0b806905ed174dcd8bdd5e418a40d5567a05615a030a5aeddea692d", - "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", - "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde", - "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990", - "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", - "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", - "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", - "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", - "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af", - "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", - "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", - "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", - "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", - "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", - "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc", - "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", - "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", - "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", - "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", - "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", - "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", - "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2", - "sha256:8ebe63ee5f8fa4296b1b8cfc743f870d10e902ca18afc65c68cf46fd39bb0783", - "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", - "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", - "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", - "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6", - "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", - "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3", - "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", - "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b", - "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", - "sha256:acab0277c40eff7143c2323190ea57b9ee5fd353d8190ee9652369fae735668a", - "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", - "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", - "sha256:d099566accf23d21037f18a2a63d323075bebace807742e4b0ac210971d4dd70", - "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", - "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", - "sha256:e6d1db20594d9daba22f90da738b1a0441a7427552cc6e2e3d1297aeddc00378", - "sha256:ebea5cc3aa8ea72e04df9913492f9a96d9348db876f9dda3ad729cfedf7ac416", - "sha256:ebebf83299b0cb1721a8859ea98f3a77811e35dce7609c5c963b9ad90728f886", - "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", - "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", - "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", - "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", - "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62" + "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", + "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", + "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", + "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", + "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", + "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", + "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", + "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", + "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", + "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", + "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", + "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", + "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", + "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", + "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", + "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", + "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", + "sha256:5361a0630a7fdb58a6a97638ab70e1dae2893c4d08d7aba64ded28bb9e7a29df", + "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", + "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", + "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", + "sha256:684083fd383e9dc04c794adb838d4faea08b291ce81f64ecd08e4577c7398adf", + "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", + "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", + "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", + "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", + "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", + "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", + "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", + "sha256:86ce2371bfd7f212cf60d8517e5e854475c2c43ce14aa910e136ace72c6db6c1", + "sha256:86f85dd7c947baa707078a236288a289044836d4b640962018ceb9cd1f899af5", + "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", + "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", + "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", + "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", + "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", + "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", + "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", + "sha256:ab399ef5e3cd2a721b1038a0f3021001f19c5ab279f145e1146bb0b9f1b2b12c", + "sha256:b0c689c02947d636bc7fab3e30cc3a3445cca99c834dfb77cd4a6cabfc1c5597", + "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", + "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", + "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", + "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", + "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", + "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", + "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", + "sha256:ce7599575eeb25c0f4dc1be59cada6219f3b56176f799627f44088b21381a28a", + "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", + "sha256:de8cb00d1483a412a06394b8303feec5dcb3b55f81d83aa216dbb6a0b86a94f5", + "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", + "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", + "sha256:e888474dee2f59ff68130f8a397792d8cb8e17e6b3434339657ba4ee90845a8c", + "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", + "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", + "sha256:f3d6379493e18ad4d39537a82371c5281e153e963cecb13f953ebac155756525", + "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", + "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", + "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", + "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", + "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7" ], "markers": "python_version >= '3.9'", - "version": "==1.76.0" + "version": "==1.78.0" }, "grpcio-status": { "hashes": [ - "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd", - "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18" + "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189", + "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34" ], "markers": "python_version >= '3.9'", - "version": "==1.76.0" + "version": "==1.78.0" }, "gunicorn": { "hashes": [ @@ -880,39 +778,6 @@ "markers": "python_version >= '3.8'", "version": "==3.11" }, - "importlib-metadata": { - "hashes": [ - "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116", - "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==4.13.0" - }, - "ipython": { - "hashes": [ - "sha256:48fbed1b2de5e2c7177eefa144aba7fcb82dac514f09b57e2ac9da34ddb54220", - "sha256:b457fe9165df2b84e8ec909a97abcf2ed88f565970efba16b1f7229c283d252b" - ], - "markers": "python_version >= '3.11'", - "version": "==9.9.0" - }, - "ipython-pygments-lexers": { - "hashes": [ - "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", - "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c" - ], - "markers": "python_version >= '3.8'", - "version": "==1.1.1" - }, - "jedi": { - "hashes": [ - "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", - "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9" - ], - "markers": "python_version >= '3.6'", - "version": "==0.19.2" - }, "jmespath": { "hashes": [ "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", @@ -932,112 +797,6 @@ "markers": "python_version >= '3.9'", "version": "==5.6.2" }, - "libsass": { - "hashes": [ - "sha256:31e86d92a5c7a551df844b72d83fc2b5e50abc6fbbb31e296f7bebd6489ed1b4", - "sha256:34cae047cbbfc4ffa832a61cbb110f3c95f5471c6170c842d3fed161e40814dc", - "sha256:4a218406d605f325d234e4678bd57126a66a88841cb95bee2caeafdc6f138306", - "sha256:6f209955ede26684e76912caf329f4ccb57e4a043fd77fe0e7348dd9574f1880", - "sha256:a2ec85d819f353cbe807432d7275d653710d12b08ec7ef61c124a580a8352f3c", - "sha256:ea97d1b45cdc2fc3590cb9d7b60f1d8915d3ce17a98c1f2d4dd47ee0d9c68ce6" - ], - "markers": "python_version >= '3.8'", - "version": "==0.23.0" - }, - "matplotlib-inline": { - "hashes": [ - "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", - "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe" - ], - "markers": "python_version >= '3.9'", - "version": "==0.2.1" - }, - "more-itertools": { - "hashes": [ - "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced", - "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713" - ], - "markers": "python_version >= '3.5'", - "version": "==8.7.0" - }, - "numpy": { - "hashes": [ - "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", - "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", - "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", - "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", - "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", - "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", - "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", - "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", - "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", - "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", - "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", - "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", - "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", - "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", - "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", - "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", - "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", - "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", - "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", - "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", - "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", - "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", - "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", - "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", - "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", - "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", - "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", - "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", - "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", - "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", - "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", - "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", - "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", - "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", - "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", - "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", - "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", - "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", - "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", - "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", - "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", - "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", - "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", - "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", - "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", - "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", - "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", - "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", - "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", - "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", - "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", - "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", - "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", - "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", - "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", - "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", - "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", - "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", - "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", - "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", - "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", - "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", - "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", - "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", - "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", - "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", - "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", - "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", - "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", - "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", - "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", - "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c" - ], - "markers": "python_version >= '3.11'", - "version": "==2.4.1" - }, "packaging": { "hashes": [ "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", @@ -1046,189 +805,6 @@ "markers": "python_version >= '3.8'", "version": "==26.0" }, - "pandas": { - "hashes": [ - "sha256:0192fee1f1a8e743b464a6607858ee4b071deb0b118eb143d71c2a1d170996d5", - "sha256:083b11415b9970b6e7888800c43c82e81a06cd6b06755d84804444f0007d6bb7", - "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", - "sha256:113b4cca2614ff7e5b9fee9b6f066618fe73c5a83e99d721ffc41217b2bf57dd", - "sha256:125eb901e233f155b268bbef9abd9afb5819db74f0e677e89a61b246228c71ac", - "sha256:14c2a4099cd38a1d18ff108168ea417909b2dea3bd1ebff2ccf28ddb6a74d740", - "sha256:15d59f885ee5011daf8335dff47dcb8a912a27b4ad7826dc6cbe809fd145d327", - "sha256:177d9df10b3f43b70307a149d7ec49a1229a653f907aa60a48f1877d0e6be3be", - "sha256:1c39eab3ad38f2d7a249095f0a3d8f8c22cc0f847e98ccf5bbe732b272e2d9fa", - "sha256:1fbbb5a7288719e36b76b4f18d46ede46e7f916b6c8d9915b756b0a6c3f792b3", - "sha256:24e6547fb64d2c92665dd2adbfa4e85fa4fd70a9c070e7cfb03b629a0bbab5eb", - "sha256:2713810ad3806767b89ad3b7b69ba153e1c6ff6d9c20f9c2140379b2a98b6c98", - "sha256:33fd3e6baa72899746b820c31e4b9688c8e1b7864d7aec2de7ab5035c285277a", - "sha256:3c9a1a149aed3b6c9bf246033ff91e1b02d529546c5d6fb6b74a28fea0cf4c70", - "sha256:412d1a89aab46889f3033a386912efcdfa0f1131c5705ff5b668dda88305e986", - "sha256:447b2d68ac5edcbf94655fe909113a6dba6ef09ad7f9f60c80477825b6c489fe", - "sha256:48ee04b90e2505c693d3f8e8f524dab8cb8aaf7ddcab52c92afa535e717c4812", - "sha256:4a4a400ca18230976724a5066f20878af785f36c6756e498e94c2a5e5d57779c", - "sha256:4a66384f017240f3858a4c8a7cf21b0591c3ac885cddb7758a589f0f71e87ebb", - "sha256:597c08fb9fef0edf1e4fa2f9828dd27f3d78f9b8c9b4a748d435ffc55732310b", - "sha256:5db1e62cb99e739fa78a28047e861b256d17f88463c76b8dafc7c1338086dca8", - "sha256:613e13426069793aa1ec53bdcc3b86e8d32071daea138bbcf4fa959c9cdaa2e2", - "sha256:66f72fb172959af42a459e27a8d8d2c7e311ff4c1f7db6deb3b643dbc382ae08", - "sha256:69780c98f286076dcafca38d8b8eee1676adf220199c0a39f0ecbf976b68151a", - "sha256:697b8f7d346c68274b1b93a170a70974cdc7d7354429894d5927c1effdcccd73", - "sha256:707a9a877a876c326ae2cb640fbdc4ef63b0a7b9e2ef55c6df9942dcee8e2af9", - "sha256:783ac35c4d0fe0effdb0d67161859078618b1b6587a1af15928137525217a721", - "sha256:8cb3120f0d9467ed95e77f67a75e030b67545bcfa08964e349252d674171def2", - "sha256:8e8b9808590fa364416b49b2a35c1f4cf2785a6c156935879e57f826df22038e", - "sha256:940eebffe55528074341a5a36515f3e4c5e25e958ebbc764c9502cfc35ba3faa", - "sha256:95683af6175d884ee89471842acfca29172a85031fccdabc35e50c0984470a0e", - "sha256:9803b31f5039b3c3b10cc858c5e40054adb4b29b4d81cb2fd789f4121c8efbcd", - "sha256:98212a38a709feb90ae658cb6227ea3657c22ba8157d4b8f913cd4c950de5e7e", - "sha256:a453aad8c4f4e9f166436994a33884442ea62aa8b27d007311e87521b97246e1", - "sha256:a8942e333dc67ceda1095227ad0febb05a3b36535e520154085db632c40ad084", - "sha256:afd0aa3d0b5cda6e0b8ffc10dbcca3b09ef3cbcd3fe2b27364f85fdc04e1989d", - "sha256:b78d646249b9a2bc191040988c7bb524c92fa8534fb0898a0741d7e6f2ffafa6", - "sha256:b86d113b6c109df3ce0ad5abbc259fe86a1bd4adfd4a31a89da42f84f65509bb", - "sha256:bc9cba7b355cb4162442a88ce495e01cb605f17ac1e27d6596ac963504e0305f", - "sha256:be8c515c9bc33989d97b89db66ea0cececb0f6e3c2a87fcc8b69443a6923e95f", - "sha256:c14837eba8e99a8da1527c0280bba29b0eb842f64aa94982c5e21227966e164b", - "sha256:d257699b9a9960e6125686098d5714ac59d05222bef7a5e6af7a7fd87c650801", - "sha256:d64ce01eb9cdca96a15266aa679ae50212ec52757c79204dbc7701a222401850", - "sha256:da768007b5a33057f6d9053563d6b74dd6d029c337d93c6d0d22a763a5c2ecc0", - "sha256:debb95c77ff3ed3ba0d9aa20c3a2f19165cc7956362f9873fce1ba0a53819d70", - "sha256:e979d22316f9350c516479dd3a92252be2937a9531ed3a26ec324198a99cdd49", - "sha256:f0b853319dec8d5e0c8b875374c078ef17f2269986a78168d9bd57e49bf650ae", - "sha256:fedabf175e7cd82b69b74c30adbaa616de301291a5231138d7242596fc296a8d" - ], - "markers": "python_version >= '3.11'", - "version": "==3.0.0" - }, - "parso": { - "hashes": [ - "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", - "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887" - ], - "markers": "python_version >= '3.6'", - "version": "==0.8.5" - }, - "pexpect": { - "hashes": [ - "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", - "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f" - ], - "markers": "sys_platform != 'win32' and sys_platform != 'emscripten'", - "version": "==4.9.0" - }, - "pgeocode": { - "hashes": [ - "sha256:07995d4cd2d7fec1f82afb14d6025e83bbc156b6f225fa3e0b3417da2ec020c8", - "sha256:60fc2bad60aa161c3cf46ace4fde607b77e016b1e2a25470534163305499e55e" - ], - "markers": "python_version >= '3.8'", - "version": "==0.4.0" - }, - "phonenumbers": { - "hashes": [ - "sha256:23944f9e628f32a975d3b221b6d76e6ba8ae618d53cb3d82fc23d9e100a59b29", - "sha256:70aa98a50ba7bc7f6bf17851f806c927107e7c44e7d21eb46bdbec07b99d23ae" - ], - "index": "pypi", - "version": "==8.12.12" - }, - "pillow": { - "hashes": [ - "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", - "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", - "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", - "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", - "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", - "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", - "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", - "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", - "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", - "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", - "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", - "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", - "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", - "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", - "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", - "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", - "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", - "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", - "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", - "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", - "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", - "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", - "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", - "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", - "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", - "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", - "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", - "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", - "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", - "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", - "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", - "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", - "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", - "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", - "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", - "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", - "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", - "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", - "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", - "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", - "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", - "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", - "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", - "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", - "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", - "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", - "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", - "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", - "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", - "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", - "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", - "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", - "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", - "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", - "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", - "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", - "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", - "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", - "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", - "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", - "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", - "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", - "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", - "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", - "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", - "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", - "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", - "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", - "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", - "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", - "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", - "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", - "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", - "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", - "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", - "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", - "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", - "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", - "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", - "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", - "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", - "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", - "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", - "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", - "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", - "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", - "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", - "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", - "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", - "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", - "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd" - ], - "markers": "python_version >= '3.10'", - "version": "==12.1.0" - }, "prompt-toolkit": { "hashes": [ "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", @@ -1239,11 +815,11 @@ }, "proto-plus": { "hashes": [ - "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82", - "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4" + "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", + "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc" ], "markers": "python_version >= '3.7'", - "version": "==1.27.0" + "version": "==1.27.1" }, "protobuf": { "hashes": [ @@ -1357,20 +933,6 @@ "markers": "python_version >= '3.7'", "version": "==2.9.9" }, - "ptyprocess": { - "hashes": [ - "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", - "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" - ], - "version": "==0.7.0" - }, - "pure-eval": { - "hashes": [ - "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", - "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42" - ], - "version": "==0.2.3" - }, "pyasn1": { "hashes": [ "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", @@ -1444,22 +1006,6 @@ "markers": "python_version >= '3.5'", "version": "==7.45.7" }, - "pygments": { - "hashes": [ - "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", - "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" - ], - "markers": "python_version >= '3.8'", - "version": "==2.19.2" - }, - "pyhamcrest": { - "hashes": [ - "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316", - "sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29" - ], - "markers": "python_version >= '3.5'", - "version": "==2.0.2" - }, "pyjwt": { "hashes": [ "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd", @@ -1502,65 +1048,6 @@ "markers": "python_version >= '3.8'", "version": "==1.0.1" }, - "pyyaml": { - "hashes": [ - "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff", - "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", - "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", - "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", - "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", - "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", - "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", - "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", - "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", - "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", - "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a", - "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", - "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", - "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", - "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", - "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", - "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", - "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a", - "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", - "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", - "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", - "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", - "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", - "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", - "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", - "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", - "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", - "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", - "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", - "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706", - "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", - "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", - "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", - "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083", - "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", - "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", - "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", - "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", - "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", - "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", - "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", - "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", - "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", - "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", - "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5", - "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d", - "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", - "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", - "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", - "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", - "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", - "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", - "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4" - ], - "markers": "python_version >= '3.8'", - "version": "==6.0.2" - }, "qrcode": { "hashes": [ "sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a", @@ -1569,14 +1056,6 @@ "markers": "python_version >= '3.7'", "version": "==7.4.2" }, - "rapid-router": { - "hashes": [ - "sha256:736a37ef2a9a2592add7ba0a885c08228d81fe15a773a194ecb93cece793c26c", - "sha256:b18545684c1707c9889c8e1bdaf68db84b9a7364da6273c4de5e04d30116ce37" - ], - "index": "pypi", - "version": "==7.6.18" - }, "redis": { "extras": [ "hiredis" @@ -1689,14 +1168,6 @@ "markers": "python_version >= '3.8'", "version": "==2024.11.6" }, - "reportlab": { - "hashes": [ - "sha256:58e11be387457928707c12153b7e41e52533a5da3f587b15ba8f8fd0805c6ee2", - "sha256:fc6283048ddd0781a9db1d671715990e6aa059c8d40ec9baf34294c4bd583a36" - ], - "markers": "python_version >= '3.7' and python_version < '4'", - "version": "==4.4.2" - }, "requests": { "hashes": [ "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", @@ -1706,14 +1177,6 @@ "markers": "python_version >= '3.9'", "version": "==2.32.5" }, - "requests-toolbelt": { - "hashes": [ - "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", - "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.0.0" - }, "rsa": { "hashes": [ "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", @@ -1730,14 +1193,6 @@ "markers": "python_version >= '3.8'", "version": "==0.11.3" }, - "setuptools": { - "hashes": [ - "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", - "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173" - ], - "markers": "python_version >= '3.9'", - "version": "==80.10.2" - }, "six": { "hashes": [ "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", @@ -1754,13 +1209,6 @@ "markers": "python_version >= '3.8'", "version": "==0.5.5" }, - "stack-data": { - "hashes": [ - "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", - "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695" - ], - "version": "==0.6.3" - }, "tablib": { "hashes": [ "sha256:9a6930037cfe0f782377963ca3f2b1dae3fd4cdbf0883848f22f1447e7bb718b", @@ -1769,14 +1217,6 @@ "markers": "python_version >= '3.9'", "version": "==3.7.0" }, - "traitlets": { - "hashes": [ - "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", - "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f" - ], - "markers": "python_version >= '3.8'", - "version": "==5.14.3" - }, "typing-extensions": { "hashes": [ "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", @@ -1828,45 +1268,21 @@ }, "wcwidth": { "hashes": [ - "sha256:1efe1361b83b0ff7877b81ba57c8562c99cf812158b778988ce17ec061095695", - "sha256:f89c103c949a693bf563377b2153082bf58e309919dfb7f27b04d862a0089333" + "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", + "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159" ], "markers": "python_version >= '3.8'", - "version": "==0.5.0" - }, - "wheel": { - "hashes": [ - "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", - "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803" - ], - "markers": "python_version >= '3.9'", - "version": "==0.46.3" - }, - "whitenoise": { - "hashes": [ - "sha256:8c4a7c9d384694990c26f3047e118c691557481d624f069b7f7752a2f735d609", - "sha256:c8a489049b7ee9889617bb4c274a153f3d979e8f51d2efd0f5b403caf41c57df" - ], - "markers": "python_version >= '3.9'", - "version": "==6.9.0" - }, - "zipp": { - "hashes": [ - "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", - "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" - ], - "markers": "python_version >= '3.9'", - "version": "==3.23.0" + "version": "==0.6.0" } }, "develop": { "asgiref": { "hashes": [ - "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", - "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d" + "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", + "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133" ], "markers": "python_version >= '3.9'", - "version": "==3.11.0" + "version": "==3.11.1" }, "astroid": { "hashes": [ @@ -1918,11 +1334,11 @@ }, "botocore-stubs": { "hashes": [ - "sha256:96cf63b6949264f078f5009fa72932c6fd06a88c7bde4e21ed96df6c37e8edac", - "sha256:e21dadac08ee134cc0713884545c01b8eb1552c77e0d0c55c05e9584590e2b28" + "sha256:9423110fb0e391834bd2ed44ae5f879d8cb370a444703d966d30842ce2bcb5f0", + "sha256:dbeac2f744df6b814ce83ec3f3777b299a015cbea57a2efc41c33b8c38265825" ], "markers": "python_version >= '3.9'", - "version": "==1.42.38" + "version": "==1.42.41" }, "celery-types": { "hashes": [ @@ -2073,101 +1489,115 @@ "toml" ], "hashes": [ - "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", - "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e", - "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", - "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28", - "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", - "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", - "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", - "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c", - "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", - "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e", - "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", - "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3", - "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", - "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1", - "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", - "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", - "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2", - "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", - "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", - "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", - "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", - "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", - "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f", - "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", - "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", - "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f", - "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", - "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5", - "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b", - "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", - "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", - "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", - "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", - "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", - "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", - "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", - "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad", - "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", - "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", - "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", - "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31", - "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", - "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", - "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", - "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b", - "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031", - "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", - "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b", - "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", - "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", - "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", - "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", - "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", - "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", - "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc", - "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896", - "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", - "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99", - "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", - "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", - "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", - "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", - "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", - "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", - "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", - "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508", - "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", - "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", - "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e", - "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", - "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059", - "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c", - "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", - "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", - "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", - "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", - "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", - "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", - "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", - "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8", - "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", - "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", - "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", - "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", - "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", - "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", - "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b", - "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", - "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", - "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", - "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", - "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99" + "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", + "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", + "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", + "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", + "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", + "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", + "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", + "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", + "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", + "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", + "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", + "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", + "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", + "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", + "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", + "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", + "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", + "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", + "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", + "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", + "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", + "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", + "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", + "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", + "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", + "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", + "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", + "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", + "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", + "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", + "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", + "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", + "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", + "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", + "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", + "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", + "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", + "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", + "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", + "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", + "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", + "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", + "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", + "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", + "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", + "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", + "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", + "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", + "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", + "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", + "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", + "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", + "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", + "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", + "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", + "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", + "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", + "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", + "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", + "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", + "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", + "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", + "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", + "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", + "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", + "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", + "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", + "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", + "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", + "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", + "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", + "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", + "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", + "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", + "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", + "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", + "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", + "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", + "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", + "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", + "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", + "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", + "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", + "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", + "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", + "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", + "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", + "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", + "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", + "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", + "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", + "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", + "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", + "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", + "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", + "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", + "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", + "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", + "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", + "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", + "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", + "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", + "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", + "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", + "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", + "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0" ], "markers": "python_version >= '3.10'", - "version": "==7.13.2" + "version": "==7.13.4" }, "dill": { "hashes": [ diff --git a/codeforlife/settings/django.py b/codeforlife/settings/django.py index ff4ee774..330c0d77 100644 --- a/codeforlife/settings/django.py +++ b/codeforlife/settings/django.py @@ -235,6 +235,11 @@ def get_databases(): WSGI_APPLICATION = "application.django_wsgi" +# Custom user model +# https://docs.djangoproject.com/en/6.0/topics/auth/customizing/#auth-custom-user + +AUTH_USER_MODEL = "user.User" + # Password validation # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators @@ -275,9 +280,6 @@ def get_databases(): "django.contrib.messages", "django.contrib.sites", "django.contrib.staticfiles", - "game", # TODO: remove - "portal", # TODO: remove - "common", # TODO: remove "src", "codeforlife.user", "corsheaders", diff --git a/codeforlife/user/fixtures/google_users.json b/codeforlife/user/fixtures/google_users.json index 6a075828..f56b4119 100644 --- a/codeforlife/user/fixtures/google_users.json +++ b/codeforlife/user/fixtures/google_users.json @@ -1,6 +1,6 @@ [ { - "model": "auth.user", + "model": "user.user", "pk": 34, "fields": { "first_name": "Google", @@ -11,7 +11,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 34, "fields": { "user": 34, @@ -21,7 +21,7 @@ } }, { - "model": "common.teacher", + "model": "user.teacher", "pk": 13, "fields": { "user": 34, diff --git a/codeforlife/user/fixtures/independent.json b/codeforlife/user/fixtures/independent.json index 00e5c7bd..71d2f958 100644 --- a/codeforlife/user/fixtures/independent.json +++ b/codeforlife/user/fixtures/independent.json @@ -1,6 +1,6 @@ [ { - "model": "auth.user", + "model": "user.user", "pk": 28, "fields": { "first_name": "Indy", @@ -11,7 +11,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 28, "fields": { "user": 28, @@ -19,7 +19,7 @@ } }, { - "model": "common.student", + "model": "user.student", "pk": 18, "fields": { "user": 28, @@ -28,7 +28,7 @@ } }, { - "model": "auth.user", + "model": "user.user", "pk": 30, "fields": { "first_name": "Indy", @@ -39,7 +39,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 30, "fields": { "user": 30, @@ -47,7 +47,7 @@ } }, { - "model": "common.student", + "model": "user.student", "pk": 20, "fields": { "user": 30, diff --git a/codeforlife/user/fixtures/non_school_teacher.json b/codeforlife/user/fixtures/non_school_teacher.json index 55f015c4..1bcc2b7b 100644 --- a/codeforlife/user/fixtures/non_school_teacher.json +++ b/codeforlife/user/fixtures/non_school_teacher.json @@ -1,6 +1,6 @@ [ { - "model": "auth.user", + "model": "user.user", "pk": 22, "fields": { "first_name": "John", @@ -11,7 +11,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 22, "fields": { "user": 22, @@ -19,7 +19,7 @@ } }, { - "model": "common.teacher", + "model": "user.teacher", "pk": 5, "fields": { "user": 22, @@ -27,7 +27,7 @@ } }, { - "model": "auth.user", + "model": "user.user", "pk": 33, "fields": { "first_name": "Unverified", @@ -38,7 +38,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 33, "fields": { "user": 33, @@ -46,11 +46,11 @@ } }, { - "model": "common.teacher", + "model": "user.teacher", "pk": 12, "fields": { "user": 33, "new_user": 33 } } -] \ No newline at end of file +] diff --git a/codeforlife/user/fixtures/school_1.json b/codeforlife/user/fixtures/school_1.json index 9955f700..444fa023 100644 --- a/codeforlife/user/fixtures/school_1.json +++ b/codeforlife/user/fixtures/school_1.json @@ -1,6 +1,6 @@ [ { - "model": "common.school", + "model": "user.school", "pk": 2, "fields": { "name": "School 1", @@ -9,7 +9,7 @@ } }, { - "model": "auth.user", + "model": "user.user", "pk": 23, "fields": { "first_name": "John", @@ -20,7 +20,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 23, "fields": { "user": 23, @@ -28,7 +28,7 @@ } }, { - "model": "common.teacher", + "model": "user.teacher", "pk": 6, "fields": { "user": 23, @@ -37,7 +37,7 @@ } }, { - "model": "common.class", + "model": "user.class", "pk": 6, "fields": { "name": "Class 1 @ School 1", @@ -47,7 +47,7 @@ } }, { - "model": "auth.user", + "model": "user.user", "pk": 27, "fields": { "first_name": "Student1", @@ -56,7 +56,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 27, "fields": { "user": 27, @@ -64,7 +64,7 @@ } }, { - "model": "common.student", + "model": "user.student", "pk": 17, "fields": { "user": 27, @@ -73,7 +73,7 @@ } }, { - "model": "auth.user", + "model": "user.user", "pk": 24, "fields": { "first_name": "Jane", @@ -84,7 +84,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 24, "fields": { "user": 24, @@ -92,7 +92,7 @@ } }, { - "model": "common.teacher", + "model": "user.teacher", "pk": 7, "fields": { "user": 24, @@ -102,7 +102,7 @@ } }, { - "model": "common.class", + "model": "user.class", "pk": 7, "fields": { "name": "Class 2 @ School 1", @@ -111,7 +111,7 @@ } }, { - "model": "auth.user", + "model": "user.user", "pk": 29, "fields": { "first_name": "Student2", @@ -120,7 +120,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 29, "fields": { "user": 29, @@ -128,7 +128,7 @@ } }, { - "model": "common.student", + "model": "user.student", "pk": 19, "fields": { "user": 29, @@ -137,7 +137,7 @@ } }, { - "model": "common.class", + "model": "user.class", "pk": 10, "fields": { "name": "Class 3 @ School 1", diff --git a/codeforlife/user/fixtures/school_2.json b/codeforlife/user/fixtures/school_2.json index 2fd9b02a..9711cadf 100644 --- a/codeforlife/user/fixtures/school_2.json +++ b/codeforlife/user/fixtures/school_2.json @@ -1,6 +1,6 @@ [ { - "model": "common.school", + "model": "user.school", "pk": 3, "fields": { "name": "School 2", @@ -9,7 +9,7 @@ } }, { - "model": "auth.user", + "model": "user.user", "pk": 25, "fields": { "first_name": "John", @@ -20,7 +20,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 25, "fields": { "user": 25, @@ -117,7 +117,7 @@ } }, { - "model": "common.teacher", + "model": "user.teacher", "pk": 8, "fields": { "user": 25, @@ -126,7 +126,7 @@ } }, { - "model": "common.class", + "model": "user.class", "pk": 8, "fields": { "name": "Class 1 @ School 2", @@ -135,7 +135,7 @@ } }, { - "model": "auth.user", + "model": "user.user", "pk": 26, "fields": { "first_name": "Jane", @@ -146,7 +146,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 26, "fields": { "user": 26, @@ -163,7 +163,7 @@ } }, { - "model": "common.teacher", + "model": "user.teacher", "pk": 9, "fields": { "user": 26, @@ -173,7 +173,7 @@ } }, { - "model": "common.class", + "model": "user.class", "pk": 9, "fields": { "name": "Class 2 @ School 2", @@ -181,4 +181,4 @@ "teacher": 9 } } -] \ No newline at end of file +] diff --git a/codeforlife/user/fixtures/school_3.json b/codeforlife/user/fixtures/school_3.json index ea2c1173..bbd1ec10 100644 --- a/codeforlife/user/fixtures/school_3.json +++ b/codeforlife/user/fixtures/school_3.json @@ -1,6 +1,6 @@ [ { - "model": "common.school", + "model": "user.school", "pk": 4, "fields": { "name": "School 3", @@ -9,7 +9,7 @@ } }, { - "model": "auth.user", + "model": "user.user", "pk": 31, "fields": { "first_name": "Peter", @@ -20,7 +20,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 31, "fields": { "user": 31, @@ -28,7 +28,7 @@ } }, { - "model": "common.teacher", + "model": "user.teacher", "pk": 10, "fields": { "user": 31, @@ -38,7 +38,7 @@ } }, { - "model": "auth.user", + "model": "user.user", "pk": 32, "fields": { "first_name": "Doctor", @@ -49,7 +49,7 @@ } }, { - "model": "common.userprofile", + "model": "user.userprofile", "pk": 32, "fields": { "user": 32, @@ -57,7 +57,7 @@ } }, { - "model": "common.teacher", + "model": "user.teacher", "pk": 11, "fields": { "user": 32, @@ -65,4 +65,4 @@ "school": 4 } } -] \ No newline at end of file +] diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index c48171a9..ca28de0e 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,9 +1,21 @@ -# Generated by Django 5.1.10 on 2025-08-12 10:29 +# Generated by Django 5.1.15 on 2026-02-10 14:27 import codeforlife.models.encrypted_char_field -import codeforlife.user.models.user +import codeforlife.user.models.user.admin_school_teacher +import codeforlife.user.models.user.contactable +import codeforlife.user.models.user.google +import codeforlife.user.models.user.independent +import codeforlife.user.models.user.non_admin_school_teacher +import codeforlife.user.models.user.non_school_teacher +import codeforlife.user.models.user.school_teacher +import codeforlife.user.models.user.student +import codeforlife.user.models.user.teacher import django.contrib.auth.models +import django.contrib.auth.validators import django.db.models.deletion +import django.utils.timezone +import django_countries.fields +from django.conf import settings from django.db import migrations, models @@ -13,52 +25,327 @@ class Migration(migrations.Migration): dependencies = [ ("auth", "0012_alter_user_first_name_max_length"), - ("common", "0057_teacher_teacher__is_admin"), ] operations = [ + migrations.CreateModel( + name="Class", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200)), + ("access_code", models.CharField(max_length=5, null=True)), + ("classmates_data_viewable", models.BooleanField(default=False)), + ("always_accept_requests", models.BooleanField(default=False)), + ("accept_requests_until", models.DateTimeField(null=True)), + ( + "creation_time", + models.DateTimeField(default=django.utils.timezone.now, null=True), + ), + ("is_active", models.BooleanField(default=True)), + ], + options={ + "verbose_name_plural": "classes", + }, + ), + migrations.CreateModel( + name="DailyActivity", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateField(default=django.utils.timezone.now)), + ("csv_click_count", models.PositiveIntegerField(default=0)), + ("login_cards_click_count", models.PositiveIntegerField(default=0)), + ( + "primary_coding_club_downloads", + models.PositiveIntegerField(default=0), + ), + ( + "python_coding_club_downloads", + models.PositiveIntegerField(default=0), + ), + ("level_control_submits", models.PositiveBigIntegerField(default=0)), + ("teacher_lockout_resets", models.PositiveIntegerField(default=0)), + ("indy_lockout_resets", models.PositiveIntegerField(default=0)), + ( + "school_student_lockout_resets", + models.PositiveIntegerField(default=0), + ), + ( + "anonymised_unverified_teachers", + models.PositiveIntegerField(default=0), + ), + ( + "anonymised_unverified_independents", + models.PositiveIntegerField(default=0), + ), + ], + options={ + "verbose_name_plural": "Daily activities", + }, + ), + migrations.CreateModel( + name="School", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200, unique=True)), + ( + "country", + django_countries.fields.CountryField( + blank=True, max_length=2, null=True + ), + ), + ("county", models.CharField(blank=True, max_length=50, null=True)), + ( + "creation_time", + models.DateTimeField(default=django.utils.timezone.now, null=True), + ), + ("is_active", models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name="TotalActivity", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("teacher_registrations", models.PositiveIntegerField(default=0)), + ("student_registrations", models.PositiveIntegerField(default=0)), + ("independent_registrations", models.PositiveIntegerField(default=0)), + ( + "anonymised_unverified_teachers", + models.PositiveIntegerField(default=0), + ), + ( + "anonymised_unverified_independents", + models.PositiveIntegerField(default=0), + ), + ], + options={ + "verbose_name_plural": "Total activity", + }, + ), migrations.CreateModel( name="User", - fields=[], + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], options={ - "proxy": True, - "indexes": [], - "constraints": [], + "abstract": False, }, - bases=("auth.user", models.Model), managers=[ ("objects", django.contrib.auth.models.UserManager()), ], ), migrations.CreateModel( - name="SchoolTeacher", + name="ContactableUser", fields=[], options={ "proxy": True, "indexes": [], "constraints": [], }, - bases=("common.teacher",), + bases=("user.user",), + managers=[ + ( + "objects", + codeforlife.user.models.user.contactable.ContactableUserManager(), + ), + ], ), migrations.CreateModel( - name="Independent", + name="StudentUser", fields=[], options={ "proxy": True, "indexes": [], "constraints": [], }, - bases=("common.student",), + bases=("user.user",), + managers=[ + ("objects", codeforlife.user.models.user.student.StudentUserManager()), + ], ), migrations.CreateModel( - name="NonSchoolTeacher", - fields=[], + name="AuthFactor", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("type", models.TextField(choices=[("otp", "one-time password")])), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="auth_factors", + to=settings.AUTH_USER_MODEL, + ), + ), + ], options={ - "proxy": True, - "indexes": [], - "constraints": [], + "unique_together": {("user", "type")}, + }, + ), + migrations.CreateModel( + name="OtpBypassToken", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "token", + codeforlife.models.encrypted_char_field.EncryptedCharField( + help_text="The encrypted equivalent of the token.", + max_length=108, + verbose_name="token", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="otp_bypass_tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "OTP bypass token", + "verbose_name_plural": "OTP bypass tokens", }, - bases=("common.teacher",), ), migrations.CreateModel( name="Session", @@ -83,7 +370,7 @@ class Migration(migrations.Migration): blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, - to="user.user", + to=settings.AUTH_USER_MODEL, ), ), ], @@ -94,7 +381,7 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name="OtpBypassToken", + name="Student", fields=[ ( "id", @@ -105,30 +392,79 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), + ("login_id", models.CharField(max_length=64, null=True)), + ("blocked_time", models.DateTimeField(blank=True, null=True)), ( - "token", - codeforlife.models.encrypted_char_field.EncryptedCharField( - help_text="The encrypted equivalent of the token.", - max_length=104, - verbose_name="token", + "class_field", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="students", + to="user.class", ), ), ( - "user", + "new_user", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="new_student", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "pending_class_request", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="class_request", + to="user.class", + ), + ), + ], + ), + migrations.CreateModel( + name="JoinReleaseStudent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("action_type", models.CharField(max_length=64)), + ( + "action_time", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "student", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="otp_bypass_tokens", - to="user.user", + related_name="student", + to="user.student", ), ), ], + ), + migrations.CreateModel( + name="Independent", + fields=[], options={ - "verbose_name": "OTP bypass token", - "verbose_name_plural": "OTP bypass tokens", + "proxy": True, + "indexes": [], + "constraints": [], }, + bases=("user.student",), ), migrations.CreateModel( - name="AuthFactor", + name="Teacher", fields=[ ( "id", @@ -139,65 +475,214 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("type", models.TextField(choices=[("otp", "one-time password")])), + ("is_admin", models.BooleanField(default=False)), + ("blocked_time", models.DateTimeField(blank=True, null=True)), ( - "user", + "invited_by", models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="invited_teachers", + to="user.teacher", + ), + ), + ( + "new_user", + models.OneToOneField( + blank=True, + null=True, on_delete=django.db.models.deletion.CASCADE, - related_name="auth_factors", - to="user.user", + related_name="new_teacher", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "school", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="teacher_school", + to="user.school", ), ), ], + ), + migrations.AddField( + model_name="class", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_classes", + to="user.teacher", + ), + ), + migrations.AddField( + model_name="class", + name="teacher", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="class_teacher", + to="user.teacher", + ), + ), + migrations.CreateModel( + name="NonSchoolTeacher", + fields=[], options={ - "unique_together": {("user", "type")}, + "proxy": True, + "indexes": [], + "constraints": [], }, + bases=("user.teacher",), ), migrations.CreateModel( - name="ContactableUser", + name="SchoolTeacher", fields=[], options={ "proxy": True, "indexes": [], "constraints": [], }, - bases=("user.user",), - managers=[ - ("objects", codeforlife.user.models.user.ContactableUserManager()), + bases=("user.teacher",), + ), + migrations.CreateModel( + name="UserProfile", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("otp_secret", models.CharField(blank=True, max_length=40, null=True)), + ("last_otp_for_time", models.DateTimeField(blank=True, null=True)), + ("developer", models.BooleanField(default=False)), + ("is_verified", models.BooleanField(default=False)), + ("first_name", models.CharField(blank=True, max_length=200, null=True)), + ("_first_name", models.BinaryField(blank=True, null=True)), + ("last_name", models.CharField(blank=True, max_length=200, null=True)), + ("_last_name", models.BinaryField(blank=True, null=True)), + ("email", models.CharField(blank=True, max_length=200, null=True)), + ("_email", models.BinaryField(blank=True, null=True)), + ("username", models.CharField(blank=True, max_length=200, null=True)), + ("_username", models.BinaryField(blank=True, null=True)), + ( + "google_refresh_token", + codeforlife.models.encrypted_char_field.EncryptedCharField( + blank=True, max_length=1012, null=True + ), + ), + ("google_sub", models.CharField(blank=True, max_length=255, null=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], ), + migrations.AddField( + model_name="teacher", + name="user", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, to="user.userprofile" + ), + ), + migrations.AddField( + model_name="student", + name="user", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, to="user.userprofile" + ), + ), migrations.CreateModel( - name="StudentUser", + name="UserSession", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("login_time", models.DateTimeField(default=django.utils.timezone.now)), + ("login_type", models.CharField(max_length=100, null=True)), + ( + "class_field", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="user.class", + ), + ), + ( + "school", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="user.school", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="GoogleUser", fields=[], options={ "proxy": True, "indexes": [], "constraints": [], }, - bases=("user.user",), + bases=("user.contactableuser",), managers=[ - ("objects", codeforlife.user.models.user.StudentUserManager()), + ("objects", codeforlife.user.models.user.google.GoogleUserManager()), ], ), migrations.CreateModel( - name="AdminSchoolTeacher", + name="IndependentUser", fields=[], options={ "proxy": True, "indexes": [], "constraints": [], }, - bases=("user.schoolteacher",), + bases=("user.contactableuser",), + managers=[ + ( + "objects", + codeforlife.user.models.user.independent.IndependentUserManager(), + ), + ], ), migrations.CreateModel( - name="NonAdminSchoolTeacher", + name="TeacherUser", fields=[], options={ "proxy": True, "indexes": [], "constraints": [], }, - bases=("user.schoolteacher",), + bases=("user.contactableuser",), + managers=[ + ("objects", codeforlife.user.models.user.teacher.TeacherUserManager()), + ], ), migrations.CreateModel( name="SessionAuthFactor", @@ -233,43 +718,33 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name="GoogleUser", + name="AdminSchoolTeacher", fields=[], options={ "proxy": True, "indexes": [], "constraints": [], }, - bases=("user.contactableuser",), - managers=[ - ("objects", codeforlife.user.models.user.GoogleUserManager()), - ], + bases=("user.schoolteacher",), ), migrations.CreateModel( - name="IndependentUser", + name="NonAdminSchoolTeacher", fields=[], options={ "proxy": True, "indexes": [], "constraints": [], }, - bases=("user.contactableuser",), - managers=[ - ("objects", codeforlife.user.models.user.IndependentUserManager()), - ], + bases=("user.schoolteacher",), ), - migrations.CreateModel( - name="TeacherUser", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.contactableuser",), - managers=[ - ("objects", codeforlife.user.models.user.TeacherUserManager()), - ], + migrations.AddConstraint( + model_name="teacher", + constraint=models.CheckConstraint( + condition=models.Q( + ("is_admin", True), ("school__isnull", True), _negated=True + ), + name="teacher__is_admin", + ), ), migrations.CreateModel( name="NonSchoolTeacherUser", @@ -281,7 +756,10 @@ class Migration(migrations.Migration): }, bases=("user.teacheruser",), managers=[ - ("objects", codeforlife.user.models.user.NonSchoolTeacherUserManager()), + ( + "objects", + codeforlife.user.models.user.non_school_teacher.NonSchoolTeacherUserManager(), + ), ], ), migrations.CreateModel( @@ -294,7 +772,10 @@ class Migration(migrations.Migration): }, bases=("user.teacheruser",), managers=[ - ("objects", codeforlife.user.models.user.SchoolTeacherUserManager()), + ( + "objects", + codeforlife.user.models.user.school_teacher.SchoolTeacherUserManager(), + ), ], ), migrations.CreateModel( @@ -309,7 +790,7 @@ class Migration(migrations.Migration): managers=[ ( "objects", - codeforlife.user.models.user.AdminSchoolTeacherUserManager(), + codeforlife.user.models.user.admin_school_teacher.AdminSchoolTeacherUserManager(), ), ], ), @@ -325,7 +806,7 @@ class Migration(migrations.Migration): managers=[ ( "objects", - codeforlife.user.models.user.NonAdminSchoolTeacherUserManager(), + codeforlife.user.models.user.non_admin_school_teacher.NonAdminSchoolTeacherUserManager(), ), ], ), diff --git a/codeforlife/user/models/__init__.py b/codeforlife/user/models/__init__.py index f68e4461..fe9765ab 100644 --- a/codeforlife/user/models/__init__.py +++ b/codeforlife/user/models/__init__.py @@ -5,6 +5,7 @@ from .auth_factor import AuthFactor from .klass import Class, class_name_validators +from .other import DailyActivity, JoinReleaseStudent, TotalActivity, UserSession from .otp_bypass_token import OtpBypassToken from .school import School, school_name_validators from .session import Session diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index 46f6dc21..e1f8ac61 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -3,15 +3,24 @@ Created on 19/02/2024 at 21:54:04(+00:00). """ -# pylint: disable-next=unused-import -from common.models import Class # type: ignore[import-untyped] +import typing as t +from datetime import timedelta +from uuid import uuid4 + from django.core.validators import MaxLengthValidator, MinLengthValidator +from django.db import models +from django.utils import timezone from ...types import Validators from ...validators import ( UnicodeAlphanumericCharSetValidator, UppercaseAsciiAlphanumericCharSetValidator, ) +from .teacher import Teacher + +if t.TYPE_CHECKING: + from django.db.models import ManyToManyField + from game.models import Worksheet class_access_code_validators: Validators = [ MinLengthValidator(5), @@ -25,3 +34,101 @@ special_chars="-_", ) ] + + +class ClassModelManager(models.Manager): + def all_members(self, user): + members = [] + if hasattr(user, "teacher"): + members.append(user.teacher) + if user.teacher.has_school(): + classes = user.teacher.class_teacher.all() + for c in classes: + members.extend(c.students.all()) + else: + c = user.student.class_field + members.append(c.teacher) + members.extend(c.students.all()) + return members + + def get_original_queryset(self): + return super().get_queryset() + + # Filter out non active classes by default + def get_queryset(self): + return super().get_queryset().filter(is_active=True) + + +class Class(models.Model): + locked_worksheets: "ManyToManyField[Worksheet]" + + name = models.CharField(max_length=200) + teacher = models.ForeignKey( + Teacher, related_name="class_teacher", on_delete=models.CASCADE + ) + access_code = models.CharField(max_length=5, null=True) + classmates_data_viewable = models.BooleanField(default=False) + always_accept_requests = models.BooleanField(default=False) + accept_requests_until = models.DateTimeField(null=True) + creation_time = models.DateTimeField(default=timezone.now, null=True) + is_active = models.BooleanField(default=True) + created_by = models.ForeignKey( + Teacher, + null=True, + blank=True, + related_name="created_classes", + on_delete=models.SET_NULL, + ) + + objects = ClassModelManager() + + def __str__(self): + return self.name + + @property + def active_game(self): + games = self.game_set.filter(game_class=self, is_archived=False) + if len(games) >= 1: + assert ( + len(games) == 1 + ) # there should NOT be more than one active game + return games[0] + return None + + def has_students(self): + students = self.students.all() + return students.count() != 0 + + def get_requests_message(self): + if self.always_accept_requests: + external_requests_message = ( + "This class is currently set to always accept requests." + ) + elif ( + self.accept_requests_until is not None + and (self.accept_requests_until - timezone.now()) >= timedelta() + ): + external_requests_message = ( + "This class is accepting external requests until " + + self.accept_requests_until.strftime("%d-%m-%Y %H:%M") + + " " + + timezone.get_current_timezone_name() + ) + else: + external_requests_message = ( + "This class is not currently accepting external requests." + ) + + return external_requests_message + + def anonymise(self): + self.name = uuid4().hex + self.access_code = "" + self.is_active = False + self.save() + + # Remove independent students' requests to join this class + self.class_request.clear() + + class Meta(object): + verbose_name_plural = "classes" diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py new file mode 100644 index 00000000..58697a5f --- /dev/null +++ b/codeforlife/user/models/other.py @@ -0,0 +1,91 @@ +""" +© Ocado Group +Created on 10/02/2026 at 14:00:56(+00:00). + +Models that have been carried over from the old schema but are not yet fully +integrated into the new schema. These models are expected to be refactored and +integrated or removed in the new schema in the future. +""" + +from django.db import models +from django.utils import timezone + +from .klass import Class +from .school import School +from .student import Student +from .user import User + + +class UserSession(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + login_time = models.DateTimeField(default=timezone.now) + school = models.ForeignKey(School, null=True, on_delete=models.SET_NULL) + class_field = models.ForeignKey(Class, null=True, on_delete=models.SET_NULL) + login_type = models.CharField( + max_length=100, null=True + ) # for student login + + def __str__(self): + return f"{self.user} login: {self.login_time} type: {self.login_type}" + + +class JoinReleaseStudent(models.Model): + """ + To keep track when a student is released to be independent student or + joins a class to be a school student. + """ + + JOIN = "join" + RELEASE = "release" + + student = models.ForeignKey( + Student, related_name="student", on_delete=models.CASCADE + ) + # either "release" or "join" + action_type = models.CharField(max_length=64) + action_time = models.DateTimeField(default=timezone.now) + + +class TotalActivity(models.Model): + """ + A model to record total activity. Meant to only have one entry which + records all total activity. An example of this is total ever registrations. + """ + + teacher_registrations = models.PositiveIntegerField(default=0) + student_registrations = models.PositiveIntegerField(default=0) + independent_registrations = models.PositiveIntegerField(default=0) + anonymised_unverified_teachers = models.PositiveIntegerField(default=0) + anonymised_unverified_independents = models.PositiveIntegerField(default=0) + + class Meta: + verbose_name_plural = "Total activity" + + def __str__(self): + return "Total activity" + + +class DailyActivity(models.Model): + """ + A model to record sets of daily activity. Currently used to record the + amount of student details download clicks, through the CSV and login + cards methods, per day. + """ + + date = models.DateField(default=timezone.now) + csv_click_count = models.PositiveIntegerField(default=0) + login_cards_click_count = models.PositiveIntegerField(default=0) + primary_coding_club_downloads = models.PositiveIntegerField(default=0) + python_coding_club_downloads = models.PositiveIntegerField(default=0) + level_control_submits = models.PositiveBigIntegerField(default=0) + teacher_lockout_resets = models.PositiveIntegerField(default=0) + indy_lockout_resets = models.PositiveIntegerField(default=0) + school_student_lockout_resets = models.PositiveIntegerField(default=0) + anonymised_unverified_teachers = models.PositiveIntegerField(default=0) + anonymised_unverified_independents = models.PositiveIntegerField(default=0) + + class Meta: + verbose_name_plural = "Daily activities" + + def __str__(self): + return f"Activity on {self.date}: CSV clicks: {self.csv_click_count}, login cards clicks: {self.login_cards_click_count}, primary pack downloads: {self.primary_coding_club_downloads}, python pack downloads: {self.python_coding_club_downloads}, level control submits: {self.level_control_submits}, teacher lockout resets: {self.teacher_lockout_resets}, indy lockout resets: {self.indy_lockout_resets}, school student lockout resets: {self.school_student_lockout_resets}, unverified teachers anonymised: {self.anonymised_unverified_teachers}, unverified independents anonymised: {self.anonymised_unverified_independents}" diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index a934b0d4..283db471 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -3,8 +3,11 @@ Created on 20/02/2024 at 15:37:52(+00:00). """ -# pylint: disable-next=unused-import -from common.models import School # type: ignore[import-untyped] +from uuid import uuid4 + +from django.db import models +from django.utils import timezone +from django_countries.fields import CountryField from ...types import Validators from ...validators import UnicodeAlphanumericCharSetValidator @@ -16,3 +19,51 @@ special_chars="'.", ) ] + + +class SchoolModelManager(models.Manager): + def get_original_queryset(self): + return super().get_queryset() + + # Filter out inactive schools by default + def get_queryset(self): + return super().get_queryset().filter(is_active=True) + + +class School(models.Model): + name = models.CharField(max_length=200, unique=True) + country = CountryField( + blank_label="(select country)", null=True, blank=True + ) + # TODO: Create an Address model to house address details + county = models.CharField(max_length=50, blank=True, null=True) + creation_time = models.DateTimeField(default=timezone.now, null=True) + is_active = models.BooleanField(default=True) + + objects = SchoolModelManager() + + def __str__(self): + return self.name + + def classes(self): + teachers = self.teacher_school.all() + if teachers: + classes = [] + for teacher in teachers: + if teacher.class_teacher.all(): + classes.extend(list(teacher.class_teacher.all())) + return classes + return None + + def admins(self): + teachers = self.teacher_school.all() + return ( + [teacher for teacher in teachers if teacher.is_admin] + if teachers + else None + ) + + def anonymise(self): + self.name = uuid4().hex + self.is_active = False + self.save() diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index fd48bfea..fe6d7a90 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -6,16 +6,87 @@ """ import typing as t +from uuid import uuid4 -from common.models import Student, StudentModelManager from django.db import models +from .klass import Class +from .user import User, UserProfile + if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta else: TypedModelMeta = object +class StudentModelManager(models.Manager): + def get_random_username(self): + while True: + random_username = uuid4().hex[:30] # generate a random username + if not User.objects.filter(username=random_username).exists(): + return random_username + + def schoolFactory(self, klass, name, password, login_id=None): + user = User.objects.create_user( + username=self.get_random_username(), + password=password, + first_name=name, + ) + user_profile = UserProfile.objects.create(user=user) + + return Student.objects.create( + class_field=klass, + user=user_profile, + new_user=user, + login_id=login_id, + ) + + def independentStudentFactory(self, name, email, password): + user = User.objects.create_user( + username=email, email=email, password=password, first_name=name + ) + + user_profile = UserProfile.objects.create(user=user) + + return Student.objects.create(user=user_profile, new_user=user) + + +class Student(models.Model): + class_field = models.ForeignKey( + Class, + related_name="students", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + # hashed uuid used for the unique direct login url + login_id = models.CharField(max_length=64, null=True) + user = models.OneToOneField(UserProfile, on_delete=models.CASCADE) + new_user = models.OneToOneField( + User, + related_name="new_student", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + pending_class_request = models.ForeignKey( + Class, + related_name="class_request", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + blocked_time = models.DateTimeField(null=True, blank=True) + + objects = StudentModelManager() + + def is_independent(self): + return not self.class_field + + def __str__(self): + return f"{self.new_user.first_name} {self.new_user.last_name}" + + # TODO: This model is legacy and should be removed in the new data schema. class Independent(Student): """An independent student.""" diff --git a/codeforlife/user/models/teacher.py b/codeforlife/user/models/teacher.py index 2bd39269..97c5b062 100644 --- a/codeforlife/user/models/teacher.py +++ b/codeforlife/user/models/teacher.py @@ -7,19 +7,97 @@ import typing as t -from common.models import Teacher, TeacherModelManager from django.db import models from django.db.models import Q -from .klass import Class from .school import School -from .student import Student +from .user import User, UserProfile if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta else: TypedModelMeta = object + +class TeacherModelManager(models.Manager): + def factory(self, first_name, last_name, email, password): + user = User.objects.create_user( + username=email, + email=email, + password=password, + first_name=first_name, + last_name=last_name, + ) + + user_profile = UserProfile.objects.create(user=user) + + return Teacher.objects.create(user=user_profile, new_user=user) + + def get_original_queryset(self): + return super().get_queryset() + + # Filter out non active teachers by default + def get_queryset(self): + return super().get_queryset().filter(new_user__is_active=True) + + +class Teacher(models.Model): + user = models.OneToOneField(UserProfile, on_delete=models.CASCADE) + new_user = models.OneToOneField( + User, + related_name="new_teacher", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + school = models.ForeignKey( + School, + related_name="teacher_school", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + is_admin = models.BooleanField(default=False) + blocked_time = models.DateTimeField(null=True, blank=True) + invited_by = models.ForeignKey( + "self", + related_name="invited_teachers", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + + objects = TeacherModelManager() + + class Meta: + constraints = [ + models.CheckConstraint( + check=~models.Q( + school__isnull=True, + is_admin=True, + ), + name="teacher__is_admin", + ) + ] + + def teaches(self, userprofile): + if hasattr(userprofile, "student"): + student = userprofile.student + return ( + not student.is_independent() + and student.class_field.teacher == self + ) + + def has_school(self): + return self.school is not (None or "") + + def has_class(self): + return self.class_teacher.exists() + + def __str__(self): + return f"{self.new_user.first_name} {self.new_user.last_name}" + + AnyTeacher = t.TypeVar("AnyTeacher", bound=Teacher) @@ -55,6 +133,9 @@ def student_users(self): @property def students(self): """All students the teacher can query.""" + # pylint: disable-next=import-outside-toplevel + from .student import Student + return Student.objects.filter( **( {"class_field__teacher__school": self.school} @@ -66,6 +147,9 @@ def students(self): @property def classes(self): """All classes the teacher can query.""" + # pylint: disable-next=import-outside-toplevel + from .klass import Class + return Class.objects.filter(teacher__school=self.school) @property @@ -104,7 +188,7 @@ def school_users(self): {"new_student__class_field__teacher__school": self.school} if self.is_admin else {"new_student__class_field__teacher": self} - ) + ), ) | Q( # school-teacher-users new_student__isnull=True, diff --git a/codeforlife/user/models/user/google.py b/codeforlife/user/models/user/google.py index 95f252c8..cee91e46 100644 --- a/codeforlife/user/models/user/google.py +++ b/codeforlife/user/models/user/google.py @@ -7,14 +7,13 @@ import typing as t -from common.models import UserProfile from django.db.models.query import QuerySet from requests import Session from requests.adapters import HTTPAdapter, Retry from ....types import JsonDict from .contactable import ContactableUser, ContactableUserManager -from .user import User +from .user import User, UserProfile if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta diff --git a/codeforlife/user/models/user/independent.py b/codeforlife/user/models/user/independent.py index fc21e896..956aec79 100644 --- a/codeforlife/user/models/user/independent.py +++ b/codeforlife/user/models/user/independent.py @@ -7,12 +7,11 @@ import typing as t -from common.models import TotalActivity, UserProfile from django.db.models import F from django.db.models.query import QuerySet from .contactable import ContactableUser, ContactableUserManager -from .user import User +from .user import User, UserProfile if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta @@ -50,9 +49,12 @@ def create_user( # type: ignore[override] **extra_fields, ): """Create an independent-user.""" - # pylint: disable-next=import-outside-toplevel + # pylint: disable=import-outside-toplevel + from ..other import TotalActivity from ..student import Student + # pylint: enable=import-outside-toplevel + assert "username" not in extra_fields user = super().create_user( diff --git a/codeforlife/user/models/user/student.py b/codeforlife/user/models/user/student.py index 2b2bd29b..ce716e22 100644 --- a/codeforlife/user/models/user/student.py +++ b/codeforlife/user/models/user/student.py @@ -8,17 +8,16 @@ import string import typing as t -from common.models import TotalActivity, UserProfile from django.db.models import F from django.db.models.query import QuerySet from django.utils.crypto import get_random_string -from ..klass import Class -from .user import User, UserManager +from .user import User, UserManager, UserProfile if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta + from ..klass import Class from ..student import Student else: TypedModelMeta = object @@ -29,12 +28,14 @@ # pylint: disable-next=missing-class-docstring,too-few-public-methods class StudentUserManager(UserManager["StudentUser"]): def create_user( # type: ignore[override] - self, first_name: str, klass: Class, **extra_fields + self, first_name: str, klass: "Class", **extra_fields ): """Create a student-user.""" - # pylint: disable-next=import-outside-toplevel + # pylint: disable=import-outside-toplevel + from ..other import TotalActivity from ..student import Student + # pylint: enable=import-outside-toplevel # pylint: disable=protected-access password = StudentUser._get_random_password() login_id, hashed_login_id = StudentUser._get_random_login_id() diff --git a/codeforlife/user/models/user/teacher.py b/codeforlife/user/models/user/teacher.py index a2bb45d0..b0a71aac 100644 --- a/codeforlife/user/models/user/teacher.py +++ b/codeforlife/user/models/user/teacher.py @@ -7,13 +7,12 @@ import typing as t -from common.models import TotalActivity, UserProfile from django.db.models import F from django.db.models.query import QuerySet from ..school import School from .contactable import ContactableUser, ContactableUserManager -from .user import User +from .user import User, UserProfile if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta @@ -40,9 +39,12 @@ def create_user( # type: ignore[override] **extra_fields, ): """Create a teacher-user.""" - # pylint: disable-next=import-outside-toplevel + # pylint: disable=import-outside-toplevel + from ..other import TotalActivity from ..teacher import Teacher + # pylint: enable=import-outside-toplevel + assert "username" not in extra_fields # pylint: disable=duplicate-code diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index b7d4c1f5..2d22a2f6 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -6,17 +6,17 @@ """ import typing as t -from datetime import datetime - -from common.models import UserProfile +from datetime import datetime, timedelta # pylint: disable-next=imported-auth-user -from django.contrib.auth.models import User as _User +from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import UserManager as _UserManager +from django.db import models from django.db.models.query import QuerySet +from django.utils import timezone from pyotp import TOTP -from ....models import AbstractBaseUser +from ....models import AbstractBaseUser, EncryptedCharField from ....types import Validators from ....validators import UnicodeAlphanumericCharSetValidator @@ -57,7 +57,10 @@ class Meta(TypedModelMeta): # pylint: disable-next=too-many-ancestors -class User(_AbstractBaseUser, _User): +class User( + _AbstractBaseUser, + AbstractUser, # TODO: remove this inheritance in new schema +): """A proxy to Django's user class.""" _password: t.Optional[str] @@ -67,13 +70,10 @@ class User(_AbstractBaseUser, _User): # pylint: disable-next=line-too-long otp_bypass_tokens: QuerySet["OtpBypassToken"] # type: ignore[assignment,misc] session: "Session" # type: ignore[assignment] - userprofile: UserProfile + userprofile: "UserProfile" credential_fields = frozenset(["email", "password"]) - class Meta(TypedModelMeta): - proxy = True - @property def is_authenticated(self): return ( @@ -203,3 +203,39 @@ def filter_users(self, queryset: QuerySet[User]): # pylint: disable-next=missing-function-docstring def get_queryset(self): return self.filter_users(super().get_queryset().filter(is_active=True)) + + +class UserProfile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + + otp_secret = models.CharField(max_length=40, null=True, blank=True) + last_otp_for_time = models.DateTimeField(null=True, blank=True) + developer = models.BooleanField(default=False) + is_verified = models.BooleanField(default=False) + + # TODO: Make not nullable once data has been transferred + first_name = models.CharField(max_length=200, null=True, blank=True) + _first_name = models.BinaryField(null=True, blank=True) + last_name = models.CharField(max_length=200, null=True, blank=True) + _last_name = models.BinaryField(null=True, blank=True) + email = models.CharField(max_length=200, null=True, blank=True) + _email = models.BinaryField(null=True, blank=True) + # TODO: Make not nullable once data has been transferred + username = models.CharField(max_length=200, null=True, blank=True) + _username = models.BinaryField(null=True, blank=True) + + # Google. + google_refresh_token = EncryptedCharField( + # pylint: disable-next=protected-access + max_length=1000 + len(EncryptedCharField._prefix), + null=True, + blank=True, + ) + google_sub = models.CharField(max_length=255, null=True, blank=True) + + def __str__(self): + return f"{self.user.first_name} {self.user.last_name}" + + def joined_recently(self): + now = timezone.now() + return now - timedelta(days=7) <= self.user.date_joined diff --git a/settings.py b/settings.py index fe271014..18cff3eb 100644 --- a/settings.py +++ b/settings.py @@ -26,9 +26,6 @@ "django.contrib.staticfiles", "django.contrib.sites", "codeforlife.user", - "game", # TODO: remove this. - "common", # TODO: remove this. - "portal", # TODO: remove this. ] MIDDLEWARE = [ From 43718f0d06819fb9ac4d8ed237419495c7ab81d3 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 10 Feb 2026 15:54:01 +0000 Subject: [PATCH 02/58] fix type errors --- codeforlife/forms.py | 2 +- codeforlife/tests/api_client.py | 3 ++- codeforlife/user/models/klass.py | 6 ++---- codeforlife/user/models/student.py | 8 ++++++-- codeforlife/user/models/teacher.py | 25 +++++++++++++++-------- codeforlife/user/serializers/user_test.py | 9 +++++--- codeforlife/user/views/klass_test.py | 7 ++++--- codeforlife/user/views/school_test.py | 6 ++++-- codeforlife/user/views/user_test.py | 10 +++++---- 9 files changed, 47 insertions(+), 29 deletions(-) diff --git a/codeforlife/forms.py b/codeforlife/forms.py index 674fecb9..5241f5a7 100644 --- a/codeforlife/forms.py +++ b/codeforlife/forms.py @@ -62,7 +62,7 @@ def clean(self): "Incorrect user class.", code="incorrect_user_class", ) - if not user.is_active: + if not user.is_active: # type: ignore[attr-defined] raise ValidationError( "User is not active", code="user_not_active", diff --git a/codeforlife/tests/api_client.py b/codeforlife/tests/api_client.py index 7c6a865c..78d183ea 100644 --- a/codeforlife/tests/api_client.py +++ b/codeforlife/tests/api_client.py @@ -558,7 +558,8 @@ def login_as(self, user: "TypedUser", password: str = "password"): auth_user = self.login_teacher(user.email, password) elif isinstance(user, StudentUser): auth_user = self.login_student( - user.student.class_field.access_code, + # pylint: disable-next=line-too-long + user.student.class_field.access_code, # type: ignore[union-attr,arg-type] user.first_name, password, ) diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index e1f8ac61..d84875c0 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -20,7 +20,6 @@ if t.TYPE_CHECKING: from django.db.models import ManyToManyField - from game.models import Worksheet class_access_code_validators: Validators = [ MinLengthValidator(5), @@ -60,8 +59,6 @@ def get_queryset(self): class Class(models.Model): - locked_worksheets: "ManyToManyField[Worksheet]" - name = models.CharField(max_length=200) teacher = models.ForeignKey( Teacher, related_name="class_teacher", on_delete=models.CASCADE @@ -87,7 +84,8 @@ def __str__(self): @property def active_game(self): - games = self.game_set.filter(game_class=self, is_archived=False) + # pylint: disable-next=line-too-long + games = self.game_set.filter(game_class=self, is_archived=False) # type: ignore[attr-defined] if len(games) >= 1: assert ( len(games) == 1 diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index fe6d7a90..989abb72 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -84,6 +84,9 @@ def is_independent(self): return not self.class_field def __str__(self): + if self.new_user is None: + return super().__str__() + return f"{self.new_user.first_name} {self.new_user.last_name}" @@ -91,7 +94,7 @@ def __str__(self): class Independent(Student): """An independent student.""" - class_field: None + class_field: None # type: ignore[assignment] class Meta(TypedModelMeta): proxy = True @@ -102,4 +105,5 @@ class Manager(StudentModelManager): def get_queryset(self): return super().get_queryset().filter(class_field__isnull=True) - objects: models.Manager["Independent"] = Manager() + # pylint: disable-next=line-too-long + objects: models.Manager["Independent"] = Manager() # type: ignore[assignment,misc] diff --git a/codeforlife/user/models/teacher.py b/codeforlife/user/models/teacher.py index 97c5b062..f50b80b0 100644 --- a/codeforlife/user/models/teacher.py +++ b/codeforlife/user/models/teacher.py @@ -95,6 +95,9 @@ def has_class(self): return self.class_teacher.exists() def __str__(self): + if self.new_user is None: + return super().__str__() + return f"{self.new_user.first_name} {self.new_user.last_name}" @@ -104,7 +107,7 @@ def __str__(self): class SchoolTeacher(Teacher): """A teacher that is in a school.""" - school: School + school: School # type: ignore[assignment] class Meta(TypedModelMeta): proxy = True @@ -114,7 +117,8 @@ class Manager(TeacherModelManager): def get_queryset(self): return super().get_queryset().filter(school__isnull=False) - objects: models.Manager["SchoolTeacher"] = Manager() + # pylint: disable-next=line-too-long + objects: models.Manager["SchoolTeacher"] = Manager() # type: ignore[assignment,misc] @property def student_users(self): @@ -200,7 +204,7 @@ def school_users(self): class AdminSchoolTeacher(SchoolTeacher): """An admin-teacher that is in a school.""" - is_admin: t.Literal[True] + is_admin: t.Literal[True] # type: ignore[assignment] class Meta(TypedModelMeta): proxy = True @@ -210,7 +214,8 @@ class Manager(SchoolTeacher.Manager): def get_queryset(self): return super().get_queryset().filter(is_admin=True) - objects: models.Manager["AdminSchoolTeacher"] = Manager() + # pylint: disable-next=line-too-long + objects: models.Manager["AdminSchoolTeacher"] = Manager() # type: ignore[misc] @property def is_last_admin(self): @@ -225,7 +230,7 @@ def is_last_admin(self): class NonAdminSchoolTeacher(SchoolTeacher): """A non-admin-teacher that is in a school.""" - is_admin: t.Literal[False] + is_admin: t.Literal[False] # type: ignore[assignment] class Meta(TypedModelMeta): proxy = True @@ -235,14 +240,15 @@ class Manager(SchoolTeacher.Manager): def get_queryset(self): return super().get_queryset().filter(is_admin=False) - objects: models.Manager["NonAdminSchoolTeacher"] = Manager() + # pylint: disable-next=line-too-long + objects: models.Manager["NonAdminSchoolTeacher"] = Manager() # type: ignore[misc] class NonSchoolTeacher(Teacher): """A teacher that is not in a school.""" - school: None - is_admin: t.Literal[False] + school: None # type: ignore[assignment] + is_admin: t.Literal[False] # type: ignore[assignment] class Meta(TypedModelMeta): proxy = True @@ -252,7 +258,8 @@ class Manager(TeacherModelManager): def get_queryset(self): return super().get_queryset().filter(school__isnull=True) - objects: models.Manager["NonSchoolTeacher"] = Manager() + # pylint: disable-next=line-too-long + objects: models.Manager["NonSchoolTeacher"] = Manager() # type: ignore[assignment,misc] # pylint: disable-next=invalid-name diff --git a/codeforlife/user/serializers/user_test.py b/codeforlife/user/serializers/user_test.py index 72b94e86..63bdd917 100644 --- a/codeforlife/user/serializers/user_test.py +++ b/codeforlife/user/serializers/user_test.py @@ -25,7 +25,8 @@ def test_to_representation__teacher(self): "requesting_to_join_class": None, "teacher": { "id": user.teacher.id, - "school": user.teacher.school.id, + # pylint: disable-next=line-too-long + "school": user.teacher.school.id, # type: ignore[union-attr] "is_admin": user.teacher.is_admin, }, "student": None, @@ -46,8 +47,10 @@ def test_to_representation__student(self): "teacher": None, "student": { "id": user.student.id, - "klass": user.student.class_field.access_code, - "school": user.student.class_field.teacher.school.id, + # pylint: disable-next=line-too-long + "klass": user.student.class_field.access_code, # type: ignore[union-attr] + # pylint: disable-next=line-too-long + "school": user.student.class_field.teacher.school.id, # type: ignore[union-attr] }, }, # TODO: remove in new schema. diff --git a/codeforlife/user/views/klass_test.py b/codeforlife/user/views/klass_test.py index f9275a70..071931ef 100644 --- a/codeforlife/user/views/klass_test.py +++ b/codeforlife/user/views/klass_test.py @@ -47,7 +47,7 @@ def test_get_queryset__student(self): assert user self.assert_get_queryset( - values=[user.student.class_field], + values=[user.student.class_field], # type: ignore[list-item] request=self.client.request_factory.get(user=user), ) @@ -69,7 +69,8 @@ def test_retrieve(self): assert user self.client.login_as(user, password="Password1") - self.client.retrieve(model=user.student.class_field) + # pylint: disable-next=line-too-long + self.client.retrieve(model=user.student.class_field) # type: ignore[type-var] def test_list(self): """Can successfully list classes.""" @@ -130,6 +131,6 @@ def test_list__teacher(self): self.client.login_as(user) self.client.list( - models=classes, + models=classes, # type: ignore[arg-type] filters={"teacher": str(user.teacher.id)}, ) diff --git a/codeforlife/user/views/school_test.py b/codeforlife/user/views/school_test.py index e569f51e..cf09bc77 100644 --- a/codeforlife/user/views/school_test.py +++ b/codeforlife/user/views/school_test.py @@ -63,7 +63,8 @@ def test_get_queryset__student(self): assert user self.assert_get_queryset( - values=[user.student.class_field.teacher.school], + # pylint: disable-next=line-too-long + values=[user.student.class_field.teacher.school], # type: ignore[union-attr,list-item] request=self.client.request_factory.get(user=user), ) @@ -78,7 +79,8 @@ def test_get_queryset__independent(self): assert user self.assert_get_queryset( - values=[user.student.pending_class_request.teacher.school], + # pylint: disable-next=line-too-long + values=[user.student.pending_class_request.teacher.school], # type: ignore[union-attr,list-item] request=self.client.request_factory.get(user=user), ) diff --git a/codeforlife/user/views/user_test.py b/codeforlife/user/views/user_test.py index 4643579e..97717bf9 100644 --- a/codeforlife/user/views/user_test.py +++ b/codeforlife/user/views/user_test.py @@ -56,16 +56,18 @@ def test_get_queryset__student(self): user = StudentUser.objects.first() assert user - users = [ + users: t.List[User] = [ user, - user.student.class_field.teacher.new_user, + # pylint: disable-next=line-too-long + user.student.class_field.teacher.new_user, # type: ignore[union-attr,list-item] *list( User.objects.exclude(pk=user.pk).filter( - new_student__in=user.student.class_field.students.all() + # pylint: disable-next=line-too-long + new_student__in=user.student.class_field.students.all() # type: ignore[union-attr] ) ), ] - users.sort(key=lambda user: user.pk) + users.sort(key=lambda user: user.pk) # type: ignore[union-attr] self.assert_get_queryset( values=users, From a5cbd1fb1f956fe0eb16a7cf2aa85e8b2fa28262 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 11 Feb 2026 14:03:06 +0000 Subject: [PATCH 03/58] fix linting errors --- Pipfile | 4 +- Pipfile.lock | 25 +- codeforlife/mail.py | 4 +- codeforlife/server.py | 2 +- codeforlife/tasks/bigquery_test.py | 2 +- codeforlife/tests/api_client.py | 4 +- codeforlife/tests/api_request_factory.py | 10 +- codeforlife/tests/model_view_set.py | 2 +- codeforlife/tests/model_view_set_client.py | 12 +- codeforlife/user/migrations/0001_initial.py | 2 +- codeforlife/user/models/auth_factor.py | 16 +- codeforlife/user/models/klass.py | 80 ++++- codeforlife/user/models/other.py | 173 ++++++++-- codeforlife/user/models/otp_bypass_token.py | 15 +- codeforlife/user/models/school.py | 48 ++- .../user/models/session_auth_factor.py | 23 +- codeforlife/user/models/student.py | 70 +++- codeforlife/user/models/teacher.py | 298 ------------------ codeforlife/user/models/teacher/__init__.py | 47 +++ .../user/models/teacher/admin_school.py | 41 +++ .../user/models/teacher/non_admin_school.py | 32 ++ codeforlife/user/models/teacher/non_school.py | 33 ++ codeforlife/user/models/teacher/school.py | 115 +++++++ codeforlife/user/models/teacher/teacher.py | 139 ++++++++ .../user/models/user/admin_school_teacher.py | 7 +- codeforlife/user/models/user/google.py | 10 +- codeforlife/user/models/user/independent.py | 9 +- .../models/user/non_admin_school_teacher.py | 7 +- .../user/models/user/non_school_teacher.py | 7 +- .../user/models/user/school_teacher.py | 13 +- codeforlife/user/models/user/teacher.py | 13 +- codeforlife/user/models/user/user.py | 3 + codeforlife/validators/char_set/ascii.py | 1 + codeforlife/validators/char_set/base.py | 1 + codeforlife/validators/char_set/unicode.py | 1 + codeforlife/validators/enhanced_regex.py | 2 +- 36 files changed, 824 insertions(+), 447 deletions(-) delete mode 100644 codeforlife/user/models/teacher.py create mode 100644 codeforlife/user/models/teacher/__init__.py create mode 100644 codeforlife/user/models/teacher/admin_school.py create mode 100644 codeforlife/user/models/teacher/non_admin_school.py create mode 100644 codeforlife/user/models/teacher/non_school.py create mode 100644 codeforlife/user/models/teacher/school.py create mode 100644 codeforlife/user/models/teacher/teacher.py diff --git a/Pipfile b/Pipfile index e43b8293..02a90ead 100644 --- a/Pipfile +++ b/Pipfile @@ -42,8 +42,8 @@ django-extensions = "==3.2.1" django-test-migrations = "==1.2.0" pyparsing = "==3.0.9" pydot = "==1.4.2" -pylint = "==3.2.7" -pylint-django = "==2.5.5" +pylint = "==4.0.4" +pylint-django = "==2.7.0" isort = "==5.13.2" mypy = "==1.15.0" django-stubs = {version = "==5.1.3", extras = ["compatible-mypy"]} diff --git a/Pipfile.lock b/Pipfile.lock index 0c7242d3..742fd686 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "89ff1d169339b93c750837aed9cf2af6808f5fd41db2092b6c926b57228205e4" + "sha256": "b1b213110b3c6f5a403ba8b3442fb4aa9679d65403d6189c9cf382ea4f742cc5" }, "pipfile-spec": 6, "requires": { @@ -1286,11 +1286,11 @@ }, "astroid": { "hashes": [ - "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a", - "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25" + "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", + "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0" ], - "markers": "python_full_version >= '3.8.0'", - "version": "==3.2.4" + "markers": "python_full_version >= '3.10.0'", + "version": "==4.0.4" }, "black": { "hashes": [ @@ -1868,21 +1868,20 @@ }, "pylint": { "hashes": [ - "sha256:02f4aedeac91be69fb3b4bea997ce580a4ac68ce58b89eaefeaf06749df73f4b", - "sha256:1b7a721b575eaeaa7d39db076b6e7743c993ea44f57979127c517c6c572c803e" + "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", + "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2" ], "index": "pypi", - "markers": "python_full_version >= '3.8.0'", - "version": "==3.2.7" + "markers": "python_full_version >= '3.10.0'", + "version": "==4.0.4" }, "pylint-django": { "hashes": [ - "sha256:2f339e4bf55776958283395c5139c37700c91bd5ef1d8251ef6ac88b5abbba9b", - "sha256:5abd5c2228e0e5e2a4cb6d0b4fc1d1cef1e773d0be911412f4dd4fc1a1a440b7" + "sha256:76ef7e7bbbcf7ee86adbb2beac0ffaa7232509a17bf4a488d81467a1bbaa215b" ], "index": "pypi", - "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==2.5.5" + "markers": "python_version >= '3.9' and python_version < '4.0'", + "version": "==2.7.0" }, "pylint-plugin-utils": { "hashes": [ diff --git a/codeforlife/mail.py b/codeforlife/mail.py index 89fbd6a0..306f54f3 100644 --- a/codeforlife/mail.py +++ b/codeforlife/mail.py @@ -38,7 +38,7 @@ class Preference: is_opted_in: t.Optional[bool] = None -# pylint: disable-next=too-many-arguments +# pylint: disable-next=too-many-arguments,too-many-positional-arguments def add_contact( email: str, opt_in_type: t.Optional[ @@ -218,7 +218,7 @@ class EmailAttachment: content: str -# pylint: disable-next=too-many-arguments +# pylint: disable-next=too-many-arguments,too-many-positional-arguments def send_mail( campaign_id: int, to_addresses: t.List[str], diff --git a/codeforlife/server.py b/codeforlife/server.py index 9c8d8aeb..9a669175 100644 --- a/codeforlife/server.py +++ b/codeforlife/server.py @@ -56,7 +56,7 @@ def django_dev_server_is_running(self): and sys.argv[1] == "runserver" ) - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def __init__( self, mode: Mode = t.cast(Mode, os.getenv("SERVER_MODE", "django")), diff --git a/codeforlife/tasks/bigquery_test.py b/codeforlife/tasks/bigquery_test.py index 8b0e8a0f..51374627 100644 --- a/codeforlife/tasks/bigquery_test.py +++ b/codeforlife/tasks/bigquery_test.py @@ -180,7 +180,7 @@ def _assert_csv_file_loaded_into_bigquery( # settings - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def _test_settings( self, code: str, diff --git a/codeforlife/tests/api_client.py b/codeforlife/tests/api_client.py index 78d183ea..e6cbbf93 100644 --- a/codeforlife/tests/api_client.py +++ b/codeforlife/tests/api_client.py @@ -119,7 +119,7 @@ def _make_assertions(): StatusCodeAssertion = t.Optional[t.Union[int, t.Callable[[int], bool]]] - # pylint: disable=too-many-arguments,redefined-builtin + # pylint: disable=too-many-arguments,redefined-builtin,too-many-positional-arguments def generic( self, @@ -304,7 +304,7 @@ def options( # type: ignore[override] **extra, ) - # pylint: enable=too-many-arguments,redefined-builtin + # pylint: enable=too-many-arguments,redefined-builtin,too-many-positional-arguments class APIClient( diff --git a/codeforlife/tests/api_request_factory.py b/codeforlife/tests/api_request_factory.py index 98720b99..4c7e1a6b 100644 --- a/codeforlife/tests/api_request_factory.py +++ b/codeforlife/tests/api_request_factory.py @@ -59,7 +59,8 @@ def request(self, user: t.Optional[AnyAbstractBaseUser] = None, **kwargs): return request - # pylint: disable-next=too-many-arguments + # pylint: disable=too-many-arguments,too-many-positional-arguments + def generic( # type: ignore[override] self, method: str, @@ -100,7 +101,6 @@ def get( # type: ignore[override] ), ) - # pylint: disable-next=too-many-arguments def post( # type: ignore[override] self, path: t.Optional[str] = None, @@ -126,7 +126,6 @@ def post( # type: ignore[override] ), ) - # pylint: disable-next=too-many-arguments def put( # type: ignore[override] self, path: t.Optional[str] = None, @@ -152,7 +151,6 @@ def put( # type: ignore[override] ), ) - # pylint: disable-next=too-many-arguments def patch( # type: ignore[override] self, path: t.Optional[str] = None, @@ -178,7 +176,6 @@ def patch( # type: ignore[override] ), ) - # pylint: disable-next=too-many-arguments def delete( # type: ignore[override] self, path: t.Optional[str] = None, @@ -204,7 +201,6 @@ def delete( # type: ignore[override] ), ) - # pylint: disable-next=too-many-arguments def options( # type: ignore[override] self, path: t.Optional[str] = None, @@ -230,6 +226,8 @@ def options( # type: ignore[override] ), ) + # pylint: enable=too-many-arguments,too-many-positional-arguments + class APIRequestFactory( BaseAPIRequestFactory[Request[AnyUser], AnyUser], diff --git a/codeforlife/tests/model_view_set.py b/codeforlife/tests/model_view_set.py index dc54b0b7..9b2fb342 100644 --- a/codeforlife/tests/model_view_set.py +++ b/codeforlife/tests/model_view_set.py @@ -98,7 +98,7 @@ def reverse_action( # Assertion Helpers # -------------------------------------------------------------------------- - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def assert_serialized_model_equals_json_model( self, model: AnyModel, diff --git a/codeforlife/tests/model_view_set_client.py b/codeforlife/tests/model_view_set_client.py index d1551eff..fd68ebf4 100644 --- a/codeforlife/tests/model_view_set_client.py +++ b/codeforlife/tests/model_view_set_client.py @@ -206,7 +206,7 @@ def retrieve( return response - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def list( self, models: t.Collection[AnyModel], @@ -268,7 +268,7 @@ def _make_assertions(response_json: JsonDict): # Partial Update (HTTP PATCH) # -------------------------------------------------------------------------- - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def _assert_update( self, model: AnyModel, @@ -282,7 +282,7 @@ def _assert_update( model, json_model, action, request_method, contains_subset=partial ) - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def partial_update( self, model: AnyModel, @@ -333,7 +333,7 @@ def partial_update( return response - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def bulk_partial_update( self, models: t.Union[t.List[AnyModel], QuerySet[AnyModel]], @@ -394,7 +394,7 @@ def _make_assertions(json_models: t.List[JsonDict]): # Update (HTTP PUT) # -------------------------------------------------------------------------- - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def update( self, model: AnyModel, @@ -445,7 +445,7 @@ def update( return response - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def bulk_update( self, models: t.Union[t.List[AnyModel], QuerySet[AnyModel]], diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index ca28de0e..29705ce5 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.15 on 2026-02-10 14:27 +# Generated by Django 5.1.15 on 2026-02-11 10:37 import codeforlife.models.encrypted_char_field import codeforlife.user.models.user.admin_school_teacher diff --git a/codeforlife/user/models/auth_factor.py b/codeforlife/user/models/auth_factor.py index f7ae2e33..67c12217 100644 --- a/codeforlife/user/models/auth_factor.py +++ b/codeforlife/user/models/auth_factor.py @@ -12,10 +12,14 @@ from ...types import Validators from ...validators import AsciiNumericCharSetValidator -from .user import User if t.TYPE_CHECKING: # pragma: no cover + from django_stubs_ext.db.models import TypedModelMeta + from .session_auth_factor import SessionAuthFactor + from .user import User +else: + TypedModelMeta = object class AuthFactor(models.Model): @@ -35,15 +39,17 @@ class Type(models.TextChoices): OTP = "otp", _("one-time password") - user = models.ForeignKey( - User, + user: "User" + user = models.ForeignKey( # type: ignore[assignment] + "user.User", related_name="auth_factors", on_delete=models.CASCADE, ) - type = models.TextField(choices=Type.choices) + type: str + type = models.TextField(choices=Type.choices) # type: ignore[assignment] - class Meta: + class Meta(TypedModelMeta): unique_together = ["user", "type"] def __str__(self): diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index d84875c0..e525e3d3 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -16,10 +16,16 @@ UnicodeAlphanumericCharSetValidator, UppercaseAsciiAlphanumericCharSetValidator, ) -from .teacher import Teacher -if t.TYPE_CHECKING: - from django.db.models import ManyToManyField +if t.TYPE_CHECKING: # pragma: no cover + from datetime import datetime + + from django_stubs_ext.db.models import TypedModelMeta + + from .teacher import Teacher +else: + TypedModelMeta = object + class_access_code_validators: Validators = [ MinLengthValidator(5), @@ -36,7 +42,10 @@ class ClassModelManager(models.Manager): + """Manager for Class model.""" + def all_members(self, user): + """Get all members of the class associated with the user.""" members = [] if hasattr(user, "teacher"): members.append(user.teacher) @@ -51,26 +60,58 @@ def all_members(self, user): return members def get_original_queryset(self): + """Get the original queryset without filtering.""" return super().get_queryset() - # Filter out non active classes by default def get_queryset(self): + """Filter out non active classes by default.""" return super().get_queryset().filter(is_active=True) class Class(models.Model): + """A class.""" + name = models.CharField(max_length=200) - teacher = models.ForeignKey( - Teacher, related_name="class_teacher", on_delete=models.CASCADE + + teacher: "Teacher" + teacher = models.ForeignKey( # type: ignore[assignment] + "user.Teacher", + related_name="class_teacher", + on_delete=models.CASCADE, ) - access_code = models.CharField(max_length=5, null=True) - classmates_data_viewable = models.BooleanField(default=False) - always_accept_requests = models.BooleanField(default=False) - accept_requests_until = models.DateTimeField(null=True) - creation_time = models.DateTimeField(default=timezone.now, null=True) - is_active = models.BooleanField(default=True) - created_by = models.ForeignKey( - Teacher, + + access_code: t.Optional[str] + access_code = models.CharField( # type: ignore[assignment] + max_length=5, + null=True, + ) + + classmates_data_viewable: bool + classmates_data_viewable = models.BooleanField( # type: ignore[assignment] + default=False + ) + + always_accept_requests: bool + always_accept_requests = models.BooleanField( # type: ignore[assignment] + default=False + ) + + accept_requests_until: t.Optional["datetime"] + accept_requests_until = models.DateTimeField( # type: ignore[assignment] + null=True + ) + + creation_time: t.Optional["datetime"] + creation_time = models.DateTimeField( # type: ignore[assignment] + default=timezone.now, null=True + ) + + is_active: bool + is_active = models.BooleanField(default=True) # type: ignore[assignment] + + created_by: t.Optional["Teacher"] + created_by = models.ForeignKey( # type: ignore[assignment] + "user.Teacher", null=True, blank=True, related_name="created_classes", @@ -84,6 +125,10 @@ def __str__(self): @property def active_game(self): + """ + Get the active game for the class, if it exists. There should only be + one active game per class. + """ # pylint: disable-next=line-too-long games = self.game_set.filter(game_class=self, is_archived=False) # type: ignore[attr-defined] if len(games) >= 1: @@ -94,10 +139,12 @@ def active_game(self): return None def has_students(self): + """Check if the class has any students.""" students = self.students.all() return students.count() != 0 def get_requests_message(self): + """Get the message regarding the class's request acceptance status.""" if self.always_accept_requests: external_requests_message = ( "This class is currently set to always accept requests." @@ -108,6 +155,7 @@ def get_requests_message(self): ): external_requests_message = ( "This class is accepting external requests until " + # pylint: disable-next=no-member + self.accept_requests_until.strftime("%d-%m-%Y %H:%M") + " " + timezone.get_current_timezone_name() @@ -120,13 +168,15 @@ def get_requests_message(self): return external_requests_message def anonymise(self): + """Anonymise the class.""" self.name = uuid4().hex self.access_code = "" self.is_active = False self.save() # Remove independent students' requests to join this class + # pylint: disable-next=no-member self.class_request.clear() - class Meta(object): + class Meta(TypedModelMeta): verbose_name_plural = "classes" diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py index 58697a5f..9b8437b7 100644 --- a/codeforlife/user/models/other.py +++ b/codeforlife/user/models/other.py @@ -7,23 +7,58 @@ integrated or removed in the new schema in the future. """ +import typing as t + from django.db import models from django.utils import timezone -from .klass import Class -from .school import School -from .student import Student -from .user import User +if t.TYPE_CHECKING: # pragma: no cover + from datetime import datetime + + from django_stubs_ext.db.models import TypedModelMeta + + from .klass import Class + from .school import School + from .student import Student + from .user import User +else: + TypedModelMeta = object class UserSession(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) - login_time = models.DateTimeField(default=timezone.now) - school = models.ForeignKey(School, null=True, on_delete=models.SET_NULL) - class_field = models.ForeignKey(Class, null=True, on_delete=models.SET_NULL) - login_type = models.CharField( - max_length=100, null=True - ) # for student login + """A model to track user sessions.""" + + user: "User" + user = models.ForeignKey( # type: ignore[assignment] + "user.User", + on_delete=models.CASCADE, + ) + + login_time: "datetime" + login_time = models.DateTimeField( # type: ignore[assignment] + default=timezone.now + ) + + school: t.Optional["School"] + school = models.ForeignKey( # type: ignore[assignment] + "user.School", + null=True, + on_delete=models.SET_NULL, + ) + + class_field: t.Optional["Class"] + class_field = models.ForeignKey( # type: ignore[assignment] + "user.Class", + null=True, + on_delete=models.SET_NULL, + ) + + # for student login + login_type: t.Optional[str] + login_type = models.CharField( # type: ignore[assignment] + max_length=100, + null=True, + ) def __str__(self): return f"{self.user} login: {self.login_time} type: {self.login_type}" @@ -38,12 +73,21 @@ class JoinReleaseStudent(models.Model): JOIN = "join" RELEASE = "release" - student = models.ForeignKey( - Student, related_name="student", on_delete=models.CASCADE + student: "Student" + student = models.ForeignKey( # type: ignore[assignment] + "user.Student", + related_name="student", + on_delete=models.CASCADE, ) + # either "release" or "join" - action_type = models.CharField(max_length=64) - action_time = models.DateTimeField(default=timezone.now) + action_type: str + action_type = models.CharField(max_length=64) # type: ignore[assignment] + + action_time: "datetime" + action_time = models.DateTimeField( # type: ignore[assignment] + default=timezone.now + ) class TotalActivity(models.Model): @@ -52,13 +96,32 @@ class TotalActivity(models.Model): records all total activity. An example of this is total ever registrations. """ - teacher_registrations = models.PositiveIntegerField(default=0) - student_registrations = models.PositiveIntegerField(default=0) - independent_registrations = models.PositiveIntegerField(default=0) - anonymised_unverified_teachers = models.PositiveIntegerField(default=0) - anonymised_unverified_independents = models.PositiveIntegerField(default=0) + teacher_registrations: int + teacher_registrations = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + student_registrations: int + student_registrations = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + independent_registrations: int + independent_registrations = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + anonymised_unverified_teachers: int + anonymised_unverified_teachers = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + anonymised_unverified_independents: int + anonymised_unverified_independents = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] - class Meta: + class Meta(TypedModelMeta): verbose_name_plural = "Total activity" def __str__(self): @@ -72,20 +135,62 @@ class DailyActivity(models.Model): cards methods, per day. """ - date = models.DateField(default=timezone.now) - csv_click_count = models.PositiveIntegerField(default=0) - login_cards_click_count = models.PositiveIntegerField(default=0) - primary_coding_club_downloads = models.PositiveIntegerField(default=0) - python_coding_club_downloads = models.PositiveIntegerField(default=0) - level_control_submits = models.PositiveBigIntegerField(default=0) - teacher_lockout_resets = models.PositiveIntegerField(default=0) - indy_lockout_resets = models.PositiveIntegerField(default=0) - school_student_lockout_resets = models.PositiveIntegerField(default=0) - anonymised_unverified_teachers = models.PositiveIntegerField(default=0) - anonymised_unverified_independents = models.PositiveIntegerField(default=0) - - class Meta: + date: "datetime" + date = models.DateField(default=timezone.now) # type: ignore[assignment] + + csv_click_count: int + csv_click_count = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + login_cards_click_count: int + login_cards_click_count = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + primary_coding_club_downloads: int + primary_coding_club_downloads = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + python_coding_club_downloads: int + python_coding_club_downloads = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + level_control_submits: int + level_control_submits = models.PositiveBigIntegerField( + default=0 + ) # type: ignore[assignment] + + teacher_lockout_resets: int + teacher_lockout_resets = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + indy_lockout_resets: int + indy_lockout_resets = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + school_student_lockout_resets: int + school_student_lockout_resets = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + anonymised_unverified_teachers: int + anonymised_unverified_teachers = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + anonymised_unverified_independents: int + anonymised_unverified_independents = models.PositiveIntegerField( + default=0 + ) # type: ignore[assignment] + + class Meta(TypedModelMeta): verbose_name_plural = "Daily activities" def __str__(self): + # pylint: disable-next=line-too-long return f"Activity on {self.date}: CSV clicks: {self.csv_click_count}, login cards clicks: {self.login_cards_click_count}, primary pack downloads: {self.primary_coding_club_downloads}, python pack downloads: {self.python_coding_club_downloads}, level control submits: {self.level_control_submits}, teacher lockout resets: {self.teacher_lockout_resets}, indy lockout resets: {self.indy_lockout_resets}, school student lockout resets: {self.school_student_lockout_resets}, unverified teachers anonymised: {self.anonymised_unverified_teachers}, unverified independents anonymised: {self.anonymised_unverified_independents}" diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index db4a4438..a669caee 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -15,10 +15,11 @@ from ...models import EncryptedCharField from ...types import Validators from ...validators import CharSetValidatorBuilder -from .user import User -if t.TYPE_CHECKING: +if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta + + from .user import User else: TypedModelMeta = object @@ -40,7 +41,7 @@ class OtpBypassToken(models.Model): # pylint: disable-next=missing-class-docstring,too-few-public-methods class Manager(models.Manager["OtpBypassToken"]): - def bulk_create(self, user: User): # type: ignore[override] + def bulk_create(self, user: "User"): # type: ignore[override] """Bulk create OTP-bypass tokens. Args: @@ -66,13 +67,15 @@ def bulk_create(self, user: User): # type: ignore[override] objects: Manager = Manager() - user = models.ForeignKey( - User, + user: "User" + user = models.ForeignKey( # type: ignore[assignment] + "user.User", related_name="otp_bypass_tokens", on_delete=models.CASCADE, ) - token = EncryptedCharField( + token: str + token = EncryptedCharField( # type: ignore[assignment] _("token"), max_length=100, help_text=_("The encrypted equivalent of the token."), diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index 283db471..40980fd0 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -3,6 +3,7 @@ Created on 20/02/2024 at 15:37:52(+00:00). """ +import typing as t from uuid import uuid4 from django.db import models @@ -12,6 +13,10 @@ from ...types import Validators from ...validators import UnicodeAlphanumericCharSetValidator +if t.TYPE_CHECKING: # pragma: no cover + from datetime import datetime + + # TODO: add to School.name field-validators in new schema. school_name_validators: Validators = [ UnicodeAlphanumericCharSetValidator( @@ -22,23 +27,49 @@ class SchoolModelManager(models.Manager): + """Manager for School model.""" + def get_original_queryset(self): + """Get the original queryset without filtering.""" return super().get_queryset() - # Filter out inactive schools by default def get_queryset(self): + """Filter out inactive schools by default.""" return super().get_queryset().filter(is_active=True) class School(models.Model): - name = models.CharField(max_length=200, unique=True) - country = CountryField( - blank_label="(select country)", null=True, blank=True + """A school.""" + + name: str + name = models.CharField( # type: ignore[assignment] + max_length=200, + unique=True, + ) + + country: t.Optional[str] + country = CountryField( # type: ignore[assignment] + blank_label="(select country)", + null=True, + blank=True, ) + # TODO: Create an Address model to house address details - county = models.CharField(max_length=50, blank=True, null=True) - creation_time = models.DateTimeField(default=timezone.now, null=True) - is_active = models.BooleanField(default=True) + county: t.Optional[str] + county = models.CharField( # type: ignore[assignment] + max_length=50, + blank=True, + null=True, + ) + + creation_time: t.Optional["datetime"] + creation_time = models.DateTimeField( # type: ignore[assignment] + default=timezone.now, + null=True, + ) + + is_active: bool + is_active = models.BooleanField(default=True) # type: ignore[assignment] objects = SchoolModelManager() @@ -46,6 +77,7 @@ def __str__(self): return self.name def classes(self): + """Get all classes associated with the school.""" teachers = self.teacher_school.all() if teachers: classes = [] @@ -56,6 +88,7 @@ def classes(self): return None def admins(self): + """Get all admin teachers associated with the school.""" teachers = self.teacher_school.all() return ( [teacher for teacher in teachers if teacher.is_admin] @@ -64,6 +97,7 @@ def admins(self): ) def anonymise(self): + """Anonymize the school.""" self.name = uuid4().hex self.is_active = False self.save() diff --git a/codeforlife/user/models/session_auth_factor.py b/codeforlife/user/models/session_auth_factor.py index 60a9277a..6a76117f 100644 --- a/codeforlife/user/models/session_auth_factor.py +++ b/codeforlife/user/models/session_auth_factor.py @@ -3,28 +3,37 @@ Created on 20/02/2024 at 15:36:28(+00:00). """ +import typing as t + from django.db import models -from .auth_factor import AuthFactor -from .session import Session +if t.TYPE_CHECKING: # pragma: no cover + from django_stubs_ext.db.models import TypedModelMeta + + from .auth_factor import AuthFactor + from .session import Session +else: + TypedModelMeta = object class SessionAuthFactor(models.Model): """A pending authentication factor for a session.""" - session = models.ForeignKey( - Session, + session: "Session" + session = models.ForeignKey( # type: ignore[assignment] + "user.Session", related_name="auth_factors", on_delete=models.CASCADE, ) - auth_factor = models.ForeignKey( - AuthFactor, + auth_factor: "AuthFactor" + auth_factor = models.ForeignKey( # type: ignore[assignment] + "user.AuthFactor", related_name="sessions", on_delete=models.CASCADE, ) - class Meta: + class Meta(TypedModelMeta): unique_together = ["session", "auth_factor"] def __str__(self): diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index 989abb72..4489fc1c 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -10,23 +10,38 @@ from django.db import models -from .klass import Class -from .user import User, UserProfile +if t.TYPE_CHECKING: # pragma: no cover + from datetime import datetime -if t.TYPE_CHECKING: from django_stubs_ext.db.models import TypedModelMeta + + from .klass import Class + from .user import User, UserProfile else: TypedModelMeta = object class StudentModelManager(models.Manager): + """Manager for Student model.""" + def get_random_username(self): + """Generate a random username that does not already exist.""" + # NOTE: avoid circular imports by importing here + # pylint: disable-next=import-outside-toplevel + from .user import User + while True: random_username = uuid4().hex[:30] # generate a random username if not User.objects.filter(username=random_username).exists(): return random_username + # pylint: disable-next=invalid-name def schoolFactory(self, klass, name, password, login_id=None): + """Factory method to create a student user associated with a class.""" + # NOTE: avoid circular imports by importing here + # pylint: disable-next=import-outside-toplevel + from .user import User, UserProfile + user = User.objects.create_user( username=self.get_random_username(), password=password, @@ -41,7 +56,13 @@ def schoolFactory(self, klass, name, password, login_id=None): login_id=login_id, ) + # pylint: disable-next=invalid-name def independentStudentFactory(self, name, email, password): + """Factory method to create an independent student user.""" + # NOTE: avoid circular imports by importing here + # pylint: disable-next=import-outside-toplevel + from .user import User, UserProfile + user = User.objects.create_user( username=email, email=email, password=password, first_name=name ) @@ -52,35 +73,60 @@ def independentStudentFactory(self, name, email, password): class Student(models.Model): - class_field = models.ForeignKey( - Class, + """A student.""" + + class_field: t.Optional["Class"] + class_field = models.ForeignKey( # type: ignore[assignment] + "user.Class", related_name="students", null=True, blank=True, on_delete=models.CASCADE, ) + # hashed uuid used for the unique direct login url - login_id = models.CharField(max_length=64, null=True) - user = models.OneToOneField(UserProfile, on_delete=models.CASCADE) - new_user = models.OneToOneField( - User, + login_id: str + login_id = models.CharField( # type: ignore[assignment] + max_length=64, + null=True, + ) + + # pylint: disable=duplicate-code + user: "UserProfile" + user = models.OneToOneField( # type: ignore[assignment] + "user.UserProfile", + on_delete=models.CASCADE, + ) + + new_user: t.Optional["User"] + new_user = models.OneToOneField( # type: ignore[assignment] + "user.User", related_name="new_student", null=True, blank=True, on_delete=models.CASCADE, ) - pending_class_request = models.ForeignKey( - Class, + # pylint: enable=duplicate-code + + pending_class_request: t.Optional["Class"] + pending_class_request = models.ForeignKey( # type: ignore[assignment] + "user.Class", related_name="class_request", null=True, blank=True, on_delete=models.SET_NULL, ) - blocked_time = models.DateTimeField(null=True, blank=True) + + blocked_time: t.Optional["datetime"] + blocked_time = models.DateTimeField( # type: ignore[assignment] + null=True, + blank=True, + ) objects = StudentModelManager() def is_independent(self): + """Whether the student is independent (not associated with a class).""" return not self.class_field def __str__(self): diff --git a/codeforlife/user/models/teacher.py b/codeforlife/user/models/teacher.py deleted file mode 100644 index f50b80b0..00000000 --- a/codeforlife/user/models/teacher.py +++ /dev/null @@ -1,298 +0,0 @@ -# TODO: remove this in new system -# mypy: disable-error-code="import-untyped" -""" -© Ocado Group -Created on 05/02/2024 at 09:49:56(+00:00). -""" - -import typing as t - -from django.db import models -from django.db.models import Q - -from .school import School -from .user import User, UserProfile - -if t.TYPE_CHECKING: - from django_stubs_ext.db.models import TypedModelMeta -else: - TypedModelMeta = object - - -class TeacherModelManager(models.Manager): - def factory(self, first_name, last_name, email, password): - user = User.objects.create_user( - username=email, - email=email, - password=password, - first_name=first_name, - last_name=last_name, - ) - - user_profile = UserProfile.objects.create(user=user) - - return Teacher.objects.create(user=user_profile, new_user=user) - - def get_original_queryset(self): - return super().get_queryset() - - # Filter out non active teachers by default - def get_queryset(self): - return super().get_queryset().filter(new_user__is_active=True) - - -class Teacher(models.Model): - user = models.OneToOneField(UserProfile, on_delete=models.CASCADE) - new_user = models.OneToOneField( - User, - related_name="new_teacher", - null=True, - blank=True, - on_delete=models.CASCADE, - ) - school = models.ForeignKey( - School, - related_name="teacher_school", - null=True, - blank=True, - on_delete=models.SET_NULL, - ) - is_admin = models.BooleanField(default=False) - blocked_time = models.DateTimeField(null=True, blank=True) - invited_by = models.ForeignKey( - "self", - related_name="invited_teachers", - null=True, - blank=True, - on_delete=models.SET_NULL, - ) - - objects = TeacherModelManager() - - class Meta: - constraints = [ - models.CheckConstraint( - check=~models.Q( - school__isnull=True, - is_admin=True, - ), - name="teacher__is_admin", - ) - ] - - def teaches(self, userprofile): - if hasattr(userprofile, "student"): - student = userprofile.student - return ( - not student.is_independent() - and student.class_field.teacher == self - ) - - def has_school(self): - return self.school is not (None or "") - - def has_class(self): - return self.class_teacher.exists() - - def __str__(self): - if self.new_user is None: - return super().__str__() - - return f"{self.new_user.first_name} {self.new_user.last_name}" - - -AnyTeacher = t.TypeVar("AnyTeacher", bound=Teacher) - - -class SchoolTeacher(Teacher): - """A teacher that is in a school.""" - - school: School # type: ignore[assignment] - - class Meta(TypedModelMeta): - proxy = True - - # pylint: disable-next=missing-class-docstring - class Manager(TeacherModelManager): - def get_queryset(self): - return super().get_queryset().filter(school__isnull=False) - - # pylint: disable-next=line-too-long - objects: models.Manager["SchoolTeacher"] = Manager() # type: ignore[assignment,misc] - - @property - def student_users(self): - """All student-users the teacher can query.""" - # pylint: disable-next=import-outside-toplevel - from .user.student import StudentUser - - return StudentUser.objects.filter( - **( - {"new_student__class_field__teacher__school": self.school} - if self.is_admin - else {"new_student__class_field__teacher": self} - ) - ).prefetch_related("new_student") - - @property - def students(self): - """All students the teacher can query.""" - # pylint: disable-next=import-outside-toplevel - from .student import Student - - return Student.objects.filter( - **( - {"class_field__teacher__school": self.school} - if self.is_admin - else {"class_field__teacher": self} - ) - ).prefetch_related("new_user") - - @property - def classes(self): - """All classes the teacher can query.""" - # pylint: disable-next=import-outside-toplevel - from .klass import Class - - return Class.objects.filter(teacher__school=self.school) - - @property - def indy_users(self): - """All independent-users the teacher can query.""" - # pylint: disable-next=import-outside-toplevel - from .user.independent import IndependentUser - - return IndependentUser.objects.filter( - new_student__pending_class_request__in=self.classes - ) - - @property - def school_teacher_users(self): - """All school-teacher-users the teacher can query.""" - # pylint: disable-next=import-outside-toplevel - from .user.school_teacher import SchoolTeacherUser - - return SchoolTeacherUser.objects.filter(new_teacher__school=self.school) - - @property - def school_teachers(self): - """All school-teachers the teacher can query.""" - return SchoolTeacher.objects.filter(school=self.school) - - @property - def school_users(self): - """All users in the school the teacher can query.""" - # pylint: disable-next=import-outside-toplevel - from .user.user import User - - return User.objects.filter( - Q( # student-users - new_teacher__isnull=True, - **( - {"new_student__class_field__teacher__school": self.school} - if self.is_admin - else {"new_student__class_field__teacher": self} - ), - ) - | Q( # school-teacher-users - new_student__isnull=True, - new_teacher__school=self.school, - ) - ) - - -class AdminSchoolTeacher(SchoolTeacher): - """An admin-teacher that is in a school.""" - - is_admin: t.Literal[True] # type: ignore[assignment] - - class Meta(TypedModelMeta): - proxy = True - - # pylint: disable-next=missing-class-docstring - class Manager(SchoolTeacher.Manager): - def get_queryset(self): - return super().get_queryset().filter(is_admin=True) - - # pylint: disable-next=line-too-long - objects: models.Manager["AdminSchoolTeacher"] = Manager() # type: ignore[misc] - - @property - def is_last_admin(self): - """Whether of not the teacher is the last admin in the school.""" - return ( - not self.__class__.objects.filter(school=self.school) - .exclude(pk=self.pk) - .exists() - ) - - -class NonAdminSchoolTeacher(SchoolTeacher): - """A non-admin-teacher that is in a school.""" - - is_admin: t.Literal[False] # type: ignore[assignment] - - class Meta(TypedModelMeta): - proxy = True - - # pylint: disable-next=missing-class-docstring - class Manager(SchoolTeacher.Manager): - def get_queryset(self): - return super().get_queryset().filter(is_admin=False) - - # pylint: disable-next=line-too-long - objects: models.Manager["NonAdminSchoolTeacher"] = Manager() # type: ignore[misc] - - -class NonSchoolTeacher(Teacher): - """A teacher that is not in a school.""" - - school: None # type: ignore[assignment] - is_admin: t.Literal[False] # type: ignore[assignment] - - class Meta(TypedModelMeta): - proxy = True - - # pylint: disable-next=missing-class-docstring - class Manager(TeacherModelManager): - def get_queryset(self): - return super().get_queryset().filter(school__isnull=True) - - # pylint: disable-next=line-too-long - objects: models.Manager["NonSchoolTeacher"] = Manager() # type: ignore[assignment,misc] - - -# pylint: disable-next=invalid-name -TypedTeacher = t.Union[ - SchoolTeacher, - AdminSchoolTeacher, - NonAdminSchoolTeacher, - NonSchoolTeacher, -] - -AnyTypedTeacher = t.TypeVar("AnyTypedTeacher", bound=TypedTeacher) - - -# TODO: add this as a method on base Teacher model in new schema. -def teacher_as_type( - teacher: Teacher, typed_teacher_class: t.Type[AnyTypedTeacher] -): - """Convert a generic teacher to a typed teacher. - - Args: - teacher: The teacher to convert. - typed_teacher_class: The type of teacher to convert to. - - Returns: - An instance of the typed teacher. - """ - - return typed_teacher_class( - pk=teacher.pk, - user=teacher.user, - new_user=teacher.new_user, - school=teacher.school, - is_admin=teacher.is_admin, - blocked_time=teacher.blocked_time, - invited_by=teacher.invited_by, - ) diff --git a/codeforlife/user/models/teacher/__init__.py b/codeforlife/user/models/teacher/__init__.py new file mode 100644 index 00000000..0182d976 --- /dev/null +++ b/codeforlife/user/models/teacher/__init__.py @@ -0,0 +1,47 @@ +""" +© Ocado Group +Created on 19/02/2024 at 21:54:04(+00:00). +""" + +import typing as t + +from .admin_school import AdminSchoolTeacher +from .non_admin_school import NonAdminSchoolTeacher +from .non_school import NonSchoolTeacher +from .school import SchoolTeacher +from .teacher import AnyTeacher, Teacher + +# pylint: disable-next=invalid-name +TypedTeacher = t.Union[ + SchoolTeacher, + AdminSchoolTeacher, + NonAdminSchoolTeacher, + NonSchoolTeacher, +] + +AnyTypedTeacher = t.TypeVar("AnyTypedTeacher", bound=TypedTeacher) + + +# TODO: add this as a method on base Teacher model in new schema. +def teacher_as_type( + teacher: Teacher, typed_teacher_class: t.Type[AnyTypedTeacher] +): + """Convert a generic teacher to a typed teacher. + + Args: + teacher: The teacher to convert. + typed_teacher_class: The type of teacher to convert to. + + Returns: + An instance of the typed teacher. + """ + + return typed_teacher_class( + pk=teacher.pk, + user=teacher.user, + new_user=teacher.new_user, + school=teacher.school, + is_admin=teacher.is_admin, + blocked_time=teacher.blocked_time, + invited_by=teacher.invited_by, + ) diff --git a/codeforlife/user/models/teacher/admin_school.py b/codeforlife/user/models/teacher/admin_school.py new file mode 100644 index 00000000..9246e930 --- /dev/null +++ b/codeforlife/user/models/teacher/admin_school.py @@ -0,0 +1,41 @@ +""" +© Ocado Group +Created on 19/02/2024 at 21:54:04(+00:00). +""" + +import typing as t + +from django.db import models + +from .school import SchoolTeacher + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta +else: + TypedModelMeta = object + + +class AdminSchoolTeacher(SchoolTeacher): + """An admin-teacher that is in a school.""" + + is_admin: t.Literal[True] # type: ignore[assignment] + + class Meta(TypedModelMeta): + proxy = True + + # pylint: disable-next=missing-class-docstring + class Manager(SchoolTeacher.Manager): + def get_queryset(self): + return super().get_queryset().filter(is_admin=True) + + # pylint: disable-next=line-too-long + objects: models.Manager["AdminSchoolTeacher"] = Manager() # type: ignore[misc] + + @property + def is_last_admin(self): + """Whether of not the teacher is the last admin in the school.""" + return ( + not self.__class__.objects.filter(school=self.school) + .exclude(pk=self.pk) + .exists() + ) diff --git a/codeforlife/user/models/teacher/non_admin_school.py b/codeforlife/user/models/teacher/non_admin_school.py new file mode 100644 index 00000000..2e90ffbc --- /dev/null +++ b/codeforlife/user/models/teacher/non_admin_school.py @@ -0,0 +1,32 @@ +""" +© Ocado Group +Created on 19/02/2024 at 21:54:04(+00:00). +""" + +import typing as t + +from django.db import models + +from .school import SchoolTeacher + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta +else: + TypedModelMeta = object + + +class NonAdminSchoolTeacher(SchoolTeacher): + """A non-admin-teacher that is in a school.""" + + is_admin: t.Literal[False] # type: ignore[assignment] + + class Meta(TypedModelMeta): + proxy = True + + # pylint: disable-next=missing-class-docstring + class Manager(SchoolTeacher.Manager): + def get_queryset(self): + return super().get_queryset().filter(is_admin=False) + + # pylint: disable-next=line-too-long + objects: models.Manager["NonAdminSchoolTeacher"] = Manager() # type: ignore[misc] diff --git a/codeforlife/user/models/teacher/non_school.py b/codeforlife/user/models/teacher/non_school.py new file mode 100644 index 00000000..8c34ede4 --- /dev/null +++ b/codeforlife/user/models/teacher/non_school.py @@ -0,0 +1,33 @@ +""" +© Ocado Group +Created on 19/02/2024 at 21:54:04(+00:00). +""" + +import typing as t + +from django.db import models + +from .teacher import Teacher, TeacherModelManager + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta +else: + TypedModelMeta = object + + +class NonSchoolTeacher(Teacher): + """A teacher that is not in a school.""" + + school: None # type: ignore[assignment] + is_admin: t.Literal[False] # type: ignore[assignment] + + class Meta(TypedModelMeta): + proxy = True + + # pylint: disable-next=missing-class-docstring + class Manager(TeacherModelManager): + def get_queryset(self): + return super().get_queryset().filter(school__isnull=True) + + # pylint: disable-next=line-too-long + objects: models.Manager["NonSchoolTeacher"] = Manager() # type: ignore[assignment,misc] diff --git a/codeforlife/user/models/teacher/school.py b/codeforlife/user/models/teacher/school.py new file mode 100644 index 00000000..6c41ba35 --- /dev/null +++ b/codeforlife/user/models/teacher/school.py @@ -0,0 +1,115 @@ +""" +© Ocado Group +Created on 19/02/2024 at 21:54:04(+00:00). +""" + +import typing as t + +from django.db import models +from django.db.models import Q + +from .teacher import Teacher, TeacherModelManager + +if t.TYPE_CHECKING: + from django_stubs_ext.db.models import TypedModelMeta + + from ..school import School +else: + TypedModelMeta = object + + +class SchoolTeacher(Teacher): + """A teacher that is in a school.""" + + school: "School" # type: ignore[assignment] + + class Meta(TypedModelMeta): + proxy = True + + # pylint: disable-next=missing-class-docstring + class Manager(TeacherModelManager): + def get_queryset(self): + return super().get_queryset().filter(school__isnull=False) + + # pylint: disable-next=line-too-long + objects: models.Manager["SchoolTeacher"] = Manager() # type: ignore[assignment,misc] + + @property + def student_users(self): + """All student-users the teacher can query.""" + # pylint: disable-next=import-outside-toplevel + from ..user.student import StudentUser + + return StudentUser.objects.filter( + **( + {"new_student__class_field__teacher__school": self.school} + if self.is_admin + else {"new_student__class_field__teacher": self} + ) + ).prefetch_related("new_student") + + @property + def students(self): + """All students the teacher can query.""" + # pylint: disable-next=import-outside-toplevel + from ..student import Student + + return Student.objects.filter( + **( + {"class_field__teacher__school": self.school} + if self.is_admin + else {"class_field__teacher": self} + ) + ).prefetch_related("new_user") + + @property + def classes(self): + """All classes the teacher can query.""" + # pylint: disable-next=import-outside-toplevel + from ..klass import Class + + return Class.objects.filter(teacher__school=self.school) + + @property + def indy_users(self): + """All independent-users the teacher can query.""" + # pylint: disable-next=import-outside-toplevel + from ..user.independent import IndependentUser + + return IndependentUser.objects.filter( + new_student__pending_class_request__in=self.classes + ) + + @property + def school_teacher_users(self): + """All school-teacher-users the teacher can query.""" + # pylint: disable-next=import-outside-toplevel + from ..user.school_teacher import SchoolTeacherUser + + return SchoolTeacherUser.objects.filter(new_teacher__school=self.school) + + @property + def school_teachers(self): + """All school-teachers the teacher can query.""" + return SchoolTeacher.objects.filter(school=self.school) + + @property + def school_users(self): + """All users in the school the teacher can query.""" + # pylint: disable-next=import-outside-toplevel + from ..user.user import User + + return User.objects.filter( + Q( # student-users + new_teacher__isnull=True, + **( + {"new_student__class_field__teacher__school": self.school} + if self.is_admin + else {"new_student__class_field__teacher": self} + ), + ) + | Q( # school-teacher-users + new_student__isnull=True, + new_teacher__school=self.school, + ) + ) diff --git a/codeforlife/user/models/teacher/teacher.py b/codeforlife/user/models/teacher/teacher.py new file mode 100644 index 00000000..7604e7f2 --- /dev/null +++ b/codeforlife/user/models/teacher/teacher.py @@ -0,0 +1,139 @@ +""" +© Ocado Group +Created on 19/02/2024 at 21:54:04(+00:00). +""" + +import typing as t + +from django.db import models + +if t.TYPE_CHECKING: # pragma: no cover + from datetime import datetime + + from django_stubs_ext.db.models import TypedModelMeta + + from ..school import School + from ..user import User, UserProfile +else: + TypedModelMeta = object + + +class TeacherModelManager(models.Manager): + """Manager for Teacher model.""" + + def factory(self, first_name, last_name, email, password): + """ + Factory method to create a new teacher with an associated user and user + profile. + """ + # NOTE: avoid circular imports by importing here + # pylint: disable-next=import-outside-toplevel + from ..user import User, UserProfile + + user = User.objects.create_user( + username=email, + email=email, + password=password, + first_name=first_name, + last_name=last_name, + ) + + user_profile = UserProfile.objects.create(user=user) + + return Teacher.objects.create(user=user_profile, new_user=user) + + def get_original_queryset(self): + """Get the original queryset without filtering.""" + return super().get_queryset() + + # Filter out non active teachers by default + def get_queryset(self): + """Filter out non active teachers by default.""" + return super().get_queryset().filter(new_user__is_active=True) + + +class Teacher(models.Model): + """A teacher.""" + + user: "UserProfile" + user = models.OneToOneField( # type: ignore[assignment] + "user.UserProfile", + on_delete=models.CASCADE, + ) + + new_user: t.Optional["User"] + new_user = models.OneToOneField( # type: ignore[assignment] + "user.User", + related_name="new_teacher", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + + school: t.Optional["School"] + school = models.ForeignKey( # type: ignore[assignment] + "user.School", + related_name="teacher_school", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + + is_admin: bool + is_admin = models.BooleanField(default=False) # type: ignore[assignment] + + blocked_time: t.Optional["datetime"] + blocked_time = models.DateTimeField( # type: ignore[assignment] + null=True, + blank=True, + ) + + invited_by: t.Optional["Teacher"] + invited_by = models.ForeignKey( # type: ignore[assignment] + "self", + related_name="invited_teachers", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + + objects = TeacherModelManager() + + class Meta(TypedModelMeta): + constraints = [ + models.CheckConstraint( + check=~models.Q( + school__isnull=True, + is_admin=True, + ), + name="teacher__is_admin", + ) + ] + + def teaches(self, userprofile): + """Check if the teacher teaches the given userprofile.""" + if hasattr(userprofile, "student"): + student = userprofile.student + return ( + not student.is_independent() + and student.class_field.teacher == self + ) + + return False + + def has_school(self): + """Check if the teacher has an associated school.""" + return self.school is not (None or "") + + def has_class(self): + """Check if the teacher has an associated class.""" + return self.class_teacher.exists() + + def __str__(self): + if self.new_user is None: + return super().__str__() + + return f"{self.new_user.first_name} {self.new_user.last_name}" + + +AnyTeacher = t.TypeVar("AnyTeacher", bound=Teacher) diff --git a/codeforlife/user/models/user/admin_school_teacher.py b/codeforlife/user/models/user/admin_school_teacher.py index 4f8a72e9..54d41559 100644 --- a/codeforlife/user/models/user/admin_school_teacher.py +++ b/codeforlife/user/models/user/admin_school_teacher.py @@ -10,21 +10,22 @@ from django.db.models.query import QuerySet from .school_teacher import SchoolTeacherUser, SchoolTeacherUserManager -from .user import User if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta + + from .user import User else: TypedModelMeta = object -AnyUser = t.TypeVar("AnyUser", bound=User) +AnyUser = t.TypeVar("AnyUser", bound="User") # pylint: disable-next=missing-class-docstring,too-few-public-methods class AdminSchoolTeacherUserManager( SchoolTeacherUserManager["AdminSchoolTeacherUser"] ): - def filter_users(self, queryset: QuerySet[User]): + def filter_users(self, queryset: QuerySet["User"]): return super().filter_users(queryset).filter(new_teacher__is_admin=True) diff --git a/codeforlife/user/models/user/google.py b/codeforlife/user/models/user/google.py index cee91e46..d9dbca1b 100644 --- a/codeforlife/user/models/user/google.py +++ b/codeforlife/user/models/user/google.py @@ -13,14 +13,15 @@ from ....types import JsonDict from .contactable import ContactableUser, ContactableUserManager -from .user import User, UserProfile if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta + + from .user import User else: TypedModelMeta = object -AnyUser = t.TypeVar("AnyUser", bound=User) +AnyUser = t.TypeVar("AnyUser", bound="User") # pylint: disable-next=missing-class-docstring,too-few-public-methods @@ -35,6 +36,9 @@ def __init__(self): ) def _sync(self, auth_header: str, refresh_token: t.Optional[str] = None): + # pylint: disable=import-outside-toplevel + from .user import UserProfile + response = self.session.get( url="https://www.googleapis.com/oauth2/v3/userinfo", headers={"Authorization": auth_header}, @@ -102,7 +106,7 @@ def sync_or_create(self, auth_header: str, refresh_token: str): """Syncs an existing Google-user or creates a new one.""" return self._sync(auth_header=auth_header, refresh_token=refresh_token) - def filter_users(self, queryset: QuerySet[User]): + def filter_users(self, queryset: QuerySet["User"]): return ( super() .filter_users(queryset) diff --git a/codeforlife/user/models/user/independent.py b/codeforlife/user/models/user/independent.py index 956aec79..c61a2854 100644 --- a/codeforlife/user/models/user/independent.py +++ b/codeforlife/user/models/user/independent.py @@ -11,21 +11,21 @@ from django.db.models.query import QuerySet from .contactable import ContactableUser, ContactableUserManager -from .user import User, UserProfile if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta from ..student import Independent + from .user import User else: TypedModelMeta = object -AnyUser = t.TypeVar("AnyUser", bound=User) +AnyUser = t.TypeVar("AnyUser", bound="User") # pylint: disable-next=missing-class-docstring,too-few-public-methods class IndependentUserManager(ContactableUserManager["IndependentUser"]): - def filter_users(self, queryset: QuerySet[User]): + def filter_users(self, queryset: QuerySet["User"]): return ( super() .filter_users(queryset) @@ -52,11 +52,13 @@ def create_user( # type: ignore[override] # pylint: disable=import-outside-toplevel from ..other import TotalActivity from ..student import Student + from .user import UserProfile # pylint: enable=import-outside-toplevel assert "username" not in extra_fields + # pylint: disable=duplicate-code user = super().create_user( username=email, email=email, @@ -65,6 +67,7 @@ def create_user( # type: ignore[override] last_name=last_name, **extra_fields, ) + # pylint: enable=duplicate-code # NOTE: Indy user needs a student object for now while we use the # old models. diff --git a/codeforlife/user/models/user/non_admin_school_teacher.py b/codeforlife/user/models/user/non_admin_school_teacher.py index c43e58f5..d5c26a40 100644 --- a/codeforlife/user/models/user/non_admin_school_teacher.py +++ b/codeforlife/user/models/user/non_admin_school_teacher.py @@ -10,21 +10,22 @@ from django.db.models.query import QuerySet from .school_teacher import SchoolTeacherUser, SchoolTeacherUserManager -from .user import User if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta + + from .user import User else: TypedModelMeta = object -AnyUser = t.TypeVar("AnyUser", bound=User) +AnyUser = t.TypeVar("AnyUser", bound="User") # pylint: disable-next=missing-class-docstring,too-few-public-methods class NonAdminSchoolTeacherUserManager( SchoolTeacherUserManager["NonAdminSchoolTeacherUser"] ): - def filter_users(self, queryset: QuerySet[User]): + def filter_users(self, queryset: QuerySet["User"]): return ( super().filter_users(queryset).filter(new_teacher__is_admin=False) ) diff --git a/codeforlife/user/models/user/non_school_teacher.py b/codeforlife/user/models/user/non_school_teacher.py index 62880f7d..791fac32 100644 --- a/codeforlife/user/models/user/non_school_teacher.py +++ b/codeforlife/user/models/user/non_school_teacher.py @@ -10,19 +10,20 @@ from django.db.models.query import QuerySet from .teacher import TeacherUser, TeacherUserManager -from .user import User if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta + + from .user import User else: TypedModelMeta = object -AnyUser = t.TypeVar("AnyUser", bound=User) +AnyUser = t.TypeVar("AnyUser", bound="User") # pylint: disable-next=missing-class-docstring,too-few-public-methods class NonSchoolTeacherUserManager(TeacherUserManager["NonSchoolTeacherUser"]): - def filter_users(self, queryset: QuerySet[User]): + def filter_users(self, queryset: QuerySet["User"]): return ( super() .filter_users(queryset) diff --git a/codeforlife/user/models/user/school_teacher.py b/codeforlife/user/models/user/school_teacher.py index 8a9d24fe..97a55514 100644 --- a/codeforlife/user/models/user/school_teacher.py +++ b/codeforlife/user/models/user/school_teacher.py @@ -9,28 +9,29 @@ from django.db.models.query import QuerySet -from ..school import School from .teacher import TeacherUser, TeacherUserManager -from .user import User if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta + + from ..school import School + from .user import User else: TypedModelMeta = object -AnyUser = t.TypeVar("AnyUser", bound=User) +AnyUser = t.TypeVar("AnyUser", bound="User") # pylint: disable-next=missing-class-docstring,too-few-public-methods class SchoolTeacherUserManager(TeacherUserManager[AnyUser], t.Generic[AnyUser]): - # pylint: disable-next=signature-differs,too-many-arguments + # pylint: disable-next=signature-differs,too-many-arguments,too-many-positional-arguments def create_user( # type: ignore[override] self, first_name: str, last_name: str, email: str, password: str, - school: School, + school: "School", is_admin: bool = False, is_verified: bool = False, **extra_fields, @@ -46,7 +47,7 @@ def create_user( # type: ignore[override] **extra_fields, ) - def filter_users(self, queryset: QuerySet[User]): + def filter_users(self, queryset: QuerySet["User"]): return ( super() .filter_users(queryset) diff --git a/codeforlife/user/models/user/teacher.py b/codeforlife/user/models/user/teacher.py index b0a71aac..8b6f4c40 100644 --- a/codeforlife/user/models/user/teacher.py +++ b/codeforlife/user/models/user/teacher.py @@ -10,30 +10,30 @@ from django.db.models import F from django.db.models.query import QuerySet -from ..school import School from .contactable import ContactableUser, ContactableUserManager -from .user import User, UserProfile if t.TYPE_CHECKING: # pragma: no cover from django_stubs_ext.db.models import TypedModelMeta + from ..school import School from ..teacher import Teacher + from .user import User else: TypedModelMeta = object -AnyUser = t.TypeVar("AnyUser", bound=User) +AnyUser = t.TypeVar("AnyUser", bound="User") # pylint: disable-next=missing-class-docstring,too-few-public-methods class TeacherUserManager(ContactableUserManager[AnyUser], t.Generic[AnyUser]): - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def create_user( # type: ignore[override] self, first_name: str, last_name: str, email: str, password: str, - school: t.Optional[School] = None, + school: t.Optional["School"] = None, is_admin: bool = False, is_verified: bool = False, **extra_fields, @@ -42,6 +42,7 @@ def create_user( # type: ignore[override] # pylint: disable=import-outside-toplevel from ..other import TotalActivity from ..teacher import Teacher + from .user import UserProfile # pylint: enable=import-outside-toplevel @@ -72,7 +73,7 @@ def create_user( # type: ignore[override] return user - def filter_users(self, queryset: QuerySet[User]): + def filter_users(self, queryset: QuerySet["User"]): return ( super() .filter_users(queryset) diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 2d22a2f6..9a396b5f 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -206,6 +206,8 @@ def get_queryset(self): class UserProfile(models.Model): + """A user's profile.""" + user = models.OneToOneField(User, on_delete=models.CASCADE) otp_secret = models.CharField(max_length=40, null=True, blank=True) @@ -237,5 +239,6 @@ def __str__(self): return f"{self.user.first_name} {self.user.last_name}" def joined_recently(self): + """Whether the user joined within the last week.""" now = timezone.now() return now - timedelta(days=7) <= self.user.date_joined diff --git a/codeforlife/validators/char_set/ascii.py b/codeforlife/validators/char_set/ascii.py index 9cd803dd..7efbda80 100644 --- a/codeforlife/validators/char_set/ascii.py +++ b/codeforlife/validators/char_set/ascii.py @@ -11,6 +11,7 @@ from .base import CharSetValidatorBuilder # pylint: disable=too-few-public-methods +# pylint: disable=too-many-positional-arguments # pylint: disable=too-many-arguments # pylint: disable=duplicate-code diff --git a/codeforlife/validators/char_set/base.py b/codeforlife/validators/char_set/base.py index 3ac0e797..fab7db77 100644 --- a/codeforlife/validators/char_set/base.py +++ b/codeforlife/validators/char_set/base.py @@ -12,6 +12,7 @@ # pylint: disable=too-few-public-methods # pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments class CharSetValidator(EnhancedRegexValidator): diff --git a/codeforlife/validators/char_set/unicode.py b/codeforlife/validators/char_set/unicode.py index b8fceb18..4ef249b2 100644 --- a/codeforlife/validators/char_set/unicode.py +++ b/codeforlife/validators/char_set/unicode.py @@ -12,6 +12,7 @@ # pylint: disable=too-few-public-methods # pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments # pylint: disable=duplicate-code diff --git a/codeforlife/validators/enhanced_regex.py b/codeforlife/validators/enhanced_regex.py index 4fec0d2c..4009c62f 100644 --- a/codeforlife/validators/enhanced_regex.py +++ b/codeforlife/validators/enhanced_regex.py @@ -35,7 +35,7 @@ def _compile(): class EnhancedRegexValidator(RegexValidator): """Extends Django's default regex validator to support enhanced patterns.""" - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def __init__( self, # pylint: disable-next=redefined-outer-name From dac77f8eaa323ad437b62631a2d49b6b40b2ec29 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 11 Feb 2026 15:03:40 +0000 Subject: [PATCH 04/58] fix tests --- codeforlife/tasks/bigquery_test.py | 1 + .../user/auth/backends/student_auto.py | 14 +- .../password_validators/independent_test.py | 2 + .../auth/password_validators/student_test.py | 2 + .../auth/password_validators/teacher_test.py | 2 + codeforlife/user/fixtures/legacy.json | 1158 +++++++++++++++++ codeforlife/user/migrations/0001_initial.py | 9 +- codeforlife/user/models/user/student.py | 18 +- codeforlife/user/models/user/user.py | 16 + codeforlife/user/serializers/user_test.py | 5 +- codeforlife/user/views/klass_test.py | 2 +- codeforlife/user/views/school_test.py | 2 +- codeforlife/user/views/user_test.py | 18 +- 13 files changed, 1229 insertions(+), 20 deletions(-) create mode 100644 codeforlife/user/fixtures/legacy.json diff --git a/codeforlife/tasks/bigquery_test.py b/codeforlife/tasks/bigquery_test.py index 51374627..41b43b72 100644 --- a/codeforlife/tasks/bigquery_test.py +++ b/codeforlife/tasks/bigquery_test.py @@ -31,6 +31,7 @@ # pylint: disable-next=too-many-instance-attributes,too-many-public-methods class TestLoadDataIntoBigQueryTask(CeleryTestCase): + fixtures = ["school_1"] append_users: BigQueryTask truncate_users: BigQueryTask diff --git a/codeforlife/user/auth/backends/student_auto.py b/codeforlife/user/auth/backends/student_auto.py index 1d48e33e..a4317939 100644 --- a/codeforlife/user/auth/backends/student_auto.py +++ b/codeforlife/user/auth/backends/student_auto.py @@ -3,20 +3,20 @@ Created on 01/02/2024 at 14:44:16(+00:00). """ +import hashlib import typing as t -# isort: off -from common.helpers.generators import ( # type: ignore[import-untyped] - get_hashed_login_id, -) - -# isort: on - from ....request import HttpRequest from ...models import Student, StudentUser from .base import BaseBackend +# NOTE: copied from legacy code. +def get_hashed_login_id(login_id): + """Returns the hash of a given string used for login url""" + return hashlib.sha256(login_id.encode()).hexdigest() + + class StudentAutoBackend(BaseBackend): """Authenticate a student using their ID and auto-generated password.""" diff --git a/codeforlife/user/auth/password_validators/independent_test.py b/codeforlife/user/auth/password_validators/independent_test.py index b705c833..0ed1d028 100644 --- a/codeforlife/user/auth/password_validators/independent_test.py +++ b/codeforlife/user/auth/password_validators/independent_test.py @@ -10,6 +10,8 @@ # pylint: disable-next=missing-class-docstring class TestIndependentPasswordValidator(TestCase): + fixtures = ["school_1", "independent"] + def setUp(self): # TODO: Update to check for not student and not teacher once we # switch to new models diff --git a/codeforlife/user/auth/password_validators/student_test.py b/codeforlife/user/auth/password_validators/student_test.py index 9f275235..e638bbf9 100644 --- a/codeforlife/user/auth/password_validators/student_test.py +++ b/codeforlife/user/auth/password_validators/student_test.py @@ -10,6 +10,8 @@ # pylint: disable-next=missing-class-docstring class TestStudentPasswordValidator(TestCase): + fixtures = ["school_1"] + def setUp(self): # TODO: Remove second check once we switch to new models self.user = User.objects.filter( diff --git a/codeforlife/user/auth/password_validators/teacher_test.py b/codeforlife/user/auth/password_validators/teacher_test.py index 85d0f382..4e11f534 100644 --- a/codeforlife/user/auth/password_validators/teacher_test.py +++ b/codeforlife/user/auth/password_validators/teacher_test.py @@ -10,6 +10,8 @@ # pylint: disable-next=missing-class-docstring class TestTeacherPasswordValidator(TestCase): + fixtures = ["school_1"] + def setUp(self): self.user = User.objects.filter(new_teacher__isnull=False).first() assert self.user is not None diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json new file mode 100644 index 00000000..98a2c5c5 --- /dev/null +++ b/codeforlife/user/fixtures/legacy.json @@ -0,0 +1,1158 @@ +[ + { + "model": "user.userprofile", + "pk": 1, + "fields": { + "user": 2, + "otp_secret": null, + "last_otp_for_time": null, + "developer": true, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 2, + "fields": { + "user": 3, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 3, + "fields": { + "user": 4, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 4, + "fields": { + "user": 5, + "otp_secret": null, + "last_otp_for_time": null, + "developer": true, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 5, + "fields": { + "user": 6, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 6, + "fields": { + "user": 7, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 7, + "fields": { + "user": 8, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 8, + "fields": { + "user": 9, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 9, + "fields": { + "user": 10, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 10, + "fields": { + "user": 11, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 11, + "fields": { + "user": 12, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 12, + "fields": { + "user": 13, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 13, + "fields": { + "user": 14, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 14, + "fields": { + "user": 15, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 15, + "fields": { + "user": 16, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 16, + "fields": { + "user": 17, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 17, + "fields": { + "user": 18, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 18, + "fields": { + "user": 19, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 19, + "fields": { + "user": 20, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 20, + "fields": { + "user": 1, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": true, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.userprofile", + "pk": 21, + "fields": { + "user": 21, + "otp_secret": null, + "last_otp_for_time": null, + "developer": false, + "is_verified": false, + "first_name": null, + "_first_name": null, + "last_name": null, + "_last_name": null, + "email": null, + "_email": null, + "username": null, + "_username": null, + "google_refresh_token": null, + "google_sub": null + } + }, + { + "model": "user.school", + "pk": 1, + "fields": { + "name": "Swiss Federal Polytechnic", + "country": "GB", + "county": "nan", + "creation_time": null, + "is_active": true + } + }, + { + "model": "user.teacher", + "pk": 1, + "fields": { + "user": 1, + "new_user": 2, + "school": 1, + "is_admin": true, + "blocked_time": null, + "invited_by": null + } + }, + { + "model": "user.teacher", + "pk": 2, + "fields": { + "user": 2, + "new_user": 3, + "school": 1, + "is_admin": false, + "blocked_time": null, + "invited_by": null + } + }, + { + "model": "user.teacher", + "pk": 3, + "fields": { + "user": 3, + "new_user": 4, + "school": 1, + "is_admin": true, + "blocked_time": null, + "invited_by": null + } + }, + { + "model": "user.teacher", + "pk": 4, + "fields": { + "user": 20, + "new_user": 1, + "school": 1, + "is_admin": true, + "blocked_time": null, + "invited_by": null + } + }, + { + "model": "user.class", + "pk": 1, + "fields": { + "name": "Class 101", + "teacher": 1, + "access_code": "AB123", + "classmates_data_viewable": true, + "always_accept_requests": true, + "accept_requests_until": null, + "creation_time": null, + "is_active": true, + "created_by": null + } + }, + { + "model": "user.class", + "pk": 2, + "fields": { + "name": "Class 102", + "teacher": 2, + "access_code": "AB124", + "classmates_data_viewable": true, + "always_accept_requests": true, + "accept_requests_until": null, + "creation_time": null, + "is_active": true, + "created_by": null + } + }, + { + "model": "user.class", + "pk": 3, + "fields": { + "name": "Class 103", + "teacher": 2, + "access_code": "AB125", + "classmates_data_viewable": true, + "always_accept_requests": true, + "accept_requests_until": null, + "creation_time": null, + "is_active": true, + "created_by": null + } + }, + { + "model": "user.class", + "pk": 4, + "fields": { + "name": "Young Coders 101", + "teacher": 3, + "access_code": "RL123", + "classmates_data_viewable": true, + "always_accept_requests": true, + "accept_requests_until": null, + "creation_time": null, + "is_active": true, + "created_by": null + } + }, + { + "model": "user.class", + "pk": 5, + "fields": { + "name": "Portaladmin's class", + "teacher": 4, + "access_code": "PO123", + "classmates_data_viewable": true, + "always_accept_requests": true, + "accept_requests_until": null, + "creation_time": null, + "is_active": true, + "created_by": null + } + }, + { + "model": "user.student", + "pk": 1, + "fields": { + "class_field": 1, + "login_id": null, + "user": 4, + "new_user": 5, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 2, + "fields": { + "class_field": 1, + "login_id": null, + "user": 5, + "new_user": 6, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 3, + "fields": { + "class_field": null, + "login_id": null, + "user": 6, + "new_user": 7, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 4, + "fields": { + "class_field": 2, + "login_id": null, + "user": 7, + "new_user": 8, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 5, + "fields": { + "class_field": 2, + "login_id": null, + "user": 8, + "new_user": 9, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 6, + "fields": { + "class_field": 3, + "login_id": null, + "user": 9, + "new_user": 10, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 7, + "fields": { + "class_field": null, + "login_id": null, + "user": 10, + "new_user": 11, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 8, + "fields": { + "class_field": 4, + "login_id": null, + "user": 11, + "new_user": 12, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 9, + "fields": { + "class_field": 4, + "login_id": null, + "user": 12, + "new_user": 13, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 10, + "fields": { + "class_field": 4, + "login_id": null, + "user": 13, + "new_user": 14, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 11, + "fields": { + "class_field": 4, + "login_id": null, + "user": 14, + "new_user": 15, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 12, + "fields": { + "class_field": 4, + "login_id": null, + "user": 15, + "new_user": 16, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 13, + "fields": { + "class_field": 4, + "login_id": null, + "user": 16, + "new_user": 17, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 14, + "fields": { + "class_field": 4, + "login_id": null, + "user": 17, + "new_user": 18, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 15, + "fields": { + "class_field": 4, + "login_id": null, + "user": 18, + "new_user": 19, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.student", + "pk": 16, + "fields": { + "class_field": 5, + "login_id": null, + "user": 21, + "new_user": 21, + "pending_class_request": null, + "blocked_time": null + } + }, + { + "model": "user.totalactivity", + "pk": 1, + "fields": { + "teacher_registrations": 4, + "student_registrations": 14, + "independent_registrations": 2, + "anonymised_unverified_teachers": 0, + "anonymised_unverified_independents": 0 + } + }, + { + "model": "user.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$1000000$y2lUeK8u5GnocWVuonlViT$TnNTL7sG9pcvz6MotXSSptrHdQzAFxmLnqNbj7syLS4=", + "last_login": null, + "is_superuser": true, + "username": "codeforlife-portal@ocado.com", + "first_name": "Portal", + "last_name": "Admin", + "email": "codeforlife-portal@ocado.com", + "is_staff": true, + "is_active": true, + "date_joined": "2026-02-04T16:02:33.631Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$1000000$IOkFLrqDnlDiEoASMl5OSY$C7gzJeOhwbfq/nJp47LaErHOxgA/3DIkaUI93ZUqauc=", + "last_login": null, + "is_superuser": false, + "username": "alberteinstein@codeforlife.com", + "first_name": "Albert", + "last_name": "Einstein", + "email": "alberteinstein@codeforlife.com", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:34.051Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$1000000$QHwukC6Cv822F2nSjQjLu0$09yKE5c8mFVwIHS5BWccM+q33Rs6XSCcc5rik3RD7eY=", + "last_login": null, + "is_superuser": false, + "username": "maxplanck@codeforlife.com", + "first_name": "Max", + "last_name": "Planck", + "email": "maxplanck@codeforlife.com", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:34.252Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 4, + "fields": { + "password": "pbkdf2_sha256$1000000$OcLHQ75ALkslmiUxFwe1Ft$7hpkII5kIi1lytO4PQ0p/6HdtT6//mPcZfZNjROXqrg=", + "last_login": null, + "is_superuser": false, + "username": "ramleith@codeforlife.com", + "first_name": "Ram", + "last_name": "Leith", + "email": "ramleith@codeforlife.com", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:34.448Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 5, + "fields": { + "password": "pbkdf2_sha256$1000000$6QiofvSo5LPJ0DlGshTh0h$XdFmFi/Al5vuhxw02nlZE/rUG8g2uHxFnHXs46XgT/c=", + "last_login": null, + "is_superuser": false, + "username": "leonardodavinci@codeforlife.com", + "first_name": "Leonardo", + "last_name": "DaVinci", + "email": "leonardodavinci@codeforlife.com", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:34.641Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 6, + "fields": { + "password": "pbkdf2_sha256$1000000$HF1HBQVpMLdAuYb0vt55r1$gY3+SetJqidFfylntSqmmrtremtNk8QuGeAdlHNqD9A=", + "last_login": null, + "is_superuser": false, + "username": "galileogalilei@codeforlife.com", + "first_name": "Galileo", + "last_name": "Galilei", + "email": "galileogalilei@codeforlife.com", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:34.839Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 7, + "fields": { + "password": "pbkdf2_sha256$1000000$BUSt8V8F4DwWZPlCZeS1Sc$WMQElSAhvIE7LRHx6lwTde9YG5P74dc7Az+639AHdYc=", + "last_login": null, + "is_superuser": false, + "username": "isaacnewton@codeforlife.com", + "first_name": "Isaac", + "last_name": "Newton", + "email": "isaacnewton@codeforlife.com", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:35.036Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 8, + "fields": { + "password": "pbkdf2_sha256$1000000$TaYcwU3iqPA4c4co14ZYHx$FAwypkSE7UucZod6BY/YY+A4zV4JbySNxDwd4ypOZT8=", + "last_login": null, + "is_superuser": false, + "username": "richardfeynman@codeforlife.com", + "first_name": "Richard", + "last_name": "Feynman", + "email": "richardfeynman@codeforlife.com", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:35.230Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 9, + "fields": { + "password": "pbkdf2_sha256$1000000$mCuu3Ptx9EGmKQ7FSNIqzw$dqwQK/ovljow4iGxfLIQTm8PPR8Iu5AITzQ9tVQxv9w=", + "last_login": null, + "is_superuser": false, + "username": "alexanderflemming@codeforlife.com", + "first_name": "Alexander", + "last_name": "Flemming", + "email": "alexanderflemming@codeforlife.com", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:35.422Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 10, + "fields": { + "password": "pbkdf2_sha256$1000000$isUqjEe0ZtdHC0tDFilALA$k20Gy//4APEqsGNYGa8aQPUTUBqMPKe6hI5GMIWiMnE=", + "last_login": null, + "is_superuser": false, + "username": "danielbernoulli@codeforlife.com", + "first_name": "Daniel", + "last_name": "Bernoulli", + "email": "danielbernoulli@codeforlife.com", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:35.611Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 11, + "fields": { + "password": "pbkdf2_sha256$1000000$mjbmbYr7G3WRyxxHlDcH6d$w1wf3D0lDP6pF3nSoTUD3vSRioCzPEloW3r8sXnLSJw=", + "last_login": null, + "is_superuser": false, + "username": "indianajones@codeforlife.com", + "first_name": "Indiana", + "last_name": "Jones", + "email": "indianajones@codeforlife.com", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:35.803Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 12, + "fields": { + "password": "pbkdf2_sha256$1000000$qm1oQx3HNeIx6dpY1aRzXb$xMKwoqXoW6MuHqoABww+Yq+3A1uwkLbkpaWWytNu6l0=", + "last_login": null, + "is_superuser": false, + "username": "media noah", + "first_name": "Noah", + "last_name": "Monaghan", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:35.999Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 13, + "fields": { + "password": "pbkdf2_sha256$1000000$vQZKG1iO3Np3cdOHSrG85F$QnuZLe1KvMt64uWXjz3RKOEnr6owhloxfgs3u8xB6GQ=", + "last_login": null, + "is_superuser": false, + "username": "media elliot", + "first_name": "Elliot", + "last_name": "Sharp", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:36.195Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 14, + "fields": { + "password": "pbkdf2_sha256$1000000$iCO3IsvmN5C8rpX5J0d1ob$TFK2zYume/i4StDly6EUiqFej/sOnuzV/wYE9rgr/cg=", + "last_login": null, + "is_superuser": false, + "username": "media tajmae", + "first_name": "Tajmae", + "last_name": "Joseph", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:36.394Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 15, + "fields": { + "password": "pbkdf2_sha256$1000000$6Nhm1sHtcMbFdBitdz7nxq$s5i8H7MwdH6SRQ3+DZDDfsPtqxXVDPAC5t67G4FluM4=", + "last_login": null, + "is_superuser": false, + "username": "media carlton", + "first_name": "Carlton", + "last_name": "Joseph", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:36.589Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 16, + "fields": { + "password": "pbkdf2_sha256$1000000$FRA5lx8xBTO5Rn8J9wwZZW$KiQzLItBM40bzbwRdXpKnrI1uWXv0xsSzTMkFwAewwo=", + "last_login": null, + "is_superuser": false, + "username": "media nadal", + "first_name": "Nadal", + "last_name": "Spencer-Jennings", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:36.792Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 17, + "fields": { + "password": "pbkdf2_sha256$1000000$LAQQycnLxi5rY9QCD15nHw$Ad5r4JEo5CCQrwuD2F12ajXnV/bDSL+FGQ99vM2l0/k=", + "last_login": null, + "is_superuser": false, + "username": "media freddie", + "first_name": "Freddie", + "last_name": "Goff", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:37.009Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 18, + "fields": { + "password": "pbkdf2_sha256$1000000$XHhBEg6yrF9a6TR487gNrU$DOR6TONr+9q0rLXJwB43UuezRcQkQAmEmoN0nYmm4do=", + "last_login": null, + "is_superuser": false, + "username": "media leon", + "first_name": "Leon", + "last_name": "Scott", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:37.216Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 19, + "fields": { + "password": "pbkdf2_sha256$1000000$ua0WUUCFIijIjxpLdkc25v$mvl+UejCNGop0krujly8TPlMbgWPFVZ42tH+NPDltS4=", + "last_login": null, + "is_superuser": false, + "username": "media betty", + "first_name": "Betty", + "last_name": "Kessell", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:37.413Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 20, + "fields": { + "password": "pbkdf2_sha256$1000000$o2AfZh0JqqIFRTZGHWEQKV$c1es5kdOakAvCGkeMVgDOX5q+rFhdQgOkMZiKAtqMrk=", + "last_login": null, + "is_superuser": false, + "username": "4271ee7b7ce94e34a58d1f4e82025280", + "first_name": "Deleted", + "last_name": "User", + "email": "", + "is_staff": false, + "is_active": false, + "date_joined": "2026-02-04T16:02:37.614Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 21, + "fields": { + "password": "pbkdf2_sha256$1000000$cv54m0PSNCQrH0MBLU56pk$nhGzkqLXL01EEtLAsoAbaWcTxuj1SOrZ7s6rBdA7eZk=", + "last_login": null, + "is_superuser": false, + "username": "adminstudent@codeforlife.com", + "first_name": "Portaladmin", + "last_name": "Student", + "email": "adminstudent@codeforlife.com", + "is_staff": false, + "is_active": true, + "date_joined": "2026-02-04T16:02:40.242Z", + "groups": [], + "user_permissions": [] + } + } +] diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index 29705ce5..aacc3369 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.15 on 2026-02-11 10:37 +# Generated by Django 5.1.15 on 2026-02-11 14:16 import codeforlife.models.encrypted_char_field import codeforlife.user.models.user.admin_school_teacher @@ -228,6 +228,13 @@ class Migration(migrations.Migration): default=django.utils.timezone.now, verbose_name="date joined" ), ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), ( "groups", models.ManyToManyField( diff --git a/codeforlife/user/models/user/student.py b/codeforlife/user/models/user/student.py index ce716e22..aa94c273 100644 --- a/codeforlife/user/models/user/student.py +++ b/codeforlife/user/models/user/student.py @@ -116,8 +116,22 @@ def _get_random_login_id(): # login_id = get_random_string(length=64) # TODO: replace below code with commented out code above. - # pylint: disable-next=import-outside-toplevel - from common.helpers.generators import generate_login_id + # pylint: disable=import-outside-toplevel + import hashlib + from uuid import uuid4 + + # pylint: enable=import-outside-toplevel + + def get_hashed_login_id(login_id): + """Returns the hash of a given string used for login url""" + return hashlib.sha256(login_id.encode()).hexdigest() + + def generate_login_id(): + """Returns the uuid string and its hashed. + The string is used for URL, and the hashed is stored in the DB.""" + login_id = uuid4().hex + hashed_login_id = get_hashed_login_id(login_id) + return login_id, hashed_login_id return generate_login_id() diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 9a396b5f..b0609b73 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -14,6 +14,7 @@ from django.db import models from django.db.models.query import QuerySet from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from pyotp import TOTP from ....models import AbstractBaseUser, EncryptedCharField @@ -74,6 +75,21 @@ class User( credential_fields = frozenset(["email", "password"]) + # TODO: remove in new schema + password: str # type: ignore[assignment] + password = models.CharField( # type: ignore[assignment] + _("password"), + max_length=128, + ) + + # TODO: remove in new schema + last_login: t.Optional[datetime] # type: ignore[assignment] + last_login = models.DateTimeField( # type: ignore[assignment] + _("last login"), + blank=True, + null=True, + ) + @property def is_authenticated(self): return ( diff --git a/codeforlife/user/serializers/user_test.py b/codeforlife/user/serializers/user_test.py index 63bdd917..a77506ae 100644 --- a/codeforlife/user/serializers/user_test.py +++ b/codeforlife/user/serializers/user_test.py @@ -10,6 +10,7 @@ # pylint: disable-next=missing-class-docstring,too-many-ancestors class TestUserSerializer(ModelSerializerTestCase[User, User]): + fixtures = ["school_1", "independent"] model_serializer_class = UserSerializer # type: ignore[assignment,override] # test: to representation @@ -59,7 +60,9 @@ def test_to_representation__student(self): def test_to_representation__indy(self): """Serialize independent user to representation.""" - user = IndependentUser.objects.first() + user = IndependentUser.objects.filter( + new_student__pending_class_request__isnull=True + ).first() assert user self.assert_to_representation( diff --git a/codeforlife/user/views/klass_test.py b/codeforlife/user/views/klass_test.py index 071931ef..b7383cdb 100644 --- a/codeforlife/user/views/klass_test.py +++ b/codeforlife/user/views/klass_test.py @@ -68,7 +68,7 @@ def test_retrieve(self): user = StudentUser.objects.first() assert user - self.client.login_as(user, password="Password1") + self.client.login_as(user) # pylint: disable-next=line-too-long self.client.retrieve(model=user.student.class_field) # type: ignore[type-var] diff --git a/codeforlife/user/views/school_test.py b/codeforlife/user/views/school_test.py index cf09bc77..d9f50bb1 100644 --- a/codeforlife/user/views/school_test.py +++ b/codeforlife/user/views/school_test.py @@ -91,5 +91,5 @@ def test_retrieve(self): user = SchoolTeacherUser.objects.first() assert user - self.client.login_as(user, password="abc123") + self.client.login_as(user) self.client.retrieve(model=user.teacher.school) diff --git a/codeforlife/user/views/user_test.py b/codeforlife/user/views/user_test.py index 97717bf9..c36d833e 100644 --- a/codeforlife/user/views/user_test.py +++ b/codeforlife/user/views/user_test.py @@ -95,6 +95,7 @@ def test_get_queryset__teacher__admin(self): users = [ *list(user.teacher.school_teacher_users), *list(user.teacher.student_users), + *list(user.teacher.indy_users), ] users.sort(key=lambda user: user.pk) @@ -118,6 +119,7 @@ def test_get_queryset__teacher__non_admin(self): ) ), *list(user.teacher.student_users), + *list(user.teacher.indy_users), ] users.sort(key=lambda user: user.pk) @@ -136,10 +138,11 @@ def test_list(self): users = [ *list(user.teacher.school_teacher_users), *list(user.teacher.student_users), + *list(user.teacher.indy_users), ] users.sort(key=lambda user: user.pk) - self.client.login_as(user, password="abc123") + self.client.login_as(user) self.client.list(models=users) def test_list__students_in_class(self): @@ -172,7 +175,7 @@ def test_list__type__teacher(self): self.client.login_as(user) self.client.list( - models=school_teacher_users, + models=school_teacher_users.order_by("pk"), filters={"type": "teacher"}, ) @@ -184,7 +187,7 @@ def test_list__type__student(self): self.client.login_as(user) self.client.list( - models=student_users, + models=student_users.order_by("pk"), filters={"type": "student"}, ) @@ -196,7 +199,7 @@ def test_list__type__indy(self): self.client.login_as(user) self.client.list( - models=indy_users, + models=indy_users.order_by("pk"), filters={"type": "indy"}, ) @@ -208,13 +211,14 @@ def test_list___id(self): users = [ *list(user.teacher.school_teacher_users), *list(user.teacher.student_users), + *list(user.teacher.indy_users), ] users.sort(key=lambda user: user.pk) exclude_user_1: User = users.pop() exclude_user_2: User = users.pop() - self.client.login_as(user, password="abc123") + self.client.login_as(user) self.client.list( models=users, filters={ @@ -233,7 +237,7 @@ def test_list__name(self): school_users = user.teacher.school_users first_name, last_name = user.first_name, user.last_name[:1] - self.client.login_as(user, password="abc123") + self.client.login_as(user) self.client.list( models=school_users.filter( Q(first_name__icontains=first_name) @@ -247,5 +251,5 @@ def test_retrieve(self): user = AdminSchoolTeacherUser.objects.first() assert user - self.client.login_as(user, password="abc123") + self.client.login_as(user) self.client.retrieve(model=user) From 4b30d45251ea95b64938db024e439502994ce126 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 11 Feb 2026 15:17:05 +0000 Subject: [PATCH 05/58] fix --- codeforlife/models/encrypted_char_field.py | 6 ++++++ setup.py | 1 + 2 files changed, 7 insertions(+) diff --git a/codeforlife/models/encrypted_char_field.py b/codeforlife/models/encrypted_char_field.py index 056e94e0..07a52111 100644 --- a/codeforlife/models/encrypted_char_field.py +++ b/codeforlife/models/encrypted_char_field.py @@ -23,6 +23,12 @@ def __init__(self, *args, **kwargs): kwargs["max_length"] += len(self._prefix) super().__init__(*args, **kwargs) + def deconstruct(self): + # pylint: disable-next=no-member + name, path, args, kwargs = super().deconstruct() + kwargs["max_length"] += len(self._prefix) + return name, path, args, kwargs + # pylint: disable-next=unused-argument def from_db_value(self, value: t.Optional[str], expression, connection): """ diff --git a/setup.py b/setup.py index 39a06af2..2a20229f 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,7 @@ import typing as t from pathlib import Path +# pylint: disable-next=import-error from setuptools import find_packages, setup # type: ignore[import-untyped] from codeforlife import DATA_DIR, TEMPLATES_DIR, __version__ From c658dcca574622fefd649d87d4c079af129339dc Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 11 Feb 2026 15:35:25 +0000 Subject: [PATCH 06/58] upgrade packages --- Pipfile | 6 +-- Pipfile.lock | 106 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 65 insertions(+), 47 deletions(-) diff --git a/Pipfile b/Pipfile index 02a90ead..47575f45 100644 --- a/Pipfile +++ b/Pipfile @@ -5,9 +5,9 @@ name = "pypi" [packages] celery = {version = "==5.4.0", extras = ["sqs"]} -cryptography = "==44.0.1" +cryptography = "==46.0.5" boto3 = "==1.36.14" -django = "==5.1.15" +django = "==5.2.11" djangorestframework = "==3.16.0" django-filter = "==25.1" django-countries = "==7.6.1" @@ -46,7 +46,7 @@ pylint = "==4.0.4" pylint-django = "==2.7.0" isort = "==5.13.2" mypy = "==1.15.0" -django-stubs = {version = "==5.1.3", extras = ["compatible-mypy"]} +django-stubs = {version = "==5.2.9", extras = ["compatible-mypy"]} djangorestframework-stubs = {version = "==3.15.3", extras = ["compatible-mypy"]} types-regex = "==2024.11.6.*" types-psutil = "==7.0.0.20250601" diff --git a/Pipfile.lock b/Pipfile.lock index 742fd686..16501cf7 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b1b213110b3c6f5a403ba8b3442fb4aa9679d65403d6189c9cf382ea4f742cc5" + "sha256": "35766b468d3e233eb753ef7405409d3e6942204311d42b4d57b192bafa4446a6" }, "pipfile-spec": 6, "requires": { @@ -326,41 +326,59 @@ }, "cryptography": { "hashes": [ - "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", - "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3", - "sha256:1f9a92144fa0c877117e9748c74501bea842f93d21ee00b0cf922846d9d0b183", - "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69", - "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a", - "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62", - "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911", - "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7", - "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a", - "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41", - "sha256:5fed5cd6102bb4eb843e3315d2bf25fede494509bddadb81e03a859c1bc17b83", - "sha256:610a83540765a8d8ce0f351ce42e26e53e1f774a6efb71eb1b41eb01d01c3d12", - "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864", - "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf", - "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c", - "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2", - "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b", - "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0", - "sha256:94f99f2b943b354a5b6307d7e8d19f5c423a794462bde2bf310c770ba052b1c4", - "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9", - "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008", - "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862", - "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009", - "sha256:d9c5b9f698a83c8bd71e0f4d3f9f839ef244798e5ffe96febfa9714717db7af7", - "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f", - "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026", - "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f", - "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd", - "sha256:f4daefc971c2d1f82f03097dc6f216744a6cd2ac0f04c68fb935ea2ba2a0d420", - "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14", - "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00" + "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", + "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", + "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", + "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", + "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", + "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", + "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", + "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", + "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", + "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", + "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", + "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", + "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", + "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", + "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", + "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", + "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", + "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", + "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", + "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", + "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", + "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", + "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", + "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", + "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", + "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", + "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", + "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", + "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", + "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", + "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", + "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", + "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", + "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", + "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", + "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", + "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", + "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", + "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", + "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", + "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", + "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", + "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", + "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", + "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", + "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", + "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", + "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", + "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87" ], "index": "pypi", - "markers": "python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'", - "version": "==44.0.1" + "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", + "version": "==46.0.5" }, "diff-match-patch": { "hashes": [ @@ -372,12 +390,12 @@ }, "django": { "hashes": [ - "sha256:117871e58d6eda37f09870b7d73a3d66567b03aecd515b386b1751177c413432", - "sha256:46a356b5ff867bece73fc6365e081f21c569973403ee7e9b9a0316f27d0eb947" + "sha256:7f2d292ad8b9ee35e405d965fbbad293758b858c34bbf7f3df551aeeac6f02d3", + "sha256:e7130df33ada9ab5e5e929bc19346a20fe383f5454acb2cc004508f242ee92c0" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==5.1.15" + "version": "==5.2.11" }, "django-cors-headers": { "hashes": [ @@ -1609,12 +1627,12 @@ }, "django": { "hashes": [ - "sha256:117871e58d6eda37f09870b7d73a3d66567b03aecd515b386b1751177c413432", - "sha256:46a356b5ff867bece73fc6365e081f21c569973403ee7e9b9a0316f27d0eb947" + "sha256:7f2d292ad8b9ee35e405d965fbbad293758b858c34bbf7f3df551aeeac6f02d3", + "sha256:e7130df33ada9ab5e5e929bc19346a20fe383f5454acb2cc004508f242ee92c0" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==5.1.15" + "version": "==5.2.11" }, "django-extensions": { "hashes": [ @@ -1630,11 +1648,11 @@ "compatible-mypy" ], "hashes": [ - "sha256:716758ced158b439213062e52de6df3cff7c586f9f9ad7ab59210efbea5dfe78", - "sha256:8c230bc5bebee6da282ba8a27ad1503c84a0c4cd2f46e63d149e76d2a63e639a" + "sha256:2317a7130afdaa76f6ff7f623650d7f3bf1b6c86a60f95840e14e6ec6de1a7cd", + "sha256:c192257120b08785cfe6f2f1c91f1797aceae8e9daa689c336e52c91e8f6a493" ], - "markers": "python_version >= '3.8'", - "version": "==5.1.3" + "markers": "python_version >= '3.10'", + "version": "==5.2.9" }, "django-stubs-ext": { "hashes": [ From 7b37cb4f7533667fac6e426c4fa47f76247f5584 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 11 Feb 2026 16:03:49 +0000 Subject: [PATCH 07/58] fix --- codeforlife/tests/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codeforlife/tests/api.py b/codeforlife/tests/api.py index 44cbe644..5856cb49 100644 --- a/codeforlife/tests/api.py +++ b/codeforlife/tests/api.py @@ -35,10 +35,10 @@ def setUpClass(cls): return super().setUpClass() - def _pre_setup(self): + def _setup_and_call(self, result, debug=False): # pylint: disable-next=protected-access self.client_class._test_case = self - super()._pre_setup() # type: ignore[misc] + super()._setup_and_call(result, debug) # type: ignore[misc] class APITestCase( From 0f5d76b014478ed0a216193ac64c5b0532e6baef Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 11 Feb 2026 16:45:00 +0000 Subject: [PATCH 08/58] fix imports --- Pipfile | 2 -- Pipfile.lock | 75 +--------------------------------------------------- 2 files changed, 1 insertion(+), 76 deletions(-) diff --git a/Pipfile b/Pipfile index 47575f45..7a93f5d4 100644 --- a/Pipfile +++ b/Pipfile @@ -11,10 +11,8 @@ django = "==5.2.11" djangorestframework = "==3.16.0" django-filter = "==25.1" django-countries = "==7.6.1" -django-two-factor-auth = "==1.17.0" django-cors-headers = "==4.7.0" django-csp = "==3.8" -django-import-export = "==4.2.0" django-storages = {version = "==1.14.6", extras = ["s3"]} pyotp = "==2.9.0" python-dotenv = "==1.0.1" diff --git a/Pipfile.lock b/Pipfile.lock index 16501cf7..02774fc4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "35766b468d3e233eb753ef7405409d3e6942204311d42b4d57b192bafa4446a6" + "sha256": "1aae90ff799f71d4e05946630c6b8957cb4ad354d878c3bb714a05037b10f468" }, "pipfile-spec": 6, "requires": { @@ -380,14 +380,6 @@ "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", "version": "==46.0.5" }, - "diff-match-patch": { - "hashes": [ - "sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782", - "sha256:beae57a99fa48084532935ee2968b8661db861862ec82c6f21f4acdd6d835073" - ], - "markers": "python_version >= '3.7'", - "version": "==20241021" - }, "django": { "hashes": [ "sha256:7f2d292ad8b9ee35e405d965fbbad293758b858c34bbf7f3df551aeeac6f02d3", @@ -431,39 +423,6 @@ "markers": "python_version >= '3.9'", "version": "==25.1" }, - "django-formtools": { - "hashes": [ - "sha256:47cb34552c6efca088863d693284d04fc36eaaf350eb21e1a1d935e0df523c93", - "sha256:bce9b64eda52cc1eef6961cc649cf75aacd1a707c2fff08d6c3efcbc8e7e761a" - ], - "markers": "python_version >= '3.8'", - "version": "==2.5.1" - }, - "django-import-export": { - "hashes": [ - "sha256:6a616046498b44bf4291610609615b00101bb2b9c4701b59b78edfaa5552aa7b", - "sha256:bb8482bd8b124f1f47e58a877e34358820d09293c65fe36c21e9dbcdee170d4d" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==4.2.0" - }, - "django-otp": { - "hashes": [ - "sha256:406d2d7f797dc313569270e06d6c360c7d986c9f653eab80b190d663ed5f1133", - "sha256:961ccf2d80a67303cb46d97427b16c476ee075acfa2b4c82a59d8f1e0745a454" - ], - "markers": "python_version >= '3.8'", - "version": "==1.7.0" - }, - "django-phonenumber-field": { - "hashes": [ - "sha256:2b83e843dac35eec6a69880a166487235b737a71a1e38c9a52e5ad67d6996083", - "sha256:7a1cb3a6456edb54d879f11ffa0acb227ded08c93b587035d0f28093f0e46511" - ], - "markers": "python_version >= '3.10'", - "version": "==8.4.0" - }, "django-storages": { "extras": [ "s3" @@ -475,15 +434,6 @@ "markers": "python_version >= '3.7'", "version": "==1.14.6" }, - "django-two-factor-auth": { - "hashes": [ - "sha256:622e78b0d6cf12eeafa239665d99c1221c399228f2f902fe478aea7759995e0e", - "sha256:a2dcc3efedd0ce4b4c14d389766c9fd8e13cabdff5e4e1b645adeb650c550cf7" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==1.17.0" - }, "djangorestframework": { "hashes": [ "sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361", @@ -1042,13 +992,6 @@ "markers": "python_version >= '3.7'", "version": "==2.9.0" }, - "pypng": { - "hashes": [ - "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c", - "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1" - ], - "version": "==0.20220715.0" - }, "python-dateutil": { "hashes": [ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", @@ -1066,14 +1009,6 @@ "markers": "python_version >= '3.8'", "version": "==1.0.1" }, - "qrcode": { - "hashes": [ - "sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a", - "sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845" - ], - "markers": "python_version >= '3.7'", - "version": "==7.4.2" - }, "redis": { "extras": [ "hiredis" @@ -1227,14 +1162,6 @@ "markers": "python_version >= '3.8'", "version": "==0.5.5" }, - "tablib": { - "hashes": [ - "sha256:9a6930037cfe0f782377963ca3f2b1dae3fd4cdbf0883848f22f1447e7bb718b", - "sha256:f9db84ed398df5109bd69c11d46613d16cc572fb9ad3213f10d95e2b5f12c18e" - ], - "markers": "python_version >= '3.9'", - "version": "==3.7.0" - }, "typing-extensions": { "hashes": [ "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", From a83b9d8b029c83b97e3ed970098a79b69aa8b0a9 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 11 Feb 2026 16:50:00 +0000 Subject: [PATCH 09/58] fix --- Pipfile | 2 +- Pipfile.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Pipfile b/Pipfile index 7a93f5d4..b75ea629 100644 --- a/Pipfile +++ b/Pipfile @@ -8,7 +8,7 @@ celery = {version = "==5.4.0", extras = ["sqs"]} cryptography = "==46.0.5" boto3 = "==1.36.14" django = "==5.2.11" -djangorestframework = "==3.16.0" +djangorestframework = "==3.16.1" django-filter = "==25.1" django-countries = "==7.6.1" django-cors-headers = "==4.7.0" diff --git a/Pipfile.lock b/Pipfile.lock index 02774fc4..ca0c25a1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1aae90ff799f71d4e05946630c6b8957cb4ad354d878c3bb714a05037b10f468" + "sha256": "9c419d386ba3f611f24d60bc56c6850984f2d4fb82b621217dcf7699db56fea8" }, "pipfile-spec": 6, "requires": { @@ -436,12 +436,12 @@ }, "djangorestframework": { "hashes": [ - "sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361", - "sha256:f022ff46613584de994c0c6a4aebbace5fd700555fbe9d33b865ebf173eba6c9" + "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", + "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==3.16.0" + "version": "==3.16.1" }, "google-api-core": { "extras": [ From 584963f3823f8d893e08442411c4206991a936d5 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 11 Feb 2026 17:45:51 +0000 Subject: [PATCH 10/58] support git requirements --- setup.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 2a20229f..0995c452 100644 --- a/setup.py +++ b/setup.py @@ -66,12 +66,16 @@ def parse_requirements(packages: t.Dict[str, t.Dict[str, t.Any]]): requirements: t.List[str] = [] for name, package in packages.items(): requirement = name - if "extras" in package: - requirement += f"[{','.join(package['extras'])}]" - if "version" in package: + if "git" in package: + requirement += f" @ git+{package['git']}" + if "ref" in package: + requirement += f"@{package['ref']}" + elif "version" in package: + if "extras" in package: + requirement += f"[{','.join(package['extras'])}]" requirement += package["version"] - if "markers" in package: - requirement += f"; {package['markers']}" + if "markers" in package: + requirement += f"; {package['markers']}" requirements.append(requirement) return requirements From e1a1a3786b6fe3b0a325f84494304173bd71eac4 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 12 Feb 2026 11:36:15 +0000 Subject: [PATCH 11/58] use separate encryption key --- codeforlife/__init__.py | 2 +- codeforlife/models/encrypted_char_field.py | 2 +- codeforlife/settings/custom.py | 2 ++ codeforlife/settings/django.py | 3 +++ codeforlife/user/models/otp_bypass_token_test.py | 2 +- settings.py | 4 +--- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/codeforlife/__init__.py b/codeforlife/__init__.py index 9c7b31bd..090c6ca7 100644 --- a/codeforlife/__init__.py +++ b/codeforlife/__init__.py @@ -114,7 +114,7 @@ def set_up_settings(service_base_dir: Path, service_name: str): secrets = dotenv_values(secrets_path) secrets.setdefault( # NOTE: This is only used locally for testing purposes. - "SECRET_KEY", + "ENCRYPTION_KEY", "XTgWqMlZCMI_E5BvCArkif9nrJIIhe_6Ic6Q_UcWJDk=", ) else: diff --git a/codeforlife/models/encrypted_char_field.py b/codeforlife/models/encrypted_char_field.py index 07a52111..42a3b52e 100644 --- a/codeforlife/models/encrypted_char_field.py +++ b/codeforlife/models/encrypted_char_field.py @@ -16,7 +16,7 @@ class EncryptedCharField(models.CharField): retrieved. """ - _fernet = Fernet(settings.SECRET_KEY) + _fernet = Fernet(settings.ENCRYPTION_KEY) _prefix = "ENC:" def __init__(self, *args, **kwargs): diff --git a/codeforlife/settings/custom.py b/codeforlife/settings/custom.py index 294083ff..0259a6cf 100644 --- a/codeforlife/settings/custom.py +++ b/codeforlife/settings/custom.py @@ -144,3 +144,5 @@ def get_redis_url(): GOOGLE_CLOUD_BIGQUERY_DATASET_ID = os.getenv( "GOOGLE_CLOUD_BIGQUERY_DATASET_ID", "REPLACE_ME" ) + +ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY", "REPLACE_ME") diff --git a/codeforlife/settings/django.py b/codeforlife/settings/django.py index 330c0d77..231f3c3e 100644 --- a/codeforlife/settings/django.py +++ b/codeforlife/settings/django.py @@ -52,6 +52,9 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = bool(int(os.getenv("DEBUG", "1"))) +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv("SECRET_KEY", "replace-me") + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ diff --git a/codeforlife/user/models/otp_bypass_token_test.py b/codeforlife/user/models/otp_bypass_token_test.py index 75d006d4..b423fac4 100644 --- a/codeforlife/user/models/otp_bypass_token_test.py +++ b/codeforlife/user/models/otp_bypass_token_test.py @@ -16,7 +16,7 @@ class TestOtpBypassToken(ModelTestCase[OtpBypassToken]): fixtures = ["school_2"] def setUp(self): - self.fernet = Fernet(settings.SECRET_KEY) + self.fernet = Fernet(settings.ENCRYPTION_KEY) user = User.objects.filter(otp_bypass_tokens__isnull=False).first() assert user diff --git a/settings.py b/settings.py index 18cff3eb..e6c44990 100644 --- a/settings.py +++ b/settings.py @@ -13,9 +13,7 @@ from codeforlife.settings import * # NOTE: This is only used locally for testing purposes. -SECRET_KEY = "XTgWqMlZCMI_E5BvCArkif9nrJIIhe_6Ic6Q_UcWJDk=" -# TODO: remove this when cfl-common is not longer installed -ENCRYPTION_KEY = SECRET_KEY +ENCRYPTION_KEY = "XTgWqMlZCMI_E5BvCArkif9nrJIIhe_6Ic6Q_UcWJDk=" INSTALLED_APPS = [ "django.contrib.admin", From b4cd735ccf2a08843e32507b502f26c5b7b4b2b2 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 12 Feb 2026 15:21:23 +0000 Subject: [PATCH 12/58] final fixes --- codeforlife/models/encrypted_char_field.py | 10 -- codeforlife/user/migrations/0001_initial.py | 58 +++++++++- codeforlife/user/models/__init__.py | 8 +- codeforlife/user/models/other.py | 112 ++++++++++++++++++++ codeforlife/user/models/otp_bypass_token.py | 3 +- 5 files changed, 176 insertions(+), 15 deletions(-) diff --git a/codeforlife/models/encrypted_char_field.py b/codeforlife/models/encrypted_char_field.py index 42a3b52e..774494fb 100644 --- a/codeforlife/models/encrypted_char_field.py +++ b/codeforlife/models/encrypted_char_field.py @@ -19,16 +19,6 @@ class EncryptedCharField(models.CharField): _fernet = Fernet(settings.ENCRYPTION_KEY) _prefix = "ENC:" - def __init__(self, *args, **kwargs): - kwargs["max_length"] += len(self._prefix) - super().__init__(*args, **kwargs) - - def deconstruct(self): - # pylint: disable-next=no-member - name, path, args, kwargs = super().deconstruct() - kwargs["max_length"] += len(self._prefix) - return name, path, args, kwargs - # pylint: disable-next=unused-argument def from_db_value(self, value: t.Optional[str], expression, connection): """ diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index aacc3369..ccbff82f 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.15 on 2026-02-11 14:16 +# Generated by Django 5.2.11 on 2026-02-12 15:19 import codeforlife.models.encrypted_char_field import codeforlife.user.models.user.admin_school_teacher @@ -336,7 +336,7 @@ class Migration(migrations.Migration): "token", codeforlife.models.encrypted_char_field.EncryptedCharField( help_text="The encrypted equivalent of the token.", - max_length=108, + max_length=104, verbose_name="token", ), ), @@ -516,6 +516,58 @@ class Migration(migrations.Migration): ), ], ), + migrations.CreateModel( + name="SchoolTeacherInvitation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("token", models.CharField(max_length=88)), + ("invited_teacher_first_name", models.CharField(max_length=150)), + ( + "_invited_teacher_first_name", + models.BinaryField(blank=True, null=True), + ), + ("invited_teacher_last_name", models.CharField(max_length=150)), + ( + "_invited_teacher_last_name", + models.BinaryField(blank=True, null=True), + ), + ("invited_teacher_email", models.EmailField(max_length=254)), + ("_invited_teacher_email", models.BinaryField(blank=True, null=True)), + ("invited_teacher_is_admin", models.BooleanField(default=False)), + ("expiry", models.DateTimeField()), + ( + "creation_time", + models.DateTimeField(default=django.utils.timezone.now, null=True), + ), + ("is_active", models.BooleanField(default=True)), + ( + "school", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="teacher_invitations", + to="user.school", + ), + ), + ( + "from_teacher", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="school_invitations", + to="user.teacher", + ), + ), + ], + ), migrations.AddField( model_name="class", name="created_by", @@ -583,7 +635,7 @@ class Migration(migrations.Migration): ( "google_refresh_token", codeforlife.models.encrypted_char_field.EncryptedCharField( - blank=True, max_length=1012, null=True + blank=True, max_length=1004, null=True ), ), ("google_sub", models.CharField(blank=True, max_length=255, null=True)), diff --git a/codeforlife/user/models/__init__.py b/codeforlife/user/models/__init__.py index fe9765ab..bafb5754 100644 --- a/codeforlife/user/models/__init__.py +++ b/codeforlife/user/models/__init__.py @@ -5,7 +5,13 @@ from .auth_factor import AuthFactor from .klass import Class, class_name_validators -from .other import DailyActivity, JoinReleaseStudent, TotalActivity, UserSession +from .other import ( + DailyActivity, + JoinReleaseStudent, + SchoolTeacherInvitation, + TotalActivity, + UserSession, +) from .otp_bypass_token import OtpBypassToken from .school import School, school_name_validators from .session import Session diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py index 9b8437b7..ea75b8b7 100644 --- a/codeforlife/user/models/other.py +++ b/codeforlife/user/models/other.py @@ -8,6 +8,7 @@ """ import typing as t +from uuid import uuid4 from django.db import models from django.utils import timezone @@ -20,6 +21,7 @@ from .klass import Class from .school import School from .student import Student + from .teacher import Teacher from .user import User else: TypedModelMeta = object @@ -194,3 +196,113 @@ class Meta(TypedModelMeta): def __str__(self): # pylint: disable-next=line-too-long return f"Activity on {self.date}: CSV clicks: {self.csv_click_count}, login cards clicks: {self.login_cards_click_count}, primary pack downloads: {self.primary_coding_club_downloads}, python pack downloads: {self.python_coding_club_downloads}, level control submits: {self.level_control_submits}, teacher lockout resets: {self.teacher_lockout_resets}, indy lockout resets: {self.indy_lockout_resets}, school student lockout resets: {self.school_student_lockout_resets}, unverified teachers anonymised: {self.anonymised_unverified_teachers}, unverified independents anonymised: {self.anonymised_unverified_independents}" + + +class SchoolTeacherInvitationModelManager(models.Manager): + """ + A custom model manager for the SchoolTeacherInvitation model to filter out + inactive invitations by default. + """ + + def get_original_queryset(self): + """ + Get the original queryset without filtering out inactive invitations. + """ + return super().get_queryset() + + def get_queryset(self): + """ + Get the queryset for the SchoolTeacherInvitation model, filtering out + inactive invitations by default. + """ + return super().get_queryset().filter(is_active=True) + + +class SchoolTeacherInvitation(models.Model): + """ + A model to track invitations for teachers to join a school. This is meant to + be used when a teacher invites another teacher to join their school, and the + invitation needs to be tracked until the invited teacher accepts or declines + the invitation, or the invitation expires. + """ + + token: str + token = models.CharField(max_length=88) # type: ignore[assignment] + + school: t.Optional["School"] + school = models.ForeignKey( # type: ignore[assignment] + "user.School", + related_name="teacher_invitations", + null=True, + on_delete=models.SET_NULL, + ) + + from_teacher: t.Optional["Teacher"] + from_teacher = models.ForeignKey( # type: ignore[assignment] + "user.Teacher", + related_name="school_invitations", + null=True, + on_delete=models.SET_NULL, + ) + + invited_teacher_first_name: str + invited_teacher_first_name = models.CharField( # type: ignore[assignment] + max_length=150 + ) # Same as User model + # TODO: Make not nullable once data has been transferred + _invited_teacher_first_name = models.BinaryField(null=True, blank=True) + + invited_teacher_last_name: str + invited_teacher_last_name = models.CharField( # type: ignore[assignment] + max_length=150 + ) # Same as User model + # TODO: Make not nullable once data has been transferred + _invited_teacher_last_name = models.BinaryField(null=True, blank=True) + + # TODO: Switch to a CharField to be able to hold hashed value + invited_teacher_email: str + invited_teacher_email = ( + models.EmailField() # type: ignore[assignment] + ) # Same as User model + # TODO: Make not nullable once data has been transferred + _invited_teacher_email = models.BinaryField(null=True, blank=True) + + invited_teacher_is_admin: bool + invited_teacher_is_admin = models.BooleanField( # type: ignore[assignment] + default=False + ) + + expiry: "datetime" + expiry = models.DateTimeField() # type: ignore[assignment] + + # pylint: disable=duplicate-code + creation_time: t.Optional["datetime"] + creation_time = models.DateTimeField( # type: ignore[assignment] + default=timezone.now, null=True + ) + + is_active: bool + is_active = models.BooleanField(default=True) # type: ignore[assignment] + # pylint: enable=duplicate-code + + objects = SchoolTeacherInvitationModelManager() + + @property + def is_expired(self): + """Whether the invitation has expired based on the expiry datetime.""" + return self.expiry < timezone.now() + + def __str__(self): + if self.school is None: + return super().__str__() + + # pylint: disable-next=line-too-long + return f"School teacher invitation for {self.invited_teacher_email} to {self.school.name}" + + def anonymise(self): + """Anonymise the invitation.""" + self.invited_teacher_first_name = uuid4().hex + self.invited_teacher_last_name = uuid4().hex + self.invited_teacher_email = uuid4().hex + self.is_active = False + self.save() diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index a669caee..dfebdcbf 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -77,7 +77,8 @@ def bulk_create(self, user: "User"): # type: ignore[override] token: str token = EncryptedCharField( # type: ignore[assignment] _("token"), - max_length=100, + # pylint: disable-next=protected-access + max_length=100 + len(EncryptedCharField._prefix), help_text=_("The encrypted equivalent of the token."), ) From 056971fd4f41468a84a1cb5d1cf87de57fcd05ef Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 17 Feb 2026 13:46:03 +0000 Subject: [PATCH 13/58] subdirectory --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 0995c452..ba30c913 100644 --- a/setup.py +++ b/setup.py @@ -70,6 +70,8 @@ def parse_requirements(packages: t.Dict[str, t.Dict[str, t.Any]]): requirement += f" @ git+{package['git']}" if "ref" in package: requirement += f"@{package['ref']}" + if "subdirectory" in package: + requirement += f"#subdirectory={package['subdirectory']}" elif "version" in package: if "extras" in package: requirement += f"[{','.join(package['extras'])}]" From b97682452377c995bbe0ab3565f922ebb69d02f5 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 17 Feb 2026 15:48:05 +0000 Subject: [PATCH 14/58] fix --- codeforlife/settings/custom.py | 7 +++++++ codeforlife/user/models/user/user.py | 14 ++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/codeforlife/settings/custom.py b/codeforlife/settings/custom.py index 0259a6cf..f05d1e56 100644 --- a/codeforlife/settings/custom.py +++ b/codeforlife/settings/custom.py @@ -127,6 +127,13 @@ def get_redis_url(): # The URL to connect to the Redis cache. REDIS_URL = get_redis_url() +# A flag to indicate whether the old system is the current runtime to +# conditionally run code that is still needed for the old system to work but is +# no longer needed in the new system. Once the old system is fully deprecated, +# this flag and all code that depends on it should be removed. +# WARN: This setting should never be imported in the old system. +OLD_SYSTEM = False + # Our Google OAuth 2.0 client credentials # https://console.cloud.google.com/auth/clients GOOGLE_CLIENT_ID = os.getenv( diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index b0609b73..3feb655e 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -8,6 +8,8 @@ import typing as t from datetime import datetime, timedelta +from django.conf import settings + # pylint: disable-next=imported-auth-user from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import UserManager as _UserManager @@ -93,10 +95,14 @@ class User( @property def is_authenticated(self): return ( - not self.session.auth_factors.exists() - and self.userprofile.is_verified - if super().is_authenticated - else False + True + if getattr(settings, "OLD_SYSTEM", True) + else ( + not self.session.auth_factors.exists() + and self.userprofile.is_verified + if super().is_authenticated + else False + ) ) @property From c5b44ea762c9f8ee8739f1fb61ca4969da2d5a1c Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 18 Feb 2026 11:27:12 +0000 Subject: [PATCH 15/58] dynamically define is_verified --- codeforlife/user/models/user/user.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 3feb655e..8aa9f838 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -139,10 +139,13 @@ def last_otp_for_time(self): """Shorthand for user-profile field.""" return self.userprofile.last_otp_for_time - @property - def is_verified(self): - """Shorthand for user-profile field.""" - return self.userprofile.is_verified + # This property is set up differently in the old and new systems, so is not + # defined on the model in the old system. + is_verified: bool + # @property + # def is_verified(self): + # """Shorthand for user-profile field.""" + # return self.userprofile.is_verified @property def totp(self): @@ -206,6 +209,15 @@ def anonymize(self): ) +if not getattr(settings, "OLD_SYSTEM", True): + + def is_verified(self: User): + """Shorthand for user-profile field.""" + return self.userprofile.is_verified + + User.is_verified = property(fget=is_verified) # type: ignore[assignment] + + AnyUser = t.TypeVar("AnyUser", bound=User) From ed633c3f1ac6cf66b80ddac008f0b184a98f1506 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 23 Feb 2026 16:36:45 +0000 Subject: [PATCH 16/58] merge from main --- codeforlife/user/models/user/user.py | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 8aa9f838..f35486cb 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _ from pyotp import TOTP -from ....models import AbstractBaseUser, EncryptedCharField +from ....models import AbstractBaseUser from ....types import Validators from ....validators import UnicodeAlphanumericCharSetValidator @@ -249,25 +249,14 @@ class UserProfile(models.Model): developer = models.BooleanField(default=False) is_verified = models.BooleanField(default=False) - # TODO: Make not nullable once data has been transferred - first_name = models.CharField(max_length=200, null=True, blank=True) - _first_name = models.BinaryField(null=True, blank=True) - last_name = models.CharField(max_length=200, null=True, blank=True) - _last_name = models.BinaryField(null=True, blank=True) - email = models.CharField(max_length=200, null=True, blank=True) - _email = models.BinaryField(null=True, blank=True) - # TODO: Make not nullable once data has been transferred - username = models.CharField(max_length=200, null=True, blank=True) - _username = models.BinaryField(null=True, blank=True) - # Google. - google_refresh_token = EncryptedCharField( - # pylint: disable-next=protected-access - max_length=1000 + len(EncryptedCharField._prefix), - null=True, - blank=True, - ) - google_sub = models.CharField(max_length=255, null=True, blank=True) + # google_refresh_token = EncryptedCharField( + # # pylint: disable-next=protected-access + # max_length=1000 + len(EncryptedCharField._prefix), + # null=True, + # blank=True, + # ) + # google_sub = models.CharField(max_length=255, null=True, blank=True) def __str__(self): return f"{self.user.first_name} {self.user.last_name}" From 422755f3f7faaaf855d1d9ef02b9e15cf78c4bad Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 23 Feb 2026 16:48:54 +0000 Subject: [PATCH 17/58] handle old encrypted char fields --- codeforlife/user/fixtures/google_users.json | 4 +- codeforlife/user/fixtures/legacy.json | 252 ++------------------ codeforlife/user/models/user/google.py | 3 +- codeforlife/user/models/user/user.py | 2 + 4 files changed, 26 insertions(+), 235 deletions(-) diff --git a/codeforlife/user/fixtures/google_users.json b/codeforlife/user/fixtures/google_users.json index f56b4119..13cd2a2a 100644 --- a/codeforlife/user/fixtures/google_users.json +++ b/codeforlife/user/fixtures/google_users.json @@ -15,9 +15,7 @@ "pk": 34, "fields": { "user": 34, - "is_verified": true, - "google_refresh_token": "example", - "google_sub": "34" + "is_verified": true } }, { diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json index 98a2c5c5..ff6bb9dc 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -7,17 +7,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": true, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -28,17 +18,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -49,17 +29,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -70,17 +40,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": true, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -91,17 +51,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -112,17 +62,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -133,17 +73,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -154,17 +84,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -175,17 +95,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -196,17 +106,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -217,17 +117,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -238,17 +128,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -259,17 +139,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -280,17 +150,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -301,17 +161,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -322,17 +172,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -343,17 +183,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -364,17 +194,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -385,17 +205,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -406,17 +216,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": true, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": true } }, { @@ -427,17 +227,7 @@ "otp_secret": null, "last_otp_for_time": null, "developer": false, - "is_verified": false, - "first_name": null, - "_first_name": null, - "last_name": null, - "_last_name": null, - "email": null, - "_email": null, - "username": null, - "_username": null, - "google_refresh_token": null, - "google_sub": null + "is_verified": false } }, { diff --git a/codeforlife/user/models/user/google.py b/codeforlife/user/models/user/google.py index d9dbca1b..08d19da7 100644 --- a/codeforlife/user/models/user/google.py +++ b/codeforlife/user/models/user/google.py @@ -82,7 +82,8 @@ def _sync(self, auth_header: str, refresh_token: t.Optional[str] = None): last_name=last_name, ) - UserProfile.objects.create( + # TODO: remove type ignore when we add back these fields. + UserProfile.objects.create( # type: ignore[misc] user=user, is_verified=is_verified, google_refresh_token=refresh_token, diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index f35486cb..6d938423 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -250,12 +250,14 @@ class UserProfile(models.Model): is_verified = models.BooleanField(default=False) # Google. + google_refresh_token: t.Optional[str] # google_refresh_token = EncryptedCharField( # # pylint: disable-next=protected-access # max_length=1000 + len(EncryptedCharField._prefix), # null=True, # blank=True, # ) + google_sub: t.Optional[str] # google_sub = models.CharField(max_length=255, null=True, blank=True) def __str__(self): From 66b7b9e5c52d93ab362cb5f885fb77008ed70ea4 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 23 Feb 2026 17:03:10 +0000 Subject: [PATCH 18/58] comment out otp bypass token tests --- .../auth/backends/otp_bypass_token_test.py | 60 +++++++------- codeforlife/user/models/otp_bypass_token.py | 2 +- .../user/models/otp_bypass_token_test.py | 79 +++++++++---------- codeforlife/user/signals/auth_factor_test.py | 34 ++++---- 4 files changed, 88 insertions(+), 87 deletions(-) diff --git a/codeforlife/user/auth/backends/otp_bypass_token_test.py b/codeforlife/user/auth/backends/otp_bypass_token_test.py index 5a98c4df..20e38b3d 100644 --- a/codeforlife/user/auth/backends/otp_bypass_token_test.py +++ b/codeforlife/user/auth/backends/otp_bypass_token_test.py @@ -3,35 +3,37 @@ Created on 10/04/2024 at 13:17:18(+01:00). """ -from ....tests import APIRequestFactory, TestCase -from ...models import AuthFactor, User -from .otp_bypass_token import OtpBypassTokenBackend +# TODO: uncomment test once CSE is implemented + +# from ....tests import APIRequestFactory, TestCase +# from ...models import AuthFactor, User +# from .otp_bypass_token import OtpBypassTokenBackend # pylint: disable-next=missing-class-docstring,too-many-instance-attributes -class TestTokenBackend(TestCase): - fixtures = ["school_2", "school_2_sessions"] - - def setUp(self): - self.backend = OtpBypassTokenBackend() - self.request_factory = APIRequestFactory(User) - - user = User.objects.filter( - otp_bypass_tokens__isnull=False, - session__isnull=False, - session__auth_factors__auth_factor__type__in=[AuthFactor.Type.OTP], - ).first() - assert user - self.user = user - - def test_authenticate(self): - """Can authenticate by bypassing a user's enabled OTP auth factor.""" - otp_bypass_token_count = self.user.otp_bypass_tokens.count() - - user = self.backend.authenticate( - request=self.request_factory.post("/", user=self.user), - token="aaaaaaaa", - ) - - assert user == self.user - assert user.otp_bypass_tokens.count() == otp_bypass_token_count - 1 +# class TestTokenBackend(TestCase): +# fixtures = ["school_2", "school_2_sessions"] + +# def setUp(self): +# self.backend = OtpBypassTokenBackend() +# self.request_factory = APIRequestFactory(User) + +# user = User.objects.filter( +# otp_bypass_tokens__isnull=False, +# session__isnull=False, +# session__auth_factors__auth_factor__type__in=[AuthFactor.Type.OTP], +# ).first() +# assert user +# self.user = user + +# def test_authenticate(self): +# """Can authenticate by bypassing a user's enabled OTP auth factor.""" +# otp_bypass_token_count = self.user.otp_bypass_tokens.count() + +# user = self.backend.authenticate( +# request=self.request_factory.post("/", user=self.user), +# token="aaaaaaaa", +# ) + +# assert user == self.user +# assert user.otp_bypass_tokens.count() == otp_bypass_token_count - 1 diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index 330ac8a9..73ab31fe 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -81,7 +81,7 @@ def bulk_create(self, user: "User"): # type: ignore[override] ) token: str - token = EncryptedTextField( + token = EncryptedTextField( # type: ignore[assignment] associated_data="token", verbose_name=_("token"), help_text=_("The encrypted equivalent of the token."), diff --git a/codeforlife/user/models/otp_bypass_token_test.py b/codeforlife/user/models/otp_bypass_token_test.py index 435f89bf..e25fa877 100644 --- a/codeforlife/user/models/otp_bypass_token_test.py +++ b/codeforlife/user/models/otp_bypass_token_test.py @@ -3,57 +3,54 @@ Created on 24/01/2024 at 16:17:22(+00:00). """ -from cryptography.fernet import Fernet -from django.conf import settings +# TODO: uncomment test once CSE is implemented -from ...tests import ModelTestCase -from .otp_bypass_token import OtpBypassToken -from .user import User +# from ...tests import ModelTestCase +# from .otp_bypass_token import OtpBypassToken +# from .user import User -# pylint: disable-next=missing-class-docstring -class TestOtpBypassToken(ModelTestCase[OtpBypassToken]): - fixtures = ["school_2"] +# # pylint: disable-next=missing-class-docstring +# class TestOtpBypassToken(ModelTestCase[OtpBypassToken]): +# fixtures = ["school_2"] - def setUp(self): - self.fernet = Fernet(settings.ENCRYPTION_KEY) +# def setUp(self): +# user = User.objects.filter(otp_bypass_tokens__isnull=False).first() +# assert user +# self.user = user - user = User.objects.filter(otp_bypass_tokens__isnull=False).first() - assert user - self.user = user +# def test_objects__bulk_create(self): +# """Can bulk create a new set of tokens.""" +# original_otp_bypass_tokens = list(self.user.otp_bypass_tokens.all()) - def test_objects__bulk_create(self): - """Can bulk create a new set of tokens.""" - original_otp_bypass_tokens = list(self.user.otp_bypass_tokens.all()) +# otp_bypass_tokens = OtpBypassToken.objects.bulk_create(self.user) - otp_bypass_tokens = OtpBypassToken.objects.bulk_create(self.user) +# for otp_bypass_token in original_otp_bypass_tokens: +# self.assert_does_not_exist(otp_bypass_token) - for otp_bypass_token in original_otp_bypass_tokens: - self.assert_does_not_exist(otp_bypass_token) +# assert len(otp_bypass_tokens) == OtpBypassToken.max_count +# assert len(otp_bypass_tokens) == self.user.otp_bypass_tokens.count() - assert len(otp_bypass_tokens) == OtpBypassToken.max_count - assert len(otp_bypass_tokens) == self.user.otp_bypass_tokens.count() +# for otp_bypass_token in otp_bypass_tokens: +# assert otp_bypass_token.token is not None +# assert len(otp_bypass_token.token) == OtpBypassToken.length +# assert all( +# char in OtpBypassToken.allowed_chars +# for char in otp_bypass_token.token +# ) - for otp_bypass_token in otp_bypass_tokens: - assert otp_bypass_token.token is not None - assert len(otp_bypass_token.token) == OtpBypassToken.length - assert all( - char in OtpBypassToken.allowed_chars - for char in otp_bypass_token.token - ) +# def test_save(self): +# """Cannot create or update a single instance.""" +# with self.assert_raises_integrity_error(): +# OtpBypassToken().save() - def test_save(self): - """Cannot create or update a single instance.""" - with self.assert_raises_integrity_error(): - OtpBypassToken().save() +# def test_check_token(self): +# """Can check a single token.""" +# otp_bypass_token = self.user.otp_bypass_tokens.first() +# assert otp_bypass_token - def test_check_token(self): - """Can check a single token.""" - otp_bypass_token = self.user.otp_bypass_tokens.first() - assert otp_bypass_token +# assert not otp_bypass_token.check_token("--------") +# otp_bypass_token.refresh_from_db() # assert exists - assert not otp_bypass_token.check_token("--------") - otp_bypass_token.refresh_from_db() # assert exists - - assert otp_bypass_token.check_token("aaaaaaaa") - self.assert_does_not_exist(otp_bypass_token) +# assert otp_bypass_token.check_token("aaaaaaaa") +# self.assert_does_not_exist(otp_bypass_token) diff --git a/codeforlife/user/signals/auth_factor_test.py b/codeforlife/user/signals/auth_factor_test.py index efc763ee..f74b49c4 100644 --- a/codeforlife/user/signals/auth_factor_test.py +++ b/codeforlife/user/signals/auth_factor_test.py @@ -3,26 +3,28 @@ Created on 17/01/2025 at 16:04:46(+00:00). """ -from django.test import TestCase +# TODO: uncomment test once CSE is implemented -from ..models import AuthFactor +# from django.test import TestCase +# from ..models import AuthFactor -# pylint: disable-next=missing-class-docstring -class TestAuthFactor(TestCase): - fixtures = ["school_2"] - def test_post_delete(self): - """Deleting an otp-auth-factor assigns a new otp-secret to its user.""" - auth_factor = AuthFactor.objects.filter( - type=AuthFactor.Type.OTP - ).first() - assert auth_factor +# # pylint: disable-next=missing-class-docstring +# class TestAuthFactor(TestCase): +# fixtures = ["school_2"] - userprofile = auth_factor.user.userprofile - otp_secret = userprofile.otp_secret +# def test_post_delete(self): +# """Deleting an otp-auth-factor assigns a new otp-secret to its user.""" +# auth_factor = AuthFactor.objects.filter( +# type=AuthFactor.Type.OTP +# ).first() +# assert auth_factor - auth_factor.delete() +# userprofile = auth_factor.user.userprofile +# otp_secret = userprofile.otp_secret - userprofile.refresh_from_db() - assert otp_secret != userprofile.otp_secret +# auth_factor.delete() + +# userprofile.refresh_from_db() +# assert otp_secret != userprofile.otp_secret From 345691467347d6e8f3ca29b0cdabe251a6270476 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 23 Feb 2026 17:30:00 +0000 Subject: [PATCH 19/58] fix linting errors --- codeforlife/user/auth/backends/otp_bypass_token_test.py | 2 +- codeforlife/user/models/otp_bypass_token.py | 2 +- codeforlife/user/signals/auth_factor_test.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/codeforlife/user/auth/backends/otp_bypass_token_test.py b/codeforlife/user/auth/backends/otp_bypass_token_test.py index 20e38b3d..8a7b4a70 100644 --- a/codeforlife/user/auth/backends/otp_bypass_token_test.py +++ b/codeforlife/user/auth/backends/otp_bypass_token_test.py @@ -21,7 +21,7 @@ # user = User.objects.filter( # otp_bypass_tokens__isnull=False, # session__isnull=False, -# session__auth_factors__auth_factor__type__in=[AuthFactor.Type.OTP], +# session__auth_factors__auth_factor__type__in=[AuthFactor.Type.OTP], # ).first() # assert user # self.user = user diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index 73ab31fe..23a3552f 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -93,7 +93,7 @@ class Meta(TypedModelMeta): @property def dek_aead(self): - return self.user.userprofile.dek_aead + return self.user.userprofile.dek_aead # type: ignore[attr-defined] def save(self, *args, **kwargs): raise IntegrityError("Cannot create or update a single instance.") diff --git a/codeforlife/user/signals/auth_factor_test.py b/codeforlife/user/signals/auth_factor_test.py index f74b49c4..033e65ca 100644 --- a/codeforlife/user/signals/auth_factor_test.py +++ b/codeforlife/user/signals/auth_factor_test.py @@ -15,7 +15,8 @@ # fixtures = ["school_2"] # def test_post_delete(self): -# """Deleting an otp-auth-factor assigns a new otp-secret to its user.""" +# """ +# Deleting an otp-auth-factor assigns a new otp-secret to its user.""" # auth_factor = AuthFactor.objects.filter( # type=AuthFactor.Type.OTP # ).first() From 663c526a1a96a61fc8a8cd885a5f599a4b8c3070 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 23 Feb 2026 17:35:09 +0000 Subject: [PATCH 20/58] test --- codeforlife/tests/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codeforlife/tests/exceptions.py b/codeforlife/tests/exceptions.py index a15d488b..462f6c0d 100644 --- a/codeforlife/tests/exceptions.py +++ b/codeforlife/tests/exceptions.py @@ -22,7 +22,7 @@ class InterruptPipelineError(Exception): """ @classmethod - # pylint: disable-next=too-many-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-argument def run( cls, test_case: "TestCase", From 894599638ba8873b9aa25cab655a59abfac37cb7 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 23 Feb 2026 17:53:14 +0000 Subject: [PATCH 21/58] test --- codeforlife/tests/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codeforlife/tests/exceptions.py b/codeforlife/tests/exceptions.py index 462f6c0d..87f6dc40 100644 --- a/codeforlife/tests/exceptions.py +++ b/codeforlife/tests/exceptions.py @@ -22,7 +22,7 @@ class InterruptPipelineError(Exception): """ @classmethod - # pylint: disable-next=too-many-arguments,too-many-positional-argument + # pylint: disable-next=too-many-arguments,too-many-positional-argumen def run( cls, test_case: "TestCase", From c9184f6cd5d5650c303ceabfd47e72c44b339adb Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 23 Feb 2026 18:01:17 +0000 Subject: [PATCH 22/58] test --- codeforlife/tests/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codeforlife/tests/exceptions.py b/codeforlife/tests/exceptions.py index 87f6dc40..462f6c0d 100644 --- a/codeforlife/tests/exceptions.py +++ b/codeforlife/tests/exceptions.py @@ -22,7 +22,7 @@ class InterruptPipelineError(Exception): """ @classmethod - # pylint: disable-next=too-many-arguments,too-many-positional-argumen + # pylint: disable-next=too-many-arguments,too-many-positional-argument def run( cls, test_case: "TestCase", From 33555ed7cccc2827f589659ea9965d4c4c5d59ee Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 23 Feb 2026 18:07:04 +0000 Subject: [PATCH 23/58] fix --- codeforlife/tests/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codeforlife/tests/exceptions.py b/codeforlife/tests/exceptions.py index 462f6c0d..5662c3ce 100644 --- a/codeforlife/tests/exceptions.py +++ b/codeforlife/tests/exceptions.py @@ -22,7 +22,7 @@ class InterruptPipelineError(Exception): """ @classmethod - # pylint: disable-next=too-many-arguments,too-many-positional-argument + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def run( cls, test_case: "TestCase", From 4d55e43b1161de5f638c2f5dd520ddba33eb72a3 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 24 Feb 2026 11:36:10 +0000 Subject: [PATCH 24/58] delete ENCRYPTION_KEY --- codeforlife/__init__.py | 5 ----- settings.py | 3 --- 2 files changed, 8 deletions(-) diff --git a/codeforlife/__init__.py b/codeforlife/__init__.py index 090c6ca7..b00ba40e 100644 --- a/codeforlife/__init__.py +++ b/codeforlife/__init__.py @@ -112,11 +112,6 @@ def set_up_settings(service_base_dir: Path, service_name: str): secrets_file.write(secrets_file_comment) secrets = dotenv_values(secrets_path) - secrets.setdefault( - # NOTE: This is only used locally for testing purposes. - "ENCRYPTION_KEY", - "XTgWqMlZCMI_E5BvCArkif9nrJIIhe_6Ic6Q_UcWJDk=", - ) else: # pylint: disable-next=import-outside-toplevel import boto3 diff --git a/settings.py b/settings.py index e6c44990..f74c773a 100644 --- a/settings.py +++ b/settings.py @@ -12,9 +12,6 @@ # pylint: disable-next=wildcard-import,unused-wildcard-import,wrong-import-position from codeforlife.settings import * -# NOTE: This is only used locally for testing purposes. -ENCRYPTION_KEY = "XTgWqMlZCMI_E5BvCArkif9nrJIIhe_6Ic6Q_UcWJDk=" - INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", From 8cbda5844351e1cb411133f571321c82d6ad9c12 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 24 Feb 2026 14:26:36 +0000 Subject: [PATCH 25/58] dek models --- codeforlife/models/encrypted.py | 4 +- codeforlife/models/fields/base_encrypted.py | 8 ++- .../models/fields/data_encryption_key.py | 8 ++- codeforlife/user/models/school.py | 11 +++- codeforlife/user/models/user/user.py | 64 ++++++++++++------- 5 files changed, 65 insertions(+), 30 deletions(-) diff --git a/codeforlife/models/encrypted.py b/codeforlife/models/encrypted.py index 69c9f0b4..f64a2b2a 100644 --- a/codeforlife/models/encrypted.py +++ b/codeforlife/models/encrypted.py @@ -110,7 +110,8 @@ def _check_associated_data(cls, **kwargs): """ errors: t.List[checks.Error] = [] - if cls._meta.abstract: + # Skip abstract and proxy models. + if cls._meta.abstract or cls._meta.proxy: return errors # Ensure associated_data is defined. @@ -150,6 +151,7 @@ def _check_associated_data(cls, **kwargs): # pylint: disable-next=too-many-boolean-expressions not model is cls and not model._meta.abstract + and not model._meta.proxy and issubclass(model, EncryptedModel) and hasattr(model, "associated_data") and isinstance(model.associated_data, str) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 621ddf36..e11597e5 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -215,8 +215,12 @@ def contribute_to_class(self, cls, name, private_only=False): """ super().contribute_to_class(cls, name, private_only) - # Skip fake models used for migrations. - if cls.__module__ == "__fake__": + # Skip fake (used for migrations), abstract and proxy models. + if ( + cls.__module__ == "__fake__" + or cls._meta.abstract + or cls._meta.proxy + ): return # Ensure the model subclasses EncryptedModel. diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index f4e71610..cd302b75 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -131,8 +131,12 @@ def deconstruct(self): def contribute_to_class(self, cls, name, private_only=False): super().contribute_to_class(cls, name, private_only) - # Skip fake models used for migrations. - if cls.__module__ == "__fake__": + # Skip fake (used for migrations), abstract and proxy models. + if ( + cls.__module__ == "__fake__" + or cls._meta.abstract + or cls._meta.proxy + ): return # Ensure the model subclasses BaseDataEncryptionKeyModel. diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index 40980fd0..a531b531 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -10,6 +10,7 @@ from django.utils import timezone from django_countries.fields import CountryField +from ...models import DataEncryptionKeyModel from ...types import Validators from ...validators import UnicodeAlphanumericCharSetValidator @@ -26,7 +27,7 @@ ] -class SchoolModelManager(models.Manager): +class SchoolModelManager(DataEncryptionKeyModel.Manager["School"]): """Manager for School model.""" def get_original_queryset(self): @@ -38,9 +39,11 @@ def get_queryset(self): return super().get_queryset().filter(is_active=True) -class School(models.Model): +class School(DataEncryptionKeyModel): """A school.""" + associated_data = "school" + name: str name = models.CharField( # type: ignore[assignment] max_length=200, @@ -71,7 +74,9 @@ class School(models.Model): is_active: bool is_active = models.BooleanField(default=True) # type: ignore[assignment] - objects = SchoolModelManager() + objects: SchoolModelManager = ( + SchoolModelManager() # type: ignore[assignment] + ) def __str__(self): return self.name diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 6d938423..5c96c0dc 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _ from pyotp import TOTP -from ....models import AbstractBaseUser +from ....models import AbstractBaseUser, DataEncryptionKeyModel from ....types import Validators from ....validators import UnicodeAlphanumericCharSetValidator @@ -59,13 +59,50 @@ class Meta(TypedModelMeta): abstract = True +AnyUser = t.TypeVar("AnyUser", bound="User") + + +class UserManager( + _UserManager[AnyUser], + DataEncryptionKeyModel.Manager[AnyUser], + t.Generic[AnyUser], +): + """ + Manager for the User model that inherits Django's default manager and + encrypted manager to handle encrypted fields. + """ + + def filter_users(self, queryset: QuerySet["User"]): + """Filter the users to the specific type. + + Args: + queryset: The queryset of users to filter. + + Returns: + A subset of the queryset of users. + """ + return queryset + + # pylint: disable-next=missing-function-docstring + def get_queryset(self): + queryset = super().get_queryset() + return ( + queryset + if getattr(settings, "OLD_SYSTEM", True) + else self.filter_users(queryset.filter(is_active=True)) + ) + + # pylint: disable-next=too-many-ancestors class User( _AbstractBaseUser, AbstractUser, # TODO: remove this inheritance in new schema + DataEncryptionKeyModel, ): """A proxy to Django's user class.""" + associated_data = "user" + _password: t.Optional[str] id: int # type: ignore[assignment] @@ -92,6 +129,10 @@ class User( null=True, ) + objects: UserManager[ # type: ignore[misc] + "User" + ] = UserManager() # type: ignore[assignment] + @property def is_authenticated(self): return ( @@ -218,27 +259,6 @@ def is_verified(self: User): User.is_verified = property(fget=is_verified) # type: ignore[assignment] -AnyUser = t.TypeVar("AnyUser", bound=User) - - -# pylint: disable-next=missing-class-docstring -class UserManager(_UserManager[AnyUser], t.Generic[AnyUser]): - def filter_users(self, queryset: QuerySet[User]): - """Filter the users to the specific type. - - Args: - queryset: The queryset of users to filter. - - Returns: - A subset of the queryset of users. - """ - return queryset - - # pylint: disable-next=missing-function-docstring - def get_queryset(self): - return self.filter_users(super().get_queryset().filter(is_active=True)) - - class UserProfile(models.Model): """A user's profile.""" From e2b76788a74d3745f10835c4de6e827c1769e5b1 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 24 Feb 2026 16:12:21 +0000 Subject: [PATCH 26/58] quick save --- ...alter_user_managers_school_dek_user_dek.py | 41 +++++++ .../migrations/0003_remove_user_first_name.py | 109 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 codeforlife/user/migrations/0002_alter_user_managers_school_dek_user_dek.py create mode 100644 codeforlife/user/migrations/0003_remove_user_first_name.py diff --git a/codeforlife/user/migrations/0002_alter_user_managers_school_dek_user_dek.py b/codeforlife/user/migrations/0002_alter_user_managers_school_dek_user_dek.py new file mode 100644 index 00000000..a14169ea --- /dev/null +++ b/codeforlife/user/migrations/0002_alter_user_managers_school_dek_user_dek.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.11 on 2026-02-24 14:18 + +import codeforlife.models.fields.data_encryption_key +import codeforlife.user.models.user.user +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("user", "0001_initial"), + ] + + operations = [ + migrations.AlterModelManagers( + name="user", + managers=[ + ("objects", codeforlife.user.models.user.user.UserManager()), + ], + ), + migrations.AddField( + model_name="school", + name="dek", + field=codeforlife.models.fields.data_encryption_key.DataEncryptionKeyField( + editable=False, + help_text="The encrypted data encryption key (DEK) for this model.", + null=True, + verbose_name="data encryption key", + ), + ), + migrations.AddField( + model_name="user", + name="dek", + field=codeforlife.models.fields.data_encryption_key.DataEncryptionKeyField( + editable=False, + help_text="The encrypted data encryption key (DEK) for this model.", + null=True, + verbose_name="data encryption key", + ), + ), + ] diff --git a/codeforlife/user/migrations/0003_remove_user_first_name.py b/codeforlife/user/migrations/0003_remove_user_first_name.py new file mode 100644 index 00000000..c3fd0e3b --- /dev/null +++ b/codeforlife/user/migrations/0003_remove_user_first_name.py @@ -0,0 +1,109 @@ +import typing as t + +from codeforlife.models.fields import EncryptedTextField +from django.apps.registry import Apps +from django.db import migrations + +if t.TYPE_CHECKING: + from django.db.models import Model + + +def encrypt_field(model_name: str, name: str, temp_name: str): + def code(apps: Apps, schema_editor): + model_class: Model = apps.get_model("codeforlife.user", model_name) + + models = model_class.objects.filter(**{f"{name}__isnull": False}) + for model in models: + value = getattr(model, name) + setattr(model, temp_name, value) + model.save(update_fields=[temp_name]) + + return code + + +def decrypt_field(model_name: str, name: str, temp_name: str): + def reverse_code(apps: Apps, schema_editor): + model_class: Model = apps.get_model("codeforlife.user", model_name) + + models = model_class.objects.filter(**{f"{temp_name}__isnull": False}) + for model in models: + value = getattr(model, temp_name) + setattr(model, name, value) + model.save(update_fields=[name]) + + return reverse_code + + +def create_migrations(model_name: str, name: str, verbose_name: str): + """Creates a list of migrations to encrypt a field in the database.""" + + temp_name = f"_{name}" + + return [ + migrations.AddField( + model_name=model_name, + name=temp_name, + field=EncryptedTextField( + associated_data=name, + db_column=temp_name, + default=None, + verbose_name=verbose_name, + ), + ), + migrations.RunPython( + code=encrypt_field(model_name, name, temp_name), + reverse_code=decrypt_field(model_name, name, temp_name), + ), + migrations.RemoveField( + model_name=model_name, + name=name, + ), + migrations.RenameField( + model_name=model_name, + old_name=temp_name, + new_name=name, + ), + migrations.AlterField( + model_name=model_name, + name=name, + field=EncryptedTextField( + associated_data=name, + db_column=name, + default=None, + verbose_name=verbose_name, + ), + ), + ] + + +class Migration(migrations.Migration): + + dependencies = [ + ("user", "0002_alter_user_managers_school_dek_user_dek"), + ] + + operations = [ + *create_migrations("user", "first_name", "first name"), + *create_migrations("user", "last_name", "last name"), + *create_migrations("user", "email", "email address"), + *create_migrations("user", "username", "username"), + *create_migrations("class", "name", "name"), + *create_migrations("class", "access_code", "access code"), + *create_migrations("schoolteacherinvitation", "token", "token"), + *create_migrations( + "schoolteacherinvitation", + "invited_teacher_first_name", + "invited teacher first name", + ), + *create_migrations( + "schoolteacherinvitation", + "invited_teacher_last_name", + "invited teacher last name", + ), + *create_migrations( + "schoolteacherinvitation", + "invited_teacher_email", + "invited teacher email", + ), + *create_migrations("school", "name", "name"), + ] From 1e09dfe79f8c8a6876b7e098d36fd81e1d3a25ea Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 10 Mar 2026 11:13:56 +0000 Subject: [PATCH 27/58] fix migrations --- ...alter_user_managers_school_dek_user_dek.py | 41 ------- .../0002_client_side_encryption_part_1.py | 115 ++++++++++++++++++ .../0003_client_side_encryption_part_2.py | 70 +++++++++++ .../migrations/0003_remove_user_first_name.py | 109 ----------------- 4 files changed, 185 insertions(+), 150 deletions(-) delete mode 100644 codeforlife/user/migrations/0002_alter_user_managers_school_dek_user_dek.py create mode 100644 codeforlife/user/migrations/0002_client_side_encryption_part_1.py create mode 100644 codeforlife/user/migrations/0003_client_side_encryption_part_2.py delete mode 100644 codeforlife/user/migrations/0003_remove_user_first_name.py diff --git a/codeforlife/user/migrations/0002_alter_user_managers_school_dek_user_dek.py b/codeforlife/user/migrations/0002_alter_user_managers_school_dek_user_dek.py deleted file mode 100644 index a14169ea..00000000 --- a/codeforlife/user/migrations/0002_alter_user_managers_school_dek_user_dek.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 5.2.11 on 2026-02-24 14:18 - -import codeforlife.models.fields.data_encryption_key -import codeforlife.user.models.user.user -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("user", "0001_initial"), - ] - - operations = [ - migrations.AlterModelManagers( - name="user", - managers=[ - ("objects", codeforlife.user.models.user.user.UserManager()), - ], - ), - migrations.AddField( - model_name="school", - name="dek", - field=codeforlife.models.fields.data_encryption_key.DataEncryptionKeyField( - editable=False, - help_text="The encrypted data encryption key (DEK) for this model.", - null=True, - verbose_name="data encryption key", - ), - ), - migrations.AddField( - model_name="user", - name="dek", - field=codeforlife.models.fields.data_encryption_key.DataEncryptionKeyField( - editable=False, - help_text="The encrypted data encryption key (DEK) for this model.", - null=True, - verbose_name="data encryption key", - ), - ), - ] diff --git a/codeforlife/user/migrations/0002_client_side_encryption_part_1.py b/codeforlife/user/migrations/0002_client_side_encryption_part_1.py new file mode 100644 index 00000000..de913921 --- /dev/null +++ b/codeforlife/user/migrations/0002_client_side_encryption_part_1.py @@ -0,0 +1,115 @@ +import typing as t + +from codeforlife.models.fields.data_encryption_key import DataEncryptionKeyField +from codeforlife.models.fields.encrypted_text import EncryptedTextField +from codeforlife.user.models.user.user import UserManager +from django.db import migrations + + +def rename_plain_text_fields_and_create_encrypted_text_fields( + model_name: str, fields: t.Dict[str, str] +): + """ + Renames all plaintext fields with the naming convention {field_name}_plain + and creates new encrypted text fields with the naming convention + {field_name}_enc. + + Args: + model_name: The name of the model to modify. + fields: A dictionary mapping field names to their verbose names. + + Returns: + A list of migration operations. + """ + + migrations_list = [] + for name, verbose_name in fields.items(): + plain_name = f"{name}_plain" + enc_name = f"{name}_enc" + + migrations_list += [ + # Rename the original field. + migrations.RenameField( + model_name=model_name, + old_name=name, + new_name=plain_name, + ), + # Add a new encrypted field. + migrations.AddField( + model_name=model_name, + name=enc_name, + field=EncryptedTextField( + associated_data=name, + db_column=enc_name, + default=None, + verbose_name=verbose_name, + ), + ), + ] + + return migrations_list + + +class Migration(migrations.Migration): + + dependencies = [ + ("user", "0001_initial"), + ] + + operations = [ + migrations.AlterModelManagers( + name="user", + managers=[("objects", UserManager())], + ), + migrations.AddField( + model_name="school", + name="dek", + field=DataEncryptionKeyField( + editable=False, + help_text="The encrypted data encryption key (DEK) for this model.", + null=True, + verbose_name="data encryption key", + ), + ), + migrations.AddField( + model_name="user", + name="dek", + field=DataEncryptionKeyField( + editable=False, + help_text="The encrypted data encryption key (DEK) for this model.", + null=True, + verbose_name="data encryption key", + ), + ), + *rename_plain_text_fields_and_create_encrypted_text_fields( + "user", + { + "first_name": "first name", + "last_name": "last name", + "email": "email address", + "username": "username", + }, + ), + # *rename_plain_text_fields_and_create_encrypted_text_fields( + # "class", + # { + # "name": "name", + # "access_code": "access code", + # }, + # ), + # *rename_plain_text_fields_and_create_encrypted_text_fields( + # "schoolteacherinvitation", + # { + # "token": "token", + # "invited_teacher_first_name": "invited teacher first name", + # "invited_teacher_last_name": "invited teacher last name", + # "invited_teacher_email": "invited teacher email", + # }, + # ), + # *rename_plain_text_fields_and_create_encrypted_text_fields( + # "school", + # { + # "name": "name", + # }, + # ), + ] diff --git a/codeforlife/user/migrations/0003_client_side_encryption_part_2.py b/codeforlife/user/migrations/0003_client_side_encryption_part_2.py new file mode 100644 index 00000000..525eacb9 --- /dev/null +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_2.py @@ -0,0 +1,70 @@ +import typing as t + +from django.db import migrations + + +def remove_plain_text_fields_and_rename_encrypted_text_fields( + model_name: str, fields: t.List[str] +): + """ + Removes all plaintext fields with the naming convention {field_name}_plain + and renames the encrypted text fields with the naming convention + {field_name}_enc to {field_name}. + + Args: + model_name: The name of the model to modify. + fields: A list of field names to process. + + Returns: + A list of migration operations. + """ + + migrations_list = [] + for name in fields: + plain_name = f"{name}_plain" + enc_name = f"{name}_enc" + + migrations_list += [ + migrations.RemoveField( + model_name=model_name, + name=plain_name, + ), + migrations.RenameField( + model_name=model_name, + old_name=enc_name, + new_name=name, + ), + ] + + return migrations_list + + +class Migration(migrations.Migration): + + dependencies = [ + ("user", "0002_client_side_encryption_part_1"), + ] + + operations = [ + # *remove_plain_text_fields_and_rename_encrypted_text_fields( + # "user", + # ["first_name", "last_name", "email", "username"], + # ), + # *remove_plain_text_fields_and_rename_encrypted_text_fields( + # "class", + # ["name", "access_code"], + # ), + # *remove_plain_text_fields_and_rename_encrypted_text_fields( + # "schoolteacherinvitation", + # [ + # "token", + # "invited_teacher_first_name", + # "invited_teacher_last_name", + # "invited_teacher_email", + # ], + # ), + # *remove_plain_text_fields_and_rename_encrypted_text_fields( + # "school", + # ["name"], + # ), + ] diff --git a/codeforlife/user/migrations/0003_remove_user_first_name.py b/codeforlife/user/migrations/0003_remove_user_first_name.py deleted file mode 100644 index c3fd0e3b..00000000 --- a/codeforlife/user/migrations/0003_remove_user_first_name.py +++ /dev/null @@ -1,109 +0,0 @@ -import typing as t - -from codeforlife.models.fields import EncryptedTextField -from django.apps.registry import Apps -from django.db import migrations - -if t.TYPE_CHECKING: - from django.db.models import Model - - -def encrypt_field(model_name: str, name: str, temp_name: str): - def code(apps: Apps, schema_editor): - model_class: Model = apps.get_model("codeforlife.user", model_name) - - models = model_class.objects.filter(**{f"{name}__isnull": False}) - for model in models: - value = getattr(model, name) - setattr(model, temp_name, value) - model.save(update_fields=[temp_name]) - - return code - - -def decrypt_field(model_name: str, name: str, temp_name: str): - def reverse_code(apps: Apps, schema_editor): - model_class: Model = apps.get_model("codeforlife.user", model_name) - - models = model_class.objects.filter(**{f"{temp_name}__isnull": False}) - for model in models: - value = getattr(model, temp_name) - setattr(model, name, value) - model.save(update_fields=[name]) - - return reverse_code - - -def create_migrations(model_name: str, name: str, verbose_name: str): - """Creates a list of migrations to encrypt a field in the database.""" - - temp_name = f"_{name}" - - return [ - migrations.AddField( - model_name=model_name, - name=temp_name, - field=EncryptedTextField( - associated_data=name, - db_column=temp_name, - default=None, - verbose_name=verbose_name, - ), - ), - migrations.RunPython( - code=encrypt_field(model_name, name, temp_name), - reverse_code=decrypt_field(model_name, name, temp_name), - ), - migrations.RemoveField( - model_name=model_name, - name=name, - ), - migrations.RenameField( - model_name=model_name, - old_name=temp_name, - new_name=name, - ), - migrations.AlterField( - model_name=model_name, - name=name, - field=EncryptedTextField( - associated_data=name, - db_column=name, - default=None, - verbose_name=verbose_name, - ), - ), - ] - - -class Migration(migrations.Migration): - - dependencies = [ - ("user", "0002_alter_user_managers_school_dek_user_dek"), - ] - - operations = [ - *create_migrations("user", "first_name", "first name"), - *create_migrations("user", "last_name", "last name"), - *create_migrations("user", "email", "email address"), - *create_migrations("user", "username", "username"), - *create_migrations("class", "name", "name"), - *create_migrations("class", "access_code", "access code"), - *create_migrations("schoolteacherinvitation", "token", "token"), - *create_migrations( - "schoolteacherinvitation", - "invited_teacher_first_name", - "invited teacher first name", - ), - *create_migrations( - "schoolteacherinvitation", - "invited_teacher_last_name", - "invited teacher last name", - ), - *create_migrations( - "schoolteacherinvitation", - "invited_teacher_email", - "invited teacher email", - ), - *create_migrations("school", "name", "name"), - ] From 498d73780a03dd1d3ad57dd90fa559285896b310 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 10 Mar 2026 11:59:08 +0000 Subject: [PATCH 28/58] quick save --- codeforlife/models/fields/base_encrypted.py | 16 ++-------------- codeforlife/user/migrations/0001_initial.py | 2 ++ .../0002_client_side_encryption_part_1.py | 14 +++++--------- .../0003_client_side_encryption_part_2.py | 9 ++++----- 4 files changed, 13 insertions(+), 28 deletions(-) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index e11597e5..9a7e7413 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -173,17 +173,7 @@ class BaseEncryptedField(BinaryField, t.Generic[T]): # Construction & Deconstruction # -------------------------------------------------------------------------- - def set_init_kwargs(self, kwargs: KwArgs): - """Sets common init kwargs.""" - kwargs.setdefault("db_column", self.associated_data) - - def __init__( - self, - associated_data: str, - # Set type for default to match T. - default: t.Optional[t.Union[T, t.Callable[[], T]]] = None, - **kwargs, - ): + def __init__(self, associated_data: str, **kwargs): if not associated_data: raise ValidationError( "Associated data cannot be empty.", @@ -191,15 +181,13 @@ def __init__( ) self.associated_data = associated_data - self.set_init_kwargs(kwargs) - super().__init__(**kwargs, default=default) + super().__init__(**kwargs) def deconstruct(self): name, path, args, kwargs = t.cast( t.Tuple[str, str, Args, KwArgs], super().deconstruct() ) - self.set_init_kwargs(kwargs) kwargs["associated_data"] = self.associated_data return name, path, args, kwargs diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index e928c13c..98f4e1c7 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -260,6 +260,8 @@ class Migration(migrations.Migration): ], options={ "abstract": False, + "verbose_name": "user", + "verbose_name_plural": "users", }, managers=[ ("objects", django.contrib.auth.models.UserManager()), diff --git a/codeforlife/user/migrations/0002_client_side_encryption_part_1.py b/codeforlife/user/migrations/0002_client_side_encryption_part_1.py index de913921..5ced4084 100644 --- a/codeforlife/user/migrations/0002_client_side_encryption_part_1.py +++ b/codeforlife/user/migrations/0002_client_side_encryption_part_1.py @@ -24,24 +24,20 @@ def rename_plain_text_fields_and_create_encrypted_text_fields( migrations_list = [] for name, verbose_name in fields.items(): - plain_name = f"{name}_plain" - enc_name = f"{name}_enc" - migrations_list += [ - # Rename the original field. + # Rename the plain text field. migrations.RenameField( model_name=model_name, old_name=name, - new_name=plain_name, + new_name=f"{name}_plain", ), - # Add a new encrypted field. + # Add an encrypted text field. migrations.AddField( model_name=model_name, - name=enc_name, + name=f"{name}_enc", field=EncryptedTextField( associated_data=name, - db_column=enc_name, - default=None, + null=True, verbose_name=verbose_name, ), ), diff --git a/codeforlife/user/migrations/0003_client_side_encryption_part_2.py b/codeforlife/user/migrations/0003_client_side_encryption_part_2.py index 525eacb9..722f4169 100644 --- a/codeforlife/user/migrations/0003_client_side_encryption_part_2.py +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_2.py @@ -21,17 +21,16 @@ def remove_plain_text_fields_and_rename_encrypted_text_fields( migrations_list = [] for name in fields: - plain_name = f"{name}_plain" - enc_name = f"{name}_enc" - migrations_list += [ + # Remove the plain text field. migrations.RemoveField( model_name=model_name, - name=plain_name, + name=f"{name}_plain", ), + # Rename the encrypted text field. migrations.RenameField( model_name=model_name, - old_name=enc_name, + old_name=f"{name}_enc", new_name=name, ), ] From 97f8ea2130966912dea90c06763bfa89f4ca4846 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 10 Mar 2026 13:18:14 +0000 Subject: [PATCH 29/58] user fields --- codeforlife/user/migrations/0001_initial.py | 414 +++--------------- .../0002_user_proxies_and_new_models.py | 348 +++++++++++++++ ... => 0003_client_side_encryption_part_1.py} | 2 +- ... => 0004_client_side_encryption_part_2.py} | 2 +- codeforlife/user/models/user/user.py | 157 ++++++- 5 files changed, 558 insertions(+), 365 deletions(-) create mode 100644 codeforlife/user/migrations/0002_user_proxies_and_new_models.py rename codeforlife/user/migrations/{0002_client_side_encryption_part_1.py => 0003_client_side_encryption_part_1.py} (98%) rename codeforlife/user/migrations/{0003_client_side_encryption_part_2.py => 0004_client_side_encryption_part_2.py} (97%) diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index 98f4e1c7..09ac62e9 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,15 +1,5 @@ # Generated by Django 5.2.11 on 2026-02-24 16:38 -import codeforlife.models.fields.encrypted_text -import codeforlife.user.models.user.admin_school_teacher -import codeforlife.user.models.user.contactable -import codeforlife.user.models.user.google -import codeforlife.user.models.user.independent -import codeforlife.user.models.user.non_admin_school_teacher -import codeforlife.user.models.user.non_school_teacher -import codeforlife.user.models.user.school_teacher -import codeforlife.user.models.user.student -import codeforlife.user.models.user.teacher import django.contrib.auth.models import django.contrib.auth.validators import django.db.models.deletion @@ -42,12 +32,17 @@ class Migration(migrations.Migration): ), ("name", models.CharField(max_length=200)), ("access_code", models.CharField(max_length=5, null=True)), - ("classmates_data_viewable", models.BooleanField(default=False)), + ( + "classmates_data_viewable", + models.BooleanField(default=False), + ), ("always_accept_requests", models.BooleanField(default=False)), ("accept_requests_until", models.DateTimeField(null=True)), ( "creation_time", - models.DateTimeField(default=django.utils.timezone.now, null=True), + models.DateTimeField( + default=django.utils.timezone.now, null=True + ), ), ("is_active", models.BooleanField(default=True)), ], @@ -69,7 +64,10 @@ class Migration(migrations.Migration): ), ("date", models.DateField(default=django.utils.timezone.now)), ("csv_click_count", models.PositiveIntegerField(default=0)), - ("login_cards_click_count", models.PositiveIntegerField(default=0)), + ( + "login_cards_click_count", + models.PositiveIntegerField(default=0), + ), ( "primary_coding_club_downloads", models.PositiveIntegerField(default=0), @@ -78,8 +76,14 @@ class Migration(migrations.Migration): "python_coding_club_downloads", models.PositiveIntegerField(default=0), ), - ("level_control_submits", models.PositiveBigIntegerField(default=0)), - ("teacher_lockout_resets", models.PositiveIntegerField(default=0)), + ( + "level_control_submits", + models.PositiveBigIntegerField(default=0), + ), + ( + "teacher_lockout_resets", + models.PositiveIntegerField(default=0), + ), ("indy_lockout_resets", models.PositiveIntegerField(default=0)), ( "school_student_lockout_resets", @@ -117,10 +121,15 @@ class Migration(migrations.Migration): blank=True, max_length=2, null=True ), ), - ("county", models.CharField(blank=True, max_length=50, null=True)), + ( + "county", + models.CharField(blank=True, max_length=50, null=True), + ), ( "creation_time", - models.DateTimeField(default=django.utils.timezone.now, null=True), + models.DateTimeField( + default=django.utils.timezone.now, null=True + ), ), ("is_active", models.BooleanField(default=True)), ], @@ -137,9 +146,18 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("teacher_registrations", models.PositiveIntegerField(default=0)), - ("student_registrations", models.PositiveIntegerField(default=0)), - ("independent_registrations", models.PositiveIntegerField(default=0)), + ( + "teacher_registrations", + models.PositiveIntegerField(default=0), + ), + ( + "student_registrations", + models.PositiveIntegerField(default=0), + ), + ( + "independent_registrations", + models.PositiveIntegerField(default=0), + ), ( "anonymised_unverified_teachers", models.PositiveIntegerField(default=0), @@ -225,10 +243,14 @@ class Migration(migrations.Migration): ( "date_joined", models.DateTimeField( - default=django.utils.timezone.now, verbose_name="date joined" + default=django.utils.timezone.now, + verbose_name="date joined", ), ), - ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "password", + models.CharField(max_length=128, verbose_name="password"), + ), ( "last_login", models.DateTimeField( @@ -267,130 +289,6 @@ class Migration(migrations.Migration): ("objects", django.contrib.auth.models.UserManager()), ], ), - migrations.CreateModel( - name="ContactableUser", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.user",), - managers=[ - ( - "objects", - codeforlife.user.models.user.contactable.ContactableUserManager(), - ), - ], - ), - migrations.CreateModel( - name="StudentUser", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.user",), - managers=[ - ("objects", codeforlife.user.models.user.student.StudentUserManager()), - ], - ), - migrations.CreateModel( - name="AuthFactor", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("type", models.TextField(choices=[("otp", "one-time password")])), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="auth_factors", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "unique_together": {("user", "type")}, - }, - ), - migrations.CreateModel( - name="OtpBypassToken", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "token", - codeforlife.models.fields.encrypted_text.EncryptedTextField( - associated_data="token", - db_column="token", - default=None, - help_text="The encrypted equivalent of the token.", - verbose_name="token", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="otp_bypass_tokens", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "OTP bypass token", - "verbose_name_plural": "OTP bypass tokens", - }, - ), - migrations.CreateModel( - name="Session", - fields=[ - ( - "session_key", - models.CharField( - max_length=40, - primary_key=True, - serialize=False, - verbose_name="session key", - ), - ), - ("session_data", models.TextField(verbose_name="session data")), - ( - "expire_date", - models.DateTimeField(db_index=True, verbose_name="expire date"), - ), - ( - "user", - models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "session", - "verbose_name_plural": "sessions", - "abstract": False, - }, - ), migrations.CreateModel( name="Student", fields=[ @@ -464,16 +362,6 @@ class Migration(migrations.Migration): ), ], ), - migrations.CreateModel( - name="Independent", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.student",), - ), migrations.CreateModel( name="Teacher", fields=[ @@ -533,14 +421,22 @@ class Migration(migrations.Migration): ), ), ("token", models.CharField(max_length=88)), - ("invited_teacher_first_name", models.CharField(max_length=150)), + ( + "invited_teacher_first_name", + models.CharField(max_length=150), + ), ("invited_teacher_last_name", models.CharField(max_length=150)), ("invited_teacher_email", models.EmailField(max_length=254)), - ("invited_teacher_is_admin", models.BooleanField(default=False)), + ( + "invited_teacher_is_admin", + models.BooleanField(default=False), + ), ("expiry", models.DateTimeField()), ( "creation_time", - models.DateTimeField(default=django.utils.timezone.now, null=True), + models.DateTimeField( + default=django.utils.timezone.now, null=True + ), ), ("is_active", models.BooleanField(default=True)), ( @@ -583,26 +479,6 @@ class Migration(migrations.Migration): to="user.teacher", ), ), - migrations.CreateModel( - name="NonSchoolTeacher", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.teacher",), - ), - migrations.CreateModel( - name="SchoolTeacher", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.teacher",), - ), migrations.CreateModel( name="UserProfile", fields=[ @@ -615,8 +491,14 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("otp_secret", models.CharField(blank=True, max_length=40, null=True)), - ("last_otp_for_time", models.DateTimeField(blank=True, null=True)), + ( + "otp_secret", + models.CharField(blank=True, max_length=40, null=True), + ), + ( + "last_otp_for_time", + models.DateTimeField(blank=True, null=True), + ), ("developer", models.BooleanField(default=False)), ("is_verified", models.BooleanField(default=False)), ( @@ -632,14 +514,16 @@ class Migration(migrations.Migration): model_name="teacher", name="user", field=models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, to="user.userprofile" + on_delete=django.db.models.deletion.CASCADE, + to="user.userprofile", ), ), migrations.AddField( model_name="student", name="user", field=models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, to="user.userprofile" + on_delete=django.db.models.deletion.CASCADE, + to="user.userprofile", ), ), migrations.CreateModel( @@ -654,7 +538,10 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("login_time", models.DateTimeField(default=django.utils.timezone.now)), + ( + "login_time", + models.DateTimeField(default=django.utils.timezone.now), + ), ("login_type", models.CharField(max_length=100, null=True)), ( "class_field", @@ -681,101 +568,6 @@ class Migration(migrations.Migration): ), ], ), - migrations.CreateModel( - name="GoogleUser", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.contactableuser",), - managers=[ - ("objects", codeforlife.user.models.user.google.GoogleUserManager()), - ], - ), - migrations.CreateModel( - name="IndependentUser", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.contactableuser",), - managers=[ - ( - "objects", - codeforlife.user.models.user.independent.IndependentUserManager(), - ), - ], - ), - migrations.CreateModel( - name="TeacherUser", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.contactableuser",), - managers=[ - ("objects", codeforlife.user.models.user.teacher.TeacherUserManager()), - ], - ), - migrations.CreateModel( - name="SessionAuthFactor", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "auth_factor", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="sessions", - to="user.authfactor", - ), - ), - ( - "session", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="auth_factors", - to="user.session", - ), - ), - ], - options={ - "unique_together": {("session", "auth_factor")}, - }, - ), - migrations.CreateModel( - name="AdminSchoolTeacher", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.schoolteacher",), - ), - migrations.CreateModel( - name="NonAdminSchoolTeacher", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.schoolteacher",), - ), migrations.AddConstraint( model_name="teacher", constraint=models.CheckConstraint( @@ -785,68 +577,4 @@ class Migration(migrations.Migration): name="teacher__is_admin", ), ), - migrations.CreateModel( - name="NonSchoolTeacherUser", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.teacheruser",), - managers=[ - ( - "objects", - codeforlife.user.models.user.non_school_teacher.NonSchoolTeacherUserManager(), - ), - ], - ), - migrations.CreateModel( - name="SchoolTeacherUser", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.teacheruser",), - managers=[ - ( - "objects", - codeforlife.user.models.user.school_teacher.SchoolTeacherUserManager(), - ), - ], - ), - migrations.CreateModel( - name="AdminSchoolTeacherUser", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.schoolteacheruser",), - managers=[ - ( - "objects", - codeforlife.user.models.user.admin_school_teacher.AdminSchoolTeacherUserManager(), - ), - ], - ), - migrations.CreateModel( - name="NonAdminSchoolTeacherUser", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("user.schoolteacheruser",), - managers=[ - ( - "objects", - codeforlife.user.models.user.non_admin_school_teacher.NonAdminSchoolTeacherUserManager(), - ), - ], - ), ] diff --git a/codeforlife/user/migrations/0002_user_proxies_and_new_models.py b/codeforlife/user/migrations/0002_user_proxies_and_new_models.py new file mode 100644 index 00000000..cd3748e2 --- /dev/null +++ b/codeforlife/user/migrations/0002_user_proxies_and_new_models.py @@ -0,0 +1,348 @@ +import codeforlife.models.fields.encrypted_text +import codeforlife.user.models.user.admin_school_teacher +import codeforlife.user.models.user.contactable +import codeforlife.user.models.user.google +import codeforlife.user.models.user.independent +import codeforlife.user.models.user.non_admin_school_teacher +import codeforlife.user.models.user.non_school_teacher +import codeforlife.user.models.user.school_teacher +import codeforlife.user.models.user.student +import codeforlife.user.models.user.teacher +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("user", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="ContactableUser", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.user",), + managers=[ + ( + "objects", + codeforlife.user.models.user.contactable.ContactableUserManager(), + ), + ], + ), + migrations.CreateModel( + name="StudentUser", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.user",), + managers=[ + ( + "objects", + codeforlife.user.models.user.student.StudentUserManager(), + ), + ], + ), + migrations.CreateModel( + name="AuthFactor", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.TextField(choices=[("otp", "one-time password")]), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="auth_factors", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "type")}, + }, + ), + migrations.CreateModel( + name="OtpBypassToken", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "token", + codeforlife.models.fields.encrypted_text.EncryptedTextField( + associated_data="token", + help_text="The encrypted equivalent of the token.", + verbose_name="token", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="otp_bypass_tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "OTP bypass token", + "verbose_name_plural": "OTP bypass tokens", + }, + ), + migrations.CreateModel( + name="Session", + fields=[ + ( + "session_key", + models.CharField( + max_length=40, + primary_key=True, + serialize=False, + verbose_name="session key", + ), + ), + ("session_data", models.TextField(verbose_name="session data")), + ( + "expire_date", + models.DateTimeField( + db_index=True, verbose_name="expire date" + ), + ), + ( + "user", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "session", + "verbose_name_plural": "sessions", + "abstract": False, + }, + ), + migrations.CreateModel( + name="Independent", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.student",), + ), + migrations.CreateModel( + name="NonSchoolTeacher", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.teacher",), + ), + migrations.CreateModel( + name="SchoolTeacher", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.teacher",), + ), + migrations.CreateModel( + name="GoogleUser", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.contactableuser",), + managers=[ + ( + "objects", + codeforlife.user.models.user.google.GoogleUserManager(), + ), + ], + ), + migrations.CreateModel( + name="IndependentUser", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.contactableuser",), + managers=[ + ( + "objects", + codeforlife.user.models.user.independent.IndependentUserManager(), + ), + ], + ), + migrations.CreateModel( + name="TeacherUser", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.contactableuser",), + managers=[ + ( + "objects", + codeforlife.user.models.user.teacher.TeacherUserManager(), + ), + ], + ), + migrations.CreateModel( + name="SessionAuthFactor", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "auth_factor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sessions", + to="user.authfactor", + ), + ), + ( + "session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="auth_factors", + to="user.session", + ), + ), + ], + options={ + "unique_together": {("session", "auth_factor")}, + }, + ), + migrations.CreateModel( + name="AdminSchoolTeacher", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.schoolteacher",), + ), + migrations.CreateModel( + name="NonAdminSchoolTeacher", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.schoolteacher",), + ), + migrations.CreateModel( + name="NonSchoolTeacherUser", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.teacheruser",), + managers=[ + ( + "objects", + codeforlife.user.models.user.non_school_teacher.NonSchoolTeacherUserManager(), + ), + ], + ), + migrations.CreateModel( + name="SchoolTeacherUser", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.teacheruser",), + managers=[ + ( + "objects", + codeforlife.user.models.user.school_teacher.SchoolTeacherUserManager(), + ), + ], + ), + migrations.CreateModel( + name="AdminSchoolTeacherUser", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.schoolteacheruser",), + managers=[ + ( + "objects", + codeforlife.user.models.user.admin_school_teacher.AdminSchoolTeacherUserManager(), + ), + ], + ), + migrations.CreateModel( + name="NonAdminSchoolTeacherUser", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("user.schoolteacheruser",), + managers=[ + ( + "objects", + codeforlife.user.models.user.non_admin_school_teacher.NonAdminSchoolTeacherUserManager(), + ), + ], + ), + ] diff --git a/codeforlife/user/migrations/0002_client_side_encryption_part_1.py b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py similarity index 98% rename from codeforlife/user/migrations/0002_client_side_encryption_part_1.py rename to codeforlife/user/migrations/0003_client_side_encryption_part_1.py index 5ced4084..73009f61 100644 --- a/codeforlife/user/migrations/0002_client_side_encryption_part_1.py +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py @@ -49,7 +49,7 @@ def rename_plain_text_fields_and_create_encrypted_text_fields( class Migration(migrations.Migration): dependencies = [ - ("user", "0001_initial"), + ("user", "0002_user_proxies_and_new_models"), ] operations = [ diff --git a/codeforlife/user/migrations/0003_client_side_encryption_part_2.py b/codeforlife/user/migrations/0004_client_side_encryption_part_2.py similarity index 97% rename from codeforlife/user/migrations/0003_client_side_encryption_part_2.py rename to codeforlife/user/migrations/0004_client_side_encryption_part_2.py index 722f4169..25461564 100644 --- a/codeforlife/user/migrations/0003_client_side_encryption_part_2.py +++ b/codeforlife/user/migrations/0004_client_side_encryption_part_2.py @@ -41,7 +41,7 @@ def remove_plain_text_fields_and_rename_encrypted_text_fields( class Migration(migrations.Migration): dependencies = [ - ("user", "0002_client_side_encryption_part_1"), + ("user", "0003_client_side_encryption_part_1"), ] operations = [ diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 5c96c0dc..5dd4fb3f 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -11,8 +11,9 @@ from django.conf import settings # pylint: disable-next=imported-auth-user -from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import PermissionsMixin from django.contrib.auth.models import UserManager as _UserManager +from django.contrib.auth.validators import UnicodeUsernameValidator from django.db import models from django.db.models.query import QuerySet from django.utils import timezone @@ -20,19 +21,16 @@ from pyotp import TOTP from ....models import AbstractBaseUser, DataEncryptionKeyModel +from ....models.fields import EncryptedTextField from ....types import Validators from ....validators import UnicodeAlphanumericCharSetValidator if t.TYPE_CHECKING: # pragma: no cover - from django_stubs_ext.db.models import TypedModelMeta - from ..auth_factor import AuthFactor from ..otp_bypass_token import OtpBypassToken from ..session import Session from ..student import Student from ..teacher import Teacher -else: - TypedModelMeta = object # TODO: add to model validators in new schema. @@ -50,15 +48,6 @@ ] -# TODO: remove in new schema -class _AbstractBaseUser(AbstractBaseUser): - password: str = None # type: ignore[assignment] - last_login: datetime = None # type: ignore[assignment] - - class Meta(TypedModelMeta): - abstract = True - - AnyUser = t.TypeVar("AnyUser", bound="User") @@ -93,16 +82,16 @@ def get_queryset(self): ) -# pylint: disable-next=too-many-ancestors -class User( - _AbstractBaseUser, - AbstractUser, # TODO: remove this inheritance in new schema - DataEncryptionKeyModel, -): +# pylint: disable-next=too-many-ancestors,too-many-instance-attributes +class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): """A proxy to Django's user class.""" associated_data = "user" + EMAIL_FIELD = "email_plain" + USERNAME_FIELD = "username_plain" + REQUIRED_FIELDS = ["email_plain"] + _password: t.Optional[str] id: int # type: ignore[assignment] @@ -114,6 +103,134 @@ class User( credential_fields = frozenset(["email", "password"]) + # -------------------------------------------------------------------------- + # Username + # -------------------------------------------------------------------------- + + username_validator = UnicodeUsernameValidator() + username_plain = models.CharField( + _("username"), + max_length=150, + unique=True, + help_text=_( + "Required. 150 characters or fewer." + " Letters, digits and @/./+/-/_ only." + ), + validators=[username_validator], + error_messages={ + "unique": _("A user with that username already exists."), + }, + ) + username_enc = EncryptedTextField( + associated_data="username", null=True, verbose_name=_("username") + ) + + @property + def username(self): + """The user's username.""" + if self.username_enc is not None: + return self.username_enc + return self.username_plain + + @username.setter + def username(self, value: str): + """Set the user's username.""" + self.username_enc = value + self.username_plain = value + + # -------------------------------------------------------------------------- + # First name + # -------------------------------------------------------------------------- + + first_name_plain = models.CharField( + _("first name"), max_length=150, blank=True + ) + first_name_enc = EncryptedTextField( + associated_data="first_name", null=True, verbose_name=_("first name") + ) + + @property + def first_name(self): + """The user's first name.""" + if self.first_name_enc is not None: + return self.first_name_enc + return self.first_name_plain + + @first_name.setter + def first_name(self, value: str): + """Set the user's first name.""" + self.first_name_enc = value + self.first_name_plain = value + + # -------------------------------------------------------------------------- + # Last name + # -------------------------------------------------------------------------- + + last_name_plain = models.CharField( + _("last name"), max_length=150, blank=True + ) + last_name_enc = EncryptedTextField( + associated_data="last_name", null=True, verbose_name=_("last name") + ) + + @property + def last_name(self): + """The user's last name.""" + if self.last_name_enc is not None: + return self.last_name_enc + return self.last_name_plain + + @last_name.setter + def last_name(self, value: str): + """Set the user's last name.""" + self.last_name_enc = value + self.last_name_plain = value + + # -------------------------------------------------------------------------- + # Email + # -------------------------------------------------------------------------- + + email_plain = models.EmailField(_("email address"), blank=True) + email_enc = EncryptedTextField( + associated_data="email", null=True, verbose_name=_("email address") + ) + + @property + def email(self): + """The user's email address.""" + if self.email_enc is not None: + return self.email_enc + return self.email_plain + + @email.setter + def email(self, value: str): + """Set the user's email address.""" + self.email_enc = value + self.email_plain = value + + # -------------------------------------------------------------------------- + # Other + # -------------------------------------------------------------------------- + + is_staff = models.BooleanField( + _("staff status"), + default=False, + help_text=_( + "Designates whether the user can log into this admin site." + ), + ) + + is_active = models.BooleanField( + _("active"), + default=True, + help_text=_( + "Designates whether this user should be treated as active. " + "Unselect this instead of deleting accounts." + ), + ) + + date_joined = models.DateTimeField(_("date joined"), default=timezone.now) + # TODO: remove in new schema password: str # type: ignore[assignment] password = models.CharField( # type: ignore[assignment] From 730f9fe370be3e780a0949638f0889dd478114b7 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 10 Mar 2026 13:22:35 +0000 Subject: [PATCH 30/58] house keeping --- .../user/migrations/0002_user_proxies_and_new_models.py | 7 +++++++ .../user/migrations/0003_client_side_encryption_part_1.py | 5 ----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/codeforlife/user/migrations/0002_user_proxies_and_new_models.py b/codeforlife/user/migrations/0002_user_proxies_and_new_models.py index cd3748e2..e5fb5a4d 100644 --- a/codeforlife/user/migrations/0002_user_proxies_and_new_models.py +++ b/codeforlife/user/migrations/0002_user_proxies_and_new_models.py @@ -8,6 +8,7 @@ import codeforlife.user.models.user.school_teacher import codeforlife.user.models.user.student import codeforlife.user.models.user.teacher +import codeforlife.user.models.user.user import django.db.models.deletion from django.conf import settings from django.db import migrations, models @@ -20,6 +21,12 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterModelManagers( + name="user", + managers=[ + ("objects", codeforlife.user.models.user.user.UserManager()) + ], + ), migrations.CreateModel( name="ContactableUser", fields=[], diff --git a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py index 73009f61..fed1ce58 100644 --- a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py @@ -2,7 +2,6 @@ from codeforlife.models.fields.data_encryption_key import DataEncryptionKeyField from codeforlife.models.fields.encrypted_text import EncryptedTextField -from codeforlife.user.models.user.user import UserManager from django.db import migrations @@ -53,10 +52,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AlterModelManagers( - name="user", - managers=[("objects", UserManager())], - ), migrations.AddField( model_name="school", name="dek", From 0d778545a7aa6c6c3ebb9d4ff94d35bd663f6298 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 10 Mar 2026 13:38:41 +0000 Subject: [PATCH 31/58] house keeping --- .../models/fields/data_encryption_key.py | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index cd302b75..865ed797 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -25,10 +25,13 @@ from django.db.models import BinaryField from django.utils.translation import gettext_lazy as _ -from ...types import KwArgs from ..base_data_encryption_key import BaseDataEncryptionKeyModel from .deferred_attribute import DeferredAttribute +if t.TYPE_CHECKING: # pragma: no cover + from django_stubs_ext import StrOrPromise + + AnyDataEncryptionKeyField = t.TypeVar( "AnyDataEncryptionKeyField", bound="DataEncryptionKeyField" ) @@ -86,19 +89,18 @@ class DataEncryptionKeyField(BinaryField): # Construction & Deconstruction # -------------------------------------------------------------------------- - default_verbose_name = "data encryption key" - default_help_text = ( - "The encrypted data encryption key (DEK) for this model." - ) - - def set_init_kwargs(self, kwargs: KwArgs): - """Sets common init kwargs.""" - kwargs["editable"] = False # DEK should not be editable in admin forms - kwargs["null"] = True # Allow null for data shredding - kwargs.setdefault("verbose_name", _(self.default_verbose_name)) - kwargs.setdefault("help_text", _(self.default_help_text)) - - def __init__(self, **kwargs): + def __init__( + self, + # DEK should not be editable in admin forms. + editable: t.Literal[False] = False, + # Allow null for data shredding. + null: t.Literal[True] = True, + verbose_name: t.Optional["StrOrPromise"] = _("data encryption key"), + help_text: "StrOrPromise" = _( + "The encrypted data encryption key (DEK) for this model." + ), + **kwargs, + ): if kwargs.get("editable", False): raise ValidationError( "DataEncryptionKeyField cannot be editable.", @@ -116,13 +118,13 @@ def __init__(self, **kwargs): code="null_not_allowed", ) - self.set_init_kwargs(kwargs) - super().__init__(**kwargs) - - def deconstruct(self): - name, path, args, kwargs = super().deconstruct() - self.set_init_kwargs(kwargs) - return name, path, args, kwargs + super().__init__( + **kwargs, + editable=editable, + null=null, + verbose_name=verbose_name, + help_text=help_text, + ) # -------------------------------------------------------------------------- # Django Model Field Integration From 5fd5d06e7afe7558d8d30a540259f8dc5ad52508 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 10 Mar 2026 13:59:21 +0000 Subject: [PATCH 32/58] class --- .../0003_client_side_encryption_part_1.py | 14 ++-- codeforlife/user/models/klass.py | 76 ++++++++++++++++--- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py index fed1ce58..2786294f 100644 --- a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py @@ -81,13 +81,13 @@ class Migration(migrations.Migration): "username": "username", }, ), - # *rename_plain_text_fields_and_create_encrypted_text_fields( - # "class", - # { - # "name": "name", - # "access_code": "access code", - # }, - # ), + *rename_plain_text_fields_and_create_encrypted_text_fields( + "class", + { + "name": "name", + "access_code": "access code", + }, + ), # *rename_plain_text_fields_and_create_encrypted_text_fields( # "schoolteacherinvitation", # { diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index 8d5bcced..8d3045b6 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -10,7 +10,10 @@ from django.core.validators import MaxLengthValidator, MinLengthValidator from django.db import models from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from ...models import EncryptedModel +from ...models.fields import EncryptedTextField from ...types import Validators from ...validators import ( UnicodeAlphanumericCharSetValidator, @@ -22,7 +25,7 @@ from django_stubs_ext.db.models import TypedModelMeta - from .teacher import Teacher + from .teacher import SchoolTeacher else: TypedModelMeta = object @@ -41,7 +44,7 @@ ] -class ClassModelManager(models.Manager): +class ClassModelManager(EncryptedModel.Manager["Class"]): """Manager for Class model.""" def get_original_queryset(self): @@ -53,23 +56,74 @@ def get_queryset(self): return super().get_queryset().filter(is_active=True) -class Class(models.Model): +class Class(EncryptedModel): """A class.""" - name = models.CharField(max_length=200) + associated_data = "class" - teacher: "Teacher" + # -------------------------------------------------------------------------- + # Name + # -------------------------------------------------------------------------- + + name_plain: str + name_plain = models.CharField(max_length=200) # type: ignore[assignment] + name_enc = EncryptedTextField( + associated_data="name", + null=True, + verbose_name=_("name"), + ) + + @property + def name(self): + """Get the name of the class.""" + if self.name_enc is not None: + return self.name_enc + return self.name_plain + + @name.setter + def name(self, value: str): + """Set the name of the class.""" + self.name_plain = value + self.name_enc = value + + # -------------------------------------------------------------------------- + + teacher: "SchoolTeacher" teacher = models.ForeignKey( # type: ignore[assignment] "user.Teacher", related_name="class_teacher", on_delete=models.CASCADE, ) - access_code: t.Optional[str] - access_code = models.CharField( # type: ignore[assignment] + # -------------------------------------------------------------------------- + # Access code + # -------------------------------------------------------------------------- + + access_code_plain: t.Optional[str] + access_code_plain = models.CharField( # type: ignore[assignment] max_length=5, null=True, ) + access_code_enc = EncryptedTextField( + associated_data="access_code", + null=True, + verbose_name=_("access code"), + ) + + @property + def access_code(self): + """Get the access code for the class.""" + if self.access_code_enc is not None: + return self.access_code_enc + return self.access_code_plain + + @access_code.setter + def access_code(self, value: t.Optional[str]): + """Set the access code for the class.""" + self.access_code_plain = value + self.access_code_enc = value + + # -------------------------------------------------------------------------- classmates_data_viewable: bool classmates_data_viewable = models.BooleanField( # type: ignore[assignment] @@ -94,7 +148,7 @@ class Class(models.Model): is_active: bool is_active = models.BooleanField(default=True) # type: ignore[assignment] - created_by: t.Optional["Teacher"] + created_by: t.Optional["SchoolTeacher"] created_by = models.ForeignKey( # type: ignore[assignment] "user.Teacher", null=True, @@ -103,7 +157,7 @@ class Class(models.Model): on_delete=models.SET_NULL, ) - objects = ClassModelManager() + objects: ClassModelManager = ClassModelManager() # type: ignore[assignment] def __str__(self): return self.name @@ -150,3 +204,7 @@ def anonymise(self): class Meta(TypedModelMeta): verbose_name_plural = "classes" + + @property + def dek_aead(self): + return self.teacher.school.dek_aead From 339095ac30d7e0addb88e75f8c6f60a98d947d67 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 10 Mar 2026 14:18:47 +0000 Subject: [PATCH 33/58] schoolteacherinvitation --- .../0003_client_side_encryption_part_1.py | 18 +-- codeforlife/user/models/klass.py | 4 +- codeforlife/user/models/other.py | 131 ++++++++++++++++-- 3 files changed, 131 insertions(+), 22 deletions(-) diff --git a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py index 2786294f..2196d735 100644 --- a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py @@ -88,15 +88,15 @@ class Migration(migrations.Migration): "access_code": "access code", }, ), - # *rename_plain_text_fields_and_create_encrypted_text_fields( - # "schoolteacherinvitation", - # { - # "token": "token", - # "invited_teacher_first_name": "invited teacher first name", - # "invited_teacher_last_name": "invited teacher last name", - # "invited_teacher_email": "invited teacher email", - # }, - # ), + *rename_plain_text_fields_and_create_encrypted_text_fields( + "schoolteacherinvitation", + { + "token": "token", + "invited_teacher_first_name": "invited teacher first name", + "invited_teacher_last_name": "invited teacher last name", + "invited_teacher_email": "invited teacher email", + }, + ), # *rename_plain_text_fields_and_create_encrypted_text_fields( # "school", # { diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index 8d3045b6..ebd10ce4 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -25,7 +25,7 @@ from django_stubs_ext.db.models import TypedModelMeta - from .teacher import SchoolTeacher + from .teacher import SchoolTeacher, Teacher else: TypedModelMeta = object @@ -148,7 +148,7 @@ def access_code(self, value: t.Optional[str]): is_active: bool is_active = models.BooleanField(default=True) # type: ignore[assignment] - created_by: t.Optional["SchoolTeacher"] + created_by: t.Optional["Teacher"] created_by = models.ForeignKey( # type: ignore[assignment] "user.Teacher", null=True, diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py index ea000bac..782f1458 100644 --- a/codeforlife/user/models/other.py +++ b/codeforlife/user/models/other.py @@ -12,6 +12,10 @@ from django.db import models from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from ...models import EncryptedModel +from ...models.fields import EncryptedTextField if t.TYPE_CHECKING: # pragma: no cover from datetime import datetime @@ -198,7 +202,9 @@ def __str__(self): return f"Activity on {self.date}: CSV clicks: {self.csv_click_count}, login cards clicks: {self.login_cards_click_count}, primary pack downloads: {self.primary_coding_club_downloads}, python pack downloads: {self.python_coding_club_downloads}, level control submits: {self.level_control_submits}, teacher lockout resets: {self.teacher_lockout_resets}, indy lockout resets: {self.indy_lockout_resets}, school student lockout resets: {self.school_student_lockout_resets}, unverified teachers anonymised: {self.anonymised_unverified_teachers}, unverified independents anonymised: {self.anonymised_unverified_independents}" -class SchoolTeacherInvitationModelManager(models.Manager): +class SchoolTeacherInvitationModelManager( + EncryptedModel.Manager["SchoolTeacherInvitation"] +): """ A custom model manager for the SchoolTeacherInvitation model to filter out inactive invitations by default. @@ -218,7 +224,7 @@ def get_queryset(self): return super().get_queryset().filter(is_active=True) -class SchoolTeacherInvitation(models.Model): +class SchoolTeacherInvitation(EncryptedModel): """ A model to track invitations for teachers to join a school. This is meant to be used when a teacher invites another teacher to join their school, and the @@ -226,8 +232,34 @@ class SchoolTeacherInvitation(models.Model): the invitation, or the invitation expires. """ - token: str - token = models.CharField(max_length=88) # type: ignore[assignment] + associated_data = "school_teacher_invitation" + + # -------------------------------------------------------------------------- + # Token + # -------------------------------------------------------------------------- + + token_plain: str + token_plain = models.CharField(max_length=88) # type: ignore[assignment] + token_enc = EncryptedTextField( + associated_data="token", + null=True, + verbose_name=_("token"), + ) + + @property + def token(self): + """Get the decrypted token value.""" + if self.token_enc is not None: + return self.token_enc + return self.token_plain + + @token.setter + def token(self, value: str): + """Sets the token value.""" + self.token_plain = value + self.token_enc = value + + # -------------------------------------------------------------------------- school: t.Optional["School"] school = models.ForeignKey( # type: ignore[assignment] @@ -245,21 +277,89 @@ class SchoolTeacherInvitation(models.Model): on_delete=models.SET_NULL, ) - invited_teacher_first_name: str - invited_teacher_first_name = models.CharField( # type: ignore[assignment] + # -------------------------------------------------------------------------- + # First name + # -------------------------------------------------------------------------- + + invited_teacher_first_name_plain: str + invited_teacher_first_name_plain = models.CharField( # type: ignore[assignment] max_length=150 ) # Same as User model + invited_teacher_first_name_enc = EncryptedTextField( + associated_data="invited_teacher_first_name", + null=True, + verbose_name=_("invited teacher first name"), + ) - invited_teacher_last_name: str - invited_teacher_last_name = models.CharField( # type: ignore[assignment] + @property + def invited_teacher_first_name(self): + """Get the decrypted invited teacher first name value.""" + if self.invited_teacher_first_name_enc is not None: + return self.invited_teacher_first_name_enc + return self.invited_teacher_first_name_plain + + @invited_teacher_first_name.setter + def invited_teacher_first_name(self, value: str): + """Sets the invited teacher first name value.""" + self.invited_teacher_first_name_plain = value + self.invited_teacher_first_name_enc = value + + # -------------------------------------------------------------------------- + # Last name + # -------------------------------------------------------------------------- + + invited_teacher_last_name_plain: str + invited_teacher_last_name_plain = models.CharField( # type: ignore[assignment] max_length=150 ) # Same as User model + invited_teacher_last_name_enc = EncryptedTextField( + associated_data="invited_teacher_last_name", + null=True, + verbose_name=_("invited teacher last name"), + ) + + @property + def invited_teacher_last_name(self): + """Get the decrypted invited teacher last name value.""" + if self.invited_teacher_last_name_enc is not None: + return self.invited_teacher_last_name_enc + return self.invited_teacher_last_name_plain + + @invited_teacher_last_name.setter + def invited_teacher_last_name(self, value: str): + """Sets the invited teacher last name value.""" + self.invited_teacher_last_name_plain = value + self.invited_teacher_last_name_enc = value + + # -------------------------------------------------------------------------- + # Email + # -------------------------------------------------------------------------- # TODO: Switch to a CharField to be able to hold hashed value - invited_teacher_email: str - invited_teacher_email = ( + invited_teacher_email_plain: str + invited_teacher_email_plain = ( models.EmailField() # type: ignore[assignment] ) # Same as User model + invited_teacher_email_enc = EncryptedTextField( + associated_data="invited_teacher_email", + null=True, + verbose_name=_("invited teacher email"), + ) + + @property + def invited_teacher_email(self): + """Get the decrypted invited teacher email value.""" + if self.invited_teacher_email_enc is not None: + return self.invited_teacher_email_enc + return self.invited_teacher_email_plain + + @invited_teacher_email.setter + def invited_teacher_email(self, value: str): + """Sets the invited teacher email value.""" + self.invited_teacher_email_plain = value + self.invited_teacher_email_enc = value + + # -------------------------------------------------------------------------- invited_teacher_is_admin: bool invited_teacher_is_admin = models.BooleanField( # type: ignore[assignment] @@ -279,7 +379,9 @@ class SchoolTeacherInvitation(models.Model): is_active = models.BooleanField(default=True) # type: ignore[assignment] # pylint: enable=duplicate-code - objects = SchoolTeacherInvitationModelManager() + objects: SchoolTeacherInvitationModelManager = ( + SchoolTeacherInvitationModelManager() # type: ignore[assignment] + ) @property def is_expired(self): @@ -300,3 +402,10 @@ def anonymise(self): self.invited_teacher_email = uuid4().hex self.is_active = False self.save() + + @property + def dek_aead(self): + if self.school: + return self.school.dek_aead + + raise KeyError("Data Encryption Key (DEK) not found.") From b196aafd75872eabc071bf93090a71484af08724 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 10 Mar 2026 14:49:55 +0000 Subject: [PATCH 34/58] school --- .../0003_client_side_encryption_part_1.py | 15 ++++------ codeforlife/user/models/school.py | 30 +++++++++++++++++-- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py index 2196d735..f7686252 100644 --- a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py @@ -83,10 +83,7 @@ class Migration(migrations.Migration): ), *rename_plain_text_fields_and_create_encrypted_text_fields( "class", - { - "name": "name", - "access_code": "access code", - }, + {"name": "name", "access_code": "access code"}, ), *rename_plain_text_fields_and_create_encrypted_text_fields( "schoolteacherinvitation", @@ -97,10 +94,8 @@ class Migration(migrations.Migration): "invited_teacher_email": "invited teacher email", }, ), - # *rename_plain_text_fields_and_create_encrypted_text_fields( - # "school", - # { - # "name": "name", - # }, - # ), + *rename_plain_text_fields_and_create_encrypted_text_fields( + "school", + {"name": "name"}, + ), ] diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index a531b531..faa7a53a 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -8,9 +8,11 @@ from django.db import models from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from django_countries.fields import CountryField from ...models import DataEncryptionKeyModel +from ...models.fields import EncryptedTextField from ...types import Validators from ...validators import UnicodeAlphanumericCharSetValidator @@ -44,11 +46,35 @@ class School(DataEncryptionKeyModel): associated_data = "school" - name: str - name = models.CharField( # type: ignore[assignment] + # -------------------------------------------------------------------------- + # Name + # -------------------------------------------------------------------------- + + name_plain: str + name_plain = models.CharField( # type: ignore[assignment] max_length=200, unique=True, ) + name_enc = EncryptedTextField( + associated_data="name", + null=True, + verbose_name=_("name"), + ) + + @property + def name(self): + """Get the school's name.""" + if self.name_enc is not None: + return self.name_enc + return self.name_plain + + @name.setter + def name(self, value: str): + """Set the school's name.""" + self.name_plain = value + self.name_enc = value + + # -------------------------------------------------------------------------- country: t.Optional[str] country = CountryField( # type: ignore[assignment] From 977f9cd7b8754261a3597eb28e4bcada4932033b Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 11 Mar 2026 13:32:11 +0000 Subject: [PATCH 35/58] parse args --- .../commands/encrypt_plaintext_fields.py | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 codeforlife/user/management/commands/encrypt_plaintext_fields.py diff --git a/codeforlife/user/management/commands/encrypt_plaintext_fields.py b/codeforlife/user/management/commands/encrypt_plaintext_fields.py new file mode 100644 index 00000000..d5d76d3e --- /dev/null +++ b/codeforlife/user/management/commands/encrypt_plaintext_fields.py @@ -0,0 +1,112 @@ +import typing as t + +from django.apps import apps +from django.core.management.base import BaseCommand +from django.db.models import CharField, Model, TextField + +from ....models.fields import EncryptedTextField + +FieldsToEncrypt: t.TypeAlias = t.List[ + t.Tuple[t.Union[CharField, TextField], EncryptedTextField] +] + + +# pylint: disable-next=missing-class-docstring +class Command(BaseCommand): + format_help = ( + "Arguments should be in the format 'app_label.ModelName=" + "plain_field1:encrypted_text_field1," + "plain_field2:encrypted_text_field2'." + ) + help = f"Encrypts plaintext fields for specified models. {format_help}" + + def add_arguments(self, parser): + parser.add_argument( + "model_fields", nargs="+", type=str, help=self.format_help + ) + + def _parse_arg(self, arg: str): + # Split the argument into model specification and field name pairs. + if not "=" in arg: + raise ValueError(self.format_help) + model_spec, field_csv = arg.split("=", 1) + + # Retrieve the model class based on the app and model name. + if "." not in model_spec: + raise ValueError(self.format_help) + app_label, model_name = model_spec.split(".", 1) + model_class: t.Type[Model] = apps.get_model(app_label, model_name) + + # Parse the field name pairs and validate their existence in the model. + fields_to_encrypt: FieldsToEncrypt = [] + for field_name_pair in field_csv.split(","): + # Split the field name pair. + if ":" not in field_name_pair: + raise ValueError(self.format_help) + plain_field_name, enc_field_name = field_name_pair.split(":", 1) + + # Strip and validate field names. + plain_field_name = plain_field_name.strip() + enc_field_name = enc_field_name.strip() + if not plain_field_name or not enc_field_name: + raise ValueError(self.format_help) + + # Get the plaintext field and validate its type. + plain_field = model_class._meta.get_field(plain_field_name) + if not isinstance(plain_field, (CharField, TextField)): + raise ValueError( + f"Plaintext field '{plain_field_name}' must be " + "a CharField or TextField." + ) + + # Get the encrypted field and validate its type. + enc_field = model_class._meta.get_field(enc_field_name) + if not isinstance(enc_field, EncryptedTextField): + raise ValueError( + f"Encrypted field '{enc_field_name}' must be an " + "EncryptedTextField." + ) + + fields_to_encrypt.append((plain_field, enc_field)) + + return model_class, fields_to_encrypt + + def _encrypt_fields( + self, model_class: t.Type[Model], fields_to_encrypt: FieldsToEncrypt + ): + pass + # Now you can iterate through the parsed arguments + # for model_index, (model_spec, field_names) in enumerate( + # fields_to_encrypt.items(), start=1 + # ): + # app_label, model_name = model_spec.split(".", 1) + + # self.stdout.write( + # self.style.SUCCESS( + # f"Processing model: {app_label}.{model_name}" + # ) + # ) + + # self.stdout.write(f"{model_index}. {app_label}.{model_name}:") + # for field_index, field in enumerate(field_names, start=1): + # self.stdout.write(f"{model_index}.{field_index}. {field}") + + # self.stdout.write( + # self.style.SUCCESS(f"Processed model: {app_label}.{model_name}") + # ) + + # Here you would add your logic to get the model and encrypt the fields + # For example: + # try: + # model = apps.get_model(app_label, model_name) + # # ... encryption logic for model and field_names ... + # except LookupError: + # self.stderr.write(f"Model '{model_spec}' not found.") + + def handle(self, *args, **options): + for arg in options["model_fields"]: + self.stdout.write(f"Parsing argument: {arg}") + + model_class, fields_to_encrypt = self._parse_arg(arg) + + self._encrypt_fields(model_class, fields_to_encrypt) From 16d950be6a1977ae5c35f4210a3a8508f416ab20 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Wed, 11 Mar 2026 17:52:26 +0000 Subject: [PATCH 36/58] pretty print --- codeforlife/pprint.py | 159 ++++++++++++++++++ .../commands/encrypt_plaintext_fields.py | 76 +++++---- 2 files changed, 202 insertions(+), 33 deletions(-) create mode 100644 codeforlife/pprint.py diff --git a/codeforlife/pprint.py b/codeforlife/pprint.py new file mode 100644 index 00000000..3d4dab0e --- /dev/null +++ b/codeforlife/pprint.py @@ -0,0 +1,159 @@ +""" +© Ocado Group +Created on 11/03/2026 at 14:42:45(+00:00). + +Pretty-printing utilities for terminal output. +""" + +import os +import typing as t + +RESET = "\033[0m" + +BLACK = "\033[30m" +WHITE = "\033[37m" + +RED = "\033[31m" +GREEN = "\033[32m" +BLUE = "\033[34m" + +YELLOW = "\033[33m" +MAGENTA = "\033[35m" +CYAN = "\033[36m" + +BOLD = "\033[1m" +UNDERLINE = "\033[4m" +OVERLINE = "\033[53m" + +if t.TYPE_CHECKING: + from typing_extensions import Protocol + + # pylint: disable-next=too-few-public-methods + class StyleProtocol(Protocol): + """A callable that applies a style to a message.""" + + @t.overload + def __call__( + self, message: str, write: t.Literal[True] = True, **kwargs + ) -> str: ... + + @t.overload + def __call__(self, message: str, write: t.Literal[False]) -> str: ... + + +# pylint: disable-next=too-many-instance-attributes +class PrettyPrinter: + """A utility class for pretty-printing styled messages to the terminal.""" + + Write: t.TypeAlias = t.Callable[..., None] + Style: t.TypeAlias = "StyleProtocol" + + def __init__(self, write: Write): + self.write = write + + self.black = self.make_ansi_style(BLACK) + self.white = self.make_ansi_style(WHITE) + + self.red = self.make_ansi_style(RED) + self.green = self.make_ansi_style(GREEN) + self.blue = self.make_ansi_style(BLUE) + + self.yellow = self.make_ansi_style(YELLOW) + self.magenta = self.make_ansi_style(MAGENTA) + self.cyan = self.make_ansi_style(CYAN) + + self.bold = self.make_ansi_style(BOLD) + self.underline = self.make_ansi_style(UNDERLINE) + self.overline = self.make_ansi_style(OVERLINE) + + self.success = self.combine_styles(self.green, self.bold) + self.error = self.combine_styles(self.red, self.bold) + self.warn = self.warning = self.combine_styles(self.yellow, self.bold) + self.info = self.notice = self.combine_styles(self.blue, self.bold) + + def make_style(self, stylize: t.Callable[[str], str]) -> Style: + # pylint: disable=line-too-long + """Make a style that applies the given stylization function to messages. + + Args: + stylize: A function that takes a message and returns a stylized version of it. + + Returns: + A style that applies the given stylization function to messages and writes them if a write function is set. + """ + # pylint: enable=line-too-long + + def style(message: str, write: bool = True, **kwargs): + styled_message = stylize(message) + + if write: + self.write(styled_message, **kwargs) + + return styled_message + + return style + + def make_ansi_style(self, ansi_code: str): + """Make a style that applies the given ANSI code to messages. + + Args: + ansi_code: The ANSI code to apply. + + Returns: + A style that applies the given ANSI code to messages. + """ + return self.make_style(lambda message: ansi_code + message + RESET) + + def combine_styles(self, *styles: Style): + """Combine multiple styles into a single style. + + Args: + *styles: The styles to combine. + + Returns: + A function that takes a message and applies all the styles to it. + """ + + def combined_style(message: str): + for style in styles: + message = style(message, write=False) + + return message + + return self.make_style(combined_style) + + def divider( + self, + default_columns=80, + char="-", + style: t.Optional[Style] = None, + **kwargs + ): + # pylint: disable=line-too-long + """Write a divider line with the specified character. + + Args: + default_columns: The default number of columns to use if the terminal width cannot be determined. + char: The character to use for the divider. + style: An optional style to apply to the divider line. + """ + # pylint: enable=line-too-long + message = int(os.getenv("COLUMNS", default_columns)) * char + + if style: + message = style(message, write=False) + + self.write(message, **kwargs) + + def indent(self, count: int, spaces=2, char=" ", **kwargs): + """Write an indentation of the specified number of spaces. + + Args: + count: The number of indentation levels. + spaces: The number of spaces per indentation level. + char: The character to use for indentation. + """ + self.write(char * count * spaces, **kwargs) + + +pprint = PrettyPrinter(write=print) diff --git a/codeforlife/user/management/commands/encrypt_plaintext_fields.py b/codeforlife/user/management/commands/encrypt_plaintext_fields.py index d5d76d3e..cb2d2df4 100644 --- a/codeforlife/user/management/commands/encrypt_plaintext_fields.py +++ b/codeforlife/user/management/commands/encrypt_plaintext_fields.py @@ -5,9 +5,10 @@ from django.db.models import CharField, Model, TextField from ....models.fields import EncryptedTextField +from ....pprint import PrettyPrinter -FieldsToEncrypt: t.TypeAlias = t.List[ - t.Tuple[t.Union[CharField, TextField], EncryptedTextField] +FieldsToEncrypt: t.TypeAlias = t.Dict[ + t.Union[CharField, TextField], EncryptedTextField ] @@ -20,12 +21,20 @@ class Command(BaseCommand): ) help = f"Encrypts plaintext fields for specified models. {format_help}" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.pprint = PrettyPrinter(write=self.stdout.write) + def add_arguments(self, parser): parser.add_argument( "model_fields", nargs="+", type=str, help=self.format_help ) def _parse_arg(self, arg: str): + self.pprint.indent( + 1, ending=self.pprint.bold("Parsing ... ", write=False) + ) + # Split the argument into model specification and field name pairs. if not "=" in arg: raise ValueError(self.format_help) @@ -38,7 +47,7 @@ def _parse_arg(self, arg: str): model_class: t.Type[Model] = apps.get_model(app_label, model_name) # Parse the field name pairs and validate their existence in the model. - fields_to_encrypt: FieldsToEncrypt = [] + fields_to_encrypt: FieldsToEncrypt = {} for field_name_pair in field_csv.split(","): # Split the field name pair. if ":" not in field_name_pair: @@ -58,6 +67,11 @@ def _parse_arg(self, arg: str): f"Plaintext field '{plain_field_name}' must be " "a CharField or TextField." ) + if plain_field in fields_to_encrypt: + raise ValueError( + f"Duplicate plaintext field '{plain_field_name}' in " + "argument." + ) # Get the encrypted field and validate its type. enc_field = model_class._meta.get_field(enc_field_name) @@ -66,46 +80,42 @@ def _parse_arg(self, arg: str): f"Encrypted field '{enc_field_name}' must be an " "EncryptedTextField." ) + if enc_field in fields_to_encrypt.values(): + raise ValueError( + f"Duplicate encrypted field '{enc_field_name}' in " + "argument." + ) + + fields_to_encrypt[plain_field] = enc_field - fields_to_encrypt.append((plain_field, enc_field)) + self.pprint.success("Done") return model_class, fields_to_encrypt def _encrypt_fields( self, model_class: t.Type[Model], fields_to_encrypt: FieldsToEncrypt ): - pass - # Now you can iterate through the parsed arguments - # for model_index, (model_spec, field_names) in enumerate( - # fields_to_encrypt.items(), start=1 - # ): - # app_label, model_name = model_spec.split(".", 1) - - # self.stdout.write( - # self.style.SUCCESS( - # f"Processing model: {app_label}.{model_name}" - # ) - # ) - - # self.stdout.write(f"{model_index}. {app_label}.{model_name}:") - # for field_index, field in enumerate(field_names, start=1): - # self.stdout.write(f"{model_index}.{field_index}. {field}") - - # self.stdout.write( - # self.style.SUCCESS(f"Processed model: {app_label}.{model_name}") - # ) - - # Here you would add your logic to get the model and encrypt the fields - # For example: - # try: - # model = apps.get_model(app_label, model_name) - # # ... encryption logic for model and field_names ... - # except LookupError: - # self.stderr.write(f"Model '{model_spec}' not found.") + self.pprint.indent( + 1, ending=self.pprint.bold("Encrypting fields:\n", write=False) + ) + + for plain_field, enc_field in fields_to_encrypt.items(): + self.pprint.indent( + 2, ending=f"{plain_field.name} -> {enc_field.name} ... " + ) + + # TODO: encrypt the plaintext field values and save them to the + # encrypted field. + + self.pprint.success("Done") def handle(self, *args, **options): for arg in options["model_fields"]: - self.stdout.write(f"Parsing argument: {arg}") + self.pprint.divider() + self.pprint.bold("Processing argument:", ending=" ") + self.pprint.write( + f'"{arg if len(arg) <= 50 else arg[:37] + "..." + arg[-10:]}".' + ) model_class, fields_to_encrypt = self._parse_arg(arg) From 2ea788729e606540e8c343e0aa3516ddf297a07e Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 12 Mar 2026 10:32:41 +0000 Subject: [PATCH 37/58] headers and dividers --- codeforlife/pprint.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/codeforlife/pprint.py b/codeforlife/pprint.py index 3d4dab0e..8076a8d7 100644 --- a/codeforlife/pprint.py +++ b/codeforlife/pprint.py @@ -71,6 +71,8 @@ def __init__(self, write: Write): self.warn = self.warning = self.combine_styles(self.yellow, self.bold) self.info = self.notice = self.combine_styles(self.blue, self.bold) + self.h3 = self.combine_styles(self.overline, self.underline, self.bold) + def make_style(self, stylize: t.Callable[[str], str]) -> Style: # pylint: disable=line-too-long """Make a style that applies the given stylization function to messages. @@ -138,7 +140,12 @@ def divider( style: An optional style to apply to the divider line. """ # pylint: enable=line-too-long - message = int(os.getenv("COLUMNS", default_columns)) * char + try: + columns = os.get_terminal_size().columns + except OSError: + columns = default_columns + + message = char * columns if style: message = style(message, write=False) @@ -155,5 +162,25 @@ def indent(self, count: int, spaces=2, char=" ", **kwargs): """ self.write(char * count * spaces, **kwargs) + def h1(self, message: str): + """Write a level 1 header. + + Args: + message: The header message to write. + """ + self.divider(char="=", style=self.bold) + self.bold(message) + self.divider(char="=", style=self.bold) + + def h2(self, message: str): + """Write a level 2 header. + + Args: + message: The header message to write. + """ + self.divider(char="-", style=self.bold) + self.bold(message) + self.divider(char="-", style=self.bold) + pprint = PrettyPrinter(write=print) From 9356efafc5a16faccf88df78133a0f772fdeed27 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 12 Mar 2026 11:43:00 +0000 Subject: [PATCH 38/58] simplify --- codeforlife/pprint.py | 217 ++++++++---------- .../commands/encrypt_plaintext_fields.py | 21 +- 2 files changed, 108 insertions(+), 130 deletions(-) diff --git a/codeforlife/pprint.py b/codeforlife/pprint.py index 8076a8d7..7da9ce34 100644 --- a/codeforlife/pprint.py +++ b/codeforlife/pprint.py @@ -7,137 +7,141 @@ import os import typing as t +from enum import Enum -RESET = "\033[0m" +Write: t.TypeAlias = t.Callable[[str], None] -BLACK = "\033[30m" -WHITE = "\033[37m" -RED = "\033[31m" -GREEN = "\033[32m" -BLUE = "\033[34m" +class ANSI(Enum): + """ANSI escape codes for styling terminal output.""" -YELLOW = "\033[33m" -MAGENTA = "\033[35m" -CYAN = "\033[36m" + RESET = "\033[0m" -BOLD = "\033[1m" -UNDERLINE = "\033[4m" -OVERLINE = "\033[53m" + BLACK = "\033[30m" + WHITE = "\033[37m" -if t.TYPE_CHECKING: - from typing_extensions import Protocol + RED = "\033[31m" + GREEN = "\033[32m" + BLUE = "\033[34m" - # pylint: disable-next=too-few-public-methods - class StyleProtocol(Protocol): - """A callable that applies a style to a message.""" + YELLOW = "\033[33m" + MAGENTA = "\033[35m" + CYAN = "\033[36m" - @t.overload - def __call__( - self, message: str, write: t.Literal[True] = True, **kwargs - ) -> str: ... + BOLD = "\033[1m" + UNDERLINE = "\033[4m" + OVERLINE = "\033[53m" - @t.overload - def __call__(self, message: str, write: t.Literal[False]) -> str: ... +class Style: + """A callable class that applies styles to messages.""" -# pylint: disable-next=too-many-instance-attributes -class PrettyPrinter: - """A utility class for pretty-printing styled messages to the terminal.""" - - Write: t.TypeAlias = t.Callable[..., None] - Style: t.TypeAlias = "StyleProtocol" - - def __init__(self, write: Write): + def __init__(self, apply: t.Callable[[str], str], write: Write = print): + self.apply = apply self.write = write - self.black = self.make_ansi_style(BLACK) - self.white = self.make_ansi_style(WHITE) + def __call__(self, message: str, *args, **kwargs): + styled_message = self.apply(message) + self.write(styled_message, *args, **kwargs) - self.red = self.make_ansi_style(RED) - self.green = self.make_ansi_style(GREEN) - self.blue = self.make_ansi_style(BLUE) - - self.yellow = self.make_ansi_style(YELLOW) - self.magenta = self.make_ansi_style(MAGENTA) - self.cyan = self.make_ansi_style(CYAN) - - self.bold = self.make_ansi_style(BOLD) - self.underline = self.make_ansi_style(UNDERLINE) - self.overline = self.make_ansi_style(OVERLINE) - - self.success = self.combine_styles(self.green, self.bold) - self.error = self.combine_styles(self.red, self.bold) - self.warn = self.warning = self.combine_styles(self.yellow, self.bold) - self.info = self.notice = self.combine_styles(self.blue, self.bold) - - self.h3 = self.combine_styles(self.overline, self.underline, self.bold) - - def make_style(self, stylize: t.Callable[[str], str]) -> Style: - # pylint: disable=line-too-long - """Make a style that applies the given stylization function to messages. + @classmethod + def ansi(cls, code: ANSI, write: Write = print): + """Create a style that applies the given ANSI code to messages. Args: - stylize: A function that takes a message and returns a stylized version of it. + code: The ANSI code to apply. + write: The function to use for writing the styled message. Returns: - A style that applies the given stylization function to messages and writes them if a write function is set. + A style that applies the given ANSI code to messages. """ - # pylint: enable=line-too-long - - def style(message: str, write: bool = True, **kwargs): - styled_message = stylize(message) - - if write: - self.write(styled_message, **kwargs) - return styled_message + def apply(message: str): + return code.value + message + ANSI.RESET.value - return style + return cls(apply, write) - def make_ansi_style(self, ansi_code: str): - """Make a style that applies the given ANSI code to messages. - - Args: - ansi_code: The ANSI code to apply. - - Returns: - A style that applies the given ANSI code to messages. - """ - return self.make_style(lambda message: ansi_code + message + RESET) - - def combine_styles(self, *styles: Style): + @classmethod + def combine(cls, styles: t.List["Style"], write: Write = print): """Combine multiple styles into a single style. Args: - *styles: The styles to combine. + styles: The styles to combine. + write: The function to use for writing the styled message. Returns: - A function that takes a message and applies all the styles to it. + A style that applies all the given styles to messages. """ - def combined_style(message: str): + def apply(message: str): for style in styles: - message = style(message, write=False) + message = style.apply(message) return message - return self.make_style(combined_style) + return cls(apply, write) + + +# pylint: disable-next=too-many-instance-attributes +class PrettyPrinter: + """A utility class for pretty-printing styled messages to the terminal.""" + + def __init__(self, write: Write): + self.write = write - def divider( - self, - default_columns=80, - char="-", - style: t.Optional[Style] = None, - **kwargs - ): + # Black and white. + self.black = Style.ansi(ANSI.BLACK, write) + self.white = Style.ansi(ANSI.WHITE, write) + + # Red, green, and blue. + self.red = Style.ansi(ANSI.RED, write) + self.green = Style.ansi(ANSI.GREEN, write) + self.blue = Style.ansi(ANSI.BLUE, write) + + # Cyan, magenta, and yellow. + self.cyan = Style.ansi(ANSI.CYAN, write) + self.magenta = Style.ansi(ANSI.MAGENTA, write) + self.yellow = Style.ansi(ANSI.YELLOW, write) + + # Common text styles. + self.bold = Style.ansi(ANSI.BOLD, write) + self.underline = Style.ansi(ANSI.UNDERLINE, write) + self.overline = Style.ansi(ANSI.OVERLINE, write) + + # Status styles. + self.success = Style.combine([self.green, self.bold], write) + self.error = Style.combine([self.red, self.bold], write) + self.warn = self.warning = Style.combine( + [self.yellow, self.bold], write + ) + self.info = self.notice = Style.combine([self.blue, self.bold], write) + + # Heading styles. + h1 = Style( + lambda message: "\n".join( + [self.divider("="), message, self.divider("=")] + ), + write, + ) + self.h1 = Style.combine([h1, self.bold], write) + h2 = Style( + lambda message: "\n".join( + [self.divider("-"), message, self.divider("-")] + ), + write, + ) + self.h2 = Style.combine([h2, self.bold], write) + self.h3 = Style.combine( + [self.overline, self.underline, self.bold], write + ) + + def divider(self, char="-", default_columns=80): # pylint: disable=line-too-long """Write a divider line with the specified character. Args: default_columns: The default number of columns to use if the terminal width cannot be determined. char: The character to use for the divider. - style: An optional style to apply to the divider line. """ # pylint: enable=line-too-long try: @@ -145,14 +149,9 @@ def divider( except OSError: columns = default_columns - message = char * columns + return char * columns - if style: - message = style(message, write=False) - - self.write(message, **kwargs) - - def indent(self, count: int, spaces=2, char=" ", **kwargs): + def indent(self, count: int, spaces=2, char=" "): """Write an indentation of the specified number of spaces. Args: @@ -160,27 +159,7 @@ def indent(self, count: int, spaces=2, char=" ", **kwargs): spaces: The number of spaces per indentation level. char: The character to use for indentation. """ - self.write(char * count * spaces, **kwargs) - - def h1(self, message: str): - """Write a level 1 header. - - Args: - message: The header message to write. - """ - self.divider(char="=", style=self.bold) - self.bold(message) - self.divider(char="=", style=self.bold) - - def h2(self, message: str): - """Write a level 2 header. - - Args: - message: The header message to write. - """ - self.divider(char="-", style=self.bold) - self.bold(message) - self.divider(char="-", style=self.bold) + return char * count * spaces pprint = PrettyPrinter(write=print) diff --git a/codeforlife/user/management/commands/encrypt_plaintext_fields.py b/codeforlife/user/management/commands/encrypt_plaintext_fields.py index cb2d2df4..353a9c6a 100644 --- a/codeforlife/user/management/commands/encrypt_plaintext_fields.py +++ b/codeforlife/user/management/commands/encrypt_plaintext_fields.py @@ -31,9 +31,7 @@ def add_arguments(self, parser): ) def _parse_arg(self, arg: str): - self.pprint.indent( - 1, ending=self.pprint.bold("Parsing ... ", write=False) - ) + self.pprint.bold(self.pprint.indent(1) + "Parsing", ending=" ... ") # Split the argument into model specification and field name pairs. if not "=" in arg: @@ -95,13 +93,13 @@ def _parse_arg(self, arg: str): def _encrypt_fields( self, model_class: t.Type[Model], fields_to_encrypt: FieldsToEncrypt ): - self.pprint.indent( - 1, ending=self.pprint.bold("Encrypting fields:\n", write=False) - ) + self.pprint.bold(self.pprint.indent(1) + "Encrypting fields:") for plain_field, enc_field in fields_to_encrypt.items(): - self.pprint.indent( - 2, ending=f"{plain_field.name} -> {enc_field.name} ... " + self.pprint.write( + self.pprint.indent(2) + + f"{plain_field.name} -> {enc_field.name}", + ending=" ... ", ) # TODO: encrypt the plaintext field values and save them to the @@ -111,10 +109,11 @@ def _encrypt_fields( def handle(self, *args, **options): for arg in options["model_fields"]: - self.pprint.divider() - self.pprint.bold("Processing argument:", ending=" ") self.pprint.write( - f'"{arg if len(arg) <= 50 else arg[:37] + "..." + arg[-10:]}".' + self.pprint.divider() + + "\n" + + self.pprint.bold.apply("Processing argument:") + + f'"{arg if len(arg) <= 50 else f"{arg[:37]}...{arg[-10:]}"}".' ) model_class, fields_to_encrypt = self._parse_arg(arg) From 8523ea274dba1eda90b86e4e57893cdc20a3b838 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Thu, 12 Mar 2026 15:58:57 +0000 Subject: [PATCH 39/58] support process indentation --- codeforlife/pprint.py | 100 +++++++++++++---- .../commands/encrypt_plaintext_fields.py | 104 +++++++++++------- 2 files changed, 144 insertions(+), 60 deletions(-) diff --git a/codeforlife/pprint.py b/codeforlife/pprint.py index 7da9ce34..c0a9094e 100644 --- a/codeforlife/pprint.py +++ b/codeforlife/pprint.py @@ -8,6 +8,7 @@ import os import typing as t from enum import Enum +from timeit import default_timer Write: t.TypeAlias = t.Callable[[str], None] @@ -86,53 +87,106 @@ def apply(message: str): class PrettyPrinter: """A utility class for pretty-printing styled messages to the terminal.""" - def __init__(self, write: Write): + def __init__(self, write: Write, name: str, indent_level=0): self.write = write + self.name = name + self.indent_level = indent_level + self.start_time: t.Optional[float] = None + self.end_time: t.Optional[float] = None # Black and white. - self.black = Style.ansi(ANSI.BLACK, write) - self.white = Style.ansi(ANSI.WHITE, write) + self.black = Style.ansi(ANSI.BLACK, self.__call__) + self.white = Style.ansi(ANSI.WHITE, self.__call__) # Red, green, and blue. - self.red = Style.ansi(ANSI.RED, write) - self.green = Style.ansi(ANSI.GREEN, write) - self.blue = Style.ansi(ANSI.BLUE, write) + self.red = Style.ansi(ANSI.RED, self.__call__) + self.green = Style.ansi(ANSI.GREEN, self.__call__) + self.blue = Style.ansi(ANSI.BLUE, self.__call__) # Cyan, magenta, and yellow. - self.cyan = Style.ansi(ANSI.CYAN, write) - self.magenta = Style.ansi(ANSI.MAGENTA, write) - self.yellow = Style.ansi(ANSI.YELLOW, write) + self.cyan = Style.ansi(ANSI.CYAN, self.__call__) + self.magenta = Style.ansi(ANSI.MAGENTA, self.__call__) + self.yellow = Style.ansi(ANSI.YELLOW, self.__call__) # Common text styles. - self.bold = Style.ansi(ANSI.BOLD, write) - self.underline = Style.ansi(ANSI.UNDERLINE, write) - self.overline = Style.ansi(ANSI.OVERLINE, write) + self.bold = Style.ansi(ANSI.BOLD, self.__call__) + self.underline = Style.ansi(ANSI.UNDERLINE, self.__call__) + self.overline = Style.ansi(ANSI.OVERLINE, self.__call__) # Status styles. - self.success = Style.combine([self.green, self.bold], write) - self.error = Style.combine([self.red, self.bold], write) + self.success = Style.combine([self.green, self.bold], self.__call__) + self.error = Style.combine([self.red, self.bold], self.__call__) self.warn = self.warning = Style.combine( - [self.yellow, self.bold], write + [self.yellow, self.bold], self.__call__ + ) + self.info = self.notice = Style.combine( + [self.blue, self.bold], self.__call__ ) - self.info = self.notice = Style.combine([self.blue, self.bold], write) # Heading styles. h1 = Style( lambda message: "\n".join( [self.divider("="), message, self.divider("=")] ), - write, + self.__call__, ) - self.h1 = Style.combine([h1, self.bold], write) + self.h1 = Style.combine([h1, self.bold], self.__call__) h2 = Style( lambda message: "\n".join( [self.divider("-"), message, self.divider("-")] ), - write, + self.__call__, ) - self.h2 = Style.combine([h2, self.bold], write) + self.h2 = Style.combine([h2, self.bold], self.__call__) self.h3 = Style.combine( - [self.overline, self.underline, self.bold], write + [self.overline, self.underline, self.bold], self.__call__ + ) + + def __call__(self, message: str, *args, **kwargs): + # pylint: disable=line-too-long + """Write a message to the terminal. + + Args: + message: The message to write. + *args: Additional positional arguments to pass to the write function. + **kwargs: Additional keyword arguments to pass to the write function. + """ + # pylint: enable=line-too-long + indented_message = self.indent(self.indent_level) + message + self.write(indented_message, *args, **kwargs) + + def __enter__(self): + self.bold(self.name) + self.indent_level = max(0, self.indent_level + 1) + + self.start_time = default_timer() + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.end_time = default_timer() + elapsed_time = self.end_time - (self.start_time or self.end_time) + + self.indent_level = max(0, self.indent_level - 1) + (self.error if exc_type else self.success)( + f"{self.name} ({elapsed_time:.2f}s elapsed)" + ) + + def process(self, name: str, indent_level: t.Optional[int] = None): + """ + A context manager for processing a message with automatic success or + error handling. If an exception is raised within the context, the + message is marked as an error; otherwise, it is marked as a success. + + Any messages written within the context are indented by the specified + level and written when the context is entered. + """ + return self.__class__( + write=self.write, + name=name, + indent_level=( + self.indent_level if indent_level is None else indent_level + ), ) def divider(self, char="-", default_columns=80): @@ -151,7 +205,7 @@ def divider(self, char="-", default_columns=80): return char * columns - def indent(self, count: int, spaces=2, char=" "): + def indent(self, count: int, spaces=4, char=" "): """Write an indentation of the specified number of spaces. Args: @@ -162,4 +216,4 @@ def indent(self, count: int, spaces=2, char=" "): return char * count * spaces -pprint = PrettyPrinter(write=print) +pprint = PrettyPrinter(write=print, name="main") diff --git a/codeforlife/user/management/commands/encrypt_plaintext_fields.py b/codeforlife/user/management/commands/encrypt_plaintext_fields.py index 353a9c6a..aead6bd5 100644 --- a/codeforlife/user/management/commands/encrypt_plaintext_fields.py +++ b/codeforlife/user/management/commands/encrypt_plaintext_fields.py @@ -7,9 +7,8 @@ from ....models.fields import EncryptedTextField from ....pprint import PrettyPrinter -FieldsToEncrypt: t.TypeAlias = t.Dict[ - t.Union[CharField, TextField], EncryptedTextField -] +PlaintextField: t.TypeAlias = t.Union[CharField, TextField] +FieldsToEncrypt: t.TypeAlias = t.Dict[PlaintextField, EncryptedTextField] # pylint: disable-next=missing-class-docstring @@ -21,18 +20,18 @@ class Command(BaseCommand): ) help = f"Encrypts plaintext fields for specified models. {format_help}" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.pprint = PrettyPrinter(write=self.stdout.write) - def add_arguments(self, parser): parser.add_argument( "model_fields", nargs="+", type=str, help=self.format_help ) + parser.add_argument( + "--chunk-size", + type=int, + default=100, + help="The number of records to process in each batch.", + ) - def _parse_arg(self, arg: str): - self.pprint.bold(self.pprint.indent(1) + "Parsing", ending=" ... ") - + def _parse_arg(self, arg: str, pprint: PrettyPrinter): # Split the argument into model specification and field name pairs. if not "=" in arg: raise ValueError(self.format_help) @@ -43,6 +42,7 @@ def _parse_arg(self, arg: str): raise ValueError(self.format_help) app_label, model_name = model_spec.split(".", 1) model_class: t.Type[Model] = apps.get_model(app_label, model_name) + pprint(f"Found model: {pprint.notice.apply(model_spec)}") # Parse the field name pairs and validate their existence in the model. fields_to_encrypt: FieldsToEncrypt = {} @@ -70,6 +70,10 @@ def _parse_arg(self, arg: str): f"Duplicate plaintext field '{plain_field_name}' in " "argument." ) + pprint( + "Found plaintext field: " + + pprint.notice.apply(f"{model_spec}.{plain_field_name}") + ) # Get the encrypted field and validate its type. enc_field = model_class._meta.get_field(enc_field_name) @@ -83,39 +87,65 @@ def _parse_arg(self, arg: str): f"Duplicate encrypted field '{enc_field_name}' in " "argument." ) + pprint( + "Found encrypted field: " + + pprint.notice.apply(f"{model_spec}.{enc_field_name}") + ) fields_to_encrypt[plain_field] = enc_field - self.pprint.success("Done") - return model_class, fields_to_encrypt - def _encrypt_fields( - self, model_class: t.Type[Model], fields_to_encrypt: FieldsToEncrypt + # pylint: disable-next=too-many-arguments,too-many-positional-arguments + def _encrypt_field( + self, + chunk_size: int, + model_class: t.Type[Model], + plain_field: PlaintextField, + enc_field: EncryptedTextField, + pprint: PrettyPrinter, ): - self.pprint.bold(self.pprint.indent(1) + "Encrypting fields:") - - for plain_field, enc_field in fields_to_encrypt.items(): - self.pprint.write( - self.pprint.indent(2) - + f"{plain_field.name} -> {enc_field.name}", - ending=" ... ", - ) - - # TODO: encrypt the plaintext field values and save them to the - # encrypted field. + models = model_class.objects.filter( # type: ignore[attr-defined] + **{f"{plain_field.name}__isnull": False} + ) + model_count = models.count() + for model_index, model in enumerate(models.iterator(chunk_size)): + if model_index % chunk_size == 0: + pprint(f"({model_index}/{model_count})") - self.pprint.success("Done") + plaintext = getattr(model, plain_field.name) + setattr(model, enc_field.name, plaintext) + model.save(update_fields=[enc_field.name]) def handle(self, *args, **options): - for arg in options["model_fields"]: - self.pprint.write( - self.pprint.divider() - + "\n" - + self.pprint.bold.apply("Processing argument:") - + f'"{arg if len(arg) <= 50 else f"{arg[:37]}...{arg[-10:]}"}".' - ) - - model_class, fields_to_encrypt = self._parse_arg(arg) - - self._encrypt_fields(model_class, fields_to_encrypt) + model_fields: t.List[str] = options["model_fields"] + chunk_size: int = options["chunk_size"] + + pprint = PrettyPrinter(write=self.stdout.write, name=self.__module__) + + for arg in model_fields: + with pprint.process( + "Processing argument: " + f'"{arg if len(arg) <= 50 else f"{arg[:37]}...{arg[-10:]}"}"' + ) as arg_pprint: + with arg_pprint.process("Parsing argument") as parse_pprint: + model_class, fields_to_encrypt = self._parse_arg( + arg, parse_pprint + ) + + with arg_pprint.process( + "Encrypting model:" + f" {model_class._meta.app_label}" + f".{model_class._meta.model_name}" + ) as enc_model_pprint: + for plain_field, enc_field in fields_to_encrypt.items(): + with enc_model_pprint.process( + f"Field: {plain_field.name} -> {enc_field.name}" + ) as enc_field_pprint: + self._encrypt_field( + chunk_size, + model_class, + plain_field, + enc_field, + enc_field_pprint, + ) From a99738b9f0c9cb774eb0fbd8681da1e343993b5f Mon Sep 17 00:00:00 2001 From: SKairinos Date: Fri, 13 Mar 2026 10:39:48 +0000 Subject: [PATCH 40/58] add dek to fixtures --- codeforlife/models/fields/base_encrypted.py | 19 +- .../models/fields/data_encryption_key.py | 14 +- codeforlife/user/fixtures/google_users.json | 9 +- codeforlife/user/fixtures/independent.json | 18 +- codeforlife/user/fixtures/legacy.json | 212 ++++++++++-------- .../user/fixtures/non_school_teacher.json | 18 +- codeforlife/user/fixtures/school_1.json | 43 ++-- codeforlife/user/fixtures/school_2.json | 29 +-- codeforlife/user/fixtures/school_3.json | 21 +- codeforlife/user/models/otp_bypass_token.py | 2 +- 10 files changed, 225 insertions(+), 160 deletions(-) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 9a7e7413..265114a2 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -51,6 +51,7 @@ import typing as t from dataclasses import dataclass +from enum import IntEnum, auto from django.core.exceptions import ValidationError from django.db.models import BinaryField @@ -73,7 +74,14 @@ class _PendingEncryption(t.Generic[T]): class _TrustedCiphertext: """A wrapper for ciphertext that comes directly from the database.""" + class Source(IntEnum): + """The source of the ciphertext.""" + + DB = auto() + FIXTURE = auto() + ciphertext: bytes + source: Source Value: t.TypeAlias = t.Union[_TrustedCiphertext, _PendingEncryption[T]] @@ -108,6 +116,11 @@ def __get__(self, instance, cls=None): return internal_value.value if isinstance(internal_value, _TrustedCiphertext): + # If the ciphertext came from a fixture, do not decrypt it so that + # it can be loaded as-is into the database. + if internal_value.source == _TrustedCiphertext.Source.FIXTURE: + return internal_value.ciphertext + # If we have a cached decrypted value, return it. if self.field.attname in instance.__decrypted_values__: return t.cast( @@ -152,7 +165,9 @@ def __set__( "Expected bytes in memoryview for encrypted field.", code="invalid_memoryview_type", ) - internal_value = _TrustedCiphertext(value.obj) + internal_value = _TrustedCiphertext( + value.obj, _TrustedCiphertext.Source.FIXTURE + ) elif isinstance(value, _TrustedCiphertext): # From DB. internal_value = value else: # From user input. @@ -274,7 +289,7 @@ def from_db_value(self, value: t.Optional[bytes], expression, connection): return None # Wrap it so __set__ knows this is NOT new user input. - return _TrustedCiphertext(value) + return _TrustedCiphertext(value, _TrustedCiphertext.Source.DB) def pre_save( self, model_instance: EncryptedModel, add # type: ignore[override] diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index 865ed797..d6c0f132 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -57,7 +57,9 @@ class DataEncryptionKeyAttribute( def __set__( self, instance, - value: t.Optional[_TrustedDek], # type: ignore[override] + value: t.Optional[ # type: ignore[override] + t.Union[memoryview, _TrustedDek] + ], ): # Clear any cached DEK AEAD. if instance.pk is not None and instance.pk in instance.DEK_AEAD_CACHE: @@ -67,6 +69,16 @@ def __set__( internal_value = value.dek elif value is None: # Data is being shredded. internal_value = None + # When Django loads data from a fixture (e.g., a JSON file), it + # provides binary data as a `memoryview` object. Our descriptor + # handles this by extracting the raw bytes from the `memoryview`. + elif isinstance(value, memoryview): + if not isinstance(value.obj, bytes): + raise ValidationError( + "Expected bytes in memoryview for encrypted field.", + code="invalid_memoryview_type", + ) + internal_value = value.obj else: raise ValidationError( "DataEncryptionKeyField can only be set to None.", diff --git a/codeforlife/user/fixtures/google_users.json b/codeforlife/user/fixtures/google_users.json index 13cd2a2a..394fa89e 100644 --- a/codeforlife/user/fixtures/google_users.json +++ b/codeforlife/user/fixtures/google_users.json @@ -3,10 +3,11 @@ "model": "user.user", "pk": 34, "fields": { - "first_name": "Google", - "last_name": "Teacher", - "username": "google.teacher@noschool.com", - "email": "google.teacher@noschool.com", + "dek": "ZmFrZV9lbmM6YlVkTE4xSndaRlZTTW5kc1NqQkJXa1ZxU2xKV2NWTk9WbFJ6YzFjMmVHVT0=", + "first_name_plain": "Google", + "last_name_plain": "Teacher", + "username_plain": "google.teacher@noschool.com", + "email_plain": "google.teacher@noschool.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, diff --git a/codeforlife/user/fixtures/independent.json b/codeforlife/user/fixtures/independent.json index 71d2f958..bcdf4d51 100644 --- a/codeforlife/user/fixtures/independent.json +++ b/codeforlife/user/fixtures/independent.json @@ -3,10 +3,11 @@ "model": "user.user", "pk": 28, "fields": { - "first_name": "Indy", - "last_name": "Requester", - "username": "indy.requester@email.com", - "email": "indy.requester@email.com", + "dek": "ZmFrZV9lbmM6Ym5oYVFqaHhUMDV4VTI4NE9YVk9TbmxvVkVzemRUWnpkMUpTWjFkMFVFRT0=", + "first_name_plain": "Indy", + "last_name_plain": "Requester", + "username_plain": "indy.requester@email.com", + "email_plain": "indy.requester@email.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -31,10 +32,11 @@ "model": "user.user", "pk": 30, "fields": { - "first_name": "Indy", - "last_name": "NoRequest", - "username": "indy@email.com", - "email": "indy@email.com", + "dek": "ZmFrZV9lbmM6TTIxbFVXMWlWMnR3VXpOa2JXaENlbkJGUW05a1oyZEpkRzV6YlhGa1pVYz0=", + "first_name_plain": "Indy", + "last_name_plain": "NoRequest", + "username_plain": "indy@email.com", + "email_plain": "indy@email.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json index ff6bb9dc..bc63ec31 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -234,7 +234,8 @@ "model": "user.school", "pk": 1, "fields": { - "name": "Swiss Federal Polytechnic", + "dek": "ZmFrZV9lbmM6VHpkNWFFZzVaVUozVEdNM1MyRkJXV042YUVkcVUwWnZWVzV4YWtaaVNFbz0=", + "name_plain": "Swiss Federal Polytechnic", "country": "GB", "county": "nan", "creation_time": null, @@ -293,9 +294,9 @@ "model": "user.class", "pk": 1, "fields": { - "name": "Class 101", + "name_plain": "Class 101", "teacher": 1, - "access_code": "AB123", + "access_code_plain": "AB123", "classmates_data_viewable": true, "always_accept_requests": true, "accept_requests_until": null, @@ -308,9 +309,9 @@ "model": "user.class", "pk": 2, "fields": { - "name": "Class 102", + "name_plain": "Class 102", "teacher": 2, - "access_code": "AB124", + "access_code_plain": "AB124", "classmates_data_viewable": true, "always_accept_requests": true, "accept_requests_until": null, @@ -323,9 +324,9 @@ "model": "user.class", "pk": 3, "fields": { - "name": "Class 103", + "name_plain": "Class 103", "teacher": 2, - "access_code": "AB125", + "access_code_plain": "AB125", "classmates_data_viewable": true, "always_accept_requests": true, "accept_requests_until": null, @@ -338,9 +339,9 @@ "model": "user.class", "pk": 4, "fields": { - "name": "Young Coders 101", + "name_plain": "Young Coders 101", "teacher": 3, - "access_code": "RL123", + "access_code_plain": "RL123", "classmates_data_viewable": true, "always_accept_requests": true, "accept_requests_until": null, @@ -353,9 +354,9 @@ "model": "user.class", "pk": 5, "fields": { - "name": "Portaladmin's class", + "name_plain": "Portaladmin's class", "teacher": 4, - "access_code": "PO123", + "access_code_plain": "PO123", "classmates_data_viewable": true, "always_accept_requests": true, "accept_requests_until": null, @@ -571,13 +572,14 @@ "model": "user.user", "pk": 1, "fields": { + "dek": "ZmFrZV9lbmM6UkZkTlNrYzVhR05aVlZwRVZFaFlTMHhUVTJnMlVVbzNORVZUZERjeFIxTT0=", "password": "pbkdf2_sha256$1000000$y2lUeK8u5GnocWVuonlViT$TnNTL7sG9pcvz6MotXSSptrHdQzAFxmLnqNbj7syLS4=", "last_login": null, "is_superuser": true, - "username": "codeforlife-portal@ocado.com", - "first_name": "Portal", - "last_name": "Admin", - "email": "codeforlife-portal@ocado.com", + "username_plain": "codeforlife-portal@ocado.com", + "first_name_plain": "Portal", + "last_name_plain": "Admin", + "email_plain": "codeforlife-portal@ocado.com", "is_staff": true, "is_active": true, "date_joined": "2026-02-04T16:02:33.631Z", @@ -589,13 +591,14 @@ "model": "user.user", "pk": 2, "fields": { + "dek": "ZmFrZV9lbmM6VlZkaFpuUlZha2hNUjJGTU5XSlNaR3hsVUhFMVkzSjBNR1l4TUZwa1IxQT0=", "password": "pbkdf2_sha256$1000000$IOkFLrqDnlDiEoASMl5OSY$C7gzJeOhwbfq/nJp47LaErHOxgA/3DIkaUI93ZUqauc=", "last_login": null, "is_superuser": false, - "username": "alberteinstein@codeforlife.com", - "first_name": "Albert", - "last_name": "Einstein", - "email": "alberteinstein@codeforlife.com", + "username_plain": "alberteinstein@codeforlife.com", + "first_name_plain": "Albert", + "last_name_plain": "Einstein", + "email_plain": "alberteinstein@codeforlife.com", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:34.051Z", @@ -607,13 +610,14 @@ "model": "user.user", "pk": 3, "fields": { + "dek": "ZmFrZV9lbmM6ZDIwMmQyaGFNMlJHWWxNM2EyaEVaVlZUTXpGbFRsZEhUMjlrTnpoMmRUaz0=", "password": "pbkdf2_sha256$1000000$QHwukC6Cv822F2nSjQjLu0$09yKE5c8mFVwIHS5BWccM+q33Rs6XSCcc5rik3RD7eY=", "last_login": null, "is_superuser": false, - "username": "maxplanck@codeforlife.com", - "first_name": "Max", - "last_name": "Planck", - "email": "maxplanck@codeforlife.com", + "username_plain": "maxplanck@codeforlife.com", + "first_name_plain": "Max", + "last_name_plain": "Planck", + "email_plain": "maxplanck@codeforlife.com", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:34.252Z", @@ -625,13 +629,14 @@ "model": "user.user", "pk": 4, "fields": { + "dek": "ZmFrZV9lbmM6WW5CTGJHRnBSMDVrVGt4eVFWbFljMDU2VjJwM1dqWmlia2xPUlhwVVdqVT0=", "password": "pbkdf2_sha256$1000000$OcLHQ75ALkslmiUxFwe1Ft$7hpkII5kIi1lytO4PQ0p/6HdtT6//mPcZfZNjROXqrg=", "last_login": null, "is_superuser": false, - "username": "ramleith@codeforlife.com", - "first_name": "Ram", - "last_name": "Leith", - "email": "ramleith@codeforlife.com", + "username_plain": "ramleith@codeforlife.com", + "first_name_plain": "Ram", + "last_name_plain": "Leith", + "email_plain": "ramleith@codeforlife.com", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:34.448Z", @@ -643,13 +648,14 @@ "model": "user.user", "pk": 5, "fields": { + "dek": "ZmFrZV9lbmM6ZVhsRFZWQkRXWEJVVWxvM1QzbDJhM05yV0VkblIyUldRVlpHVG5CVVRqST0=", "password": "pbkdf2_sha256$1000000$6QiofvSo5LPJ0DlGshTh0h$XdFmFi/Al5vuhxw02nlZE/rUG8g2uHxFnHXs46XgT/c=", "last_login": null, "is_superuser": false, - "username": "leonardodavinci@codeforlife.com", - "first_name": "Leonardo", - "last_name": "DaVinci", - "email": "leonardodavinci@codeforlife.com", + "username_plain": "leonardodavinci@codeforlife.com", + "first_name_plain": "Leonardo", + "last_name_plain": "DaVinci", + "email_plain": "leonardodavinci@codeforlife.com", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:34.641Z", @@ -661,13 +667,14 @@ "model": "user.user", "pk": 6, "fields": { + "dek": "ZmFrZV9lbmM6ZW10aVZtbHVibWxpYXpKSmMxUlhWWHBzTmsxMFRYcHZkblJOV1hGUGJERT0=", "password": "pbkdf2_sha256$1000000$HF1HBQVpMLdAuYb0vt55r1$gY3+SetJqidFfylntSqmmrtremtNk8QuGeAdlHNqD9A=", "last_login": null, "is_superuser": false, - "username": "galileogalilei@codeforlife.com", - "first_name": "Galileo", - "last_name": "Galilei", - "email": "galileogalilei@codeforlife.com", + "username_plain": "galileogalilei@codeforlife.com", + "first_name_plain": "Galileo", + "last_name_plain": "Galilei", + "email_plain": "galileogalilei@codeforlife.com", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:34.839Z", @@ -679,13 +686,14 @@ "model": "user.user", "pk": 7, "fields": { + "dek": "ZmFrZV9lbmM6VGtWVWVHeFlXSEpaUTBKcGJFVk9TV3hYWkhCSmRtbERRa2h3WmtKQ01FUT0=", "password": "pbkdf2_sha256$1000000$BUSt8V8F4DwWZPlCZeS1Sc$WMQElSAhvIE7LRHx6lwTde9YG5P74dc7Az+639AHdYc=", "last_login": null, "is_superuser": false, - "username": "isaacnewton@codeforlife.com", - "first_name": "Isaac", - "last_name": "Newton", - "email": "isaacnewton@codeforlife.com", + "username_plain": "isaacnewton@codeforlife.com", + "first_name_plain": "Isaac", + "last_name_plain": "Newton", + "email_plain": "isaacnewton@codeforlife.com", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:35.036Z", @@ -697,13 +705,14 @@ "model": "user.user", "pk": 8, "fields": { + "dek": "ZmFrZV9lbmM6U1ZCNFNVbFRkR1JvY2xodmRGSXpOM0ZrYmpsb2MycGFXblZTTVZaemFWWT0=", "password": "pbkdf2_sha256$1000000$TaYcwU3iqPA4c4co14ZYHx$FAwypkSE7UucZod6BY/YY+A4zV4JbySNxDwd4ypOZT8=", "last_login": null, "is_superuser": false, - "username": "richardfeynman@codeforlife.com", - "first_name": "Richard", - "last_name": "Feynman", - "email": "richardfeynman@codeforlife.com", + "username_plain": "richardfeynman@codeforlife.com", + "first_name_plain": "Richard", + "last_name_plain": "Feynman", + "email_plain": "richardfeynman@codeforlife.com", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:35.230Z", @@ -715,13 +724,14 @@ "model": "user.user", "pk": 9, "fields": { + "dek": "ZmFrZV9lbmM6YVc5eFlYcHVjMWsxVEZWVFRXaFRRVFpEVkhWMWNUZG9abmR5UVRCT2RERT0=", "password": "pbkdf2_sha256$1000000$mCuu3Ptx9EGmKQ7FSNIqzw$dqwQK/ovljow4iGxfLIQTm8PPR8Iu5AITzQ9tVQxv9w=", "last_login": null, "is_superuser": false, - "username": "alexanderflemming@codeforlife.com", - "first_name": "Alexander", - "last_name": "Flemming", - "email": "alexanderflemming@codeforlife.com", + "username_plain": "alexanderflemming@codeforlife.com", + "first_name_plain": "Alexander", + "last_name_plain": "Flemming", + "email_plain": "alexanderflemming@codeforlife.com", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:35.422Z", @@ -733,13 +743,14 @@ "model": "user.user", "pk": 10, "fields": { + "dek": "ZmFrZV9lbmM6WjJscWNqWlFRbEZCVkZCaGVsRTRWVFl3ZDBWRFltbEROM2x4UXpCQmVGUT0=", "password": "pbkdf2_sha256$1000000$isUqjEe0ZtdHC0tDFilALA$k20Gy//4APEqsGNYGa8aQPUTUBqMPKe6hI5GMIWiMnE=", "last_login": null, "is_superuser": false, - "username": "danielbernoulli@codeforlife.com", - "first_name": "Daniel", - "last_name": "Bernoulli", - "email": "danielbernoulli@codeforlife.com", + "username_plain": "danielbernoulli@codeforlife.com", + "first_name_plain": "Daniel", + "last_name_plain": "Bernoulli", + "email_plain": "danielbernoulli@codeforlife.com", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:35.611Z", @@ -751,13 +762,14 @@ "model": "user.user", "pk": 11, "fields": { + "dek": "ZmFrZV9lbmM6UW5KQ2JUYzFSRU54VVhCd05rRmxka2w0ZVRoMFdGWkhhbEpCUzBnMGRuWT0=", "password": "pbkdf2_sha256$1000000$mjbmbYr7G3WRyxxHlDcH6d$w1wf3D0lDP6pF3nSoTUD3vSRioCzPEloW3r8sXnLSJw=", "last_login": null, "is_superuser": false, - "username": "indianajones@codeforlife.com", - "first_name": "Indiana", - "last_name": "Jones", - "email": "indianajones@codeforlife.com", + "username_plain": "indianajones@codeforlife.com", + "first_name_plain": "Indiana", + "last_name_plain": "Jones", + "email_plain": "indianajones@codeforlife.com", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:35.803Z", @@ -769,13 +781,14 @@ "model": "user.user", "pk": 12, "fields": { + "dek": "ZmFrZV9lbmM6TVhGU1NtSlhaVkpwYlRWWVR6QTNlVE5UV2xaNWRHNUlkbFJtVG1GMVJHVT0=", "password": "pbkdf2_sha256$1000000$qm1oQx3HNeIx6dpY1aRzXb$xMKwoqXoW6MuHqoABww+Yq+3A1uwkLbkpaWWytNu6l0=", "last_login": null, "is_superuser": false, - "username": "media noah", - "first_name": "Noah", - "last_name": "Monaghan", - "email": "", + "username_plain": "media noah", + "first_name_plain": "Noah", + "last_name_plain": "Monaghan", + "email_plain": "", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:35.999Z", @@ -787,13 +800,14 @@ "model": "user.user", "pk": 13, "fields": { + "dek": "ZmFrZV9lbmM6ZFZwVmRGbHpTVkoxTkVaMU5XRk9WMDlwV25CS1EzVlRaR28yV2tac1QzVT0=", "password": "pbkdf2_sha256$1000000$vQZKG1iO3Np3cdOHSrG85F$QnuZLe1KvMt64uWXjz3RKOEnr6owhloxfgs3u8xB6GQ=", "last_login": null, "is_superuser": false, - "username": "media elliot", - "first_name": "Elliot", - "last_name": "Sharp", - "email": "", + "username_plain": "media elliot", + "first_name_plain": "Elliot", + "last_name_plain": "Sharp", + "email_plain": "", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:36.195Z", @@ -805,13 +819,14 @@ "model": "user.user", "pk": 14, "fields": { + "dek": "ZmFrZV9lbmM6UW1sWlVsSjNNM0JHVW1Sd1puQkNaVU4xUkdSVGVHRk1kV2x5ZFRoUGNFST0=", "password": "pbkdf2_sha256$1000000$iCO3IsvmN5C8rpX5J0d1ob$TFK2zYume/i4StDly6EUiqFej/sOnuzV/wYE9rgr/cg=", "last_login": null, "is_superuser": false, - "username": "media tajmae", - "first_name": "Tajmae", - "last_name": "Joseph", - "email": "", + "username_plain": "media tajmae", + "first_name_plain": "Tajmae", + "last_name_plain": "Joseph", + "email_plain": "", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:36.394Z", @@ -823,13 +838,14 @@ "model": "user.user", "pk": 15, "fields": { + "dek": "ZmFrZV9lbmM6ZEhWRlUxSjFZMUpxZFdoaU5XTnNiWEpqZVdSUVdFdGtOVWhFVkVGVWRrND0=", "password": "pbkdf2_sha256$1000000$6Nhm1sHtcMbFdBitdz7nxq$s5i8H7MwdH6SRQ3+DZDDfsPtqxXVDPAC5t67G4FluM4=", "last_login": null, "is_superuser": false, - "username": "media carlton", - "first_name": "Carlton", - "last_name": "Joseph", - "email": "", + "username_plain": "media carlton", + "first_name_plain": "Carlton", + "last_name_plain": "Joseph", + "email_plain": "", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:36.589Z", @@ -841,13 +857,14 @@ "model": "user.user", "pk": 16, "fields": { + "dek": "ZmFrZV9lbmM6TW5SM1FUQnNUMlJ2VFU1RFQwUllVSEp1VFc1aWQxSmxOWHBqVDJKeFZtTT0=", "password": "pbkdf2_sha256$1000000$FRA5lx8xBTO5Rn8J9wwZZW$KiQzLItBM40bzbwRdXpKnrI1uWXv0xsSzTMkFwAewwo=", "last_login": null, "is_superuser": false, - "username": "media nadal", - "first_name": "Nadal", - "last_name": "Spencer-Jennings", - "email": "", + "username_plain": "media nadal", + "first_name_plain": "Nadal", + "last_name_plain": "Spencer-Jennings", + "email_plain": "", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:36.792Z", @@ -859,13 +876,14 @@ "model": "user.user", "pk": 17, "fields": { + "dek": "ZmFrZV9lbmM6UlZKbll6UjJhM1JPU2tvemRIQTVSRUZZYUZsMGFtOVZla05HYzI5bmQyaz0=", "password": "pbkdf2_sha256$1000000$LAQQycnLxi5rY9QCD15nHw$Ad5r4JEo5CCQrwuD2F12ajXnV/bDSL+FGQ99vM2l0/k=", "last_login": null, "is_superuser": false, - "username": "media freddie", - "first_name": "Freddie", - "last_name": "Goff", - "email": "", + "username_plain": "media freddie", + "first_name_plain": "Freddie", + "last_name_plain": "Goff", + "email_plain": "", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:37.009Z", @@ -877,13 +895,14 @@ "model": "user.user", "pk": 18, "fields": { + "dek": "ZmFrZV9lbmM6YzIxdGRIbzVRbEkyVlRKdVRVVk1lSFE1TmtRMFFXeFVUamRaYldWbFpFUT0=", "password": "pbkdf2_sha256$1000000$XHhBEg6yrF9a6TR487gNrU$DOR6TONr+9q0rLXJwB43UuezRcQkQAmEmoN0nYmm4do=", "last_login": null, "is_superuser": false, - "username": "media leon", - "first_name": "Leon", - "last_name": "Scott", - "email": "", + "username_plain": "media leon", + "first_name_plain": "Leon", + "last_name_plain": "Scott", + "email_plain": "", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:37.216Z", @@ -895,13 +914,14 @@ "model": "user.user", "pk": 19, "fields": { + "dek": "ZmFrZV9lbmM6T0VoaU5rRTVWalZTVUVkbVpVTlllRmhOVlVoVVZVRnVUR1pvVVU5bVUybz0=", "password": "pbkdf2_sha256$1000000$ua0WUUCFIijIjxpLdkc25v$mvl+UejCNGop0krujly8TPlMbgWPFVZ42tH+NPDltS4=", "last_login": null, "is_superuser": false, - "username": "media betty", - "first_name": "Betty", - "last_name": "Kessell", - "email": "", + "username_plain": "media betty", + "first_name_plain": "Betty", + "last_name_plain": "Kessell", + "email_plain": "", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:37.413Z", @@ -913,13 +933,14 @@ "model": "user.user", "pk": 20, "fields": { + "dek": "ZmFrZV9lbmM6Y25SMVIwYzNNbXBVUWtkTk1IVmhjMUI2YVdWb2VtNVBaRUUyVFdsMldrcz0=", "password": "pbkdf2_sha256$1000000$o2AfZh0JqqIFRTZGHWEQKV$c1es5kdOakAvCGkeMVgDOX5q+rFhdQgOkMZiKAtqMrk=", "last_login": null, "is_superuser": false, - "username": "4271ee7b7ce94e34a58d1f4e82025280", - "first_name": "Deleted", - "last_name": "User", - "email": "", + "username_plain": "4271ee7b7ce94e34a58d1f4e82025280", + "first_name_plain": "Deleted", + "last_name_plain": "User", + "email_plain": "", "is_staff": false, "is_active": false, "date_joined": "2026-02-04T16:02:37.614Z", @@ -931,13 +952,14 @@ "model": "user.user", "pk": 21, "fields": { + "dek": "ZmFrZV9lbmM6WmtGRFlYQjBUek5OU1VjeGVXczNVbmRTVmxsRGRtOXlWREZzVFU5Q1dqQT0=", "password": "pbkdf2_sha256$1000000$cv54m0PSNCQrH0MBLU56pk$nhGzkqLXL01EEtLAsoAbaWcTxuj1SOrZ7s6rBdA7eZk=", "last_login": null, "is_superuser": false, - "username": "adminstudent@codeforlife.com", - "first_name": "Portaladmin", - "last_name": "Student", - "email": "adminstudent@codeforlife.com", + "username_plain": "adminstudent@codeforlife.com", + "first_name_plain": "Portaladmin", + "last_name_plain": "Student", + "email_plain": "adminstudent@codeforlife.com", "is_staff": false, "is_active": true, "date_joined": "2026-02-04T16:02:40.242Z", diff --git a/codeforlife/user/fixtures/non_school_teacher.json b/codeforlife/user/fixtures/non_school_teacher.json index 1bcc2b7b..8052e610 100644 --- a/codeforlife/user/fixtures/non_school_teacher.json +++ b/codeforlife/user/fixtures/non_school_teacher.json @@ -3,10 +3,11 @@ "model": "user.user", "pk": 22, "fields": { - "first_name": "John", - "last_name": "Doe", - "username": "teacher@noschool.com", - "email": "teacher@noschool.com", + "dek": "ZmFrZV9lbmM6TkhGdE1EWlFOVkJ3VDFwTVVYWmlVRzlFYVdwU1JIUTVialZ0V1RoQ2NXYz0=", + "first_name_plain": "John", + "last_name_plain": "Doe", + "username_plain": "teacher@noschool.com", + "email_plain": "teacher@noschool.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -30,10 +31,11 @@ "model": "user.user", "pk": 33, "fields": { - "first_name": "Unverified", - "last_name": "Teacher", - "username": "unverified.teacher@noschool.com", - "email": "unverified.teacher@noschool.com", + "dek": "ZmFrZV9lbmM6UVhac01Ea3dibmQxYzBkRFdVbGFRMDR4VEZkelRVWklUbHA1VVZsU1RXOD0=", + "first_name_plain": "Unverified", + "last_name_plain": "Teacher", + "username_plain": "unverified.teacher@noschool.com", + "email_plain": "unverified.teacher@noschool.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, diff --git a/codeforlife/user/fixtures/school_1.json b/codeforlife/user/fixtures/school_1.json index 444fa023..467914c4 100644 --- a/codeforlife/user/fixtures/school_1.json +++ b/codeforlife/user/fixtures/school_1.json @@ -3,7 +3,8 @@ "model": "user.school", "pk": 2, "fields": { - "name": "School 1", + "dek": "ZmFrZV9lbmM6V1cxTVdtMXFibTgyVlhadGVFTlRla1p2ZDBOM2QzVnVSMEZ3WkdGeVpsQT0=", + "name_plain": "School 1", "country": "GB", "county": "Hertfordshire" } @@ -12,10 +13,11 @@ "model": "user.user", "pk": 23, "fields": { - "first_name": "John", - "last_name": "Doe", - "username": "teacher@school1.com", - "email": "teacher@school1.com", + "dek": "ZmFrZV9lbmM6U2tGVmEzZHhlVkF4VERKR1QwaGxPVWRTWkhkVE16WTBiREV3WjFkQlVHcz0=", + "first_name_plain": "John", + "last_name_plain": "Doe", + "username_plain": "teacher@school1.com", + "email_plain": "teacher@school1.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -40,8 +42,8 @@ "model": "user.class", "pk": 6, "fields": { - "name": "Class 1 @ School 1", - "access_code": "ZZ111", + "name_plain": "Class 1 @ School 1", + "access_code_plain": "ZZ111", "teacher": 6, "accept_requests_until": "9999-02-09 20:26:08.298402+00:00" } @@ -50,8 +52,9 @@ "model": "user.user", "pk": 27, "fields": { - "first_name": "Student1", - "username": "111111111111111111111111111111", + "dek": "ZmFrZV9lbmM6ZEc1aFZ6RkhVRTFNVFdaSWQzTlZXRmxRZFRsTFZIbE1kRWxSVGs1Q1FWQT0=", + "first_name_plain": "Student1", + "username_plain": "111111111111111111111111111111", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -76,10 +79,11 @@ "model": "user.user", "pk": 24, "fields": { - "first_name": "Jane", - "last_name": "Doe", - "username": "admin.teacher@school1.com", - "email": "admin.teacher@school1.com", + "dek": "ZmFrZV9lbmM6YkVGQk1XVTBVWFJqTWpFelFVMWhTVE5EUWtWeVMzZGpjR1JNU2xSTVlrUT0=", + "first_name_plain": "Jane", + "last_name_plain": "Doe", + "username_plain": "admin.teacher@school1.com", + "email_plain": "admin.teacher@school1.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -105,8 +109,8 @@ "model": "user.class", "pk": 7, "fields": { - "name": "Class 2 @ School 1", - "access_code": "ZZ222", + "name_plain": "Class 2 @ School 1", + "access_code_plain": "ZZ222", "teacher": 7 } }, @@ -114,8 +118,9 @@ "model": "user.user", "pk": 29, "fields": { - "first_name": "Student2", - "username": "222222222222222222222222222222", + "dek": "ZmFrZV9lbmM6YXpWd1dGZEZOWHBXZEdnMFJWQlROWFZFV2s0eVRuVTBOVTlGZUdwNWJtND0=", + "first_name_plain": "Student2", + "username_plain": "222222222222222222222222222222", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -140,8 +145,8 @@ "model": "user.class", "pk": 10, "fields": { - "name": "Class 3 @ School 1", - "access_code": "ZZ333", + "name_plain": "Class 3 @ School 1", + "access_code_plain": "ZZ333", "teacher": 7, "accept_requests_until": "2023-02-09 20:26:08.298402+00:00" } diff --git a/codeforlife/user/fixtures/school_2.json b/codeforlife/user/fixtures/school_2.json index 3c3b7a07..d01916c1 100644 --- a/codeforlife/user/fixtures/school_2.json +++ b/codeforlife/user/fixtures/school_2.json @@ -3,7 +3,8 @@ "model": "user.school", "pk": 3, "fields": { - "name": "School 2", + "dek": "ZmFrZV9lbmM6VlUxaWN6TTVhbk56WW5wcFpIVXhhVlE1UW10WGFVcHpUa3BFT0VNNVdtYz0=", + "name_plain": "School 2", "country": "GB", "county": "Hertfordshire" } @@ -12,10 +13,11 @@ "model": "user.user", "pk": 25, "fields": { - "first_name": "John", - "last_name": "Doe", - "username": "teacher@school2.com", - "email": "teacher@school2.com", + "dek": "ZmFrZV9lbmM6UkhWQ1kwaHlOa2hqYzFSV1MxcE1SblkwYnpnMGRtNUxWMnRLV1dWMlRUYz0=", + "first_name_plain": "John", + "last_name_plain": "Doe", + "username_plain": "teacher@school2.com", + "email_plain": "teacher@school2.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -129,8 +131,8 @@ "model": "user.class", "pk": 8, "fields": { - "name": "Class 1 @ School 2", - "access_code": "XX111", + "name_plain": "Class 1 @ School 2", + "access_code_plain": "XX111", "teacher": 8 } }, @@ -138,10 +140,11 @@ "model": "user.user", "pk": 26, "fields": { - "first_name": "Jane", - "last_name": "Doe", - "username": "admin.teacher@school2.com", - "email": "admin.teacher@school2.com", + "dek": "ZmFrZV9lbmM6WkdoS2FsQnNjR3R4UmtaSFdrOVJTa2huVW1wa1lVcDVhRVJ2Ulhkd2RuYz0=", + "first_name_plain": "Jane", + "last_name_plain": "Doe", + "username_plain": "admin.teacher@school2.com", + "email_plain": "admin.teacher@school2.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -176,8 +179,8 @@ "model": "user.class", "pk": 9, "fields": { - "name": "Class 2 @ School 2", - "access_code": "XX222", + "name_plain": "Class 2 @ School 2", + "access_code_plain": "XX222", "teacher": 9 } } diff --git a/codeforlife/user/fixtures/school_3.json b/codeforlife/user/fixtures/school_3.json index bbd1ec10..f9d8ce84 100644 --- a/codeforlife/user/fixtures/school_3.json +++ b/codeforlife/user/fixtures/school_3.json @@ -3,7 +3,8 @@ "model": "user.school", "pk": 4, "fields": { - "name": "School 3", + "dek": "ZmFrZV9lbmM6Vm1ORlNIbENURmRyZEdOR1VGaHFOMFZIVFhkRE1IQjVNR1oyTkdkVFNIRT0=", + "name_plain": "School 3", "country": "GB", "county": "Hertfordshire" } @@ -12,10 +13,11 @@ "model": "user.user", "pk": 31, "fields": { - "first_name": "Peter", - "last_name": "Parker", - "username": "admin.teacher@school3.com", - "email": "admin.teacher@school3.com", + "dek": "ZmFrZV9lbmM6ZGxKVE4xTm5jRGhKT1ZWVVdqWlpPVUpDUm14V1EycDRNbkYxZUc1aVExbz0=", + "first_name_plain": "Peter", + "last_name_plain": "Parker", + "username_plain": "admin.teacher@school3.com", + "email_plain": "admin.teacher@school3.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -41,10 +43,11 @@ "model": "user.user", "pk": 32, "fields": { - "first_name": "Doctor", - "last_name": "Octopus", - "username": "teacher@school3.com", - "email": "teacher@school3.com", + "dek": "ZmFrZV9lbmM6VjA5cGVVcG1SR1pRTkdoNVptUXhOR1Z0ZEVOblVtUmtjRWhJUlZob01Faz0=", + "first_name_plain": "Doctor", + "last_name_plain": "Octopus", + "username_plain": "teacher@school3.com", + "email_plain": "teacher@school3.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index 23a3552f..aeb43a1d 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -93,7 +93,7 @@ class Meta(TypedModelMeta): @property def dek_aead(self): - return self.user.userprofile.dek_aead # type: ignore[attr-defined] + return self.user.dek_aead # type: ignore[attr-defined] def save(self, *args, **kwargs): raise IntegrityError("Cannot create or update a single instance.") From 7fbf0ff306c62711bc74c04c93b685222a4a2baa Mon Sep 17 00:00:00 2001 From: SKairinos Date: Fri, 13 Mar 2026 13:59:56 +0000 Subject: [PATCH 41/58] remove username --- codeforlife/hashers.py | 25 +++++ codeforlife/user/auth/backends/email.py | 11 +- codeforlife/user/fixtures/google_users.json | 1 - codeforlife/user/fixtures/independent.json | 2 - codeforlife/user/fixtures/legacy.json | 21 ---- .../user/fixtures/non_school_teacher.json | 2 - codeforlife/user/fixtures/school_1.json | 4 - codeforlife/user/fixtures/school_2.json | 2 - codeforlife/user/fixtures/school_3.json | 2 - .../0003_client_side_encryption_part_1.py | 14 ++- codeforlife/user/models/user/contactable.py | 4 +- codeforlife/user/models/user/user.py | 104 ++++++++++-------- codeforlife/user/views/user_test.py | 2 +- 13 files changed, 110 insertions(+), 84 deletions(-) create mode 100644 codeforlife/hashers.py diff --git a/codeforlife/hashers.py b/codeforlife/hashers.py new file mode 100644 index 00000000..984f9fd3 --- /dev/null +++ b/codeforlife/hashers.py @@ -0,0 +1,25 @@ +""" +© Ocado Group +Created on 19/01/2026 at 09:55:44(+00:00). +""" + +import hashlib +import hmac + +from django.conf import settings + + +def hash_email(email: str): + """Create a consistent, salted hash of an email address. + + Args: + email: The email address to hash. + + Returns: + A hash of the email address salted with the Django secret key. + """ + return hmac.new( + key=settings.SECRET_KEY.encode("utf-8"), + msg=email.encode("utf-8"), + digestmod=hashlib.sha256, + ).hexdigest() diff --git a/codeforlife/user/auth/backends/email.py b/codeforlife/user/auth/backends/email.py index 58f215e7..2cbc2645 100644 --- a/codeforlife/user/auth/backends/email.py +++ b/codeforlife/user/auth/backends/email.py @@ -5,6 +5,7 @@ import typing as t +from ....hashers import hash_email from ....request import HttpRequest from .base import BaseBackend @@ -22,13 +23,17 @@ def authenticate( # type: ignore[override] if email is None or password is None: return None + email = self.user_class.objects.normalize_email(email) + email_hash = hash_email(email) # type: ignore[arg-type] + # pylint: disable=duplicate-code try: - user = self.user_class.objects.get(email__iexact=email) - if user.check_password(password): - return user + user = self.user_class.objects.get(email_hash=email_hash) except self.user_class.DoesNotExist: return None # pylint: enable=duplicate-code + if user.check_password(password): + return user + return None diff --git a/codeforlife/user/fixtures/google_users.json b/codeforlife/user/fixtures/google_users.json index 394fa89e..4cbfce61 100644 --- a/codeforlife/user/fixtures/google_users.json +++ b/codeforlife/user/fixtures/google_users.json @@ -6,7 +6,6 @@ "dek": "ZmFrZV9lbmM6YlVkTE4xSndaRlZTTW5kc1NqQkJXa1ZxU2xKV2NWTk9WbFJ6YzFjMmVHVT0=", "first_name_plain": "Google", "last_name_plain": "Teacher", - "username_plain": "google.teacher@noschool.com", "email_plain": "google.teacher@noschool.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } diff --git a/codeforlife/user/fixtures/independent.json b/codeforlife/user/fixtures/independent.json index bcdf4d51..cd2df09c 100644 --- a/codeforlife/user/fixtures/independent.json +++ b/codeforlife/user/fixtures/independent.json @@ -6,7 +6,6 @@ "dek": "ZmFrZV9lbmM6Ym5oYVFqaHhUMDV4VTI4NE9YVk9TbmxvVkVzemRUWnpkMUpTWjFkMFVFRT0=", "first_name_plain": "Indy", "last_name_plain": "Requester", - "username_plain": "indy.requester@email.com", "email_plain": "indy.requester@email.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -35,7 +34,6 @@ "dek": "ZmFrZV9lbmM6TTIxbFVXMWlWMnR3VXpOa2JXaENlbkJGUW05a1oyZEpkRzV6YlhGa1pVYz0=", "first_name_plain": "Indy", "last_name_plain": "NoRequest", - "username_plain": "indy@email.com", "email_plain": "indy@email.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json index bc63ec31..6776fbf1 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -576,7 +576,6 @@ "password": "pbkdf2_sha256$1000000$y2lUeK8u5GnocWVuonlViT$TnNTL7sG9pcvz6MotXSSptrHdQzAFxmLnqNbj7syLS4=", "last_login": null, "is_superuser": true, - "username_plain": "codeforlife-portal@ocado.com", "first_name_plain": "Portal", "last_name_plain": "Admin", "email_plain": "codeforlife-portal@ocado.com", @@ -595,7 +594,6 @@ "password": "pbkdf2_sha256$1000000$IOkFLrqDnlDiEoASMl5OSY$C7gzJeOhwbfq/nJp47LaErHOxgA/3DIkaUI93ZUqauc=", "last_login": null, "is_superuser": false, - "username_plain": "alberteinstein@codeforlife.com", "first_name_plain": "Albert", "last_name_plain": "Einstein", "email_plain": "alberteinstein@codeforlife.com", @@ -614,7 +612,6 @@ "password": "pbkdf2_sha256$1000000$QHwukC6Cv822F2nSjQjLu0$09yKE5c8mFVwIHS5BWccM+q33Rs6XSCcc5rik3RD7eY=", "last_login": null, "is_superuser": false, - "username_plain": "maxplanck@codeforlife.com", "first_name_plain": "Max", "last_name_plain": "Planck", "email_plain": "maxplanck@codeforlife.com", @@ -633,7 +630,6 @@ "password": "pbkdf2_sha256$1000000$OcLHQ75ALkslmiUxFwe1Ft$7hpkII5kIi1lytO4PQ0p/6HdtT6//mPcZfZNjROXqrg=", "last_login": null, "is_superuser": false, - "username_plain": "ramleith@codeforlife.com", "first_name_plain": "Ram", "last_name_plain": "Leith", "email_plain": "ramleith@codeforlife.com", @@ -652,7 +648,6 @@ "password": "pbkdf2_sha256$1000000$6QiofvSo5LPJ0DlGshTh0h$XdFmFi/Al5vuhxw02nlZE/rUG8g2uHxFnHXs46XgT/c=", "last_login": null, "is_superuser": false, - "username_plain": "leonardodavinci@codeforlife.com", "first_name_plain": "Leonardo", "last_name_plain": "DaVinci", "email_plain": "leonardodavinci@codeforlife.com", @@ -671,7 +666,6 @@ "password": "pbkdf2_sha256$1000000$HF1HBQVpMLdAuYb0vt55r1$gY3+SetJqidFfylntSqmmrtremtNk8QuGeAdlHNqD9A=", "last_login": null, "is_superuser": false, - "username_plain": "galileogalilei@codeforlife.com", "first_name_plain": "Galileo", "last_name_plain": "Galilei", "email_plain": "galileogalilei@codeforlife.com", @@ -690,7 +684,6 @@ "password": "pbkdf2_sha256$1000000$BUSt8V8F4DwWZPlCZeS1Sc$WMQElSAhvIE7LRHx6lwTde9YG5P74dc7Az+639AHdYc=", "last_login": null, "is_superuser": false, - "username_plain": "isaacnewton@codeforlife.com", "first_name_plain": "Isaac", "last_name_plain": "Newton", "email_plain": "isaacnewton@codeforlife.com", @@ -709,7 +702,6 @@ "password": "pbkdf2_sha256$1000000$TaYcwU3iqPA4c4co14ZYHx$FAwypkSE7UucZod6BY/YY+A4zV4JbySNxDwd4ypOZT8=", "last_login": null, "is_superuser": false, - "username_plain": "richardfeynman@codeforlife.com", "first_name_plain": "Richard", "last_name_plain": "Feynman", "email_plain": "richardfeynman@codeforlife.com", @@ -728,7 +720,6 @@ "password": "pbkdf2_sha256$1000000$mCuu3Ptx9EGmKQ7FSNIqzw$dqwQK/ovljow4iGxfLIQTm8PPR8Iu5AITzQ9tVQxv9w=", "last_login": null, "is_superuser": false, - "username_plain": "alexanderflemming@codeforlife.com", "first_name_plain": "Alexander", "last_name_plain": "Flemming", "email_plain": "alexanderflemming@codeforlife.com", @@ -747,7 +738,6 @@ "password": "pbkdf2_sha256$1000000$isUqjEe0ZtdHC0tDFilALA$k20Gy//4APEqsGNYGa8aQPUTUBqMPKe6hI5GMIWiMnE=", "last_login": null, "is_superuser": false, - "username_plain": "danielbernoulli@codeforlife.com", "first_name_plain": "Daniel", "last_name_plain": "Bernoulli", "email_plain": "danielbernoulli@codeforlife.com", @@ -766,7 +756,6 @@ "password": "pbkdf2_sha256$1000000$mjbmbYr7G3WRyxxHlDcH6d$w1wf3D0lDP6pF3nSoTUD3vSRioCzPEloW3r8sXnLSJw=", "last_login": null, "is_superuser": false, - "username_plain": "indianajones@codeforlife.com", "first_name_plain": "Indiana", "last_name_plain": "Jones", "email_plain": "indianajones@codeforlife.com", @@ -785,7 +774,6 @@ "password": "pbkdf2_sha256$1000000$qm1oQx3HNeIx6dpY1aRzXb$xMKwoqXoW6MuHqoABww+Yq+3A1uwkLbkpaWWytNu6l0=", "last_login": null, "is_superuser": false, - "username_plain": "media noah", "first_name_plain": "Noah", "last_name_plain": "Monaghan", "email_plain": "", @@ -804,7 +792,6 @@ "password": "pbkdf2_sha256$1000000$vQZKG1iO3Np3cdOHSrG85F$QnuZLe1KvMt64uWXjz3RKOEnr6owhloxfgs3u8xB6GQ=", "last_login": null, "is_superuser": false, - "username_plain": "media elliot", "first_name_plain": "Elliot", "last_name_plain": "Sharp", "email_plain": "", @@ -823,7 +810,6 @@ "password": "pbkdf2_sha256$1000000$iCO3IsvmN5C8rpX5J0d1ob$TFK2zYume/i4StDly6EUiqFej/sOnuzV/wYE9rgr/cg=", "last_login": null, "is_superuser": false, - "username_plain": "media tajmae", "first_name_plain": "Tajmae", "last_name_plain": "Joseph", "email_plain": "", @@ -842,7 +828,6 @@ "password": "pbkdf2_sha256$1000000$6Nhm1sHtcMbFdBitdz7nxq$s5i8H7MwdH6SRQ3+DZDDfsPtqxXVDPAC5t67G4FluM4=", "last_login": null, "is_superuser": false, - "username_plain": "media carlton", "first_name_plain": "Carlton", "last_name_plain": "Joseph", "email_plain": "", @@ -861,7 +846,6 @@ "password": "pbkdf2_sha256$1000000$FRA5lx8xBTO5Rn8J9wwZZW$KiQzLItBM40bzbwRdXpKnrI1uWXv0xsSzTMkFwAewwo=", "last_login": null, "is_superuser": false, - "username_plain": "media nadal", "first_name_plain": "Nadal", "last_name_plain": "Spencer-Jennings", "email_plain": "", @@ -880,7 +864,6 @@ "password": "pbkdf2_sha256$1000000$LAQQycnLxi5rY9QCD15nHw$Ad5r4JEo5CCQrwuD2F12ajXnV/bDSL+FGQ99vM2l0/k=", "last_login": null, "is_superuser": false, - "username_plain": "media freddie", "first_name_plain": "Freddie", "last_name_plain": "Goff", "email_plain": "", @@ -899,7 +882,6 @@ "password": "pbkdf2_sha256$1000000$XHhBEg6yrF9a6TR487gNrU$DOR6TONr+9q0rLXJwB43UuezRcQkQAmEmoN0nYmm4do=", "last_login": null, "is_superuser": false, - "username_plain": "media leon", "first_name_plain": "Leon", "last_name_plain": "Scott", "email_plain": "", @@ -918,7 +900,6 @@ "password": "pbkdf2_sha256$1000000$ua0WUUCFIijIjxpLdkc25v$mvl+UejCNGop0krujly8TPlMbgWPFVZ42tH+NPDltS4=", "last_login": null, "is_superuser": false, - "username_plain": "media betty", "first_name_plain": "Betty", "last_name_plain": "Kessell", "email_plain": "", @@ -937,7 +918,6 @@ "password": "pbkdf2_sha256$1000000$o2AfZh0JqqIFRTZGHWEQKV$c1es5kdOakAvCGkeMVgDOX5q+rFhdQgOkMZiKAtqMrk=", "last_login": null, "is_superuser": false, - "username_plain": "4271ee7b7ce94e34a58d1f4e82025280", "first_name_plain": "Deleted", "last_name_plain": "User", "email_plain": "", @@ -956,7 +936,6 @@ "password": "pbkdf2_sha256$1000000$cv54m0PSNCQrH0MBLU56pk$nhGzkqLXL01EEtLAsoAbaWcTxuj1SOrZ7s6rBdA7eZk=", "last_login": null, "is_superuser": false, - "username_plain": "adminstudent@codeforlife.com", "first_name_plain": "Portaladmin", "last_name_plain": "Student", "email_plain": "adminstudent@codeforlife.com", diff --git a/codeforlife/user/fixtures/non_school_teacher.json b/codeforlife/user/fixtures/non_school_teacher.json index 8052e610..47c36da0 100644 --- a/codeforlife/user/fixtures/non_school_teacher.json +++ b/codeforlife/user/fixtures/non_school_teacher.json @@ -6,7 +6,6 @@ "dek": "ZmFrZV9lbmM6TkhGdE1EWlFOVkJ3VDFwTVVYWmlVRzlFYVdwU1JIUTVialZ0V1RoQ2NXYz0=", "first_name_plain": "John", "last_name_plain": "Doe", - "username_plain": "teacher@noschool.com", "email_plain": "teacher@noschool.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -34,7 +33,6 @@ "dek": "ZmFrZV9lbmM6UVhac01Ea3dibmQxYzBkRFdVbGFRMDR4VEZkelRVWklUbHA1VVZsU1RXOD0=", "first_name_plain": "Unverified", "last_name_plain": "Teacher", - "username_plain": "unverified.teacher@noschool.com", "email_plain": "unverified.teacher@noschool.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } diff --git a/codeforlife/user/fixtures/school_1.json b/codeforlife/user/fixtures/school_1.json index 467914c4..f814b4c4 100644 --- a/codeforlife/user/fixtures/school_1.json +++ b/codeforlife/user/fixtures/school_1.json @@ -16,7 +16,6 @@ "dek": "ZmFrZV9lbmM6U2tGVmEzZHhlVkF4VERKR1QwaGxPVWRTWkhkVE16WTBiREV3WjFkQlVHcz0=", "first_name_plain": "John", "last_name_plain": "Doe", - "username_plain": "teacher@school1.com", "email_plain": "teacher@school1.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -54,7 +53,6 @@ "fields": { "dek": "ZmFrZV9lbmM6ZEc1aFZ6RkhVRTFNVFdaSWQzTlZXRmxRZFRsTFZIbE1kRWxSVGs1Q1FWQT0=", "first_name_plain": "Student1", - "username_plain": "111111111111111111111111111111", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -82,7 +80,6 @@ "dek": "ZmFrZV9lbmM6YkVGQk1XVTBVWFJqTWpFelFVMWhTVE5EUWtWeVMzZGpjR1JNU2xSTVlrUT0=", "first_name_plain": "Jane", "last_name_plain": "Doe", - "username_plain": "admin.teacher@school1.com", "email_plain": "admin.teacher@school1.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -120,7 +117,6 @@ "fields": { "dek": "ZmFrZV9lbmM6YXpWd1dGZEZOWHBXZEdnMFJWQlROWFZFV2s0eVRuVTBOVTlGZUdwNWJtND0=", "first_name_plain": "Student2", - "username_plain": "222222222222222222222222222222", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, diff --git a/codeforlife/user/fixtures/school_2.json b/codeforlife/user/fixtures/school_2.json index d01916c1..e6c95198 100644 --- a/codeforlife/user/fixtures/school_2.json +++ b/codeforlife/user/fixtures/school_2.json @@ -16,7 +16,6 @@ "dek": "ZmFrZV9lbmM6UkhWQ1kwaHlOa2hqYzFSV1MxcE1SblkwYnpnMGRtNUxWMnRLV1dWMlRUYz0=", "first_name_plain": "John", "last_name_plain": "Doe", - "username_plain": "teacher@school2.com", "email_plain": "teacher@school2.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -143,7 +142,6 @@ "dek": "ZmFrZV9lbmM6WkdoS2FsQnNjR3R4UmtaSFdrOVJTa2huVW1wa1lVcDVhRVJ2Ulhkd2RuYz0=", "first_name_plain": "Jane", "last_name_plain": "Doe", - "username_plain": "admin.teacher@school2.com", "email_plain": "admin.teacher@school2.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } diff --git a/codeforlife/user/fixtures/school_3.json b/codeforlife/user/fixtures/school_3.json index f9d8ce84..c2013b3d 100644 --- a/codeforlife/user/fixtures/school_3.json +++ b/codeforlife/user/fixtures/school_3.json @@ -16,7 +16,6 @@ "dek": "ZmFrZV9lbmM6ZGxKVE4xTm5jRGhKT1ZWVVdqWlpPVUpDUm14V1EycDRNbkYxZUc1aVExbz0=", "first_name_plain": "Peter", "last_name_plain": "Parker", - "username_plain": "admin.teacher@school3.com", "email_plain": "admin.teacher@school3.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -46,7 +45,6 @@ "dek": "ZmFrZV9lbmM6VjA5cGVVcG1SR1pRTkdoNVptUXhOR1Z0ZEVOblVtUmtjRWhJUlZob01Faz0=", "first_name_plain": "Doctor", "last_name_plain": "Octopus", - "username_plain": "teacher@school3.com", "email_plain": "teacher@school3.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } diff --git a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py index f7686252..d55601ec 100644 --- a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py @@ -2,7 +2,7 @@ from codeforlife.models.fields.data_encryption_key import DataEncryptionKeyField from codeforlife.models.fields.encrypted_text import EncryptedTextField -from django.db import migrations +from django.db import migrations, models def rename_plain_text_fields_and_create_encrypted_text_fields( @@ -52,6 +52,17 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RemoveField( + model_name="user", + name="username", + ), + migrations.AddField( + model_name="user", + name="email_hash", + field=models.CharField( + blank=True, max_length=254, verbose_name="email hash" + ), + ), migrations.AddField( model_name="school", name="dek", @@ -78,7 +89,6 @@ class Migration(migrations.Migration): "first_name": "first name", "last_name": "last name", "email": "email address", - "username": "username", }, ), *rename_plain_text_fields_and_create_encrypted_text_fields( diff --git a/codeforlife/user/models/user/contactable.py b/codeforlife/user/models/user/contactable.py index b63c2104..db149136 100644 --- a/codeforlife/user/models/user/contactable.py +++ b/codeforlife/user/models/user/contactable.py @@ -23,7 +23,9 @@ # pylint: disable-next=missing-class-docstring,too-few-public-methods class ContactableUserManager(UserManager[AnyUser], t.Generic[AnyUser]): def filter_users(self, queryset: QuerySet[User]): - return queryset.exclude(email__isnull=True).exclude(email="") + return queryset.exclude(email_plain__isnull=True).exclude( + email_plain="" + ) # pylint: disable-next=too-many-ancestors diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 5dd4fb3f..d4ae3006 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -9,17 +9,16 @@ from datetime import datetime, timedelta from django.conf import settings - -# pylint: disable-next=imported-auth-user +from django.contrib.auth.hashers import make_password from django.contrib.auth.models import PermissionsMixin from django.contrib.auth.models import UserManager as _UserManager -from django.contrib.auth.validators import UnicodeUsernameValidator from django.db import models from django.db.models.query import QuerySet from django.utils import timezone from django.utils.translation import gettext_lazy as _ from pyotp import TOTP +from ....hashers import hash_email from ....models import AbstractBaseUser, DataEncryptionKeyModel from ....models.fields import EncryptedTextField from ....types import Validators @@ -61,6 +60,56 @@ class UserManager( encrypted manager to handle encrypted fields. """ + def _create_user_object( + self, + _: t.Literal[""], # username is not used but is required by the parent + email: str, + password: t.Optional[str], + **extra_fields, + ): + email = self.normalize_email(email) + user = self.model(email_plain=email, **extra_fields) + user.email_enc = email + user.email_hash = hash_email(email) + user.password = make_password(password) + return user + + # pylint: disable=missing-function-docstring + + @classmethod + def normalize_email(cls, email): + return super().normalize_email(email).lower() + + def create_user( # type: ignore[override] + self, email: str, password: t.Optional[str] = None, **extra_fields + ): + return super().create_user( + username="", email=email, password=password, **extra_fields + ) + + def acreate_user( # type: ignore[override] + self, email: str, password: t.Optional[str] = None, **extra_fields + ): + return super().acreate_user( + username="", email=email, password=password, **extra_fields + ) + + def create_superuser( # type: ignore[override] + self, email: str, password: t.Optional[str] = None, **extra_fields + ): + return super().create_superuser( + username="", email=email, password=password, **extra_fields + ) + + def acreate_superuser( # type: ignore[override] + self, email: str, password: t.Optional[str] = None, **extra_fields + ): + return super().acreate_superuser( + username="", email=email, password=password, **extra_fields + ) + + # pylint: enable=missing-function-docstring + def filter_users(self, queryset: QuerySet["User"]): """Filter the users to the specific type. @@ -84,12 +133,12 @@ def get_queryset(self): # pylint: disable-next=too-many-ancestors,too-many-instance-attributes class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): - """A proxy to Django's user class.""" + """A Code for Life user.""" associated_data = "user" EMAIL_FIELD = "email_plain" - USERNAME_FIELD = "username_plain" + USERNAME_FIELD = "email_hash" REQUIRED_FIELDS = ["email_plain"] _password: t.Optional[str] @@ -103,41 +152,6 @@ class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): credential_fields = frozenset(["email", "password"]) - # -------------------------------------------------------------------------- - # Username - # -------------------------------------------------------------------------- - - username_validator = UnicodeUsernameValidator() - username_plain = models.CharField( - _("username"), - max_length=150, - unique=True, - help_text=_( - "Required. 150 characters or fewer." - " Letters, digits and @/./+/-/_ only." - ), - validators=[username_validator], - error_messages={ - "unique": _("A user with that username already exists."), - }, - ) - username_enc = EncryptedTextField( - associated_data="username", null=True, verbose_name=_("username") - ) - - @property - def username(self): - """The user's username.""" - if self.username_enc is not None: - return self.username_enc - return self.username_plain - - @username.setter - def username(self, value: str): - """Set the user's username.""" - self.username_enc = value - self.username_plain = value - # -------------------------------------------------------------------------- # First name # -------------------------------------------------------------------------- @@ -190,6 +204,7 @@ def last_name(self, value: str): # Email # -------------------------------------------------------------------------- + email_hash = models.CharField(_("email hash"), max_length=254, blank=True) email_plain = models.EmailField(_("email address"), blank=True) email_enc = EncryptedTextField( associated_data="email", null=True, verbose_name=_("email address") @@ -205,8 +220,10 @@ def email(self): @email.setter def email(self, value: str): """Set the user's email address.""" - self.email_enc = value + value = self.objects.normalize_email(value) self.email_plain = value + self.email_enc = value + self.email_hash = hash_email(value) # -------------------------------------------------------------------------- # Other @@ -331,9 +348,10 @@ def as_type(self, user_class: t.Type["AnyUser"]): pk=self.pk, first_name=self.first_name, last_name=self.last_name, - username=self.username, is_active=self.is_active, - email=self.email, + email_plain=self.email_plain, + email_enc=self.email_enc, + email_hash=self.email_hash, is_staff=self.is_staff, date_joined=self.date_joined, is_superuser=self.is_superuser, diff --git a/codeforlife/user/views/user_test.py b/codeforlife/user/views/user_test.py index c36d833e..aae06b3d 100644 --- a/codeforlife/user/views/user_test.py +++ b/codeforlife/user/views/user_test.py @@ -33,7 +33,7 @@ class TestUserViewSet(ModelViewSetTestCase[RequestUser, User]): def setUp(self): self.admin_school_teacher_user = AdminSchoolTeacherUser.objects.get( - email="admin.teacher@school1.com" + pk=24 ) # test: get queryset From 75fefcba1a3abbe41286dff38f77df6afd3e088e Mon Sep 17 00:00:00 2001 From: SKairinos Date: Fri, 13 Mar 2026 15:03:11 +0000 Subject: [PATCH 42/58] encrypt fields in fixtures --- codeforlife/models/fields/base_encrypted.py | 17 +- codeforlife/user/fixtures/google_users.json | 6 +- codeforlife/user/fixtures/independent.json | 14 +- codeforlife/user/fixtures/legacy.json | 404 ++++++++++-------- .../user/fixtures/non_school_teacher.json | 14 +- codeforlife/user/fixtures/school_1.json | 16 +- codeforlife/user/fixtures/school_2.json | 14 +- codeforlife/user/fixtures/school_3.json | 12 +- 8 files changed, 308 insertions(+), 189 deletions(-) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 265114a2..6ff3ff46 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -61,6 +61,7 @@ from .deferred_attribute import DeferredAttribute T = t.TypeVar("T") +Ciphertext: t.TypeAlias = t.Union[bytes, memoryview] @dataclass(frozen=True) @@ -80,7 +81,7 @@ class Source(IntEnum): DB = auto() FIXTURE = auto() - ciphertext: bytes + ciphertext: Ciphertext source: Source @@ -127,9 +128,16 @@ def __get__(self, instance, cls=None): T, instance.__decrypted_values__[self.field.attname] ) + # Extract the raw bytes from the ciphertext. + ciphertext = ( + internal_value.ciphertext + if isinstance(internal_value.ciphertext, bytes) + else bytes(internal_value.ciphertext) + ) + # Decrypt the value before returning it. decrypted_value = t.cast( - T, self.field.decrypt_value(instance, internal_value.ciphertext) + T, self.field.decrypt_value(instance, ciphertext) ) # Cache the decrypted value on the instance. @@ -166,7 +174,7 @@ def __set__( code="invalid_memoryview_type", ) internal_value = _TrustedCiphertext( - value.obj, _TrustedCiphertext.Source.FIXTURE + value, _TrustedCiphertext.Source.FIXTURE ) elif isinstance(value, _TrustedCiphertext): # From DB. internal_value = value @@ -278,8 +286,7 @@ def __get__(self, instance: t.Optional[EncryptedModel], owner: t.Any): # Set the internal value when assigned on an instance. def __set__(self, instance: EncryptedModel, value: t.Optional[T]): ... - # pylint: disable-next=unused-argument - def from_db_value(self, value: t.Optional[bytes], expression, connection): + def from_db_value(self, value: t.Optional[Ciphertext], _, __): """ Converts a value as returned by the database to a Python object. We wrap the raw bytes in _TrustedCiphertext to signal that this is diff --git a/codeforlife/user/fixtures/google_users.json b/codeforlife/user/fixtures/google_users.json index 4cbfce61..6f2393fb 100644 --- a/codeforlife/user/fixtures/google_users.json +++ b/codeforlife/user/fixtures/google_users.json @@ -4,9 +4,13 @@ "pk": 34, "fields": { "dek": "ZmFrZV9lbmM6YlVkTE4xSndaRlZTTW5kc1NqQkJXa1ZxU2xKV2NWTk9WbFJ6YzFjMmVHVT0=", + "email_enc": "ZmFrZV9lbmM6WjI5dloyeGxMblJsWVdOb1pYSkFibTl6WTJodmIyd3VZMjl0", + "email_hash": "ee95f43c0012fa1a9d5771313a7034cf94af568b0588b34ca20c34c25701af78", + "email_plain": "google.teacher@noschool.com", + "first_name_enc": "ZmFrZV9lbmM6UjI5dloyeGw=", "first_name_plain": "Google", + "last_name_enc": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", "last_name_plain": "Teacher", - "email_plain": "google.teacher@noschool.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, diff --git a/codeforlife/user/fixtures/independent.json b/codeforlife/user/fixtures/independent.json index cd2df09c..60105446 100644 --- a/codeforlife/user/fixtures/independent.json +++ b/codeforlife/user/fixtures/independent.json @@ -4,9 +4,13 @@ "pk": 28, "fields": { "dek": "ZmFrZV9lbmM6Ym5oYVFqaHhUMDV4VTI4NE9YVk9TbmxvVkVzemRUWnpkMUpTWjFkMFVFRT0=", + "email_enc": "ZmFrZV9lbmM6YVc1a2VTNXlaWEYxWlhOMFpYSkFaVzFoYVd3dVkyOXQ=", + "email_hash": "8efc7683cec3a31792d0c22f49a1cc374ba2ae2199b3cb8d228a7cbabe8370b2", + "email_plain": "indy.requester@email.com", + "first_name_enc": "ZmFrZV9lbmM6U1c1a2VRPT0=", "first_name_plain": "Indy", + "last_name_enc": "ZmFrZV9lbmM6VW1WeGRXVnpkR1Z5", "last_name_plain": "Requester", - "email_plain": "indy.requester@email.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -32,9 +36,13 @@ "pk": 30, "fields": { "dek": "ZmFrZV9lbmM6TTIxbFVXMWlWMnR3VXpOa2JXaENlbkJGUW05a1oyZEpkRzV6YlhGa1pVYz0=", + "email_enc": "ZmFrZV9lbmM6YVc1a2VVQmxiV0ZwYkM1amIyMD0=", + "email_hash": "63e90930d7617f596f1006362382d81aecb8308c5a9893007f78c50e954e6d1a", + "email_plain": "indy@email.com", + "first_name_enc": "ZmFrZV9lbmM6U1c1a2VRPT0=", "first_name_plain": "Indy", + "last_name_enc": "ZmFrZV9lbmM6VG05U1pYRjFaWE4w", "last_name_plain": "NoRequest", - "email_plain": "indy@email.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -54,4 +62,4 @@ "new_user": 30 } } -] +] \ No newline at end of file diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json index 6776fbf1..568a4692 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -572,17 +572,21 @@ "model": "user.user", "pk": 1, "fields": { + "date_joined": "2026-02-04T16:02:33.631Z", "dek": "ZmFrZV9lbmM6UkZkTlNrYzVhR05aVlZwRVZFaFlTMHhUVTJnMlVVbzNORVZUZERjeFIxTT0=", - "password": "pbkdf2_sha256$1000000$y2lUeK8u5GnocWVuonlViT$TnNTL7sG9pcvz6MotXSSptrHdQzAFxmLnqNbj7syLS4=", - "last_login": null, - "is_superuser": true, - "first_name_plain": "Portal", - "last_name_plain": "Admin", + "email_enc": "ZmFrZV9lbmM6WTI5a1pXWnZjbXhwWm1VdGNHOXlkR0ZzUUc5allXUnZMbU52YlE9PQ==", + "email_hash": "6229d653107a83cfe416912e62307e9b3bfa5185b06deb74db27576e291ba301", "email_plain": "codeforlife-portal@ocado.com", - "is_staff": true, - "is_active": true, - "date_joined": "2026-02-04T16:02:33.631Z", + "first_name_enc": "ZmFrZV9lbmM6VUc5eWRHRnM=", + "first_name_plain": "Portal", "groups": [], + "is_active": true, + "is_staff": true, + "is_superuser": true, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6UVdSdGFXND0=", + "last_name_plain": "Admin", + "password": "pbkdf2_sha256$1000000$y2lUeK8u5GnocWVuonlViT$TnNTL7sG9pcvz6MotXSSptrHdQzAFxmLnqNbj7syLS4=", "user_permissions": [] } }, @@ -590,17 +594,21 @@ "model": "user.user", "pk": 2, "fields": { + "date_joined": "2026-02-04T16:02:34.051Z", "dek": "ZmFrZV9lbmM6VlZkaFpuUlZha2hNUjJGTU5XSlNaR3hsVUhFMVkzSjBNR1l4TUZwa1IxQT0=", - "password": "pbkdf2_sha256$1000000$IOkFLrqDnlDiEoASMl5OSY$C7gzJeOhwbfq/nJp47LaErHOxgA/3DIkaUI93ZUqauc=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Albert", - "last_name_plain": "Einstein", + "email_enc": "ZmFrZV9lbmM6WVd4aVpYSjBaV2x1YzNSbGFXNUFZMjlrWldadmNteHBabVV1WTI5dA==", + "email_hash": "9cb6a6152b4d3dfbad65576fb4f6688b56d73cbc65b36b2a7fbb313a1352d3c4", "email_plain": "alberteinstein@codeforlife.com", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:34.051Z", + "first_name_enc": "ZmFrZV9lbmM6UVd4aVpYSjA=", + "first_name_plain": "Albert", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6UldsdWMzUmxhVzQ9", + "last_name_plain": "Einstein", + "password": "pbkdf2_sha256$1000000$IOkFLrqDnlDiEoASMl5OSY$C7gzJeOhwbfq/nJp47LaErHOxgA/3DIkaUI93ZUqauc=", "user_permissions": [] } }, @@ -608,17 +616,21 @@ "model": "user.user", "pk": 3, "fields": { + "date_joined": "2026-02-04T16:02:34.252Z", "dek": "ZmFrZV9lbmM6ZDIwMmQyaGFNMlJHWWxNM2EyaEVaVlZUTXpGbFRsZEhUMjlrTnpoMmRUaz0=", - "password": "pbkdf2_sha256$1000000$QHwukC6Cv822F2nSjQjLu0$09yKE5c8mFVwIHS5BWccM+q33Rs6XSCcc5rik3RD7eY=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Max", - "last_name_plain": "Planck", + "email_enc": "ZmFrZV9lbmM6YldGNGNHeGhibU5yUUdOdlpHVm1iM0pzYVdabExtTnZiUT09", + "email_hash": "586984c6c88ed9d06cbb6d64e746ca1c7b71dc8782f68a075aece08fd2b5557a", "email_plain": "maxplanck@codeforlife.com", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:34.252Z", + "first_name_enc": "ZmFrZV9lbmM6VFdGNA==", + "first_name_plain": "Max", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6VUd4aGJtTnI=", + "last_name_plain": "Planck", + "password": "pbkdf2_sha256$1000000$QHwukC6Cv822F2nSjQjLu0$09yKE5c8mFVwIHS5BWccM+q33Rs6XSCcc5rik3RD7eY=", "user_permissions": [] } }, @@ -626,17 +638,21 @@ "model": "user.user", "pk": 4, "fields": { + "date_joined": "2026-02-04T16:02:34.448Z", "dek": "ZmFrZV9lbmM6WW5CTGJHRnBSMDVrVGt4eVFWbFljMDU2VjJwM1dqWmlia2xPUlhwVVdqVT0=", - "password": "pbkdf2_sha256$1000000$OcLHQ75ALkslmiUxFwe1Ft$7hpkII5kIi1lytO4PQ0p/6HdtT6//mPcZfZNjROXqrg=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Ram", - "last_name_plain": "Leith", + "email_enc": "ZmFrZV9lbmM6Y21GdGJHVnBkR2hBWTI5a1pXWnZjbXhwWm1VdVkyOXQ=", + "email_hash": "6df8a9b30d61359c2f434a45e881fd4461899af9f5df0e05fd15ba57e5885878", "email_plain": "ramleith@codeforlife.com", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:34.448Z", + "first_name_enc": "ZmFrZV9lbmM6VW1GdA==", + "first_name_plain": "Ram", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6VEdWcGRHZz0=", + "last_name_plain": "Leith", + "password": "pbkdf2_sha256$1000000$OcLHQ75ALkslmiUxFwe1Ft$7hpkII5kIi1lytO4PQ0p/6HdtT6//mPcZfZNjROXqrg=", "user_permissions": [] } }, @@ -644,17 +660,21 @@ "model": "user.user", "pk": 5, "fields": { + "date_joined": "2026-02-04T16:02:34.641Z", "dek": "ZmFrZV9lbmM6ZVhsRFZWQkRXWEJVVWxvM1QzbDJhM05yV0VkblIyUldRVlpHVG5CVVRqST0=", - "password": "pbkdf2_sha256$1000000$6QiofvSo5LPJ0DlGshTh0h$XdFmFi/Al5vuhxw02nlZE/rUG8g2uHxFnHXs46XgT/c=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Leonardo", - "last_name_plain": "DaVinci", + "email_enc": "ZmFrZV9lbmM6YkdWdmJtRnlaRzlrWVhacGJtTnBRR052WkdWbWIzSnNhV1psTG1OdmJRPT0=", + "email_hash": "55a63981ea426d7470560ecfaf3b53c5ecd90e036edd8eca2b1a33c84178908e", "email_plain": "leonardodavinci@codeforlife.com", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:34.641Z", + "first_name_enc": "ZmFrZV9lbmM6VEdWdmJtRnlaRzg9", + "first_name_plain": "Leonardo", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6UkdGV2FXNWphUT09", + "last_name_plain": "DaVinci", + "password": "pbkdf2_sha256$1000000$6QiofvSo5LPJ0DlGshTh0h$XdFmFi/Al5vuhxw02nlZE/rUG8g2uHxFnHXs46XgT/c=", "user_permissions": [] } }, @@ -662,17 +682,21 @@ "model": "user.user", "pk": 6, "fields": { + "date_joined": "2026-02-04T16:02:34.839Z", "dek": "ZmFrZV9lbmM6ZW10aVZtbHVibWxpYXpKSmMxUlhWWHBzTmsxMFRYcHZkblJOV1hGUGJERT0=", - "password": "pbkdf2_sha256$1000000$HF1HBQVpMLdAuYb0vt55r1$gY3+SetJqidFfylntSqmmrtremtNk8QuGeAdlHNqD9A=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Galileo", - "last_name_plain": "Galilei", + "email_enc": "ZmFrZV9lbmM6WjJGc2FXeGxiMmRoYkdsc1pXbEFZMjlrWldadmNteHBabVV1WTI5dA==", + "email_hash": "1c5f6e85fe62be2048d5a3867eff53db48f9bcd6da889b92e68668a4a464afd2", "email_plain": "galileogalilei@codeforlife.com", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:34.839Z", + "first_name_enc": "ZmFrZV9lbmM6UjJGc2FXeGxidz09", + "first_name_plain": "Galileo", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6UjJGc2FXeGxhUT09", + "last_name_plain": "Galilei", + "password": "pbkdf2_sha256$1000000$HF1HBQVpMLdAuYb0vt55r1$gY3+SetJqidFfylntSqmmrtremtNk8QuGeAdlHNqD9A=", "user_permissions": [] } }, @@ -680,17 +704,21 @@ "model": "user.user", "pk": 7, "fields": { + "date_joined": "2026-02-04T16:02:35.036Z", "dek": "ZmFrZV9lbmM6VGtWVWVHeFlXSEpaUTBKcGJFVk9TV3hYWkhCSmRtbERRa2h3WmtKQ01FUT0=", - "password": "pbkdf2_sha256$1000000$BUSt8V8F4DwWZPlCZeS1Sc$WMQElSAhvIE7LRHx6lwTde9YG5P74dc7Az+639AHdYc=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Isaac", - "last_name_plain": "Newton", + "email_enc": "ZmFrZV9lbmM6YVhOaFlXTnVaWGQwYjI1QVkyOWtaV1p2Y214cFptVXVZMjl0", + "email_hash": "4fc8d589140865b27bc27623cb547e2a24f9b985ab2e1dd0b5b5c035d367c4b6", "email_plain": "isaacnewton@codeforlife.com", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:35.036Z", + "first_name_enc": "ZmFrZV9lbmM6U1hOaFlXTT0=", + "first_name_plain": "Isaac", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6VG1WM2RHOXU=", + "last_name_plain": "Newton", + "password": "pbkdf2_sha256$1000000$BUSt8V8F4DwWZPlCZeS1Sc$WMQElSAhvIE7LRHx6lwTde9YG5P74dc7Az+639AHdYc=", "user_permissions": [] } }, @@ -698,17 +726,21 @@ "model": "user.user", "pk": 8, "fields": { + "date_joined": "2026-02-04T16:02:35.230Z", "dek": "ZmFrZV9lbmM6U1ZCNFNVbFRkR1JvY2xodmRGSXpOM0ZrYmpsb2MycGFXblZTTVZaemFWWT0=", - "password": "pbkdf2_sha256$1000000$TaYcwU3iqPA4c4co14ZYHx$FAwypkSE7UucZod6BY/YY+A4zV4JbySNxDwd4ypOZT8=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Richard", - "last_name_plain": "Feynman", + "email_enc": "ZmFrZV9lbmM6Y21samFHRnlaR1psZVc1dFlXNUFZMjlrWldadmNteHBabVV1WTI5dA==", + "email_hash": "6bd23daa66a426a27655016f084d3fa65a49e2e5d264701b6d84e550e415638d", "email_plain": "richardfeynman@codeforlife.com", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:35.230Z", + "first_name_enc": "ZmFrZV9lbmM6VW1samFHRnlaQT09", + "first_name_plain": "Richard", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6Um1WNWJtMWhiZz09", + "last_name_plain": "Feynman", + "password": "pbkdf2_sha256$1000000$TaYcwU3iqPA4c4co14ZYHx$FAwypkSE7UucZod6BY/YY+A4zV4JbySNxDwd4ypOZT8=", "user_permissions": [] } }, @@ -716,17 +748,21 @@ "model": "user.user", "pk": 9, "fields": { + "date_joined": "2026-02-04T16:02:35.422Z", "dek": "ZmFrZV9lbmM6YVc5eFlYcHVjMWsxVEZWVFRXaFRRVFpEVkhWMWNUZG9abmR5UVRCT2RERT0=", - "password": "pbkdf2_sha256$1000000$mCuu3Ptx9EGmKQ7FSNIqzw$dqwQK/ovljow4iGxfLIQTm8PPR8Iu5AITzQ9tVQxv9w=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Alexander", - "last_name_plain": "Flemming", + "email_enc": "ZmFrZV9lbmM6WVd4bGVHRnVaR1Z5Wm14bGJXMXBibWRBWTI5a1pXWnZjbXhwWm1VdVkyOXQ=", + "email_hash": "1ab9d2f1ae8ef6a5e99cee8948c326e3404aceb89b8d72215e297a1cfc8393a6", "email_plain": "alexanderflemming@codeforlife.com", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:35.422Z", + "first_name_enc": "ZmFrZV9lbmM6UVd4bGVHRnVaR1Z5", + "first_name_plain": "Alexander", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6Um14bGJXMXBibWM9", + "last_name_plain": "Flemming", + "password": "pbkdf2_sha256$1000000$mCuu3Ptx9EGmKQ7FSNIqzw$dqwQK/ovljow4iGxfLIQTm8PPR8Iu5AITzQ9tVQxv9w=", "user_permissions": [] } }, @@ -734,17 +770,21 @@ "model": "user.user", "pk": 10, "fields": { + "date_joined": "2026-02-04T16:02:35.611Z", "dek": "ZmFrZV9lbmM6WjJscWNqWlFRbEZCVkZCaGVsRTRWVFl3ZDBWRFltbEROM2x4UXpCQmVGUT0=", - "password": "pbkdf2_sha256$1000000$isUqjEe0ZtdHC0tDFilALA$k20Gy//4APEqsGNYGa8aQPUTUBqMPKe6hI5GMIWiMnE=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Daniel", - "last_name_plain": "Bernoulli", + "email_enc": "ZmFrZV9lbmM6WkdGdWFXVnNZbVZ5Ym05MWJHeHBRR052WkdWbWIzSnNhV1psTG1OdmJRPT0=", + "email_hash": "0c92dd15cc629b84ea9436c965f944543cfedd41ced6c8f2594a26ee50cf3ea5", "email_plain": "danielbernoulli@codeforlife.com", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:35.611Z", + "first_name_enc": "ZmFrZV9lbmM6UkdGdWFXVnM=", + "first_name_plain": "Daniel", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6UW1WeWJtOTFiR3hw", + "last_name_plain": "Bernoulli", + "password": "pbkdf2_sha256$1000000$isUqjEe0ZtdHC0tDFilALA$k20Gy//4APEqsGNYGa8aQPUTUBqMPKe6hI5GMIWiMnE=", "user_permissions": [] } }, @@ -752,17 +792,21 @@ "model": "user.user", "pk": 11, "fields": { + "date_joined": "2026-02-04T16:02:35.803Z", "dek": "ZmFrZV9lbmM6UW5KQ2JUYzFSRU54VVhCd05rRmxka2w0ZVRoMFdGWkhhbEpCUzBnMGRuWT0=", - "password": "pbkdf2_sha256$1000000$mjbmbYr7G3WRyxxHlDcH6d$w1wf3D0lDP6pF3nSoTUD3vSRioCzPEloW3r8sXnLSJw=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Indiana", - "last_name_plain": "Jones", + "email_enc": "ZmFrZV9lbmM6YVc1a2FXRnVZV3B2Ym1WelFHTnZaR1ZtYjNKc2FXWmxMbU52YlE9PQ==", + "email_hash": "1a93bc8026b39e9d71124c74cef2f072d4e41625ea3b12f4009f104dce962e2f", "email_plain": "indianajones@codeforlife.com", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:35.803Z", + "first_name_enc": "ZmFrZV9lbmM6U1c1a2FXRnVZUT09", + "first_name_plain": "Indiana", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6U205dVpYTT0=", + "last_name_plain": "Jones", + "password": "pbkdf2_sha256$1000000$mjbmbYr7G3WRyxxHlDcH6d$w1wf3D0lDP6pF3nSoTUD3vSRioCzPEloW3r8sXnLSJw=", "user_permissions": [] } }, @@ -770,17 +814,19 @@ "model": "user.user", "pk": 12, "fields": { + "date_joined": "2026-02-04T16:02:35.999Z", "dek": "ZmFrZV9lbmM6TVhGU1NtSlhaVkpwYlRWWVR6QTNlVE5UV2xaNWRHNUlkbFJtVG1GMVJHVT0=", - "password": "pbkdf2_sha256$1000000$qm1oQx3HNeIx6dpY1aRzXb$xMKwoqXoW6MuHqoABww+Yq+3A1uwkLbkpaWWytNu6l0=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Noah", - "last_name_plain": "Monaghan", "email_plain": "", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:35.999Z", + "first_name_enc": "ZmFrZV9lbmM6VG05aGFBPT0=", + "first_name_plain": "Noah", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6VFc5dVlXZG9ZVzQ9", + "last_name_plain": "Monaghan", + "password": "pbkdf2_sha256$1000000$qm1oQx3HNeIx6dpY1aRzXb$xMKwoqXoW6MuHqoABww+Yq+3A1uwkLbkpaWWytNu6l0=", "user_permissions": [] } }, @@ -788,17 +834,19 @@ "model": "user.user", "pk": 13, "fields": { + "date_joined": "2026-02-04T16:02:36.195Z", "dek": "ZmFrZV9lbmM6ZFZwVmRGbHpTVkoxTkVaMU5XRk9WMDlwV25CS1EzVlRaR28yV2tac1QzVT0=", - "password": "pbkdf2_sha256$1000000$vQZKG1iO3Np3cdOHSrG85F$QnuZLe1KvMt64uWXjz3RKOEnr6owhloxfgs3u8xB6GQ=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Elliot", - "last_name_plain": "Sharp", "email_plain": "", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:36.195Z", + "first_name_enc": "ZmFrZV9lbmM6Uld4c2FXOTA=", + "first_name_plain": "Elliot", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6VTJoaGNuQT0=", + "last_name_plain": "Sharp", + "password": "pbkdf2_sha256$1000000$vQZKG1iO3Np3cdOHSrG85F$QnuZLe1KvMt64uWXjz3RKOEnr6owhloxfgs3u8xB6GQ=", "user_permissions": [] } }, @@ -806,17 +854,19 @@ "model": "user.user", "pk": 14, "fields": { + "date_joined": "2026-02-04T16:02:36.394Z", "dek": "ZmFrZV9lbmM6UW1sWlVsSjNNM0JHVW1Sd1puQkNaVU4xUkdSVGVHRk1kV2x5ZFRoUGNFST0=", - "password": "pbkdf2_sha256$1000000$iCO3IsvmN5C8rpX5J0d1ob$TFK2zYume/i4StDly6EUiqFej/sOnuzV/wYE9rgr/cg=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Tajmae", - "last_name_plain": "Joseph", "email_plain": "", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:36.394Z", + "first_name_enc": "ZmFrZV9lbmM6VkdGcWJXRmw=", + "first_name_plain": "Tajmae", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6U205elpYQm8=", + "last_name_plain": "Joseph", + "password": "pbkdf2_sha256$1000000$iCO3IsvmN5C8rpX5J0d1ob$TFK2zYume/i4StDly6EUiqFej/sOnuzV/wYE9rgr/cg=", "user_permissions": [] } }, @@ -824,17 +874,19 @@ "model": "user.user", "pk": 15, "fields": { + "date_joined": "2026-02-04T16:02:36.589Z", "dek": "ZmFrZV9lbmM6ZEhWRlUxSjFZMUpxZFdoaU5XTnNiWEpqZVdSUVdFdGtOVWhFVkVGVWRrND0=", - "password": "pbkdf2_sha256$1000000$6Nhm1sHtcMbFdBitdz7nxq$s5i8H7MwdH6SRQ3+DZDDfsPtqxXVDPAC5t67G4FluM4=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Carlton", - "last_name_plain": "Joseph", "email_plain": "", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:36.589Z", + "first_name_enc": "ZmFrZV9lbmM6UTJGeWJIUnZiZz09", + "first_name_plain": "Carlton", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6U205elpYQm8=", + "last_name_plain": "Joseph", + "password": "pbkdf2_sha256$1000000$6Nhm1sHtcMbFdBitdz7nxq$s5i8H7MwdH6SRQ3+DZDDfsPtqxXVDPAC5t67G4FluM4=", "user_permissions": [] } }, @@ -842,17 +894,19 @@ "model": "user.user", "pk": 16, "fields": { + "date_joined": "2026-02-04T16:02:36.792Z", "dek": "ZmFrZV9lbmM6TW5SM1FUQnNUMlJ2VFU1RFQwUllVSEp1VFc1aWQxSmxOWHBqVDJKeFZtTT0=", - "password": "pbkdf2_sha256$1000000$FRA5lx8xBTO5Rn8J9wwZZW$KiQzLItBM40bzbwRdXpKnrI1uWXv0xsSzTMkFwAewwo=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Nadal", - "last_name_plain": "Spencer-Jennings", "email_plain": "", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:36.792Z", + "first_name_enc": "ZmFrZV9lbmM6VG1Ga1lXdz0=", + "first_name_plain": "Nadal", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6VTNCbGJtTmxjaTFLWlc1dWFXNW5jdz09", + "last_name_plain": "Spencer-Jennings", + "password": "pbkdf2_sha256$1000000$FRA5lx8xBTO5Rn8J9wwZZW$KiQzLItBM40bzbwRdXpKnrI1uWXv0xsSzTMkFwAewwo=", "user_permissions": [] } }, @@ -860,17 +914,19 @@ "model": "user.user", "pk": 17, "fields": { + "date_joined": "2026-02-04T16:02:37.009Z", "dek": "ZmFrZV9lbmM6UlZKbll6UjJhM1JPU2tvemRIQTVSRUZZYUZsMGFtOVZla05HYzI5bmQyaz0=", - "password": "pbkdf2_sha256$1000000$LAQQycnLxi5rY9QCD15nHw$Ad5r4JEo5CCQrwuD2F12ajXnV/bDSL+FGQ99vM2l0/k=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Freddie", - "last_name_plain": "Goff", "email_plain": "", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:37.009Z", + "first_name_enc": "ZmFrZV9lbmM6Um5KbFpHUnBaUT09", + "first_name_plain": "Freddie", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6UjI5bVpnPT0=", + "last_name_plain": "Goff", + "password": "pbkdf2_sha256$1000000$LAQQycnLxi5rY9QCD15nHw$Ad5r4JEo5CCQrwuD2F12ajXnV/bDSL+FGQ99vM2l0/k=", "user_permissions": [] } }, @@ -878,17 +934,19 @@ "model": "user.user", "pk": 18, "fields": { + "date_joined": "2026-02-04T16:02:37.216Z", "dek": "ZmFrZV9lbmM6YzIxdGRIbzVRbEkyVlRKdVRVVk1lSFE1TmtRMFFXeFVUamRaYldWbFpFUT0=", - "password": "pbkdf2_sha256$1000000$XHhBEg6yrF9a6TR487gNrU$DOR6TONr+9q0rLXJwB43UuezRcQkQAmEmoN0nYmm4do=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Leon", - "last_name_plain": "Scott", "email_plain": "", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:37.216Z", + "first_name_enc": "ZmFrZV9lbmM6VEdWdmJnPT0=", + "first_name_plain": "Leon", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6VTJOdmRIUT0=", + "last_name_plain": "Scott", + "password": "pbkdf2_sha256$1000000$XHhBEg6yrF9a6TR487gNrU$DOR6TONr+9q0rLXJwB43UuezRcQkQAmEmoN0nYmm4do=", "user_permissions": [] } }, @@ -896,17 +954,19 @@ "model": "user.user", "pk": 19, "fields": { + "date_joined": "2026-02-04T16:02:37.413Z", "dek": "ZmFrZV9lbmM6T0VoaU5rRTVWalZTVUVkbVpVTlllRmhOVlVoVVZVRnVUR1pvVVU5bVUybz0=", - "password": "pbkdf2_sha256$1000000$ua0WUUCFIijIjxpLdkc25v$mvl+UejCNGop0krujly8TPlMbgWPFVZ42tH+NPDltS4=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Betty", - "last_name_plain": "Kessell", "email_plain": "", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:37.413Z", + "first_name_enc": "ZmFrZV9lbmM6UW1WMGRIaz0=", + "first_name_plain": "Betty", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6UzJWemMyVnNiQT09", + "last_name_plain": "Kessell", + "password": "pbkdf2_sha256$1000000$ua0WUUCFIijIjxpLdkc25v$mvl+UejCNGop0krujly8TPlMbgWPFVZ42tH+NPDltS4=", "user_permissions": [] } }, @@ -914,17 +974,19 @@ "model": "user.user", "pk": 20, "fields": { + "date_joined": "2026-02-04T16:02:37.614Z", "dek": "ZmFrZV9lbmM6Y25SMVIwYzNNbXBVUWtkTk1IVmhjMUI2YVdWb2VtNVBaRUUyVFdsMldrcz0=", - "password": "pbkdf2_sha256$1000000$o2AfZh0JqqIFRTZGHWEQKV$c1es5kdOakAvCGkeMVgDOX5q+rFhdQgOkMZiKAtqMrk=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Deleted", - "last_name_plain": "User", "email_plain": "", - "is_staff": false, - "is_active": false, - "date_joined": "2026-02-04T16:02:37.614Z", + "first_name_enc": "ZmFrZV9lbmM6UkdWc1pYUmxaQT09", + "first_name_plain": "Deleted", "groups": [], + "is_active": false, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6VlhObGNnPT0=", + "last_name_plain": "User", + "password": "pbkdf2_sha256$1000000$o2AfZh0JqqIFRTZGHWEQKV$c1es5kdOakAvCGkeMVgDOX5q+rFhdQgOkMZiKAtqMrk=", "user_permissions": [] } }, @@ -932,18 +994,22 @@ "model": "user.user", "pk": 21, "fields": { + "date_joined": "2026-02-04T16:02:40.242Z", "dek": "ZmFrZV9lbmM6WmtGRFlYQjBUek5OU1VjeGVXczNVbmRTVmxsRGRtOXlWREZzVFU5Q1dqQT0=", - "password": "pbkdf2_sha256$1000000$cv54m0PSNCQrH0MBLU56pk$nhGzkqLXL01EEtLAsoAbaWcTxuj1SOrZ7s6rBdA7eZk=", - "last_login": null, - "is_superuser": false, - "first_name_plain": "Portaladmin", - "last_name_plain": "Student", + "email_enc": "ZmFrZV9lbmM6WVdSdGFXNXpkSFZrWlc1MFFHTnZaR1ZtYjNKc2FXWmxMbU52YlE9PQ==", + "email_hash": "d10b776ab1e76347e63692528b0e4e97a5e3288b5f3c918476577a96b9255463", "email_plain": "adminstudent@codeforlife.com", - "is_staff": false, - "is_active": true, - "date_joined": "2026-02-04T16:02:40.242Z", + "first_name_enc": "ZmFrZV9lbmM6VUc5eWRHRnNZV1J0YVc0PQ==", + "first_name_plain": "Portaladmin", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkQT09", + "last_name_plain": "Student", + "password": "pbkdf2_sha256$1000000$cv54m0PSNCQrH0MBLU56pk$nhGzkqLXL01EEtLAsoAbaWcTxuj1SOrZ7s6rBdA7eZk=", "user_permissions": [] } } -] +] \ No newline at end of file diff --git a/codeforlife/user/fixtures/non_school_teacher.json b/codeforlife/user/fixtures/non_school_teacher.json index 47c36da0..a1663afa 100644 --- a/codeforlife/user/fixtures/non_school_teacher.json +++ b/codeforlife/user/fixtures/non_school_teacher.json @@ -4,9 +4,13 @@ "pk": 22, "fields": { "dek": "ZmFrZV9lbmM6TkhGdE1EWlFOVkJ3VDFwTVVYWmlVRzlFYVdwU1JIUTVialZ0V1RoQ2NXYz0=", + "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J1YjNOamFHOXZiQzVqYjIwPQ==", + "email_hash": "7cc99dd2f335a068ac421d2cec8f04687fa34d37d313718f56ebc6b6c3c09d2c", + "email_plain": "teacher@noschool.com", + "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", "first_name_plain": "John", + "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", - "email_plain": "teacher@noschool.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -31,9 +35,13 @@ "pk": 33, "fields": { "dek": "ZmFrZV9lbmM6UVhac01Ea3dibmQxYzBkRFdVbGFRMDR4VEZkelRVWklUbHA1VVZsU1RXOD0=", + "email_enc": "ZmFrZV9lbmM6ZFc1MlpYSnBabWxsWkM1MFpXRmphR1Z5UUc1dmMyTm9iMjlzTG1OdmJRPT0=", + "email_hash": "7f34bbe92504e0ca5c2235f2f64bf2686a7d040690a45948074247cad2eb5c71", + "email_plain": "unverified.teacher@noschool.com", + "first_name_enc": "ZmFrZV9lbmM6Vlc1MlpYSnBabWxsWkE9PQ==", "first_name_plain": "Unverified", + "last_name_enc": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", "last_name_plain": "Teacher", - "email_plain": "unverified.teacher@noschool.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -53,4 +61,4 @@ "new_user": 33 } } -] +] \ No newline at end of file diff --git a/codeforlife/user/fixtures/school_1.json b/codeforlife/user/fixtures/school_1.json index f814b4c4..83ad051b 100644 --- a/codeforlife/user/fixtures/school_1.json +++ b/codeforlife/user/fixtures/school_1.json @@ -14,9 +14,13 @@ "pk": 23, "fields": { "dek": "ZmFrZV9lbmM6U2tGVmEzZHhlVkF4VERKR1QwaGxPVWRTWkhkVE16WTBiREV3WjFkQlVHcz0=", + "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3hMbU52YlE9PQ==", + "email_hash": "d23e2f365e919be1cf5e29a5472801d54bd75c6f360bce7c37674506bc63a9ce", + "email_plain": "teacher@school1.com", + "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", "first_name_plain": "John", + "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", - "email_plain": "teacher@school1.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -52,6 +56,7 @@ "pk": 27, "fields": { "dek": "ZmFrZV9lbmM6ZEc1aFZ6RkhVRTFNVFdaSWQzTlZXRmxRZFRsTFZIbE1kRWxSVGs1Q1FWQT0=", + "first_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkREU9", "first_name_plain": "Student1", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -78,9 +83,13 @@ "pk": 24, "fields": { "dek": "ZmFrZV9lbmM6YkVGQk1XVTBVWFJqTWpFelFVMWhTVE5EUWtWeVMzZGpjR1JNU2xSTVlrUT0=", + "email_enc": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3eExtTnZiUT09", + "email_hash": "01c1e0721b31cb0636da3f5b3dc1a107bdd44fd0185fa12dd87f6aad06c91a08", + "email_plain": "admin.teacher@school1.com", + "first_name_enc": "ZmFrZV9lbmM6U21GdVpRPT0=", "first_name_plain": "Jane", + "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", - "email_plain": "admin.teacher@school1.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -116,6 +125,7 @@ "pk": 29, "fields": { "dek": "ZmFrZV9lbmM6YXpWd1dGZEZOWHBXZEdnMFJWQlROWFZFV2s0eVRuVTBOVTlGZUdwNWJtND0=", + "first_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkREk9", "first_name_plain": "Student2", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -147,4 +157,4 @@ "accept_requests_until": "2023-02-09 20:26:08.298402+00:00" } } -] +] \ No newline at end of file diff --git a/codeforlife/user/fixtures/school_2.json b/codeforlife/user/fixtures/school_2.json index e6c95198..8957dc8f 100644 --- a/codeforlife/user/fixtures/school_2.json +++ b/codeforlife/user/fixtures/school_2.json @@ -14,9 +14,13 @@ "pk": 25, "fields": { "dek": "ZmFrZV9lbmM6UkhWQ1kwaHlOa2hqYzFSV1MxcE1SblkwYnpnMGRtNUxWMnRLV1dWMlRUYz0=", + "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3lMbU52YlE9PQ==", + "email_hash": "8cdd687351ee31833adcb72d37e7b8f1c1c17c655c3fb182ac0127320af64284", + "email_plain": "teacher@school2.com", + "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", "first_name_plain": "John", + "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", - "email_plain": "teacher@school2.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -140,9 +144,13 @@ "pk": 26, "fields": { "dek": "ZmFrZV9lbmM6WkdoS2FsQnNjR3R4UmtaSFdrOVJTa2huVW1wa1lVcDVhRVJ2Ulhkd2RuYz0=", + "email_enc": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3eUxtTnZiUT09", + "email_hash": "4841a8d3657918adffdab6cb4a0a6b64ad771aa56a53f80fed0ee8d074841ca3", + "email_plain": "admin.teacher@school2.com", + "first_name_enc": "ZmFrZV9lbmM6U21GdVpRPT0=", "first_name_plain": "Jane", + "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", - "email_plain": "admin.teacher@school2.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -182,4 +190,4 @@ "teacher": 9 } } -] +] \ No newline at end of file diff --git a/codeforlife/user/fixtures/school_3.json b/codeforlife/user/fixtures/school_3.json index c2013b3d..1538f93f 100644 --- a/codeforlife/user/fixtures/school_3.json +++ b/codeforlife/user/fixtures/school_3.json @@ -14,9 +14,13 @@ "pk": 31, "fields": { "dek": "ZmFrZV9lbmM6ZGxKVE4xTm5jRGhKT1ZWVVdqWlpPVUpDUm14V1EycDRNbkYxZUc1aVExbz0=", + "email_enc": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3ekxtTnZiUT09", + "email_hash": "22a604d4828ae8429e8303d16c7d9967145b214b827a8ff1845b19e2a10b12c3", + "email_plain": "admin.teacher@school3.com", + "first_name_enc": "ZmFrZV9lbmM6VUdWMFpYST0=", "first_name_plain": "Peter", + "last_name_enc": "ZmFrZV9lbmM6VUdGeWEyVnk=", "last_name_plain": "Parker", - "email_plain": "admin.teacher@school3.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -43,9 +47,13 @@ "pk": 32, "fields": { "dek": "ZmFrZV9lbmM6VjA5cGVVcG1SR1pRTkdoNVptUXhOR1Z0ZEVOblVtUmtjRWhJUlZob01Faz0=", + "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3pMbU52YlE9PQ==", + "email_hash": "c66f480d5458f514966dd1a540e652d1c57850a11122a26b1760eb95d7833fa1", + "email_plain": "teacher@school3.com", + "first_name_enc": "ZmFrZV9lbmM6Ukc5amRHOXk=", "first_name_plain": "Doctor", + "last_name_enc": "ZmFrZV9lbmM6VDJOMGIzQjFjdz09", "last_name_plain": "Octopus", - "email_plain": "teacher@school3.com", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, From f6773529d7cddd3034920216815bcdc5982d8d4f Mon Sep 17 00:00:00 2001 From: SKairinos Date: Fri, 13 Mar 2026 17:15:45 +0000 Subject: [PATCH 43/58] hash credentials --- codeforlife/hashers.py | 10 +-- codeforlife/user/auth/backends/email.py | 4 +- codeforlife/user/auth/backends/student.py | 13 +++- codeforlife/user/fixtures/google_users.json | 3 +- codeforlife/user/fixtures/independent.json | 2 + codeforlife/user/fixtures/legacy.json | 76 +++++++++++++------ .../user/fixtures/non_school_teacher.json | 2 + codeforlife/user/fixtures/school_1.json | 21 +++-- codeforlife/user/fixtures/school_2.json | 8 +- codeforlife/user/fixtures/school_3.json | 4 +- .../0003_client_side_encryption_part_1.py | 19 ++++- codeforlife/user/models/klass.py | 7 ++ codeforlife/user/models/user/user.py | 35 +++++---- codeforlife/user/views/klass.py | 1 - docs/client-side-encryption.md | 8 +- 15 files changed, 148 insertions(+), 65 deletions(-) diff --git a/codeforlife/hashers.py b/codeforlife/hashers.py index 984f9fd3..5382ce60 100644 --- a/codeforlife/hashers.py +++ b/codeforlife/hashers.py @@ -9,17 +9,17 @@ from django.conf import settings -def hash_email(email: str): - """Create a consistent, salted hash of an email address. +def hash_credential(credential: str): + """Create a consistent, salted hash of a credential. Args: - email: The email address to hash. + credential: The credential to hash. Returns: - A hash of the email address salted with the Django secret key. + A hash of the credential salted with the Django secret key. """ return hmac.new( key=settings.SECRET_KEY.encode("utf-8"), - msg=email.encode("utf-8"), + msg=credential.encode("utf-8"), digestmod=hashlib.sha256, ).hexdigest() diff --git a/codeforlife/user/auth/backends/email.py b/codeforlife/user/auth/backends/email.py index 2cbc2645..4e932df2 100644 --- a/codeforlife/user/auth/backends/email.py +++ b/codeforlife/user/auth/backends/email.py @@ -5,7 +5,7 @@ import typing as t -from ....hashers import hash_email +from ....hashers import hash_credential from ....request import HttpRequest from .base import BaseBackend @@ -24,7 +24,7 @@ def authenticate( # type: ignore[override] return None email = self.user_class.objects.normalize_email(email) - email_hash = hash_email(email) # type: ignore[arg-type] + email_hash = hash_credential(email) # type: ignore[arg-type] # pylint: disable=duplicate-code try: diff --git a/codeforlife/user/auth/backends/student.py b/codeforlife/user/auth/backends/student.py index aed1cb0b..a61c7eb9 100644 --- a/codeforlife/user/auth/backends/student.py +++ b/codeforlife/user/auth/backends/student.py @@ -5,6 +5,7 @@ import typing as t +from ....hashers import hash_credential from ....request import HttpRequest from ...models import StudentUser from .base import BaseBackend @@ -26,16 +27,20 @@ def authenticate( # type: ignore[override] if first_name is None or password is None or class_id is None: return None + first_name_hash = hash_credential(first_name) + class_id_hash = hash_credential(class_id) + # pylint: disable=duplicate-code try: user = self.user_class.objects.get( - first_name=first_name, - new_student__class_field__access_code=class_id, + first_name_hash=first_name_hash, + new_student__class_field__access_code_hash=class_id_hash, ) - if user.check_password(password): - return user except self.user_class.DoesNotExist: return None # pylint: enable=duplicate-code + if user.check_password(password): + return user + return None diff --git a/codeforlife/user/fixtures/google_users.json b/codeforlife/user/fixtures/google_users.json index 6f2393fb..dc325f67 100644 --- a/codeforlife/user/fixtures/google_users.json +++ b/codeforlife/user/fixtures/google_users.json @@ -8,6 +8,7 @@ "email_hash": "ee95f43c0012fa1a9d5771313a7034cf94af568b0588b34ca20c34c25701af78", "email_plain": "google.teacher@noschool.com", "first_name_enc": "ZmFrZV9lbmM6UjI5dloyeGw=", + "first_name_hash": "5463f3f79e73077256f212d9e43a2752447cb7b238cda23004926bc0b9c5076f", "first_name_plain": "Google", "last_name_enc": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", "last_name_plain": "Teacher", @@ -30,4 +31,4 @@ "new_user": 34 } } -] +] \ No newline at end of file diff --git a/codeforlife/user/fixtures/independent.json b/codeforlife/user/fixtures/independent.json index 60105446..6e1dee5a 100644 --- a/codeforlife/user/fixtures/independent.json +++ b/codeforlife/user/fixtures/independent.json @@ -8,6 +8,7 @@ "email_hash": "8efc7683cec3a31792d0c22f49a1cc374ba2ae2199b3cb8d228a7cbabe8370b2", "email_plain": "indy.requester@email.com", "first_name_enc": "ZmFrZV9lbmM6U1c1a2VRPT0=", + "first_name_hash": "c90d2a9004eccdad8bec752f90ffe656955c44a81f984d3d32b827f17b473d7c", "first_name_plain": "Indy", "last_name_enc": "ZmFrZV9lbmM6VW1WeGRXVnpkR1Z5", "last_name_plain": "Requester", @@ -40,6 +41,7 @@ "email_hash": "63e90930d7617f596f1006362382d81aecb8308c5a9893007f78c50e954e6d1a", "email_plain": "indy@email.com", "first_name_enc": "ZmFrZV9lbmM6U1c1a2VRPT0=", + "first_name_hash": "c90d2a9004eccdad8bec752f90ffe656955c44a81f984d3d32b827f17b473d7c", "first_name_plain": "Indy", "last_name_enc": "ZmFrZV9lbmM6VG05U1pYRjFaWE4w", "last_name_plain": "NoRequest", diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json index 568a4692..0f80a388 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -294,75 +294,80 @@ "model": "user.class", "pk": 1, "fields": { - "name_plain": "Class 101", - "teacher": 1, + "accept_requests_until": null, + "access_code_hash": "7dcbfe80fd523896acdad8310fe14d804dad5ae7e4dc4975815f3d5ff76aa963", "access_code_plain": "AB123", - "classmates_data_viewable": true, "always_accept_requests": true, - "accept_requests_until": null, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name_plain": "Class 101", + "teacher": 1 } }, { "model": "user.class", "pk": 2, "fields": { - "name_plain": "Class 102", - "teacher": 2, + "accept_requests_until": null, + "access_code_hash": "ee16d5af3f31578ab0327da3257b212fe2beefcadef4f6b8ad6c992d153b90aa", "access_code_plain": "AB124", - "classmates_data_viewable": true, "always_accept_requests": true, - "accept_requests_until": null, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name_plain": "Class 102", + "teacher": 2 } }, { "model": "user.class", "pk": 3, "fields": { - "name_plain": "Class 103", - "teacher": 2, + "accept_requests_until": null, + "access_code_hash": "a895a482b72ec418311b8a9677bcf2c690425993f563f58d4a15b9586753b88b", "access_code_plain": "AB125", - "classmates_data_viewable": true, "always_accept_requests": true, - "accept_requests_until": null, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name_plain": "Class 103", + "teacher": 2 } }, { "model": "user.class", "pk": 4, "fields": { - "name_plain": "Young Coders 101", - "teacher": 3, + "accept_requests_until": null, + "access_code_hash": "38045227bf3c575c643dc1b34a1e70669f35b90225495bbeba0a53ae5ba82c81", "access_code_plain": "RL123", - "classmates_data_viewable": true, "always_accept_requests": true, - "accept_requests_until": null, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name_plain": "Young Coders 101", + "teacher": 3 } }, { "model": "user.class", "pk": 5, "fields": { - "name_plain": "Portaladmin's class", - "teacher": 4, + "accept_requests_until": null, + "access_code_hash": "732f2cd0915bdea50771ce7c92a4e1b8ea83f6d3323b95dd07619e5ea7f0fd06", "access_code_plain": "PO123", - "classmates_data_viewable": true, "always_accept_requests": true, - "accept_requests_until": null, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name_plain": "Portaladmin's class", + "teacher": 4 } }, { @@ -578,6 +583,7 @@ "email_hash": "6229d653107a83cfe416912e62307e9b3bfa5185b06deb74db27576e291ba301", "email_plain": "codeforlife-portal@ocado.com", "first_name_enc": "ZmFrZV9lbmM6VUc5eWRHRnM=", + "first_name_hash": "dc0c24809d18768f01857048c816eb3885cb627a759bd2dd5314970adafdaf3e", "first_name_plain": "Portal", "groups": [], "is_active": true, @@ -600,6 +606,7 @@ "email_hash": "9cb6a6152b4d3dfbad65576fb4f6688b56d73cbc65b36b2a7fbb313a1352d3c4", "email_plain": "alberteinstein@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6UVd4aVpYSjA=", + "first_name_hash": "920e5149d016b9ef005052b8f52ec833b0974ecf7fb25dc8efd42d8cb1912bf2", "first_name_plain": "Albert", "groups": [], "is_active": true, @@ -622,6 +629,7 @@ "email_hash": "586984c6c88ed9d06cbb6d64e746ca1c7b71dc8782f68a075aece08fd2b5557a", "email_plain": "maxplanck@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6VFdGNA==", + "first_name_hash": "337aa91f0cb585a80fee292721d7e78119da4aa52be054785bf1069302979e5e", "first_name_plain": "Max", "groups": [], "is_active": true, @@ -644,6 +652,7 @@ "email_hash": "6df8a9b30d61359c2f434a45e881fd4461899af9f5df0e05fd15ba57e5885878", "email_plain": "ramleith@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6VW1GdA==", + "first_name_hash": "62ea6bdedfb21184d0fb2342a6109dfd8ecbe0ed5b80ec8db8b2b414ac01423c", "first_name_plain": "Ram", "groups": [], "is_active": true, @@ -666,6 +675,7 @@ "email_hash": "55a63981ea426d7470560ecfaf3b53c5ecd90e036edd8eca2b1a33c84178908e", "email_plain": "leonardodavinci@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6VEdWdmJtRnlaRzg9", + "first_name_hash": "9e84472777869ca6205d06421ed265c1c3cfcdfc35a7de158201227c3c541198", "first_name_plain": "Leonardo", "groups": [], "is_active": true, @@ -688,6 +698,7 @@ "email_hash": "1c5f6e85fe62be2048d5a3867eff53db48f9bcd6da889b92e68668a4a464afd2", "email_plain": "galileogalilei@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6UjJGc2FXeGxidz09", + "first_name_hash": "cae481802ba39e14537dda2d0125c791bab6e0b4e323e9833591a5de6c50207e", "first_name_plain": "Galileo", "groups": [], "is_active": true, @@ -710,6 +721,7 @@ "email_hash": "4fc8d589140865b27bc27623cb547e2a24f9b985ab2e1dd0b5b5c035d367c4b6", "email_plain": "isaacnewton@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6U1hOaFlXTT0=", + "first_name_hash": "c49fb23f0176885dece511c58087b7f23019c343b9861e2d70926600577a4611", "first_name_plain": "Isaac", "groups": [], "is_active": true, @@ -732,6 +744,7 @@ "email_hash": "6bd23daa66a426a27655016f084d3fa65a49e2e5d264701b6d84e550e415638d", "email_plain": "richardfeynman@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6VW1samFHRnlaQT09", + "first_name_hash": "0a8a5deb9f8f9a8f0e96af7630e59b6fdc0a2528095a5a382c3d417da9c97f7e", "first_name_plain": "Richard", "groups": [], "is_active": true, @@ -754,6 +767,7 @@ "email_hash": "1ab9d2f1ae8ef6a5e99cee8948c326e3404aceb89b8d72215e297a1cfc8393a6", "email_plain": "alexanderflemming@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6UVd4bGVHRnVaR1Z5", + "first_name_hash": "e78f1c3e180b8af2c8dfc79ec04d65ac565361c887584100e67890cdb70ab52b", "first_name_plain": "Alexander", "groups": [], "is_active": true, @@ -776,6 +790,7 @@ "email_hash": "0c92dd15cc629b84ea9436c965f944543cfedd41ced6c8f2594a26ee50cf3ea5", "email_plain": "danielbernoulli@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6UkdGdWFXVnM=", + "first_name_hash": "6bc6d4fc17ad3131d3417aebb876bdec85a697de1a69734b9d113c800e72aaf3", "first_name_plain": "Daniel", "groups": [], "is_active": true, @@ -798,6 +813,7 @@ "email_hash": "1a93bc8026b39e9d71124c74cef2f072d4e41625ea3b12f4009f104dce962e2f", "email_plain": "indianajones@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6U1c1a2FXRnVZUT09", + "first_name_hash": "9859180db8b64c329244146e3cfda4e6c42fd38f8ef9fc0f272af81298c6c02a", "first_name_plain": "Indiana", "groups": [], "is_active": true, @@ -818,6 +834,7 @@ "dek": "ZmFrZV9lbmM6TVhGU1NtSlhaVkpwYlRWWVR6QTNlVE5UV2xaNWRHNUlkbFJtVG1GMVJHVT0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6VG05aGFBPT0=", + "first_name_hash": "91886b94f7298f93affde2a4c4bb1e1ec836b5da66507834c964dc8db418fa14", "first_name_plain": "Noah", "groups": [], "is_active": true, @@ -838,6 +855,7 @@ "dek": "ZmFrZV9lbmM6ZFZwVmRGbHpTVkoxTkVaMU5XRk9WMDlwV25CS1EzVlRaR28yV2tac1QzVT0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6Uld4c2FXOTA=", + "first_name_hash": "050fa7990069876e75ceb3fe387a3f10b5beec63612c5b4e500cfcf246daa312", "first_name_plain": "Elliot", "groups": [], "is_active": true, @@ -858,6 +876,7 @@ "dek": "ZmFrZV9lbmM6UW1sWlVsSjNNM0JHVW1Sd1puQkNaVU4xUkdSVGVHRk1kV2x5ZFRoUGNFST0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6VkdGcWJXRmw=", + "first_name_hash": "969bd503b94e99bb283caa34c5e28fcb8cebfb278111bff1f76ed1f8d08c2720", "first_name_plain": "Tajmae", "groups": [], "is_active": true, @@ -878,6 +897,7 @@ "dek": "ZmFrZV9lbmM6ZEhWRlUxSjFZMUpxZFdoaU5XTnNiWEpqZVdSUVdFdGtOVWhFVkVGVWRrND0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6UTJGeWJIUnZiZz09", + "first_name_hash": "8e450f405264e612fb62f915830166cd43c03612b82c19c0c52aaee93183b0bb", "first_name_plain": "Carlton", "groups": [], "is_active": true, @@ -898,6 +918,7 @@ "dek": "ZmFrZV9lbmM6TW5SM1FUQnNUMlJ2VFU1RFQwUllVSEp1VFc1aWQxSmxOWHBqVDJKeFZtTT0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6VG1Ga1lXdz0=", + "first_name_hash": "8424a2115bf1ee8fa253b59cd46cd6487c1b53b51201e593c08a12c390d70ad4", "first_name_plain": "Nadal", "groups": [], "is_active": true, @@ -918,6 +939,7 @@ "dek": "ZmFrZV9lbmM6UlZKbll6UjJhM1JPU2tvemRIQTVSRUZZYUZsMGFtOVZla05HYzI5bmQyaz0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6Um5KbFpHUnBaUT09", + "first_name_hash": "8d81abd4d9f4dd116d4473915b068c9790f2229ab72fbcd974daff57ebf62717", "first_name_plain": "Freddie", "groups": [], "is_active": true, @@ -938,6 +960,7 @@ "dek": "ZmFrZV9lbmM6YzIxdGRIbzVRbEkyVlRKdVRVVk1lSFE1TmtRMFFXeFVUamRaYldWbFpFUT0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6VEdWdmJnPT0=", + "first_name_hash": "5081dca0f5b49f2fdfda5543c6bbeef8e733838de7b8e642064f9b8edc0e24b5", "first_name_plain": "Leon", "groups": [], "is_active": true, @@ -958,6 +981,7 @@ "dek": "ZmFrZV9lbmM6T0VoaU5rRTVWalZTVUVkbVpVTlllRmhOVlVoVVZVRnVUR1pvVVU5bVUybz0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6UW1WMGRIaz0=", + "first_name_hash": "c25e694e58d404373d63ef5dd2b2a413dab9c64aab028d735401c166813a87fa", "first_name_plain": "Betty", "groups": [], "is_active": true, @@ -978,6 +1002,7 @@ "dek": "ZmFrZV9lbmM6Y25SMVIwYzNNbXBVUWtkTk1IVmhjMUI2YVdWb2VtNVBaRUUyVFdsMldrcz0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6UkdWc1pYUmxaQT09", + "first_name_hash": "959e43f80903c037d87f933a2225789fbdbd71e0dea09fc1854a51e8d2f9b17e", "first_name_plain": "Deleted", "groups": [], "is_active": false, @@ -1000,6 +1025,7 @@ "email_hash": "d10b776ab1e76347e63692528b0e4e97a5e3288b5f3c918476577a96b9255463", "email_plain": "adminstudent@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6VUc5eWRHRnNZV1J0YVc0PQ==", + "first_name_hash": "689afa6b012cd1c17a5b32dbf80482ccb8049f87af317883a23e7144b84242b2", "first_name_plain": "Portaladmin", "groups": [], "is_active": true, diff --git a/codeforlife/user/fixtures/non_school_teacher.json b/codeforlife/user/fixtures/non_school_teacher.json index a1663afa..a3fadbcb 100644 --- a/codeforlife/user/fixtures/non_school_teacher.json +++ b/codeforlife/user/fixtures/non_school_teacher.json @@ -8,6 +8,7 @@ "email_hash": "7cc99dd2f335a068ac421d2cec8f04687fa34d37d313718f56ebc6b6c3c09d2c", "email_plain": "teacher@noschool.com", "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", + "first_name_hash": "7554c6b4930e0ac2c4d7fb5e7e266797b0444953067a0d7a17e835b518229e84", "first_name_plain": "John", "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", @@ -39,6 +40,7 @@ "email_hash": "7f34bbe92504e0ca5c2235f2f64bf2686a7d040690a45948074247cad2eb5c71", "email_plain": "unverified.teacher@noschool.com", "first_name_enc": "ZmFrZV9lbmM6Vlc1MlpYSnBabWxsWkE9PQ==", + "first_name_hash": "47c086ea8af2fcd653408088d2d3809d3d46fd2698755a4a208d5fb78ebc205b", "first_name_plain": "Unverified", "last_name_enc": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", "last_name_plain": "Teacher", diff --git a/codeforlife/user/fixtures/school_1.json b/codeforlife/user/fixtures/school_1.json index 83ad051b..7734fdd5 100644 --- a/codeforlife/user/fixtures/school_1.json +++ b/codeforlife/user/fixtures/school_1.json @@ -18,6 +18,7 @@ "email_hash": "d23e2f365e919be1cf5e29a5472801d54bd75c6f360bce7c37674506bc63a9ce", "email_plain": "teacher@school1.com", "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", + "first_name_hash": "7554c6b4930e0ac2c4d7fb5e7e266797b0444953067a0d7a17e835b518229e84", "first_name_plain": "John", "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", @@ -45,10 +46,11 @@ "model": "user.class", "pk": 6, "fields": { - "name_plain": "Class 1 @ School 1", + "accept_requests_until": "9999-02-09 20:26:08.298402+00:00", + "access_code_hash": "ca366870cf5e795a3c9ff340f119df2ff646128cf164d992b0167bbca8c247eb", "access_code_plain": "ZZ111", - "teacher": 6, - "accept_requests_until": "9999-02-09 20:26:08.298402+00:00" + "name_plain": "Class 1 @ School 1", + "teacher": 6 } }, { @@ -57,6 +59,7 @@ "fields": { "dek": "ZmFrZV9lbmM6ZEc1aFZ6RkhVRTFNVFdaSWQzTlZXRmxRZFRsTFZIbE1kRWxSVGs1Q1FWQT0=", "first_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkREU9", + "first_name_hash": "341d77ca3f20f1a3ddf8d9a06d076940c6e24b252a08c5dae066b43c2b236ed5", "first_name_plain": "Student1", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -87,6 +90,7 @@ "email_hash": "01c1e0721b31cb0636da3f5b3dc1a107bdd44fd0185fa12dd87f6aad06c91a08", "email_plain": "admin.teacher@school1.com", "first_name_enc": "ZmFrZV9lbmM6U21GdVpRPT0=", + "first_name_hash": "a5945caf6491a22426f870debae98c823043af86691d336f95248daff7bb989a", "first_name_plain": "Jane", "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", @@ -115,8 +119,9 @@ "model": "user.class", "pk": 7, "fields": { - "name_plain": "Class 2 @ School 1", + "access_code_hash": "e90c6683965279b92914a9e2b18716f0bc826c0dccb5976130562ff88cbd7f01", "access_code_plain": "ZZ222", + "name_plain": "Class 2 @ School 1", "teacher": 7 } }, @@ -126,6 +131,7 @@ "fields": { "dek": "ZmFrZV9lbmM6YXpWd1dGZEZOWHBXZEdnMFJWQlROWFZFV2s0eVRuVTBOVTlGZUdwNWJtND0=", "first_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkREk9", + "first_name_hash": "f7ecf0b3ebd27342b1a6d30a6ef300b6dc23518fd5c83ca25cdc2b7135927cf8", "first_name_plain": "Student2", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -151,10 +157,11 @@ "model": "user.class", "pk": 10, "fields": { - "name_plain": "Class 3 @ School 1", + "accept_requests_until": "2023-02-09 20:26:08.298402+00:00", + "access_code_hash": "3e3b96d4c7775a2c8d15999f6d662b164ccf3d66b8df93329bd5e426251d8cdb", "access_code_plain": "ZZ333", - "teacher": 7, - "accept_requests_until": "2023-02-09 20:26:08.298402+00:00" + "name_plain": "Class 3 @ School 1", + "teacher": 7 } } ] \ No newline at end of file diff --git a/codeforlife/user/fixtures/school_2.json b/codeforlife/user/fixtures/school_2.json index 8957dc8f..cccc60b0 100644 --- a/codeforlife/user/fixtures/school_2.json +++ b/codeforlife/user/fixtures/school_2.json @@ -18,6 +18,7 @@ "email_hash": "8cdd687351ee31833adcb72d37e7b8f1c1c17c655c3fb182ac0127320af64284", "email_plain": "teacher@school2.com", "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", + "first_name_hash": "7554c6b4930e0ac2c4d7fb5e7e266797b0444953067a0d7a17e835b518229e84", "first_name_plain": "John", "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", @@ -134,8 +135,9 @@ "model": "user.class", "pk": 8, "fields": { - "name_plain": "Class 1 @ School 2", + "access_code_hash": "0157477281b2aee7660889207dee1a14756bddd428162de41bada7a02c593ff6", "access_code_plain": "XX111", + "name_plain": "Class 1 @ School 2", "teacher": 8 } }, @@ -148,6 +150,7 @@ "email_hash": "4841a8d3657918adffdab6cb4a0a6b64ad771aa56a53f80fed0ee8d074841ca3", "email_plain": "admin.teacher@school2.com", "first_name_enc": "ZmFrZV9lbmM6U21GdVpRPT0=", + "first_name_hash": "a5945caf6491a22426f870debae98c823043af86691d336f95248daff7bb989a", "first_name_plain": "Jane", "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", @@ -185,8 +188,9 @@ "model": "user.class", "pk": 9, "fields": { - "name_plain": "Class 2 @ School 2", + "access_code_hash": "750df33e8a531e83fddf652344b680983a1555c6dcd8fda80f4af785357b444d", "access_code_plain": "XX222", + "name_plain": "Class 2 @ School 2", "teacher": 9 } } diff --git a/codeforlife/user/fixtures/school_3.json b/codeforlife/user/fixtures/school_3.json index 1538f93f..7a75aac1 100644 --- a/codeforlife/user/fixtures/school_3.json +++ b/codeforlife/user/fixtures/school_3.json @@ -18,6 +18,7 @@ "email_hash": "22a604d4828ae8429e8303d16c7d9967145b214b827a8ff1845b19e2a10b12c3", "email_plain": "admin.teacher@school3.com", "first_name_enc": "ZmFrZV9lbmM6VUdWMFpYST0=", + "first_name_hash": "083ec2a4fd4004ed6f9bd61965b170a9b5db5d5873c7217f65bede117f004a79", "first_name_plain": "Peter", "last_name_enc": "ZmFrZV9lbmM6VUdGeWEyVnk=", "last_name_plain": "Parker", @@ -51,6 +52,7 @@ "email_hash": "c66f480d5458f514966dd1a540e652d1c57850a11122a26b1760eb95d7833fa1", "email_plain": "teacher@school3.com", "first_name_enc": "ZmFrZV9lbmM6Ukc5amRHOXk=", + "first_name_hash": "be367a9fe2b4bf78b7f7bd98e888fac606cfafa10fc91f25745d4a60e167ba72", "first_name_plain": "Doctor", "last_name_enc": "ZmFrZV9lbmM6VDJOMGIzQjFjdz09", "last_name_plain": "Octopus", @@ -74,4 +76,4 @@ "school": 4 } } -] +] \ No newline at end of file diff --git a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py index d55601ec..7369dba5 100644 --- a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py @@ -60,7 +60,24 @@ class Migration(migrations.Migration): model_name="user", name="email_hash", field=models.CharField( - blank=True, max_length=254, verbose_name="email hash" + editable=False, max_length=64, verbose_name="email hash" + ), + ), + migrations.AddField( + model_name="user", + name="first_name_hash", + field=models.CharField( + editable=False, max_length=64, verbose_name="first name hash" + ), + ), + migrations.AddField( + model_name="class", + name="access_code_hash", + field=models.CharField( + null=True, + editable=False, + max_length=64, + verbose_name="access code hash", ), ), migrations.AddField( diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index ebd10ce4..c10a23e7 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -12,6 +12,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from ...hashers import hash_credential from ...models import EncryptedModel from ...models.fields import EncryptedTextField from ...types import Validators @@ -99,6 +100,9 @@ def name(self, value: str): # Access code # -------------------------------------------------------------------------- + access_code_hash = models.CharField( + _("access code hash"), max_length=64, editable=False, null=True + ) access_code_plain: t.Optional[str] access_code_plain = models.CharField( # type: ignore[assignment] max_length=5, @@ -122,6 +126,9 @@ def access_code(self, value: t.Optional[str]): """Set the access code for the class.""" self.access_code_plain = value self.access_code_enc = value + self.access_code_hash = ( + value if value is None else hash_credential(value) + ) # -------------------------------------------------------------------------- diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index d4ae3006..929b57c0 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -18,7 +18,7 @@ from django.utils.translation import gettext_lazy as _ from pyotp import TOTP -from ....hashers import hash_email +from ....hashers import hash_credential from ....models import AbstractBaseUser, DataEncryptionKeyModel from ....models.fields import EncryptedTextField from ....types import Validators @@ -67,11 +67,11 @@ def _create_user_object( password: t.Optional[str], **extra_fields, ): - email = self.normalize_email(email) - user = self.model(email_plain=email, **extra_fields) - user.email_enc = email - user.email_hash = hash_email(email) + user = self.model(**extra_fields) + user.email = email user.password = make_password(password) + user.first_name = extra_fields.get("first_name", "") + user.last_name = extra_fields.get("last_name", "") return user # pylint: disable=missing-function-docstring @@ -140,6 +140,7 @@ class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): EMAIL_FIELD = "email_plain" USERNAME_FIELD = "email_hash" REQUIRED_FIELDS = ["email_plain"] + credential_fields = frozenset(["email", "password"]) _password: t.Optional[str] @@ -150,12 +151,13 @@ class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): session: "Session" # type: ignore[assignment] userprofile: "UserProfile" - credential_fields = frozenset(["email", "password"]) - # -------------------------------------------------------------------------- # First name # -------------------------------------------------------------------------- + first_name_hash = models.CharField( + _("first name hash"), max_length=64, editable=False + ) first_name_plain = models.CharField( _("first name"), max_length=150, blank=True ) @@ -175,6 +177,7 @@ def first_name(self, value: str): """Set the user's first name.""" self.first_name_enc = value self.first_name_plain = value + self.first_name_hash = hash_credential(value) # -------------------------------------------------------------------------- # Last name @@ -204,7 +207,9 @@ def last_name(self, value: str): # Email # -------------------------------------------------------------------------- - email_hash = models.CharField(_("email hash"), max_length=254, blank=True) + email_hash = models.CharField( + _("email hash"), max_length=64, editable=False + ) email_plain = models.EmailField(_("email address"), blank=True) email_enc = EncryptedTextField( associated_data="email", null=True, verbose_name=_("email address") @@ -223,7 +228,7 @@ def email(self, value: str): value = self.objects.normalize_email(value) self.email_plain = value self.email_enc = value - self.email_hash = hash_email(value) + self.email_hash = hash_credential(value) # -------------------------------------------------------------------------- # Other @@ -367,10 +372,14 @@ def anonymize(self): self.is_active = False self.save( update_fields=[ - "first_name", - "last_name", - "email", - "username", + "first_name_hash", + "first_name_plain", + "first_name_enc", + "last_name_plain", + "last_name_enc", + "email_plain", + "email_enc", + "email_hash", "is_active", ] ) diff --git a/codeforlife/user/views/klass.py b/codeforlife/user/views/klass.py index 7f27f45d..94165ebc 100644 --- a/codeforlife/user/views/klass.py +++ b/codeforlife/user/views/klass.py @@ -16,7 +16,6 @@ class ClassViewSet(ModelViewSet[User, Class]): request_user_class = User model_class = Class http_method_names = ["get"] - lookup_field = "access_code" serializer_class = ClassSerializer filterset_class = ClassFilterSet diff --git a/docs/client-side-encryption.md b/docs/client-side-encryption.md index 144f2e8b..58c7589a 100644 --- a/docs/client-side-encryption.md +++ b/docs/client-side-encryption.md @@ -88,8 +88,9 @@ class User(DataEncryptionKeyModel): """ associated_data = "user" # Required for EncryptedModel - username = models.CharField(max_length=150, unique=True) email = EncryptedTextField(associated_data="email") + first_name = models.CharField(max_length=150) + last_name = models.CharField(max_length=150) class Meta: app_label = "auth" @@ -99,8 +100,9 @@ class User(DataEncryptionKeyModel): # Create a new user. A new DEK is automatically generated and stored in the # 'dek' field. The 'email' field is encrypted using this key. user = User.objects.create( - username="johndoe", - email="john.doe@example.com" + email="john.doe@example.com", + first_name="John", + last_name="Doe", ) # The 'dek' and 'email' fields are stored as encrypted bytes in the database. From 3b95a03bbb17d9e9f12fc699bbb9863d07c2ab8a Mon Sep 17 00:00:00 2001 From: SKairinos Date: Fri, 13 Mar 2026 17:23:03 +0000 Subject: [PATCH 44/58] delete AWS code --- codeforlife/__init__.py | 17 -- codeforlife/settings/__init__.py | 1 - codeforlife/settings/custom.py | 60 +--- codeforlife/settings/django.py | 163 +---------- codeforlife/settings/otp.py | 70 ----- codeforlife/settings/third_party.py | 27 +- codeforlife/tasks/__init__.py | 7 - codeforlife/tasks/bigquery.py | 410 ---------------------------- codeforlife/tasks/bigquery_test.py | 379 ------------------------- codeforlife/tasks/utils.py | 64 ----- codeforlife/tests/__init__.py | 1 - codeforlife/tests/celery.py | 67 ----- codeforlife/types.py | 2 - 13 files changed, 12 insertions(+), 1256 deletions(-) delete mode 100644 codeforlife/settings/otp.py delete mode 100644 codeforlife/tasks/__init__.py delete mode 100644 codeforlife/tasks/bigquery.py delete mode 100644 codeforlife/tasks/bigquery_test.py delete mode 100644 codeforlife/tasks/utils.py delete mode 100644 codeforlife/tests/celery.py diff --git a/codeforlife/__init__.py b/codeforlife/__init__.py index e678a3c7..d364e542 100644 --- a/codeforlife/__init__.py +++ b/codeforlife/__init__.py @@ -6,7 +6,6 @@ import os import sys import typing as t -from io import StringIO from pathlib import Path from types import SimpleNamespace @@ -112,21 +111,5 @@ def set_up_settings(service_base_dir: Path, service_name: str): secrets_file.write(secrets_file_comment) secrets = dotenv_values(secrets_path) - else: - # pylint: disable-next=import-outside-toplevel - import boto3 - - s3: "S3Client" = boto3.client("s3") - secrets_object = s3.get_object( - Bucket=os.environ["aws_s3_app_bucket"], - Key=( - os.environ["aws_s3_app_folder"] - + f"/secure/.env.secrets.{service_name}" - ), - ) - - secrets = dotenv_values( - stream=StringIO(secrets_object["Body"].read().decode("utf-8")) - ) return Secrets(**secrets) diff --git a/codeforlife/settings/__init__.py b/codeforlife/settings/__init__.py index 0166da8d..d7418f43 100644 --- a/codeforlife/settings/__init__.py +++ b/codeforlife/settings/__init__.py @@ -17,5 +17,4 @@ from .custom import * from .django import * from .google import * -from .otp import * from .third_party import * diff --git a/codeforlife/settings/custom.py b/codeforlife/settings/custom.py index 13a66574..f2c57f21 100644 --- a/codeforlife/settings/custom.py +++ b/codeforlife/settings/custom.py @@ -5,34 +5,19 @@ This file contains all of our custom settings we define for our own purposes. """ -import json import os import re import typing as t from pathlib import Path -import boto3 - -from .otp import ( - AWS_S3_APP_BUCKET, - AWS_S3_APP_FOLDER, - AWS_S3_STATIC_FOLDER, - CACHE_DB_DATA_PATH, -) - if t.TYPE_CHECKING: - from mypy_boto3_s3.client import S3Client - from ..server import Server - from ..types import CookieSamesite, DatabaseEngine, Env, JsonDict + from ..types import CookieSamesite, Env # The name of the current environment. ENV = t.cast("Env", os.getenv("ENV", "local")) -# The database's engine type. -DB_ENGINE = t.cast("DatabaseEngine", os.getenv("DB_ENGINE", "postgresql")) - # The mode the service is being served in. SERVER_MODE = t.cast("Server.Mode", os.getenv("SERVER_MODE", "django")) @@ -68,11 +53,6 @@ # The frontend url of the current service. SERVICE_SITE_URL = os.getenv("SERVICE_SITE_URL", "http://localhost:5173") -# The location of the service's folder in the s3 buckets. -SERVICE_S3_APP_LOCATION = f"{AWS_S3_APP_FOLDER}/{SERVICE_NAME}/{SERVER_MODE}" -SERVICE_S3_STATIC_LOCATION = ( - f"{AWS_S3_STATIC_FOLDER}/{SERVICE_NAME}/{SERVER_MODE}" -) # The authorization bearer token used to authenticate with Dotdigital. MAIL_AUTH = os.getenv("MAIL_AUTH", "REPLACE_ME") @@ -89,44 +69,6 @@ SESSION_METADATA_COOKIE_SAMESITE: "CookieSamesite" = "Strict" -def get_redis_url(): - """Get the Redis URL for the current environment. - - Raises: - ConnectionAbortedError: If the engine is not Redis. - - Returns: - The Redis URL. - """ - - if ENV == "local": - host = os.getenv("REDIS_HOST", "cache") - port = int(os.getenv("REDIS_PORT", "6379")) - path = os.getenv("REDIS_PATH", "0") - url = f"{host}:{port}/{path}" - else: - # Get the dbdata object. - s3: "S3Client" = boto3.client("s3") - db_data_object = s3.get_object( - Bucket=t.cast(str, AWS_S3_APP_BUCKET), Key=CACHE_DB_DATA_PATH - ) - - # Load the object as a JSON dict. - db_data: "JsonDict" = json.loads( - db_data_object["Body"].read().decode("utf-8") - ) - if not db_data or db_data["Engine"] != "Redis": - raise ConnectionAbortedError("Invalid database data.") - - endpoint = t.cast(dict, db_data["Endpoint"]) - url = t.cast(str, endpoint["0001"]) - - return f"redis://{url}" - - -# The URL to connect to the Redis cache. -REDIS_URL = get_redis_url() - # A flag to indicate whether the old system is the current runtime to # conditionally run code that is still needed for the old system to work but is # no longer needed in the new system. Once the old system is fully deprecated, diff --git a/codeforlife/settings/django.py b/codeforlife/settings/django.py index 231f3c3e..4081b176 100644 --- a/codeforlife/settings/django.py +++ b/codeforlife/settings/django.py @@ -6,48 +6,21 @@ https://docs.djangoproject.com/en/5.1/ref/settings/ """ -import json import os -import typing as t -import boto3 from django.utils.translation import gettext_lazy as _ from .. import TEMPLATES_DIR from .custom import ( - DB_ENGINE, ENV, LOG_LEVEL, - REDIS_URL, SERVICE_BASE_DIR, SERVICE_BASE_URL, SERVICE_DOMAIN, SERVICE_EXTERNAL_DOMAIN, SERVICE_NAME, - SERVICE_S3_APP_LOCATION, - SERVICE_S3_STATIC_LOCATION, SERVICE_SITE_URL, ) -from .otp import ( - AWS_REGION, - AWS_S3_APP_BUCKET, - AWS_S3_APP_DEFAULT_ACL, - AWS_S3_APP_DOMAIN, - AWS_S3_APP_QUERYSTRING_AUTH, - AWS_S3_APP_QUERYSTRING_EXPIRE, - AWS_S3_STATIC_BUCKET, - AWS_S3_STATIC_DEFAULT_ACL, - AWS_S3_STATIC_DOMAIN, - AWS_S3_STATIC_QUERYSTRING_AUTH, - AWS_S3_STATIC_QUERYSTRING_EXPIRE, - RDS_DB_DATA_PATH, -) - -if t.TYPE_CHECKING: - from mypy_boto3_s3.client import S3Client - - from ..types import JsonDict - # SECURITY WARNING: don't run with debug turned on in production! DEBUG = bool(int(os.getenv("DEBUG", "1"))) @@ -63,65 +36,17 @@ # Database # https://docs.djangoproject.com/en/5.1/ref/settings/#databases - -def get_databases(): - """Get the databases for the current environment. - - Raises: - ConnectionAbortedError: If the engine is not postgres. - - Returns: - The database configs. - """ - - if DB_ENGINE == "sqlite": - return { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": ":memory:", - } - } - - if ENV == "local": - name = os.getenv("DB_NAME", SERVICE_NAME) - user = os.getenv("DB_USER", "root") - password = os.getenv("DB_PASSWORD", "password") - host = os.getenv("DB_HOST", "db") - port = int(os.getenv("DB_PORT", "5432")) - else: - # Get the dbdata object. - s3: "S3Client" = boto3.client("s3") - db_data_object = s3.get_object( - Bucket=t.cast(str, AWS_S3_APP_BUCKET), Key=RDS_DB_DATA_PATH - ) - - # Load the object as a JSON dict. - db_data: "JsonDict" = json.loads( - db_data_object["Body"].read().decode("utf-8") - ) - if not db_data or db_data["DBEngine"] != "postgres": - raise ConnectionAbortedError("Invalid database data.") - - name = t.cast(str, db_data["Database"]) - user = t.cast(str, db_data["user"]) - password = t.cast(str, db_data["password"]) - host = t.cast(str, db_data["Endpoint"]) - port = t.cast(int, db_data["Port"]) - - return { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": name, - "USER": user, - "PASSWORD": password, - "HOST": host, - "PORT": port, - "ATOMIC_REQUESTS": True, - } +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("DB_NAME", SERVICE_NAME), + "USER": os.getenv("DB_USER", "root"), + "PASSWORD": os.getenv("DB_PASSWORD", "password"), + "HOST": os.getenv("DB_HOST", "db"), + "PORT": int(os.getenv("DB_PORT", "5432")), + "ATOMIC_REQUESTS": True, } - - -DATABASES = get_databases() +} # Application definition @@ -291,26 +216,6 @@ def get_databases(): "storages", ] -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.1/howto/static-files/ - -STATIC_ROOT = SERVICE_BASE_DIR / "static" -STATIC_URL = ( - f"https://{AWS_S3_STATIC_DOMAIN}/{SERVICE_S3_STATIC_LOCATION}/" - if ENV != "local" - else "/static/" -) - -# User-uploaded files -# https://docs.djangoproject.com/en/5.1/topics/files/ - -MEDIA_ROOT = SERVICE_BASE_DIR / "media" -MEDIA_URL = ( - f"https://{AWS_S3_APP_DOMAIN}/{SERVICE_S3_APP_LOCATION}/" - if ENV != "local" - else "/media/" -) - # Templates # https://docs.djangoproject.com/en/5.1/ref/templates/ @@ -332,51 +237,3 @@ def get_databases(): }, }, ] - -# Storages -# https://docs.djangoproject.com/en/5.1/ref/settings/#storages - -STORAGES: t.Dict[str, t.Any] = { - "default": ( - {"BACKEND": "django.core.files.storage.FileSystemStorage"} - if ENV == "local" - else { - "BACKEND": "storages.backends.s3.S3Storage", - # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings - "OPTIONS": { - "bucket_name": AWS_S3_APP_BUCKET, - "location": SERVICE_S3_APP_LOCATION, - "region_name": AWS_REGION, - "default_acl": AWS_S3_APP_DEFAULT_ACL, - "querystring_auth": AWS_S3_APP_QUERYSTRING_AUTH, - "querystring_expire": AWS_S3_APP_QUERYSTRING_EXPIRE, - }, - } - ), - "staticfiles": ( - {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"} - if ENV == "local" - else { - "BACKEND": "storages.backends.s3.S3Storage", - # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings - "OPTIONS": { - "bucket_name": AWS_S3_STATIC_BUCKET, - "location": SERVICE_S3_STATIC_LOCATION, - "region_name": AWS_REGION, - "default_acl": AWS_S3_STATIC_DEFAULT_ACL, - "querystring_auth": AWS_S3_STATIC_QUERYSTRING_AUTH, - "querystring_expire": AWS_S3_STATIC_QUERYSTRING_EXPIRE, - }, - } - ), -} - -# Caches -# https://docs.djangoproject.com/en/5.1/topics/cache/ - -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": REDIS_URL, - } -} diff --git a/codeforlife/settings/otp.py b/codeforlife/settings/otp.py deleted file mode 100644 index 30ebc790..00000000 --- a/codeforlife/settings/otp.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -© Ocado Group -Created on 29/11/2024 at 15:59:40(+00:00). - -This file contains all the variables required and/or exposed by Ocado's -Technology Platform. - -NOTE: All variables should be retrieved like so: `os.getenv("key")`. -""" - -import os - -# App - -APP_ID = os.getenv("APP_ID") -APP_VERSION = os.getenv("APP_VERSION") - -# AWS - -AWS_REGION = os.getenv("aws_region") - -# AWS S3 - -AWS_S3_APP_BUCKET = os.getenv("aws_s3_app_bucket") -AWS_S3_APP_FOLDER = os.getenv("aws_s3_app_folder") -AWS_S3_APP_DOMAIN = f"{AWS_S3_APP_BUCKET}.s3.amazonaws.com" -AWS_S3_APP_DEFAULT_ACL = os.getenv("AWS_S3_APP_DEFAULT_ACL") -AWS_S3_APP_QUERYSTRING_AUTH = bool( - int(os.getenv("AWS_S3_APP_QUERYSTRING_AUTH", "1")) -) -AWS_S3_APP_QUERYSTRING_EXPIRE = int( - os.getenv("AWS_S3_APP_QUERYSTRING_EXPIRE", "3600") -) -AWS_S3_STATIC_BUCKET = os.getenv("AWS_S3_STATIC_BUCKET") -AWS_S3_STATIC_FOLDER = os.getenv("AWS_S3_STATIC_FOLDER") -AWS_S3_STATIC_DOMAIN = f"{AWS_S3_STATIC_BUCKET}.s3.amazonaws.com" -AWS_S3_STATIC_DEFAULT_ACL = os.getenv("AWS_S3_STATIC_DEFAULT_ACL") -AWS_S3_STATIC_QUERYSTRING_AUTH = bool( - int(os.getenv("AWS_S3_STATIC_QUERYSTRING_AUTH", "1")) -) -AWS_S3_STATIC_QUERYSTRING_EXPIRE = int( - os.getenv("AWS_S3_STATIC_QUERYSTRING_EXPIRE", "3600") -) - -# RDS - -RDS_DB_NAME = os.getenv("RDS_DB_NAME") -RDS_SCHEMA_NAME = os.getenv("RDS_SCHEMA_NAME") -RDS_INSTANCE_NAME = os.getenv("RDS_INSTANCE_NAME") -RDS_DB_DATA_PATH = ( - f"{AWS_S3_APP_FOLDER}/dbMetadata/" - + (f"{RDS_INSTANCE_NAME}/" if RDS_INSTANCE_NAME else "") - + f"{RDS_DB_NAME}/{RDS_SCHEMA_NAME}.dbdata" -) - -# ElastiCache - -CACHE_CLUSTER_ID = os.getenv("CACHE_CLUSTER_ID") -CACHE_DB_DATA_PATH = ( - f"{AWS_S3_APP_FOLDER}/elasticacheMetadata/{CACHE_CLUSTER_ID}.dbdata" -) - -# GCP - -GCP_WIF_AUDIENCE = os.getenv("GCP_WIF_AUDIENCE") -GCP_WIF_SERVICE_ACCOUNT = os.getenv("GCP_WIF_SERVICE_ACCOUNT") - -# SQS - -SQS_URL = os.getenv("SQS_URL") diff --git a/codeforlife/settings/third_party.py b/codeforlife/settings/third_party.py index 43c10f39..459adea9 100644 --- a/codeforlife/settings/third_party.py +++ b/codeforlife/settings/third_party.py @@ -7,11 +7,7 @@ import os -from ..tasks import get_local_sqs_url as _get_local_sqs_url -from .custom import ENV, SERVICE_NAME, SERVICE_SITE_URL -from .django import TIME_ZONE -from .otp import AWS_REGION as OTP_AWS_REGION -from .otp import SQS_URL +from .custom import ENV, SERVICE_SITE_URL # CORS # https://pypi.org/project/django-cors-headers/ @@ -43,24 +39,3 @@ AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "test") AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "test") AWS_ENDPOINT_URL = os.getenv("AWS_ENDPOINT_URL", "http://aws:4566") - -# Celery -# https://docs.celeryq.dev/en/v5.4.0/userguide/configuration.html -# https://docs.celeryq.dev/en/v5.4.0/getting-started/backends-and-brokers/sqs.html - -CELERY_TIMEZONE = TIME_ZONE -CELERY_BROKER_URL = "sqs://" -CELERY_BROKER_TRANSPORT_OPTIONS = { - "region": OTP_AWS_REGION if ENV != "local" else AWS_REGION, - "predefined_queues": { - SERVICE_NAME: { - "url": ( - SQS_URL - if ENV != "local" - else _get_local_sqs_url(AWS_REGION, SERVICE_NAME) - ) - } - }, -} -CELERY_TASK_DEFAULT_QUEUE = SERVICE_NAME -CELERY_TASK_TIME_LIMIT = 60 * 30 diff --git a/codeforlife/tasks/__init__.py b/codeforlife/tasks/__init__.py deleted file mode 100644 index 265c0975..00000000 --- a/codeforlife/tasks/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -© Ocado Group -Created on 06/10/2025 at 17:14:31(+01:00). -""" - -from .bigquery import BigQueryTask -from .utils import get_local_sqs_url, get_task_name, shared_task diff --git a/codeforlife/tasks/bigquery.py b/codeforlife/tasks/bigquery.py deleted file mode 100644 index 05ad2c8f..00000000 --- a/codeforlife/tasks/bigquery.py +++ /dev/null @@ -1,410 +0,0 @@ -""" -© Ocado Group -Created on 06/10/2025 at 17:15:37(+01:00). -""" - -import csv -import io -import logging -import typing as t -from dataclasses import dataclass, field -from datetime import date, datetime, time, timezone -from tempfile import NamedTemporaryFile, _TemporaryFileWrapper - -from celery import Task -from celery import shared_task as _shared_task -from django.conf import settings as django_settings -from django.core.exceptions import ValidationError -from django.db.models.query import QuerySet -from google.cloud.bigquery import ( - Client, - CreateDisposition, - LoadJobConfig, - SourceFormat, - WriteDisposition, -) - -from ..auth import get_gcp_service_account_credentials -from ..types import KwArgs -from .utils import get_task_name - -if t.TYPE_CHECKING: - CsvFile = _TemporaryFileWrapper[bytes] - - -# pylint: disable-next=abstract-method -class BigQueryTask(Task): - """A task which loads data from a Django queryset into a BigQuery table.""" - - TABLE_NAMES: t.Set[str] = set() - - WriteDisposition: t.TypeAlias = WriteDisposition # shorthand - GetQuerySet: t.TypeAlias = t.Callable[..., QuerySet[t.Any]] - - @dataclass(frozen=True) - # pylint: disable-next=too-many-instance-attributes - class Settings: - """The settings for a BigQuery task.""" - - # The BigQuery table's write disposition. - write_disposition: str - # The number of rows to write at a time. Must be a multiple of 10. - chunk_size: int - # The [Django model] fields to include in the CSV. - fields: t.List[str] - # The name of the field used to identify each row. - id_field: str = "id" - # The maximum amount of time this task is allowed to take before it's - # hard-killed. - time_limit: int = 3600 - # The name of the BigQuery table where the data will ultimately be - # saved. If not provided, the name of the decorated function is used. - table_name: t.Optional[str] = None - # The maximum number of retries allowed. - max_retries: int = 5 - # The countdown before attempting the next retry. - retry_countdown: int = 10 - # The additional keyword arguments to pass to the Celery task decorator. - kwargs: KwArgs = field(default_factory=dict) - - def __post_init__(self): - # Set required values as defaults. - self.kwargs.setdefault("bind", True) - self.kwargs.setdefault("base", BigQueryTask) - - # Ensure the ID field is always present. - if self.id_field not in self.fields: - self.fields.append(self.id_field) - - # Validate args. - if not self.write_disposition.startswith("WRITE_") or not hasattr( - WriteDisposition, self.write_disposition - ): - raise ValidationError( - f'The write disposition "{self.write_disposition}"' - " does not exist.", - code="write_disposition_does_not_exist", - ) - if self.chunk_size <= 0: - raise ValidationError( - "The chunk size must be > 0.", - code="chunk_size_lte_0", - ) - if self.chunk_size % 10 != 0: - raise ValidationError( - "The chunk size must be a multiple of 10.", - code="chunk_size_not_multiple_of_10", - ) - if len(self.fields) <= 1: - raise ValidationError( - "Must provide at least 1 field (not including ID field).", - code="no_fields", - ) - if len(self.fields) != len(set(self.fields)): - raise ValidationError( - "Fields must be unique.", - code="duplicate_fields", - ) - if self.time_limit <= 0: - raise ValidationError( - "The time limit must be > 0.", - code="time_limit_lte_0", - ) - if self.time_limit > 3600: - raise ValidationError( - "The time limit must be <= 3600 (1 hour).", - code="time_limit_gt_3600", - ) - if self.max_retries < 0: - raise ValidationError( - "The max retries must be >= 0.", - code="max_retries_lt_0", - ) - if self.retry_countdown < 0: - raise ValidationError( - "The retry countdown must be >= 0.", - code="retry_countdown_lt_0", - ) - if self.kwargs["bind"] is not True: - raise ValidationError( - "The task must be bound.", code="task_unbound" - ) - if not issubclass(self.kwargs["base"], BigQueryTask): - raise ValidationError( - f"The base must be a subclass of " - f"'{BigQueryTask.__module__}." - f"{BigQueryTask.__qualname__}'.", - code="base_not_subclass", - ) - - settings: Settings - get_queryset: GetQuerySet - - @classmethod - def register_table_name(cls, table_name: str): - """Register a table name to ensure it is unique. - - Args: - table_name: The name of the table to register. - - Raises: - ValidationError: If the table name is already registered. - """ - - if table_name in cls.TABLE_NAMES: - raise ValidationError( - f'The table name "{table_name}" is already registered.', - code="table_name_already_registered", - ) - - cls.TABLE_NAMES.add(table_name) - - def get_ordered_queryset(self, *task_args, **task_kwargs): - """Get the ordered queryset. - - Args: - task_args: The positional arguments passed to the task. - task_kwargs: The keyword arguments passed to the task. - - Returns: - The ordered queryset. - """ - - queryset = self.get_queryset(*task_args, **task_kwargs) - if not queryset.ordered: - queryset = queryset.order_by(self.settings.id_field) - - return queryset - - @staticmethod - def format_value_for_csv(value: t.Any) -> str: - """Format a value for inclusion in a CSV file. - - Args: - value: The value to format. - - Returns: - The formatted value as a string. - """ - - if value is None: - return "" # BigQuery treats an empty string as NULL/None. - if isinstance(value, datetime): - return ( - value.astimezone(timezone.utc) - .replace(tzinfo=None) - .isoformat(sep=" ") - ) - if isinstance(value, (date, time)): - return value.isoformat() - if not isinstance(value, str): - return str(value) - - return value - - @classmethod - def write_queryset_to_csv( - cls, - fields: t.List[str], - chunk_size: int, - queryset: QuerySet[t.Any], - csv_file: "CsvFile", - ): - """Write a queryset to a CSV file. - - Args: - fields: The list of fields to include in the CSV. - chunk_size: The number of rows to write at a time. - queryset: The queryset to write. - csv_file: The CSV file to write to. - - Returns: - Whether any values were written to the CSV file. - """ - - text_wrapper = io.TextIOWrapper(csv_file, encoding="utf-8", newline="") - - csv_writer = csv.writer( - text_wrapper, lineterminator="\n", quoting=csv.QUOTE_MINIMAL - ) - csv_writer.writerow(fields) # Write the headers. - - chunk_index = 1 # 1 based index. For logging. - wrote_values = False # Track if any values were written. - - for row_index, values in enumerate( - t.cast( - t.Iterator[t.Tuple[t.Any, ...]], - # Iterate chunks to avoid OOM for large querysets. - queryset.values_list(*fields).iterator(chunk_size), - ) - ): - if row_index % chunk_size == 0: - logging.info("Writing chunk %d", chunk_index) - chunk_index += 1 - - csv_row = [cls.format_value_for_csv(value) for value in values] - csv_writer.writerow(csv_row) - wrote_values = True - - # Move back 1 byte (because lineterminator is "\n"). - text_wrapper.seek(text_wrapper.tell() - 1) - # Chop off the trailing newline. - text_wrapper.truncate() - # Detach the wrapper to flush data to the binary file. - text_wrapper.detach() - - return wrote_values - - @staticmethod - def load_csv_into_bq( - write_disposition: str, - time_limit: int, - table_name: str, - csv_file: "CsvFile", - ): - """Load a CSV file into a BigQuery table. - - Args: - write_disposition: Write disposition for the BigQuery table. - time_limit: The maximum time to wait for the load job to complete. - table_name: The table name in BigQuery. - csv_file: The CSV file to load into BigQuery. - """ - - bq_client = Client( - project=django_settings.GOOGLE_CLOUD_PROJECT_ID, - credentials=get_gcp_service_account_credentials( - token_lifetime_seconds=time_limit - ), - ) - - full_table_id = ".".join( - [ - django_settings.GOOGLE_CLOUD_PROJECT_ID, - django_settings.GOOGLE_CLOUD_BIGQUERY_DATASET_ID, - table_name, - ] - ) - - csv_file.seek(0) # Reset file pointer to the start. - - logging.info("Starting BigQuery load job.") - # Load the temporary CSV file into BigQuery. - bq_load_job = bq_client.load_table_from_file( - file_obj=csv_file, - destination=full_table_id, - job_config=LoadJobConfig( - create_disposition=CreateDisposition.CREATE_IF_NEEDED, - source_format=SourceFormat.CSV, - skip_leading_rows=1, - write_disposition=write_disposition, - time_zone="Etc/UTC", - date_format="YYYY-MM-DD", - time_format="HH24:MI:SS", - datetime_format="YYYY-MM-DD HH24:MI:SS", - ), - ) - - bq_load_job.result() - logging.info( - "Successfully loaded %d rows into to BigQuery table %s.", - bq_load_job.output_rows, - full_table_id, - ) - - @staticmethod - # pylint: disable-next=too-many-locals,bad-staticmethod-argument - def _load_data_into_bq( - self: "BigQueryTask", table_name: str, *task_args, **task_kwargs - ): - queryset = self.get_ordered_queryset(*task_args, **task_kwargs) - - with NamedTemporaryFile( - mode="w+b", suffix=".csv", delete=True - ) as csv_file: - if self.write_queryset_to_csv( - fields=self.settings.fields, - chunk_size=self.settings.chunk_size, - queryset=queryset, - csv_file=csv_file, - ): - self.load_csv_into_bq( - write_disposition=self.settings.write_disposition, - time_limit=self.settings.time_limit, - table_name=table_name, - csv_file=csv_file, - ) - - @classmethod - def shared(cls, settings: Settings): - """Create a shared BigQuery task. - - This decorator creates a Celery task that saves the queryset to a - BigQuery table. - - Each task *must* be given a distinct table name and queryset to avoid - unintended consequences. - - Examples: - ``` - @BigQueryTask.shared( - BigQueryTask.Settings( - # table_name = "example", <- explicitly set the table name - write_disposition=BigQueryTask.WriteDisposition.WRITE_TRUNCATE, - chunk_size=1000, - fields=["first_name", "joined_at", "is_active"], - ) - ) - def user(): # All users will be saved to a BQ table named "user". - return User.objects.all() - ``` - - Args: - settings: The settings for this BigQuery task. - - Returns: - A wrapper-function which expects to receive a callable that returns - a queryset and returns a Celery task to save the queryset to - BigQuery. - """ - - def wrapper(get_queryset: "BigQueryTask.GetQuerySet"): - table_name = settings.table_name or get_queryset.__name__ - cls.register_table_name(table_name) - - # Wraps the task with retry logic. - def task(self: "BigQueryTask", *task_args, **task_kwargs): - try: - cls._load_data_into_bq( - self, table_name, *task_args, **task_kwargs - ) - except Exception as exc: - raise self.retry( - args=task_args, - kwargs=task_kwargs, - exc=exc, - countdown=settings.retry_countdown, - ) - - # Namespace the task with service's name. If the name is not - # explicitly provided, it defaults to the name of the decorated - # function. - name = settings.kwargs.pop("name", None) - name = get_task_name( - name if isinstance(name, str) else get_queryset - ) - - return t.cast( - BigQueryTask, - _shared_task( # type: ignore[call-overload] - **settings.kwargs, - name=name, - time_limit=settings.time_limit, - max_retries=settings.max_retries, - settings=settings, - get_queryset=staticmethod(get_queryset), - )(task), - ) - - return wrapper diff --git a/codeforlife/tasks/bigquery_test.py b/codeforlife/tasks/bigquery_test.py deleted file mode 100644 index 41b43b72..00000000 --- a/codeforlife/tasks/bigquery_test.py +++ /dev/null @@ -1,379 +0,0 @@ -""" -© Ocado Group -Created on 02/10/2025 at 17:22:38(+01:00). -""" - -import csv -import io -import os -import typing as t -from datetime import date, datetime, time, timedelta, timezone -from tempfile import NamedTemporaryFile -from unittest.mock import MagicMock - -from celery import Celery -from django.conf import settings -from django.db.models.query import QuerySet -from google.cloud.bigquery import CreateDisposition, SourceFormat - -from ..tests import CeleryTestCase -from ..types import KwArgs -from ..user.models import User -from .bigquery import BigQueryTask - -if t.TYPE_CHECKING: - from tempfile import _TemporaryFileWrapper - -CsvFile = t.Union[io.BufferedReader, "_TemporaryFileWrapper[bytes]"] - -# pylint: disable=missing-class-docstring - - -# pylint: disable-next=too-many-instance-attributes,too-many-public-methods -class TestLoadDataIntoBigQueryTask(CeleryTestCase): - fixtures = ["school_1"] - - append_users: BigQueryTask - truncate_users: BigQueryTask - - @staticmethod - def _get_users(order_by: t.Optional[str] = None): - queryset = User.objects.all() - if order_by: - queryset = queryset.order_by(order_by) - return queryset - - @classmethod - def setUpClass(cls): - cls.app = Celery(broker="memory://") - - cls.append_users = BigQueryTask.shared( - BigQueryTask.Settings( - table_name="user__append", - write_disposition=BigQueryTask.WriteDisposition.WRITE_APPEND, - chunk_size=10, - fields=["first_name", "is_active"], - ) - )(cls._get_users) - - cls.truncate_users = BigQueryTask.shared( - BigQueryTask.Settings( - table_name="user__truncate", - write_disposition=BigQueryTask.WriteDisposition.WRITE_TRUNCATE, - chunk_size=10, - fields=["first_name", "is_active"], - ) - )(cls._get_users) - - return super().setUpClass() - - def setUp(self): - def target(relative_dot_path: str): # Shortcut for patching. - return f"{BigQueryTask.__module__}.{relative_dot_path}" - - # Mock creating a NamedTemporaryFile. - # pylint: disable-next=consider-using-with - self.csv_file = NamedTemporaryFile( - mode="w+b", suffix=".csv", delete=False - ) - self.addCleanup(os.remove, self.csv_file.name) - self.mock_named_temporary_file = self.patch( - target("NamedTemporaryFile"), return_value=self.csv_file - ) - - # Mock getting GCP service account credentials. - self.credentials = "I can haz cheezburger?" - self.mock_get_gcp_service_account_credentials = self.patch( - target("get_gcp_service_account_credentials"), - return_value=self.credentials, - ) - - # Mock BigQuery client and its methods. - self.mock_bq_client = MagicMock() - self.mock_bq_client_class = self.patch( - target("Client"), return_value=self.mock_bq_client - ) - - # Mock load_table_from_file method and its result(). - self.mock_load_table_from_file: MagicMock = ( - self.mock_bq_client.load_table_from_file - ) - self.mock_load_job = MagicMock() - self.mock_load_table_from_file.return_value = self.mock_load_job - self.mock_load_job_result: MagicMock = self.mock_load_job.result - self.job_config = MagicMock() - self.mock_load_job_config_class = self.patch( - target("LoadJobConfig"), return_value=self.job_config - ) - - return super().setUp() - - # assertions - - def _assert_queryset_written_to_csv( - self, - queryset: QuerySet[t.Any], - fields: t.List[str], - csv_file: t.Optional[CsvFile] = None, - ): - # Read the actual CSV content. - csv_file = csv_file or self.csv_file - csv_file.seek(0) - actual_content = csv_file.read().decode("utf-8") - - # Generate the expected CSV content. - csv_content = io.StringIO() - csv_writer = csv.writer( - csv_content, lineterminator="\n", quoting=csv.QUOTE_MINIMAL - ) - csv_writer.writerow(fields) # Write the headers. - for obj in queryset: - csv_writer.writerow( - [ - BigQueryTask.format_value_for_csv(getattr(obj, field)) - for field in fields - ] - ) - expected_content = csv_content.getvalue().rstrip() - - # Assert the actual CSV content matches the expected content. - assert actual_content == expected_content - - def _assert_csv_file_loaded_into_bigquery( - self, - table_name: str, - token_lifetime_seconds: int, - write_disposition: str, - csv_file: CsvFile, - ): - # Assert BigQuery client was created. - self.mock_get_gcp_service_account_credentials.assert_called_once_with( - token_lifetime_seconds=token_lifetime_seconds - ) - self.mock_bq_client_class.assert_called_once_with( - project=settings.GOOGLE_CLOUD_PROJECT_ID, - credentials=self.credentials, - ) - - # Assert load job was created and run. - self.mock_load_job_config_class.assert_called_once_with( - create_disposition=CreateDisposition.CREATE_IF_NEEDED, - source_format=SourceFormat.CSV, - skip_leading_rows=1, - write_disposition=write_disposition, - time_zone="Etc/UTC", - date_format="YYYY-MM-DD", - time_format="HH24:MI:SS", - datetime_format="YYYY-MM-DD HH24:MI:SS", - ) - self.mock_load_table_from_file.assert_called_once_with( - file_obj=csv_file, - destination=".".join( - [ - settings.GOOGLE_CLOUD_PROJECT_ID, - settings.GOOGLE_CLOUD_BIGQUERY_DATASET_ID, - table_name, - ] - ), - job_config=self.job_config, - ) - self.mock_load_job_result.assert_called_once_with() - - # settings - - # pylint: disable-next=too-many-arguments,too-many-positional-arguments - def _test_settings( - self, - code: str, - write_disposition: str = BigQueryTask.WriteDisposition.WRITE_APPEND, - chunk_size: int = 10, - fields: t.Optional[t.List[str]] = None, - kwargs: t.Optional[KwArgs] = None, - **settings_kwargs, - ): - with self.assert_raises_validation_error(code=code): - BigQueryTask.Settings( - write_disposition=write_disposition, - chunk_size=chunk_size, - fields=fields or ["some_field"], - kwargs=kwargs or {}, - **settings_kwargs, - ) - - def test_settings__write_disposition_does_not_exist(self): - """Write disposition must exist.""" - self._test_settings( - code="write_disposition_does_not_exist", - write_disposition="WRITE_INVALID", - ) - - def test_settings__chunk_size_lte_0(self): - """Chunk size must be > 0.""" - self._test_settings(code="chunk_size_lte_0", chunk_size=0) - - def test_settings__chunk_size_not_multiple_of_10(self): - """Chunk size must be a multiple of 10.""" - self._test_settings(code="chunk_size_not_multiple_of_10", chunk_size=9) - - def test_settings__no_fields(self): - """Must provide at least 1 field (not including ID field).""" - self._test_settings(code="no_fields", fields=["id"]) - - def test_settings__duplicate_fields(self): - """Fields must be unique.""" - self._test_settings(code="duplicate_fields", fields=["email", "email"]) - - def test_settings__time_limit_lte_0(self): - """Time limit must be > 0.""" - self._test_settings(code="time_limit_lte_0", time_limit=0) - - def test_settings__time_limit_gt_3600(self): - """Time limit must be <= 3600 (1 hour).""" - self._test_settings(code="time_limit_gt_3600", time_limit=3601) - - def test_settings__max_retries_lt_0(self): - """Max retries must be >= 0.""" - self._test_settings(code="max_retries_lt_0", max_retries=-1) - - def test_settings__retry_countdown_lt_0(self): - """Retry countdown must be >= 0.""" - self._test_settings(code="retry_countdown_lt_0", retry_countdown=-1) - - def test_settings__task_unbound(self): - """BigQueryTask must be bound.""" - self._test_settings(code="task_unbound", kwargs={"bind": False}) - - def test_settings__base_not_subclass(self): - """Base must be a subclass of BigQueryTask.""" - self._test_settings(code="base_not_subclass", kwargs={"base": int}) - - # register_table_name - - def test_register_table_name__registered(self): - """An already registered table name raises a ValidationError.""" - table_name = self.append_users.settings.table_name - assert table_name - assert table_name in BigQueryTask.TABLE_NAMES - with self.assert_raises_validation_error( - code="table_name_already_registered" - ): - BigQueryTask.register_table_name(table_name) - - def test_register_table_name__unregistered(self): - """An unregistered table name does not raise an error.""" - table_name = "some_unique_table_name" - assert table_name not in BigQueryTask.TABLE_NAMES - BigQueryTask.register_table_name(table_name) - assert table_name in BigQueryTask.TABLE_NAMES - - # format_value_for_csv - - def test_format_value_for_csv__none(self): - """None is converted to an empty string.""" - assert "" == BigQueryTask.format_value_for_csv(None) - - def test_format_value_for_csv__bool(self): - """Booleans are converted to 0 or 1.""" - assert "True" == BigQueryTask.format_value_for_csv(True) - assert "False" == BigQueryTask.format_value_for_csv(False) - - def test_format_value_for_csv__datetime(self): - """Datetimes are converted to ISO 8601 format with a space separator.""" - assert "2025-02-01 11:30:15" == BigQueryTask.format_value_for_csv( - datetime( - year=2025, month=2, day=1, hour=12, minute=30, second=15 - ).replace(tzinfo=timezone(timedelta(hours=1))) - ) - - def test_format_value_for_csv__date(self): - """Dates are converted to ISO 8601 format.""" - assert "2025-02-01" == BigQueryTask.format_value_for_csv( - date(year=2025, month=2, day=1) - ) - - def test_format_value_for_csv__time(self): - """Times are converted to ISO 8601 format, ignoring timezone info.""" - assert "12:30:15" == BigQueryTask.format_value_for_csv( - time(hour=12, minute=30, second=15) - ) - - # get_ordered_queryset - - def _test_get_ordered_queryset(self, order_by: t.Optional[str] = None): - task = self.append_users - queryset = task.get_ordered_queryset(order_by=order_by) - assert queryset.ordered - assert list(queryset) == list( - User.objects.order_by(order_by or task.settings.id_field) - ) - - def test_get_ordered_queryset__pre_ordered(self): - """Does not reorder an already ordered queryset.""" - self._test_get_ordered_queryset(order_by="first_name") - - def test_get_ordered_queryset__post_ordered(self): - """Orders the queryset if not already ordered. The default is by ID.""" - self._test_get_ordered_queryset() - - # write_queryset_to_csv - - def _test_write_queryset_to_csv( - self, - queryset: QuerySet[t.Any], - fields: t.List[str], - chunk_size: int = 10, - ): - assert queryset.exists() == BigQueryTask.write_queryset_to_csv( - fields=fields, - chunk_size=chunk_size, - queryset=queryset, - csv_file=self.csv_file, - ) - - self._assert_queryset_written_to_csv(queryset, fields) - - def test_write_queryset_to_csv__all(self): - """Values are written to the CSV file.""" - queryset = User.objects.all() - assert queryset.exists() - self._test_write_queryset_to_csv(queryset, fields=["first_name"]) - - def test_write_queryset_to_csv__none(self): - """No values are written to the CSV file.""" - queryset = User.objects.none() - assert not queryset.exists() - self._test_write_queryset_to_csv(queryset, fields=["first_name"]) - - # shared - - def _test_shared__write(self, task: BigQueryTask): - self.apply_task(name=task.name) - - # Assert CSV file was created. - self.mock_named_temporary_file.assert_called_once_with( - mode="w+b", suffix=".csv", delete=True - ) - - # Assert queryset was written to CSV. - assert self.csv_file.closed - with open(self.csv_file.name, "rb") as csv_file: - self._assert_queryset_written_to_csv( - queryset=task.get_ordered_queryset(), - fields=task.settings.fields, - csv_file=csv_file, - ) - - self._assert_csv_file_loaded_into_bigquery( - table_name=task.settings.table_name or task.get_queryset.__name__, - token_lifetime_seconds=task.settings.time_limit, - write_disposition=task.settings.write_disposition, - csv_file=self.csv_file, - ) - - def test_shared__write_append(self): - """The append_users task writes data to BigQuery in append mode.""" - self._test_shared__write(self.append_users) - - def test_shared__write_truncate(self): - """The append_users task writes data to BigQuery in truncate mode.""" - self._test_shared__write(self.truncate_users) diff --git a/codeforlife/tasks/utils.py b/codeforlife/tasks/utils.py deleted file mode 100644 index 18b095d8..00000000 --- a/codeforlife/tasks/utils.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -© Ocado Group -Created on 28/03/2025 at 14:37:46(+00:00). - -Custom utilities for Celery tasks. -""" - -import typing as t - -from celery import shared_task as _shared_task -from django.conf import settings - - -def get_task_name(task: t.Union[str, t.Callable]): - """Namespace a task by the service it's in. - - Args: - task: The name of the task. - - Returns: - The name of the task in the format: "{SERVICE_NAME}.{TASK_NAME}". - """ - - if callable(task): - task = f"{task.__module__}.{task.__name__}" - - if not task.startswith(settings.SERVICE_NAME): - task = f"{settings.SERVICE_NAME}.{task}" - - return task - - -def shared_task(*args, **kwargs): - """ - Wrapper around Celery's default shared_task decorator which namespaces all - tasks to a specific service. - """ - - if len(args) == 1 and callable(args[0]): - task = args[0] - return _shared_task(name=get_task_name(task))(task) - - def wrapper(task: t.Callable): - name = kwargs.pop("name", None) - name = get_task_name(name if isinstance(name, str) else task) - return _shared_task(*args, **kwargs, name=name)(task) - - return wrapper - - -def get_local_sqs_url(aws_region: str, service_name: str): - """Get the URL of an SQS queue in the local environment. - - Args: - aws_region: The AWS region. - service_name: The service this SQS queue belongs to. - - Returns: - The SQS queue's URL. - """ - return ( - f"http://sqs.{aws_region}.localhost.localstack.cloud:4566" - f"/000000000000/{service_name}" - ) diff --git a/codeforlife/tests/__init__.py b/codeforlife/tests/__init__.py index 185ecf84..da013ebe 100644 --- a/codeforlife/tests/__init__.py +++ b/codeforlife/tests/__init__.py @@ -8,7 +8,6 @@ from .api import APITestCase, BaseAPITestCase from .api_client import APIClient, BaseAPIClient from .api_request_factory import APIRequestFactory, BaseAPIRequestFactory -from .celery import CeleryTestCase from .exceptions import InterruptPipelineError from .model import ModelTestCase from .model_list_serializer import ( diff --git a/codeforlife/tests/celery.py b/codeforlife/tests/celery.py deleted file mode 100644 index 98a902c2..00000000 --- a/codeforlife/tests/celery.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -© Ocado Group -Created on 01/04/2025 at 16:57:19(+01:00). -""" - -import typing as t -from importlib import import_module - -from celery import Celery, Task -from django.db.models import QuerySet - -from ..tasks import BigQueryTask, get_task_name -from ..types import Args, KwArgs -from .test import TestCase - - -class CeleryTestCase(TestCase): - """A test case for celery tasks.""" - - # The dot-path of the module containing the Celery app. - app_module: str = "application" - # The name of the Celery app. - app_name: str = "celery" - # The Celery app instance. Auto-imported if not set. - app: Celery - - @classmethod - def setUpClass(cls): - if not hasattr(cls, "app"): - cls.app = getattr(import_module(cls.app_module), cls.app_name) - - return super().setUpClass() - - def apply_task( - self, - name: str, - args: t.Optional[Args] = None, - kwargs: t.Optional[KwArgs] = None, - **options - ): - """Apply a task. - - Args: - name: The name of the task. - args: The args to pass to the task. - kwargs: The keyword args to pass to the task. - """ - task: Task = self.app.tasks[get_task_name(name)] - task.apply(args=args, kwargs=kwargs, **options) - - def assert_bigquery_task( - self, - task: BigQueryTask, - args: t.Optional[Args] = None, - kwargs: t.Optional[KwArgs] = None, - ): - """Assert that the decorated data warehouse task returns a queryset. - - Args: - task: The data warehouse task. - args: The args to pass to the task. - kwargs: The keyword args to pass to the task. - """ - args, kwargs = args or tuple(), kwargs or {} - queryset = task.get_queryset(*args, **kwargs) - self.assertIsInstance(queryset, QuerySet) - queryset.values_list(*task.settings.fields) # assert fields in queryset diff --git a/codeforlife/types.py b/codeforlife/types.py index 6396e9b1..2f660587 100644 --- a/codeforlife/types.py +++ b/codeforlife/types.py @@ -11,8 +11,6 @@ Env = t.Literal["local", "development", "staging", "production"] -DatabaseEngine = t.Literal["postgresql", "sqlite"] - Args = t.Tuple[t.Any, ...] KwArgs = t.Dict[str, t.Any] From 5f800b1c871d319379aa9ebc4a884de2e2a81cdf Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 16 Mar 2026 10:33:04 +0000 Subject: [PATCH 45/58] add hash fields --- codeforlife/encryption.py | 16 +++++----- .../models/fields/base_encrypted_test.py | 24 ++++++++++----- .../models/fields/data_encryption_key.py | 25 +++++++++------- .../models/fields/data_encryption_key_test.py | 1 - codeforlife/user/filters/klass.py | 18 +++++++++-- codeforlife/user/filters/user.py | 30 ++++++++++++------- codeforlife/user/models/klass.py | 5 ++++ codeforlife/user/models/student.py | 14 +-------- codeforlife/user/models/teacher/teacher.py | 1 - codeforlife/user/models/user/google.py | 14 +++++---- codeforlife/user/models/user/independent.py | 4 --- codeforlife/user/models/user/student.py | 12 -------- codeforlife/user/models/user/teacher.py | 4 --- codeforlife/user/models/user/user.py | 3 ++ codeforlife/user/serializers/user_test.py | 30 +++++++++++++++++-- codeforlife/user/views/klass_test.py | 17 ++++++----- codeforlife/user/views/user_test.py | 18 ++++++----- 17 files changed, 138 insertions(+), 98 deletions(-) diff --git a/codeforlife/encryption.py b/codeforlife/encryption.py index b0a2ea4c..17b4c6e4 100644 --- a/codeforlife/encryption.py +++ b/codeforlife/encryption.py @@ -52,20 +52,22 @@ class FakeAead: """A fake AEAD primitive for local testing.""" - @staticmethod + ciphertext_prefix = b"fake_enc:" + + @classmethod # pylint: disable-next=unused-argument - def encrypt(plaintext: bytes, associated_data: bytes = b""): + def encrypt(cls, plaintext: bytes, associated_data: bytes = b""): """Simulate ciphertext by wrapping in base64 and adding a prefix.""" - return b"fake_enc:" + b64encode(plaintext) + return cls.ciphertext_prefix + b64encode(plaintext) - @staticmethod + @classmethod # pylint: disable-next=unused-argument - def decrypt(ciphertext: bytes, associated_data: bytes = b""): + def decrypt(cls, ciphertext: bytes, associated_data: bytes = b""): """Simulate decryption by removing prefix and base64 decoding.""" - if not ciphertext.startswith(b"fake_enc:"): + if not ciphertext.startswith(cls.ciphertext_prefix): raise ValueError("Invalid ciphertext for fake mock") - return b64decode(ciphertext.replace(b"fake_enc:", b"")) + return b64decode(ciphertext.replace(cls.ciphertext_prefix, b"")) @classmethod def as_mock(cls): diff --git a/codeforlife/models/fields/base_encrypted_test.py b/codeforlife/models/fields/base_encrypted_test.py index 4c206d1e..342c9f04 100644 --- a/codeforlife/models/fields/base_encrypted_test.py +++ b/codeforlife/models/fields/base_encrypted_test.py @@ -84,8 +84,8 @@ def _value_to_bytes(value: str): def _bytes_to_value(data: bytes): return data.decode() - def __init__(self, associated_data, default=None, **kwargs): - super().__init__(associated_data, default, **kwargs) + def __init__(self, associated_data, **kwargs): + super().__init__(associated_data, **kwargs) self.value_to_bytes = MagicMock(side_effect=self._value_to_bytes) self.bytes_to_value = MagicMock(side_effect=self._bytes_to_value) @@ -148,14 +148,12 @@ def test_init__no_associated_data(self): def test_init(self): """BaseEncryptedField is constructed correctly.""" assert self.field.associated_data == self.field_associated_data - assert self.field.db_column == self.field_associated_data def test_deconstruct(self): """BaseEncryptedField is deconstructed correctly.""" _, _, _, kwargs = self.field.deconstruct() assert kwargs["associated_data"] == self.field_associated_data - assert kwargs["db_column"] == self.field_associated_data # -------------------------------------------------------------------------- # Django Model Field Integration Tests @@ -318,7 +316,9 @@ def test_set__none(self): def test_set__trusted_ciphertext(self): """Setting field to _TrustedCiphertext stores ciphertext directly.""" - trusted_ciphertext = _TrustedCiphertext(b"encrypted_value") + trusted_ciphertext = _TrustedCiphertext( + b"encrypted_value", _TrustedCiphertext.Source.DB + ) instance = self._get_model_instance(field=trusted_ciphertext) assert instance.get_stored_value(self.field) is trusted_ciphertext @@ -381,7 +381,10 @@ def test_get__descriptor(self): def test_get__cached(self): """Getting field when cached returns cached value.""" instance = self._get_model_instance() - instance.set_stored_value(self.field, _TrustedCiphertext(b"irrelevant")) + instance.set_stored_value( + self.field, + _TrustedCiphertext(b"irrelevant", _TrustedCiphertext.Source.DB), + ) value = "decrypted_value" instance.__decrypted_values__[self.field.attname] = value @@ -414,7 +417,10 @@ def test_get__decrypted_value(self): # Create instance with stored ciphertext. instance = self._get_model_instance() - instance.set_stored_value(self.field, _TrustedCiphertext(ciphertext)) + instance.set_stored_value( + self.field, + _TrustedCiphertext(ciphertext, _TrustedCiphertext.Source.DB), + ) # Ensure cache is not set initially. assert self.field.attname not in instance.__decrypted_values__ @@ -478,7 +484,9 @@ def test_pre_save__trusted_ciphertext(self): """pre_save with trusted ciphertext does nothing.""" # Create instance with trusted ciphertext. ciphertext = b"encrypted_value" - trusted_ciphertext = _TrustedCiphertext(ciphertext) + trusted_ciphertext = _TrustedCiphertext( + ciphertext, _TrustedCiphertext.Source.DB + ) instance = self._get_model_instance(field=trusted_ciphertext) # Assert pre_save returns the ciphertext directly. diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index d6c0f132..b9a82fe9 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -25,6 +25,7 @@ from django.db.models import BinaryField from django.utils.translation import gettext_lazy as _ +from ...encryption import FakeAead from ..base_data_encryption_key import BaseDataEncryptionKeyModel from .deferred_attribute import DeferredAttribute @@ -73,12 +74,13 @@ def __set__( # provides binary data as a `memoryview` object. Our descriptor # handles this by extracting the raw bytes from the `memoryview`. elif isinstance(value, memoryview): - if not isinstance(value.obj, bytes): + internal_value = bytes(value) + if not internal_value.startswith(FakeAead.ciphertext_prefix): raise ValidationError( - "Expected bytes in memoryview for encrypted field.", - code="invalid_memoryview_type", + "Memoryview is expected to start with the fake ciphertext " + "prefix.", + code="memoryview_invalid_prefix", ) - internal_value = value.obj else: raise ValidationError( "DataEncryptionKeyField can only be set to None.", @@ -101,19 +103,22 @@ class DataEncryptionKeyField(BinaryField): # Construction & Deconstruction # -------------------------------------------------------------------------- + default_verbose_name = "data encryption key" + default_help_text = ( + "The encrypted data encryption key (DEK) for this model." + ) + def __init__( self, # DEK should not be editable in admin forms. editable: t.Literal[False] = False, # Allow null for data shredding. null: t.Literal[True] = True, - verbose_name: t.Optional["StrOrPromise"] = _("data encryption key"), - help_text: "StrOrPromise" = _( - "The encrypted data encryption key (DEK) for this model." - ), + verbose_name: t.Optional["StrOrPromise"] = _(default_verbose_name), + help_text: "StrOrPromise" = _(default_help_text), **kwargs, ): - if kwargs.get("editable", False): + if editable: raise ValidationError( "DataEncryptionKeyField cannot be editable.", code="editable_not_allowed", @@ -123,7 +128,7 @@ def __init__( "DataEncryptionKeyField cannot have a default value.", code="default_not_allowed", ) - if not kwargs.get("null", True): + if not null: raise ValidationError( "DataEncryptionKeyField must allow null to support data" " shredding.", diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index 42d8c3b8..4ea389bf 100644 --- a/codeforlife/models/fields/data_encryption_key_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -93,7 +93,6 @@ def test_deconstruct(self): """DataEncryptionKeyField is deconstructed correctly.""" _, _, _, kwargs = self.field.deconstruct() - assert kwargs["editable"] is False assert kwargs["null"] is True assert ( kwargs["verbose_name"] diff --git a/codeforlife/user/filters/klass.py b/codeforlife/user/filters/klass.py index 10bfcc91..2c0af857 100644 --- a/codeforlife/user/filters/klass.py +++ b/codeforlife/user/filters/klass.py @@ -9,6 +9,7 @@ rest_framework as filters, ) +from ...hashers import hash_credential # isort: skip from ...filters import FilterSet # isort: skip from ..models import Class # isort: skip @@ -16,14 +17,27 @@ # pylint: disable-next=missing-class-docstring class ClassFilterSet(FilterSet): _id = filters.CharFilter(method="_id__method") - _id__method = FilterSet.make_exclude_field_list_method("access_code") id_or_name = filters.CharFilter(method="id_or_name__method") + def _id__method(self, queryset: QuerySet[Class], name: str, *args): + access_code_hashes = [ + hash_credential(access_code) + for access_code in self.request.GET.getlist(name) + ] + return queryset.exclude(**{"access_code_hash__in": access_code_hashes}) + def id_or_name__method(self, queryset: QuerySet[Class], _: str, value: str): """Get classes where the id or the name contain a substring.""" + name = value.lower() + pks = [ + klass.pk + for klass in queryset.only("name_enc") + if name in klass.name.lower() + ] + return queryset.filter( - Q(access_code__icontains=value) | Q(name__icontains=value) + Q(access_code_hash=hash_credential(value)) | Q(pk__in=pks) ) class Meta: diff --git a/codeforlife/user/filters/user.py b/codeforlife/user/filters/user.py index 9a62dc6e..fa749c02 100644 --- a/codeforlife/user/filters/user.py +++ b/codeforlife/user/filters/user.py @@ -5,12 +5,12 @@ import typing as t -from django.db.models import Q # isort: skip from django.db.models.query import QuerySet # isort: skip from django_filters import ( # type: ignore[import-untyped] # isort: skip rest_framework as filters, ) +from ...hashers import hash_credential # isort: skip from ...filters import FilterSet # isort: skip from ..models import ( # isort: skip User, @@ -22,10 +22,7 @@ # pylint: disable-next=missing-class-docstring class UserFilterSet(FilterSet): - students_in_class = filters.CharFilter( - "new_student__class_field__access_code", - "exact", - ) + students_in_class = filters.CharFilter(method="students_in_class__method") _id = filters.NumberFilter(method="_id__method") _id__method = FilterSet.make_exclude_field_list_method("id") @@ -42,6 +39,14 @@ class UserFilterSet(FilterSet): method="type__method", ) + def students_in_class__method( + self: FilterSet, queryset: QuerySet[User], _: str, value: str + ): + """Get students in a class by the class access code.""" + return queryset.filter( + new_student__class_field__access_code_hash=hash_credential(value) + ) + def name__method( self: FilterSet, queryset: QuerySet[User], name: str, *args ): @@ -50,13 +55,16 @@ def name__method( first_name, last_name = ( names if len(names) == 2 else (names[0], names[0]) ) + first_name, last_name = first_name.lower(), last_name.lower() - # TODO: use PostgreSQL specific lookup - # https://docs.djangoproject.com/en/5.0/ref/contrib/postgres/lookups/#std-fieldlookup-trigram_similar - return queryset.filter( - Q(first_name__icontains=first_name) - | Q(last_name__icontains=last_name) - ) + pks = [ + user.pk + for user in queryset.only("first_name_enc", "last_name_enc") + if first_name in user.first_name.lower() + or last_name in user.last_name.lower() + ] + + return queryset.filter(pk__in=pks) def type__method( self: FilterSet, diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index c10a23e7..c158f665 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -9,6 +9,7 @@ from django.core.validators import MaxLengthValidator, MinLengthValidator from django.db import models +from django.db.models.query import QuerySet from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -26,6 +27,7 @@ from django_stubs_ext.db.models import TypedModelMeta + from .student import Student from .teacher import SchoolTeacher, Teacher else: TypedModelMeta = object @@ -57,9 +59,12 @@ def get_queryset(self): return super().get_queryset().filter(is_active=True) +# pylint: disable-next=too-many-instance-attributes class Class(EncryptedModel): """A class.""" + students: QuerySet["Student"] + associated_data = "class" # -------------------------------------------------------------------------- diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index 4489fc1c..5e62766e 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -24,17 +24,6 @@ class StudentModelManager(models.Manager): """Manager for Student model.""" - def get_random_username(self): - """Generate a random username that does not already exist.""" - # NOTE: avoid circular imports by importing here - # pylint: disable-next=import-outside-toplevel - from .user import User - - while True: - random_username = uuid4().hex[:30] # generate a random username - if not User.objects.filter(username=random_username).exists(): - return random_username - # pylint: disable-next=invalid-name def schoolFactory(self, klass, name, password, login_id=None): """Factory method to create a student user associated with a class.""" @@ -43,7 +32,6 @@ def schoolFactory(self, klass, name, password, login_id=None): from .user import User, UserProfile user = User.objects.create_user( - username=self.get_random_username(), password=password, first_name=name, ) @@ -64,7 +52,7 @@ def independentStudentFactory(self, name, email, password): from .user import User, UserProfile user = User.objects.create_user( - username=email, email=email, password=password, first_name=name + email=email, password=password, first_name=name ) user_profile = UserProfile.objects.create(user=user) diff --git a/codeforlife/user/models/teacher/teacher.py b/codeforlife/user/models/teacher/teacher.py index 7604e7f2..5e05d16d 100644 --- a/codeforlife/user/models/teacher/teacher.py +++ b/codeforlife/user/models/teacher/teacher.py @@ -31,7 +31,6 @@ def factory(self, first_name, last_name, email, password): from ..user import User, UserProfile user = User.objects.create_user( - username=email, email=email, password=password, first_name=first_name, diff --git a/codeforlife/user/models/user/google.py b/codeforlife/user/models/user/google.py index 08d19da7..1e769751 100644 --- a/codeforlife/user/models/user/google.py +++ b/codeforlife/user/models/user/google.py @@ -56,16 +56,19 @@ def _sync(self, auth_header: str, refresh_token: t.Optional[str] = None): try: user = self.get(userprofile__google_sub=google_sub) - user.username = email user.email = email user.first_name = first_name user.last_name = last_name user.save( update_fields=[ - "username", - "email", - "first_name", - "last_name", + "email_hash", + "email_plain", + "email_enc", + "first_name_hash", + "first_name_plain", + "first_name_enc", + "last_name_plain", + "last_name_enc", ] ) @@ -76,7 +79,6 @@ def _sync(self, auth_header: str, refresh_token: t.Optional[str] = None): raise does_not_exist user = self.create_user( - username=email, email=email, first_name=first_name, last_name=last_name, diff --git a/codeforlife/user/models/user/independent.py b/codeforlife/user/models/user/independent.py index c61a2854..6cfd8b12 100644 --- a/codeforlife/user/models/user/independent.py +++ b/codeforlife/user/models/user/independent.py @@ -55,12 +55,8 @@ def create_user( # type: ignore[override] from .user import UserProfile # pylint: enable=import-outside-toplevel - - assert "username" not in extra_fields - # pylint: disable=duplicate-code user = super().create_user( - username=email, email=email, password=password, first_name=first_name, diff --git a/codeforlife/user/models/user/student.py b/codeforlife/user/models/user/student.py index aa94c273..1ef354d0 100644 --- a/codeforlife/user/models/user/student.py +++ b/codeforlife/user/models/user/student.py @@ -44,7 +44,6 @@ def create_user( # type: ignore[override] user = super().create_user( **extra_fields, first_name=first_name, - username=StudentUser.get_random_username(), password=password, ) @@ -135,17 +134,6 @@ def generate_login_id(): return generate_login_id() - @staticmethod - def get_random_username(): - """Generate a random username that is unique.""" - username = None - while ( - username is None or User.objects.filter(username=username).exists() - ): - username = get_random_string(length=30) - - return username - # pylint: disable-next=arguments-differ def set_password(self, raw_password: t.Optional[str] = None): super().set_password(raw_password or self._get_random_password()) diff --git a/codeforlife/user/models/user/teacher.py b/codeforlife/user/models/user/teacher.py index 8b6f4c40..561efa5d 100644 --- a/codeforlife/user/models/user/teacher.py +++ b/codeforlife/user/models/user/teacher.py @@ -45,12 +45,8 @@ def create_user( # type: ignore[override] from .user import UserProfile # pylint: enable=import-outside-toplevel - - assert "username" not in extra_fields - # pylint: disable=duplicate-code user = super().create_user( - username=email, email=email, password=password, first_name=first_name, diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 929b57c0..185774a2 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -393,6 +393,9 @@ def anonymize(self): ] ) + def __repr__(self): + return f"" + if not getattr(settings, "OLD_SYSTEM", True): diff --git a/codeforlife/user/serializers/user_test.py b/codeforlife/user/serializers/user_test.py index a77506ae..592b063c 100644 --- a/codeforlife/user/serializers/user_test.py +++ b/codeforlife/user/serializers/user_test.py @@ -33,7 +33,15 @@ def test_to_representation__teacher(self): "student": None, }, # TODO: remove in new schema. - non_model_fields={"requesting_to_join_class", "teacher", "student"}, + non_model_fields={ + "requesting_to_join_class", + "teacher", + "student", + # TODO: remove once plain fields are removed. + "first_name", + "last_name", + "email", + }, ) def test_to_representation__student(self): @@ -55,7 +63,15 @@ def test_to_representation__student(self): }, }, # TODO: remove in new schema. - non_model_fields={"requesting_to_join_class", "teacher", "student"}, + non_model_fields={ + "requesting_to_join_class", + "teacher", + "student", + # TODO: remove once plain fields are removed. + "first_name", + "last_name", + "email", + }, ) def test_to_representation__indy(self): @@ -73,5 +89,13 @@ def test_to_representation__indy(self): "student": None, }, # TODO: remove in new schema. - non_model_fields={"requesting_to_join_class", "teacher", "student"}, + non_model_fields={ + "requesting_to_join_class", + "teacher", + "student", + # TODO: remove once plain fields are removed. + "first_name", + "last_name", + "email", + }, ) diff --git a/codeforlife/user/views/klass_test.py b/codeforlife/user/views/klass_test.py index b7383cdb..b59d30d5 100644 --- a/codeforlife/user/views/klass_test.py +++ b/codeforlife/user/views/klass_test.py @@ -20,7 +20,7 @@ class TestClassViewSet(ModelViewSetTestCase[RequestUser, Class]): def setUp(self): self.admin_school_teacher_user = AdminSchoolTeacherUser.objects.get( - email="admin.teacher@school1.com" + pk=24 ) # test: get permissions @@ -108,18 +108,19 @@ def test_list__id_or_name(self): klass = user.teacher.classes.first() assert klass - partial_access_code = klass.access_code[:-1] - partial_name = klass.name[:-1] + partial_name = klass.name[:-1].lower() self.client.login_as(user) self.client.list( - models=user.teacher.classes.filter( - access_code__icontains=partial_access_code - ), - filters={"id_or_name": partial_access_code}, + models=[klass], + filters={"id_or_name": klass.access_code}, ) self.client.list( - models=user.teacher.classes.filter(name__icontains=partial_name), + models=[ + klass + for klass in Class.objects.only("name_enc") + if partial_name in klass.name.lower() + ], filters={"id_or_name": partial_name}, ) diff --git a/codeforlife/user/views/user_test.py b/codeforlife/user/views/user_test.py index aae06b3d..0d4d0b9e 100644 --- a/codeforlife/user/views/user_test.py +++ b/codeforlife/user/views/user_test.py @@ -5,9 +5,6 @@ import typing as t -from django.db.models import Q -from django.db.models.query import QuerySet - from ...tests import ModelViewSetTestCase from ..models import ( AdminSchoolTeacherUser, @@ -151,7 +148,7 @@ def test_list__students_in_class(self): assert user.teacher.classes.count() >= 2 klass = t.cast(Class, user.teacher.classes.first()) - students: QuerySet[Student] = klass.students.all() + students = klass.students.all() assert ( Student.objects.filter( class_field__teacher__school=user.teacher.school @@ -236,13 +233,18 @@ def test_list__name(self): school_users = user.teacher.school_users first_name, last_name = user.first_name, user.last_name[:1] + first_name, last_name = first_name.lower(), last_name.lower() + + pks = [ + user.pk + for user in school_users.only("first_name_enc", "last_name_enc") + if first_name in user.first_name.lower() + or last_name in user.last_name.lower() + ] self.client.login_as(user) self.client.list( - models=school_users.filter( - Q(first_name__icontains=first_name) - | Q(last_name__icontains=last_name) - ).order_by("pk"), + models=school_users.filter(pk__in=pks).order_by("pk"), filters={"name": f"{first_name} {last_name}"}, ) From 28f96f3abca37fe84d216ccaefc0e11df2eb49d3 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 16 Mar 2026 10:44:02 +0000 Subject: [PATCH 46/58] remove aws code --- codeforlife/auth.py | 90 --------- codeforlife/server.py | 249 ----------------------- codeforlife/urls/patterns.py | 21 +- codeforlife/views/__init__.py | 1 - codeforlife/views/health_check.py | 317 ------------------------------ 5 files changed, 1 insertion(+), 677 deletions(-) delete mode 100644 codeforlife/auth.py delete mode 100644 codeforlife/server.py delete mode 100644 codeforlife/views/health_check.py diff --git a/codeforlife/auth.py b/codeforlife/auth.py deleted file mode 100644 index 0be7f677..00000000 --- a/codeforlife/auth.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -© Ocado Group -Created on 04/12/2025 at 18:58:44(+00:00). - -Authentication credentials. -""" - -import typing as t - -from boto3 import Session as AwsSession -from django.conf import settings -from google.auth.aws import ( - AwsSecurityCredentials, - AwsSecurityCredentialsSupplier, -) -from google.auth.aws import Credentials as AwsCredentials -from google.auth.credentials import Credentials -from google.oauth2.service_account import ( - Credentials as GcpServiceAccountCredentials, -) - - -class AwsSessionSecurityCredentialsSupplier(AwsSecurityCredentialsSupplier): - """Supplies AWS security credentials from the current boto3 session.""" - - def get_aws_region(self, _, __): - return settings.AWS_REGION - - def get_aws_security_credentials(self, _, __) -> AwsSecurityCredentials: - aws_credentials = AwsSession().get_credentials() - assert aws_credentials - - aws_read_only_credentials = aws_credentials.get_frozen_credentials() - assert aws_read_only_credentials.access_key - assert aws_read_only_credentials.secret_key - assert aws_read_only_credentials.token - - return AwsSecurityCredentials( - access_key_id=aws_read_only_credentials.access_key, - secret_access_key=aws_read_only_credentials.secret_key, - session_token=aws_read_only_credentials.token, - ) - - -# pylint: disable-next=abstract-method,too-many-ancestors -class GcpWifCredentials(AwsCredentials): - """Workload Identity Federation credentials for GCP using AWS IAM roles.""" - - def __init__(self, token_lifetime_seconds: int = 600): - super().__init__( - subject_token_type="urn:ietf:params:aws:token-type:aws4_request", - audience=settings.GCP_WIF_AUDIENCE, - universe_domain="googleapis.com", - token_url="https://sts.googleapis.com/v1/token", - service_account_impersonation_url=( - "https://iamcredentials.googleapis.com/v1/projects/-/" - f"serviceAccounts/{settings.GCP_WIF_SERVICE_ACCOUNT}" - ":generateAccessToken" - ), - service_account_impersonation_options={ - "token_lifetime_seconds": token_lifetime_seconds - }, - aws_security_credentials_supplier=( - AwsSessionSecurityCredentialsSupplier() - ), - ) - - -def get_gcp_service_account_credentials( - token_lifetime_seconds: int = 600, - service_account_json: t.Optional[str] = None, -) -> Credentials: - """Get GCP service account credentials. - - Args: - token_lifetime_seconds: The lifetime of the token in seconds. - service_account_json: The path to the service account JSON file. - - Returns: - The GCP service account credentials. - """ - if settings.ENV != "local": - return GcpWifCredentials(token_lifetime_seconds=token_lifetime_seconds) - assert ( - service_account_json - ), "Service account JSON file path must be provided in local environment." - - return GcpServiceAccountCredentials.from_service_account_file( - service_account_json - ) diff --git a/codeforlife/server.py b/codeforlife/server.py deleted file mode 100644 index 9a669175..00000000 --- a/codeforlife/server.py +++ /dev/null @@ -1,249 +0,0 @@ -""" -© Ocado Group -Created on 05/06/2025 at 17:33:56(+01:00). -""" - -import atexit -import logging -import multiprocessing -import os -import subprocess -import sys -import typing as t -from functools import cached_property -from importlib import import_module - -from celery import Celery -from django import setup as setup_django -from django.core.asgi import get_asgi_application as get_django_asgi_app -from django.core.management import call_command as call_django_command -from django.core.wsgi import get_wsgi_application as get_django_wsgi_app -from gunicorn.app.base import BaseApplication # type: ignore[import-untyped] - -from .tasks import get_task_name -from .types import DatabaseEngine, Env, LogLevel - - -# pylint: disable-next=abstract-method,too-many-instance-attributes -class Server(BaseApplication): - """Serves a service in different modes.""" - - Mode = t.Literal["django", "celery"] - - # The entrypoint module. - main_module = os.path.splitext(os.path.basename(sys.argv[0]))[0] - # The dot-path of the application module. - app_module: str = "application" - # The dot-path of the settings module. - settings_module: str = "settings" - # The dot-path of Django's manage module. - django_manage_module: str = "manage" - # The dot-path of the source-code module. - src_module: str = "src" - # The port the app is served on. - app_port: int = 8080 - - @cached_property - def app_server_is_running(self): - """Whether or not the app server is running.""" - return self.main_module == self.app_module - - @cached_property - def django_dev_server_is_running(self): - """Whether or not the Django development server is running.""" - return ( - self.main_module == self.django_manage_module - and sys.argv[1] == "runserver" - ) - - # pylint: disable-next=too-many-arguments,too-many-positional-arguments - def __init__( - self, - mode: Mode = t.cast(Mode, os.getenv("SERVER_MODE", "django")), - workers: int = int(os.getenv("SERVER_WORKERS", "0")), - log_level: t.Optional[LogLevel] = t.cast( - LogLevel, os.getenv("LOG_LEVEL", "INFO") - ), - db_engine: DatabaseEngine = "postgresql", - dump_request: bool = False, - ): - # pylint: disable=line-too-long - """Initialize a service's app-server. - - Examples: - ``` - from codeforlife.server import Server - - Server().run() - ``` - - Args: - mode: The mode to run in. Note, "celery" will start Django with only the health-check url. - workers: The number of workers. 0 will auto-calculate. Note, "celery" will create 1 Django worker. - log_level: The log level. None uses the default. - db_engine: The database's engine type. - dump_request: A flag designating whether to add the dump_request Celery task (useful for debugging). - """ - # pylint: enable=line-too-long - - if mode != "django" and self.django_dev_server_is_running: - mode = "django" - os.environ["SERVER_MODE"] = mode - self.mode = mode - - if log_level: - os.environ["LOG_LEVEL"] = log_level - self.log_level = log_level - - os.environ["DB_ENGINE"] = db_engine - self.db_engine = db_engine - - if mode == "django": - # https://docs.gunicorn.org/en/stable/design.html#how-many-workers - workers = workers or (multiprocessing.cpu_count() * 2) + 1 - self.workers = workers - - if self.app_server_is_running: - os.environ["SERVICE_PORT"] = str(self.app_port) - - os.environ["DJANGO_SETTINGS_MODULE"] = self.settings_module - setup_django() - - self.django_asgi_app = get_django_asgi_app() - self.django_wsgi_app = get_django_wsgi_app() - self.celery_app = Celery() - - self.options = { - "bind": f"0.0.0.0:{self.app_port}", - "workers": 1 if mode == "celery" else workers, - "worker_class": "uvicorn.workers.UvicornWorker", - "forwarded_allow_ips": "*", - } - - if mode == "celery": - # Using a string here means the worker doesn't have to serialize - # the configuration object to child processes. - # - namespace='CELERY' means all celery-related configuration keys - # should have a `CELERY_` prefix. - self.celery_app.config_from_object( - "django.conf:settings", namespace="CELERY" - ) - - # Load task modules from all registered Django apps. - self.celery_app.autodiscover_tasks([self.src_module]) - - if dump_request: - - @self.celery_app.task( - name=get_task_name("dump_request"), - bind=True, - ignore_result=True, - ) - def _dump_request(self, *args, **kwargs): - """Dumps its own request information.""" - - logging.info("Request: %s", self.request) - - super().__init__() - - # Set the apps as global variables in the app module. - app = import_module(self.app_module) - app.django_wsgi = self.django_wsgi_app # type: ignore[attr-defined] - app.celery = self.celery_app # type: ignore[attr-defined] - - def load_config(self): - config = { - key: value - for key, value in self.options.items() - if key in self.cfg.settings and value is not None - } - for key, value in config.items(): - self.cfg.set(key.lower(), value) - - def load(self): - return self.django_asgi_app - - # pylint: disable-next=dangerous-default-value - def run( - self, - migrate: bool = True, - collect_static: bool = True, - create_sites: bool = True, - load_fixtures: t.Optional[t.Set[str]] = {src_module}, - ): - """Run the server in the set mode. - - Args: - migrate: A flag designating whether to migrate the models. - collect_static: A flag designating whether to collect static files. - create_sites: A flag designating whether to create the django-sites. - load_fixtures: An array of fixtures to load. None to skip. - """ - - if self.mode == "django": - # NOTE: Imports come after django setup in server initialization. - # pylint: disable=import-outside-toplevel - from django.conf import settings - from django.contrib.sites.models import Site - - # pylint: enable=import-outside-toplevel - - if self.db_engine == "sqlite": - migrate = False - create_sites = False - load_fixtures = None - - if not self.django_dev_server_is_running: - load_fixtures = None - collect_static = False - - if migrate: - call_django_command("migrate", interactive=False) - if load_fixtures: - call_django_command("load_fixtures", *load_fixtures) - if collect_static: - call_django_command("collectstatic", "--noinput", "--clear") - if create_sites: - - def create_site(domain: str): - Site.objects.get_or_create( - domain=domain, - defaults={"name": settings.SERVICE_NAME}, - ) - - if t.cast(Env, settings.ENV) == "local": - create_site(domain=f"localhost:{settings.SERVICE_PORT}") - create_site(domain=f"127.0.0.1:{settings.SERVICE_PORT}") - else: - create_site(domain=settings.SERVICE_DOMAIN) - create_site(domain=settings.SERVICE_HOST) - - if self.app_server_is_running: - if self.mode == "celery": - self.run_celery_worker_as_subprocess() - - super().run() - - def run_celery_worker_as_subprocess(self): - """Starts a worker using the 'celery worker' command.""" - - command = ["celery", f"--app={self.app_module}", "worker"] - if self.workers: - command.append(f"--concurrency={self.workers}") - if self.log_level: - command.append(f"--loglevel={self.log_level}") - - stdout, stderr = (None, None) # Use defaults. - else: - stdout, stderr = (subprocess.DEVNULL, subprocess.DEVNULL) - - try: - # pylint: disable-next=consider-using-with - process = subprocess.Popen(command, stdout=stdout, stderr=stderr) - - atexit.register(process.terminate) - - os.environ["SERVER_CELERY_WORKER_PID"] = str(process.pid) - - except Exception as ex: # pylint: disable=broad-exception-caught - print(f"Error starting Celery worker: {ex}") diff --git a/codeforlife/urls/patterns.py b/codeforlife/urls/patterns.py index 2c649e4f..d5566f9f 100644 --- a/codeforlife/urls/patterns.py +++ b/codeforlife/urls/patterns.py @@ -5,30 +5,22 @@ import typing as t -from django.conf import settings from django.contrib import admin from django.urls import URLPattern, URLResolver, include, path -from ..views import ( - CsrfCookieView, - HealthCheckView, - LogoutView, - session_expired_view, -) +from ..views import CsrfCookieView, LogoutView, session_expired_view UrlPatterns = t.List[t.Union[URLResolver, URLPattern]] def get_urlpatterns( api_url_patterns: UrlPatterns, - health_check_view: t.Type[HealthCheckView] = HealthCheckView, include_user_urls: bool = True, ) -> UrlPatterns: """Generate standard url patterns for each service. Args: api_urls_path: The path to the api's urls. - health_check_view: The health check view to use. include_user_urls: Whether or not to include the CFL's user urls. Returns: @@ -36,17 +28,6 @@ def get_urlpatterns( """ urlpatterns: UrlPatterns = [ - path( - "health-check/", - health_check_view.as_view(), - name="health-check", - ), - ] - - if settings.SERVER_MODE == "celery": - return urlpatterns - - urlpatterns += [ # https://www.django-rest-framework.org/topics/browsable-api/#authentication path( "/", diff --git a/codeforlife/views/__init__.py b/codeforlife/views/__init__.py index d069ca36..0f979ec0 100644 --- a/codeforlife/views/__init__.py +++ b/codeforlife/views/__init__.py @@ -8,6 +8,5 @@ from .base_login import BaseLoginView from .csrf import CsrfCookieView from .decorators import action -from .health_check import HealthCheckView from .model import BaseModelViewSet, ModelViewSet from .session import LogoutView, session_expired_view diff --git a/codeforlife/views/health_check.py b/codeforlife/views/health_check.py deleted file mode 100644 index 4939088b..00000000 --- a/codeforlife/views/health_check.py +++ /dev/null @@ -1,317 +0,0 @@ -""" -© Ocado Group -Created on 14/11/2024 at 16:31:56(+00:00). -""" - -import json -import logging -import os -import typing as t -from dataclasses import dataclass -from datetime import datetime -from functools import cached_property - -from django.apps import apps -from django.conf import settings -from django.contrib.sites.models import Site -from django.views.decorators.cache import cache_page -from psutil import Process -from rest_framework import status -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from ..permissions import AllowAny - -if t.TYPE_CHECKING: - from ..server import Server - from ..types import Env, JsonDict, JsonList - -HealthStatus = t.Literal[ - "healthy", - "startingUp", - "shuttingDown", - "unhealthy", - "unknown", -] - - -@dataclass(frozen=True) -class HealthCheck: - """The health of the current service.""" - - @dataclass(frozen=True) - class Detail: - """A health detail.""" - - name: str - description: str - health: t.Optional[HealthStatus] = None - - health_status: HealthStatus - additional_info: str - details: t.Optional[t.List[Detail]] = None - - -class HealthCheckDetailList(t.List[HealthCheck.Detail]): - """Builds a list of health-check details with convenience utilities.""" - - def __init__(self, server_mode: "Server.Mode"): - super().__init__() - self.server_mode = server_mode - - @property - def health_statuses(self): - """The health statuses of all the details.""" - return t.cast( - t.FrozenSet[HealthStatus], - frozenset(detail.health for detail in self if detail.health), - ) - - def append( # type: ignore[override] - self, - name: str, - description: str, - health: t.Optional[HealthStatus] = None, - ): - return super().append( - HealthCheck.Detail( - name=f"{self.server_mode}.{name}", - description=description, - health=health, - ) - ) - - -class HealthCheckView(APIView): - """A view for load balancers to determine whether the app is healthy.""" - - http_method_names = ["get"] - permission_classes = [AllowAny] - startup_timestamp = datetime.now().isoformat() - cache_timeout: float = 30 - - def resolve_health_status( - self, *health_statuses: HealthStatus - ) -> HealthStatus: - """Given 1+ health statuses, resolve the final health status.""" - if len(health_statuses) > 0: - search_health_statuses: t.List[HealthStatus] = [ - "unhealthy", - "shuttingDown", - "startingUp", - ] - for search_health_status in search_health_statuses: - if search_health_status in health_statuses: - return search_health_status - - if all( - health_status == "healthy" for health_status in health_statuses - ): - return "healthy" - - return "unknown" - - @cached_property - def celery_worker(self): - """The celery worker started by the server.""" - return Process(int(os.environ["SERVER_CELERY_WORKER_PID"])) - - def get_celery_worker_health_check(self) -> HealthCheck: - """Check the health of the celery worker process.""" - health_check_details = HealthCheckDetailList("celery") - - _status = self.celery_worker.status() - health_check_details.append( - name="status", - description=_status, - health=( - "healthy" - if _status in ["running", "sleeping", "waking", "idle"] - else "unhealthy" - ), - ) - - health_check_details.append( - name="cpu_percent", - description=str(self.celery_worker.cpu_percent()), - ) - - health_status = self.resolve_health_status( - *health_check_details.health_statuses - ) - - return HealthCheck( - health_status=health_status, - additional_info="[celery] " - + ( - "All healthy." - if health_status == "healthy" - else "Not healthy. See details for more info." - ), - details=health_check_details, - ) - - def get_django_worker_health_check(self, request: Request) -> HealthCheck: - """Check the health of the django worker process.""" - health_check_details = HealthCheckDetailList("django") - - ready = apps.ready - health_check_details.append( - name="ready", - description=str(ready), - health="healthy" if ready else "startingUp", - ) - - apps_ready = apps.apps_ready - health_check_details.append( - name="apps_ready", - description=str(apps_ready), - health="healthy" if apps_ready else "startingUp", - ) - - models_ready = apps.models_ready - health_check_details.append( - name="models_ready", - description=str(models_ready), - health="healthy" if models_ready else "startingUp", - ) - - if settings.DB_ENGINE == "postgresql": - - def check_site_health(health_check_name: str, site_domain: str): - exists = Site.objects.filter(domain=site_domain).exists() - health_check_details.append( - name=f"site_exists.{health_check_name}", - description=str(exists), - health="healthy" if exists else "unhealthy", - ) - - if t.cast("Env", settings.ENV) == "local": - check_site_health( - health_check_name="localhost", - site_domain=f"localhost:{settings.SERVICE_PORT}", - ) - check_site_health( - health_check_name="ip_address", - site_domain=f"127.0.0.1:{settings.SERVICE_PORT}", - ) - else: - check_site_health( - health_check_name="domain", - site_domain=settings.SERVICE_DOMAIN, - ) - check_site_health( - health_check_name="host", - site_domain=settings.SERVICE_HOST, - ) - - health_status = self.resolve_health_status( - *health_check_details.health_statuses - ) - - return HealthCheck( - health_status=health_status, - additional_info="[django] " - + ( - "All healthy." - if health_status == "healthy" - else "Not healthy. See details for more info." - ), - details=health_check_details, - ) - - def get_health_check(self, request: Request) -> HealthCheck: - """Check the health of the current service.""" - details: t.List[HealthCheck.Detail] = [] - - try: - django_worker_health_check = self.get_django_worker_health_check( - request - ) - health_status = django_worker_health_check.health_status - additional_info = django_worker_health_check.additional_info - if django_worker_health_check.details: - details += django_worker_health_check.details - - if t.cast("Server.Mode", settings.SERVER_MODE) == "celery": - celery_worker_health_check = ( - self.get_celery_worker_health_check() - ) - health_status = self.resolve_health_status( - health_status, - celery_worker_health_check.health_status, - ) - additional_info += celery_worker_health_check.additional_info - if celery_worker_health_check.details: - details += celery_worker_health_check.details - - return HealthCheck( - health_status=health_status, - additional_info=additional_info, - details=details, - ) - # pylint: disable-next=broad-exception-caught - except Exception as ex: - return HealthCheck( - health_status="unknown", - additional_info=str(ex), - details=details, - ) - - def get(self, request: Request): - """Return a health check for the current service.""" - health_check = self.get_health_check(request) - - data: JsonDict = { - "appId": settings.APP_ID, - "healthStatus": health_check.health_status, - "lastCheckedTimestamp": datetime.now().isoformat(), - "additionalInformation": health_check.additional_info, - "startupTimestamp": self.startup_timestamp, - "appVersion": settings.APP_VERSION, - } - - if health_check.details: - details: JsonList = [] - for _detail in health_check.details: - detail: JsonDict = { - "name": _detail.name, - "description": _detail.description, - } - if _detail.health: - detail["health"] = _detail.health - - details.append(detail) - - data["details"] = details - - if health_check.health_status != "healthy": - logging.warning("health check: %s", json.dumps(data)) - - return Response( - data, - status={ - # The app is running normally. - "healthy": status.HTTP_200_OK, - # The app is performing app-specific initialisation which must - # complete before it will serve normal application requests - # (perhaps the app is warming a cache or something similar). You - # only need to use this status if your app will be in a start-up - # mode for a prolonged period of time. - "startingUp": status.HTTP_503_SERVICE_UNAVAILABLE, - # The app is shutting down. As with startingUp, you only need to - # use this status if your app takes a prolonged amount of time - # to shutdown, perhaps because it waits for a long-running - # process to complete before shutting down. - "shuttingDown": status.HTTP_503_SERVICE_UNAVAILABLE, - # The app is not running normally. - "unhealthy": status.HTTP_503_SERVICE_UNAVAILABLE, - # The app is not able to report its own state. - "unknown": status.HTTP_503_SERVICE_UNAVAILABLE, - }[health_check.health_status], - ) - - @classmethod - def as_view(cls, **initkwargs): - return cache_page(cls.cache_timeout)(super().as_view(**initkwargs)) From d8c6f3b042684d4c731be527cfd05d4866f2489e Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 16 Mar 2026 10:44:22 +0000 Subject: [PATCH 47/58] fix type errors --- codeforlife/models/fields/data_encryption_key_test.py | 4 ++-- codeforlife/settings/custom.py | 4 ---- codeforlife/user/models/student.py | 1 + 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index 4ea389bf..65a71e3c 100644 --- a/codeforlife/models/fields/data_encryption_key_test.py +++ b/codeforlife/models/fields/data_encryption_key_test.py @@ -67,7 +67,7 @@ def setUp(self): def test_init__editable_not_allowed(self): """Cannot create DataEncryptionKeyField with editable=True.""" with self.assert_raises_validation_error(code="editable_not_allowed"): - DataEncryptionKeyField(editable=True) + DataEncryptionKeyField(editable=True) # type: ignore[arg-type] def test_init__default_not_allowed(self): """Cannot create DataEncryptionKeyField with default value.""" @@ -77,7 +77,7 @@ def test_init__default_not_allowed(self): def test_init__null_allowed(self): """Cannot create DataEncryptionKeyField with null=True.""" with self.assert_raises_validation_error(code="null_not_allowed"): - DataEncryptionKeyField(null=False) + DataEncryptionKeyField(null=False) # type: ignore[arg-type] def test_init(self): """DataEncryptionKeyField is constructed correctly.""" diff --git a/codeforlife/settings/custom.py b/codeforlife/settings/custom.py index f2c57f21..2fcd3c39 100644 --- a/codeforlife/settings/custom.py +++ b/codeforlife/settings/custom.py @@ -11,16 +11,12 @@ from pathlib import Path if t.TYPE_CHECKING: - from ..server import Server from ..types import CookieSamesite, Env # The name of the current environment. ENV = t.cast("Env", os.getenv("ENV", "local")) -# The mode the service is being served in. -SERVER_MODE = t.cast("Server.Mode", os.getenv("SERVER_MODE", "django")) - # The level of the logs. LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index 5e62766e..662050e5 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -32,6 +32,7 @@ def schoolFactory(self, klass, name, password, login_id=None): from .user import User, UserProfile user = User.objects.create_user( + email="", # email is not required for school students password=password, first_name=name, ) From c1b9bbd5eeedd661cbd69ffd727b5c2dbe410317 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 16 Mar 2026 11:43:57 +0000 Subject: [PATCH 48/58] fix linting errors --- codeforlife/__init__.py | 3 +++ codeforlife/models/fields/base_encrypted.py | 7 ++----- .../models/fields/data_encryption_key.py | 7 ++----- codeforlife/models/utils.py | 17 +++++++++++++++++ .../commands/encrypt_plaintext_fields.py | 5 +++++ codeforlife/user/models/other.py | 3 +++ codeforlife/user/models/school.py | 2 ++ codeforlife/user/models/student.py | 1 - codeforlife/user/models/user/contactable.py | 1 + codeforlife/user/models/user/independent.py | 1 + codeforlife/user/models/user/student.py | 1 + codeforlife/user/models/user/teacher.py | 2 +- codeforlife/user/models/user/user.py | 18 ++++++++++-------- 13 files changed, 48 insertions(+), 20 deletions(-) create mode 100644 codeforlife/models/utils.py diff --git a/codeforlife/__init__.py b/codeforlife/__init__.py index d364e542..d8cb4996 100644 --- a/codeforlife/__init__.py +++ b/codeforlife/__init__.py @@ -111,5 +111,8 @@ def set_up_settings(service_base_dir: Path, service_name: str): secrets_file.write(secrets_file_comment) secrets = dotenv_values(secrets_path) + else: + # TODO: load secrets from bucket in non-local environments. + secrets = {} return Secrets(**secrets) diff --git a/codeforlife/models/fields/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 6ff3ff46..5cb82d62 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -58,6 +58,7 @@ from ...types import Args, KwArgs from ..encrypted import EncryptedModel +from ..utils import is_real_model_class from .deferred_attribute import DeferredAttribute T = t.TypeVar("T") @@ -227,11 +228,7 @@ def contribute_to_class(self, cls, name, private_only=False): super().contribute_to_class(cls, name, private_only) # Skip fake (used for migrations), abstract and proxy models. - if ( - cls.__module__ == "__fake__" - or cls._meta.abstract - or cls._meta.proxy - ): + if not is_real_model_class(cls): return # Ensure the model subclasses EncryptedModel. diff --git a/codeforlife/models/fields/data_encryption_key.py b/codeforlife/models/fields/data_encryption_key.py index b9a82fe9..7bef10ea 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -27,6 +27,7 @@ from ...encryption import FakeAead from ..base_data_encryption_key import BaseDataEncryptionKeyModel +from ..utils import is_real_model_class from .deferred_attribute import DeferredAttribute if t.TYPE_CHECKING: # pragma: no cover @@ -151,11 +152,7 @@ def contribute_to_class(self, cls, name, private_only=False): super().contribute_to_class(cls, name, private_only) # Skip fake (used for migrations), abstract and proxy models. - if ( - cls.__module__ == "__fake__" - or cls._meta.abstract - or cls._meta.proxy - ): + if not is_real_model_class(cls): return # Ensure the model subclasses BaseDataEncryptionKeyModel. diff --git a/codeforlife/models/utils.py b/codeforlife/models/utils.py new file mode 100644 index 00000000..212a04bf --- /dev/null +++ b/codeforlife/models/utils.py @@ -0,0 +1,17 @@ +""" +© Ocado Group +Created on 16/03/2026 at 11:34:42(+00:00). +""" + +import typing as t + +from django.db.models import Model + + +def is_real_model_class(cls: t.Type[Model]): + """Determine if the class is a real model class that should be validated.""" + return ( + cls.__module__ != "__fake__" # used for migrations + and not cls._meta.abstract + and not cls._meta.proxy + ) diff --git a/codeforlife/user/management/commands/encrypt_plaintext_fields.py b/codeforlife/user/management/commands/encrypt_plaintext_fields.py index aead6bd5..fb773b03 100644 --- a/codeforlife/user/management/commands/encrypt_plaintext_fields.py +++ b/codeforlife/user/management/commands/encrypt_plaintext_fields.py @@ -1,3 +1,8 @@ +""" +© Ocado Group +Created on 16/03/2026 at 10:58:04(+00:00). +""" + import typing as t from django.apps import apps diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py index 782f1458..5b6efc62 100644 --- a/codeforlife/user/models/other.py +++ b/codeforlife/user/models/other.py @@ -224,6 +224,7 @@ def get_queryset(self): return super().get_queryset().filter(is_active=True) +# pylint: disable-next=too-many-instance-attributes class SchoolTeacherInvitation(EncryptedModel): """ A model to track invitations for teachers to join a school. This is meant to @@ -282,6 +283,7 @@ def token(self, value: str): # -------------------------------------------------------------------------- invited_teacher_first_name_plain: str + # pylint: disable-next=line-too-long invited_teacher_first_name_plain = models.CharField( # type: ignore[assignment] max_length=150 ) # Same as User model @@ -309,6 +311,7 @@ def invited_teacher_first_name(self, value: str): # -------------------------------------------------------------------------- invited_teacher_last_name_plain: str + # pylint: disable-next=line-too-long invited_teacher_last_name_plain = models.CharField( # type: ignore[assignment] max_length=150 ) # Same as User model diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index faa7a53a..eaf55de1 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -49,6 +49,7 @@ class School(DataEncryptionKeyModel): # -------------------------------------------------------------------------- # Name # -------------------------------------------------------------------------- + # pylint: disable=duplicate-code name_plain: str name_plain = models.CharField( # type: ignore[assignment] @@ -74,6 +75,7 @@ def name(self, value: str): self.name_plain = value self.name_enc = value + # pylint: enable=duplicate-code # -------------------------------------------------------------------------- country: t.Optional[str] diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index 662050e5..8f2fdbd8 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -6,7 +6,6 @@ """ import typing as t -from uuid import uuid4 from django.db import models diff --git a/codeforlife/user/models/user/contactable.py b/codeforlife/user/models/user/contactable.py index db149136..0c799abb 100644 --- a/codeforlife/user/models/user/contactable.py +++ b/codeforlife/user/models/user/contactable.py @@ -54,6 +54,7 @@ def email_user( # type: ignore[override] personalization_values: t.Optional[t.Dict[str, str]] = None, **kwargs, ): + """Send an email to this user using DotDigital.""" kwargs["to_addresses"] = [self.email] mail.send_mail( campaign_id=campaign_id, diff --git a/codeforlife/user/models/user/independent.py b/codeforlife/user/models/user/independent.py index 6cfd8b12..22e65384 100644 --- a/codeforlife/user/models/user/independent.py +++ b/codeforlife/user/models/user/independent.py @@ -40,6 +40,7 @@ def filter_users(self, queryset: QuerySet["User"]): def get_queryset(self): return super().get_queryset().prefetch_related("new_student") + # pylint: disable-next=arguments-differ def create_user( # type: ignore[override] self, first_name: str, diff --git a/codeforlife/user/models/user/student.py b/codeforlife/user/models/user/student.py index 1ef354d0..9b932126 100644 --- a/codeforlife/user/models/user/student.py +++ b/codeforlife/user/models/user/student.py @@ -27,6 +27,7 @@ # pylint: disable-next=missing-class-docstring,too-few-public-methods class StudentUserManager(UserManager["StudentUser"]): + # pylint: disable-next=arguments-renamed def create_user( # type: ignore[override] self, first_name: str, klass: "Class", **extra_fields ): diff --git a/codeforlife/user/models/user/teacher.py b/codeforlife/user/models/user/teacher.py index 561efa5d..13c9837c 100644 --- a/codeforlife/user/models/user/teacher.py +++ b/codeforlife/user/models/user/teacher.py @@ -26,7 +26,7 @@ # pylint: disable-next=missing-class-docstring,too-few-public-methods class TeacherUserManager(ContactableUserManager[AnyUser], t.Generic[AnyUser]): - # pylint: disable-next=too-many-arguments,too-many-positional-arguments + # pylint: disable-next=too-many-arguments,too-many-positional-arguments,arguments-differ def create_user( # type: ignore[override] self, first_name: str, diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 185774a2..4d8bf9f8 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -372,6 +372,7 @@ def anonymize(self): self.is_active = False self.save( update_fields=[ + # pylint: disable=duplicate-code "first_name_hash", "first_name_plain", "first_name_enc", @@ -381,17 +382,18 @@ def anonymize(self): "email_enc", "email_hash", "is_active", + # pylint: enable=duplicate-code ] ) - self.userprofile.google_refresh_token = None - self.userprofile.google_sub = None - self.userprofile.save( - update_fields=[ - "google_refresh_token", - "google_sub", - ] - ) + # self.userprofile.google_refresh_token = None + # self.userprofile.google_sub = None + # self.userprofile.save( + # update_fields=[ + # "google_refresh_token", + # "google_sub", + # ] + # ) def __repr__(self): return f"" From 0dd2839ec0e926aac9d28d4bf970724611d670d0 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 16 Mar 2026 12:00:52 +0000 Subject: [PATCH 49/58] encrypt fields --- codeforlife/user/fixtures/legacy.json | 17 ++++++++++++++--- codeforlife/user/fixtures/school_1.json | 13 ++++++++++--- codeforlife/user/fixtures/school_2.json | 11 ++++++++--- codeforlife/user/fixtures/school_3.json | 7 ++++--- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json index 0f80a388..beb9fd43 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -234,12 +234,13 @@ "model": "user.school", "pk": 1, "fields": { - "dek": "ZmFrZV9lbmM6VHpkNWFFZzVaVUozVEdNM1MyRkJXV042YUVkcVUwWnZWVzV4YWtaaVNFbz0=", - "name_plain": "Swiss Federal Polytechnic", "country": "GB", "county": "nan", "creation_time": null, - "is_active": true + "dek": "ZmFrZV9lbmM6VHpkNWFFZzVaVUozVEdNM1MyRkJXV042YUVkcVUwWnZWVzV4YWtaaVNFbz0=", + "is_active": true, + "name_enc": "ZmFrZV9lbmM6VTNkcGMzTWdSbVZrWlhKaGJDQlFiMng1ZEdWamFHNXBZdz09", + "name_plain": "Swiss Federal Polytechnic" } }, { @@ -295,6 +296,7 @@ "pk": 1, "fields": { "accept_requests_until": null, + "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qTT0=", "access_code_hash": "7dcbfe80fd523896acdad8310fe14d804dad5ae7e4dc4975815f3d5ff76aa963", "access_code_plain": "AB123", "always_accept_requests": true, @@ -302,6 +304,7 @@ "created_by": null, "creation_time": null, "is_active": true, + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF4", "name_plain": "Class 101", "teacher": 1 } @@ -311,6 +314,7 @@ "pk": 2, "fields": { "accept_requests_until": null, + "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qUT0=", "access_code_hash": "ee16d5af3f31578ab0327da3257b212fe2beefcadef4f6b8ad6c992d153b90aa", "access_code_plain": "AB124", "always_accept_requests": true, @@ -318,6 +322,7 @@ "created_by": null, "creation_time": null, "is_active": true, + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF5", "name_plain": "Class 102", "teacher": 2 } @@ -327,6 +332,7 @@ "pk": 3, "fields": { "accept_requests_until": null, + "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qVT0=", "access_code_hash": "a895a482b72ec418311b8a9677bcf2c690425993f563f58d4a15b9586753b88b", "access_code_plain": "AB125", "always_accept_requests": true, @@ -334,6 +340,7 @@ "created_by": null, "creation_time": null, "is_active": true, + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF6", "name_plain": "Class 103", "teacher": 2 } @@ -343,6 +350,7 @@ "pk": 4, "fields": { "accept_requests_until": null, + "access_code_enc": "ZmFrZV9lbmM6VWt3eE1qTT0=", "access_code_hash": "38045227bf3c575c643dc1b34a1e70669f35b90225495bbeba0a53ae5ba82c81", "access_code_plain": "RL123", "always_accept_requests": true, @@ -350,6 +358,7 @@ "created_by": null, "creation_time": null, "is_active": true, + "name_enc": "ZmFrZV9lbmM6V1c5MWJtY2dRMjlrWlhKeklERXdNUT09", "name_plain": "Young Coders 101", "teacher": 3 } @@ -359,6 +368,7 @@ "pk": 5, "fields": { "accept_requests_until": null, + "access_code_enc": "ZmFrZV9lbmM6VUU4eE1qTT0=", "access_code_hash": "732f2cd0915bdea50771ce7c92a4e1b8ea83f6d3323b95dd07619e5ea7f0fd06", "access_code_plain": "PO123", "always_accept_requests": true, @@ -366,6 +376,7 @@ "created_by": null, "creation_time": null, "is_active": true, + "name_enc": "ZmFrZV9lbmM6VUc5eWRHRnNZV1J0YVc0bmN5QmpiR0Z6Y3c9PQ==", "name_plain": "Portaladmin's class", "teacher": 4 } diff --git a/codeforlife/user/fixtures/school_1.json b/codeforlife/user/fixtures/school_1.json index 7734fdd5..2642c9e7 100644 --- a/codeforlife/user/fixtures/school_1.json +++ b/codeforlife/user/fixtures/school_1.json @@ -3,10 +3,11 @@ "model": "user.school", "pk": 2, "fields": { - "dek": "ZmFrZV9lbmM6V1cxTVdtMXFibTgyVlhadGVFTlRla1p2ZDBOM2QzVnVSMEZ3WkdGeVpsQT0=", - "name_plain": "School 1", "country": "GB", - "county": "Hertfordshire" + "county": "Hertfordshire", + "dek": "ZmFrZV9lbmM6V1cxTVdtMXFibTgyVlhadGVFTlRla1p2ZDBOM2QzVnVSMEZ3WkdGeVpsQT0=", + "name_enc": "ZmFrZV9lbmM6VTJOb2IyOXNJREU9", + "name_plain": "School 1" } }, { @@ -47,8 +48,10 @@ "pk": 6, "fields": { "accept_requests_until": "9999-02-09 20:26:08.298402+00:00", + "access_code_enc": "ZmFrZV9lbmM6V2xveE1URT0=", "access_code_hash": "ca366870cf5e795a3c9ff340f119df2ff646128cf164d992b0167bbca8c247eb", "access_code_plain": "ZZ111", + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNU0JBSUZOamFHOXZiQ0F4", "name_plain": "Class 1 @ School 1", "teacher": 6 } @@ -119,8 +122,10 @@ "model": "user.class", "pk": 7, "fields": { + "access_code_enc": "ZmFrZV9lbmM6V2xveU1qST0=", "access_code_hash": "e90c6683965279b92914a9e2b18716f0bc826c0dccb5976130562ff88cbd7f01", "access_code_plain": "ZZ222", + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F4", "name_plain": "Class 2 @ School 1", "teacher": 7 } @@ -158,8 +163,10 @@ "pk": 10, "fields": { "accept_requests_until": "2023-02-09 20:26:08.298402+00:00", + "access_code_enc": "ZmFrZV9lbmM6V2xvek16TT0=", "access_code_hash": "3e3b96d4c7775a2c8d15999f6d662b164ccf3d66b8df93329bd5e426251d8cdb", "access_code_plain": "ZZ333", + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNeUJBSUZOamFHOXZiQ0F4", "name_plain": "Class 3 @ School 1", "teacher": 7 } diff --git a/codeforlife/user/fixtures/school_2.json b/codeforlife/user/fixtures/school_2.json index cccc60b0..827d971f 100644 --- a/codeforlife/user/fixtures/school_2.json +++ b/codeforlife/user/fixtures/school_2.json @@ -3,10 +3,11 @@ "model": "user.school", "pk": 3, "fields": { - "dek": "ZmFrZV9lbmM6VlUxaWN6TTVhbk56WW5wcFpIVXhhVlE1UW10WGFVcHpUa3BFT0VNNVdtYz0=", - "name_plain": "School 2", "country": "GB", - "county": "Hertfordshire" + "county": "Hertfordshire", + "dek": "ZmFrZV9lbmM6VlUxaWN6TTVhbk56WW5wcFpIVXhhVlE1UW10WGFVcHpUa3BFT0VNNVdtYz0=", + "name_enc": "ZmFrZV9lbmM6VTJOb2IyOXNJREk9", + "name_plain": "School 2" } }, { @@ -135,8 +136,10 @@ "model": "user.class", "pk": 8, "fields": { + "access_code_enc": "ZmFrZV9lbmM6V0ZneE1URT0=", "access_code_hash": "0157477281b2aee7660889207dee1a14756bddd428162de41bada7a02c593ff6", "access_code_plain": "XX111", + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNU0JBSUZOamFHOXZiQ0F5", "name_plain": "Class 1 @ School 2", "teacher": 8 } @@ -188,8 +191,10 @@ "model": "user.class", "pk": 9, "fields": { + "access_code_enc": "ZmFrZV9lbmM6V0ZneU1qST0=", "access_code_hash": "750df33e8a531e83fddf652344b680983a1555c6dcd8fda80f4af785357b444d", "access_code_plain": "XX222", + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F5", "name_plain": "Class 2 @ School 2", "teacher": 9 } diff --git a/codeforlife/user/fixtures/school_3.json b/codeforlife/user/fixtures/school_3.json index 7a75aac1..74b54b88 100644 --- a/codeforlife/user/fixtures/school_3.json +++ b/codeforlife/user/fixtures/school_3.json @@ -3,10 +3,11 @@ "model": "user.school", "pk": 4, "fields": { - "dek": "ZmFrZV9lbmM6Vm1ORlNIbENURmRyZEdOR1VGaHFOMFZIVFhkRE1IQjVNR1oyTkdkVFNIRT0=", - "name_plain": "School 3", "country": "GB", - "county": "Hertfordshire" + "county": "Hertfordshire", + "dek": "ZmFrZV9lbmM6Vm1ORlNIbENURmRyZEdOR1VGaHFOMFZIVFhkRE1IQjVNR1oyTkdkVFNIRT0=", + "name_enc": "ZmFrZV9lbmM6VTJOb2IyOXNJRE09", + "name_plain": "School 3" } }, { From 694b5e8f1b8cdc1c6415c7ae4624f6124a675944 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 16 Mar 2026 14:18:15 +0000 Subject: [PATCH 50/58] fix user fields --- .../0003_client_side_encryption_part_1.py | 16 ++++++++- codeforlife/user/models/student.py | 1 - codeforlife/user/models/user/user.py | 36 ++++++++++++------- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py index 7369dba5..d8f63d6c 100644 --- a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py @@ -56,11 +56,25 @@ class Migration(migrations.Migration): model_name="user", name="username", ), + migrations.AlterField( + model_name="user", + name="email", + field=models.EmailField( + max_length=254, + null=True, + unique=True, + verbose_name="email address", + ), + ), migrations.AddField( model_name="user", name="email_hash", field=models.CharField( - editable=False, max_length=64, verbose_name="email hash" + unique=True, + null=True, + editable=False, + max_length=64, + verbose_name="email hash", ), ), migrations.AddField( diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index 8f2fdbd8..a01bfed8 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -31,7 +31,6 @@ def schoolFactory(self, klass, name, password, login_id=None): from .user import User, UserProfile user = User.objects.create_user( - email="", # email is not required for school students password=password, first_name=name, ) diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 4d8bf9f8..02d5f048 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -63,7 +63,7 @@ class UserManager( def _create_user_object( self, _: t.Literal[""], # username is not used but is required by the parent - email: str, + email: t.Optional[str], password: t.Optional[str], **extra_fields, ): @@ -78,31 +78,43 @@ def _create_user_object( @classmethod def normalize_email(cls, email): - return super().normalize_email(email).lower() + return None if email is None else email.lower() def create_user( # type: ignore[override] - self, email: str, password: t.Optional[str] = None, **extra_fields + self, + email: t.Optional[str] = None, + password: t.Optional[str] = None, + **extra_fields, ): return super().create_user( username="", email=email, password=password, **extra_fields ) def acreate_user( # type: ignore[override] - self, email: str, password: t.Optional[str] = None, **extra_fields + self, + email: t.Optional[str] = None, + password: t.Optional[str] = None, + **extra_fields, ): return super().acreate_user( username="", email=email, password=password, **extra_fields ) def create_superuser( # type: ignore[override] - self, email: str, password: t.Optional[str] = None, **extra_fields + self, + email: t.Optional[str] = None, + password: t.Optional[str] = None, + **extra_fields, ): return super().create_superuser( username="", email=email, password=password, **extra_fields ) def acreate_superuser( # type: ignore[override] - self, email: str, password: t.Optional[str] = None, **extra_fields + self, + email: t.Optional[str] = None, + password: t.Optional[str] = None, + **extra_fields, ): return super().acreate_superuser( username="", email=email, password=password, **extra_fields @@ -137,9 +149,9 @@ class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): associated_data = "user" - EMAIL_FIELD = "email_plain" + EMAIL_FIELD = "email_enc" USERNAME_FIELD = "email_hash" - REQUIRED_FIELDS = ["email_plain"] + REQUIRED_FIELDS = ["email_enc"] credential_fields = frozenset(["email", "password"]) _password: t.Optional[str] @@ -208,9 +220,9 @@ def last_name(self, value: str): # -------------------------------------------------------------------------- email_hash = models.CharField( - _("email hash"), max_length=64, editable=False + _("email hash"), max_length=64, editable=False, unique=True, null=True ) - email_plain = models.EmailField(_("email address"), blank=True) + email_plain = models.EmailField(_("email address"), null=True, unique=True) email_enc = EncryptedTextField( associated_data="email", null=True, verbose_name=_("email address") ) @@ -223,12 +235,12 @@ def email(self): return self.email_plain @email.setter - def email(self, value: str): + def email(self, value: t.Optional[str]): """Set the user's email address.""" value = self.objects.normalize_email(value) self.email_plain = value self.email_enc = value - self.email_hash = hash_credential(value) + self.email_hash = None if value is None else hash_credential(value) # -------------------------------------------------------------------------- # Other From 780b24eaa2cb275d856af4723a7021e267a8ec27 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 16 Mar 2026 17:36:23 +0000 Subject: [PATCH 51/58] sha256 field --- codeforlife/hashers.py | 25 ---- codeforlife/models/fields/__init__.py | 1 + codeforlife/models/fields/sha256.py | 141 ++++++++++++++++++ codeforlife/models/fields/sha256_test.py | 73 +++++++++ codeforlife/user/auth/backends/email.py | 4 +- codeforlife/user/auth/backends/student.py | 8 +- codeforlife/user/filters/klass.py | 10 +- codeforlife/user/filters/user.py | 14 +- codeforlife/user/fixtures/google_users.json | 4 +- codeforlife/user/fixtures/independent.json | 8 +- codeforlife/user/fixtures/legacy.json | 76 +++++----- .../user/fixtures/non_school_teacher.json | 8 +- codeforlife/user/fixtures/school_1.json | 18 +-- codeforlife/user/fixtures/school_2.json | 12 +- codeforlife/user/fixtures/school_3.json | 8 +- .../0003_client_side_encryption_part_1.py | 14 +- codeforlife/user/models/klass.py | 11 +- codeforlife/user/models/user/user.py | 15 +- 18 files changed, 311 insertions(+), 139 deletions(-) delete mode 100644 codeforlife/hashers.py create mode 100644 codeforlife/models/fields/sha256.py create mode 100644 codeforlife/models/fields/sha256_test.py diff --git a/codeforlife/hashers.py b/codeforlife/hashers.py deleted file mode 100644 index 5382ce60..00000000 --- a/codeforlife/hashers.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -© Ocado Group -Created on 19/01/2026 at 09:55:44(+00:00). -""" - -import hashlib -import hmac - -from django.conf import settings - - -def hash_credential(credential: str): - """Create a consistent, salted hash of a credential. - - Args: - credential: The credential to hash. - - Returns: - A hash of the credential salted with the Django secret key. - """ - return hmac.new( - key=settings.SECRET_KEY.encode("utf-8"), - msg=credential.encode("utf-8"), - digestmod=hashlib.sha256, - ).hexdigest() diff --git a/codeforlife/models/fields/__init__.py b/codeforlife/models/fields/__init__.py index 19cfac94..6da52f1b 100644 --- a/codeforlife/models/fields/__init__.py +++ b/codeforlife/models/fields/__init__.py @@ -7,3 +7,4 @@ from .data_encryption_key import DataEncryptionKeyField from .deferred_attribute import DeferredAttribute from .encrypted_text import EncryptedTextField +from .sha256 import Sha256Field diff --git a/codeforlife/models/fields/sha256.py b/codeforlife/models/fields/sha256.py new file mode 100644 index 00000000..7d8273e7 --- /dev/null +++ b/codeforlife/models/fields/sha256.py @@ -0,0 +1,141 @@ +""" +© Ocado Group +Created on 16/03/2026 at 17:35:19(+00:00). +""" + +import hmac +import typing as t +from hashlib import sha256 + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db.models import CharField, Model, lookups + +from .deferred_attribute import DeferredAttribute + + +class Sha256Attribute(DeferredAttribute["Sha256Field", Model, t.Optional[str]]): + """ + Custom attribute for Sha256Field to handle hashing on assignment. + """ + + def __set__(self, instance, value): + super().__set__( + instance, value=None if value is None else Sha256Field.hash(value) + ) + + +class Sha256Field(CharField): + """A CharField that stores the hashed version of a credential. + + Values are automatically hashed on assignment. + """ + + descriptor_class = Sha256Attribute + + def __init__( + self, + editable: t.Literal[False] = False, + max_length: t.Literal[64] = 64, # Length of SHA-256 hash in hexadecimal + **kwargs, + ): + if editable: + raise ValidationError( + f"{self.__class__.__name__} cannot be editable.", + code="editable_not_allowed", + ) + if max_length != 64: + raise ValidationError( + f"{self.__class__.__name__} must have max_length of 64 to " + "store a SHA-256 hash in hexadecimal.", + code="max_length_not_64", + ) + + super().__init__(editable=editable, max_length=max_length, **kwargs) + + # Get the descriptor. + @t.overload # type: ignore[override] + def __get__(self, instance: None, owner: t.Any) -> Sha256Attribute: ... + + @t.overload # Get the value. + def __get__(self, instance: Model, owner: t.Any) -> t.Optional[str]: ... + + # Actual implementation of __get__. + def __get__(self, instance: t.Optional[Model], owner: t.Any): + return t.cast( + t.Union[Sha256Attribute, t.Optional[str]], + # pylint: disable-next=no-member + super().__get__(instance, owner), + ) + + def __set__(self, instance: Model, value: t.Optional[str]): ... + + @staticmethod + def hash(value: str): + """Create a consistent, salted hash of a value. + + Args: + value: The value to hash. + + Returns: + A hash of the value salted with the Django secret key. + """ + return hmac.new( + key=settings.SECRET_KEY.encode("utf-8"), + msg=value.encode("utf-8"), + digestmod=sha256, + ).hexdigest() + + +# pylint: disable-next=abstract-method +class Sha256ExactLookup(lookups.Exact): + """ + A lookup that hashes the right-hand side value before comparing. + + This allows querying a hashed field with a plain text value, e.g.: + `User.objects.filter(email_hash__sha256="user@example.com")` + """ + + rhs: t.Optional[str] + + lookup_name = "sha256" + + def process_rhs(self, compiler, connection): + sql, params = super().process_rhs(compiler, connection) + + return sql, params if self.rhs is None else [Sha256Field.hash(self.rhs)] + + def get_rhs_op(self, connection, rhs): + """ + Get the operator for the right-hand side of the expression. + + We force it to use the '=' operator from the 'exact' lookup. + """ + return connection.operators["exact"] % rhs + + +# pylint: disable-next=abstract-method,too-many-ancestors +class Sha256InLookup(lookups.In): + """ + A lookup that hashes the right-hand side values before comparing. + + This allows querying a hashed field with plain text values, e.g.: + `User.objects.filter(email_hash__sha256_in=["user@example.com"])` + """ + + rhs: t.Optional[t.Iterable[str]] + + lookup_name = f"{Sha256ExactLookup.lookup_name}_in" + + def process_rhs(self, compiler, connection): + sql, params = super().process_rhs(compiler, connection) + + return sql, ( + params + if self.rhs is None + else [Sha256Field.hash(value) for value in self.rhs] + ) + + +Sha256Field.register_lookup(Sha256ExactLookup) +Sha256Field.register_lookup(Sha256InLookup) diff --git a/codeforlife/models/fields/sha256_test.py b/codeforlife/models/fields/sha256_test.py new file mode 100644 index 00000000..20de1f34 --- /dev/null +++ b/codeforlife/models/fields/sha256_test.py @@ -0,0 +1,73 @@ +""" +© Ocado Group +Created on 16/03/2026 at 15:01:24(+00:00). +""" + +from ...tests import TestCase +from ...user.models import User +from .sha256 import Sha256Field + + +# pylint: disable-next=missing-class-docstring +class Sha256FieldTests(TestCase): + fixtures = ["school_1"] + + def test_init__editable_not_allowed(self): + """Cannot create Sha256Field with editable=True.""" + with self.assert_raises_validation_error(code="editable_not_allowed"): + Sha256Field(editable=True) # type: ignore[arg-type] + + def test_init__max_length_not_64(self): + """Cannot create Sha256Field with max_length not equal to 64.""" + with self.assert_raises_validation_error(code="max_length_not_64"): + Sha256Field(max_length=32) # type: ignore[arg-type] + + def test_get__descriptor(self): + """Getting field from class returns the descriptor.""" + assert isinstance(User.email_hash, Sha256Field.descriptor_class) + assert isinstance(User.email_hash.field, Sha256Field) + + def test_get__value(self): + """Getting field from instance returns the value.""" + email = "test@example.com" + user = User(email_hash=email) + assert user.email_hash == Sha256Field.hash(email) + + def test_set__none(self): + """Setting field to None sets to None.""" + user = User(email_hash=None) + assert user.__dict__["email_hash"] is None + + def test_set__str(self): + """Setting field to a string sets the hashed value.""" + email = "test@example.com" + user = User(email_hash=email) + assert user.__dict__["email_hash"] == Sha256Field.hash(email) + + def test_hash(self): + """Hashing the same value produces the same hash of 64 characters.""" + value = "consistent_value" + hashed_value = Sha256Field.hash(value) + assert hashed_value == Sha256Field.hash(value) + assert hashed_value != Sha256Field.hash("different_value") + assert len(hashed_value) == 64 + + def test_lookup__sha256(self): + """ + `sha256` lookup hashes the right-hand side value before doing an exact + match. + """ + user = User.objects.filter(email_hash__isnull=False).first() + assert user + assert user.email != user.email_hash + assert User.objects.get(email_hash__sha256=user.email) == user + + def test_lookup__sha256_in(self): + """ + `sha256_in` lookup hashes each value in the list before doing an exact + match. + """ + user = User.objects.filter(email_hash__isnull=False).first() + assert user + assert user.email != user.email_hash + assert User.objects.get(email_hash__sha256_in=[user.email]) == user diff --git a/codeforlife/user/auth/backends/email.py b/codeforlife/user/auth/backends/email.py index 4e932df2..d480056c 100644 --- a/codeforlife/user/auth/backends/email.py +++ b/codeforlife/user/auth/backends/email.py @@ -5,7 +5,6 @@ import typing as t -from ....hashers import hash_credential from ....request import HttpRequest from .base import BaseBackend @@ -24,11 +23,10 @@ def authenticate( # type: ignore[override] return None email = self.user_class.objects.normalize_email(email) - email_hash = hash_credential(email) # type: ignore[arg-type] # pylint: disable=duplicate-code try: - user = self.user_class.objects.get(email_hash=email_hash) + user = self.user_class.objects.get(email_hash__sha256=email) except self.user_class.DoesNotExist: return None # pylint: enable=duplicate-code diff --git a/codeforlife/user/auth/backends/student.py b/codeforlife/user/auth/backends/student.py index a61c7eb9..3e12d550 100644 --- a/codeforlife/user/auth/backends/student.py +++ b/codeforlife/user/auth/backends/student.py @@ -5,7 +5,6 @@ import typing as t -from ....hashers import hash_credential from ....request import HttpRequest from ...models import StudentUser from .base import BaseBackend @@ -27,14 +26,11 @@ def authenticate( # type: ignore[override] if first_name is None or password is None or class_id is None: return None - first_name_hash = hash_credential(first_name) - class_id_hash = hash_credential(class_id) - # pylint: disable=duplicate-code try: user = self.user_class.objects.get( - first_name_hash=first_name_hash, - new_student__class_field__access_code_hash=class_id_hash, + first_name_hash__sha256=first_name, + new_student__class_field__access_code_hash__sha256=class_id, ) except self.user_class.DoesNotExist: return None diff --git a/codeforlife/user/filters/klass.py b/codeforlife/user/filters/klass.py index 2c0af857..b652976c 100644 --- a/codeforlife/user/filters/klass.py +++ b/codeforlife/user/filters/klass.py @@ -9,7 +9,6 @@ rest_framework as filters, ) -from ...hashers import hash_credential # isort: skip from ...filters import FilterSet # isort: skip from ..models import Class # isort: skip @@ -21,11 +20,8 @@ class ClassFilterSet(FilterSet): id_or_name = filters.CharFilter(method="id_or_name__method") def _id__method(self, queryset: QuerySet[Class], name: str, *args): - access_code_hashes = [ - hash_credential(access_code) - for access_code in self.request.GET.getlist(name) - ] - return queryset.exclude(**{"access_code_hash__in": access_code_hashes}) + access_codes = self.request.GET.getlist(name) + return queryset.exclude(**{"access_code_hash__sha256_in": access_codes}) def id_or_name__method(self, queryset: QuerySet[Class], _: str, value: str): """Get classes where the id or the name contain a substring.""" @@ -37,7 +33,7 @@ def id_or_name__method(self, queryset: QuerySet[Class], _: str, value: str): ] return queryset.filter( - Q(access_code_hash=hash_credential(value)) | Q(pk__in=pks) + Q(access_code_hash__sha256=value) | Q(pk__in=pks) ) class Meta: diff --git a/codeforlife/user/filters/user.py b/codeforlife/user/filters/user.py index fa749c02..671523df 100644 --- a/codeforlife/user/filters/user.py +++ b/codeforlife/user/filters/user.py @@ -10,7 +10,6 @@ rest_framework as filters, ) -from ...hashers import hash_credential # isort: skip from ...filters import FilterSet # isort: skip from ..models import ( # isort: skip User, @@ -22,7 +21,10 @@ # pylint: disable-next=missing-class-docstring class UserFilterSet(FilterSet): - students_in_class = filters.CharFilter(method="students_in_class__method") + students_in_class = filters.CharFilter( + "new_student__class_field__access_code_hash", + "sha256", + ) _id = filters.NumberFilter(method="_id__method") _id__method = FilterSet.make_exclude_field_list_method("id") @@ -39,14 +41,6 @@ class UserFilterSet(FilterSet): method="type__method", ) - def students_in_class__method( - self: FilterSet, queryset: QuerySet[User], _: str, value: str - ): - """Get students in a class by the class access code.""" - return queryset.filter( - new_student__class_field__access_code_hash=hash_credential(value) - ) - def name__method( self: FilterSet, queryset: QuerySet[User], name: str, *args ): diff --git a/codeforlife/user/fixtures/google_users.json b/codeforlife/user/fixtures/google_users.json index dc325f67..6615ea08 100644 --- a/codeforlife/user/fixtures/google_users.json +++ b/codeforlife/user/fixtures/google_users.json @@ -5,10 +5,10 @@ "fields": { "dek": "ZmFrZV9lbmM6YlVkTE4xSndaRlZTTW5kc1NqQkJXa1ZxU2xKV2NWTk9WbFJ6YzFjMmVHVT0=", "email_enc": "ZmFrZV9lbmM6WjI5dloyeGxMblJsWVdOb1pYSkFibTl6WTJodmIyd3VZMjl0", - "email_hash": "ee95f43c0012fa1a9d5771313a7034cf94af568b0588b34ca20c34c25701af78", + "email_hash": "google.teacher@noschool.com", "email_plain": "google.teacher@noschool.com", "first_name_enc": "ZmFrZV9lbmM6UjI5dloyeGw=", - "first_name_hash": "5463f3f79e73077256f212d9e43a2752447cb7b238cda23004926bc0b9c5076f", + "first_name_hash": "Google", "first_name_plain": "Google", "last_name_enc": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", "last_name_plain": "Teacher", diff --git a/codeforlife/user/fixtures/independent.json b/codeforlife/user/fixtures/independent.json index 6e1dee5a..4ccd38e6 100644 --- a/codeforlife/user/fixtures/independent.json +++ b/codeforlife/user/fixtures/independent.json @@ -5,10 +5,10 @@ "fields": { "dek": "ZmFrZV9lbmM6Ym5oYVFqaHhUMDV4VTI4NE9YVk9TbmxvVkVzemRUWnpkMUpTWjFkMFVFRT0=", "email_enc": "ZmFrZV9lbmM6YVc1a2VTNXlaWEYxWlhOMFpYSkFaVzFoYVd3dVkyOXQ=", - "email_hash": "8efc7683cec3a31792d0c22f49a1cc374ba2ae2199b3cb8d228a7cbabe8370b2", + "email_hash": "indy.requester@email.com", "email_plain": "indy.requester@email.com", "first_name_enc": "ZmFrZV9lbmM6U1c1a2VRPT0=", - "first_name_hash": "c90d2a9004eccdad8bec752f90ffe656955c44a81f984d3d32b827f17b473d7c", + "first_name_hash": "Indy", "first_name_plain": "Indy", "last_name_enc": "ZmFrZV9lbmM6VW1WeGRXVnpkR1Z5", "last_name_plain": "Requester", @@ -38,10 +38,10 @@ "fields": { "dek": "ZmFrZV9lbmM6TTIxbFVXMWlWMnR3VXpOa2JXaENlbkJGUW05a1oyZEpkRzV6YlhGa1pVYz0=", "email_enc": "ZmFrZV9lbmM6YVc1a2VVQmxiV0ZwYkM1amIyMD0=", - "email_hash": "63e90930d7617f596f1006362382d81aecb8308c5a9893007f78c50e954e6d1a", + "email_hash": "indy@email.com", "email_plain": "indy@email.com", "first_name_enc": "ZmFrZV9lbmM6U1c1a2VRPT0=", - "first_name_hash": "c90d2a9004eccdad8bec752f90ffe656955c44a81f984d3d32b827f17b473d7c", + "first_name_hash": "Indy", "first_name_plain": "Indy", "last_name_enc": "ZmFrZV9lbmM6VG05U1pYRjFaWE4w", "last_name_plain": "NoRequest", diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json index beb9fd43..b0d5041c 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -297,7 +297,7 @@ "fields": { "accept_requests_until": null, "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qTT0=", - "access_code_hash": "7dcbfe80fd523896acdad8310fe14d804dad5ae7e4dc4975815f3d5ff76aa963", + "access_code_hash": "AB123", "access_code_plain": "AB123", "always_accept_requests": true, "classmates_data_viewable": true, @@ -315,7 +315,7 @@ "fields": { "accept_requests_until": null, "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qUT0=", - "access_code_hash": "ee16d5af3f31578ab0327da3257b212fe2beefcadef4f6b8ad6c992d153b90aa", + "access_code_hash": "AB124", "access_code_plain": "AB124", "always_accept_requests": true, "classmates_data_viewable": true, @@ -333,7 +333,7 @@ "fields": { "accept_requests_until": null, "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qVT0=", - "access_code_hash": "a895a482b72ec418311b8a9677bcf2c690425993f563f58d4a15b9586753b88b", + "access_code_hash": "AB125", "access_code_plain": "AB125", "always_accept_requests": true, "classmates_data_viewable": true, @@ -351,7 +351,7 @@ "fields": { "accept_requests_until": null, "access_code_enc": "ZmFrZV9lbmM6VWt3eE1qTT0=", - "access_code_hash": "38045227bf3c575c643dc1b34a1e70669f35b90225495bbeba0a53ae5ba82c81", + "access_code_hash": "RL123", "access_code_plain": "RL123", "always_accept_requests": true, "classmates_data_viewable": true, @@ -369,7 +369,7 @@ "fields": { "accept_requests_until": null, "access_code_enc": "ZmFrZV9lbmM6VUU4eE1qTT0=", - "access_code_hash": "732f2cd0915bdea50771ce7c92a4e1b8ea83f6d3323b95dd07619e5ea7f0fd06", + "access_code_hash": "PO123", "access_code_plain": "PO123", "always_accept_requests": true, "classmates_data_viewable": true, @@ -591,10 +591,10 @@ "date_joined": "2026-02-04T16:02:33.631Z", "dek": "ZmFrZV9lbmM6UkZkTlNrYzVhR05aVlZwRVZFaFlTMHhUVTJnMlVVbzNORVZUZERjeFIxTT0=", "email_enc": "ZmFrZV9lbmM6WTI5a1pXWnZjbXhwWm1VdGNHOXlkR0ZzUUc5allXUnZMbU52YlE9PQ==", - "email_hash": "6229d653107a83cfe416912e62307e9b3bfa5185b06deb74db27576e291ba301", + "email_hash": "codeforlife-portal@ocado.com", "email_plain": "codeforlife-portal@ocado.com", "first_name_enc": "ZmFrZV9lbmM6VUc5eWRHRnM=", - "first_name_hash": "dc0c24809d18768f01857048c816eb3885cb627a759bd2dd5314970adafdaf3e", + "first_name_hash": "Portal", "first_name_plain": "Portal", "groups": [], "is_active": true, @@ -614,10 +614,10 @@ "date_joined": "2026-02-04T16:02:34.051Z", "dek": "ZmFrZV9lbmM6VlZkaFpuUlZha2hNUjJGTU5XSlNaR3hsVUhFMVkzSjBNR1l4TUZwa1IxQT0=", "email_enc": "ZmFrZV9lbmM6WVd4aVpYSjBaV2x1YzNSbGFXNUFZMjlrWldadmNteHBabVV1WTI5dA==", - "email_hash": "9cb6a6152b4d3dfbad65576fb4f6688b56d73cbc65b36b2a7fbb313a1352d3c4", + "email_hash": "alberteinstein@codeforlife.com", "email_plain": "alberteinstein@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6UVd4aVpYSjA=", - "first_name_hash": "920e5149d016b9ef005052b8f52ec833b0974ecf7fb25dc8efd42d8cb1912bf2", + "first_name_hash": "Albert", "first_name_plain": "Albert", "groups": [], "is_active": true, @@ -637,10 +637,10 @@ "date_joined": "2026-02-04T16:02:34.252Z", "dek": "ZmFrZV9lbmM6ZDIwMmQyaGFNMlJHWWxNM2EyaEVaVlZUTXpGbFRsZEhUMjlrTnpoMmRUaz0=", "email_enc": "ZmFrZV9lbmM6YldGNGNHeGhibU5yUUdOdlpHVm1iM0pzYVdabExtTnZiUT09", - "email_hash": "586984c6c88ed9d06cbb6d64e746ca1c7b71dc8782f68a075aece08fd2b5557a", + "email_hash": "maxplanck@codeforlife.com", "email_plain": "maxplanck@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6VFdGNA==", - "first_name_hash": "337aa91f0cb585a80fee292721d7e78119da4aa52be054785bf1069302979e5e", + "first_name_hash": "Max", "first_name_plain": "Max", "groups": [], "is_active": true, @@ -660,10 +660,10 @@ "date_joined": "2026-02-04T16:02:34.448Z", "dek": "ZmFrZV9lbmM6WW5CTGJHRnBSMDVrVGt4eVFWbFljMDU2VjJwM1dqWmlia2xPUlhwVVdqVT0=", "email_enc": "ZmFrZV9lbmM6Y21GdGJHVnBkR2hBWTI5a1pXWnZjbXhwWm1VdVkyOXQ=", - "email_hash": "6df8a9b30d61359c2f434a45e881fd4461899af9f5df0e05fd15ba57e5885878", + "email_hash": "ramleith@codeforlife.com", "email_plain": "ramleith@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6VW1GdA==", - "first_name_hash": "62ea6bdedfb21184d0fb2342a6109dfd8ecbe0ed5b80ec8db8b2b414ac01423c", + "first_name_hash": "Ram", "first_name_plain": "Ram", "groups": [], "is_active": true, @@ -683,10 +683,10 @@ "date_joined": "2026-02-04T16:02:34.641Z", "dek": "ZmFrZV9lbmM6ZVhsRFZWQkRXWEJVVWxvM1QzbDJhM05yV0VkblIyUldRVlpHVG5CVVRqST0=", "email_enc": "ZmFrZV9lbmM6YkdWdmJtRnlaRzlrWVhacGJtTnBRR052WkdWbWIzSnNhV1psTG1OdmJRPT0=", - "email_hash": "55a63981ea426d7470560ecfaf3b53c5ecd90e036edd8eca2b1a33c84178908e", + "email_hash": "leonardodavinci@codeforlife.com", "email_plain": "leonardodavinci@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6VEdWdmJtRnlaRzg9", - "first_name_hash": "9e84472777869ca6205d06421ed265c1c3cfcdfc35a7de158201227c3c541198", + "first_name_hash": "Leonardo", "first_name_plain": "Leonardo", "groups": [], "is_active": true, @@ -706,10 +706,10 @@ "date_joined": "2026-02-04T16:02:34.839Z", "dek": "ZmFrZV9lbmM6ZW10aVZtbHVibWxpYXpKSmMxUlhWWHBzTmsxMFRYcHZkblJOV1hGUGJERT0=", "email_enc": "ZmFrZV9lbmM6WjJGc2FXeGxiMmRoYkdsc1pXbEFZMjlrWldadmNteHBabVV1WTI5dA==", - "email_hash": "1c5f6e85fe62be2048d5a3867eff53db48f9bcd6da889b92e68668a4a464afd2", + "email_hash": "galileogalilei@codeforlife.com", "email_plain": "galileogalilei@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6UjJGc2FXeGxidz09", - "first_name_hash": "cae481802ba39e14537dda2d0125c791bab6e0b4e323e9833591a5de6c50207e", + "first_name_hash": "Galileo", "first_name_plain": "Galileo", "groups": [], "is_active": true, @@ -729,10 +729,10 @@ "date_joined": "2026-02-04T16:02:35.036Z", "dek": "ZmFrZV9lbmM6VGtWVWVHeFlXSEpaUTBKcGJFVk9TV3hYWkhCSmRtbERRa2h3WmtKQ01FUT0=", "email_enc": "ZmFrZV9lbmM6YVhOaFlXTnVaWGQwYjI1QVkyOWtaV1p2Y214cFptVXVZMjl0", - "email_hash": "4fc8d589140865b27bc27623cb547e2a24f9b985ab2e1dd0b5b5c035d367c4b6", + "email_hash": "isaacnewton@codeforlife.com", "email_plain": "isaacnewton@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6U1hOaFlXTT0=", - "first_name_hash": "c49fb23f0176885dece511c58087b7f23019c343b9861e2d70926600577a4611", + "first_name_hash": "Isaac", "first_name_plain": "Isaac", "groups": [], "is_active": true, @@ -752,10 +752,10 @@ "date_joined": "2026-02-04T16:02:35.230Z", "dek": "ZmFrZV9lbmM6U1ZCNFNVbFRkR1JvY2xodmRGSXpOM0ZrYmpsb2MycGFXblZTTVZaemFWWT0=", "email_enc": "ZmFrZV9lbmM6Y21samFHRnlaR1psZVc1dFlXNUFZMjlrWldadmNteHBabVV1WTI5dA==", - "email_hash": "6bd23daa66a426a27655016f084d3fa65a49e2e5d264701b6d84e550e415638d", + "email_hash": "richardfeynman@codeforlife.com", "email_plain": "richardfeynman@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6VW1samFHRnlaQT09", - "first_name_hash": "0a8a5deb9f8f9a8f0e96af7630e59b6fdc0a2528095a5a382c3d417da9c97f7e", + "first_name_hash": "Richard", "first_name_plain": "Richard", "groups": [], "is_active": true, @@ -775,10 +775,10 @@ "date_joined": "2026-02-04T16:02:35.422Z", "dek": "ZmFrZV9lbmM6YVc5eFlYcHVjMWsxVEZWVFRXaFRRVFpEVkhWMWNUZG9abmR5UVRCT2RERT0=", "email_enc": "ZmFrZV9lbmM6WVd4bGVHRnVaR1Z5Wm14bGJXMXBibWRBWTI5a1pXWnZjbXhwWm1VdVkyOXQ=", - "email_hash": "1ab9d2f1ae8ef6a5e99cee8948c326e3404aceb89b8d72215e297a1cfc8393a6", + "email_hash": "alexanderflemming@codeforlife.com", "email_plain": "alexanderflemming@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6UVd4bGVHRnVaR1Z5", - "first_name_hash": "e78f1c3e180b8af2c8dfc79ec04d65ac565361c887584100e67890cdb70ab52b", + "first_name_hash": "Alexander", "first_name_plain": "Alexander", "groups": [], "is_active": true, @@ -798,10 +798,10 @@ "date_joined": "2026-02-04T16:02:35.611Z", "dek": "ZmFrZV9lbmM6WjJscWNqWlFRbEZCVkZCaGVsRTRWVFl3ZDBWRFltbEROM2x4UXpCQmVGUT0=", "email_enc": "ZmFrZV9lbmM6WkdGdWFXVnNZbVZ5Ym05MWJHeHBRR052WkdWbWIzSnNhV1psTG1OdmJRPT0=", - "email_hash": "0c92dd15cc629b84ea9436c965f944543cfedd41ced6c8f2594a26ee50cf3ea5", + "email_hash": "danielbernoulli@codeforlife.com", "email_plain": "danielbernoulli@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6UkdGdWFXVnM=", - "first_name_hash": "6bc6d4fc17ad3131d3417aebb876bdec85a697de1a69734b9d113c800e72aaf3", + "first_name_hash": "Daniel", "first_name_plain": "Daniel", "groups": [], "is_active": true, @@ -821,10 +821,10 @@ "date_joined": "2026-02-04T16:02:35.803Z", "dek": "ZmFrZV9lbmM6UW5KQ2JUYzFSRU54VVhCd05rRmxka2w0ZVRoMFdGWkhhbEpCUzBnMGRuWT0=", "email_enc": "ZmFrZV9lbmM6YVc1a2FXRnVZV3B2Ym1WelFHTnZaR1ZtYjNKc2FXWmxMbU52YlE9PQ==", - "email_hash": "1a93bc8026b39e9d71124c74cef2f072d4e41625ea3b12f4009f104dce962e2f", + "email_hash": "indianajones@codeforlife.com", "email_plain": "indianajones@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6U1c1a2FXRnVZUT09", - "first_name_hash": "9859180db8b64c329244146e3cfda4e6c42fd38f8ef9fc0f272af81298c6c02a", + "first_name_hash": "Indiana", "first_name_plain": "Indiana", "groups": [], "is_active": true, @@ -845,7 +845,7 @@ "dek": "ZmFrZV9lbmM6TVhGU1NtSlhaVkpwYlRWWVR6QTNlVE5UV2xaNWRHNUlkbFJtVG1GMVJHVT0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6VG05aGFBPT0=", - "first_name_hash": "91886b94f7298f93affde2a4c4bb1e1ec836b5da66507834c964dc8db418fa14", + "first_name_hash": "Noah", "first_name_plain": "Noah", "groups": [], "is_active": true, @@ -866,7 +866,7 @@ "dek": "ZmFrZV9lbmM6ZFZwVmRGbHpTVkoxTkVaMU5XRk9WMDlwV25CS1EzVlRaR28yV2tac1QzVT0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6Uld4c2FXOTA=", - "first_name_hash": "050fa7990069876e75ceb3fe387a3f10b5beec63612c5b4e500cfcf246daa312", + "first_name_hash": "Elliot", "first_name_plain": "Elliot", "groups": [], "is_active": true, @@ -887,7 +887,7 @@ "dek": "ZmFrZV9lbmM6UW1sWlVsSjNNM0JHVW1Sd1puQkNaVU4xUkdSVGVHRk1kV2x5ZFRoUGNFST0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6VkdGcWJXRmw=", - "first_name_hash": "969bd503b94e99bb283caa34c5e28fcb8cebfb278111bff1f76ed1f8d08c2720", + "first_name_hash": "Tajmae", "first_name_plain": "Tajmae", "groups": [], "is_active": true, @@ -908,7 +908,7 @@ "dek": "ZmFrZV9lbmM6ZEhWRlUxSjFZMUpxZFdoaU5XTnNiWEpqZVdSUVdFdGtOVWhFVkVGVWRrND0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6UTJGeWJIUnZiZz09", - "first_name_hash": "8e450f405264e612fb62f915830166cd43c03612b82c19c0c52aaee93183b0bb", + "first_name_hash": "Carlton", "first_name_plain": "Carlton", "groups": [], "is_active": true, @@ -929,7 +929,7 @@ "dek": "ZmFrZV9lbmM6TW5SM1FUQnNUMlJ2VFU1RFQwUllVSEp1VFc1aWQxSmxOWHBqVDJKeFZtTT0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6VG1Ga1lXdz0=", - "first_name_hash": "8424a2115bf1ee8fa253b59cd46cd6487c1b53b51201e593c08a12c390d70ad4", + "first_name_hash": "Nadal", "first_name_plain": "Nadal", "groups": [], "is_active": true, @@ -950,7 +950,7 @@ "dek": "ZmFrZV9lbmM6UlZKbll6UjJhM1JPU2tvemRIQTVSRUZZYUZsMGFtOVZla05HYzI5bmQyaz0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6Um5KbFpHUnBaUT09", - "first_name_hash": "8d81abd4d9f4dd116d4473915b068c9790f2229ab72fbcd974daff57ebf62717", + "first_name_hash": "Freddie", "first_name_plain": "Freddie", "groups": [], "is_active": true, @@ -971,7 +971,7 @@ "dek": "ZmFrZV9lbmM6YzIxdGRIbzVRbEkyVlRKdVRVVk1lSFE1TmtRMFFXeFVUamRaYldWbFpFUT0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6VEdWdmJnPT0=", - "first_name_hash": "5081dca0f5b49f2fdfda5543c6bbeef8e733838de7b8e642064f9b8edc0e24b5", + "first_name_hash": "Leon", "first_name_plain": "Leon", "groups": [], "is_active": true, @@ -992,7 +992,7 @@ "dek": "ZmFrZV9lbmM6T0VoaU5rRTVWalZTVUVkbVpVTlllRmhOVlVoVVZVRnVUR1pvVVU5bVUybz0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6UW1WMGRIaz0=", - "first_name_hash": "c25e694e58d404373d63ef5dd2b2a413dab9c64aab028d735401c166813a87fa", + "first_name_hash": "Betty", "first_name_plain": "Betty", "groups": [], "is_active": true, @@ -1013,7 +1013,7 @@ "dek": "ZmFrZV9lbmM6Y25SMVIwYzNNbXBVUWtkTk1IVmhjMUI2YVdWb2VtNVBaRUUyVFdsMldrcz0=", "email_plain": "", "first_name_enc": "ZmFrZV9lbmM6UkdWc1pYUmxaQT09", - "first_name_hash": "959e43f80903c037d87f933a2225789fbdbd71e0dea09fc1854a51e8d2f9b17e", + "first_name_hash": "Deleted", "first_name_plain": "Deleted", "groups": [], "is_active": false, @@ -1033,10 +1033,10 @@ "date_joined": "2026-02-04T16:02:40.242Z", "dek": "ZmFrZV9lbmM6WmtGRFlYQjBUek5OU1VjeGVXczNVbmRTVmxsRGRtOXlWREZzVFU5Q1dqQT0=", "email_enc": "ZmFrZV9lbmM6WVdSdGFXNXpkSFZrWlc1MFFHTnZaR1ZtYjNKc2FXWmxMbU52YlE9PQ==", - "email_hash": "d10b776ab1e76347e63692528b0e4e97a5e3288b5f3c918476577a96b9255463", + "email_hash": "adminstudent@codeforlife.com", "email_plain": "adminstudent@codeforlife.com", "first_name_enc": "ZmFrZV9lbmM6VUc5eWRHRnNZV1J0YVc0PQ==", - "first_name_hash": "689afa6b012cd1c17a5b32dbf80482ccb8049f87af317883a23e7144b84242b2", + "first_name_hash": "Portaladmin", "first_name_plain": "Portaladmin", "groups": [], "is_active": true, diff --git a/codeforlife/user/fixtures/non_school_teacher.json b/codeforlife/user/fixtures/non_school_teacher.json index a3fadbcb..f05192da 100644 --- a/codeforlife/user/fixtures/non_school_teacher.json +++ b/codeforlife/user/fixtures/non_school_teacher.json @@ -5,10 +5,10 @@ "fields": { "dek": "ZmFrZV9lbmM6TkhGdE1EWlFOVkJ3VDFwTVVYWmlVRzlFYVdwU1JIUTVialZ0V1RoQ2NXYz0=", "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J1YjNOamFHOXZiQzVqYjIwPQ==", - "email_hash": "7cc99dd2f335a068ac421d2cec8f04687fa34d37d313718f56ebc6b6c3c09d2c", + "email_hash": "teacher@noschool.com", "email_plain": "teacher@noschool.com", "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", - "first_name_hash": "7554c6b4930e0ac2c4d7fb5e7e266797b0444953067a0d7a17e835b518229e84", + "first_name_hash": "John", "first_name_plain": "John", "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", @@ -37,10 +37,10 @@ "fields": { "dek": "ZmFrZV9lbmM6UVhac01Ea3dibmQxYzBkRFdVbGFRMDR4VEZkelRVWklUbHA1VVZsU1RXOD0=", "email_enc": "ZmFrZV9lbmM6ZFc1MlpYSnBabWxsWkM1MFpXRmphR1Z5UUc1dmMyTm9iMjlzTG1OdmJRPT0=", - "email_hash": "7f34bbe92504e0ca5c2235f2f64bf2686a7d040690a45948074247cad2eb5c71", + "email_hash": "unverified.teacher@noschool.com", "email_plain": "unverified.teacher@noschool.com", "first_name_enc": "ZmFrZV9lbmM6Vlc1MlpYSnBabWxsWkE9PQ==", - "first_name_hash": "47c086ea8af2fcd653408088d2d3809d3d46fd2698755a4a208d5fb78ebc205b", + "first_name_hash": "Unverified", "first_name_plain": "Unverified", "last_name_enc": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", "last_name_plain": "Teacher", diff --git a/codeforlife/user/fixtures/school_1.json b/codeforlife/user/fixtures/school_1.json index 2642c9e7..aeea461a 100644 --- a/codeforlife/user/fixtures/school_1.json +++ b/codeforlife/user/fixtures/school_1.json @@ -16,10 +16,10 @@ "fields": { "dek": "ZmFrZV9lbmM6U2tGVmEzZHhlVkF4VERKR1QwaGxPVWRTWkhkVE16WTBiREV3WjFkQlVHcz0=", "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3hMbU52YlE9PQ==", - "email_hash": "d23e2f365e919be1cf5e29a5472801d54bd75c6f360bce7c37674506bc63a9ce", + "email_hash": "teacher@school1.com", "email_plain": "teacher@school1.com", "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", - "first_name_hash": "7554c6b4930e0ac2c4d7fb5e7e266797b0444953067a0d7a17e835b518229e84", + "first_name_hash": "John", "first_name_plain": "John", "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", @@ -49,7 +49,7 @@ "fields": { "accept_requests_until": "9999-02-09 20:26:08.298402+00:00", "access_code_enc": "ZmFrZV9lbmM6V2xveE1URT0=", - "access_code_hash": "ca366870cf5e795a3c9ff340f119df2ff646128cf164d992b0167bbca8c247eb", + "access_code_hash": "ZZ111", "access_code_plain": "ZZ111", "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNU0JBSUZOamFHOXZiQ0F4", "name_plain": "Class 1 @ School 1", @@ -62,7 +62,7 @@ "fields": { "dek": "ZmFrZV9lbmM6ZEc1aFZ6RkhVRTFNVFdaSWQzTlZXRmxRZFRsTFZIbE1kRWxSVGs1Q1FWQT0=", "first_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkREU9", - "first_name_hash": "341d77ca3f20f1a3ddf8d9a06d076940c6e24b252a08c5dae066b43c2b236ed5", + "first_name_hash": "Student1", "first_name_plain": "Student1", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -90,10 +90,10 @@ "fields": { "dek": "ZmFrZV9lbmM6YkVGQk1XVTBVWFJqTWpFelFVMWhTVE5EUWtWeVMzZGpjR1JNU2xSTVlrUT0=", "email_enc": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3eExtTnZiUT09", - "email_hash": "01c1e0721b31cb0636da3f5b3dc1a107bdd44fd0185fa12dd87f6aad06c91a08", + "email_hash": "admin.teacher@school1.com", "email_plain": "admin.teacher@school1.com", "first_name_enc": "ZmFrZV9lbmM6U21GdVpRPT0=", - "first_name_hash": "a5945caf6491a22426f870debae98c823043af86691d336f95248daff7bb989a", + "first_name_hash": "Jane", "first_name_plain": "Jane", "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", @@ -123,7 +123,7 @@ "pk": 7, "fields": { "access_code_enc": "ZmFrZV9lbmM6V2xveU1qST0=", - "access_code_hash": "e90c6683965279b92914a9e2b18716f0bc826c0dccb5976130562ff88cbd7f01", + "access_code_hash": "ZZ222", "access_code_plain": "ZZ222", "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F4", "name_plain": "Class 2 @ School 1", @@ -136,7 +136,7 @@ "fields": { "dek": "ZmFrZV9lbmM6YXpWd1dGZEZOWHBXZEdnMFJWQlROWFZFV2s0eVRuVTBOVTlGZUdwNWJtND0=", "first_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkREk9", - "first_name_hash": "f7ecf0b3ebd27342b1a6d30a6ef300b6dc23518fd5c83ca25cdc2b7135927cf8", + "first_name_hash": "Student2", "first_name_plain": "Student2", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } @@ -164,7 +164,7 @@ "fields": { "accept_requests_until": "2023-02-09 20:26:08.298402+00:00", "access_code_enc": "ZmFrZV9lbmM6V2xvek16TT0=", - "access_code_hash": "3e3b96d4c7775a2c8d15999f6d662b164ccf3d66b8df93329bd5e426251d8cdb", + "access_code_hash": "ZZ333", "access_code_plain": "ZZ333", "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNeUJBSUZOamFHOXZiQ0F4", "name_plain": "Class 3 @ School 1", diff --git a/codeforlife/user/fixtures/school_2.json b/codeforlife/user/fixtures/school_2.json index 827d971f..64086be1 100644 --- a/codeforlife/user/fixtures/school_2.json +++ b/codeforlife/user/fixtures/school_2.json @@ -16,10 +16,10 @@ "fields": { "dek": "ZmFrZV9lbmM6UkhWQ1kwaHlOa2hqYzFSV1MxcE1SblkwYnpnMGRtNUxWMnRLV1dWMlRUYz0=", "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3lMbU52YlE9PQ==", - "email_hash": "8cdd687351ee31833adcb72d37e7b8f1c1c17c655c3fb182ac0127320af64284", + "email_hash": "teacher@school2.com", "email_plain": "teacher@school2.com", "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", - "first_name_hash": "7554c6b4930e0ac2c4d7fb5e7e266797b0444953067a0d7a17e835b518229e84", + "first_name_hash": "John", "first_name_plain": "John", "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", @@ -137,7 +137,7 @@ "pk": 8, "fields": { "access_code_enc": "ZmFrZV9lbmM6V0ZneE1URT0=", - "access_code_hash": "0157477281b2aee7660889207dee1a14756bddd428162de41bada7a02c593ff6", + "access_code_hash": "XX111", "access_code_plain": "XX111", "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNU0JBSUZOamFHOXZiQ0F5", "name_plain": "Class 1 @ School 2", @@ -150,10 +150,10 @@ "fields": { "dek": "ZmFrZV9lbmM6WkdoS2FsQnNjR3R4UmtaSFdrOVJTa2huVW1wa1lVcDVhRVJ2Ulhkd2RuYz0=", "email_enc": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3eUxtTnZiUT09", - "email_hash": "4841a8d3657918adffdab6cb4a0a6b64ad771aa56a53f80fed0ee8d074841ca3", + "email_hash": "admin.teacher@school2.com", "email_plain": "admin.teacher@school2.com", "first_name_enc": "ZmFrZV9lbmM6U21GdVpRPT0=", - "first_name_hash": "a5945caf6491a22426f870debae98c823043af86691d336f95248daff7bb989a", + "first_name_hash": "Jane", "first_name_plain": "Jane", "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", "last_name_plain": "Doe", @@ -192,7 +192,7 @@ "pk": 9, "fields": { "access_code_enc": "ZmFrZV9lbmM6V0ZneU1qST0=", - "access_code_hash": "750df33e8a531e83fddf652344b680983a1555c6dcd8fda80f4af785357b444d", + "access_code_hash": "XX222", "access_code_plain": "XX222", "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F5", "name_plain": "Class 2 @ School 2", diff --git a/codeforlife/user/fixtures/school_3.json b/codeforlife/user/fixtures/school_3.json index 74b54b88..0ef07199 100644 --- a/codeforlife/user/fixtures/school_3.json +++ b/codeforlife/user/fixtures/school_3.json @@ -16,10 +16,10 @@ "fields": { "dek": "ZmFrZV9lbmM6ZGxKVE4xTm5jRGhKT1ZWVVdqWlpPVUpDUm14V1EycDRNbkYxZUc1aVExbz0=", "email_enc": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3ekxtTnZiUT09", - "email_hash": "22a604d4828ae8429e8303d16c7d9967145b214b827a8ff1845b19e2a10b12c3", + "email_hash": "admin.teacher@school3.com", "email_plain": "admin.teacher@school3.com", "first_name_enc": "ZmFrZV9lbmM6VUdWMFpYST0=", - "first_name_hash": "083ec2a4fd4004ed6f9bd61965b170a9b5db5d5873c7217f65bede117f004a79", + "first_name_hash": "Peter", "first_name_plain": "Peter", "last_name_enc": "ZmFrZV9lbmM6VUdGeWEyVnk=", "last_name_plain": "Parker", @@ -50,10 +50,10 @@ "fields": { "dek": "ZmFrZV9lbmM6VjA5cGVVcG1SR1pRTkdoNVptUXhOR1Z0ZEVOblVtUmtjRWhJUlZob01Faz0=", "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3pMbU52YlE9PQ==", - "email_hash": "c66f480d5458f514966dd1a540e652d1c57850a11122a26b1760eb95d7833fa1", + "email_hash": "teacher@school3.com", "email_plain": "teacher@school3.com", "first_name_enc": "ZmFrZV9lbmM6Ukc5amRHOXk=", - "first_name_hash": "be367a9fe2b4bf78b7f7bd98e888fac606cfafa10fc91f25745d4a60e167ba72", + "first_name_hash": "Doctor", "first_name_plain": "Doctor", "last_name_enc": "ZmFrZV9lbmM6VDJOMGIzQjFjdz09", "last_name_plain": "Octopus", diff --git a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py index d8f63d6c..3215ae4d 100644 --- a/codeforlife/user/migrations/0003_client_side_encryption_part_1.py +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py @@ -1,9 +1,13 @@ import typing as t -from codeforlife.models.fields.data_encryption_key import DataEncryptionKeyField -from codeforlife.models.fields.encrypted_text import EncryptedTextField from django.db import migrations, models +from ...models.fields import ( + DataEncryptionKeyField, + EncryptedTextField, + Sha256Field, +) + def rename_plain_text_fields_and_create_encrypted_text_fields( model_name: str, fields: t.Dict[str, str] @@ -69,7 +73,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="user", name="email_hash", - field=models.CharField( + field=Sha256Field( unique=True, null=True, editable=False, @@ -80,14 +84,14 @@ class Migration(migrations.Migration): migrations.AddField( model_name="user", name="first_name_hash", - field=models.CharField( + field=Sha256Field( editable=False, max_length=64, verbose_name="first name hash" ), ), migrations.AddField( model_name="class", name="access_code_hash", - field=models.CharField( + field=Sha256Field( null=True, editable=False, max_length=64, diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index c158f665..0bfdf6f3 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -13,9 +13,8 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from ...hashers import hash_credential from ...models import EncryptedModel -from ...models.fields import EncryptedTextField +from ...models.fields import EncryptedTextField, Sha256Field from ...types import Validators from ...validators import ( UnicodeAlphanumericCharSetValidator, @@ -105,8 +104,8 @@ def name(self, value: str): # Access code # -------------------------------------------------------------------------- - access_code_hash = models.CharField( - _("access code hash"), max_length=64, editable=False, null=True + access_code_hash = Sha256Field( + verbose_name=_("access code hash"), null=True ) access_code_plain: t.Optional[str] access_code_plain = models.CharField( # type: ignore[assignment] @@ -131,9 +130,7 @@ def access_code(self, value: t.Optional[str]): """Set the access code for the class.""" self.access_code_plain = value self.access_code_enc = value - self.access_code_hash = ( - value if value is None else hash_credential(value) - ) + self.access_code_hash = value # -------------------------------------------------------------------------- diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 02d5f048..6538e1c1 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -18,9 +18,8 @@ from django.utils.translation import gettext_lazy as _ from pyotp import TOTP -from ....hashers import hash_credential from ....models import AbstractBaseUser, DataEncryptionKeyModel -from ....models.fields import EncryptedTextField +from ....models.fields import EncryptedTextField, Sha256Field from ....types import Validators from ....validators import UnicodeAlphanumericCharSetValidator @@ -167,9 +166,7 @@ class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): # First name # -------------------------------------------------------------------------- - first_name_hash = models.CharField( - _("first name hash"), max_length=64, editable=False - ) + first_name_hash = Sha256Field(verbose_name=_("first name hash")) first_name_plain = models.CharField( _("first name"), max_length=150, blank=True ) @@ -189,7 +186,7 @@ def first_name(self, value: str): """Set the user's first name.""" self.first_name_enc = value self.first_name_plain = value - self.first_name_hash = hash_credential(value) + self.first_name_hash = value # -------------------------------------------------------------------------- # Last name @@ -219,8 +216,8 @@ def last_name(self, value: str): # Email # -------------------------------------------------------------------------- - email_hash = models.CharField( - _("email hash"), max_length=64, editable=False, unique=True, null=True + email_hash = Sha256Field( + verbose_name=_("email hash"), unique=True, null=True ) email_plain = models.EmailField(_("email address"), null=True, unique=True) email_enc = EncryptedTextField( @@ -240,7 +237,7 @@ def email(self, value: t.Optional[str]): value = self.objects.normalize_email(value) self.email_plain = value self.email_enc = value - self.email_hash = None if value is None else hash_credential(value) + self.email_hash = value # -------------------------------------------------------------------------- # Other From dcd707719acd1bc4d336d7edc411dc7d3aa994eb Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 17 Mar 2026 09:15:25 +0000 Subject: [PATCH 52/58] fix discovery and exclude test files --- setup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ba30c913..14ac292f 100644 --- a/setup.py +++ b/setup.py @@ -98,9 +98,10 @@ def parse_requirements(packages: t.Dict[str, t.Dict[str, t.Any]]): long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/ocadotechnology/codeforlife-package-python", - # TODO: exclude test files - packages=find_packages(exclude=["tests", "tests.*"]), + packages=find_packages(where="codeforlife"), + package_dir={"codeforlife": "codeforlife"}, include_package_data=True, + exclude_package_data={"codeforlife": ["**/*_test.py", "**/test_*.py"]}, data_files=[ get_data_files(DATA_DIR), get_data_files(USER_FIXTURES_DIR), From 7daccb1c3ca54b35be20f9c853f7ec88e83464ef Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 17 Mar 2026 09:15:55 +0000 Subject: [PATCH 53/58] delete --- .../0004_client_side_encryption_part_2.py | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 codeforlife/user/migrations/0004_client_side_encryption_part_2.py diff --git a/codeforlife/user/migrations/0004_client_side_encryption_part_2.py b/codeforlife/user/migrations/0004_client_side_encryption_part_2.py deleted file mode 100644 index 25461564..00000000 --- a/codeforlife/user/migrations/0004_client_side_encryption_part_2.py +++ /dev/null @@ -1,69 +0,0 @@ -import typing as t - -from django.db import migrations - - -def remove_plain_text_fields_and_rename_encrypted_text_fields( - model_name: str, fields: t.List[str] -): - """ - Removes all plaintext fields with the naming convention {field_name}_plain - and renames the encrypted text fields with the naming convention - {field_name}_enc to {field_name}. - - Args: - model_name: The name of the model to modify. - fields: A list of field names to process. - - Returns: - A list of migration operations. - """ - - migrations_list = [] - for name in fields: - migrations_list += [ - # Remove the plain text field. - migrations.RemoveField( - model_name=model_name, - name=f"{name}_plain", - ), - # Rename the encrypted text field. - migrations.RenameField( - model_name=model_name, - old_name=f"{name}_enc", - new_name=name, - ), - ] - - return migrations_list - - -class Migration(migrations.Migration): - - dependencies = [ - ("user", "0003_client_side_encryption_part_1"), - ] - - operations = [ - # *remove_plain_text_fields_and_rename_encrypted_text_fields( - # "user", - # ["first_name", "last_name", "email", "username"], - # ), - # *remove_plain_text_fields_and_rename_encrypted_text_fields( - # "class", - # ["name", "access_code"], - # ), - # *remove_plain_text_fields_and_rename_encrypted_text_fields( - # "schoolteacherinvitation", - # [ - # "token", - # "invited_teacher_first_name", - # "invited_teacher_last_name", - # "invited_teacher_email", - # ], - # ), - # *remove_plain_text_fields_and_rename_encrypted_text_fields( - # "school", - # ["name"], - # ), - ] From 64163adc3acb095f3fafdeffbaa96bb1f297dd0e Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 17 Mar 2026 09:32:11 +0000 Subject: [PATCH 54/58] remove plain fields --- .../0004_client_side_encryption_part_2.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 codeforlife/user/migrations/0004_client_side_encryption_part_2.py diff --git a/codeforlife/user/migrations/0004_client_side_encryption_part_2.py b/codeforlife/user/migrations/0004_client_side_encryption_part_2.py new file mode 100644 index 00000000..6d8f1f1b --- /dev/null +++ b/codeforlife/user/migrations/0004_client_side_encryption_part_2.py @@ -0,0 +1,69 @@ +import typing as t + +from django.db import migrations + + +def remove_plain_text_fields_and_rename_encrypted_text_fields( + model_name: str, fields: t.List[str] +): + """ + Removes all plaintext fields with the naming convention {field_name}_plain + and renames the encrypted text fields with the naming convention + {field_name}_enc to {field_name}. + + Args: + model_name: The name of the model to modify. + fields: A list of field names to process. + + Returns: + A list of migration operations. + """ + + migrations_list = [] + for name in fields: + migrations_list += [ + # Remove the plain text field. + migrations.RemoveField( + model_name=model_name, + name=f"{name}_plain", + ), + # Rename the encrypted text field. + migrations.RenameField( + model_name=model_name, + old_name=f"{name}_enc", + new_name=name, + ), + ] + + return migrations_list + + +class Migration(migrations.Migration): + + dependencies = [ + ("user", "0003_client_side_encryption_part_1"), + ] + + operations = [ + *remove_plain_text_fields_and_rename_encrypted_text_fields( + "user", + ["first_name", "last_name", "email"], + ), + *remove_plain_text_fields_and_rename_encrypted_text_fields( + "class", + ["name", "access_code"], + ), + *remove_plain_text_fields_and_rename_encrypted_text_fields( + "schoolteacherinvitation", + [ + "token", + "invited_teacher_first_name", + "invited_teacher_last_name", + "invited_teacher_email", + ], + ), + *remove_plain_text_fields_and_rename_encrypted_text_fields( + "school", + ["name"], + ), + ] From 136f1f034d9fca57a895ddb75fe32016793baca5 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 17 Mar 2026 12:03:36 +0000 Subject: [PATCH 55/58] remove plaintext fields --- .../0004_client_side_encryption_part_2.py | 235 ++++++++++++++---- codeforlife/user/models/klass.py | 50 +--- codeforlife/user/models/other.py | 97 +------- codeforlife/user/models/school.py | 28 +-- codeforlife/user/models/user/user.py | 110 +++----- 5 files changed, 235 insertions(+), 285 deletions(-) diff --git a/codeforlife/user/migrations/0004_client_side_encryption_part_2.py b/codeforlife/user/migrations/0004_client_side_encryption_part_2.py index 6d8f1f1b..d761ae59 100644 --- a/codeforlife/user/migrations/0004_client_side_encryption_part_2.py +++ b/codeforlife/user/migrations/0004_client_side_encryption_part_2.py @@ -1,41 +1,187 @@ -import typing as t - from django.db import migrations +from ...models.fields import EncryptedTextField, Sha256Field -def remove_plain_text_fields_and_rename_encrypted_text_fields( - model_name: str, fields: t.List[str] -): - """ - Removes all plaintext fields with the naming convention {field_name}_plain - and renames the encrypted text fields with the naming convention - {field_name}_enc to {field_name}. - - Args: - model_name: The name of the model to modify. - fields: A list of field names to process. +user_migrations = [ + migrations.AlterField( + model_name="user", + name="email_hash", + field=Sha256Field( + db_column="email_hash", + editable=False, + max_length=64, + null=True, + unique=True, + verbose_name="email hash", + ), + ), + migrations.RenameField( + model_name="user", + old_name="email_hash", + new_name="_email_hash", + ), + migrations.AlterField( + model_name="user", + name="email_enc", + field=EncryptedTextField( + associated_data="email", + db_column="email", + null=True, + verbose_name="email address", + ), + ), + migrations.RenameField( + model_name="user", + old_name="email_enc", + new_name="_email", + ), + migrations.RemoveField( + model_name="user", + name="email_plain", + ), + migrations.AlterField( + model_name="user", + name="first_name_hash", + field=Sha256Field( + db_column="first_name_hash", + editable=False, + max_length=64, + null=True, + verbose_name="first name hash", + ), + ), + migrations.RenameField( + model_name="user", + old_name="first_name_hash", + new_name="_first_name_hash", + ), + migrations.AlterField( + model_name="user", + name="first_name_enc", + field=EncryptedTextField( + associated_data="first_name", + db_column="first_name", + null=True, + verbose_name="first name", + ), + ), + migrations.RenameField( + model_name="user", + old_name="first_name_enc", + new_name="_first_name", + ), + migrations.RemoveField( + model_name="user", + name="first_name_plain", + ), + migrations.RenameField( + model_name="user", + old_name="last_name_enc", + new_name="last_name", + ), + migrations.RemoveField( + model_name="user", + name="last_name_plain", + ), +] - Returns: - A list of migration operations. - """ +class_migrations = [ + migrations.AlterField( + model_name="class", + name="access_code_hash", + field=Sha256Field( + db_column="access_code_hash", + editable=False, + max_length=64, + null=True, + verbose_name="access code hash", + ), + ), + migrations.RenameField( + model_name="class", + old_name="access_code_hash", + new_name="_access_code_hash", + ), + migrations.AlterField( + model_name="class", + name="access_code_enc", + field=EncryptedTextField( + associated_data="access_code", + db_column="access_code", + null=True, + verbose_name="access code", + ), + ), + migrations.RenameField( + model_name="class", + old_name="access_code_enc", + new_name="_access_code", + ), + migrations.RemoveField( + model_name="class", + name="access_code_plain", + ), + migrations.RenameField( + model_name="class", + old_name="name_enc", + new_name="name", + ), + migrations.RemoveField( + model_name="class", + name="name_plain", + ), +] - migrations_list = [] - for name in fields: - migrations_list += [ - # Remove the plain text field. - migrations.RemoveField( - model_name=model_name, - name=f"{name}_plain", - ), - # Rename the encrypted text field. - migrations.RenameField( - model_name=model_name, - old_name=f"{name}_enc", - new_name=name, - ), - ] +school_migrations = [ + migrations.RenameField( + model_name="school", + old_name="name_enc", + new_name="name", + ), + migrations.RemoveField( + model_name="school", + name="name_plain", + ), +] - return migrations_list +school_teacher_invitation_migrations = [ + migrations.RenameField( + model_name="schoolteacherinvitation", + old_name="invited_teacher_email_enc", + new_name="invited_teacher_email", + ), + migrations.RemoveField( + model_name="schoolteacherinvitation", + name="invited_teacher_email_plain", + ), + migrations.RenameField( + model_name="schoolteacherinvitation", + old_name="invited_teacher_first_name_enc", + new_name="invited_teacher_first_name", + ), + migrations.RemoveField( + model_name="schoolteacherinvitation", + name="invited_teacher_first_name_plain", + ), + migrations.RenameField( + model_name="schoolteacherinvitation", + old_name="invited_teacher_last_name_enc", + new_name="invited_teacher_last_name", + ), + migrations.RemoveField( + model_name="schoolteacherinvitation", + name="invited_teacher_last_name_plain", + ), + migrations.RenameField( + model_name="schoolteacherinvitation", + old_name="token_enc", + new_name="token", + ), + migrations.RemoveField( + model_name="schoolteacherinvitation", + name="token_plain", + ), +] class Migration(migrations.Migration): @@ -45,25 +191,8 @@ class Migration(migrations.Migration): ] operations = [ - *remove_plain_text_fields_and_rename_encrypted_text_fields( - "user", - ["first_name", "last_name", "email"], - ), - *remove_plain_text_fields_and_rename_encrypted_text_fields( - "class", - ["name", "access_code"], - ), - *remove_plain_text_fields_and_rename_encrypted_text_fields( - "schoolteacherinvitation", - [ - "token", - "invited_teacher_first_name", - "invited_teacher_last_name", - "invited_teacher_email", - ], - ), - *remove_plain_text_fields_and_rename_encrypted_text_fields( - "school", - ["name"], - ), + *user_migrations, + *class_migrations, + *school_migrations, + *school_teacher_invitation_migrations, ] diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index 0bfdf6f3..c4fc3d50 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -66,33 +66,12 @@ class Class(EncryptedModel): associated_data = "class" - # -------------------------------------------------------------------------- - # Name - # -------------------------------------------------------------------------- - - name_plain: str - name_plain = models.CharField(max_length=200) # type: ignore[assignment] - name_enc = EncryptedTextField( + name = EncryptedTextField( associated_data="name", null=True, verbose_name=_("name"), ) - @property - def name(self): - """Get the name of the class.""" - if self.name_enc is not None: - return self.name_enc - return self.name_plain - - @name.setter - def name(self, value: str): - """Set the name of the class.""" - self.name_plain = value - self.name_enc = value - - # -------------------------------------------------------------------------- - teacher: "SchoolTeacher" teacher = models.ForeignKey( # type: ignore[assignment] "user.Teacher", @@ -100,39 +79,28 @@ def name(self, value: str): on_delete=models.CASCADE, ) - # -------------------------------------------------------------------------- - # Access code - # -------------------------------------------------------------------------- - - access_code_hash = Sha256Field( - verbose_name=_("access code hash"), null=True - ) - access_code_plain: t.Optional[str] - access_code_plain = models.CharField( # type: ignore[assignment] - max_length=5, + _access_code_hash = Sha256Field( + verbose_name=_("access code hash"), null=True, + db_column="access_code_hash", ) - access_code_enc = EncryptedTextField( + _access_code = EncryptedTextField( associated_data="access_code", null=True, verbose_name=_("access code"), + db_column="access_code", ) @property def access_code(self): """Get the access code for the class.""" - if self.access_code_enc is not None: - return self.access_code_enc - return self.access_code_plain + return self._access_code @access_code.setter def access_code(self, value: t.Optional[str]): """Set the access code for the class.""" - self.access_code_plain = value - self.access_code_enc = value - self.access_code_hash = value - - # -------------------------------------------------------------------------- + self._access_code = value + self._access_code_hash = value classmates_data_viewable: bool classmates_data_viewable = models.BooleanField( # type: ignore[assignment] diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py index 5b6efc62..67f54235 100644 --- a/codeforlife/user/models/other.py +++ b/codeforlife/user/models/other.py @@ -235,33 +235,12 @@ class SchoolTeacherInvitation(EncryptedModel): associated_data = "school_teacher_invitation" - # -------------------------------------------------------------------------- - # Token - # -------------------------------------------------------------------------- - - token_plain: str - token_plain = models.CharField(max_length=88) # type: ignore[assignment] - token_enc = EncryptedTextField( + token = EncryptedTextField( associated_data="token", null=True, verbose_name=_("token"), ) - @property - def token(self): - """Get the decrypted token value.""" - if self.token_enc is not None: - return self.token_enc - return self.token_plain - - @token.setter - def token(self, value: str): - """Sets the token value.""" - self.token_plain = value - self.token_enc = value - - # -------------------------------------------------------------------------- - school: t.Optional["School"] school = models.ForeignKey( # type: ignore[assignment] "user.School", @@ -278,92 +257,24 @@ def token(self, value: str): on_delete=models.SET_NULL, ) - # -------------------------------------------------------------------------- - # First name - # -------------------------------------------------------------------------- - - invited_teacher_first_name_plain: str - # pylint: disable-next=line-too-long - invited_teacher_first_name_plain = models.CharField( # type: ignore[assignment] - max_length=150 - ) # Same as User model - invited_teacher_first_name_enc = EncryptedTextField( + invited_teacher_first_name = EncryptedTextField( associated_data="invited_teacher_first_name", null=True, verbose_name=_("invited teacher first name"), ) - @property - def invited_teacher_first_name(self): - """Get the decrypted invited teacher first name value.""" - if self.invited_teacher_first_name_enc is not None: - return self.invited_teacher_first_name_enc - return self.invited_teacher_first_name_plain - - @invited_teacher_first_name.setter - def invited_teacher_first_name(self, value: str): - """Sets the invited teacher first name value.""" - self.invited_teacher_first_name_plain = value - self.invited_teacher_first_name_enc = value - - # -------------------------------------------------------------------------- - # Last name - # -------------------------------------------------------------------------- - - invited_teacher_last_name_plain: str - # pylint: disable-next=line-too-long - invited_teacher_last_name_plain = models.CharField( # type: ignore[assignment] - max_length=150 - ) # Same as User model - invited_teacher_last_name_enc = EncryptedTextField( + invited_teacher_last_name = EncryptedTextField( associated_data="invited_teacher_last_name", null=True, verbose_name=_("invited teacher last name"), ) - @property - def invited_teacher_last_name(self): - """Get the decrypted invited teacher last name value.""" - if self.invited_teacher_last_name_enc is not None: - return self.invited_teacher_last_name_enc - return self.invited_teacher_last_name_plain - - @invited_teacher_last_name.setter - def invited_teacher_last_name(self, value: str): - """Sets the invited teacher last name value.""" - self.invited_teacher_last_name_plain = value - self.invited_teacher_last_name_enc = value - - # -------------------------------------------------------------------------- - # Email - # -------------------------------------------------------------------------- - - # TODO: Switch to a CharField to be able to hold hashed value - invited_teacher_email_plain: str - invited_teacher_email_plain = ( - models.EmailField() # type: ignore[assignment] - ) # Same as User model - invited_teacher_email_enc = EncryptedTextField( + invited_teacher_email = EncryptedTextField( associated_data="invited_teacher_email", null=True, verbose_name=_("invited teacher email"), ) - @property - def invited_teacher_email(self): - """Get the decrypted invited teacher email value.""" - if self.invited_teacher_email_enc is not None: - return self.invited_teacher_email_enc - return self.invited_teacher_email_plain - - @invited_teacher_email.setter - def invited_teacher_email(self, value: str): - """Sets the invited teacher email value.""" - self.invited_teacher_email_plain = value - self.invited_teacher_email_enc = value - - # -------------------------------------------------------------------------- - invited_teacher_is_admin: bool invited_teacher_is_admin = models.BooleanField( # type: ignore[assignment] default=False diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index eaf55de1..4486b9a5 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -46,38 +46,12 @@ class School(DataEncryptionKeyModel): associated_data = "school" - # -------------------------------------------------------------------------- - # Name - # -------------------------------------------------------------------------- - # pylint: disable=duplicate-code - - name_plain: str - name_plain = models.CharField( # type: ignore[assignment] - max_length=200, - unique=True, - ) - name_enc = EncryptedTextField( + name = EncryptedTextField( associated_data="name", null=True, verbose_name=_("name"), ) - @property - def name(self): - """Get the school's name.""" - if self.name_enc is not None: - return self.name_enc - return self.name_plain - - @name.setter - def name(self, value: str): - """Set the school's name.""" - self.name_plain = value - self.name_enc = value - - # pylint: enable=duplicate-code - # -------------------------------------------------------------------------- - country: t.Optional[str] country = CountryField( # type: ignore[assignment] blank_label="(select country)", diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 6538e1c1..f99e292b 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -148,9 +148,9 @@ class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): associated_data = "user" - EMAIL_FIELD = "email_enc" - USERNAME_FIELD = "email_hash" - REQUIRED_FIELDS = ["email_enc"] + EMAIL_FIELD = "_email" + USERNAME_FIELD = "_email_hash" + REQUIRED_FIELDS = ["_email"] credential_fields = frozenset(["email", "password"]) _password: t.Optional[str] @@ -162,86 +162,57 @@ class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): session: "Session" # type: ignore[assignment] userprofile: "UserProfile" - # -------------------------------------------------------------------------- - # First name - # -------------------------------------------------------------------------- - - first_name_hash = Sha256Field(verbose_name=_("first name hash")) - first_name_plain = models.CharField( - _("first name"), max_length=150, blank=True + _first_name_hash = Sha256Field( + verbose_name=_("first name hash"), + db_column="first_name_hash", + null=True, ) - first_name_enc = EncryptedTextField( - associated_data="first_name", null=True, verbose_name=_("first name") + _first_name = EncryptedTextField( + associated_data="first_name", + null=True, + verbose_name=_("first name"), + db_column="first_name", ) @property def first_name(self): """The user's first name.""" - if self.first_name_enc is not None: - return self.first_name_enc - return self.first_name_plain + return self._first_name @first_name.setter def first_name(self, value: str): - """Set the user's first name.""" - self.first_name_enc = value - self.first_name_plain = value - self.first_name_hash = value + """Set first name and hash immediately.""" + self._first_name = value + self._first_name_hash = value - # -------------------------------------------------------------------------- - # Last name - # -------------------------------------------------------------------------- - - last_name_plain = models.CharField( - _("last name"), max_length=150, blank=True - ) - last_name_enc = EncryptedTextField( + last_name = EncryptedTextField( associated_data="last_name", null=True, verbose_name=_("last name") ) - @property - def last_name(self): - """The user's last name.""" - if self.last_name_enc is not None: - return self.last_name_enc - return self.last_name_plain - - @last_name.setter - def last_name(self, value: str): - """Set the user's last name.""" - self.last_name_enc = value - self.last_name_plain = value - - # -------------------------------------------------------------------------- - # Email - # -------------------------------------------------------------------------- - - email_hash = Sha256Field( - verbose_name=_("email hash"), unique=True, null=True + _email_hash = Sha256Field( + verbose_name=_("email hash"), + unique=True, + null=True, + db_column="email_hash", ) - email_plain = models.EmailField(_("email address"), null=True, unique=True) - email_enc = EncryptedTextField( - associated_data="email", null=True, verbose_name=_("email address") + _email = EncryptedTextField( + associated_data="email", + null=True, + verbose_name=_("email address"), + db_column="email", ) @property def email(self): """The user's email address.""" - if self.email_enc is not None: - return self.email_enc - return self.email_plain + return self._email @email.setter def email(self, value: t.Optional[str]): """Set the user's email address.""" value = self.objects.normalize_email(value) - self.email_plain = value - self.email_enc = value - self.email_hash = value - - # -------------------------------------------------------------------------- - # Other - # -------------------------------------------------------------------------- + self._email = value + self._email_hash = value is_staff = models.BooleanField( _("staff status"), @@ -360,12 +331,12 @@ def as_type(self, user_class: t.Type["AnyUser"]): """ return user_class( pk=self.pk, - first_name=self.first_name, + _first_name=self._first_name, + _first_name_hash=self._first_name_hash, last_name=self.last_name, is_active=self.is_active, - email_plain=self.email_plain, - email_enc=self.email_enc, - email_hash=self.email_hash, + _email=self._email, + _email_hash=self._email_hash, is_staff=self.is_staff, date_joined=self.date_joined, is_superuser=self.is_superuser, @@ -382,14 +353,11 @@ def anonymize(self): self.save( update_fields=[ # pylint: disable=duplicate-code - "first_name_hash", - "first_name_plain", - "first_name_enc", - "last_name_plain", - "last_name_enc", - "email_plain", - "email_enc", - "email_hash", + "_first_name_hash", + "_first_name", + "last_name", + "_email", + "_email_hash", "is_active", # pylint: enable=duplicate-code ] From e1b5eae5239efa114ffe9115c0405a24b601d082 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 17 Mar 2026 16:16:41 +0000 Subject: [PATCH 56/58] fix fixture keys --- codeforlife/user/fixtures/google_users.json | 15 +- codeforlife/user/fixtures/independent.json | 28 +- codeforlife/user/fixtures/legacy.json | 282 +++++++----------- .../user/fixtures/non_school_teacher.json | 28 +- codeforlife/user/fixtures/school_1.json | 65 ++-- codeforlife/user/fixtures/school_2.json | 47 ++- codeforlife/user/fixtures/school_3.json | 31 +- 7 files changed, 187 insertions(+), 309 deletions(-) diff --git a/codeforlife/user/fixtures/google_users.json b/codeforlife/user/fixtures/google_users.json index 6615ea08..df158c19 100644 --- a/codeforlife/user/fixtures/google_users.json +++ b/codeforlife/user/fixtures/google_users.json @@ -4,14 +4,11 @@ "pk": 34, "fields": { "dek": "ZmFrZV9lbmM6YlVkTE4xSndaRlZTTW5kc1NqQkJXa1ZxU2xKV2NWTk9WbFJ6YzFjMmVHVT0=", - "email_enc": "ZmFrZV9lbmM6WjI5dloyeGxMblJsWVdOb1pYSkFibTl6WTJodmIyd3VZMjl0", - "email_hash": "google.teacher@noschool.com", - "email_plain": "google.teacher@noschool.com", - "first_name_enc": "ZmFrZV9lbmM6UjI5dloyeGw=", - "first_name_hash": "Google", - "first_name_plain": "Google", - "last_name_enc": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", - "last_name_plain": "Teacher", + "_email": "ZmFrZV9lbmM6WjI5dloyeGxMblJsWVdOb1pYSkFibTl6WTJodmIyd3VZMjl0", + "_email_hash": "google.teacher@noschool.com", + "_first_name": "ZmFrZV9lbmM6UjI5dloyeGw=", + "_first_name_hash": "Google", + "last_name": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -31,4 +28,4 @@ "new_user": 34 } } -] \ No newline at end of file +] diff --git a/codeforlife/user/fixtures/independent.json b/codeforlife/user/fixtures/independent.json index 4ccd38e6..1f31b083 100644 --- a/codeforlife/user/fixtures/independent.json +++ b/codeforlife/user/fixtures/independent.json @@ -4,14 +4,11 @@ "pk": 28, "fields": { "dek": "ZmFrZV9lbmM6Ym5oYVFqaHhUMDV4VTI4NE9YVk9TbmxvVkVzemRUWnpkMUpTWjFkMFVFRT0=", - "email_enc": "ZmFrZV9lbmM6YVc1a2VTNXlaWEYxWlhOMFpYSkFaVzFoYVd3dVkyOXQ=", - "email_hash": "indy.requester@email.com", - "email_plain": "indy.requester@email.com", - "first_name_enc": "ZmFrZV9lbmM6U1c1a2VRPT0=", - "first_name_hash": "Indy", - "first_name_plain": "Indy", - "last_name_enc": "ZmFrZV9lbmM6VW1WeGRXVnpkR1Z5", - "last_name_plain": "Requester", + "_email": "ZmFrZV9lbmM6YVc1a2VTNXlaWEYxWlhOMFpYSkFaVzFoYVd3dVkyOXQ=", + "_email_hash": "indy.requester@email.com", + "_first_name": "ZmFrZV9lbmM6U1c1a2VRPT0=", + "_first_name_hash": "Indy", + "last_name": "ZmFrZV9lbmM6VW1WeGRXVnpkR1Z5", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -37,14 +34,11 @@ "pk": 30, "fields": { "dek": "ZmFrZV9lbmM6TTIxbFVXMWlWMnR3VXpOa2JXaENlbkJGUW05a1oyZEpkRzV6YlhGa1pVYz0=", - "email_enc": "ZmFrZV9lbmM6YVc1a2VVQmxiV0ZwYkM1amIyMD0=", - "email_hash": "indy@email.com", - "email_plain": "indy@email.com", - "first_name_enc": "ZmFrZV9lbmM6U1c1a2VRPT0=", - "first_name_hash": "Indy", - "first_name_plain": "Indy", - "last_name_enc": "ZmFrZV9lbmM6VG05U1pYRjFaWE4w", - "last_name_plain": "NoRequest", + "_email": "ZmFrZV9lbmM6YVc1a2VVQmxiV0ZwYkM1amIyMD0=", + "_email_hash": "indy@email.com", + "_first_name": "ZmFrZV9lbmM6U1c1a2VRPT0=", + "_first_name_hash": "Indy", + "last_name": "ZmFrZV9lbmM6VG05U1pYRjFaWE4w", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -64,4 +58,4 @@ "new_user": 30 } } -] \ No newline at end of file +] diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json index b0d5041c..cf11a187 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -239,8 +239,7 @@ "creation_time": null, "dek": "ZmFrZV9lbmM6VHpkNWFFZzVaVUozVEdNM1MyRkJXV042YUVkcVUwWnZWVzV4YWtaaVNFbz0=", "is_active": true, - "name_enc": "ZmFrZV9lbmM6VTNkcGMzTWdSbVZrWlhKaGJDQlFiMng1ZEdWamFHNXBZdz09", - "name_plain": "Swiss Federal Polytechnic" + "name": "ZmFrZV9lbmM6VTNkcGMzTWdSbVZrWlhKaGJDQlFiMng1ZEdWamFHNXBZdz09" } }, { @@ -296,16 +295,14 @@ "pk": 1, "fields": { "accept_requests_until": null, - "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qTT0=", - "access_code_hash": "AB123", - "access_code_plain": "AB123", + "_access_code": "ZmFrZV9lbmM6UVVJeE1qTT0=", + "_access_code_hash": "AB123", "always_accept_requests": true, "classmates_data_viewable": true, "created_by": null, "creation_time": null, "is_active": true, - "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF4", - "name_plain": "Class 101", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF4", "teacher": 1 } }, @@ -314,16 +311,14 @@ "pk": 2, "fields": { "accept_requests_until": null, - "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qUT0=", - "access_code_hash": "AB124", - "access_code_plain": "AB124", + "_access_code": "ZmFrZV9lbmM6UVVJeE1qUT0=", + "_access_code_hash": "AB124", "always_accept_requests": true, "classmates_data_viewable": true, "created_by": null, "creation_time": null, "is_active": true, - "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF5", - "name_plain": "Class 102", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF5", "teacher": 2 } }, @@ -332,16 +327,14 @@ "pk": 3, "fields": { "accept_requests_until": null, - "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qVT0=", - "access_code_hash": "AB125", - "access_code_plain": "AB125", + "_access_code": "ZmFrZV9lbmM6UVVJeE1qVT0=", + "_access_code_hash": "AB125", "always_accept_requests": true, "classmates_data_viewable": true, "created_by": null, "creation_time": null, "is_active": true, - "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF6", - "name_plain": "Class 103", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF6", "teacher": 2 } }, @@ -350,16 +343,14 @@ "pk": 4, "fields": { "accept_requests_until": null, - "access_code_enc": "ZmFrZV9lbmM6VWt3eE1qTT0=", - "access_code_hash": "RL123", - "access_code_plain": "RL123", + "_access_code": "ZmFrZV9lbmM6VWt3eE1qTT0=", + "_access_code_hash": "RL123", "always_accept_requests": true, "classmates_data_viewable": true, "created_by": null, "creation_time": null, "is_active": true, - "name_enc": "ZmFrZV9lbmM6V1c5MWJtY2dRMjlrWlhKeklERXdNUT09", - "name_plain": "Young Coders 101", + "name": "ZmFrZV9lbmM6V1c5MWJtY2dRMjlrWlhKeklERXdNUT09", "teacher": 3 } }, @@ -368,16 +359,14 @@ "pk": 5, "fields": { "accept_requests_until": null, - "access_code_enc": "ZmFrZV9lbmM6VUU4eE1qTT0=", - "access_code_hash": "PO123", - "access_code_plain": "PO123", + "_access_code": "ZmFrZV9lbmM6VUU4eE1qTT0=", + "_access_code_hash": "PO123", "always_accept_requests": true, "classmates_data_viewable": true, "created_by": null, "creation_time": null, "is_active": true, - "name_enc": "ZmFrZV9lbmM6VUc5eWRHRnNZV1J0YVc0bmN5QmpiR0Z6Y3c9PQ==", - "name_plain": "Portaladmin's class", + "name": "ZmFrZV9lbmM6VUc5eWRHRnNZV1J0YVc0bmN5QmpiR0Z6Y3c9PQ==", "teacher": 4 } }, @@ -590,19 +579,16 @@ "fields": { "date_joined": "2026-02-04T16:02:33.631Z", "dek": "ZmFrZV9lbmM6UkZkTlNrYzVhR05aVlZwRVZFaFlTMHhUVTJnMlVVbzNORVZUZERjeFIxTT0=", - "email_enc": "ZmFrZV9lbmM6WTI5a1pXWnZjbXhwWm1VdGNHOXlkR0ZzUUc5allXUnZMbU52YlE9PQ==", - "email_hash": "codeforlife-portal@ocado.com", - "email_plain": "codeforlife-portal@ocado.com", - "first_name_enc": "ZmFrZV9lbmM6VUc5eWRHRnM=", - "first_name_hash": "Portal", - "first_name_plain": "Portal", + "_email": "ZmFrZV9lbmM6WTI5a1pXWnZjbXhwWm1VdGNHOXlkR0ZzUUc5allXUnZMbU52YlE9PQ==", + "_email_hash": "codeforlife-portal@ocado.com", + "_first_name": "ZmFrZV9lbmM6VUc5eWRHRnM=", + "_first_name_hash": "Portal", "groups": [], "is_active": true, "is_staff": true, "is_superuser": true, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6UVdSdGFXND0=", - "last_name_plain": "Admin", + "last_name": "ZmFrZV9lbmM6UVdSdGFXND0=", "password": "pbkdf2_sha256$1000000$y2lUeK8u5GnocWVuonlViT$TnNTL7sG9pcvz6MotXSSptrHdQzAFxmLnqNbj7syLS4=", "user_permissions": [] } @@ -613,19 +599,16 @@ "fields": { "date_joined": "2026-02-04T16:02:34.051Z", "dek": "ZmFrZV9lbmM6VlZkaFpuUlZha2hNUjJGTU5XSlNaR3hsVUhFMVkzSjBNR1l4TUZwa1IxQT0=", - "email_enc": "ZmFrZV9lbmM6WVd4aVpYSjBaV2x1YzNSbGFXNUFZMjlrWldadmNteHBabVV1WTI5dA==", - "email_hash": "alberteinstein@codeforlife.com", - "email_plain": "alberteinstein@codeforlife.com", - "first_name_enc": "ZmFrZV9lbmM6UVd4aVpYSjA=", - "first_name_hash": "Albert", - "first_name_plain": "Albert", + "_email": "ZmFrZV9lbmM6WVd4aVpYSjBaV2x1YzNSbGFXNUFZMjlrWldadmNteHBabVV1WTI5dA==", + "_email_hash": "alberteinstein@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6UVd4aVpYSjA=", + "_first_name_hash": "Albert", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6UldsdWMzUmxhVzQ9", - "last_name_plain": "Einstein", + "last_name": "ZmFrZV9lbmM6UldsdWMzUmxhVzQ9", "password": "pbkdf2_sha256$1000000$IOkFLrqDnlDiEoASMl5OSY$C7gzJeOhwbfq/nJp47LaErHOxgA/3DIkaUI93ZUqauc=", "user_permissions": [] } @@ -636,19 +619,16 @@ "fields": { "date_joined": "2026-02-04T16:02:34.252Z", "dek": "ZmFrZV9lbmM6ZDIwMmQyaGFNMlJHWWxNM2EyaEVaVlZUTXpGbFRsZEhUMjlrTnpoMmRUaz0=", - "email_enc": "ZmFrZV9lbmM6YldGNGNHeGhibU5yUUdOdlpHVm1iM0pzYVdabExtTnZiUT09", - "email_hash": "maxplanck@codeforlife.com", - "email_plain": "maxplanck@codeforlife.com", - "first_name_enc": "ZmFrZV9lbmM6VFdGNA==", - "first_name_hash": "Max", - "first_name_plain": "Max", + "_email": "ZmFrZV9lbmM6YldGNGNHeGhibU5yUUdOdlpHVm1iM0pzYVdabExtTnZiUT09", + "_email_hash": "maxplanck@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6VFdGNA==", + "_first_name_hash": "Max", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6VUd4aGJtTnI=", - "last_name_plain": "Planck", + "last_name": "ZmFrZV9lbmM6VUd4aGJtTnI=", "password": "pbkdf2_sha256$1000000$QHwukC6Cv822F2nSjQjLu0$09yKE5c8mFVwIHS5BWccM+q33Rs6XSCcc5rik3RD7eY=", "user_permissions": [] } @@ -659,19 +639,16 @@ "fields": { "date_joined": "2026-02-04T16:02:34.448Z", "dek": "ZmFrZV9lbmM6WW5CTGJHRnBSMDVrVGt4eVFWbFljMDU2VjJwM1dqWmlia2xPUlhwVVdqVT0=", - "email_enc": "ZmFrZV9lbmM6Y21GdGJHVnBkR2hBWTI5a1pXWnZjbXhwWm1VdVkyOXQ=", - "email_hash": "ramleith@codeforlife.com", - "email_plain": "ramleith@codeforlife.com", - "first_name_enc": "ZmFrZV9lbmM6VW1GdA==", - "first_name_hash": "Ram", - "first_name_plain": "Ram", + "_email": "ZmFrZV9lbmM6Y21GdGJHVnBkR2hBWTI5a1pXWnZjbXhwWm1VdVkyOXQ=", + "_email_hash": "ramleith@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6VW1GdA==", + "_first_name_hash": "Ram", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6VEdWcGRHZz0=", - "last_name_plain": "Leith", + "last_name": "ZmFrZV9lbmM6VEdWcGRHZz0=", "password": "pbkdf2_sha256$1000000$OcLHQ75ALkslmiUxFwe1Ft$7hpkII5kIi1lytO4PQ0p/6HdtT6//mPcZfZNjROXqrg=", "user_permissions": [] } @@ -682,19 +659,16 @@ "fields": { "date_joined": "2026-02-04T16:02:34.641Z", "dek": "ZmFrZV9lbmM6ZVhsRFZWQkRXWEJVVWxvM1QzbDJhM05yV0VkblIyUldRVlpHVG5CVVRqST0=", - "email_enc": "ZmFrZV9lbmM6YkdWdmJtRnlaRzlrWVhacGJtTnBRR052WkdWbWIzSnNhV1psTG1OdmJRPT0=", - "email_hash": "leonardodavinci@codeforlife.com", - "email_plain": "leonardodavinci@codeforlife.com", - "first_name_enc": "ZmFrZV9lbmM6VEdWdmJtRnlaRzg9", - "first_name_hash": "Leonardo", - "first_name_plain": "Leonardo", + "_email": "ZmFrZV9lbmM6YkdWdmJtRnlaRzlrWVhacGJtTnBRR052WkdWbWIzSnNhV1psTG1OdmJRPT0=", + "_email_hash": "leonardodavinci@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6VEdWdmJtRnlaRzg9", + "_first_name_hash": "Leonardo", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6UkdGV2FXNWphUT09", - "last_name_plain": "DaVinci", + "last_name": "ZmFrZV9lbmM6UkdGV2FXNWphUT09", "password": "pbkdf2_sha256$1000000$6QiofvSo5LPJ0DlGshTh0h$XdFmFi/Al5vuhxw02nlZE/rUG8g2uHxFnHXs46XgT/c=", "user_permissions": [] } @@ -705,19 +679,16 @@ "fields": { "date_joined": "2026-02-04T16:02:34.839Z", "dek": "ZmFrZV9lbmM6ZW10aVZtbHVibWxpYXpKSmMxUlhWWHBzTmsxMFRYcHZkblJOV1hGUGJERT0=", - "email_enc": "ZmFrZV9lbmM6WjJGc2FXeGxiMmRoYkdsc1pXbEFZMjlrWldadmNteHBabVV1WTI5dA==", - "email_hash": "galileogalilei@codeforlife.com", - "email_plain": "galileogalilei@codeforlife.com", - "first_name_enc": "ZmFrZV9lbmM6UjJGc2FXeGxidz09", - "first_name_hash": "Galileo", - "first_name_plain": "Galileo", + "_email": "ZmFrZV9lbmM6WjJGc2FXeGxiMmRoYkdsc1pXbEFZMjlrWldadmNteHBabVV1WTI5dA==", + "_email_hash": "galileogalilei@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6UjJGc2FXeGxidz09", + "_first_name_hash": "Galileo", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6UjJGc2FXeGxhUT09", - "last_name_plain": "Galilei", + "last_name": "ZmFrZV9lbmM6UjJGc2FXeGxhUT09", "password": "pbkdf2_sha256$1000000$HF1HBQVpMLdAuYb0vt55r1$gY3+SetJqidFfylntSqmmrtremtNk8QuGeAdlHNqD9A=", "user_permissions": [] } @@ -728,19 +699,16 @@ "fields": { "date_joined": "2026-02-04T16:02:35.036Z", "dek": "ZmFrZV9lbmM6VGtWVWVHeFlXSEpaUTBKcGJFVk9TV3hYWkhCSmRtbERRa2h3WmtKQ01FUT0=", - "email_enc": "ZmFrZV9lbmM6YVhOaFlXTnVaWGQwYjI1QVkyOWtaV1p2Y214cFptVXVZMjl0", - "email_hash": "isaacnewton@codeforlife.com", - "email_plain": "isaacnewton@codeforlife.com", - "first_name_enc": "ZmFrZV9lbmM6U1hOaFlXTT0=", - "first_name_hash": "Isaac", - "first_name_plain": "Isaac", + "_email": "ZmFrZV9lbmM6YVhOaFlXTnVaWGQwYjI1QVkyOWtaV1p2Y214cFptVXVZMjl0", + "_email_hash": "isaacnewton@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6U1hOaFlXTT0=", + "_first_name_hash": "Isaac", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6VG1WM2RHOXU=", - "last_name_plain": "Newton", + "last_name": "ZmFrZV9lbmM6VG1WM2RHOXU=", "password": "pbkdf2_sha256$1000000$BUSt8V8F4DwWZPlCZeS1Sc$WMQElSAhvIE7LRHx6lwTde9YG5P74dc7Az+639AHdYc=", "user_permissions": [] } @@ -751,19 +719,16 @@ "fields": { "date_joined": "2026-02-04T16:02:35.230Z", "dek": "ZmFrZV9lbmM6U1ZCNFNVbFRkR1JvY2xodmRGSXpOM0ZrYmpsb2MycGFXblZTTVZaemFWWT0=", - "email_enc": "ZmFrZV9lbmM6Y21samFHRnlaR1psZVc1dFlXNUFZMjlrWldadmNteHBabVV1WTI5dA==", - "email_hash": "richardfeynman@codeforlife.com", - "email_plain": "richardfeynman@codeforlife.com", - "first_name_enc": "ZmFrZV9lbmM6VW1samFHRnlaQT09", - "first_name_hash": "Richard", - "first_name_plain": "Richard", + "_email": "ZmFrZV9lbmM6Y21samFHRnlaR1psZVc1dFlXNUFZMjlrWldadmNteHBabVV1WTI5dA==", + "_email_hash": "richardfeynman@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6VW1samFHRnlaQT09", + "_first_name_hash": "Richard", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6Um1WNWJtMWhiZz09", - "last_name_plain": "Feynman", + "last_name": "ZmFrZV9lbmM6Um1WNWJtMWhiZz09", "password": "pbkdf2_sha256$1000000$TaYcwU3iqPA4c4co14ZYHx$FAwypkSE7UucZod6BY/YY+A4zV4JbySNxDwd4ypOZT8=", "user_permissions": [] } @@ -774,19 +739,16 @@ "fields": { "date_joined": "2026-02-04T16:02:35.422Z", "dek": "ZmFrZV9lbmM6YVc5eFlYcHVjMWsxVEZWVFRXaFRRVFpEVkhWMWNUZG9abmR5UVRCT2RERT0=", - "email_enc": "ZmFrZV9lbmM6WVd4bGVHRnVaR1Z5Wm14bGJXMXBibWRBWTI5a1pXWnZjbXhwWm1VdVkyOXQ=", - "email_hash": "alexanderflemming@codeforlife.com", - "email_plain": "alexanderflemming@codeforlife.com", - "first_name_enc": "ZmFrZV9lbmM6UVd4bGVHRnVaR1Z5", - "first_name_hash": "Alexander", - "first_name_plain": "Alexander", + "_email": "ZmFrZV9lbmM6WVd4bGVHRnVaR1Z5Wm14bGJXMXBibWRBWTI5a1pXWnZjbXhwWm1VdVkyOXQ=", + "_email_hash": "alexanderflemming@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6UVd4bGVHRnVaR1Z5", + "_first_name_hash": "Alexander", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6Um14bGJXMXBibWM9", - "last_name_plain": "Flemming", + "last_name": "ZmFrZV9lbmM6Um14bGJXMXBibWM9", "password": "pbkdf2_sha256$1000000$mCuu3Ptx9EGmKQ7FSNIqzw$dqwQK/ovljow4iGxfLIQTm8PPR8Iu5AITzQ9tVQxv9w=", "user_permissions": [] } @@ -797,19 +759,16 @@ "fields": { "date_joined": "2026-02-04T16:02:35.611Z", "dek": "ZmFrZV9lbmM6WjJscWNqWlFRbEZCVkZCaGVsRTRWVFl3ZDBWRFltbEROM2x4UXpCQmVGUT0=", - "email_enc": "ZmFrZV9lbmM6WkdGdWFXVnNZbVZ5Ym05MWJHeHBRR052WkdWbWIzSnNhV1psTG1OdmJRPT0=", - "email_hash": "danielbernoulli@codeforlife.com", - "email_plain": "danielbernoulli@codeforlife.com", - "first_name_enc": "ZmFrZV9lbmM6UkdGdWFXVnM=", - "first_name_hash": "Daniel", - "first_name_plain": "Daniel", + "_email": "ZmFrZV9lbmM6WkdGdWFXVnNZbVZ5Ym05MWJHeHBRR052WkdWbWIzSnNhV1psTG1OdmJRPT0=", + "_email_hash": "danielbernoulli@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6UkdGdWFXVnM=", + "_first_name_hash": "Daniel", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6UW1WeWJtOTFiR3hw", - "last_name_plain": "Bernoulli", + "last_name": "ZmFrZV9lbmM6UW1WeWJtOTFiR3hw", "password": "pbkdf2_sha256$1000000$isUqjEe0ZtdHC0tDFilALA$k20Gy//4APEqsGNYGa8aQPUTUBqMPKe6hI5GMIWiMnE=", "user_permissions": [] } @@ -820,19 +779,16 @@ "fields": { "date_joined": "2026-02-04T16:02:35.803Z", "dek": "ZmFrZV9lbmM6UW5KQ2JUYzFSRU54VVhCd05rRmxka2w0ZVRoMFdGWkhhbEpCUzBnMGRuWT0=", - "email_enc": "ZmFrZV9lbmM6YVc1a2FXRnVZV3B2Ym1WelFHTnZaR1ZtYjNKc2FXWmxMbU52YlE9PQ==", - "email_hash": "indianajones@codeforlife.com", - "email_plain": "indianajones@codeforlife.com", - "first_name_enc": "ZmFrZV9lbmM6U1c1a2FXRnVZUT09", - "first_name_hash": "Indiana", - "first_name_plain": "Indiana", + "_email": "ZmFrZV9lbmM6YVc1a2FXRnVZV3B2Ym1WelFHTnZaR1ZtYjNKc2FXWmxMbU52YlE9PQ==", + "_email_hash": "indianajones@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6U1c1a2FXRnVZUT09", + "_first_name_hash": "Indiana", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6U205dVpYTT0=", - "last_name_plain": "Jones", + "last_name": "ZmFrZV9lbmM6U205dVpYTT0=", "password": "pbkdf2_sha256$1000000$mjbmbYr7G3WRyxxHlDcH6d$w1wf3D0lDP6pF3nSoTUD3vSRioCzPEloW3r8sXnLSJw=", "user_permissions": [] } @@ -843,17 +799,14 @@ "fields": { "date_joined": "2026-02-04T16:02:35.999Z", "dek": "ZmFrZV9lbmM6TVhGU1NtSlhaVkpwYlRWWVR6QTNlVE5UV2xaNWRHNUlkbFJtVG1GMVJHVT0=", - "email_plain": "", - "first_name_enc": "ZmFrZV9lbmM6VG05aGFBPT0=", - "first_name_hash": "Noah", - "first_name_plain": "Noah", + "_first_name": "ZmFrZV9lbmM6VG05aGFBPT0=", + "_first_name_hash": "Noah", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6VFc5dVlXZG9ZVzQ9", - "last_name_plain": "Monaghan", + "last_name": "ZmFrZV9lbmM6VFc5dVlXZG9ZVzQ9", "password": "pbkdf2_sha256$1000000$qm1oQx3HNeIx6dpY1aRzXb$xMKwoqXoW6MuHqoABww+Yq+3A1uwkLbkpaWWytNu6l0=", "user_permissions": [] } @@ -864,17 +817,14 @@ "fields": { "date_joined": "2026-02-04T16:02:36.195Z", "dek": "ZmFrZV9lbmM6ZFZwVmRGbHpTVkoxTkVaMU5XRk9WMDlwV25CS1EzVlRaR28yV2tac1QzVT0=", - "email_plain": "", - "first_name_enc": "ZmFrZV9lbmM6Uld4c2FXOTA=", - "first_name_hash": "Elliot", - "first_name_plain": "Elliot", + "_first_name": "ZmFrZV9lbmM6Uld4c2FXOTA=", + "_first_name_hash": "Elliot", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6VTJoaGNuQT0=", - "last_name_plain": "Sharp", + "last_name": "ZmFrZV9lbmM6VTJoaGNuQT0=", "password": "pbkdf2_sha256$1000000$vQZKG1iO3Np3cdOHSrG85F$QnuZLe1KvMt64uWXjz3RKOEnr6owhloxfgs3u8xB6GQ=", "user_permissions": [] } @@ -885,17 +835,14 @@ "fields": { "date_joined": "2026-02-04T16:02:36.394Z", "dek": "ZmFrZV9lbmM6UW1sWlVsSjNNM0JHVW1Sd1puQkNaVU4xUkdSVGVHRk1kV2x5ZFRoUGNFST0=", - "email_plain": "", - "first_name_enc": "ZmFrZV9lbmM6VkdGcWJXRmw=", - "first_name_hash": "Tajmae", - "first_name_plain": "Tajmae", + "_first_name": "ZmFrZV9lbmM6VkdGcWJXRmw=", + "_first_name_hash": "Tajmae", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6U205elpYQm8=", - "last_name_plain": "Joseph", + "last_name": "ZmFrZV9lbmM6U205elpYQm8=", "password": "pbkdf2_sha256$1000000$iCO3IsvmN5C8rpX5J0d1ob$TFK2zYume/i4StDly6EUiqFej/sOnuzV/wYE9rgr/cg=", "user_permissions": [] } @@ -906,17 +853,14 @@ "fields": { "date_joined": "2026-02-04T16:02:36.589Z", "dek": "ZmFrZV9lbmM6ZEhWRlUxSjFZMUpxZFdoaU5XTnNiWEpqZVdSUVdFdGtOVWhFVkVGVWRrND0=", - "email_plain": "", - "first_name_enc": "ZmFrZV9lbmM6UTJGeWJIUnZiZz09", - "first_name_hash": "Carlton", - "first_name_plain": "Carlton", + "_first_name": "ZmFrZV9lbmM6UTJGeWJIUnZiZz09", + "_first_name_hash": "Carlton", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6U205elpYQm8=", - "last_name_plain": "Joseph", + "last_name": "ZmFrZV9lbmM6U205elpYQm8=", "password": "pbkdf2_sha256$1000000$6Nhm1sHtcMbFdBitdz7nxq$s5i8H7MwdH6SRQ3+DZDDfsPtqxXVDPAC5t67G4FluM4=", "user_permissions": [] } @@ -927,17 +871,14 @@ "fields": { "date_joined": "2026-02-04T16:02:36.792Z", "dek": "ZmFrZV9lbmM6TW5SM1FUQnNUMlJ2VFU1RFQwUllVSEp1VFc1aWQxSmxOWHBqVDJKeFZtTT0=", - "email_plain": "", - "first_name_enc": "ZmFrZV9lbmM6VG1Ga1lXdz0=", - "first_name_hash": "Nadal", - "first_name_plain": "Nadal", + "_first_name": "ZmFrZV9lbmM6VG1Ga1lXdz0=", + "_first_name_hash": "Nadal", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6VTNCbGJtTmxjaTFLWlc1dWFXNW5jdz09", - "last_name_plain": "Spencer-Jennings", + "last_name": "ZmFrZV9lbmM6VTNCbGJtTmxjaTFLWlc1dWFXNW5jdz09", "password": "pbkdf2_sha256$1000000$FRA5lx8xBTO5Rn8J9wwZZW$KiQzLItBM40bzbwRdXpKnrI1uWXv0xsSzTMkFwAewwo=", "user_permissions": [] } @@ -948,17 +889,14 @@ "fields": { "date_joined": "2026-02-04T16:02:37.009Z", "dek": "ZmFrZV9lbmM6UlZKbll6UjJhM1JPU2tvemRIQTVSRUZZYUZsMGFtOVZla05HYzI5bmQyaz0=", - "email_plain": "", - "first_name_enc": "ZmFrZV9lbmM6Um5KbFpHUnBaUT09", - "first_name_hash": "Freddie", - "first_name_plain": "Freddie", + "_first_name": "ZmFrZV9lbmM6Um5KbFpHUnBaUT09", + "_first_name_hash": "Freddie", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6UjI5bVpnPT0=", - "last_name_plain": "Goff", + "last_name": "ZmFrZV9lbmM6UjI5bVpnPT0=", "password": "pbkdf2_sha256$1000000$LAQQycnLxi5rY9QCD15nHw$Ad5r4JEo5CCQrwuD2F12ajXnV/bDSL+FGQ99vM2l0/k=", "user_permissions": [] } @@ -969,17 +907,14 @@ "fields": { "date_joined": "2026-02-04T16:02:37.216Z", "dek": "ZmFrZV9lbmM6YzIxdGRIbzVRbEkyVlRKdVRVVk1lSFE1TmtRMFFXeFVUamRaYldWbFpFUT0=", - "email_plain": "", - "first_name_enc": "ZmFrZV9lbmM6VEdWdmJnPT0=", - "first_name_hash": "Leon", - "first_name_plain": "Leon", + "_first_name": "ZmFrZV9lbmM6VEdWdmJnPT0=", + "_first_name_hash": "Leon", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6VTJOdmRIUT0=", - "last_name_plain": "Scott", + "last_name": "ZmFrZV9lbmM6VTJOdmRIUT0=", "password": "pbkdf2_sha256$1000000$XHhBEg6yrF9a6TR487gNrU$DOR6TONr+9q0rLXJwB43UuezRcQkQAmEmoN0nYmm4do=", "user_permissions": [] } @@ -990,17 +925,14 @@ "fields": { "date_joined": "2026-02-04T16:02:37.413Z", "dek": "ZmFrZV9lbmM6T0VoaU5rRTVWalZTVUVkbVpVTlllRmhOVlVoVVZVRnVUR1pvVVU5bVUybz0=", - "email_plain": "", - "first_name_enc": "ZmFrZV9lbmM6UW1WMGRIaz0=", - "first_name_hash": "Betty", - "first_name_plain": "Betty", + "_first_name": "ZmFrZV9lbmM6UW1WMGRIaz0=", + "_first_name_hash": "Betty", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6UzJWemMyVnNiQT09", - "last_name_plain": "Kessell", + "last_name": "ZmFrZV9lbmM6UzJWemMyVnNiQT09", "password": "pbkdf2_sha256$1000000$ua0WUUCFIijIjxpLdkc25v$mvl+UejCNGop0krujly8TPlMbgWPFVZ42tH+NPDltS4=", "user_permissions": [] } @@ -1011,17 +943,14 @@ "fields": { "date_joined": "2026-02-04T16:02:37.614Z", "dek": "ZmFrZV9lbmM6Y25SMVIwYzNNbXBVUWtkTk1IVmhjMUI2YVdWb2VtNVBaRUUyVFdsMldrcz0=", - "email_plain": "", - "first_name_enc": "ZmFrZV9lbmM6UkdWc1pYUmxaQT09", - "first_name_hash": "Deleted", - "first_name_plain": "Deleted", + "_first_name": "ZmFrZV9lbmM6UkdWc1pYUmxaQT09", + "_first_name_hash": "Deleted", "groups": [], "is_active": false, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6VlhObGNnPT0=", - "last_name_plain": "User", + "last_name": "ZmFrZV9lbmM6VlhObGNnPT0=", "password": "pbkdf2_sha256$1000000$o2AfZh0JqqIFRTZGHWEQKV$c1es5kdOakAvCGkeMVgDOX5q+rFhdQgOkMZiKAtqMrk=", "user_permissions": [] } @@ -1032,21 +961,18 @@ "fields": { "date_joined": "2026-02-04T16:02:40.242Z", "dek": "ZmFrZV9lbmM6WmtGRFlYQjBUek5OU1VjeGVXczNVbmRTVmxsRGRtOXlWREZzVFU5Q1dqQT0=", - "email_enc": "ZmFrZV9lbmM6WVdSdGFXNXpkSFZrWlc1MFFHTnZaR1ZtYjNKc2FXWmxMbU52YlE9PQ==", - "email_hash": "adminstudent@codeforlife.com", - "email_plain": "adminstudent@codeforlife.com", - "first_name_enc": "ZmFrZV9lbmM6VUc5eWRHRnNZV1J0YVc0PQ==", - "first_name_hash": "Portaladmin", - "first_name_plain": "Portaladmin", + "_email": "ZmFrZV9lbmM6WVdSdGFXNXpkSFZrWlc1MFFHTnZaR1ZtYjNKc2FXWmxMbU52YlE9PQ==", + "_email_hash": "adminstudent@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6VUc5eWRHRnNZV1J0YVc0PQ==", + "_first_name_hash": "Portaladmin", "groups": [], "is_active": true, "is_staff": false, "is_superuser": false, "last_login": null, - "last_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkQT09", - "last_name_plain": "Student", + "last_name": "ZmFrZV9lbmM6VTNSMVpHVnVkQT09", "password": "pbkdf2_sha256$1000000$cv54m0PSNCQrH0MBLU56pk$nhGzkqLXL01EEtLAsoAbaWcTxuj1SOrZ7s6rBdA7eZk=", "user_permissions": [] } } -] \ No newline at end of file +] diff --git a/codeforlife/user/fixtures/non_school_teacher.json b/codeforlife/user/fixtures/non_school_teacher.json index f05192da..98927c14 100644 --- a/codeforlife/user/fixtures/non_school_teacher.json +++ b/codeforlife/user/fixtures/non_school_teacher.json @@ -4,14 +4,11 @@ "pk": 22, "fields": { "dek": "ZmFrZV9lbmM6TkhGdE1EWlFOVkJ3VDFwTVVYWmlVRzlFYVdwU1JIUTVialZ0V1RoQ2NXYz0=", - "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J1YjNOamFHOXZiQzVqYjIwPQ==", - "email_hash": "teacher@noschool.com", - "email_plain": "teacher@noschool.com", - "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", - "first_name_hash": "John", - "first_name_plain": "John", - "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", - "last_name_plain": "Doe", + "_email": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J1YjNOamFHOXZiQzVqYjIwPQ==", + "_email_hash": "teacher@noschool.com", + "_first_name": "ZmFrZV9lbmM6U205b2JnPT0=", + "_first_name_hash": "John", + "last_name": "ZmFrZV9lbmM6Ukc5bA==", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -36,14 +33,11 @@ "pk": 33, "fields": { "dek": "ZmFrZV9lbmM6UVhac01Ea3dibmQxYzBkRFdVbGFRMDR4VEZkelRVWklUbHA1VVZsU1RXOD0=", - "email_enc": "ZmFrZV9lbmM6ZFc1MlpYSnBabWxsWkM1MFpXRmphR1Z5UUc1dmMyTm9iMjlzTG1OdmJRPT0=", - "email_hash": "unverified.teacher@noschool.com", - "email_plain": "unverified.teacher@noschool.com", - "first_name_enc": "ZmFrZV9lbmM6Vlc1MlpYSnBabWxsWkE9PQ==", - "first_name_hash": "Unverified", - "first_name_plain": "Unverified", - "last_name_enc": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", - "last_name_plain": "Teacher", + "_email": "ZmFrZV9lbmM6ZFc1MlpYSnBabWxsWkM1MFpXRmphR1Z5UUc1dmMyTm9iMjlzTG1OdmJRPT0=", + "_email_hash": "unverified.teacher@noschool.com", + "_first_name": "ZmFrZV9lbmM6Vlc1MlpYSnBabWxsWkE9PQ==", + "_first_name_hash": "Unverified", + "last_name": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -63,4 +57,4 @@ "new_user": 33 } } -] \ No newline at end of file +] diff --git a/codeforlife/user/fixtures/school_1.json b/codeforlife/user/fixtures/school_1.json index aeea461a..c06b482e 100644 --- a/codeforlife/user/fixtures/school_1.json +++ b/codeforlife/user/fixtures/school_1.json @@ -6,8 +6,7 @@ "country": "GB", "county": "Hertfordshire", "dek": "ZmFrZV9lbmM6V1cxTVdtMXFibTgyVlhadGVFTlRla1p2ZDBOM2QzVnVSMEZ3WkdGeVpsQT0=", - "name_enc": "ZmFrZV9lbmM6VTJOb2IyOXNJREU9", - "name_plain": "School 1" + "name": "ZmFrZV9lbmM6VTJOb2IyOXNJREU9" } }, { @@ -15,14 +14,11 @@ "pk": 23, "fields": { "dek": "ZmFrZV9lbmM6U2tGVmEzZHhlVkF4VERKR1QwaGxPVWRTWkhkVE16WTBiREV3WjFkQlVHcz0=", - "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3hMbU52YlE9PQ==", - "email_hash": "teacher@school1.com", - "email_plain": "teacher@school1.com", - "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", - "first_name_hash": "John", - "first_name_plain": "John", - "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", - "last_name_plain": "Doe", + "_email": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3hMbU52YlE9PQ==", + "_email_hash": "teacher@school1.com", + "_first_name": "ZmFrZV9lbmM6U205b2JnPT0=", + "_first_name_hash": "John", + "last_name": "ZmFrZV9lbmM6Ukc5bA==", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -48,11 +44,9 @@ "pk": 6, "fields": { "accept_requests_until": "9999-02-09 20:26:08.298402+00:00", - "access_code_enc": "ZmFrZV9lbmM6V2xveE1URT0=", - "access_code_hash": "ZZ111", - "access_code_plain": "ZZ111", - "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNU0JBSUZOamFHOXZiQ0F4", - "name_plain": "Class 1 @ School 1", + "_access_code": "ZmFrZV9lbmM6V2xveE1URT0=", + "_access_code_hash": "ZZ111", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNU0JBSUZOamFHOXZiQ0F4", "teacher": 6 } }, @@ -61,9 +55,8 @@ "pk": 27, "fields": { "dek": "ZmFrZV9lbmM6ZEc1aFZ6RkhVRTFNVFdaSWQzTlZXRmxRZFRsTFZIbE1kRWxSVGs1Q1FWQT0=", - "first_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkREU9", - "first_name_hash": "Student1", - "first_name_plain": "Student1", + "_first_name": "ZmFrZV9lbmM6VTNSMVpHVnVkREU9", + "_first_name_hash": "Student1", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -89,14 +82,11 @@ "pk": 24, "fields": { "dek": "ZmFrZV9lbmM6YkVGQk1XVTBVWFJqTWpFelFVMWhTVE5EUWtWeVMzZGpjR1JNU2xSTVlrUT0=", - "email_enc": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3eExtTnZiUT09", - "email_hash": "admin.teacher@school1.com", - "email_plain": "admin.teacher@school1.com", - "first_name_enc": "ZmFrZV9lbmM6U21GdVpRPT0=", - "first_name_hash": "Jane", - "first_name_plain": "Jane", - "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", - "last_name_plain": "Doe", + "_email": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3eExtTnZiUT09", + "_email_hash": "admin.teacher@school1.com", + "_first_name": "ZmFrZV9lbmM6U21GdVpRPT0=", + "_first_name_hash": "Jane", + "last_name": "ZmFrZV9lbmM6Ukc5bA==", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -122,11 +112,9 @@ "model": "user.class", "pk": 7, "fields": { - "access_code_enc": "ZmFrZV9lbmM6V2xveU1qST0=", - "access_code_hash": "ZZ222", - "access_code_plain": "ZZ222", - "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F4", - "name_plain": "Class 2 @ School 1", + "_access_code": "ZmFrZV9lbmM6V2xveU1qST0=", + "_access_code_hash": "ZZ222", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F4", "teacher": 7 } }, @@ -135,9 +123,8 @@ "pk": 29, "fields": { "dek": "ZmFrZV9lbmM6YXpWd1dGZEZOWHBXZEdnMFJWQlROWFZFV2s0eVRuVTBOVTlGZUdwNWJtND0=", - "first_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkREk9", - "first_name_hash": "Student2", - "first_name_plain": "Student2", + "_first_name": "ZmFrZV9lbmM6VTNSMVpHVnVkREk9", + "_first_name_hash": "Student2", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -163,12 +150,10 @@ "pk": 10, "fields": { "accept_requests_until": "2023-02-09 20:26:08.298402+00:00", - "access_code_enc": "ZmFrZV9lbmM6V2xvek16TT0=", - "access_code_hash": "ZZ333", - "access_code_plain": "ZZ333", - "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNeUJBSUZOamFHOXZiQ0F4", - "name_plain": "Class 3 @ School 1", + "_access_code": "ZmFrZV9lbmM6V2xvek16TT0=", + "_access_code_hash": "ZZ333", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNeUJBSUZOamFHOXZiQ0F4", "teacher": 7 } } -] \ No newline at end of file +] diff --git a/codeforlife/user/fixtures/school_2.json b/codeforlife/user/fixtures/school_2.json index 64086be1..5320af19 100644 --- a/codeforlife/user/fixtures/school_2.json +++ b/codeforlife/user/fixtures/school_2.json @@ -6,8 +6,7 @@ "country": "GB", "county": "Hertfordshire", "dek": "ZmFrZV9lbmM6VlUxaWN6TTVhbk56WW5wcFpIVXhhVlE1UW10WGFVcHpUa3BFT0VNNVdtYz0=", - "name_enc": "ZmFrZV9lbmM6VTJOb2IyOXNJREk9", - "name_plain": "School 2" + "name": "ZmFrZV9lbmM6VTJOb2IyOXNJREk9" } }, { @@ -15,14 +14,11 @@ "pk": 25, "fields": { "dek": "ZmFrZV9lbmM6UkhWQ1kwaHlOa2hqYzFSV1MxcE1SblkwYnpnMGRtNUxWMnRLV1dWMlRUYz0=", - "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3lMbU52YlE9PQ==", - "email_hash": "teacher@school2.com", - "email_plain": "teacher@school2.com", - "first_name_enc": "ZmFrZV9lbmM6U205b2JnPT0=", - "first_name_hash": "John", - "first_name_plain": "John", - "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", - "last_name_plain": "Doe", + "_email": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3lMbU52YlE9PQ==", + "_email_hash": "teacher@school2.com", + "_first_name": "ZmFrZV9lbmM6U205b2JnPT0=", + "_first_name_hash": "John", + "last_name": "ZmFrZV9lbmM6Ukc5bA==", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -136,11 +132,9 @@ "model": "user.class", "pk": 8, "fields": { - "access_code_enc": "ZmFrZV9lbmM6V0ZneE1URT0=", - "access_code_hash": "XX111", - "access_code_plain": "XX111", - "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNU0JBSUZOamFHOXZiQ0F5", - "name_plain": "Class 1 @ School 2", + "_access_code": "ZmFrZV9lbmM6V0ZneE1URT0=", + "_access_code_hash": "XX111", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNU0JBSUZOamFHOXZiQ0F5", "teacher": 8 } }, @@ -149,14 +143,11 @@ "pk": 26, "fields": { "dek": "ZmFrZV9lbmM6WkdoS2FsQnNjR3R4UmtaSFdrOVJTa2huVW1wa1lVcDVhRVJ2Ulhkd2RuYz0=", - "email_enc": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3eUxtTnZiUT09", - "email_hash": "admin.teacher@school2.com", - "email_plain": "admin.teacher@school2.com", - "first_name_enc": "ZmFrZV9lbmM6U21GdVpRPT0=", - "first_name_hash": "Jane", - "first_name_plain": "Jane", - "last_name_enc": "ZmFrZV9lbmM6Ukc5bA==", - "last_name_plain": "Doe", + "_email": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3eUxtTnZiUT09", + "_email_hash": "admin.teacher@school2.com", + "_first_name": "ZmFrZV9lbmM6U21GdVpRPT0=", + "_first_name_hash": "Jane", + "last_name": "ZmFrZV9lbmM6Ukc5bA==", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -191,12 +182,10 @@ "model": "user.class", "pk": 9, "fields": { - "access_code_enc": "ZmFrZV9lbmM6V0ZneU1qST0=", - "access_code_hash": "XX222", - "access_code_plain": "XX222", - "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F5", - "name_plain": "Class 2 @ School 2", + "_access_code": "ZmFrZV9lbmM6V0ZneU1qST0=", + "_access_code_hash": "XX222", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F5", "teacher": 9 } } -] \ No newline at end of file +] diff --git a/codeforlife/user/fixtures/school_3.json b/codeforlife/user/fixtures/school_3.json index 0ef07199..cf2f3f44 100644 --- a/codeforlife/user/fixtures/school_3.json +++ b/codeforlife/user/fixtures/school_3.json @@ -6,8 +6,7 @@ "country": "GB", "county": "Hertfordshire", "dek": "ZmFrZV9lbmM6Vm1ORlNIbENURmRyZEdOR1VGaHFOMFZIVFhkRE1IQjVNR1oyTkdkVFNIRT0=", - "name_enc": "ZmFrZV9lbmM6VTJOb2IyOXNJRE09", - "name_plain": "School 3" + "name": "ZmFrZV9lbmM6VTJOb2IyOXNJRE09" } }, { @@ -15,14 +14,11 @@ "pk": 31, "fields": { "dek": "ZmFrZV9lbmM6ZGxKVE4xTm5jRGhKT1ZWVVdqWlpPVUpDUm14V1EycDRNbkYxZUc1aVExbz0=", - "email_enc": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3ekxtTnZiUT09", - "email_hash": "admin.teacher@school3.com", - "email_plain": "admin.teacher@school3.com", - "first_name_enc": "ZmFrZV9lbmM6VUdWMFpYST0=", - "first_name_hash": "Peter", - "first_name_plain": "Peter", - "last_name_enc": "ZmFrZV9lbmM6VUdGeWEyVnk=", - "last_name_plain": "Parker", + "_email": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3ekxtTnZiUT09", + "_email_hash": "admin.teacher@school3.com", + "_first_name": "ZmFrZV9lbmM6VUdWMFpYST0=", + "_first_name_hash": "Peter", + "last_name": "ZmFrZV9lbmM6VUdGeWEyVnk=", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -49,14 +45,11 @@ "pk": 32, "fields": { "dek": "ZmFrZV9lbmM6VjA5cGVVcG1SR1pRTkdoNVptUXhOR1Z0ZEVOblVtUmtjRWhJUlZob01Faz0=", - "email_enc": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3pMbU52YlE9PQ==", - "email_hash": "teacher@school3.com", - "email_plain": "teacher@school3.com", - "first_name_enc": "ZmFrZV9lbmM6Ukc5amRHOXk=", - "first_name_hash": "Doctor", - "first_name_plain": "Doctor", - "last_name_enc": "ZmFrZV9lbmM6VDJOMGIzQjFjdz09", - "last_name_plain": "Octopus", + "_email": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3pMbU52YlE9PQ==", + "_email_hash": "teacher@school3.com", + "_first_name": "ZmFrZV9lbmM6Ukc5amRHOXk=", + "_first_name_hash": "Doctor", + "last_name": "ZmFrZV9lbmM6VDJOMGIzQjFjdz09", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -77,4 +70,4 @@ "school": 4 } } -] \ No newline at end of file +] From bdeb8797a984e28b2bfc2bed9b9924946ecb2078 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 17 Mar 2026 16:36:48 +0000 Subject: [PATCH 57/58] fix tests --- codeforlife/models/fields/sha256_test.py | 33 +++++---- codeforlife/models/signals/__init__.py | 6 +- codeforlife/models/signals/general.py | 37 +++++++++- codeforlife/user/auth/backends/email.py | 2 +- codeforlife/user/auth/backends/student.py | 4 +- codeforlife/user/filters/klass.py | 10 +-- codeforlife/user/filters/user.py | 8 +-- codeforlife/user/models/user/contactable.py | 4 +- codeforlife/user/models/user/google.py | 11 +-- codeforlife/user/models/user/student.py | 3 +- codeforlife/user/models/user/teacher.py | 1 + codeforlife/user/models/user/user.py | 41 ++++++----- codeforlife/user/serializers/user_test.py | 12 +++- codeforlife/user/signals/user.py | 31 +++++++-- codeforlife/user/signals/user_test.py | 77 +++++++++++++++++++++ codeforlife/user/views/klass_test.py | 4 +- codeforlife/user/views/user_test.py | 6 +- 17 files changed, 220 insertions(+), 70 deletions(-) create mode 100644 codeforlife/user/signals/user_test.py diff --git a/codeforlife/models/fields/sha256_test.py b/codeforlife/models/fields/sha256_test.py index 20de1f34..f483a2f3 100644 --- a/codeforlife/models/fields/sha256_test.py +++ b/codeforlife/models/fields/sha256_test.py @@ -24,25 +24,28 @@ def test_init__max_length_not_64(self): def test_get__descriptor(self): """Getting field from class returns the descriptor.""" - assert isinstance(User.email_hash, Sha256Field.descriptor_class) - assert isinstance(User.email_hash.field, Sha256Field) + # pylint: disable-next=protected-access + email_hash = User._email_hash + assert isinstance(email_hash, Sha256Field.descriptor_class) + assert isinstance(email_hash.field, Sha256Field) def test_get__value(self): """Getting field from instance returns the value.""" email = "test@example.com" - user = User(email_hash=email) - assert user.email_hash == Sha256Field.hash(email) + user = User(_email_hash=email) + # pylint: disable-next=protected-access + assert user._email_hash == Sha256Field.hash(email) def test_set__none(self): """Setting field to None sets to None.""" - user = User(email_hash=None) - assert user.__dict__["email_hash"] is None + user = User(_email_hash=None) + assert user.__dict__["_email_hash"] is None def test_set__str(self): """Setting field to a string sets the hashed value.""" email = "test@example.com" - user = User(email_hash=email) - assert user.__dict__["email_hash"] == Sha256Field.hash(email) + user = User(_email_hash=email) + assert user.__dict__["_email_hash"] == Sha256Field.hash(email) def test_hash(self): """Hashing the same value produces the same hash of 64 characters.""" @@ -57,17 +60,19 @@ def test_lookup__sha256(self): `sha256` lookup hashes the right-hand side value before doing an exact match. """ - user = User.objects.filter(email_hash__isnull=False).first() + user = User.objects.filter(_email_hash__isnull=False).first() assert user - assert user.email != user.email_hash - assert User.objects.get(email_hash__sha256=user.email) == user + # pylint: disable-next=protected-access + assert user.email != user._email_hash + assert User.objects.get(_email_hash__sha256=user.email) == user def test_lookup__sha256_in(self): """ `sha256_in` lookup hashes each value in the list before doing an exact match. """ - user = User.objects.filter(email_hash__isnull=False).first() + user = User.objects.filter(_email_hash__isnull=False).first() assert user - assert user.email != user.email_hash - assert User.objects.get(email_hash__sha256_in=[user.email]) == user + # pylint: disable-next=protected-access + assert user.email != user._email_hash + assert User.objects.get(_email_hash__sha256_in=[user.email]) == user diff --git a/codeforlife/models/signals/__init__.py b/codeforlife/models/signals/__init__.py index f9268475..c87899b0 100644 --- a/codeforlife/models/signals/__init__.py +++ b/codeforlife/models/signals/__init__.py @@ -6,5 +6,9 @@ https://docs.djangoproject.com/en/5.1/ref/signals/#module-django.db.models.signals """ -from .general import UpdateFields, update_fields_includes +from .general import ( + UpdateFields, + update_fields_includes, + validate_update_fields_includes_none_or_all, +) from .receiver import model_receiver diff --git a/codeforlife/models/signals/general.py b/codeforlife/models/signals/general.py index 6926843d..63d8adaf 100644 --- a/codeforlife/models/signals/general.py +++ b/codeforlife/models/signals/general.py @@ -5,13 +5,16 @@ import typing as t +from django.core.exceptions import ValidationError from django.db.models import Model AnyModel = t.TypeVar("AnyModel", bound=Model) UpdateFields = t.Optional[t.FrozenSet[str]] -def update_fields_includes(update_fields: UpdateFields, includes: t.Set[str]): +def update_fields_includes( + update_fields: UpdateFields, includes: t.Union[t.Set[str], t.FrozenSet[str]] +): """Check the call to .save() includes the update-fields specified. Args: @@ -21,4 +24,34 @@ def update_fields_includes(update_fields: UpdateFields, includes: t.Set[str]): Returns: A flag designating if the fields are included in the update-fields. """ - return update_fields and includes.issubset(update_fields) + return ( + update_fields is not None + and len(update_fields) > 0 + and includes.issubset(update_fields) + ) + + +def validate_update_fields_includes_none_or_all( + update_fields: UpdateFields, includes: t.Union[t.Set[str], t.FrozenSet[str]] +): + """ + Validates that either none or all of the fields are included in the + update-fields. + + Args: + update_fields: The update-fields provided in the call to .save(). + includes: The fields that should be included in the update-fields. + + Raises: + ValidationError: If some but not all of the fields are included. + """ + if ( + update_fields + and update_fields.intersection(includes) + and not update_fields.issuperset(includes) + ): + raise ValidationError( + "Either none or all of the following fields must be included in " + f"update_fields: {', '.join(includes)}", + code="update_fields_incomplete", + ) diff --git a/codeforlife/user/auth/backends/email.py b/codeforlife/user/auth/backends/email.py index d480056c..14601f89 100644 --- a/codeforlife/user/auth/backends/email.py +++ b/codeforlife/user/auth/backends/email.py @@ -26,7 +26,7 @@ def authenticate( # type: ignore[override] # pylint: disable=duplicate-code try: - user = self.user_class.objects.get(email_hash__sha256=email) + user = self.user_class.objects.get(_email_hash__sha256=email) except self.user_class.DoesNotExist: return None # pylint: enable=duplicate-code diff --git a/codeforlife/user/auth/backends/student.py b/codeforlife/user/auth/backends/student.py index 3e12d550..aab78dac 100644 --- a/codeforlife/user/auth/backends/student.py +++ b/codeforlife/user/auth/backends/student.py @@ -29,8 +29,8 @@ def authenticate( # type: ignore[override] # pylint: disable=duplicate-code try: user = self.user_class.objects.get( - first_name_hash__sha256=first_name, - new_student__class_field__access_code_hash__sha256=class_id, + _first_name_hash__sha256=first_name, + new_student__class_field___access_code_hash__sha256=class_id, ) except self.user_class.DoesNotExist: return None diff --git a/codeforlife/user/filters/klass.py b/codeforlife/user/filters/klass.py index b652976c..f0f64eb7 100644 --- a/codeforlife/user/filters/klass.py +++ b/codeforlife/user/filters/klass.py @@ -21,19 +21,21 @@ class ClassFilterSet(FilterSet): def _id__method(self, queryset: QuerySet[Class], name: str, *args): access_codes = self.request.GET.getlist(name) - return queryset.exclude(**{"access_code_hash__sha256_in": access_codes}) + return queryset.exclude( + **{"_access_code_hash__sha256_in": access_codes} + ) def id_or_name__method(self, queryset: QuerySet[Class], _: str, value: str): """Get classes where the id or the name contain a substring.""" name = value.lower() pks = [ klass.pk - for klass in queryset.only("name_enc") - if name in klass.name.lower() + for klass in queryset.only("name") + if klass.name and name in klass.name.lower() ] return queryset.filter( - Q(access_code_hash__sha256=value) | Q(pk__in=pks) + Q(_access_code_hash__sha256=value) | Q(pk__in=pks) ) class Meta: diff --git a/codeforlife/user/filters/user.py b/codeforlife/user/filters/user.py index 671523df..12269b51 100644 --- a/codeforlife/user/filters/user.py +++ b/codeforlife/user/filters/user.py @@ -22,7 +22,7 @@ # pylint: disable-next=missing-class-docstring class UserFilterSet(FilterSet): students_in_class = filters.CharFilter( - "new_student__class_field__access_code_hash", + "new_student__class_field___access_code_hash", "sha256", ) @@ -53,9 +53,9 @@ def name__method( pks = [ user.pk - for user in queryset.only("first_name_enc", "last_name_enc") - if first_name in user.first_name.lower() - or last_name in user.last_name.lower() + for user in queryset.only("_first_name", "last_name") + if (user.first_name and first_name in user.first_name.lower()) + or (user.last_name and last_name in user.last_name.lower()) ] return queryset.filter(pk__in=pks) diff --git a/codeforlife/user/models/user/contactable.py b/codeforlife/user/models/user/contactable.py index 0c799abb..20b77e6b 100644 --- a/codeforlife/user/models/user/contactable.py +++ b/codeforlife/user/models/user/contactable.py @@ -23,9 +23,7 @@ # pylint: disable-next=missing-class-docstring,too-few-public-methods class ContactableUserManager(UserManager[AnyUser], t.Generic[AnyUser]): def filter_users(self, queryset: QuerySet[User]): - return queryset.exclude(email_plain__isnull=True).exclude( - email_plain="" - ) + return queryset.exclude(_email__isnull=True).exclude(_email=b"") # pylint: disable-next=too-many-ancestors diff --git a/codeforlife/user/models/user/google.py b/codeforlife/user/models/user/google.py index 1e769751..4f902383 100644 --- a/codeforlife/user/models/user/google.py +++ b/codeforlife/user/models/user/google.py @@ -61,14 +61,9 @@ def _sync(self, auth_header: str, refresh_token: t.Optional[str] = None): user.last_name = last_name user.save( update_fields=[ - "email_hash", - "email_plain", - "email_enc", - "first_name_hash", - "first_name_plain", - "first_name_enc", - "last_name_plain", - "last_name_enc", + *user.EMAIL_FIELDS, + *user.FIRST_NAME_FIELDS, + "last_name", ] ) diff --git a/codeforlife/user/models/user/student.py b/codeforlife/user/models/user/student.py index 9b932126..3593446c 100644 --- a/codeforlife/user/models/user/student.py +++ b/codeforlife/user/models/user/student.py @@ -86,10 +86,11 @@ class StudentUser(User): # TODO: move this is to Student model in new schema. _login_id: t.Optional[str] + last_name: None # type: ignore[assignment] teacher: None student: "Student" - credential_fields = frozenset(["first_name", "password"]) + CREDENTIAL_FIELDS = frozenset(["first_name", "password"]) class Meta(TypedModelMeta): proxy = True diff --git a/codeforlife/user/models/user/teacher.py b/codeforlife/user/models/user/teacher.py index 13c9837c..18765d91 100644 --- a/codeforlife/user/models/user/teacher.py +++ b/codeforlife/user/models/user/teacher.py @@ -84,6 +84,7 @@ def get_queryset(self): class TeacherUser(ContactableUser): """A user that is a teacher.""" + last_name: str # type: ignore[assignment] teacher: "Teacher" student: None diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index f99e292b..2d21d70a 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -146,15 +146,8 @@ def get_queryset(self): class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): """A Code for Life user.""" - associated_data = "user" - - EMAIL_FIELD = "_email" - USERNAME_FIELD = "_email_hash" - REQUIRED_FIELDS = ["_email"] - credential_fields = frozenset(["email", "password"]) - + ### Type hints for fields and related objects. _password: t.Optional[str] - id: int # type: ignore[assignment] auth_factors: QuerySet["AuthFactor"] # type: ignore[assignment,misc] # pylint: disable-next=line-too-long @@ -162,6 +155,20 @@ class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): session: "Session" # type: ignore[assignment] userprofile: "UserProfile" + ### Data encryption key model configuration. + associated_data = "user" + + ### Django auth field registries. + EMAIL_FIELD = "_email" + USERNAME_FIELD = "_email_hash" + REQUIRED_FIELDS = ["_email"] + + ### Custom field registries. + CREDENTIAL_FIELDS = frozenset(["email", "password"]) + FIRST_NAME_FIELDS = frozenset(["_first_name", "_first_name_hash"]) + EMAIL_FIELDS = frozenset(["_email", "_email_hash"]) + + ### First name fields. _first_name_hash = Sha256Field( verbose_name=_("first name hash"), db_column="first_name_hash", @@ -185,10 +192,7 @@ def first_name(self, value: str): self._first_name = value self._first_name_hash = value - last_name = EncryptedTextField( - associated_data="last_name", null=True, verbose_name=_("last name") - ) - + ### Email fields. _email_hash = Sha256Field( verbose_name=_("email hash"), unique=True, @@ -210,10 +214,15 @@ def email(self): @email.setter def email(self, value: t.Optional[str]): """Set the user's email address.""" - value = self.objects.normalize_email(value) + value = self.__class__.objects.normalize_email(value) self._email = value self._email_hash = value + ### Other fields. + last_name = EncryptedTextField( + associated_data="last_name", null=True, verbose_name=_("last name") + ) + is_staff = models.BooleanField( _("staff status"), default=False, @@ -353,11 +362,9 @@ def anonymize(self): self.save( update_fields=[ # pylint: disable=duplicate-code - "_first_name_hash", - "_first_name", + *self.FIRST_NAME_FIELDS, + *self.EMAIL_FIELDS, "last_name", - "_email", - "_email_hash", "is_active", # pylint: enable=duplicate-code ] diff --git a/codeforlife/user/serializers/user_test.py b/codeforlife/user/serializers/user_test.py index 592b063c..639c08c7 100644 --- a/codeforlife/user/serializers/user_test.py +++ b/codeforlife/user/serializers/user_test.py @@ -31,13 +31,15 @@ def test_to_representation__teacher(self): "is_admin": user.teacher.is_admin, }, "student": None, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, }, # TODO: remove in new schema. non_model_fields={ "requesting_to_join_class", "teacher", "student", - # TODO: remove once plain fields are removed. "first_name", "last_name", "email", @@ -61,13 +63,15 @@ def test_to_representation__student(self): # pylint: disable-next=line-too-long "school": user.student.class_field.teacher.school.id, # type: ignore[union-attr] }, + "first_name": user.first_name, + "last_name": None, + "email": None, }, # TODO: remove in new schema. non_model_fields={ "requesting_to_join_class", "teacher", "student", - # TODO: remove once plain fields are removed. "first_name", "last_name", "email", @@ -87,13 +91,15 @@ def test_to_representation__indy(self): "requesting_to_join_class": None, "teacher": None, "student": None, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, }, # TODO: remove in new schema. non_model_fields={ "requesting_to_join_class", "teacher", "student", - # TODO: remove once plain fields are removed. "first_name", "last_name", "email", diff --git a/codeforlife/user/signals/user.py b/codeforlife/user/signals/user.py index e086eb1f..c450638a 100644 --- a/codeforlife/user/signals/user.py +++ b/codeforlife/user/signals/user.py @@ -3,11 +3,32 @@ Created on 14/03/2024 at 12:14:54(+00:00). """ -# TODO: use custom User model in new data schema -# from ..models import User -# pylint: disable-next=imported-auth-user -from django.contrib.auth.models import User +from django.db.models import signals -from ...models.signals import model_receiver +from ...models.signals import ( + UpdateFields, + model_receiver, + validate_update_fields_includes_none_or_all, +) +from ..models import User user_receiver = model_receiver(User) + +# pylint: disable=unused-argument,missing-function-docstring + + +@user_receiver(signals.pre_save) +def user__pre_save( + sender, + instance: User, + raw: bool, + using: str, + update_fields: UpdateFields, + **kwargs +): + validate_update_fields_includes_none_or_all( + update_fields, User.FIRST_NAME_FIELDS + ) + validate_update_fields_includes_none_or_all( + update_fields, User.EMAIL_FIELDS + ) diff --git a/codeforlife/user/signals/user_test.py b/codeforlife/user/signals/user_test.py new file mode 100644 index 00000000..4fb06938 --- /dev/null +++ b/codeforlife/user/signals/user_test.py @@ -0,0 +1,77 @@ +""" +© Ocado Group +Created on 14/03/2024 at 12:14:54(+00:00). +""" + +from ...tests import TestCase +from ..models import User + + +class UserSignalsTests(TestCase): + fixtures = ["school_1"] + + def setUp(self): + user = User.objects.first() + assert user + self.user = user + + def test_pre_save__update_fields_incomplete__first_name(self): + """ + Saving a User with only one of the first_name fields in update_fields + should raise a ValidationError, but saving with both or neither should + succeed. + """ + + with self.subTest("Only _first_name included in update_fields"): + with self.assert_raises_validation_error( + "update_fields_incomplete" + ): + self.user.save(update_fields={"_first_name"}) + + with self.subTest("Only _first_name_hash included in update_fields"): + with self.assert_raises_validation_error( + "update_fields_incomplete" + ): + self.user.save(update_fields={"_first_name_hash"}) + + with self.subTest( + "Neither _first_name nor _first_name_hash included in update_fields" + ): + self.user.save() + self.user.save(update_fields={}) + + with self.subTest( + "Both _first_name and _first_name_hash included in update_fields" + ): + self.user.first_name = "John" + self.user.save(update_fields={"_first_name", "_first_name_hash"}) + + def test_pre_save__update_fields_incomplete__email(self): + """ + Saving a User with only one of the email fields in update_fields should + raise a ValidationError, but saving with both or neither should succeed. + """ + + with self.subTest("Only _email included in update_fields"): + with self.assert_raises_validation_error( + "update_fields_incomplete" + ): + self.user.save(update_fields={"_email"}) + + with self.subTest("Only _email_hash included in update_fields"): + with self.assert_raises_validation_error( + "update_fields_incomplete" + ): + self.user.save(update_fields={"_email_hash"}) + + with self.subTest( + "Neither _email nor _email_hash included in update_fields" + ): + self.user.save() + self.user.save(update_fields={}) + + with self.subTest( + "Both _email and _email_hash included in update_fields" + ): + self.user.email = "john.doe@example.com" + self.user.save(update_fields={"_email", "_email_hash"}) diff --git a/codeforlife/user/views/klass_test.py b/codeforlife/user/views/klass_test.py index b59d30d5..2b08d4e9 100644 --- a/codeforlife/user/views/klass_test.py +++ b/codeforlife/user/views/klass_test.py @@ -118,8 +118,8 @@ def test_list__id_or_name(self): self.client.list( models=[ klass - for klass in Class.objects.only("name_enc") - if partial_name in klass.name.lower() + for klass in Class.objects.only("name") + if klass.name and partial_name in klass.name.lower() ], filters={"id_or_name": partial_name}, ) diff --git a/codeforlife/user/views/user_test.py b/codeforlife/user/views/user_test.py index 0d4d0b9e..6ba0c7d2 100644 --- a/codeforlife/user/views/user_test.py +++ b/codeforlife/user/views/user_test.py @@ -237,9 +237,9 @@ def test_list__name(self): pks = [ user.pk - for user in school_users.only("first_name_enc", "last_name_enc") - if first_name in user.first_name.lower() - or last_name in user.last_name.lower() + for user in school_users.only("_first_name", "last_name") + if (user.first_name and first_name in user.first_name.lower()) + or (user.last_name and last_name in user.last_name.lower()) ] self.client.login_as(user) From 0c78f95143d32efcccc27f55581037d6a3602ed8 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Tue, 17 Mar 2026 16:40:00 +0000 Subject: [PATCH 58/58] fix linting errors --- codeforlife/user/signals/user_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/codeforlife/user/signals/user_test.py b/codeforlife/user/signals/user_test.py index 4fb06938..364cdd25 100644 --- a/codeforlife/user/signals/user_test.py +++ b/codeforlife/user/signals/user_test.py @@ -7,6 +7,7 @@ from ..models import User +# pylint: disable-next=missing-class-docstring class UserSignalsTests(TestCase): fixtures = ["school_1"]