diff --git a/Pipfile.lock b/Pipfile.lock index 4fa76a71..a02c8f8b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -192,122 +192,122 @@ }, "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:014837af6fabf57121b6254fa8ade10dceabc3528b27b721a64bbc7b8b1d4eb4", + "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", + "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", + "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", + "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", + "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", + "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", + "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", + "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", + "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", + "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", + "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", + "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", + "sha256:14498a429321de554b140013142abe7608f9d8ccc04d7baf2ad60498374aefa2", + "sha256:149ec69866c3d6c2fb6f758dbc014ecb09f30b35a5ca90b6a8a2d4e54e18fdfe", + "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", + "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", + "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", + "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", + "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", + "sha256:1f2da5cbb9becfcd607757a169e38fb82aa5fd86fae6653dea716e7b613fe2cf", + "sha256:259cd1ca995ad525f638e131dbcc2353a586564c038fc548a3fe450a91882139", + "sha256:2820a98460c83663dd8ec015d9ddfd1e4879f12e06bb7d0500f044fb477d2770", + "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", + "sha256:2b970382e4a36bed897c19f310f31d7d13489c11b4f468ddfba42d41cddfb918", + "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", + "sha256:30987f4a8ed169983f93e1be8ffeea5214a779e27ed0b059835c7afe96550ad7", + "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", + "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", + "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", + "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", + "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", + "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", + "sha256:4b8551b6e6531e156db71193771c93bda78ffc4d1e6372517fe58ad3b91e4659", + "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", + "sha256:50bcbca6603c06a1dcc7b056ed45c37715fb5d2768feb3bcd37d2313c587a5b9", + "sha256:530beedcec9b6e027e7a4b6ce26eed36678aa39e17da85e6e03d7bd9e8e9d7c9", + "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", + "sha256:573ef5814c4b7c0d59a7710aa920eaaaef383bd71626aa420fba27b5cab92e8d", + "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", + "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", + "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", + "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", + "sha256:5fea359734b140d0d6741189fea5478c6091b54ffc69d7ce119e0a05637d8c99", + "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", + "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", + "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", + "sha256:65b3c403a5b6b8034b655e7385de4f72b7b244869a22b32d4030b99a60593eca", + "sha256:66dee73039277eb35380d1b82cccc69cc82b13a66f9f4a18da32d573acf02b7c", + "sha256:708c7acde173eedd4bfa4028484426ba689d2103b28588c513b9db2cd5ecde9c", + "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", + "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", + "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", + "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", + "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", + "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", + "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", + "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", + "sha256:82cc7c2ad42faec8b574351f8bc2a0c049043893853317bd9bb309f5aba6cb5a", + "sha256:8a28afb04baa55abf26df544e3e5c6534245d3daa5178bc4a8eeb48202060d0e", + "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", + "sha256:8ce11cd4d62d11166f2b441e30ace226c19a3899a7cf0796f668fba49a9fb123", + "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", + "sha256:92263f7eca2f4af326cd20de8d16728d2602f7cfea02e790dcde9d83c365d7cc", + "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", + "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", + "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", + "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", + "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", + "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", + "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", + "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", + "sha256:aa2f963b4da26daf46231d9b9e0e2c9408a751f8f0d0f44d2de56d3caf51d294", + "sha256:aa92ec1102eaff840ccd1021478af176a831f1bccb08e526ce844b7ddda85c22", + "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", + "sha256:ae8b03427410731469c4033934cf473426faff3e04b69d2dfb64a4281a3719f8", + "sha256:afca7f78067dd27c2b848f1b234623d26b87529296c6c5652168cc1954f2f3b2", + "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", + "sha256:b3e71afc578b98512bfe7bdb822dd6bc57d4b0093b4b6e5487c1e96ad4ace242", + "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", + "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", + "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", + "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", + "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", + "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", + "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", + "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", + "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", + "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", + "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", + "sha256:d29dd9c016f2078b43d0c357511e87eee5b05108f3dd603423cb389b89813969", + "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", + "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", + "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", + "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", + "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", + "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", + "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", + "sha256:e22d1059b951e7ae7c20ef6b06afd10fb95e3c41bf3c4fbc874dba113321c193", + "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", + "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", + "sha256:e545b51da9f9af5c67815ca0eb40676c0f016d0b0381c86f20451e35696c5f95", + "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", + "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", + "sha256:ec56a2266f32bc06ed3c3e2a8f58417ce02f7e0356edc89786e52db13c593c98", + "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", + "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", + "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", + "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", + "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", + "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", + "sha256:fc1c64934b8faf7584924143eb9db4770bbdb16659626e1a1a4d9efbcb68d947", + "sha256:ff95a9283de8a457e6b12989de3f9f5193430f375d64297d323a615ea52cbdb3" ], "markers": "python_version >= '3.7'", - "version": "==3.4.4" + "version": "==3.4.5" }, "click": { "hashes": [ @@ -556,11 +556,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": [ @@ -1369,122 +1369,122 @@ }, "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:014837af6fabf57121b6254fa8ade10dceabc3528b27b721a64bbc7b8b1d4eb4", + "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", + "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", + "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", + "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", + "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", + "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", + "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", + "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", + "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", + "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", + "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", + "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", + "sha256:14498a429321de554b140013142abe7608f9d8ccc04d7baf2ad60498374aefa2", + "sha256:149ec69866c3d6c2fb6f758dbc014ecb09f30b35a5ca90b6a8a2d4e54e18fdfe", + "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", + "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", + "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", + "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", + "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", + "sha256:1f2da5cbb9becfcd607757a169e38fb82aa5fd86fae6653dea716e7b613fe2cf", + "sha256:259cd1ca995ad525f638e131dbcc2353a586564c038fc548a3fe450a91882139", + "sha256:2820a98460c83663dd8ec015d9ddfd1e4879f12e06bb7d0500f044fb477d2770", + "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", + "sha256:2b970382e4a36bed897c19f310f31d7d13489c11b4f468ddfba42d41cddfb918", + "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", + "sha256:30987f4a8ed169983f93e1be8ffeea5214a779e27ed0b059835c7afe96550ad7", + "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", + "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", + "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", + "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", + "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", + "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", + "sha256:4b8551b6e6531e156db71193771c93bda78ffc4d1e6372517fe58ad3b91e4659", + "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", + "sha256:50bcbca6603c06a1dcc7b056ed45c37715fb5d2768feb3bcd37d2313c587a5b9", + "sha256:530beedcec9b6e027e7a4b6ce26eed36678aa39e17da85e6e03d7bd9e8e9d7c9", + "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", + "sha256:573ef5814c4b7c0d59a7710aa920eaaaef383bd71626aa420fba27b5cab92e8d", + "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", + "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", + "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", + "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", + "sha256:5fea359734b140d0d6741189fea5478c6091b54ffc69d7ce119e0a05637d8c99", + "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", + "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", + "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", + "sha256:65b3c403a5b6b8034b655e7385de4f72b7b244869a22b32d4030b99a60593eca", + "sha256:66dee73039277eb35380d1b82cccc69cc82b13a66f9f4a18da32d573acf02b7c", + "sha256:708c7acde173eedd4bfa4028484426ba689d2103b28588c513b9db2cd5ecde9c", + "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", + "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", + "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", + "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", + "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", + "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", + "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", + "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", + "sha256:82cc7c2ad42faec8b574351f8bc2a0c049043893853317bd9bb309f5aba6cb5a", + "sha256:8a28afb04baa55abf26df544e3e5c6534245d3daa5178bc4a8eeb48202060d0e", + "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", + "sha256:8ce11cd4d62d11166f2b441e30ace226c19a3899a7cf0796f668fba49a9fb123", + "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", + "sha256:92263f7eca2f4af326cd20de8d16728d2602f7cfea02e790dcde9d83c365d7cc", + "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", + "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", + "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", + "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", + "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", + "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", + "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", + "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", + "sha256:aa2f963b4da26daf46231d9b9e0e2c9408a751f8f0d0f44d2de56d3caf51d294", + "sha256:aa92ec1102eaff840ccd1021478af176a831f1bccb08e526ce844b7ddda85c22", + "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", + "sha256:ae8b03427410731469c4033934cf473426faff3e04b69d2dfb64a4281a3719f8", + "sha256:afca7f78067dd27c2b848f1b234623d26b87529296c6c5652168cc1954f2f3b2", + "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", + "sha256:b3e71afc578b98512bfe7bdb822dd6bc57d4b0093b4b6e5487c1e96ad4ace242", + "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", + "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", + "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", + "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", + "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", + "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", + "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", + "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", + "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", + "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", + "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", + "sha256:d29dd9c016f2078b43d0c357511e87eee5b05108f3dd603423cb389b89813969", + "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", + "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", + "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", + "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", + "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", + "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", + "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", + "sha256:e22d1059b951e7ae7c20ef6b06afd10fb95e3c41bf3c4fbc874dba113321c193", + "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", + "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", + "sha256:e545b51da9f9af5c67815ca0eb40676c0f016d0b0381c86f20451e35696c5f95", + "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", + "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", + "sha256:ec56a2266f32bc06ed3c3e2a8f58417ce02f7e0356edc89786e52db13c593c98", + "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", + "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", + "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", + "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", + "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", + "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", + "sha256:fc1c64934b8faf7584924143eb9db4770bbdb16659626e1a1a4d9efbcb68d947", + "sha256:ff95a9283de8a457e6b12989de3f9f5193430f375d64297d323a615ea52cbdb3" ], "markers": "python_version >= '3.7'", - "version": "==3.4.4" + "version": "==3.4.5" }, "click": { "hashes": [ @@ -1836,11 +1836,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,11 +1984,11 @@ }, "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": [ 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..f483a2f3 --- /dev/null +++ b/codeforlife/models/fields/sha256_test.py @@ -0,0 +1,78 @@ +""" +© 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.""" + # pylint: disable-next=protected-access + email_hash = User._email_hash + assert isinstance(email_hash, Sha256Field.descriptor_class) + assert isinstance(email_hash.field, Sha256Field) + + def test_get__value(self): + """Getting field from instance returns the value.""" + email = "test@example.com" + user = User(_email_hash=email) + # pylint: disable-next=protected-access + assert user._email_hash == Sha256Field.hash(email) + + def test_set__none(self): + """Setting field to None sets to None.""" + user = User(_email_hash=None) + assert user.__dict__["_email_hash"] is None + + 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 + # pylint: disable-next=protected-access + assert user.email != user._email_hash + assert User.objects.get(_email_hash__sha256=user.email) == user + + def test_lookup__sha256_in(self): + """ + `sha256_in` lookup hashes each value in the list before doing an exact + match. + """ + user = User.objects.filter(_email_hash__isnull=False).first() + assert user + # pylint: disable-next=protected-access + assert user.email != user._email_hash + assert User.objects.get(_email_hash__sha256_in=[user.email]) == user diff --git a/codeforlife/models/signals/__init__.py b/codeforlife/models/signals/__init__.py index f9268475..c87899b0 100644 --- a/codeforlife/models/signals/__init__.py +++ b/codeforlife/models/signals/__init__.py @@ -6,5 +6,9 @@ https://docs.djangoproject.com/en/5.1/ref/signals/#module-django.db.models.signals """ -from .general import UpdateFields, update_fields_includes +from .general import ( + UpdateFields, + update_fields_includes, + validate_update_fields_includes_none_or_all, +) from .receiver import model_receiver diff --git a/codeforlife/models/signals/general.py b/codeforlife/models/signals/general.py index 6926843d..63d8adaf 100644 --- a/codeforlife/models/signals/general.py +++ b/codeforlife/models/signals/general.py @@ -5,13 +5,16 @@ import typing as t +from django.core.exceptions import ValidationError from django.db.models import Model AnyModel = t.TypeVar("AnyModel", bound=Model) UpdateFields = t.Optional[t.FrozenSet[str]] -def update_fields_includes(update_fields: UpdateFields, includes: t.Set[str]): +def update_fields_includes( + update_fields: UpdateFields, includes: t.Union[t.Set[str], t.FrozenSet[str]] +): """Check the call to .save() includes the update-fields specified. Args: @@ -21,4 +24,34 @@ def update_fields_includes(update_fields: UpdateFields, includes: t.Set[str]): Returns: A flag designating if the fields are included in the update-fields. """ - return update_fields and includes.issubset(update_fields) + return ( + update_fields is not None + and len(update_fields) > 0 + and includes.issubset(update_fields) + ) + + +def validate_update_fields_includes_none_or_all( + update_fields: UpdateFields, includes: t.Union[t.Set[str], t.FrozenSet[str]] +): + """ + Validates that either none or all of the fields are included in the + update-fields. + + Args: + update_fields: The update-fields provided in the call to .save(). + includes: The fields that should be included in the update-fields. + + Raises: + ValidationError: If some but not all of the fields are included. + """ + if ( + update_fields + and update_fields.intersection(includes) + and not update_fields.issuperset(includes) + ): + raise ValidationError( + "Either none or all of the following fields must be included in " + f"update_fields: {', '.join(includes)}", + code="update_fields_incomplete", + ) diff --git a/codeforlife/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/pprint.py b/codeforlife/pprint.py new file mode 100644 index 00000000..c0a9094e --- /dev/null +++ b/codeforlife/pprint.py @@ -0,0 +1,219 @@ +""" +© Ocado Group +Created on 11/03/2026 at 14:42:45(+00:00). + +Pretty-printing utilities for terminal output. +""" + +import os +import typing as t +from enum import Enum +from timeit import default_timer + +Write: t.TypeAlias = t.Callable[[str], None] + + +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" + + +class Style: + """A callable class that applies styles to messages.""" + + def __init__(self, apply: t.Callable[[str], str], 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, write: Write = print): + """Create a style that applies the given ANSI code to messages. + + Args: + code: The ANSI code to apply. + write: The function to use for writing the styled message. + + Returns: + A style that applies the given ANSI code to messages. + """ + + def apply(message: str): + return code.value + message + ANSI.RESET.value + + return cls(apply, write) + + @classmethod + def combine(cls, styles: t.List["Style"], write: Write = print): + """Combine multiple styles into a single style. + + Args: + styles: The styles to combine. + write: The function to use for writing the styled message. + + Returns: + A style that applies all the given styles to messages. + """ + + def apply(message: str): + for style in styles: + message = style.apply(message) + + return message + + return cls(apply, write) + + +# pylint: disable-next=too-many-instance-attributes +class PrettyPrinter: + """A utility class for pretty-printing styled messages to the terminal.""" + + def __init__(self, write: Write, name: str, indent_level=0): + self.write = write + self.name = name + self.indent_level = indent_level + self.start_time: t.Optional[float] = None + self.end_time: t.Optional[float] = None + + # Black and white. + self.black = Style.ansi(ANSI.BLACK, self.__call__) + self.white = Style.ansi(ANSI.WHITE, self.__call__) + + # Red, green, and blue. + self.red = Style.ansi(ANSI.RED, self.__call__) + self.green = Style.ansi(ANSI.GREEN, self.__call__) + self.blue = Style.ansi(ANSI.BLUE, self.__call__) + + # Cyan, magenta, and yellow. + self.cyan = Style.ansi(ANSI.CYAN, self.__call__) + self.magenta = Style.ansi(ANSI.MAGENTA, self.__call__) + self.yellow = Style.ansi(ANSI.YELLOW, self.__call__) + + # Common text styles. + self.bold = Style.ansi(ANSI.BOLD, self.__call__) + self.underline = Style.ansi(ANSI.UNDERLINE, self.__call__) + self.overline = Style.ansi(ANSI.OVERLINE, self.__call__) + + # Status styles. + self.success = Style.combine([self.green, self.bold], self.__call__) + self.error = Style.combine([self.red, self.bold], self.__call__) + self.warn = self.warning = Style.combine( + [self.yellow, self.bold], self.__call__ + ) + self.info = self.notice = Style.combine( + [self.blue, self.bold], self.__call__ + ) + + # Heading styles. + h1 = Style( + lambda message: "\n".join( + [self.divider("="), message, self.divider("=")] + ), + self.__call__, + ) + self.h1 = Style.combine([h1, self.bold], self.__call__) + h2 = Style( + lambda message: "\n".join( + [self.divider("-"), message, self.divider("-")] + ), + self.__call__, + ) + self.h2 = Style.combine([h2, self.bold], self.__call__) + self.h3 = Style.combine( + [self.overline, self.underline, self.bold], self.__call__ + ) + + def __call__(self, message: str, *args, **kwargs): + # pylint: disable=line-too-long + """Write a message to the terminal. + + Args: + message: The message to write. + *args: Additional positional arguments to pass to the write function. + **kwargs: Additional keyword arguments to pass to the write function. + """ + # pylint: enable=line-too-long + indented_message = self.indent(self.indent_level) + message + self.write(indented_message, *args, **kwargs) + + def __enter__(self): + self.bold(self.name) + self.indent_level = max(0, self.indent_level + 1) + + self.start_time = default_timer() + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.end_time = default_timer() + elapsed_time = self.end_time - (self.start_time or self.end_time) + + self.indent_level = max(0, self.indent_level - 1) + (self.error if exc_type else self.success)( + f"{self.name} ({elapsed_time:.2f}s elapsed)" + ) + + def process(self, name: str, indent_level: t.Optional[int] = None): + """ + A context manager for processing a message with automatic success or + error handling. If an exception is raised within the context, the + message is marked as an error; otherwise, it is marked as a success. + + Any messages written within the context are indented by the specified + level and written when the context is entered. + """ + return self.__class__( + write=self.write, + name=name, + indent_level=( + self.indent_level if indent_level is None else indent_level + ), + ) + + def divider(self, char="-", default_columns=80): + # 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/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..14601f89 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..aab78dac 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..f0f64eb7 100644 --- a/codeforlife/user/filters/klass.py +++ b/codeforlife/user/filters/klass.py @@ -16,14 +16,26 @@ # 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") + if klass.name and 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..12269b51 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", "last_name") + if (user.first_name and first_name in user.first_name.lower()) + or (user.last_name and last_name in user.last_name.lower()) + ] + + return queryset.filter(pk__in=pks) def type__method( self: FilterSet, diff --git a/codeforlife/user/fixtures/google_users.json b/codeforlife/user/fixtures/google_users.json index 13cd2a2a..df158c19 100644 --- a/codeforlife/user/fixtures/google_users.json +++ b/codeforlife/user/fixtures/google_users.json @@ -3,10 +3,12 @@ "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": "ZmFrZV9lbmM6WjI5dloyeGxMblJsWVdOb1pYSkFibTl6WTJodmIyd3VZMjl0", + "_email_hash": "google.teacher@noschool.com", + "_first_name": "ZmFrZV9lbmM6UjI5dloyeGw=", + "_first_name_hash": "Google", + "last_name": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, diff --git a/codeforlife/user/fixtures/independent.json b/codeforlife/user/fixtures/independent.json index 71d2f958..1f31b083 100644 --- a/codeforlife/user/fixtures/independent.json +++ b/codeforlife/user/fixtures/independent.json @@ -3,10 +3,12 @@ "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": "ZmFrZV9lbmM6YVc1a2VTNXlaWEYxWlhOMFpYSkFaVzFoYVd3dVkyOXQ=", + "_email_hash": "indy.requester@email.com", + "_first_name": "ZmFrZV9lbmM6U1c1a2VRPT0=", + "_first_name_hash": "Indy", + "last_name": "ZmFrZV9lbmM6VW1WeGRXVnpkR1Z5", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -31,10 +33,12 @@ "model": "user.user", "pk": 30, "fields": { - "first_name": "Indy", - "last_name": "NoRequest", - "username": "indy@email.com", - "email": "indy@email.com", + "dek": "ZmFrZV9lbmM6TTIxbFVXMWlWMnR3VXpOa2JXaENlbkJGUW05a1oyZEpkRzV6YlhGa1pVYz0=", + "_email": "ZmFrZV9lbmM6YVc1a2VVQmxiV0ZwYkM1amIyMD0=", + "_email_hash": "indy@email.com", + "_first_name": "ZmFrZV9lbmM6U1c1a2VRPT0=", + "_first_name_hash": "Indy", + "last_name": "ZmFrZV9lbmM6VG05U1pYRjFaWE4w", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json index ff6bb9dc..cf11a187 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -234,11 +234,12 @@ "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": "ZmFrZV9lbmM6VTNkcGMzTWdSbVZrWlhKaGJDQlFiMng1ZEdWamFHNXBZdz09" } }, { @@ -293,75 +294,80 @@ "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": "ZmFrZV9lbmM6UVVJeE1qTT0=", + "_access_code_hash": "AB123", + "always_accept_requests": true, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF4", + "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": "ZmFrZV9lbmM6UVVJeE1qUT0=", + "_access_code_hash": "AB124", + "always_accept_requests": true, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF5", + "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": "ZmFrZV9lbmM6UVVJeE1qVT0=", + "_access_code_hash": "AB125", + "always_accept_requests": true, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNVEF6", + "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": "ZmFrZV9lbmM6VWt3eE1qTT0=", + "_access_code_hash": "RL123", + "always_accept_requests": true, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name": "ZmFrZV9lbmM6V1c5MWJtY2dRMjlrWlhKeklERXdNUT09", + "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": "ZmFrZV9lbmM6VUU4eE1qTT0=", + "_access_code_hash": "PO123", + "always_accept_requests": true, + "classmates_data_viewable": true, + "created_by": null, "creation_time": null, "is_active": true, - "created_by": null + "name": "ZmFrZV9lbmM6VUc5eWRHRnNZV1J0YVc0bmN5QmpiR0Z6Y3c9PQ==", + "teacher": 4 } }, { @@ -571,17 +577,19 @@ "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": "ZmFrZV9lbmM6WTI5a1pXWnZjbXhwWm1VdGNHOXlkR0ZzUUc5allXUnZMbU52YlE9PQ==", + "_email_hash": "codeforlife-portal@ocado.com", + "_first_name": "ZmFrZV9lbmM6VUc5eWRHRnM=", + "_first_name_hash": "Portal", "groups": [], + "is_active": true, + "is_staff": true, + "is_superuser": true, + "last_login": null, + "last_name": "ZmFrZV9lbmM6UVdSdGFXND0=", + "password": "pbkdf2_sha256$1000000$y2lUeK8u5GnocWVuonlViT$TnNTL7sG9pcvz6MotXSSptrHdQzAFxmLnqNbj7syLS4=", "user_permissions": [] } }, @@ -589,17 +597,19 @@ "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": "ZmFrZV9lbmM6WVd4aVpYSjBaV2x1YzNSbGFXNUFZMjlrWldadmNteHBabVV1WTI5dA==", + "_email_hash": "alberteinstein@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6UVd4aVpYSjA=", + "_first_name_hash": "Albert", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6UldsdWMzUmxhVzQ9", + "password": "pbkdf2_sha256$1000000$IOkFLrqDnlDiEoASMl5OSY$C7gzJeOhwbfq/nJp47LaErHOxgA/3DIkaUI93ZUqauc=", "user_permissions": [] } }, @@ -607,17 +617,19 @@ "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": "ZmFrZV9lbmM6YldGNGNHeGhibU5yUUdOdlpHVm1iM0pzYVdabExtTnZiUT09", + "_email_hash": "maxplanck@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6VFdGNA==", + "_first_name_hash": "Max", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6VUd4aGJtTnI=", + "password": "pbkdf2_sha256$1000000$QHwukC6Cv822F2nSjQjLu0$09yKE5c8mFVwIHS5BWccM+q33Rs6XSCcc5rik3RD7eY=", "user_permissions": [] } }, @@ -625,17 +637,19 @@ "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": "ZmFrZV9lbmM6Y21GdGJHVnBkR2hBWTI5a1pXWnZjbXhwWm1VdVkyOXQ=", + "_email_hash": "ramleith@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6VW1GdA==", + "_first_name_hash": "Ram", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6VEdWcGRHZz0=", + "password": "pbkdf2_sha256$1000000$OcLHQ75ALkslmiUxFwe1Ft$7hpkII5kIi1lytO4PQ0p/6HdtT6//mPcZfZNjROXqrg=", "user_permissions": [] } }, @@ -643,17 +657,19 @@ "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": "ZmFrZV9lbmM6YkdWdmJtRnlaRzlrWVhacGJtTnBRR052WkdWbWIzSnNhV1psTG1OdmJRPT0=", + "_email_hash": "leonardodavinci@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6VEdWdmJtRnlaRzg9", + "_first_name_hash": "Leonardo", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6UkdGV2FXNWphUT09", + "password": "pbkdf2_sha256$1000000$6QiofvSo5LPJ0DlGshTh0h$XdFmFi/Al5vuhxw02nlZE/rUG8g2uHxFnHXs46XgT/c=", "user_permissions": [] } }, @@ -661,17 +677,19 @@ "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": "ZmFrZV9lbmM6WjJGc2FXeGxiMmRoYkdsc1pXbEFZMjlrWldadmNteHBabVV1WTI5dA==", + "_email_hash": "galileogalilei@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6UjJGc2FXeGxidz09", + "_first_name_hash": "Galileo", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6UjJGc2FXeGxhUT09", + "password": "pbkdf2_sha256$1000000$HF1HBQVpMLdAuYb0vt55r1$gY3+SetJqidFfylntSqmmrtremtNk8QuGeAdlHNqD9A=", "user_permissions": [] } }, @@ -679,17 +697,19 @@ "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": "ZmFrZV9lbmM6YVhOaFlXTnVaWGQwYjI1QVkyOWtaV1p2Y214cFptVXVZMjl0", + "_email_hash": "isaacnewton@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6U1hOaFlXTT0=", + "_first_name_hash": "Isaac", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6VG1WM2RHOXU=", + "password": "pbkdf2_sha256$1000000$BUSt8V8F4DwWZPlCZeS1Sc$WMQElSAhvIE7LRHx6lwTde9YG5P74dc7Az+639AHdYc=", "user_permissions": [] } }, @@ -697,17 +717,19 @@ "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": "ZmFrZV9lbmM6Y21samFHRnlaR1psZVc1dFlXNUFZMjlrWldadmNteHBabVV1WTI5dA==", + "_email_hash": "richardfeynman@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6VW1samFHRnlaQT09", + "_first_name_hash": "Richard", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6Um1WNWJtMWhiZz09", + "password": "pbkdf2_sha256$1000000$TaYcwU3iqPA4c4co14ZYHx$FAwypkSE7UucZod6BY/YY+A4zV4JbySNxDwd4ypOZT8=", "user_permissions": [] } }, @@ -715,17 +737,19 @@ "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": "ZmFrZV9lbmM6WVd4bGVHRnVaR1Z5Wm14bGJXMXBibWRBWTI5a1pXWnZjbXhwWm1VdVkyOXQ=", + "_email_hash": "alexanderflemming@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6UVd4bGVHRnVaR1Z5", + "_first_name_hash": "Alexander", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6Um14bGJXMXBibWM9", + "password": "pbkdf2_sha256$1000000$mCuu3Ptx9EGmKQ7FSNIqzw$dqwQK/ovljow4iGxfLIQTm8PPR8Iu5AITzQ9tVQxv9w=", "user_permissions": [] } }, @@ -733,17 +757,19 @@ "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": "ZmFrZV9lbmM6WkdGdWFXVnNZbVZ5Ym05MWJHeHBRR052WkdWbWIzSnNhV1psTG1OdmJRPT0=", + "_email_hash": "danielbernoulli@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6UkdGdWFXVnM=", + "_first_name_hash": "Daniel", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6UW1WeWJtOTFiR3hw", + "password": "pbkdf2_sha256$1000000$isUqjEe0ZtdHC0tDFilALA$k20Gy//4APEqsGNYGa8aQPUTUBqMPKe6hI5GMIWiMnE=", "user_permissions": [] } }, @@ -751,17 +777,19 @@ "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": "ZmFrZV9lbmM6YVc1a2FXRnVZV3B2Ym1WelFHTnZaR1ZtYjNKc2FXWmxMbU52YlE9PQ==", + "_email_hash": "indianajones@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6U1c1a2FXRnVZUT09", + "_first_name_hash": "Indiana", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6U205dVpYTT0=", + "password": "pbkdf2_sha256$1000000$mjbmbYr7G3WRyxxHlDcH6d$w1wf3D0lDP6pF3nSoTUD3vSRioCzPEloW3r8sXnLSJw=", "user_permissions": [] } }, @@ -769,17 +797,17 @@ "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": "ZmFrZV9lbmM6VG05aGFBPT0=", + "_first_name_hash": "Noah", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6VFc5dVlXZG9ZVzQ9", + "password": "pbkdf2_sha256$1000000$qm1oQx3HNeIx6dpY1aRzXb$xMKwoqXoW6MuHqoABww+Yq+3A1uwkLbkpaWWytNu6l0=", "user_permissions": [] } }, @@ -787,17 +815,17 @@ "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": "ZmFrZV9lbmM6Uld4c2FXOTA=", + "_first_name_hash": "Elliot", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6VTJoaGNuQT0=", + "password": "pbkdf2_sha256$1000000$vQZKG1iO3Np3cdOHSrG85F$QnuZLe1KvMt64uWXjz3RKOEnr6owhloxfgs3u8xB6GQ=", "user_permissions": [] } }, @@ -805,17 +833,17 @@ "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": "ZmFrZV9lbmM6VkdGcWJXRmw=", + "_first_name_hash": "Tajmae", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6U205elpYQm8=", + "password": "pbkdf2_sha256$1000000$iCO3IsvmN5C8rpX5J0d1ob$TFK2zYume/i4StDly6EUiqFej/sOnuzV/wYE9rgr/cg=", "user_permissions": [] } }, @@ -823,17 +851,17 @@ "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": "ZmFrZV9lbmM6UTJGeWJIUnZiZz09", + "_first_name_hash": "Carlton", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6U205elpYQm8=", + "password": "pbkdf2_sha256$1000000$6Nhm1sHtcMbFdBitdz7nxq$s5i8H7MwdH6SRQ3+DZDDfsPtqxXVDPAC5t67G4FluM4=", "user_permissions": [] } }, @@ -841,17 +869,17 @@ "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": "ZmFrZV9lbmM6VG1Ga1lXdz0=", + "_first_name_hash": "Nadal", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6VTNCbGJtTmxjaTFLWlc1dWFXNW5jdz09", + "password": "pbkdf2_sha256$1000000$FRA5lx8xBTO5Rn8J9wwZZW$KiQzLItBM40bzbwRdXpKnrI1uWXv0xsSzTMkFwAewwo=", "user_permissions": [] } }, @@ -859,17 +887,17 @@ "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": "ZmFrZV9lbmM6Um5KbFpHUnBaUT09", + "_first_name_hash": "Freddie", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6UjI5bVpnPT0=", + "password": "pbkdf2_sha256$1000000$LAQQycnLxi5rY9QCD15nHw$Ad5r4JEo5CCQrwuD2F12ajXnV/bDSL+FGQ99vM2l0/k=", "user_permissions": [] } }, @@ -877,17 +905,17 @@ "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": "ZmFrZV9lbmM6VEdWdmJnPT0=", + "_first_name_hash": "Leon", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6VTJOdmRIUT0=", + "password": "pbkdf2_sha256$1000000$XHhBEg6yrF9a6TR487gNrU$DOR6TONr+9q0rLXJwB43UuezRcQkQAmEmoN0nYmm4do=", "user_permissions": [] } }, @@ -895,17 +923,17 @@ "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": "ZmFrZV9lbmM6UW1WMGRIaz0=", + "_first_name_hash": "Betty", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6UzJWemMyVnNiQT09", + "password": "pbkdf2_sha256$1000000$ua0WUUCFIijIjxpLdkc25v$mvl+UejCNGop0krujly8TPlMbgWPFVZ42tH+NPDltS4=", "user_permissions": [] } }, @@ -913,17 +941,17 @@ "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": "ZmFrZV9lbmM6UkdWc1pYUmxaQT09", + "_first_name_hash": "Deleted", "groups": [], + "is_active": false, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6VlhObGNnPT0=", + "password": "pbkdf2_sha256$1000000$o2AfZh0JqqIFRTZGHWEQKV$c1es5kdOakAvCGkeMVgDOX5q+rFhdQgOkMZiKAtqMrk=", "user_permissions": [] } }, @@ -931,17 +959,19 @@ "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": "ZmFrZV9lbmM6WVdSdGFXNXpkSFZrWlc1MFFHTnZaR1ZtYjNKc2FXWmxMbU52YlE9PQ==", + "_email_hash": "adminstudent@codeforlife.com", + "_first_name": "ZmFrZV9lbmM6VUc5eWRHRnNZV1J0YVc0PQ==", + "_first_name_hash": "Portaladmin", "groups": [], + "is_active": true, + "is_staff": false, + "is_superuser": false, + "last_login": null, + "last_name": "ZmFrZV9lbmM6VTNSMVpHVnVkQT09", + "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..98927c14 100644 --- a/codeforlife/user/fixtures/non_school_teacher.json +++ b/codeforlife/user/fixtures/non_school_teacher.json @@ -3,10 +3,12 @@ "model": "user.user", "pk": 22, "fields": { - "first_name": "John", - "last_name": "Doe", - "username": "teacher@noschool.com", - "email": "teacher@noschool.com", + "dek": "ZmFrZV9lbmM6TkhGdE1EWlFOVkJ3VDFwTVVYWmlVRzlFYVdwU1JIUTVialZ0V1RoQ2NXYz0=", + "_email": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J1YjNOamFHOXZiQzVqYjIwPQ==", + "_email_hash": "teacher@noschool.com", + "_first_name": "ZmFrZV9lbmM6U205b2JnPT0=", + "_first_name_hash": "John", + "last_name": "ZmFrZV9lbmM6Ukc5bA==", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -30,10 +32,12 @@ "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": "ZmFrZV9lbmM6ZFc1MlpYSnBabWxsWkM1MFpXRmphR1Z5UUc1dmMyTm9iMjlzTG1OdmJRPT0=", + "_email_hash": "unverified.teacher@noschool.com", + "_first_name": "ZmFrZV9lbmM6Vlc1MlpYSnBabWxsWkE9PQ==", + "_first_name_hash": "Unverified", + "last_name": "ZmFrZV9lbmM6VkdWaFkyaGxjZz09", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, diff --git a/codeforlife/user/fixtures/school_1.json b/codeforlife/user/fixtures/school_1.json index 444fa023..c06b482e 100644 --- a/codeforlife/user/fixtures/school_1.json +++ b/codeforlife/user/fixtures/school_1.json @@ -3,19 +3,22 @@ "model": "user.school", "pk": 2, "fields": { - "name": "School 1", "country": "GB", - "county": "Hertfordshire" + "county": "Hertfordshire", + "dek": "ZmFrZV9lbmM6V1cxTVdtMXFibTgyVlhadGVFTlRla1p2ZDBOM2QzVnVSMEZ3WkdGeVpsQT0=", + "name": "ZmFrZV9lbmM6VTJOb2IyOXNJREU9" } }, { "model": "user.user", "pk": 23, "fields": { - "first_name": "John", - "last_name": "Doe", - "username": "teacher@school1.com", - "email": "teacher@school1.com", + "dek": "ZmFrZV9lbmM6U2tGVmEzZHhlVkF4VERKR1QwaGxPVWRTWkhkVE16WTBiREV3WjFkQlVHcz0=", + "_email": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3hMbU52YlE9PQ==", + "_email_hash": "teacher@school1.com", + "_first_name": "ZmFrZV9lbmM6U205b2JnPT0=", + "_first_name_hash": "John", + "last_name": "ZmFrZV9lbmM6Ukc5bA==", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -40,18 +43,20 @@ "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": "ZmFrZV9lbmM6V2xveE1URT0=", + "_access_code_hash": "ZZ111", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNU0JBSUZOamFHOXZiQ0F4", + "teacher": 6 } }, { "model": "user.user", "pk": 27, "fields": { - "first_name": "Student1", - "username": "111111111111111111111111111111", + "dek": "ZmFrZV9lbmM6ZEc1aFZ6RkhVRTFNVFdaSWQzTlZXRmxRZFRsTFZIbE1kRWxSVGs1Q1FWQT0=", + "_first_name": "ZmFrZV9lbmM6VTNSMVpHVnVkREU9", + "_first_name_hash": "Student1", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -76,10 +81,12 @@ "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": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3eExtTnZiUT09", + "_email_hash": "admin.teacher@school1.com", + "_first_name": "ZmFrZV9lbmM6U21GdVpRPT0=", + "_first_name_hash": "Jane", + "last_name": "ZmFrZV9lbmM6Ukc5bA==", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -105,8 +112,9 @@ "model": "user.class", "pk": 7, "fields": { - "name": "Class 2 @ School 1", - "access_code": "ZZ222", + "_access_code": "ZmFrZV9lbmM6V2xveU1qST0=", + "_access_code_hash": "ZZ222", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F4", "teacher": 7 } }, @@ -114,8 +122,9 @@ "model": "user.user", "pk": 29, "fields": { - "first_name": "Student2", - "username": "222222222222222222222222222222", + "dek": "ZmFrZV9lbmM6YXpWd1dGZEZOWHBXZEdnMFJWQlROWFZFV2s0eVRuVTBOVTlGZUdwNWJtND0=", + "_first_name": "ZmFrZV9lbmM6VTNSMVpHVnVkREk9", + "_first_name_hash": "Student2", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -140,10 +149,11 @@ "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": "ZmFrZV9lbmM6V2xvek16TT0=", + "_access_code_hash": "ZZ333", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNeUJBSUZOamFHOXZiQ0F4", + "teacher": 7 } } ] diff --git a/codeforlife/user/fixtures/school_2.json b/codeforlife/user/fixtures/school_2.json index 3c3b7a07..5320af19 100644 --- a/codeforlife/user/fixtures/school_2.json +++ b/codeforlife/user/fixtures/school_2.json @@ -3,19 +3,22 @@ "model": "user.school", "pk": 3, "fields": { - "name": "School 2", "country": "GB", - "county": "Hertfordshire" + "county": "Hertfordshire", + "dek": "ZmFrZV9lbmM6VlUxaWN6TTVhbk56WW5wcFpIVXhhVlE1UW10WGFVcHpUa3BFT0VNNVdtYz0=", + "name": "ZmFrZV9lbmM6VTJOb2IyOXNJREk9" } }, { "model": "user.user", "pk": 25, "fields": { - "first_name": "John", - "last_name": "Doe", - "username": "teacher@school2.com", - "email": "teacher@school2.com", + "dek": "ZmFrZV9lbmM6UkhWQ1kwaHlOa2hqYzFSV1MxcE1SblkwYnpnMGRtNUxWMnRLV1dWMlRUYz0=", + "_email": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3lMbU52YlE9PQ==", + "_email_hash": "teacher@school2.com", + "_first_name": "ZmFrZV9lbmM6U205b2JnPT0=", + "_first_name_hash": "John", + "last_name": "ZmFrZV9lbmM6Ukc5bA==", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -129,8 +132,9 @@ "model": "user.class", "pk": 8, "fields": { - "name": "Class 1 @ School 2", - "access_code": "XX111", + "_access_code": "ZmFrZV9lbmM6V0ZneE1URT0=", + "_access_code_hash": "XX111", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNU0JBSUZOamFHOXZiQ0F5", "teacher": 8 } }, @@ -138,10 +142,12 @@ "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": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3eUxtTnZiUT09", + "_email_hash": "admin.teacher@school2.com", + "_first_name": "ZmFrZV9lbmM6U21GdVpRPT0=", + "_first_name_hash": "Jane", + "last_name": "ZmFrZV9lbmM6Ukc5bA==", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -176,8 +182,9 @@ "model": "user.class", "pk": 9, "fields": { - "name": "Class 2 @ School 2", - "access_code": "XX222", + "_access_code": "ZmFrZV9lbmM6V0ZneU1qST0=", + "_access_code_hash": "XX222", + "name": "ZmFrZV9lbmM6UTJ4aGMzTWdNaUJBSUZOamFHOXZiQ0F5", "teacher": 9 } } diff --git a/codeforlife/user/fixtures/school_3.json b/codeforlife/user/fixtures/school_3.json index bbd1ec10..cf2f3f44 100644 --- a/codeforlife/user/fixtures/school_3.json +++ b/codeforlife/user/fixtures/school_3.json @@ -3,19 +3,22 @@ "model": "user.school", "pk": 4, "fields": { - "name": "School 3", "country": "GB", - "county": "Hertfordshire" + "county": "Hertfordshire", + "dek": "ZmFrZV9lbmM6Vm1ORlNIbENURmRyZEdOR1VGaHFOMFZIVFhkRE1IQjVNR1oyTkdkVFNIRT0=", + "name": "ZmFrZV9lbmM6VTJOb2IyOXNJRE09" } }, { "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": "ZmFrZV9lbmM6WVdSdGFXNHVkR1ZoWTJobGNrQnpZMmh2YjJ3ekxtTnZiUT09", + "_email_hash": "admin.teacher@school3.com", + "_first_name": "ZmFrZV9lbmM6VUdWMFpYST0=", + "_first_name_hash": "Peter", + "last_name": "ZmFrZV9lbmM6VUdGeWEyVnk=", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, @@ -41,10 +44,12 @@ "model": "user.user", "pk": 32, "fields": { - "first_name": "Doctor", - "last_name": "Octopus", - "username": "teacher@school3.com", - "email": "teacher@school3.com", + "dek": "ZmFrZV9lbmM6VjA5cGVVcG1SR1pRTkdoNVptUXhOR1Z0ZEVOblVtUmtjRWhJUlZob01Faz0=", + "_email": "ZmFrZV9lbmM6ZEdWaFkyaGxja0J6WTJodmIyd3pMbU52YlE9PQ==", + "_email_hash": "teacher@school3.com", + "_first_name": "ZmFrZV9lbmM6Ukc5amRHOXk=", + "_first_name_hash": "Doctor", + "last_name": "ZmFrZV9lbmM6VDJOMGIzQjFjdz09", "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" } }, 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..fb773b03 --- /dev/null +++ b/codeforlife/user/management/commands/encrypt_plaintext_fields.py @@ -0,0 +1,156 @@ +""" +© 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.management.base import BaseCommand +from django.db.models import CharField, Model, TextField + +from ....models.fields import EncryptedTextField +from ....pprint import PrettyPrinter + +PlaintextField: t.TypeAlias = t.Union[CharField, TextField] +FieldsToEncrypt: t.TypeAlias = t.Dict[PlaintextField, EncryptedTextField] + + +# pylint: disable-next=missing-class-docstring +class Command(BaseCommand): + format_help = ( + "Arguments should be in the format 'app_label.ModelName=" + "plain_field1:encrypted_text_field1," + "plain_field2:encrypted_text_field2'." + ) + help = f"Encrypts plaintext fields for specified models. {format_help}" + + def add_arguments(self, parser): + parser.add_argument( + "model_fields", nargs="+", type=str, help=self.format_help + ) + parser.add_argument( + "--chunk-size", + type=int, + default=100, + help="The number of records to process in each batch.", + ) + + def _parse_arg(self, arg: str, pprint: PrettyPrinter): + # Split the argument into model specification and field name pairs. + if not "=" in arg: + raise ValueError(self.format_help) + model_spec, field_csv = arg.split("=", 1) + + # Retrieve the model class based on the app and model name. + if "." not in model_spec: + raise ValueError(self.format_help) + app_label, model_name = model_spec.split(".", 1) + model_class: t.Type[Model] = apps.get_model(app_label, model_name) + pprint(f"Found model: {pprint.notice.apply(model_spec)}") + + # Parse the field name pairs and validate their existence in the model. + fields_to_encrypt: FieldsToEncrypt = {} + for field_name_pair in field_csv.split(","): + # Split the field name pair. + if ":" not in field_name_pair: + raise ValueError(self.format_help) + plain_field_name, enc_field_name = field_name_pair.split(":", 1) + + # Strip and validate field names. + plain_field_name = plain_field_name.strip() + enc_field_name = enc_field_name.strip() + if not plain_field_name or not enc_field_name: + raise ValueError(self.format_help) + + # Get the plaintext field and validate its type. + plain_field = model_class._meta.get_field(plain_field_name) + if not isinstance(plain_field, (CharField, TextField)): + raise ValueError( + f"Plaintext field '{plain_field_name}' must be " + "a CharField or TextField." + ) + if plain_field in fields_to_encrypt: + raise ValueError( + f"Duplicate plaintext field '{plain_field_name}' in " + "argument." + ) + pprint( + "Found plaintext field: " + + pprint.notice.apply(f"{model_spec}.{plain_field_name}") + ) + + # Get the encrypted field and validate its type. + enc_field = model_class._meta.get_field(enc_field_name) + if not isinstance(enc_field, EncryptedTextField): + raise ValueError( + f"Encrypted field '{enc_field_name}' must be an " + "EncryptedTextField." + ) + if enc_field in fields_to_encrypt.values(): + raise ValueError( + f"Duplicate encrypted field '{enc_field_name}' in " + "argument." + ) + pprint( + "Found encrypted field: " + + pprint.notice.apply(f"{model_spec}.{enc_field_name}") + ) + + fields_to_encrypt[plain_field] = enc_field + + return model_class, fields_to_encrypt + + # pylint: disable-next=too-many-arguments,too-many-positional-arguments + def _encrypt_field( + self, + chunk_size: int, + model_class: t.Type[Model], + plain_field: PlaintextField, + enc_field: EncryptedTextField, + pprint: PrettyPrinter, + ): + models = model_class.objects.filter( # type: ignore[attr-defined] + **{f"{plain_field.name}__isnull": False} + ) + model_count = models.count() + for model_index, model in enumerate(models.iterator(chunk_size)): + if model_index % chunk_size == 0: + pprint(f"({model_index}/{model_count})") + + plaintext = getattr(model, plain_field.name) + setattr(model, enc_field.name, plaintext) + model.save(update_fields=[enc_field.name]) + + def handle(self, *args, **options): + model_fields: t.List[str] = options["model_fields"] + chunk_size: int = options["chunk_size"] + + pprint = PrettyPrinter(write=self.stdout.write, name=self.__module__) + + for arg in model_fields: + with pprint.process( + "Processing argument: " + f'"{arg if len(arg) <= 50 else f"{arg[:37]}...{arg[-10:]}"}"' + ) as arg_pprint: + with arg_pprint.process("Parsing argument") as parse_pprint: + model_class, fields_to_encrypt = self._parse_arg( + arg, parse_pprint + ) + + with arg_pprint.process( + "Encrypting model:" + f" {model_class._meta.app_label}" + f".{model_class._meta.model_name}" + ) as enc_model_pprint: + for plain_field, enc_field in fields_to_encrypt.items(): + with enc_model_pprint.process( + f"Field: {plain_field.name} -> {enc_field.name}" + ) as enc_field_pprint: + self._encrypt_field( + chunk_size, + model_class, + plain_field, + enc_field, + enc_field_pprint, + ) 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..3215ae4d --- /dev/null +++ b/codeforlife/user/migrations/0003_client_side_encryption_part_1.py @@ -0,0 +1,146 @@ +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( + 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/migrations/0004_client_side_encryption_part_2.py b/codeforlife/user/migrations/0004_client_side_encryption_part_2.py new file mode 100644 index 00000000..d761ae59 --- /dev/null +++ b/codeforlife/user/migrations/0004_client_side_encryption_part_2.py @@ -0,0 +1,198 @@ +from django.db import migrations + +from ...models.fields import EncryptedTextField, Sha256Field + +user_migrations = [ + migrations.AlterField( + model_name="user", + name="email_hash", + field=Sha256Field( + db_column="email_hash", + editable=False, + max_length=64, + null=True, + unique=True, + verbose_name="email hash", + ), + ), + migrations.RenameField( + model_name="user", + old_name="email_hash", + new_name="_email_hash", + ), + migrations.AlterField( + model_name="user", + name="email_enc", + field=EncryptedTextField( + associated_data="email", + db_column="email", + null=True, + verbose_name="email address", + ), + ), + migrations.RenameField( + model_name="user", + old_name="email_enc", + new_name="_email", + ), + migrations.RemoveField( + model_name="user", + name="email_plain", + ), + migrations.AlterField( + model_name="user", + name="first_name_hash", + field=Sha256Field( + db_column="first_name_hash", + editable=False, + max_length=64, + null=True, + verbose_name="first name hash", + ), + ), + migrations.RenameField( + model_name="user", + old_name="first_name_hash", + new_name="_first_name_hash", + ), + migrations.AlterField( + model_name="user", + name="first_name_enc", + field=EncryptedTextField( + associated_data="first_name", + db_column="first_name", + null=True, + verbose_name="first name", + ), + ), + migrations.RenameField( + model_name="user", + old_name="first_name_enc", + new_name="_first_name", + ), + migrations.RemoveField( + model_name="user", + name="first_name_plain", + ), + migrations.RenameField( + model_name="user", + old_name="last_name_enc", + new_name="last_name", + ), + migrations.RemoveField( + model_name="user", + name="last_name_plain", + ), +] + +class_migrations = [ + migrations.AlterField( + model_name="class", + name="access_code_hash", + field=Sha256Field( + db_column="access_code_hash", + editable=False, + max_length=64, + null=True, + verbose_name="access code hash", + ), + ), + migrations.RenameField( + model_name="class", + old_name="access_code_hash", + new_name="_access_code_hash", + ), + migrations.AlterField( + model_name="class", + name="access_code_enc", + field=EncryptedTextField( + associated_data="access_code", + db_column="access_code", + null=True, + verbose_name="access code", + ), + ), + migrations.RenameField( + model_name="class", + old_name="access_code_enc", + new_name="_access_code", + ), + migrations.RemoveField( + model_name="class", + name="access_code_plain", + ), + migrations.RenameField( + model_name="class", + old_name="name_enc", + new_name="name", + ), + migrations.RemoveField( + model_name="class", + name="name_plain", + ), +] + +school_migrations = [ + migrations.RenameField( + model_name="school", + old_name="name_enc", + new_name="name", + ), + migrations.RemoveField( + model_name="school", + name="name_plain", + ), +] + +school_teacher_invitation_migrations = [ + migrations.RenameField( + model_name="schoolteacherinvitation", + old_name="invited_teacher_email_enc", + new_name="invited_teacher_email", + ), + migrations.RemoveField( + model_name="schoolteacherinvitation", + name="invited_teacher_email_plain", + ), + migrations.RenameField( + model_name="schoolteacherinvitation", + old_name="invited_teacher_first_name_enc", + new_name="invited_teacher_first_name", + ), + migrations.RemoveField( + model_name="schoolteacherinvitation", + name="invited_teacher_first_name_plain", + ), + migrations.RenameField( + model_name="schoolteacherinvitation", + old_name="invited_teacher_last_name_enc", + new_name="invited_teacher_last_name", + ), + migrations.RemoveField( + model_name="schoolteacherinvitation", + name="invited_teacher_last_name_plain", + ), + migrations.RenameField( + model_name="schoolteacherinvitation", + old_name="token_enc", + new_name="token", + ), + migrations.RemoveField( + model_name="schoolteacherinvitation", + name="token_plain", + ), +] + + +class Migration(migrations.Migration): + + dependencies = [ + ("user", "0003_client_side_encryption_part_1"), + ] + + operations = [ + *user_migrations, + *class_migrations, + *school_migrations, + *school_teacher_invitation_migrations, + ] diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index 8d5bcced..c4fc3d50 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,49 @@ 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 = EncryptedTextField( + associated_data="name", + null=True, + verbose_name=_("name"), + ) + + 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] - max_length=5, + _access_code_hash = Sha256Field( + verbose_name=_("access code hash"), null=True, + db_column="access_code_hash", ) + _access_code = EncryptedTextField( + associated_data="access_code", + null=True, + verbose_name=_("access code"), + db_column="access_code", + ) + + @property + def access_code(self): + """Get the access code for the class.""" + return self._access_code + + @access_code.setter + def access_code(self, value: t.Optional[str]): + """Set the access code for the class.""" + self._access_code = value + self._access_code_hash = value classmates_data_viewable: bool classmates_data_viewable = models.BooleanField( # type: ignore[assignment] @@ -103,7 +134,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 +181,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..67f54235 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,13 @@ 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 = EncryptedTextField( + associated_data="token", + null=True, + verbose_name=_("token"), + ) school: t.Optional["School"] school = models.ForeignKey( # type: ignore[assignment] @@ -245,21 +257,23 @@ class SchoolTeacherInvitation(models.Model): on_delete=models.SET_NULL, ) - invited_teacher_first_name: str - invited_teacher_first_name = models.CharField( # type: ignore[assignment] - max_length=150 - ) # Same as User model + invited_teacher_first_name = 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] - max_length=150 - ) # Same as User model + invited_teacher_last_name = EncryptedTextField( + associated_data="invited_teacher_last_name", + null=True, + verbose_name=_("invited teacher last name"), + ) - # TODO: Switch to a CharField to be able to hold hashed value - invited_teacher_email: str - invited_teacher_email = ( - models.EmailField() # type: ignore[assignment] - ) # Same as User model + invited_teacher_email = EncryptedTextField( + associated_data="invited_teacher_email", + null=True, + verbose_name=_("invited teacher email"), + ) invited_teacher_is_admin: bool invited_teacher_is_admin = models.BooleanField( # type: ignore[assignment] @@ -279,7 +293,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 +316,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..4486b9a5 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,13 +41,15 @@ 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] - max_length=200, - unique=True, + associated_data = "school" + + name = EncryptedTextField( + associated_data="name", + null=True, + verbose_name=_("name"), ) country: t.Optional[str] @@ -71,7 +76,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..20b77e6b 100644 --- a/codeforlife/user/models/user/contactable.py +++ b/codeforlife/user/models/user/contactable.py @@ -23,7 +23,7 @@ # pylint: disable-next=missing-class-docstring,too-few-public-methods class ContactableUserManager(UserManager[AnyUser], t.Generic[AnyUser]): def filter_users(self, queryset: QuerySet[User]): - return queryset.exclude(email__isnull=True).exclude(email="") + return queryset.exclude(_email__isnull=True).exclude(_email=b"") # pylint: disable-next=too-many-ancestors @@ -52,6 +52,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..4f902383 100644 --- a/codeforlife/user/models/user/google.py +++ b/codeforlife/user/models/user/google.py @@ -56,15 +56,13 @@ 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", + *user.EMAIL_FIELDS, + *user.FIRST_NAME_FIELDS, "last_name", ] ) @@ -76,7 +74,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..3593446c 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, ) @@ -86,10 +86,11 @@ class StudentUser(User): # TODO: move this is to Student model in new schema. _login_id: t.Optional[str] + last_name: None # type: ignore[assignment] teacher: None student: "Student" - credential_fields = frozenset(["first_name", "password"]) + CREDENTIAL_FIELDS = frozenset(["first_name", "password"]) class Meta(TypedModelMeta): proxy = True @@ -135,17 +136,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..18765d91 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, @@ -88,6 +84,7 @@ def get_queryset(self): class TeacherUser(ContactableUser): """A user that is a teacher.""" + last_name: str # type: ignore[assignment] teacher: "Teacher" student: None diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 6d938423..2d21d70a 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,24 +46,108 @@ ] -# 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 + ) - _password: t.Optional[str] + 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.""" + + ### Type hints for fields and related objects. + _password: t.Optional[str] id: int # type: ignore[assignment] auth_factors: QuerySet["AuthFactor"] # type: ignore[assignment,misc] # pylint: disable-next=line-too-long @@ -75,7 +155,92 @@ class User( session: "Session" # type: ignore[assignment] userprofile: "UserProfile" - credential_fields = frozenset(["email", "password"]) + ### Data encryption key model configuration. + associated_data = "user" + + ### Django auth field registries. + EMAIL_FIELD = "_email" + USERNAME_FIELD = "_email_hash" + REQUIRED_FIELDS = ["_email"] + + ### Custom field registries. + CREDENTIAL_FIELDS = frozenset(["email", "password"]) + FIRST_NAME_FIELDS = frozenset(["_first_name", "_first_name_hash"]) + EMAIL_FIELDS = frozenset(["_email", "_email_hash"]) + + ### First name fields. + _first_name_hash = Sha256Field( + verbose_name=_("first name hash"), + db_column="first_name_hash", + null=True, + ) + _first_name = EncryptedTextField( + associated_data="first_name", + null=True, + verbose_name=_("first name"), + db_column="first_name", + ) + + @property + def first_name(self): + """The user's first name.""" + return self._first_name + + @first_name.setter + def first_name(self, value: str): + """Set first name and hash immediately.""" + self._first_name = value + self._first_name_hash = value + + ### Email fields. + _email_hash = Sha256Field( + verbose_name=_("email hash"), + unique=True, + null=True, + db_column="email_hash", + ) + _email = EncryptedTextField( + associated_data="email", + null=True, + verbose_name=_("email address"), + db_column="email", + ) + + @property + def email(self): + """The user's email address.""" + return self._email + + @email.setter + def email(self, value: t.Optional[str]): + """Set the user's email address.""" + value = self.__class__.objects.normalize_email(value) + self._email = value + self._email_hash = value + + ### Other fields. + last_name = EncryptedTextField( + associated_data="last_name", null=True, verbose_name=_("last name") + ) + + is_staff = models.BooleanField( + _("staff status"), + default=False, + 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 +257,10 @@ class User( null=True, ) + objects: UserManager[ # type: ignore[misc] + "User" + ] = UserManager() # type: ignore[assignment] + @property def is_authenticated(self): return ( @@ -171,11 +340,12 @@ def as_type(self, user_class: t.Type["AnyUser"]): """ return user_class( pk=self.pk, - first_name=self.first_name, + _first_name=self._first_name, + _first_name_hash=self._first_name_hash, last_name=self.last_name, - username=self.username, is_active=self.is_active, - email=self.email, + _email=self._email, + _email_hash=self._email_hash, is_staff=self.is_staff, date_joined=self.date_joined, is_superuser=self.is_superuser, @@ -191,22 +361,26 @@ def anonymize(self): self.is_active = False self.save( update_fields=[ - "first_name", + # pylint: disable=duplicate-code + *self.FIRST_NAME_FIELDS, + *self.EMAIL_FIELDS, "last_name", - "email", - "username", "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 +392,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..639c08c7 100644 --- a/codeforlife/user/serializers/user_test.py +++ b/codeforlife/user/serializers/user_test.py @@ -31,9 +31,19 @@ def test_to_representation__teacher(self): "is_admin": user.teacher.is_admin, }, "student": None, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, }, # TODO: remove in new schema. - non_model_fields={"requesting_to_join_class", "teacher", "student"}, + non_model_fields={ + "requesting_to_join_class", + "teacher", + "student", + "first_name", + "last_name", + "email", + }, ) def test_to_representation__student(self): @@ -53,9 +63,19 @@ def test_to_representation__student(self): # pylint: disable-next=line-too-long "school": user.student.class_field.teacher.school.id, # type: ignore[union-attr] }, + "first_name": user.first_name, + "last_name": None, + "email": None, }, # TODO: remove in new schema. - non_model_fields={"requesting_to_join_class", "teacher", "student"}, + non_model_fields={ + "requesting_to_join_class", + "teacher", + "student", + "first_name", + "last_name", + "email", + }, ) def test_to_representation__indy(self): @@ -71,7 +91,17 @@ def test_to_representation__indy(self): "requesting_to_join_class": None, "teacher": None, "student": None, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, }, # TODO: remove in new schema. - non_model_fields={"requesting_to_join_class", "teacher", "student"}, + non_model_fields={ + "requesting_to_join_class", + "teacher", + "student", + "first_name", + "last_name", + "email", + }, ) diff --git a/codeforlife/user/signals/user.py b/codeforlife/user/signals/user.py index e086eb1f..c450638a 100644 --- a/codeforlife/user/signals/user.py +++ b/codeforlife/user/signals/user.py @@ -3,11 +3,32 @@ Created on 14/03/2024 at 12:14:54(+00:00). """ -# TODO: use custom User model in new data schema -# from ..models import User -# pylint: disable-next=imported-auth-user -from django.contrib.auth.models import User +from django.db.models import signals -from ...models.signals import model_receiver +from ...models.signals import ( + UpdateFields, + model_receiver, + validate_update_fields_includes_none_or_all, +) +from ..models import User user_receiver = model_receiver(User) + +# pylint: disable=unused-argument,missing-function-docstring + + +@user_receiver(signals.pre_save) +def user__pre_save( + sender, + instance: User, + raw: bool, + using: str, + update_fields: UpdateFields, + **kwargs +): + validate_update_fields_includes_none_or_all( + update_fields, User.FIRST_NAME_FIELDS + ) + validate_update_fields_includes_none_or_all( + update_fields, User.EMAIL_FIELDS + ) diff --git a/codeforlife/user/signals/user_test.py b/codeforlife/user/signals/user_test.py new file mode 100644 index 00000000..364cdd25 --- /dev/null +++ b/codeforlife/user/signals/user_test.py @@ -0,0 +1,78 @@ +""" +© Ocado Group +Created on 14/03/2024 at 12:14:54(+00:00). +""" + +from ...tests import TestCase +from ..models import User + + +# pylint: disable-next=missing-class-docstring +class UserSignalsTests(TestCase): + fixtures = ["school_1"] + + def setUp(self): + user = User.objects.first() + assert user + self.user = user + + def test_pre_save__update_fields_incomplete__first_name(self): + """ + Saving a User with only one of the first_name fields in update_fields + should raise a ValidationError, but saving with both or neither should + succeed. + """ + + with self.subTest("Only _first_name included in update_fields"): + with self.assert_raises_validation_error( + "update_fields_incomplete" + ): + self.user.save(update_fields={"_first_name"}) + + with self.subTest("Only _first_name_hash included in update_fields"): + with self.assert_raises_validation_error( + "update_fields_incomplete" + ): + self.user.save(update_fields={"_first_name_hash"}) + + with self.subTest( + "Neither _first_name nor _first_name_hash included in update_fields" + ): + self.user.save() + self.user.save(update_fields={}) + + with self.subTest( + "Both _first_name and _first_name_hash included in update_fields" + ): + self.user.first_name = "John" + self.user.save(update_fields={"_first_name", "_first_name_hash"}) + + def test_pre_save__update_fields_incomplete__email(self): + """ + Saving a User with only one of the email fields in update_fields should + raise a ValidationError, but saving with both or neither should succeed. + """ + + with self.subTest("Only _email included in update_fields"): + with self.assert_raises_validation_error( + "update_fields_incomplete" + ): + self.user.save(update_fields={"_email"}) + + with self.subTest("Only _email_hash included in update_fields"): + with self.assert_raises_validation_error( + "update_fields_incomplete" + ): + self.user.save(update_fields={"_email_hash"}) + + with self.subTest( + "Neither _email nor _email_hash included in update_fields" + ): + self.user.save() + self.user.save(update_fields={}) + + with self.subTest( + "Both _email and _email_hash included in update_fields" + ): + self.user.email = "john.doe@example.com" + self.user.save(update_fields={"_email", "_email_hash"}) diff --git a/codeforlife/user/views/klass.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..2b08d4e9 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") + if klass.name and partial_name in klass.name.lower() + ], filters={"id_or_name": partial_name}, ) diff --git a/codeforlife/user/views/user_test.py b/codeforlife/user/views/user_test.py index c36d833e..6ba0c7d2 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", "last_name") + if (user.first_name and first_name in user.first_name.lower()) + or (user.last_name and last_name in user.last_name.lower()) + ] self.client.login_as(user) 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..14ac292f 100644 --- a/setup.py +++ b/setup.py @@ -98,9 +98,10 @@ def parse_requirements(packages: t.Dict[str, t.Dict[str, t.Any]]): long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/ocadotechnology/codeforlife-package-python", - # TODO: exclude test files - packages=find_packages(exclude=["tests", "tests.*"]), + packages=find_packages(where="codeforlife"), + package_dir={"codeforlife": "codeforlife"}, include_package_data=True, + exclude_package_data={"codeforlife": ["**/*_test.py", "**/test_*.py"]}, data_files=[ get_data_files(DATA_DIR), get_data_files(USER_FIXTURES_DIR),