diff --git a/Pipfile b/Pipfile index 4343d9eb..a181edee 100644 --- a/Pipfile +++ b/Pipfile @@ -22,7 +22,7 @@ regex = "==2024.11.6" requests = "==2.32.5" gunicorn = "==23.0.0" uvicorn-worker = "==0.2.0" -pyjwt = "==2.6.0" # TODO: upgrade to latest version. +pyjwt = "==2.12.1" psutil = "==7.0.0" google-auth = "==2.48.0" google-cloud-bigquery = "==3.38.0" diff --git a/Pipfile.lock b/Pipfile.lock index 4fa76a71..81efb2f1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "881256f51efde6c35003f922bc9d2429805f3e85e20bc74b37d1aa3d5f223040" + "sha256": "1c8ee496fd06675f61b20126e851eba900dcd8cc574049553a7ea751922ec4a1" }, "pipfile-spec": 6, "requires": { @@ -192,122 +192,138 @@ }, "charset-normalizer": { "hashes": [ - "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", - "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", - "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", - "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", - "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", - "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", - "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", - "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", - "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", - "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", - "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", - "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", - "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", - "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", - "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", - "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", - "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", - "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", - "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", - "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", - "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", - "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", - "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", - "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", - "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", - "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", - "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", - "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", - "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", - "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", - "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", - "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", - "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", - "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", - "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", - "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", - "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", - "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", - "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", - "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", - "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", - "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", - "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", - "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", - "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", - "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", - "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", - "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", - "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", - "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", - "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", - "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", - "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", - "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", - "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", - "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", - "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", - "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", - "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", - "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", - "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", - "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", - "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", - "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", - "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", - "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", - "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", - "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", - "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", - "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", - "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", - "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", - "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", - "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", - "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", - "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", - "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", - "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", - "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", - "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", - "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", - "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", - "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", - "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", - "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", - "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", - "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", - "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", - "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", - "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", - "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", - "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", - "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", - "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", - "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", - "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", - "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", - "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", - "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", - "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", - "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", - "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", - "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", - "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", - "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", - "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", - "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", - "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", - "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", - "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", - "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", - "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", - "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" + "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", + "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", + "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", + "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", + "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", + "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", + "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", + "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", + "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", + "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8", + "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264", + "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", + "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", + "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", + "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", + "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", + "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa", + "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", + "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", + "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297", + "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", + "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e", + "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", + "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8", + "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", + "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", + "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", + "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", + "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", + "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", + "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7", + "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", + "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b", + "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", + "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687", + "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9", + "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14", + "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", + "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", + "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", + "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", + "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a", + "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", + "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", + "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", + "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", + "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", + "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", + "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", + "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532", + "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", + "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae", + "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", + "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64", + "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", + "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", + "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", + "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", + "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", + "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", + "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", + "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", + "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597", + "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", + "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", + "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", + "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54", + "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", + "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", + "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4", + "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", + "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", + "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", + "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", + "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", + "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", + "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", + "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", + "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", + "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", + "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", + "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", + "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", + "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", + "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", + "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", + "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc", + "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", + "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", + "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", + "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", + "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", + "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", + "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", + "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237", + "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", + "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778", + "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", + "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", + "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", + "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", + "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f", + "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5", + "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611", + "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", + "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", + "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", + "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", + "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", + "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e", + "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", + "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", + "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", + "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", + "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", + "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe", + "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", + "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17", + "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833", + "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", + "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", + "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", + "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2", + "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", + "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982", + "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", + "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", + "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104", + "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659" ], "markers": "python_version >= '3.7'", - "version": "==3.4.4" + "version": "==3.4.6" }, "click": { "hashes": [ @@ -556,11 +572,11 @@ "grpc" ], "hashes": [ - "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", - "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5" + "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", + "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8" ], "markers": "python_version >= '3.7'", - "version": "==1.72.0" + "version": "==1.73.0" }, "grpc-google-iam-v1": { "hashes": [ @@ -664,114 +680,114 @@ }, "hiredis": { "hashes": [ - "sha256:00fb04eac208cd575d14f246e74a468561081ce235937ab17d77cde73aefc66c", - "sha256:04ec150e95eea3de9ff8bac754978aa17b8bf30a86d4ab2689862020945396b0", - "sha256:087e2ef3206361281b1a658b5b4263572b6ba99465253e827796964208680459", - "sha256:0a5eebb170de1b415c78ae5ca3aee17cff8b885df93c2055d54320e789d838f4", - "sha256:0a95cef9989736ac313639f8f545b76b60b797e44e65834aabbb54e4fad8d6c8", - "sha256:0ef840d9f142556ed384180ed8cdf14ff875fcae55c980cbe5cec7adca2ef4d8", - "sha256:105596aad9249634361815c574351f1bd50455dc23b537c2940066c4a9dea685", - "sha256:106e99885d46684d62ab3ec1d6b01573cc0e0083ac295b11aaa56870b536c7ec", - "sha256:114c0b9f1b5fad99edae38e747018aead358a4f4e9720cc1876495d78cdb8276", - "sha256:1203697a7ebadc7cf873acc189df9e44fcb377b636e6660471707ac8d5bcba68", - "sha256:15edee02cc9cc06e07e2bcfae07e283e640cc1aeedd08b4c6934bf1a0113c607", - "sha256:161a4a595a53475587aef8dc549d0527962879b0c5d62f7947b44ba7e5084b76", - "sha256:1706480a683e328ae9ba5d704629dee2298e75016aa0207e7067b9c40cecc271", - "sha256:1799cc66353ad066bfdd410135c951959da9f16bcb757c845aab2f21fc4ef099", - "sha256:1bbc6b8a88bbe331e3ebf6685452cebca6dfe6d38a6d4efc5651d7e363ba28bd", - "sha256:1d00bce25c813eec45a2f524249f58daf51d38c9d3347f6f643ae53826fc735a", - "sha256:1f9a5f84a8bd29ac5b9953b27e8ba5508396afeabf1d165611a1e31fbd90a0e1", - "sha256:200678547ac3966bac3e38df188211fdc13d5f21509c23267e7def411710e112", - "sha256:2213c7eb8ad5267434891f3241c7776e3bafd92b5933fc57d53d4456247dc542", - "sha256:294de11e3995128c784534e327d1f9382b88dc5407356465df7934c710e8392d", - "sha256:298593bb08487753b3afe6dc38bac2532e9bac8dcee8d992ef9977d539cc6776", - "sha256:2cbf71a121996ffac82436b6153290815b746afb010cac19b3290a1644381b07", - "sha256:2f855c678230aed6fc29b962ce1cc67e5858a785ef3a3fd6b15dece0487a2e60", - "sha256:30a4df3d48f32538de50648d44146231dde5ad7f84f8f08818820f426840ae97", - "sha256:31eda3526e2065268a8f97fbe3d0e9a64ad26f1d89309e953c80885c511ea2ae", - "sha256:334a3f1d14c253bb092e187736c3384203bd486b244e726319bbb3f7dffa4a20", - "sha256:4016e50a8be5740a59c5af5252e5ad16c395021a999ad24c6604f0d9faf4d346", - "sha256:4059c78a930cbb33c391452ccce75b137d6f89e2eebf6273d75dafc5c2143c03", - "sha256:41aea51949142bad4e40badb0396392d7f4394791e4097a0951ab75bcc58ff84", - "sha256:45d14f745fc177bc05fc24bdf20e2b515e9a068d3d4cce90a0fb78d04c9c9d9a", - "sha256:4a3aab895358368f81f9546a7cd192b6fb427f785cb1a8853cf9db38df01e9ca", - "sha256:4c18a97ea55d1a58f5c3adfe236b3e7cccedc6735cbd36ab1c786c52fd823667", - "sha256:4d3b4e0d4445faf9041c52a98cb5d2b65c4fcaebb2aa02efa7c6517c4917f7e8", - "sha256:4ddc79afa76b805d364e202a754666cb3c4d9c85153cbfed522871ff55827838", - "sha256:50351b77f89ba6a22aff430b993653847f36b71d444509036baa0f2d79d1ebf4", - "sha256:50a54397bd104c2e2f5b7696bbdab8ba2973d3075e4deb932adb025b8863de91", - "sha256:538a9f5fbb3a8a4ef0c3abd309cccb90cd2ba9976fcc2b44193af9507d005b48", - "sha256:54b14211fbd5930fc696f6fcd1f1f364c660970d61af065a80e48a1fa5464dd6", - "sha256:550f4d1538822fc75ebf8cf63adc396b23d4958bdbbad424521f2c0e3dfcb169", - "sha256:55d8c18fe9a05496c5c04e6eccc695169d89bf358dff964bcad95696958ec05f", - "sha256:5b8e1d6a2277ec5b82af5dce11534d3ed5dffeb131fd9b210bc1940643b39b5f", - "sha256:60814a7d0b718adf3bfe2c32c6878b0e00d6ae290ad8e47f60d7bba3941234a6", - "sha256:616868352e47ab355559adca30f4f3859f9db895b4e7bc71e2323409a2add751", - "sha256:63ee6c1ae6a2462a2439eb93c38ab0315cd5f4b6d769c6a34903058ba538b5d6", - "sha256:69079fb0f0ebb61ba63340b9c4bce9388ad016092ca157e5772eb2818209d930", - "sha256:7165c7363e59b258e1875c51f35c0b2b9901e6c691037b487d8a0ace2c137ed2", - "sha256:73679607c5a19f4bcfc9cf6eb54480bcd26617b68708ac8b1079da9721be5449", - "sha256:749faa69b1ce1f741f5eaf743435ac261a9262e2d2d66089192477e7708a9abc", - "sha256:76374faa075e996c895cbe106ba923852a9f8146f2aa59eba22111c5e5ec6316", - "sha256:776dc5769d5eb05e969216de095377ff61c802414a74bd3c24a4ca8526c897ab", - "sha256:77eacd969e3c6ff50c2b078c27d2a773c652248a5d81af5765a8663478d0bc02", - "sha256:7b41833c8f0d4c7fbfaa867c8ed9a4e4aaa71d7c54e4806ed62da2d5cd27b40d", - "sha256:80638ebeab1cefda9420e9fedc7920e1ec7b4f0513a6b23d58c9d13c882f8065", - "sha256:85b9baf98050e8f43c2826ab46aaf775090d608217baf7af7882596aef74e7f9", - "sha256:88bc79d7e9b94d17ed1bd8b7f2815ed0eada376ed5f48751044e5e4d179aa2f2", - "sha256:8c3be446f0c38fbe6863a7cf4522c9a463df6e64bee87c4402e9f6d7d2e7f869", - "sha256:8e8a4b8540581dcd1b2b25827a54cfd538e0afeaa1a0e3ca87ad7126965981cc", - "sha256:8f88f4f2aceb73329ece86a1cb0794fdbc8e6d614cb5ca2d1023c9b7eb432db8", - "sha256:95c9427f2ac3f1dd016a3da4e1161fa9d82f221346c8f3fdd6f3f77d4e28946c", - "sha256:96f9a27643279853b91a1fb94a88b559e55fdecec86f1fcd5f2561492be52e47", - "sha256:9937d9b69321b393fbace69f55423480f098120bc55a3316e1ca3508c4dbbd6f", - "sha256:9a7ea2344d277317160da4911f885bcf7dfd8381b830d76b442f7775b41544b3", - "sha256:9bd7c9a089cf4e4f4b5a61f412c76293449bac6b0bf92bb49a3892850bd5c899", - "sha256:9ecd9b09b11bd0b8af87d29c3f5da628d2bdc2a6c23d2dd264d2da082bd4bf32", - "sha256:9ef1dfb0d2c92c3701655e2927e6bbe10c499aba632c7ea57b6392516df3864b", - "sha256:a0d31ff178b913137a7a08c7377e93805914755a15c3585e203d0d74496456c0", - "sha256:a172bae3e2837d74530cd60b06b141005075db1b814d966755977c69bd882ce8", - "sha256:a1a67530da714954ed50579f4fe1ab0ddbac9c43643b1721c2cb226a50dde263", - "sha256:a26bae1b61b7bcafe3d0d0c7d012fb66ab3c95f2121dbea336df67e344e39089", - "sha256:a5f9fde56550ebbe962f437a4c982b0856d03aea7fab09e30fa6c0f9be992b40", - "sha256:a68aaf9ba024f4e28cf23df9196ff4e897bd7085872f3a30644dca07fa787816", - "sha256:a7cbbc6026bf03659f0b25e94bbf6e64f6c8c22f7b4bc52fe569d041de274194", - "sha256:a8def89dd19d4e2e4482b7412d453dec4a5898954d9a210d7d05f60576cedef6", - "sha256:ae327fc13b1157b694d53f92d50920c0051e30b0c245f980a7036e299d039ab4", - "sha256:b0fb4bea72fe45ff13e93ddd1352b43ff0749f9866263b5cca759a4c960c776f", - "sha256:b442b6ab038a6f3b5109874d2514c4edf389d8d8b553f10f12654548808683bc", - "sha256:b7048b4ec0d5dddc8ddd03da603de0c4b43ef2540bf6e4c54f47d23e3480a4fa", - "sha256:b9546079f7fd5c50fbff9c791710049b32eebe7f9b94debec1e8b9f4c048cba2", - "sha256:ba063fdf1eff6377a0c409609cbe890389aefddfec109c2d20fcc19cfdafe9da", - "sha256:bcd745a28e1b3216e42680d91e142a42569dfad68a6f40535080c47b0356c796", - "sha256:bdb7cd9e1e73db78f145a09bb837732790d0912eb963dee5768631faf2ece162", - "sha256:c135bda87211f7af9e2fd4e046ab433c576cd17b69e639a0f5bb2eed5e0e71a9", - "sha256:c17b473f273465a3d2168a57a5b43846165105ac217d5652a005e14068589ddc", - "sha256:c17f77b79031ea4b0967d30255d2ae6e7df0603ee2426ad3274067f406938236", - "sha256:c290da6bc2a57e854c7da9956cd65013483ede935677e84560da3b848f253596", - "sha256:c4981de4d335f996822419e8a8b3b87367fcef67dc5fb74d3bff4df9f6f17783", - "sha256:c567aab02612d91f3e747fc492100ae894515194f85d6fb6bb68958c0e718721", - "sha256:c6d91a5e6904ed7eca21d74b041e03f2ad598dd08a6065b06a776974fe5d003c", - "sha256:c863ee44fe7bff25e41f3a5105c936a63938b76299b802d758f40994ab340071", - "sha256:c9e96f63dbc489fc86f69951e9f83dadb9582271f64f6822c47dcffa6fac7e4a", - "sha256:ca2802934557ccc28a954414c245ba7ad904718e9712cb67c05152cf6b9dd0a3", - "sha256:ca97c5e6f9e9b9f0aed61b70fed2d594ce2f7472905077d2d10b307c50a41008", - "sha256:cb91363b9fd6d41c80df9795e12fffbaf5c399819e6ae8120f414dedce6de068", - "sha256:dd9d78c5363a858f9dc5e698e5e1e402b83c00226cba294f977a92c53092b549", - "sha256:e5f86ce5a779319c15567b79e0be806e8e92c18bb2ea9153e136312fafa4b7d6", - "sha256:e799b79f3150083e9702fc37e6243c0bd47a443d6eae3f3077b0b3f510d6a145", - "sha256:eaf8418e33e23d6d7ef0128eff4c06ab3040d40b9bbc8a24d6265d751a472596", - "sha256:f7f80442a32ce51ee5d89aeb5a84ee56189a0e0e875f1a57bbf8d462555ae48f", - "sha256:fbdb97a942e66016fff034df48a7a184e2b7dc69f14c4acd20772e156f20d04b", - "sha256:fcbd1a15e935aa323b5b2534b38419511b7909b4b8ee548e42b59090a1b37bb1", - "sha256:fd137b147235447b3d067ec952c5b9b95ca54b71837e1b38dbb2ec03b89f24fc", - "sha256:fd8c438d9e1728f0085bf9b3c9484d19ec31f41002311464e75b69550c32ffa8", - "sha256:fe730716775f61e76d75810a38ee4c349d3af3896450f1525f5a4034cf8f2ed7", - "sha256:ff3179a57745d0f8d71fa8bf3ea3944d3f557dcfa4431304497987fecad381dd", - "sha256:ffea6c407cff532c7599d3ec9e8502c2c865753cebab044f3dfce9afbf71a8df" + "sha256:002fc0201b9af1cc8960e27cdc501ad1f8cdd6dbadb2091c6ddbd4e5ace6cb77", + "sha256:011a9071c3df4885cac7f58a2623feac6c8e2ad30e6ba93c55195af05ce61ff5", + "sha256:01cf82a514bc4fd145b99333c28523e61b7a9ad051a245804323ebf4e7b1c6a6", + "sha256:027ce4fabfeff5af5b9869d5524770877f9061d118bc36b85703ae3faf5aad8e", + "sha256:03baa381964b8df356d19ec4e3a6ae656044249a87b0def257fe1e08dbaf6094", + "sha256:042e57de8a2cae91e3e7c0af32960ea2c5107b2f27f68a740295861e68780a8a", + "sha256:09d41a3a965f7c261223d516ebda607aee4d8440dd7637f01af9a4c05872f0c4", + "sha256:09f5e510f637f2c72d2a79fb3ad05f7b6211e057e367ca5c4f97bb3d8c9d71f4", + "sha256:0b5ff2f643f4b452b0597b7fe6aa35d398cb31d8806801acfafb1558610ea2aa", + "sha256:0caf3fc8af0767794b335753781c3fa35f2a3e975c098edbc8f733d35d6a95e4", + "sha256:0fac4af8515e6cca74fc701169ae4dc9a71a90e9319c9d21006ec9454b43aa2f", + "sha256:113e098e4a6b3cc5500e05e7cb1548ba9e83de5fe755941b11f6020a76e6c03a", + "sha256:137c14905ea6f2933967200bc7b2a0c8ec9387888b273fd0004f25b994fd0343", + "sha256:156be6a0c736ee145cfe0fb155d0e96cec8d4872cf8b4f76ad6a2ee6ab391d0a", + "sha256:17ec8b524055a88b80d76c177dbbbe475a25c17c5bf4b67bdbdbd0629bcae838", + "sha256:1ac7697365dbe45109273b34227fee6826b276ead9a4a007e0877e1d3f0fcf21", + "sha256:1b46e96b50dad03495447860510daebd2c96fd44ed25ba8ccb03e9f89eaa9d34", + "sha256:1ebc307a87b099d0877dbd2bdc0bae427258e7ec67f60a951e89027f8dc2568f", + "sha256:1f7bceb03a1b934872ffe3942eaeed7c7e09096e67b53f095b81f39c7a819113", + "sha256:2611bfaaadc5e8d43fb7967f9bbf1110c8beaa83aee2f2d812c76f11cfb56c6a", + "sha256:264ee7e9cb6c30dc78da4ecf71d74cf14ca122817c665d838eda8b4384bce1b0", + "sha256:26f899cde0279e4b7d370716ff80320601c2bd93cdf3e774a42bdd44f65b41f8", + "sha256:29fe35e3c6fe03204e75c86514f452591957a1e06b05d86e10d795455b71c355", + "sha256:2afc675b831f7552da41116fffffca4340f387dc03f56d6ec0c7895ab0b59a10", + "sha256:2b6da6e07359107c653a809b3cff2d9ccaeedbafe33c6f16434aef6f53ce4a2b", + "sha256:2b96da7e365d6488d2a75266a662cbe3cc14b28c23dd9b0c9aa04b5bc5c20192", + "sha256:2f1c1b2e8f00b71e6214234d313f655a3a27cd4384b054126ce04073c1d47045", + "sha256:304481241e081bc26f0778b2c2b99f9c43917e4e724a016dcc9439b7ab12c726", + "sha256:318f772dd321404075d406825266e574ee0f4751be1831424c2ebd5722609398", + "sha256:3586c8a5f56d34b9dddaaa9e76905f31933cac267251006adf86ec0eef7d0400", + "sha256:3724f0e58c6ff76fd683429945491de71324ab1bc0ad943a8d68cb0932d24075", + "sha256:3fb6573efa15a29c12c0c0f7170b14e7c1347fe4bb39b6a15b779f46015cc929", + "sha256:40ae8a7041fcb328a6bc7202d8c4e6e0d38d434b2e3880b1ee8ed754f17cd836", + "sha256:4106201cd052d9eabe3cb7b5a24b0fe37307792bda4fcb3cf6ddd72f697828e8", + "sha256:439f9a5cc8f9519ce208a24cdebfa0440fef26aa682a40ba2c92acb10a53f5e0", + "sha256:4479e36d263251dba8ab8ea81adf07e7f1163603c7102c5de1e130b83b4fad3b", + "sha256:487658e1db83c1ee9fbbac6a43039ea76957767a5987ffb16b590613f9e68297", + "sha256:48ff424f8aa36aacd9fdaa68efeb27d2e8771f293af4305bdb15d92194ca6631", + "sha256:4f7e242eab698ad0be5a4b2ec616fa856569c57455cc67c625fd567726290e5f", + "sha256:526db52e5234a9463520e960a509d6c1bd5128d1ab1b569cbf459fe39189e8ab", + "sha256:52d5641027d6731bc7b5e7d126a5158a99784a9f8c6de3d97ca89aca4969e9f8", + "sha256:53148a4e21057541b6d8e493b2ea1b500037ddf34433c391970036f3cbce00e3", + "sha256:583de2f16528e66081cbdfe510d8488c2de73039dc00aada7d22bd49d73a4a94", + "sha256:5e55d90b431b0c6b64ae5a624208d4aea318566d31872e595ee723c0f5b9a79f", + "sha256:5f316cf2d0558f5027aab19dde7d7e4901c26c21fa95367bc37784e8f547bbf2", + "sha256:60543f3b068b16a86e99ed96b7fdae71cdc1d8abdfe9b3f82032a555e52ece7e", + "sha256:62cc62284541bb2a86c898c7d5e8388661cade91c184cb862095ed547e80588f", + "sha256:65c05b79cb8366c123357b354a16f9fc3f7187159422f143638d1c26b7240ed4", + "sha256:65f6ac06a9f0c32c254660ec6a9329d81d589e8f5d0a9837a941d5424a6be1ef", + "sha256:6d1434d0bcc1b3ef048bae53f26456405c08aeed9827e65b24094f5f3a6793f1", + "sha256:6e2e1024f0a021777740cb7c633a0efb2c4a4bc570f508223a8dcbcf79f99ef9", + "sha256:6ffa7ba2e2da1f806f3181b9730b3e87ba9dbfec884806725d4584055ba3faa6", + "sha256:743b85bd6902856cac457ddd8cd7dd48c89c47d641b6016ff5e4d015bfbd4799", + "sha256:77c5d2bebbc9d06691abb512a31d0f54e1562af0b872891463a67a949b5278ef", + "sha256:79cd03e7ff550c17758a7520bf437c156d3d4c8bb74214deeafa69cda49c85a4", + "sha256:80aba5f85d6227faee628ae28d1c3b69c661806a0636548ac56c68782606454f", + "sha256:81a1669b6631976b1dc9d3d58ed1ab3333e9f52feb91a2a1fb8241101ac3b665", + "sha256:8597c35c9e82f65fd5897c4a2188c65d7daf10607b102960137b23d261cd957b", + "sha256:8650158217b469d8b6087f490929211b0493a9121154c4efaafd1dec9e19319e", + "sha256:8887bf0f31e4b550bd988c8863b527b6587d200653e9375cd91eea2b944b7424", + "sha256:8a52b24cd710690c4a7e191c7e300136ad2ecb3c68ffe7e95b598e76de166e5e", + "sha256:8e3754ce60e1b11b0afad9a053481ff184d2ee24bea47099107156d1b84a84aa", + "sha256:907f7b5501a534030738f0f27459a612d2266fd0507b007bb8f3e6de08167920", + "sha256:90d6b9f2652303aefd2c5a26a5e14cb74a3a63d10faa642c08d790e99442a088", + "sha256:98fd5b39410e9d69e10e90d0330e35650becaa5dd2548f509b9598f1f3c6124d", + "sha256:9bfdeff778d3f7ff449ca5922ab773899e7d31e26a576028b06a5e9cf0ed8c34", + "sha256:9ebae74ce2b977c2fcb22d6a10aa0acb730022406977b2bcb6ddd6788f5c414a", + "sha256:a110d19881ca78a88583d3b07231e7c6864864f5f1f3491b638863ea45fa8708", + "sha256:a1d190790ee39b8b7adeeb10fc4090dc4859eb4e75ed27bd8108710eef18f358", + "sha256:a2f049c3f3c83e886cd1f53958e2a1ebb369be626bef9e50d8b24d79864f1df6", + "sha256:a3af4e9f277d6b8acd369dc44a723a055752fca9d045094383af39f90a3e3729", + "sha256:a42c7becd4c9ec4ab5769c754eb61112777bdc6e1c1525e2077389e193b5f5aa", + "sha256:a58a58cef0d911b1717154179a9ff47852249c536ea5966bde4370b6b20638ff", + "sha256:ab1f646ff531d70bfd25f01e60708dfa3d105eb458b7dedd9fe9a443039fd809", + "sha256:ad940dc2db545dc978cb41cb9a683e2ff328f3ef581230b9ca40ff6c3d01d542", + "sha256:afe3c3863f16704fb5d7c2c6ff56aaf9e054f6d269f7b4c9074c5476178d1aba", + "sha256:b1e3b9f4bf9a4120510ba77a77b2fb674893cd6795653545152bb11a79eecfcb", + "sha256:b2390ad81c03d93ef1d5afd18ffcf5935de827f1a2b96b2c829437968bdabccb", + "sha256:b37df4b10cb15dedfc203f69312d8eedd617b941c21df58c13af59496c53ad0f", + "sha256:b3df9447f9209f9aa0434ca74050e9509670c1ad99398fe5807abb90e5f3a014", + "sha256:b4fe7f38aa8956fcc1cea270e62601e0e11066aff78e384be70fd283d30293b6", + "sha256:c1d68c6980d4690a4550bd3db6c03146f7be68ef5d08d38bb1fb68b3e9c32fe3", + "sha256:c24c1460486b6b36083252c2db21a814becf8495ccd0e76b7286623e37239b63", + "sha256:c25132902d3eff38781e0d54f27a0942ec849e3c07dbdce83c4d92b7e43c8dce", + "sha256:c74bd9926954e7e575f9cd9890f63defd90cd8f812dfbf8e1efb72acc9355456", + "sha256:c8139e9011117822391c5bcfd674c5948fb1e4b8cb9adf6f13d9890859ee3a1a", + "sha256:ce334915f5d31048f76a42c607bf26687cf045eb1bc852b7340f09729c6a64fc", + "sha256:d14229beaa76e66c3a25f9477d973336441ca820df853679a98796256813316f", + "sha256:d42f3a13290f89191568fc113d95a3d2c8759cdd8c3672f021d8b7436f909e75", + "sha256:d8e56e0d1fe607bfff422633f313aec9191c3859ab99d11ff097e3e6e068000c", + "sha256:da6f0302360e99d32bc2869772692797ebadd536e1b826d0103c72ba49d38698", + "sha256:db46baf157feefd88724e6a7f145fe996a5990a8604ed9292b45d563360e513b", + "sha256:dcea8c3f53674ae68e44b12e853b844a1d315250ca6677b11ec0c06aff85e86c", + "sha256:de94b409f49eb6a588ebdd5872e826caec417cd77c17af0fb94f2128427f1a2a", + "sha256:e0356561b4a97c83b9ee3de657a41b8d1a1781226853adaf47b550bb988fda6f", + "sha256:e0db44cf81e4d7b94f3776b9f89111f74ed6bbdbfd42a22bc4a5ce0644d3e060", + "sha256:e31e92b61d56244047ad600812e16f7587a6172f74810fd919ff993af12b9149", + "sha256:e89dabf436ee79b358fd970dcbed6333a36d91db73f27069ca24a02fb138a404", + "sha256:eddeb9a153795cf6e615f9f3cef66a1d573ff3b6ee16df2b10d1d1c2f2baeaa8", + "sha256:ee11fd431f83d8a5b29d370b9d79a814d3218d30113bdcd44657e9bdf715fc92", + "sha256:ee37fe8cf081b72dea72f96a0ee604f492ec02252eb77dc26ff6eec3f997b580", + "sha256:f19ee7dc1ef8a6497570d91fa4057ba910ad98297a50b8c44ff37589f7c89d17", + "sha256:f2f94355affd51088f57f8674b0e294704c3c7c3d7d3b1545310f5b135d4843b", + "sha256:f525734382a47f9828c9d6a1501522c78d5935466d8e2be1a41ba40ca5bb922b", + "sha256:f915a34fb742e23d0d61573349aa45d6f74037fde9d58a9f340435eff8d62736" ], "markers": "python_version >= '3.8'", - "version": "==3.3.0" + "version": "==3.3.1" }, "idna": { "hashes": [ @@ -938,11 +954,11 @@ }, "pyasn1": { "hashes": [ - "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", - "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b" + "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", + "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde" ], "markers": "python_version >= '3.8'", - "version": "==0.6.2" + "version": "==0.6.3" }, "pyasn1-modules": { "hashes": [ @@ -1011,12 +1027,12 @@ }, "pyjwt": { "hashes": [ - "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd", - "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14" + "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", + "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.6.0" + "markers": "python_version >= '3.9'", + "version": "==2.12.1" }, "pyotp": { "hashes": [ @@ -1253,11 +1269,11 @@ }, "uvicorn": { "hashes": [ - "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", - "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187" + "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", + "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775" ], "markers": "python_version >= '3.10'", - "version": "==0.41.0" + "version": "==0.42.0" }, "uvicorn-worker": { "hashes": [ @@ -1369,122 +1385,138 @@ }, "charset-normalizer": { "hashes": [ - "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", - "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", - "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", - "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", - "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", - "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", - "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", - "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", - "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", - "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", - "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", - "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", - "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", - "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", - "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", - "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", - "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", - "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", - "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", - "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", - "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", - "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", - "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", - "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", - "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", - "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", - "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", - "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", - "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", - "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", - "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", - "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", - "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", - "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", - "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", - "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", - "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", - "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", - "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", - "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", - "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", - "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", - "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", - "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", - "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", - "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", - "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", - "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", - "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", - "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", - "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", - "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", - "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", - "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", - "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", - "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", - "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", - "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", - "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", - "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", - "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", - "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", - "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", - "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", - "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", - "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", - "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", - "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", - "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", - "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", - "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", - "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", - "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", - "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", - "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", - "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", - "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", - "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", - "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", - "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", - "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", - "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", - "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", - "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", - "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", - "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", - "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", - "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", - "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", - "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", - "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", - "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", - "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", - "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", - "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", - "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", - "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", - "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", - "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", - "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", - "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", - "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", - "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", - "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", - "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", - "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", - "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", - "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", - "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", - "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", - "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", - "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", - "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" + "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", + "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", + "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", + "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", + "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", + "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", + "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", + "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", + "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", + "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8", + "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264", + "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", + "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", + "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", + "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", + "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", + "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa", + "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", + "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", + "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297", + "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", + "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e", + "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", + "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8", + "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", + "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", + "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", + "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", + "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", + "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", + "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7", + "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", + "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b", + "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", + "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687", + "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9", + "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14", + "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", + "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", + "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", + "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", + "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a", + "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", + "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", + "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", + "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", + "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", + "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", + "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", + "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532", + "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", + "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae", + "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", + "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64", + "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", + "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", + "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", + "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", + "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", + "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", + "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", + "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", + "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597", + "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", + "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", + "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", + "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54", + "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", + "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", + "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4", + "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", + "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", + "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", + "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", + "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", + "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", + "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", + "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", + "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", + "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", + "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", + "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", + "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", + "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", + "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", + "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", + "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc", + "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", + "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", + "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", + "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", + "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", + "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", + "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", + "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237", + "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", + "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778", + "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", + "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", + "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", + "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", + "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f", + "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5", + "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611", + "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", + "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", + "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", + "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", + "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", + "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e", + "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", + "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", + "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", + "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", + "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", + "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe", + "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", + "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17", + "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833", + "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", + "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", + "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", + "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2", + "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", + "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982", + "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", + "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", + "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104", + "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659" ], "markers": "python_version >= '3.7'", - "version": "==3.4.4" + "version": "==3.4.6" }, "click": { "hashes": [ @@ -1499,115 +1531,115 @@ "toml" ], "hashes": [ - "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" + "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", + "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", + "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", + "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", + "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", + "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", + "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", + "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", + "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", + "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", + "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", + "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", + "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", + "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", + "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", + "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", + "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", + "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", + "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", + "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", + "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", + "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", + "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", + "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", + "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", + "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", + "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", + "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", + "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", + "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", + "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", + "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", + "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", + "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", + "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", + "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", + "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", + "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", + "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", + "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", + "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", + "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", + "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", + "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", + "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", + "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", + "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", + "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", + "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", + "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", + "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", + "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", + "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", + "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", + "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", + "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", + "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", + "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", + "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", + "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", + "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", + "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", + "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", + "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", + "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", + "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", + "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", + "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", + "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", + "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", + "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", + "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", + "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", + "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", + "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", + "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", + "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", + "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", + "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", + "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", + "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", + "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", + "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", + "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", + "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", + "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", + "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", + "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", + "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", + "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", + "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", + "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", + "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", + "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", + "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", + "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", + "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", + "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", + "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", + "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", + "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", + "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", + "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", + "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", + "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", + "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f" ], "markers": "python_version >= '3.10'", - "version": "==7.13.4" + "version": "==7.13.5" }, "dill": { "hashes": [ @@ -1648,11 +1680,11 @@ }, "django-stubs-ext": { "hashes": [ - "sha256:230c51575551b0165be40177f0f6805f1e3ebf799b835c85f5d64c371ca6cf71", - "sha256:6db4054d1580657b979b7d391474719f1a978773e66c7070a5e246cd445a25a9" + "sha256:6f8c29e0dd5111fd36aa72519446c8a21c3e419e48c5d7dc7f418c8eec9c43ae", + "sha256:fb860210b496e75ae751cadee02a3449d5a7599de68c8db9df40c84e559d9298" ], "markers": "python_version >= '3.10'", - "version": "==5.2.9" + "version": "==6.0.0" }, "django-test-migrations": { "hashes": [ @@ -1836,11 +1868,11 @@ }, "platformdirs": { "hashes": [ - "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", - "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291" + "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", + "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868" ], "markers": "python_version >= '3.10'", - "version": "==4.9.2" + "version": "==4.9.4" }, "pluggy": { "hashes": [ @@ -1984,20 +2016,20 @@ }, "types-awscrt": { "hashes": [ - "sha256:3d6a29c1cca894b191be408f4d985a8e3a14d919785652dd3fa4ee558143e4bf", - "sha256:dc79705acd24094656b8105b8d799d7e273c8eac37c69137df580cd84beb54f6" + "sha256:09d3eaf00231e0f47e101bd9867e430873bc57040050e2a3bd8305cb4fc30865", + "sha256:e5ce65a00a2ab4f35eacc1e3d700d792338d56e4823ee7b4dbe017f94cfc4458" ], "markers": "python_version >= '3.8'", - "version": "==0.31.2" + "version": "==0.31.3" }, "types-cachetools": { "hashes": [ - "sha256:698eb17b8f16b661b90624708b6915f33dbac2d185db499ed57e4997e7962cad", - "sha256:f1d3c736f0f741e89ec10f0e1b0138625023e21eb33603a930c149e0318c0cef" + "sha256:6d91855bcc944665897c125e720aa3c80aace929b77a64e796343701df4f61c6", + "sha256:92fa9bc50e4629e31fca67ceb3fb1de71791e314fa16c0a0d2728724dc222c8b" ], "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==6.2.0.20251022" + "markers": "python_version >= '3.10'", + "version": "==6.2.0.20260317" }, "types-psutil": { "hashes": [ diff --git a/codeforlife/__init__.py b/codeforlife/__init__.py index e678a3c7..d8cb4996 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 @@ -113,20 +112,7 @@ def set_up_settings(service_base_dir: Path, service_name: str): 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")) - ) + # TODO: load secrets from bucket in non-local environments. + secrets = {} return Secrets(**secrets) 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/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/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/__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/base_encrypted.py b/codeforlife/models/fields/base_encrypted.py index 621ddf36..5cb82d62 100644 --- a/codeforlife/models/fields/base_encrypted.py +++ b/codeforlife/models/fields/base_encrypted.py @@ -51,15 +51,18 @@ 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 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") +Ciphertext: t.TypeAlias = t.Union[bytes, memoryview] @dataclass(frozen=True) @@ -73,7 +76,14 @@ class _PendingEncryption(t.Generic[T]): class _TrustedCiphertext: """A wrapper for ciphertext that comes directly from the database.""" - ciphertext: bytes + class Source(IntEnum): + """The source of the ciphertext.""" + + DB = auto() + FIXTURE = auto() + + ciphertext: Ciphertext + source: Source Value: t.TypeAlias = t.Union[_TrustedCiphertext, _PendingEncryption[T]] @@ -108,15 +118,27 @@ 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( 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. @@ -152,7 +174,9 @@ def __set__( "Expected bytes in memoryview for encrypted field.", code="invalid_memoryview_type", ) - internal_value = _TrustedCiphertext(value.obj) + internal_value = _TrustedCiphertext( + value, _TrustedCiphertext.Source.FIXTURE + ) elif isinstance(value, _TrustedCiphertext): # From DB. internal_value = value else: # From user input. @@ -173,17 +197,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 +205,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 @@ -215,8 +227,8 @@ 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 not is_real_model_class(cls): return # Ensure the model subclasses EncryptedModel. @@ -271,8 +283,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 @@ -282,7 +293,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/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 f4e71610..7bef10ea 100644 --- a/codeforlife/models/fields/data_encryption_key.py +++ b/codeforlife/models/fields/data_encryption_key.py @@ -25,10 +25,15 @@ from django.db.models import BinaryField from django.utils.translation import gettext_lazy as _ -from ...types import KwArgs +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 + from django_stubs_ext import StrOrPromise + + AnyDataEncryptionKeyField = t.TypeVar( "AnyDataEncryptionKeyField", bound="DataEncryptionKeyField" ) @@ -54,7 +59,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: @@ -64,6 +71,17 @@ 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): + internal_value = bytes(value) + if not internal_value.startswith(FakeAead.ciphertext_prefix): + raise ValidationError( + "Memoryview is expected to start with the fake ciphertext " + "prefix.", + code="memoryview_invalid_prefix", + ) else: raise ValidationError( "DataEncryptionKeyField can only be set to None.", @@ -91,15 +109,17 @@ class DataEncryptionKeyField(BinaryField): "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): - if kwargs.get("editable", False): + 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"] = _(default_verbose_name), + help_text: "StrOrPromise" = _(default_help_text), + **kwargs, + ): + if editable: raise ValidationError( "DataEncryptionKeyField cannot be editable.", code="editable_not_allowed", @@ -109,20 +129,20 @@ def __init__(self, **kwargs): "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.", 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 @@ -131,8 +151,8 @@ 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 not is_real_model_class(cls): return # Ensure the model subclasses BaseDataEncryptionKeyModel. diff --git a/codeforlife/models/fields/data_encryption_key_test.py b/codeforlife/models/fields/data_encryption_key_test.py index 42d8c3b8..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.""" @@ -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/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/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/permissions/auth_header_is_github_oidc_token.py b/codeforlife/permissions/auth_header_is_github_oidc_token.py index 12980dff..5751785b 100644 --- a/codeforlife/permissions/auth_header_is_github_oidc_token.py +++ b/codeforlife/permissions/auth_header_is_github_oidc_token.py @@ -11,7 +11,7 @@ import requests from django.conf import settings from django.utils import timezone -from jwt.algorithms import RSAAlgorithm +from jwt.types import JWKDict, Options from ..types import JsonDict from .base import BasePermission @@ -79,7 +79,7 @@ def _decode_token(self, token: str): header = jwt.get_unverified_header(token) kid = header.get("kid") - jwk: t.Optional[JsonDict] = None + jwk: t.Optional[JWKDict] = None for _jwk in jwks: if _jwk.get("kid") == kid: jwk = _jwk @@ -91,11 +91,11 @@ def _decode_token(self, token: str): return jwt.decode( token, - key=RSAAlgorithm.from_jwk(jwk), + key=jwt.PyJWK.from_dict(jwk), algorithms=["RS256", "RS384", "RS512"], audience=settings.SERVICE_DOMAIN, issuer=self.issuer, - options={"require_exp": True, "verify_signature": True}, + options=Options(require=["exp"], verify_signature=True), ) except jwt.exceptions.ExpiredSignatureError: diff --git a/codeforlife/pprint/__init__.py b/codeforlife/pprint/__init__.py new file mode 100644 index 00000000..ca55bfaa --- /dev/null +++ b/codeforlife/pprint/__init__.py @@ -0,0 +1,8 @@ +""" +© Ocado Group +Created on 18/03/2026 at 14:37:45(+00:00). +""" + +from .ansi import ANSI +from .pretty_printer import PrettyPrinter, pprint +from .style import Style diff --git a/codeforlife/pprint/ansi.py b/codeforlife/pprint/ansi.py new file mode 100644 index 00000000..4c8c53db --- /dev/null +++ b/codeforlife/pprint/ansi.py @@ -0,0 +1,27 @@ +""" +© Ocado Group +Created on 18/03/2026 at 14:37:45(+00:00). +""" + +from enum import Enum + + +class ANSI(Enum): + """ANSI escape codes for styling terminal output.""" + + 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" diff --git a/codeforlife/pprint/pretty_printer.py b/codeforlife/pprint/pretty_printer.py new file mode 100644 index 00000000..013219ae --- /dev/null +++ b/codeforlife/pprint/pretty_printer.py @@ -0,0 +1,149 @@ +""" +© Ocado Group +Created on 18/03/2026 at 14:37:45(+00:00). +""" + +import os +import typing as t +from timeit import default_timer + +from .ansi import ANSI +from .style import Style + + +# pylint: disable-next=too-many-instance-attributes +class PrettyPrinter: + """A utility class for pretty-printing styled messages to the terminal.""" + + def __init__(self, write: Style.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 + + self.style = Style.with_write(self.__call__) + + # Black and white. + self.black = self.style.ansi(ANSI.BLACK) + self.white = self.style.ansi(ANSI.WHITE) + + # Red, green, and blue. + self.red = self.style.ansi(ANSI.RED) + self.green = self.style.ansi(ANSI.GREEN) + self.blue = self.style.ansi(ANSI.BLUE) + + # Cyan, magenta, and yellow. + self.cyan = self.style.ansi(ANSI.CYAN) + self.magenta = self.style.ansi(ANSI.MAGENTA) + self.yellow = self.style.ansi(ANSI.YELLOW) + + # Common text styles. + self.bold = self.style.ansi(ANSI.BOLD) + self.underline = self.style.ansi(ANSI.UNDERLINE) + self.overline = self.style.ansi(ANSI.OVERLINE) + + # Status styles. + self.success = self.style.combine(self.green, self.bold) + self.error = self.style.combine(self.red, self.bold) + self.warn = self.warning = self.style.combine(self.yellow, self.bold) + self.info = self.notice = self.style.combine(self.blue, self.bold) + + # Heading styles. + self.h1 = self.style( + lambda message, **kwargs: "\n".join( + [ + self.bold.apply(self.divider("="), **kwargs), + self.bold.apply(message, **kwargs), + self.bold.apply(self.divider("="), **kwargs), + ] + ) + ) + self.h2 = self.style( + lambda message, **kwargs: "\n".join( + [ + self.bold.apply(self.divider("-"), **kwargs), + self.bold.apply(message, **kwargs), + self.bold.apply(self.divider("-"), **kwargs), + ] + ) + ) + self.h3 = self.style.combine(self.overline, self.underline, self.bold) + + 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( + self.bold.apply(f"{self.name} ") + + (self.error.apply("✘") if exc_type else self.success.apply("✔")) + + self.bold.apply(f" ({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): + # 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. + """ + # pylint: enable=line-too-long + try: + columns = os.get_terminal_size().columns + except OSError: + columns = default_columns + + return char * columns + + def indent(self, count: int, spaces=4, char=" "): + """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. + """ + return char * count * spaces + + +pprint = PrettyPrinter(write=print, name="main") diff --git a/codeforlife/pprint/style.py b/codeforlife/pprint/style.py new file mode 100644 index 00000000..79d27765 --- /dev/null +++ b/codeforlife/pprint/style.py @@ -0,0 +1,89 @@ +""" +© Ocado Group +Created on 18/03/2026 at 14:37:45(+00:00). +""" + +import typing as t + +from .ansi import ANSI + +if t.TYPE_CHECKING: + from typing_extensions import Protocol + + # pylint: disable-next=too-few-public-methods + class ApplyProtocol(Protocol): + """A protocol for a callable that applies a style to a message.""" + + def __call__(self, message: str, **kwargs) -> str: ... + + +class Style: + """A callable class that applies styles to messages.""" + + Apply: t.TypeAlias = "ApplyProtocol" + Write: t.TypeAlias = t.Callable[[str], None] + + def __init__(self, apply: Apply, write: Write = print): + self.apply = apply + self.write = write + + def __call__(self, message: str, *args, **kwargs): + styled_message = self.apply(message) + self.write(styled_message, *args, **kwargs) + + @classmethod + def ansi(cls, code: ANSI): + """Create a style that applies the given ANSI code to messages. + + Args: + code: The ANSI code to apply. + + Returns: + A style that applies the given ANSI code to messages. + """ + + def apply(message: str, **kwargs): + if kwargs.get("reset", True): + message += ANSI.RESET.value + + return code.value + message + + return cls(apply) + + @classmethod + def combine(cls, *styles: "Style"): + """Combine multiple styles into a single style. + + Args: + *styles: The styles to combine. + + Returns: + A style that applies all the given styles to messages. + """ + + def apply(message: str, **kwargs): + for style in styles: + message = style.apply(message, **kwargs) + + return message + + return cls(apply) + + @classmethod + def with_write(cls, write: Write): + """Create a style class that uses the given write function. + + Args: + write: The function to use for writing the styled message. + + Returns: + A style class that uses the given write function. + """ + + class StyleWithWrite(cls): # type: ignore[valid-type,misc] + """A style class that uses the given write function.""" + + def __init__(self, apply: Style.Apply): + super().__init__(apply, write) + + return StyleWithWrite 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/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..2fcd3c39 100644 --- a/codeforlife/settings/custom.py +++ b/codeforlife/settings/custom.py @@ -5,37 +5,18 @@ 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")) - # The level of the logs. LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") @@ -68,11 +49,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 +65,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] 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/user/auth/backends/email.py b/codeforlife/user/auth/backends/email.py index 58f215e7..d480056c 100644 --- a/codeforlife/user/auth/backends/email.py +++ b/codeforlife/user/auth/backends/email.py @@ -22,13 +22,16 @@ def authenticate( # type: ignore[override] if email is None or password is None: return None + email = self.user_class.objects.normalize_email(email) + # 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__sha256=email) 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/auth/backends/student.py b/codeforlife/user/auth/backends/student.py index aed1cb0b..3e12d550 100644 --- a/codeforlife/user/auth/backends/student.py +++ b/codeforlife/user/auth/backends/student.py @@ -29,13 +29,14 @@ def authenticate( # type: ignore[override] # 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__sha256=first_name, + new_student__class_field__access_code_hash__sha256=class_id, ) - 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/filters/klass.py b/codeforlife/user/filters/klass.py index 10bfcc91..b652976c 100644 --- a/codeforlife/user/filters/klass.py +++ b/codeforlife/user/filters/klass.py @@ -16,14 +16,24 @@ # 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_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.""" + 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__sha256=value) | Q(pk__in=pks) ) class Meta: diff --git a/codeforlife/user/filters/user.py b/codeforlife/user/filters/user.py index 9a62dc6e..671523df 100644 --- a/codeforlife/user/filters/user.py +++ b/codeforlife/user/filters/user.py @@ -5,7 +5,6 @@ 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, @@ -23,8 +22,8 @@ # pylint: disable-next=missing-class-docstring class UserFilterSet(FilterSet): students_in_class = filters.CharFilter( - "new_student__class_field__access_code", - "exact", + "new_student__class_field__access_code_hash", + "sha256", ) _id = filters.NumberFilter(method="_id__method") @@ -50,13 +49,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/fixtures/google_users.json b/codeforlife/user/fixtures/google_users.json index 13cd2a2a..6615ea08 100644 --- a/codeforlife/user/fixtures/google_users.json +++ b/codeforlife/user/fixtures/google_users.json @@ -3,10 +3,15 @@ "model": "user.user", "pk": 34, "fields": { - "first_name": "Google", - "last_name": "Teacher", - "username": "google.teacher@noschool.com", - "email": "google.teacher@noschool.com", + "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", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -26,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 71d2f958..4ccd38e6 100644 --- a/codeforlife/user/fixtures/independent.json +++ b/codeforlife/user/fixtures/independent.json @@ -3,10 +3,15 @@ "model": "user.user", "pk": 28, "fields": { - "first_name": "Indy", - "last_name": "Requester", - "username": "indy.requester@email.com", - "email": "indy.requester@email.com", + "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", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -31,10 +36,15 @@ "model": "user.user", "pk": 30, "fields": { - "first_name": "Indy", - "last_name": "NoRequest", - "username": "indy@email.com", - "email": "indy@email.com", + "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", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -54,4 +64,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 ff6bb9dc..bcfd6ec1 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -234,11 +234,13 @@ "model": "user.school", "pk": 1, "fields": { - "name": "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" } }, { @@ -293,75 +295,90 @@ "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, + "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qTT0=", + "access_code_hash": "AB123", + "access_code_plain": "AB123", + "always_accept_requests": true, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF4", + "name_plain": "Class 101", + "teacher": 1 } }, { "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, + "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qUT0=", + "access_code_hash": "AB124", + "access_code_plain": "AB124", + "always_accept_requests": true, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF5", + "name_plain": "Class 102", + "teacher": 2 } }, { "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, + "access_code_enc": "ZmFrZV9lbmM6UVVJeE1qVT0=", + "access_code_hash": "AB125", + "access_code_plain": "AB125", + "always_accept_requests": true, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF6", + "name_plain": "Class 103", + "teacher": 2 } }, { "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, + "access_code_enc": "ZmFrZV9lbmM6VWt3eE1qTT0=", + "access_code_hash": "RL123", + "access_code_plain": "RL123", + "always_accept_requests": true, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name_enc": "ZmFrZV9lbmM6V1c5MWJtY2dRMjlrWlhKeklERXdNUT09", + "name_plain": "Young Coders 101", + "teacher": 3 } }, { "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, + "access_code_enc": "ZmFrZV9lbmM6VUU4eE1qTT0=", + "access_code_hash": "PO123", + "access_code_plain": "PO123", + "always_accept_requests": true, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name_enc": "ZmFrZV9lbmM6VUc5eWRHRnNZV1J0YVc0bmN5QmpiR0Z6Y3c9PQ==", + "name_plain": "Portaladmin's class", + "teacher": 4 } }, { @@ -571,17 +588,22 @@ "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", + "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", "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": [] } }, @@ -589,17 +611,22 @@ "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", + "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", "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": [] } }, @@ -607,17 +634,22 @@ "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", + "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", "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": [] } }, @@ -625,17 +657,22 @@ "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", + "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", "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": [] } }, @@ -643,17 +680,22 @@ "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", + "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", "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": [] } }, @@ -661,17 +703,22 @@ "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", + "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", "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": [] } }, @@ -679,17 +726,22 @@ "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", + "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", "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": [] } }, @@ -697,17 +749,22 @@ "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", + "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", "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": [] } }, @@ -715,17 +772,22 @@ "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", + "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", "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": [] } }, @@ -733,17 +795,22 @@ "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", + "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", "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": [] } }, @@ -751,17 +818,22 @@ "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", + "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", "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": [] } }, @@ -769,17 +841,19 @@ "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", + "dek": "ZmFrZV9lbmM6TVhGU1NtSlhaVkpwYlRWWVR6QTNlVE5UV2xaNWRHNUlkbFJtVG1GMVJHVT0=", + "first_name_enc": "ZmFrZV9lbmM6VG05aGFBPT0=", + "first_name_hash": "Noah", + "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": [] } }, @@ -787,17 +861,19 @@ "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", + "dek": "ZmFrZV9lbmM6ZFZwVmRGbHpTVkoxTkVaMU5XRk9WMDlwV25CS1EzVlRaR28yV2tac1QzVT0=", + "first_name_enc": "ZmFrZV9lbmM6Uld4c2FXOTA=", + "first_name_hash": "Elliot", + "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": [] } }, @@ -805,17 +881,19 @@ "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", + "dek": "ZmFrZV9lbmM6UW1sWlVsSjNNM0JHVW1Sd1puQkNaVU4xUkdSVGVHRk1kV2x5ZFRoUGNFST0=", + "first_name_enc": "ZmFrZV9lbmM6VkdGcWJXRmw=", + "first_name_hash": "Tajmae", + "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": [] } }, @@ -823,17 +901,19 @@ "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", + "dek": "ZmFrZV9lbmM6ZEhWRlUxSjFZMUpxZFdoaU5XTnNiWEpqZVdSUVdFdGtOVWhFVkVGVWRrND0=", + "first_name_enc": "ZmFrZV9lbmM6UTJGeWJIUnZiZz09", + "first_name_hash": "Carlton", + "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": [] } }, @@ -841,17 +921,19 @@ "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", + "dek": "ZmFrZV9lbmM6TW5SM1FUQnNUMlJ2VFU1RFQwUllVSEp1VFc1aWQxSmxOWHBqVDJKeFZtTT0=", + "first_name_enc": "ZmFrZV9lbmM6VG1Ga1lXdz0=", + "first_name_hash": "Nadal", + "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": [] } }, @@ -859,17 +941,19 @@ "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", + "dek": "ZmFrZV9lbmM6UlZKbll6UjJhM1JPU2tvemRIQTVSRUZZYUZsMGFtOVZla05HYzI5bmQyaz0=", + "first_name_enc": "ZmFrZV9lbmM6Um5KbFpHUnBaUT09", + "first_name_hash": "Freddie", + "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": [] } }, @@ -877,17 +961,19 @@ "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", + "dek": "ZmFrZV9lbmM6YzIxdGRIbzVRbEkyVlRKdVRVVk1lSFE1TmtRMFFXeFVUamRaYldWbFpFUT0=", + "first_name_enc": "ZmFrZV9lbmM6VEdWdmJnPT0=", + "first_name_hash": "Leon", + "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": [] } }, @@ -895,17 +981,19 @@ "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", + "dek": "ZmFrZV9lbmM6T0VoaU5rRTVWalZTVUVkbVpVTlllRmhOVlVoVVZVRnVUR1pvVVU5bVUybz0=", + "first_name_enc": "ZmFrZV9lbmM6UW1WMGRIaz0=", + "first_name_hash": "Betty", + "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": [] } }, @@ -913,17 +1001,19 @@ "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", + "dek": "ZmFrZV9lbmM6Y25SMVIwYzNNbXBVUWtkTk1IVmhjMUI2YVdWb2VtNVBaRUUyVFdsMldrcz0=", + "first_name_enc": "ZmFrZV9lbmM6UkdWc1pYUmxaQT09", + "first_name_hash": "Deleted", + "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": [] } }, @@ -931,17 +1021,22 @@ "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", + "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", "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": [] } } diff --git a/codeforlife/user/fixtures/non_school_teacher.json b/codeforlife/user/fixtures/non_school_teacher.json index 1bcc2b7b..f05192da 100644 --- a/codeforlife/user/fixtures/non_school_teacher.json +++ b/codeforlife/user/fixtures/non_school_teacher.json @@ -3,10 +3,15 @@ "model": "user.user", "pk": 22, "fields": { - "first_name": "John", - "last_name": "Doe", - "username": "teacher@noschool.com", - "email": "teacher@noschool.com", + "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", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -30,10 +35,15 @@ "model": "user.user", "pk": 33, "fields": { - "first_name": "Unverified", - "last_name": "Teacher", - "username": "unverified.teacher@noschool.com", - "email": "unverified.teacher@noschool.com", + "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", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -53,4 +63,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 444fa023..aeea461a 100644 --- a/codeforlife/user/fixtures/school_1.json +++ b/codeforlife/user/fixtures/school_1.json @@ -3,19 +3,26 @@ "model": "user.school", "pk": 2, "fields": { - "name": "School 1", "country": "GB", - "county": "Hertfordshire" + "county": "Hertfordshire", + "dek": "ZmFrZV9lbmM6V1cxTVdtMXFibTgyVlhadGVFTlRla1p2ZDBOM2QzVnVSMEZ3WkdGeVpsQT0=", + "name_enc": "ZmFrZV9lbmM6VTJOb2IyOXNJREU9", + "name_plain": "School 1" } }, { "model": "user.user", "pk": 23, "fields": { - "first_name": "John", - "last_name": "Doe", - "username": "teacher@school1.com", - "email": "teacher@school1.com", + "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", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -40,18 +47,23 @@ "model": "user.class", "pk": 6, "fields": { - "name": "Class 1 @ School 1", - "access_code": "ZZ111", - "teacher": 6, - "accept_requests_until": "9999-02-09 20:26:08.298402+00:00" + "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", + "teacher": 6 } }, { "model": "user.user", "pk": 27, "fields": { - "first_name": "Student1", - "username": "111111111111111111111111111111", + "dek": "ZmFrZV9lbmM6ZEc1aFZ6RkhVRTFNVFdaSWQzTlZXRmxRZFRsTFZIbE1kRWxSVGs1Q1FWQT0=", + "first_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkREU9", + "first_name_hash": "Student1", + "first_name_plain": "Student1", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -76,10 +88,15 @@ "model": "user.user", "pk": 24, "fields": { - "first_name": "Jane", - "last_name": "Doe", - "username": "admin.teacher@school1.com", - "email": "admin.teacher@school1.com", + "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", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -105,8 +122,11 @@ "model": "user.class", "pk": 7, "fields": { - "name": "Class 2 @ School 1", - "access_code": "ZZ222", + "access_code_enc": "ZmFrZV9lbmM6V2xveU1qST0=", + "access_code_hash": "ZZ222", + "access_code_plain": "ZZ222", + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F4", + "name_plain": "Class 2 @ School 1", "teacher": 7 } }, @@ -114,8 +134,10 @@ "model": "user.user", "pk": 29, "fields": { - "first_name": "Student2", - "username": "222222222222222222222222222222", + "dek": "ZmFrZV9lbmM6YXpWd1dGZEZOWHBXZEdnMFJWQlROWFZFV2s0eVRuVTBOVTlGZUdwNWJtND0=", + "first_name_enc": "ZmFrZV9lbmM6VTNSMVpHVnVkREk9", + "first_name_hash": "Student2", + "first_name_plain": "Student2", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -140,10 +162,13 @@ "model": "user.class", "pk": 10, "fields": { - "name": "Class 3 @ School 1", - "access_code": "ZZ333", - "teacher": 7, - "accept_requests_until": "2023-02-09 20:26:08.298402+00:00" + "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", + "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 3c3b7a07..64086be1 100644 --- a/codeforlife/user/fixtures/school_2.json +++ b/codeforlife/user/fixtures/school_2.json @@ -3,19 +3,26 @@ "model": "user.school", "pk": 3, "fields": { - "name": "School 2", "country": "GB", - "county": "Hertfordshire" + "county": "Hertfordshire", + "dek": "ZmFrZV9lbmM6VlUxaWN6TTVhbk56WW5wcFpIVXhhVlE1UW10WGFVcHpUa3BFT0VNNVdtYz0=", + "name_enc": "ZmFrZV9lbmM6VTJOb2IyOXNJREk9", + "name_plain": "School 2" } }, { "model": "user.user", "pk": 25, "fields": { - "first_name": "John", - "last_name": "Doe", - "username": "teacher@school2.com", - "email": "teacher@school2.com", + "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", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -129,8 +136,11 @@ "model": "user.class", "pk": 8, "fields": { - "name": "Class 1 @ School 2", - "access_code": "XX111", + "access_code_enc": "ZmFrZV9lbmM6V0ZneE1URT0=", + "access_code_hash": "XX111", + "access_code_plain": "XX111", + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNU0JBSUZOamFHOXZiQ0F5", + "name_plain": "Class 1 @ School 2", "teacher": 8 } }, @@ -138,10 +148,15 @@ "model": "user.user", "pk": 26, "fields": { - "first_name": "Jane", - "last_name": "Doe", - "username": "admin.teacher@school2.com", - "email": "admin.teacher@school2.com", + "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", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -176,9 +191,12 @@ "model": "user.class", "pk": 9, "fields": { - "name": "Class 2 @ School 2", - "access_code": "XX222", + "access_code_enc": "ZmFrZV9lbmM6V0ZneU1qST0=", + "access_code_hash": "XX222", + "access_code_plain": "XX222", + "name_enc": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F5", + "name_plain": "Class 2 @ School 2", "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 bbd1ec10..0ef07199 100644 --- a/codeforlife/user/fixtures/school_3.json +++ b/codeforlife/user/fixtures/school_3.json @@ -3,19 +3,26 @@ "model": "user.school", "pk": 4, "fields": { - "name": "School 3", "country": "GB", - "county": "Hertfordshire" + "county": "Hertfordshire", + "dek": "ZmFrZV9lbmM6Vm1ORlNIbENURmRyZEdOR1VGaHFOMFZIVFhkRE1IQjVNR1oyTkdkVFNIRT0=", + "name_enc": "ZmFrZV9lbmM6VTJOb2IyOXNJRE09", + "name_plain": "School 3" } }, { "model": "user.user", "pk": 31, "fields": { - "first_name": "Peter", - "last_name": "Parker", - "username": "admin.teacher@school3.com", - "email": "admin.teacher@school3.com", + "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", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -41,10 +48,15 @@ "model": "user.user", "pk": 32, "fields": { - "first_name": "Doctor", - "last_name": "Octopus", - "username": "teacher@school3.com", - "email": "teacher@school3.com", + "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", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -65,4 +77,4 @@ "school": 4 } } -] +] \ No newline at end of file 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..0369c4ae --- /dev/null +++ b/codeforlife/user/management/commands/encrypt_plaintext_fields.py @@ -0,0 +1,240 @@ +""" +© Ocado Group +Created on 16/03/2026 at 10:58:04(+00:00). +""" + +import typing as t + +from django.apps import apps +from django.core.exceptions import FieldDoesNotExist +from django.core.management.base import BaseCommand +from django.db.models import CharField, Field, Model, Q, TextField + +from ....models.fields import EncryptedTextField, Sha256Field +from ....models.utils import is_real_model_class +from ....pprint import PrettyPrinter + +PlaintextField: t.TypeAlias = t.Union[CharField, TextField] +FieldsToEncrypt: t.TypeAlias = t.List[ + t.Tuple[ + PlaintextField, + t.Optional[EncryptedTextField], + t.Optional[Sha256Field], + ] +] + + +# pylint: disable-next=missing-class-docstring +class Command(BaseCommand): + format_help = ( + "Arguments should be one or more Django app labels. " + "For each model in each app, fields ending with '_plain' will be " + "copied into matching '_enc' and '_hash' fields." + ) + help = f"Encrypts and hashes plaintext fields for app models. {format_help}" + + def add_arguments(self, parser): + parser.add_argument( + "app_labels", 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.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be updated without writing to the database.", + ) + + # pylint: disable-next=too-many-locals + def _discover_model_fields( + self, + model_class: t.Type[Model], + pprint: PrettyPrinter, + ) -> FieldsToEncrypt: + model_spec = ( + f"{model_class._meta.app_label}.{model_class._meta.model_name}" + ) + plain_fields_by_name = { + field.name: field + for field in model_class._meta.fields + if isinstance(field, (CharField, TextField)) + and field.name.endswith("_plain") + } + + fields_to_encrypt: FieldsToEncrypt = [] + for field_name, plain_field in plain_fields_by_name.items(): + field_name_prefix = field_name[: -len("_plain")] + enc_field_name = f"{field_name_prefix}_enc" + hash_field_name = f"{field_name_prefix}_hash" + + enc_field: t.Optional[EncryptedTextField] = None + hash_field: t.Optional[Sha256Field] = None + + try: + enc_field_obj = model_class._meta.get_field(enc_field_name) + except FieldDoesNotExist: + enc_field_obj = None + + try: + hash_field_obj = model_class._meta.get_field(hash_field_name) + except FieldDoesNotExist: + hash_field_obj = None + + if isinstance(enc_field_obj, EncryptedTextField): + enc_field = enc_field_obj + elif enc_field_obj is not None: + pprint( + "Skipping encrypted field due to type mismatch: " + + pprint.notice.apply(f"{model_spec}.{enc_field_name}") + ) + + if isinstance(hash_field_obj, Sha256Field): + hash_field = hash_field_obj + elif hash_field_obj is not None: + pprint( + "Skipping hash field due to type mismatch: " + + pprint.notice.apply(f"{model_spec}.{hash_field_name}") + ) + + if enc_field is None and hash_field is None: + if enc_field_obj is not None: + pprint( + "No valid target fields found for: " + + pprint.notice.apply(f"{model_spec}.{enc_field_name}") + ) + if hash_field_obj is not None: + pprint( + "No valid target fields found for: " + + pprint.notice.apply(f"{model_spec}.{hash_field_name}") + ) + continue + + target_fields = ", ".join( + field_name + for field_name in (enc_field_name, hash_field_name) + if (field_name == enc_field_name and enc_field is not None) + or (field_name == hash_field_name and hash_field is not None) + ) + + pprint( + "Discovered field mapping: " + + pprint.notice.apply(f"{field_name} -> {target_fields}") + ) + fields_to_encrypt.append((plain_field, enc_field, hash_field)) + + return fields_to_encrypt + + # pylint: disable-next=too-many-arguments,too-many-positional-arguments,too-many-locals + def _encrypt_field( + self, + chunk_size: int, + model_class: t.Type[Model], + plain_field: PlaintextField, + enc_field: t.Optional[EncryptedTextField], + hash_field: t.Optional[Sha256Field], + dry_run: bool, + pprint: PrettyPrinter, + ): + def null_or_empty(field: Field, empty: t.Any): + return Q(**{f"{field.name}__isnull": True}) | Q( + **{f"{field.name}": empty} + ) + + if enc_field is not None and hash_field is not None: + q = null_or_empty(enc_field, b"") | null_or_empty(hash_field, "") + elif enc_field is not None: + q = null_or_empty(enc_field, b"") + elif hash_field is not None: + q = null_or_empty(hash_field, "") + else: + return + + models = model_class.objects.exclude( # type: ignore[attr-defined] + null_or_empty(plain_field, "") + ).filter(q) + model_count = models.count() + + if dry_run: + pprint(f"Dry run: would update {model_count} records.") + return + + for model_index, model in enumerate(models.iterator(chunk_size)): + if model_index % chunk_size == 0: + pprint(f"({model_index}/{model_count})") + + plaintext = getattr(model, plain_field.name) + update_fields: t.List[str] = [] + if enc_field is not None: + setattr(model, enc_field.name, plaintext) + update_fields.append(enc_field.name) + if hash_field is not None: + setattr(model, hash_field.name, plaintext) + update_fields.append(hash_field.name) + model.save(update_fields=update_fields) + + # pylint: disable-next=too-many-locals + def handle(self, *args, **options): + app_labels: t.List[str] = options["app_labels"] + chunk_size: int = options["chunk_size"] + dry_run: bool = options["dry_run"] + + pprint = PrettyPrinter(write=self.stdout.write, name=self.__module__) + + for app_label in app_labels: + with pprint.process( + f"Processing app: {pprint.notice.apply(app_label)}" + ) as app_pprint: + app_config = apps.get_app_config(app_label) + for model_class in app_config.get_models(): + if not is_real_model_class(model_class): + app_pprint( + "Skipping non-real model: " + + pprint.notice.apply( + f"{model_class._meta.app_label}" + f".{model_class._meta.model_name}" + ) + ) + continue + + with app_pprint.process( + "Encrypting model: " + + app_pprint.notice.apply( + f"{model_class._meta.app_label}" + f".{model_class._meta.model_name}" + ) + ) as enc_model_pprint: + fields_to_encrypt = self._discover_model_fields( + model_class, + enc_model_pprint, + ) + + for ( + plain_field, + enc_field, + hash_field, + ) in fields_to_encrypt: + target_field_names = [ + field.name + for field in (enc_field, hash_field) + if field is not None + ] + with enc_model_pprint.process( + "Field: " + + enc_model_pprint.notice.apply( + f"{plain_field.name} -> " + + ", ".join(target_field_names) + ) + ) as enc_field_pprint: + self._encrypt_field( + chunk_size, + model_class, + plain_field, + enc_field, + hash_field, + dry_run, + enc_field_pprint, + ) diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index e928c13c..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( @@ -260,135 +282,13 @@ class Migration(migrations.Migration): ], options={ "abstract": False, + "verbose_name": "user", + "verbose_name_plural": "users", }, managers=[ ("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=[ @@ -462,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=[ @@ -531,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)), ( @@ -581,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=[ @@ -613,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)), ( @@ -630,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( @@ -652,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", @@ -679,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( @@ -783,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..e5fb5a4d --- /dev/null +++ b/codeforlife/user/migrations/0002_user_proxies_and_new_models.py @@ -0,0 +1,355 @@ +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 codeforlife.user.models.user.user +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.AlterModelManagers( + name="user", + managers=[ + ("objects", codeforlife.user.models.user.user.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", + 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/0003_client_side_encryption_part_1.py b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py new file mode 100644 index 00000000..8583f2e6 --- /dev/null +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py @@ -0,0 +1,149 @@ +import typing as t + +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] +): + """ + 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(): + migrations_list += [ + # Rename the plain text field. + migrations.RenameField( + model_name=model_name, + old_name=name, + new_name=f"{name}_plain", + ), + # Add an encrypted text field. + migrations.AddField( + model_name=model_name, + name=f"{name}_enc", + field=EncryptedTextField( + associated_data=name, + null=True, + verbose_name=verbose_name, + ), + ), + ] + + return migrations_list + + +class Migration(migrations.Migration): + + dependencies = [ + ("user", "0002_user_proxies_and_new_models"), + ] + + operations = [ + migrations.RemoveField( + 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=Sha256Field( + unique=True, + null=True, + editable=False, + max_length=64, + verbose_name="email hash", + ), + ), + migrations.AddField( + model_name="user", + name="first_name_hash", + field=Sha256Field( + null=True, + editable=False, + max_length=64, + verbose_name="first name hash", + ), + ), + migrations.AddField( + model_name="class", + name="access_code_hash", + field=Sha256Field( + null=True, + editable=False, + max_length=64, + verbose_name="access code hash", + ), + ), + 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", + }, + ), + *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/models/klass.py b/codeforlife/user/models/klass.py index 8d5bcced..0bfdf6f3 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -9,8 +9,12 @@ 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 _ +from ...models import EncryptedModel +from ...models.fields import EncryptedTextField, Sha256Field from ...types import Validators from ...validators import ( UnicodeAlphanumericCharSetValidator, @@ -22,7 +26,8 @@ from django_stubs_ext.db.models import TypedModelMeta - from .teacher import Teacher + from .student import Student + from .teacher import SchoolTeacher, Teacher else: TypedModelMeta = object @@ -41,7 +46,7 @@ ] -class ClassModelManager(models.Manager): +class ClassModelManager(EncryptedModel.Manager["Class"]): """Manager for Class model.""" def get_original_queryset(self): @@ -53,23 +58,81 @@ def get_queryset(self): return super().get_queryset().filter(is_active=True) -class Class(models.Model): +# pylint: disable-next=too-many-instance-attributes +class Class(EncryptedModel): """A class.""" - name = models.CharField(max_length=200) + students: QuerySet["Student"] - teacher: "Teacher" + associated_data = "class" + + # -------------------------------------------------------------------------- + # 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_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, 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 + self.access_code_hash = value + + # -------------------------------------------------------------------------- classmates_data_viewable: bool classmates_data_viewable = models.BooleanField( # type: ignore[assignment] @@ -103,7 +166,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 +213,7 @@ def anonymise(self): class Meta(TypedModelMeta): verbose_name_plural = "classes" + + @property + def dek_aead(self): + return self.teacher.school.dek_aead diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py index ea000bac..5b6efc62 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,8 @@ def get_queryset(self): return super().get_queryset().filter(is_active=True) -class SchoolTeacherInvitation(models.Model): +# 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 be used when a teacher invites another teacher to join their school, and the @@ -226,8 +233,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 +278,91 @@ 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 + # 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( + 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 + # 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( + 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 +382,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 +405,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.") 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.") diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index 40980fd0..eaf55de1 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -8,8 +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 @@ -26,7 +29,7 @@ ] -class SchoolModelManager(models.Manager): +class SchoolModelManager(DataEncryptionKeyModel.Manager["School"]): """Manager for School model.""" def get_original_queryset(self): @@ -38,14 +41,42 @@ def get_queryset(self): return super().get_queryset().filter(is_active=True) -class School(models.Model): +class School(DataEncryptionKeyModel): """A school.""" - name: str - name = models.CharField( # type: ignore[assignment] + 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( + 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] @@ -71,7 +102,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/student.py b/codeforlife/user/models/student.py index 4489fc1c..a01bfed8 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 @@ -24,17 +23,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 +31,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 +51,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/contactable.py b/codeforlife/user/models/user/contactable.py index b63c2104..0c799abb 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 @@ -52,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/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..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, @@ -55,12 +56,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..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 ): @@ -44,7 +45,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 +135,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..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, @@ -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 6d938423..538f92c1 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -9,9 +9,8 @@ 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.hashers import make_password +from django.contrib.auth.models import PermissionsMixin from django.contrib.auth.models import UserManager as _UserManager from django.db import models from django.db.models.query import QuerySet @@ -19,20 +18,17 @@ from django.utils.translation import gettext_lazy as _ from pyotp import TOTP -from ....models import AbstractBaseUser +from ....models import AbstractBaseUser, DataEncryptionKeyModel +from ....models.fields import EncryptedTextField, Sha256Field 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,21 +46,112 @@ ] -# 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") -# pylint: disable-next=too-many-ancestors -class User( - _AbstractBaseUser, - AbstractUser, # TODO: remove this inheritance in new schema +class UserManager( + _UserManager[AnyUser], + DataEncryptionKeyModel.Manager[AnyUser], + t.Generic[AnyUser], ): - """A proxy to Django's user class.""" + """ + Manager for the User model that inherits Django's default manager and + encrypted manager to handle encrypted fields. + """ + + def _create_user_object( + self, + _: t.Literal[""], # username is not used but is required by the parent + email: t.Optional[str], + password: t.Optional[str], + **extra_fields, + ): + 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 + + @classmethod + def normalize_email(cls, email): + return None if email is None else email.lower() + + def create_user( # type: ignore[override] + 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: 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: 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: t.Optional[str] = None, + 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. + + 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,too-many-instance-attributes +class User(AbstractBaseUser, PermissionsMixin, DataEncryptionKeyModel): + """A Code for Life user.""" + + associated_data = "user" + + EMAIL_FIELD = "email_enc" + USERNAME_FIELD = "email_hash" + REQUIRED_FIELDS = ["email_enc"] + credential_fields = frozenset(["email", "password"]) _password: t.Optional[str] @@ -75,7 +162,105 @@ class User( session: "Session" # type: ignore[assignment] userprofile: "UserProfile" - credential_fields = frozenset(["email", "password"]) + # -------------------------------------------------------------------------- + # First name + # -------------------------------------------------------------------------- + + first_name_hash = Sha256Field(verbose_name=_("first name hash"), null=True) + 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 + self.first_name_hash = 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_hash = Sha256Field( + verbose_name=_("email hash"), unique=True, null=True + ) + email_plain = models.EmailField(_("email address"), null=True, unique=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: 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 + # -------------------------------------------------------------------------- + + 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] @@ -92,6 +277,10 @@ class User( null=True, ) + objects: UserManager[ # type: ignore[misc] + "User" + ] = UserManager() # type: ignore[assignment] + @property def is_authenticated(self): return ( @@ -173,9 +362,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, @@ -187,26 +377,35 @@ def anonymize(self): """Anonymize the user.""" self.first_name = "" self.last_name = "" - self.email = "" + self.email = None self.is_active = False self.save( update_fields=[ - "first_name", - "last_name", - "email", - "username", + # 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", "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"" if not getattr(settings, "OLD_SYSTEM", True): @@ -218,27 +417,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.""" 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.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/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 c36d833e..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, @@ -33,7 +30,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 @@ -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}"}, ) 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)) 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. diff --git a/setup.py b/setup.py index ba30c913..b6f30bfa 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(include=["codeforlife", "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),