diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..af15cbc6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Pytest", + "type": "python", + "request": "test", + "justMyCode": false, + "presentation": { + "hidden": true + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 962594af..48cc2f5e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,27 @@ { + "black-formatter.path": [ + ".venv/bin/python", + "-m", + "black" + ], "black-formatter.args": [ "--config", "pyproject.toml" ], + "mypy-type-checker.path": [ + ".venv/bin/python", + "-m", + "mypy" + ], + "pylint.path": [ + ".venv/bin/python", + "-m", + "pylint" + ], + "pylint.args": [ + "--rcfile=pyproject.toml", + "--load-plugins=pylint_django" + ], "python.testing.pytestArgs": [ "-c=pyproject.toml", "." diff --git a/Pipfile b/Pipfile index f29865cd..b66a30bb 100644 --- a/Pipfile +++ b/Pipfile @@ -31,6 +31,11 @@ django-extensions = "==3.2.1" pyparsing = "==3.0.9" pydot = "==1.4.2" pytest-env = "==0.8.1" +mypy = "==1.6.1" +django-stubs = {version = "==4.2.6", extras = ["compatible-mypy"]} +djangorestframework-stubs = {version = "==3.14.4", extras = ["compatible-mypy"]} +pylint = "==3.0.2" +pylint-django = "==2.5.5" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 830b2c3a..b9e388b9 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4e77ad0e846637e2f1e9ce14086a2f878f3efb1df90745b14d7aa156b4a02bd9" + "sha256": "b31238bbff04bf67a1f6e56c714ba00fc017cd6a0a4ab38a62f2bdb258f9667f" }, "pipfile-spec": 6, "requires": { @@ -42,19 +42,19 @@ }, "cachetools": { "hashes": [ - "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590", - "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b" + "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2", + "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1" ], "markers": "python_version >= '3.7'", - "version": "==5.3.1" + "version": "==5.3.2" }, "certifi": { "hashes": [ - "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", - "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9" + "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", + "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" ], "markers": "python_version >= '3.6'", - "version": "==2023.7.22" + "version": "==2023.11.17" }, "cfl-common": { "hashes": [ @@ -66,99 +66,99 @@ }, "charset-normalizer": { "hashes": [ - "sha256:02673e456dc5ab13659f85196c534dc596d4ef260e4d86e856c3b2773ce09843", - "sha256:02af06682e3590ab952599fbadac535ede5d60d78848e555aa58d0c0abbde786", - "sha256:03680bb39035fbcffe828eae9c3f8afc0428c91d38e7d61aa992ef7a59fb120e", - "sha256:0570d21da019941634a531444364f2482e8db0b3425fcd5ac0c36565a64142c8", - "sha256:09c77f964f351a7369cc343911e0df63e762e42bac24cd7d18525961c81754f4", - "sha256:0d3d5b7db9ed8a2b11a774db2bbea7ba1884430a205dbd54a32d61d7c2a190fa", - "sha256:1063da2c85b95f2d1a430f1c33b55c9c17ffaf5e612e10aeaad641c55a9e2b9d", - "sha256:12ebea541c44fdc88ccb794a13fe861cc5e35d64ed689513a5c03d05b53b7c82", - "sha256:153e7b6e724761741e0974fc4dcd406d35ba70b92bfe3fedcb497226c93b9da7", - "sha256:15b26ddf78d57f1d143bdf32e820fd8935d36abe8a25eb9ec0b5a71c82eb3895", - "sha256:1872d01ac8c618a8da634e232f24793883d6e456a66593135aeafe3784b0848d", - "sha256:187d18082694a29005ba2944c882344b6748d5be69e3a89bf3cc9d878e548d5a", - "sha256:1b2919306936ac6efb3aed1fbf81039f7087ddadb3160882a57ee2ff74fd2382", - "sha256:232ac332403e37e4a03d209a3f92ed9071f7d3dbda70e2a5e9cff1c4ba9f0678", - "sha256:23e8565ab7ff33218530bc817922fae827420f143479b753104ab801145b1d5b", - "sha256:24817cb02cbef7cd499f7c9a2735286b4782bd47a5b3516a0e84c50eab44b98e", - "sha256:249c6470a2b60935bafd1d1d13cd613f8cd8388d53461c67397ee6a0f5dce741", - "sha256:24a91a981f185721542a0b7c92e9054b7ab4fea0508a795846bc5b0abf8118d4", - "sha256:2502dd2a736c879c0f0d3e2161e74d9907231e25d35794584b1ca5284e43f596", - "sha256:250c9eb0f4600361dd80d46112213dff2286231d92d3e52af1e5a6083d10cad9", - "sha256:278c296c6f96fa686d74eb449ea1697f3c03dc28b75f873b65b5201806346a69", - "sha256:2935ffc78db9645cb2086c2f8f4cfd23d9b73cc0dc80334bc30aac6f03f68f8c", - "sha256:2f4a0033ce9a76e391542c182f0d48d084855b5fcba5010f707c8e8c34663d77", - "sha256:30a85aed0b864ac88309b7d94be09f6046c834ef60762a8833b660139cfbad13", - "sha256:380c4bde80bce25c6e4f77b19386f5ec9db230df9f2f2ac1e5ad7af2caa70459", - "sha256:3ae38d325b512f63f8da31f826e6cb6c367336f95e418137286ba362925c877e", - "sha256:3b447982ad46348c02cb90d230b75ac34e9886273df3a93eec0539308a6296d7", - "sha256:3debd1150027933210c2fc321527c2299118aa929c2f5a0a80ab6953e3bd1908", - "sha256:4162918ef3098851fcd8a628bf9b6a98d10c380725df9e04caf5ca6dd48c847a", - "sha256:468d2a840567b13a590e67dd276c570f8de00ed767ecc611994c301d0f8c014f", - "sha256:4cc152c5dd831641e995764f9f0b6589519f6f5123258ccaca8c6d34572fefa8", - "sha256:542da1178c1c6af8873e143910e2269add130a299c9106eef2594e15dae5e482", - "sha256:557b21a44ceac6c6b9773bc65aa1b4cc3e248a5ad2f5b914b91579a32e22204d", - "sha256:5707a746c6083a3a74b46b3a631d78d129edab06195a92a8ece755aac25a3f3d", - "sha256:588245972aca710b5b68802c8cad9edaa98589b1b42ad2b53accd6910dad3545", - "sha256:5adf257bd58c1b8632046bbe43ee38c04e1038e9d37de9c57a94d6bd6ce5da34", - "sha256:619d1c96099be5823db34fe89e2582b336b5b074a7f47f819d6b3a57ff7bdb86", - "sha256:63563193aec44bce707e0c5ca64ff69fa72ed7cf34ce6e11d5127555756fd2f6", - "sha256:67b8cc9574bb518ec76dc8e705d4c39ae78bb96237cb533edac149352c1f39fe", - "sha256:6a685067d05e46641d5d1623d7c7fdf15a357546cbb2f71b0ebde91b175ffc3e", - "sha256:70f1d09c0d7748b73290b29219e854b3207aea922f839437870d8cc2168e31cc", - "sha256:750b446b2ffce1739e8578576092179160f6d26bd5e23eb1789c4d64d5af7dc7", - "sha256:7966951325782121e67c81299a031f4c115615e68046f79b85856b86ebffc4cd", - "sha256:7b8b8bf1189b3ba9b8de5c8db4d541b406611a71a955bbbd7385bbc45fcb786c", - "sha256:7f5d10bae5d78e4551b7be7a9b29643a95aded9d0f602aa2ba584f0388e7a557", - "sha256:805dfea4ca10411a5296bcc75638017215a93ffb584c9e344731eef0dcfb026a", - "sha256:81bf654678e575403736b85ba3a7867e31c2c30a69bc57fe88e3ace52fb17b89", - "sha256:82eb849f085624f6a607538ee7b83a6d8126df6d2f7d3b319cb837b289123078", - "sha256:85a32721ddde63c9df9ebb0d2045b9691d9750cb139c161c80e500d210f5e26e", - "sha256:86d1f65ac145e2c9ed71d8ffb1905e9bba3a91ae29ba55b4c46ae6fc31d7c0d4", - "sha256:86f63face3a527284f7bb8a9d4f78988e3c06823f7bea2bd6f0e0e9298ca0403", - "sha256:8eaf82f0eccd1505cf39a45a6bd0a8cf1c70dcfc30dba338207a969d91b965c0", - "sha256:93aa7eef6ee71c629b51ef873991d6911b906d7312c6e8e99790c0f33c576f89", - "sha256:96c2b49eb6a72c0e4991d62406e365d87067ca14c1a729a870d22354e6f68115", - "sha256:9cf3126b85822c4e53aa28c7ec9869b924d6fcfb76e77a45c44b83d91afd74f9", - "sha256:9fe359b2e3a7729010060fbca442ca225280c16e923b37db0e955ac2a2b72a05", - "sha256:a0ac5e7015a5920cfce654c06618ec40c33e12801711da6b4258af59a8eff00a", - "sha256:a3f93dab657839dfa61025056606600a11d0b696d79386f974e459a3fbc568ec", - "sha256:a4b71f4d1765639372a3b32d2638197f5cd5221b19531f9245fcc9ee62d38f56", - "sha256:aae32c93e0f64469f74ccc730a7cb21c7610af3a775157e50bbd38f816536b38", - "sha256:aaf7b34c5bc56b38c931a54f7952f1ff0ae77a2e82496583b247f7c969eb1479", - "sha256:abecce40dfebbfa6abf8e324e1860092eeca6f7375c8c4e655a8afb61af58f2c", - "sha256:abf0d9f45ea5fb95051c8bfe43cb40cda383772f7e5023a83cc481ca2604d74e", - "sha256:ac71b2977fb90c35d41c9453116e283fac47bb9096ad917b8819ca8b943abecd", - "sha256:ada214c6fa40f8d800e575de6b91a40d0548139e5dc457d2ebb61470abf50186", - "sha256:b09719a17a2301178fac4470d54b1680b18a5048b481cb8890e1ef820cb80455", - "sha256:b1121de0e9d6e6ca08289583d7491e7fcb18a439305b34a30b20d8215922d43c", - "sha256:b3b2316b25644b23b54a6f6401074cebcecd1244c0b8e80111c9a3f1c8e83d65", - "sha256:b3d9b48ee6e3967b7901c052b670c7dda6deb812c309439adaffdec55c6d7b78", - "sha256:b5bcf60a228acae568e9911f410f9d9e0d43197d030ae5799e20dca8df588287", - "sha256:b8f3307af845803fb0b060ab76cf6dd3a13adc15b6b451f54281d25911eb92df", - "sha256:c2af80fb58f0f24b3f3adcb9148e6203fa67dd3f61c4af146ecad033024dde43", - "sha256:c350354efb159b8767a6244c166f66e67506e06c8924ed74669b2c70bc8735b1", - "sha256:c5a74c359b2d47d26cdbbc7845e9662d6b08a1e915eb015d044729e92e7050b7", - "sha256:c71f16da1ed8949774ef79f4a0260d28b83b3a50c6576f8f4f0288d109777989", - "sha256:d47ecf253780c90ee181d4d871cd655a789da937454045b17b5798da9393901a", - "sha256:d7eff0f27edc5afa9e405f7165f85a6d782d308f3b6b9d96016c010597958e63", - "sha256:d97d85fa63f315a8bdaba2af9a6a686e0eceab77b3089af45133252618e70884", - "sha256:db756e48f9c5c607b5e33dd36b1d5872d0422e960145b08ab0ec7fd420e9d649", - "sha256:dc45229747b67ffc441b3de2f3ae5e62877a282ea828a5bdb67883c4ee4a8810", - "sha256:e0fc42822278451bc13a2e8626cf2218ba570f27856b536e00cfa53099724828", - "sha256:e39c7eb31e3f5b1f88caff88bcff1b7f8334975b46f6ac6e9fc725d829bc35d4", - "sha256:e46cd37076971c1040fc8c41273a8b3e2c624ce4f2be3f5dfcb7a430c1d3acc2", - "sha256:e5c1502d4ace69a179305abb3f0bb6141cbe4714bc9b31d427329a95acfc8bdd", - "sha256:edfe077ab09442d4ef3c52cb1f9dab89bff02f4524afc0acf2d46be17dc479f5", - "sha256:effe5406c9bd748a871dbcaf3ac69167c38d72db8c9baf3ff954c344f31c4cbe", - "sha256:f0d1e3732768fecb052d90d62b220af62ead5748ac51ef61e7b32c266cac9293", - "sha256:f5969baeaea61c97efa706b9b107dcba02784b1601c74ac84f2a532ea079403e", - "sha256:f8888e31e3a85943743f8fc15e71536bda1c81d5aa36d014a3c0c44481d7db6e", - "sha256:fc52b79d83a3fe3a360902d3f5d79073a993597d48114c29485e9431092905d8" + "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", + "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", + "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", + "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", + "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", + "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", + "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", + "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", + "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", + "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", + "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", + "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", + "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", + "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", + "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", + "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", + "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", + "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", + "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", + "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", + "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", + "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", + "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", + "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", + "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", + "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", + "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", + "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", + "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", + "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", + "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", + "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", + "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", + "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", + "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", + "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", + "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", + "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", + "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", + "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", + "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", + "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", + "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", + "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", + "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", + "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", + "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", + "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", + "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", + "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", + "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", + "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", + "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", + "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", + "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", + "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", + "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", + "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", + "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", + "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", + "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", + "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", + "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", + "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", + "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", + "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", + "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", + "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", + "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", + "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", + "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", + "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", + "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", + "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", + "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", + "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", + "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", + "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", + "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", + "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", + "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", + "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", + "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", + "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", + "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", + "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", + "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", + "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", + "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", + "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" ], "markers": "python_full_version >= '3.7.0'", - "version": "==3.3.0" + "version": "==3.3.2" }, "click": { "hashes": [ @@ -248,11 +248,11 @@ }, "django-import-export": { "hashes": [ - "sha256:88ecaf06be06bd95d97cf34f3c911c56c012a7a81712a8956740e5bfc2465162", - "sha256:d02e31908c965d512cc6f7ef6e72935177647b15d3846050d0f094177fca0d86" + "sha256:2c1b16e1cf2ea5f62a165d8867e7c6dcff25673ab7201fd18aaf67c9ee90367e", + "sha256:78973202e93897326ab0411d64eaf89b72779fcb21ee9e5f64f3fb96571a5978" ], "markers": "python_version >= '3.8'", - "version": "==3.3.1" + "version": "==3.3.3" }, "django-js-reverse": { "hashes": [ @@ -366,79 +366,74 @@ }, "google-auth": { "hashes": [ - "sha256:6864247895eea5d13b9c57c9e03abb49cb94ce2dc7c58e91cba3248c7477c9e3", - "sha256:a8f4608e65c244ead9e0538f181a96c6e11199ec114d41f1d7b1bffa96937bda" + "sha256:d5d66b8f4f6e3273740d7bb73ddefa6c2d1ff691704bd407d51c6b5800e7c97b", + "sha256:dfd7b44935d498e106c08883b2dac0ad36d8aa10402a6412e9a1c9d74b4773f1" ], "markers": "python_version >= '3.7'", - "version": "==2.23.3" + "version": "==2.25.1" }, "greenlet": { "hashes": [ - "sha256:02a807b2a58d5cdebb07050efe3d7deaf915468d112dfcf5e426d0564aa3aa4a", - "sha256:0b72b802496cccbd9b31acea72b6f87e7771ccfd7f7927437d592e5c92ed703c", - "sha256:0d3f83ffb18dc57243e0151331e3c383b05e5b6c5029ac29f754745c800f8ed9", - "sha256:10b5582744abd9858947d163843d323d0b67be9432db50f8bf83031032bc218d", - "sha256:123910c58234a8d40eaab595bc56a5ae49bdd90122dde5bdc012c20595a94c14", - "sha256:1482fba7fbed96ea7842b5a7fc11d61727e8be75a077e603e8ab49d24e234383", - "sha256:19834e3f91f485442adc1ee440171ec5d9a4840a1f7bd5ed97833544719ce10b", - "sha256:1d363666acc21d2c204dd8705c0e0457d7b2ee7a76cb16ffc099d6799744ac99", - "sha256:211ef8d174601b80e01436f4e6905aca341b15a566f35a10dd8d1e93f5dbb3b7", - "sha256:269d06fa0f9624455ce08ae0179430eea61085e3cf6457f05982b37fd2cefe17", - "sha256:2e7dcdfad252f2ca83c685b0fa9fba00e4d8f243b73839229d56ee3d9d219314", - "sha256:334ef6ed8337bd0b58bb0ae4f7f2dcc84c9f116e474bb4ec250a8bb9bd797a66", - "sha256:343675e0da2f3c69d3fb1e894ba0a1acf58f481f3b9372ce1eb465ef93cf6fed", - "sha256:37f60b3a42d8b5499be910d1267b24355c495064f271cfe74bf28b17b099133c", - "sha256:38ad562a104cd41e9d4644f46ea37167b93190c6d5e4048fcc4b80d34ecb278f", - "sha256:3c0d36f5adc6e6100aedbc976d7428a9f7194ea79911aa4bf471f44ee13a9464", - "sha256:3fd2b18432e7298fcbec3d39e1a0aa91ae9ea1c93356ec089421fabc3651572b", - "sha256:4a1a6244ff96343e9994e37e5b4839f09a0207d35ef6134dce5c20d260d0302c", - "sha256:4cd83fb8d8e17633ad534d9ac93719ef8937568d730ef07ac3a98cb520fd93e4", - "sha256:527cd90ba3d8d7ae7dceb06fda619895768a46a1b4e423bdb24c1969823b8362", - "sha256:56867a3b3cf26dc8a0beecdb4459c59f4c47cdd5424618c08515f682e1d46692", - "sha256:621fcb346141ae08cb95424ebfc5b014361621b8132c48e538e34c3c93ac7365", - "sha256:63acdc34c9cde42a6534518e32ce55c30f932b473c62c235a466469a710bfbf9", - "sha256:6512592cc49b2c6d9b19fbaa0312124cd4c4c8a90d28473f86f92685cc5fef8e", - "sha256:6672fdde0fd1a60b44fb1751a7779c6db487e42b0cc65e7caa6aa686874e79fb", - "sha256:6a5b2d4cdaf1c71057ff823a19d850ed5c6c2d3686cb71f73ae4d6382aaa7a06", - "sha256:6a68d670c8f89ff65c82b936275369e532772eebc027c3be68c6b87ad05ca695", - "sha256:6bb36985f606a7c49916eff74ab99399cdfd09241c375d5a820bb855dfb4af9f", - "sha256:73b2f1922a39d5d59cc0e597987300df3396b148a9bd10b76a058a2f2772fc04", - "sha256:7709fd7bb02b31908dc8fd35bfd0a29fc24681d5cc9ac1d64ad07f8d2b7db62f", - "sha256:8060b32d8586e912a7b7dac2d15b28dbbd63a174ab32f5bc6d107a1c4143f40b", - "sha256:80dcd3c938cbcac986c5c92779db8e8ce51a89a849c135172c88ecbdc8c056b7", - "sha256:813720bd57e193391dfe26f4871186cf460848b83df7e23e6bef698a7624b4c9", - "sha256:831d6f35037cf18ca5e80a737a27d822d87cd922521d18ed3dbc8a6967be50ce", - "sha256:871b0a8835f9e9d461b7fdaa1b57e3492dd45398e87324c047469ce2fc9f516c", - "sha256:952256c2bc5b4ee8df8dfc54fc4de330970bf5d79253c863fb5e6761f00dda35", - "sha256:96d9ea57292f636ec851a9bb961a5cc0f9976900e16e5d5647f19aa36ba6366b", - "sha256:9a812224a5fb17a538207e8cf8e86f517df2080c8ee0f8c1ed2bdaccd18f38f4", - "sha256:9adbd8ecf097e34ada8efde9b6fec4dd2a903b1e98037adf72d12993a1c80b51", - "sha256:9de687479faec7db5b198cc365bc34addd256b0028956501f4d4d5e9ca2e240a", - "sha256:a048293392d4e058298710a54dfaefcefdf49d287cd33fb1f7d63d55426e4355", - "sha256:aa15a2ec737cb609ed48902b45c5e4ff6044feb5dcdfcf6fa8482379190330d7", - "sha256:abe1ef3d780de56defd0c77c5ba95e152f4e4c4e12d7e11dd8447d338b85a625", - "sha256:ad6fb737e46b8bd63156b8f59ba6cdef46fe2b7db0c5804388a2d0519b8ddb99", - "sha256:b1660a15a446206c8545edc292ab5c48b91ff732f91b3d3b30d9a915d5ec4779", - "sha256:b505fcfc26f4148551826a96f7317e02c400665fa0883fe505d4fcaab1dabfdd", - "sha256:b822fab253ac0f330ee807e7485769e3ac85d5eef827ca224feaaefa462dc0d0", - "sha256:bdd696947cd695924aecb3870660b7545a19851f93b9d327ef8236bfc49be705", - "sha256:bdfaeecf8cc705d35d8e6de324bf58427d7eafb55f67050d8f28053a3d57118c", - "sha256:be557119bf467d37a8099d91fbf11b2de5eb1fd5fc5b91598407574848dc910f", - "sha256:c6b5ce7f40f0e2f8b88c28e6691ca6806814157ff05e794cdd161be928550f4c", - "sha256:c94e4e924d09b5a3e37b853fe5924a95eac058cb6f6fb437ebb588b7eda79870", - "sha256:cc3e2679ea13b4de79bdc44b25a0c4fcd5e94e21b8f290791744ac42d34a0353", - "sha256:d1e22c22f7826096ad503e9bb681b05b8c1f5a8138469b255eb91f26a76634f2", - "sha256:d5539f6da3418c3dc002739cb2bb8d169056aa66e0c83f6bacae0cd3ac26b423", - "sha256:d55db1db455c59b46f794346efce896e754b8942817f46a1bada2d29446e305a", - "sha256:e09dea87cc91aea5500262993cbd484b41edf8af74f976719dd83fe724644cd6", - "sha256:e52a712c38e5fb4fd68e00dc3caf00b60cb65634d50e32281a9d6431b33b4af1", - "sha256:e693e759e172fa1c2c90d35dea4acbdd1d609b6936115d3739148d5e4cd11947", - "sha256:ecf94aa539e97a8411b5ea52fc6ccd8371be9550c4041011a091eb8b3ca1d810", - "sha256:f351479a6914fd81a55c8e68963609f792d9b067fb8a60a042c585a621e0de4f", - "sha256:f47932c434a3c8d3c86d865443fadc1fbf574e9b11d6650b656e602b1797908a" + "sha256:0a02d259510b3630f330c86557331a3b0e0c79dac3d166e449a39363beaae174", + "sha256:0b6f9f8ca7093fd4433472fd99b5650f8a26dcd8ba410e14094c1e44cd3ceddd", + "sha256:100f78a29707ca1525ea47388cec8a049405147719f47ebf3895e7509c6446aa", + "sha256:1757936efea16e3f03db20efd0cd50a1c86b06734f9f7338a90c4ba85ec2ad5a", + "sha256:19075157a10055759066854a973b3d1325d964d498a805bb68a1f9af4aaef8ec", + "sha256:19bbdf1cce0346ef7341705d71e2ecf6f41a35c311137f29b8a2dc2341374565", + "sha256:20107edf7c2c3644c67c12205dc60b1bb11d26b2610b276f97d666110d1b511d", + "sha256:22f79120a24aeeae2b4471c711dcf4f8c736a2bb2fabad2a67ac9a55ea72523c", + "sha256:2847e5d7beedb8d614186962c3d774d40d3374d580d2cbdab7f184580a39d234", + "sha256:28e89e232c7593d33cac35425b58950789962011cc274aa43ef8865f2e11f46d", + "sha256:329c5a2e5a0ee942f2992c5e3ff40be03e75f745f48847f118a3cfece7a28546", + "sha256:337322096d92808f76ad26061a8f5fccb22b0809bea39212cd6c406f6a7060d2", + "sha256:3fcc780ae8edbb1d050d920ab44790201f027d59fdbd21362340a85c79066a74", + "sha256:41bdeeb552d814bcd7fb52172b304898a35818107cc8778b5101423c9017b3de", + "sha256:4eddd98afc726f8aee1948858aed9e6feeb1758889dfd869072d4465973f6bfd", + "sha256:52e93b28db27ae7d208748f45d2db8a7b6a380e0d703f099c949d0f0d80b70e9", + "sha256:55d62807f1c5a1682075c62436702aaba941daa316e9161e4b6ccebbbf38bda3", + "sha256:5805e71e5b570d490938d55552f5a9e10f477c19400c38bf1d5190d760691846", + "sha256:599daf06ea59bfedbec564b1692b0166a0045f32b6f0933b0dd4df59a854caf2", + "sha256:60d5772e8195f4e9ebf74046a9121bbb90090f6550f81d8956a05387ba139353", + "sha256:696d8e7d82398e810f2b3622b24e87906763b6ebfd90e361e88eb85b0e554dc8", + "sha256:6e6061bf1e9565c29002e3c601cf68569c450be7fc3f7336671af7ddb4657166", + "sha256:80ac992f25d10aaebe1ee15df45ca0d7571d0f70b645c08ec68733fb7a020206", + "sha256:816bd9488a94cba78d93e1abb58000e8266fa9cc2aa9ccdd6eb0696acb24005b", + "sha256:85d2b77e7c9382f004b41d9c72c85537fac834fb141b0296942d52bf03fe4a3d", + "sha256:87c8ceb0cf8a5a51b8008b643844b7f4a8264a2c13fcbcd8a8316161725383fe", + "sha256:89ee2e967bd7ff85d84a2de09df10e021c9b38c7d91dead95b406ed6350c6997", + "sha256:8bef097455dea90ffe855286926ae02d8faa335ed8e4067326257cb571fc1445", + "sha256:8d11ebbd679e927593978aa44c10fc2092bc454b7d13fdc958d3e9d508aba7d0", + "sha256:91e6c7db42638dc45cf2e13c73be16bf83179f7859b07cfc139518941320be96", + "sha256:97e7ac860d64e2dcba5c5944cfc8fa9ea185cd84061c623536154d5a89237884", + "sha256:990066bff27c4fcf3b69382b86f4c99b3652bab2a7e685d968cd4d0cfc6f67c6", + "sha256:9fbc5b8f3dfe24784cee8ce0be3da2d8a79e46a276593db6868382d9c50d97b1", + "sha256:ac4a39d1abae48184d420aa8e5e63efd1b75c8444dd95daa3e03f6c6310e9619", + "sha256:b2c02d2ad98116e914d4f3155ffc905fd0c025d901ead3f6ed07385e19122c94", + "sha256:b2d3337dcfaa99698aa2377c81c9ca72fcd89c07e7eb62ece3f23a3fe89b2ce4", + "sha256:b489c36d1327868d207002391f662a1d163bdc8daf10ab2e5f6e41b9b96de3b1", + "sha256:b641161c302efbb860ae6b081f406839a8b7d5573f20a455539823802c655f63", + "sha256:b8ba29306c5de7717b5761b9ea74f9c72b9e2b834e24aa984da99cbfc70157fd", + "sha256:b9934adbd0f6e476f0ecff3c94626529f344f57b38c9a541f87098710b18af0a", + "sha256:ce85c43ae54845272f6f9cd8320d034d7a946e9773c693b27d620edec825e376", + "sha256:cf868e08690cb89360eebc73ba4be7fb461cfbc6168dd88e2fbbe6f31812cd57", + "sha256:d2905ce1df400360463c772b55d8e2518d0e488a87cdea13dd2c71dcb2a1fa16", + "sha256:d57e20ba591727da0c230ab2c3f200ac9d6d333860d85348816e1dca4cc4792e", + "sha256:d6a8c9d4f8692917a3dc7eb25a6fb337bff86909febe2f793ec1928cd97bedfc", + "sha256:d923ff276f1c1f9680d32832f8d6c040fe9306cbfb5d161b0911e9634be9ef0a", + "sha256:daa7197b43c707462f06d2c693ffdbb5991cbb8b80b5b984007de431493a319c", + "sha256:dbd4c177afb8a8d9ba348d925b0b67246147af806f0b104af4d24f144d461cd5", + "sha256:dc4d815b794fd8868c4d67602692c21bf5293a75e4b607bb92a11e821e2b859a", + "sha256:e9d21aaa84557d64209af04ff48e0ad5e28c5cca67ce43444e939579d085da72", + "sha256:ea6b8aa9e08eea388c5f7a276fabb1d4b6b9d6e4ceb12cc477c3d352001768a9", + "sha256:eabe7090db68c981fca689299c2d116400b553f4b713266b130cfc9e2aa9c5a9", + "sha256:f2f6d303f3dee132b322a14cd8765287b8f86cdc10d2cb6a6fae234ea488888e", + "sha256:f33f3258aae89da191c6ebaa3bc517c6c4cbc9b9f689e5d8452f7aedbb913fa8", + "sha256:f7bfb769f7efa0eefcd039dd19d843a4fbfbac52f1878b1da2ed5793ec9b1a65", + "sha256:f89e21afe925fcfa655965ca8ea10f24773a1791400989ff32f467badfe4a064", + "sha256:fa24255ae3c0ab67e613556375a4341af04a084bd58764731972bcbc8baeba36" ], "markers": "python_version >= '3.7'", - "version": "==3.0.0" + "version": "==3.0.1" }, "hypothesis": { "hashes": [ @@ -450,11 +445,11 @@ }, "idna": { "hashes": [ - "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", - "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" + "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", + "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" ], "markers": "python_version >= '3.5'", - "version": "==3.4" + "version": "==3.6" }, "importlib-metadata": { "hashes": [ @@ -492,6 +487,7 @@ "hashes": [ "sha256:081e256ab3c5f3f09c7b8dea3bf3bf5e64a97c6995fd9eea880639b3f93a9f9a", "sha256:3ab5ad18e47db560f4f0c09e3d28cf3bb1a44711257488ac2adad69f4f7f8425", + "sha256:5fb2297a4754a6c8e25cfe5c015a3b51a2b6b9021b333f989bb8ce9d60eb5828", "sha256:65455a2728b696b62100eb5932604aa13a29f4ac9a305d95773c14aaa7200aaf", "sha256:89c5ce497fcf3aba1dd1b19aae93b99f68257e5f2026b731b00a872f13324c7f", "sha256:f1efc1b612299c88aec9e39d6ca0c266d360daa5b19d9430bdeaffffa86993f9" @@ -684,71 +680,71 @@ }, "pillow": { "hashes": [ - "sha256:0462b1496505a3462d0f35dc1c4d7b54069747d65d00ef48e736acda2c8cbdff", - "sha256:186f7e04248103482ea6354af6d5bcedb62941ee08f7f788a1c7707bc720c66f", - "sha256:19e9adb3f22d4c416e7cd79b01375b17159d6990003633ff1d8377e21b7f1b21", - "sha256:28444cb6ad49726127d6b340217f0627abc8732f1194fd5352dec5e6a0105635", - "sha256:2872f2d7846cf39b3dbff64bc1104cc48c76145854256451d33c5faa55c04d1a", - "sha256:2cc6b86ece42a11f16f55fe8903595eff2b25e0358dec635d0a701ac9586588f", - "sha256:2d7e91b4379f7a76b31c2dda84ab9e20c6220488e50f7822e59dac36b0cd92b1", - "sha256:2fa6dd2661838c66f1a5473f3b49ab610c98a128fc08afbe81b91a1f0bf8c51d", - "sha256:32bec7423cdf25c9038fef614a853c9d25c07590e1a870ed471f47fb80b244db", - "sha256:3855447d98cced8670aaa63683808df905e956f00348732448b5a6df67ee5849", - "sha256:3a04359f308ebee571a3127fdb1bd01f88ba6f6fb6d087f8dd2e0d9bff43f2a7", - "sha256:3a0d3e54ab1df9df51b914b2233cf779a5a10dfd1ce339d0421748232cea9876", - "sha256:44e7e4587392953e5e251190a964675f61e4dae88d1e6edbe9f36d6243547ff3", - "sha256:459307cacdd4138edee3875bbe22a2492519e060660eaf378ba3b405d1c66317", - "sha256:4ce90f8a24e1c15465048959f1e94309dfef93af272633e8f37361b824532e91", - "sha256:50bd5f1ebafe9362ad622072a1d2f5850ecfa44303531ff14353a4059113b12d", - "sha256:522ff4ac3aaf839242c6f4e5b406634bfea002469656ae8358644fc6c4856a3b", - "sha256:552912dbca585b74d75279a7570dd29fa43b6d93594abb494ebb31ac19ace6bd", - "sha256:5d6c9049c6274c1bb565021367431ad04481ebb54872edecfcd6088d27edd6ed", - "sha256:697a06bdcedd473b35e50a7e7506b1d8ceb832dc238a336bd6f4f5aa91a4b500", - "sha256:71671503e3015da1b50bd18951e2f9daf5b6ffe36d16f1eb2c45711a301521a7", - "sha256:723bd25051454cea9990203405fa6b74e043ea76d4968166dfd2569b0210886a", - "sha256:764d2c0daf9c4d40ad12fbc0abd5da3af7f8aa11daf87e4fa1b834000f4b6b0a", - "sha256:787bb0169d2385a798888e1122c980c6eff26bf941a8ea79747d35d8f9210ca0", - "sha256:7f771e7219ff04b79e231d099c0a28ed83aa82af91fd5fa9fdb28f5b8d5addaf", - "sha256:847e8d1017c741c735d3cd1883fa7b03ded4f825a6e5fcb9378fd813edee995f", - "sha256:84efb46e8d881bb06b35d1d541aa87f574b58e87f781cbba8d200daa835b42e1", - "sha256:898f1d306298ff40dc1b9ca24824f0488f6f039bc0e25cfb549d3195ffa17088", - "sha256:8b451d6ead6e3500b6ce5c7916a43d8d8d25ad74b9102a629baccc0808c54971", - "sha256:8f06be50669087250f319b706decf69ca71fdecd829091a37cc89398ca4dc17a", - "sha256:92a23b0431941a33242b1f0ce6c88a952e09feeea9af4e8be48236a68ffe2205", - "sha256:93139acd8109edcdeffd85e3af8ae7d88b258b3a1e13a038f542b79b6d255c54", - "sha256:98533fd7fa764e5f85eebe56c8e4094db912ccbe6fbf3a58778d543cadd0db08", - "sha256:9f665d1e6474af9f9da5e86c2a3a2d2d6204e04d5af9c06b9d42afa6ebde3f21", - "sha256:b059ac2c4c7a97daafa7dc850b43b2d3667def858a4f112d1aa082e5c3d6cf7d", - "sha256:b1be1c872b9b5fcc229adeadbeb51422a9633abd847c0ff87dc4ef9bb184ae08", - "sha256:b7cf63d2c6928b51d35dfdbda6f2c1fddbe51a6bc4a9d4ee6ea0e11670dd981e", - "sha256:bc2e3069569ea9dbe88d6b8ea38f439a6aad8f6e7a6283a38edf61ddefb3a9bf", - "sha256:bcf1207e2f2385a576832af02702de104be71301c2696d0012b1b93fe34aaa5b", - "sha256:ca26ba5767888c84bf5a0c1a32f069e8204ce8c21d00a49c90dabeba00ce0145", - "sha256:cbe68deb8580462ca0d9eb56a81912f59eb4542e1ef8f987405e35a0179f4ea2", - "sha256:d6caf3cd38449ec3cd8a68b375e0c6fe4b6fd04edb6c9766b55ef84a6e8ddf2d", - "sha256:d72967b06be9300fed5cfbc8b5bafceec48bf7cdc7dab66b1d2549035287191d", - "sha256:d889b53ae2f030f756e61a7bff13684dcd77e9af8b10c6048fb2c559d6ed6eaf", - "sha256:de596695a75496deb3b499c8c4f8e60376e0516e1a774e7bc046f0f48cd620ad", - "sha256:e6a90167bcca1216606223a05e2cf991bb25b14695c518bc65639463d7db722d", - "sha256:ed2d9c0704f2dc4fa980b99d565c0c9a543fe5101c25b3d60488b8ba80f0cce1", - "sha256:ee7810cf7c83fa227ba9125de6084e5e8b08c59038a7b2c9045ef4dde61663b4", - "sha256:f0b4b06da13275bc02adfeb82643c4a6385bd08d26f03068c2796f60d125f6f2", - "sha256:f11c9102c56ffb9ca87134bd025a43d2aba3f1155f508eff88f694b33a9c6d19", - "sha256:f5bb289bb835f9fe1a1e9300d011eef4d69661bb9b34d5e196e5e82c4cb09b37", - "sha256:f6d3d4c905e26354e8f9d82548475c46d8e0889538cb0657aa9c6f0872a37aa4", - "sha256:fcb59711009b0168d6ee0bd8fb5eb259c4ab1717b2f538bbf36bacf207ef7a68", - "sha256:fd2a5403a75b54661182b75ec6132437a181209b901446ee5724b589af8edef1" + "sha256:00f438bb841382b15d7deb9a05cc946ee0f2c352653c7aa659e75e592f6fa17d", + "sha256:0248f86b3ea061e67817c47ecbe82c23f9dd5d5226200eb9090b3873d3ca32de", + "sha256:04f6f6149f266a100374ca3cc368b67fb27c4af9f1cc8cb6306d849dcdf12616", + "sha256:062a1610e3bc258bff2328ec43f34244fcec972ee0717200cb1425214fe5b839", + "sha256:0a026c188be3b443916179f5d04548092e253beb0c3e2ee0a4e2cdad72f66099", + "sha256:0f7c276c05a9767e877a0b4c5050c8bee6a6d960d7f0c11ebda6b99746068c2a", + "sha256:1a8413794b4ad9719346cd9306118450b7b00d9a15846451549314a58ac42219", + "sha256:1ab05f3db77e98f93964697c8efc49c7954b08dd61cff526b7f2531a22410106", + "sha256:1c3ac5423c8c1da5928aa12c6e258921956757d976405e9467c5f39d1d577a4b", + "sha256:1c41d960babf951e01a49c9746f92c5a7e0d939d1652d7ba30f6b3090f27e412", + "sha256:1fafabe50a6977ac70dfe829b2d5735fd54e190ab55259ec8aea4aaea412fa0b", + "sha256:1fb29c07478e6c06a46b867e43b0bcdb241b44cc52be9bc25ce5944eed4648e7", + "sha256:24fadc71218ad2b8ffe437b54876c9382b4a29e030a05a9879f615091f42ffc2", + "sha256:2cdc65a46e74514ce742c2013cd4a2d12e8553e3a2563c64879f7c7e4d28bce7", + "sha256:2ef6721c97894a7aa77723740a09547197533146fba8355e86d6d9a4a1056b14", + "sha256:3b834f4b16173e5b92ab6566f0473bfb09f939ba14b23b8da1f54fa63e4b623f", + "sha256:3d929a19f5469b3f4df33a3df2983db070ebb2088a1e145e18facbc28cae5b27", + "sha256:41f67248d92a5e0a2076d3517d8d4b1e41a97e2df10eb8f93106c89107f38b57", + "sha256:47e5bf85b80abc03be7455c95b6d6e4896a62f6541c1f2ce77a7d2bb832af262", + "sha256:4d0152565c6aa6ebbfb1e5d8624140a440f2b99bf7afaafbdbf6430426497f28", + "sha256:50d08cd0a2ecd2a8657bd3d82c71efd5a58edb04d9308185d66c3a5a5bed9610", + "sha256:61f1a9d247317fa08a308daaa8ee7b3f760ab1809ca2da14ecc88ae4257d6172", + "sha256:6932a7652464746fcb484f7fc3618e6503d2066d853f68a4bd97193a3996e273", + "sha256:7a7e3daa202beb61821c06d2517428e8e7c1aab08943e92ec9e5755c2fc9ba5e", + "sha256:7dbaa3c7de82ef37e7708521be41db5565004258ca76945ad74a8e998c30af8d", + "sha256:7df5608bc38bd37ef585ae9c38c9cd46d7c81498f086915b0f97255ea60c2818", + "sha256:806abdd8249ba3953c33742506fe414880bad78ac25cc9a9b1c6ae97bedd573f", + "sha256:883f216eac8712b83a63f41b76ddfb7b2afab1b74abbb413c5df6680f071a6b9", + "sha256:912e3812a1dbbc834da2b32299b124b5ddcb664ed354916fd1ed6f193f0e2d01", + "sha256:937bdc5a7f5343d1c97dc98149a0be7eb9704e937fe3dc7140e229ae4fc572a7", + "sha256:9882a7451c680c12f232a422730f986a1fcd808da0fd428f08b671237237d651", + "sha256:9a92109192b360634a4489c0c756364c0c3a2992906752165ecb50544c251312", + "sha256:9d7bc666bd8c5a4225e7ac71f2f9d12466ec555e89092728ea0f5c0c2422ea80", + "sha256:a5f63b5a68daedc54c7c3464508d8c12075e56dcfbd42f8c1bf40169061ae666", + "sha256:a646e48de237d860c36e0db37ecaecaa3619e6f3e9d5319e527ccbc8151df061", + "sha256:a89b8312d51715b510a4fe9fc13686283f376cfd5abca8cd1c65e4c76e21081b", + "sha256:a92386125e9ee90381c3369f57a2a50fa9e6aa8b1cf1d9c4b200d41a7dd8e992", + "sha256:ae88931f93214777c7a3aa0a8f92a683f83ecde27f65a45f95f22d289a69e593", + "sha256:afc8eef765d948543a4775f00b7b8c079b3321d6b675dde0d02afa2ee23000b4", + "sha256:b0eb01ca85b2361b09480784a7931fc648ed8b7836f01fb9241141b968feb1db", + "sha256:b1c25762197144e211efb5f4e8ad656f36c8d214d390585d1d21281f46d556ba", + "sha256:b4005fee46ed9be0b8fb42be0c20e79411533d1fd58edabebc0dd24626882cfd", + "sha256:b920e4d028f6442bea9a75b7491c063f0b9a3972520731ed26c83e254302eb1e", + "sha256:baada14941c83079bf84c037e2d8b7506ce201e92e3d2fa0d1303507a8538212", + "sha256:bb40c011447712d2e19cc261c82655f75f32cb724788df315ed992a4d65696bb", + "sha256:c0949b55eb607898e28eaccb525ab104b2d86542a85c74baf3a6dc24002edec2", + "sha256:c9aeea7b63edb7884b031a35305629a7593272b54f429a9869a4f63a1bf04c34", + "sha256:cfe96560c6ce2f4c07d6647af2d0f3c54cc33289894ebd88cfbb3bcd5391e256", + "sha256:d27b5997bdd2eb9fb199982bb7eb6164db0426904020dc38c10203187ae2ff2f", + "sha256:d921bc90b1defa55c9917ca6b6b71430e4286fc9e44c55ead78ca1a9f9eba5f2", + "sha256:e6bf8de6c36ed96c86ea3b6e1d5273c53f46ef518a062464cd7ef5dd2cf92e38", + "sha256:eaed6977fa73408b7b8a24e8b14e59e1668cfc0f4c40193ea7ced8e210adf996", + "sha256:fa1d323703cfdac2036af05191b969b910d8f115cf53093125e4058f62012c9a", + "sha256:fe1e26e1ffc38be097f0ba1d0d07fcade2bcfd1d023cda5b29935ae8052bd793" ], "markers": "python_version >= '3.8'", - "version": "==10.0.1" + "version": "==10.1.0" }, "pyasn1": { "hashes": [ - "sha256:87a2121042a1ac9358cabcaf1d07680ff97ee6404333bacca15f76aa8ad01a57", - "sha256:97b7290ca68e62a832558ec3976f15cbf911bf5d7c7039d8b861c2a0ece69fde" + "sha256:4439847c58d40b1d0a573d07e3856e95333f1976294494c325775aeca506eb58", + "sha256:6d391a96e59b23130a5cfa74d6fd7f388dbbe26cc8f1edf39fdddf08d9d6676c" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==0.5.0" + "version": "==0.5.1" }, "pyasn1-modules": { "hashes": [ @@ -1036,27 +1032,27 @@ }, "urllib3": { "hashes": [ - "sha256:7a7c7003b000adf9e7ca2a377c9688bbc54ed41b985789ed576570342a375cd2", - "sha256:b19e1a85d206b56d7df1d5e683df4a7725252a964e3993648dd0fb5a1c157564" + "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3", + "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.6" + "markers": "python_version >= '3.8'", + "version": "==2.1.0" }, "websocket-client": { "hashes": [ - "sha256:084072e0a7f5f347ef2ac3d8698a5e0b4ffbfcab607628cadabc650fc9a83a24", - "sha256:b3324019b3c28572086c4a319f91d1dcd44e6e11cd340232978c684a7650d0df" + "sha256:10e511ea3a8c744631d3bd77e61eb17ed09304c413ad42cf6ddfa4c7787e8fe6", + "sha256:f4c3d22fec12a2461427a29957ff07d35098ee2d976d3ba244e688b8b4057588" ], "markers": "python_version >= '3.8'", - "version": "==1.6.4" + "version": "==1.7.0" }, "werkzeug": { "hashes": [ - "sha256:3ffff4dcc32db52ef3cc94dff3000a3c2846890f3a5a51800a27b909c5e770f0", - "sha256:cbb2600f7eabe51dbc0502f58be0b3e1b96b893b05695ea2b35b43d4de2d9962" + "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc", + "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10" ], "markers": "python_version >= '3.8'", - "version": "==3.0.0" + "version": "==3.0.1" }, "xlrd": { "hashes": [ @@ -1090,6 +1086,14 @@ "markers": "python_version >= '3.7'", "version": "==3.7.2" }, + "astroid": { + "hashes": [ + "sha256:7d5895c9825e18079c5aeac0572bc2e4c83205c95d416e0b4fee8bc361d2d9ca", + "sha256:86b0bb7d7da0be1a7c4aedb7974e391b32d4ed89e33de6ed6902b4b15c97577e" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.1" + }, "attrs": { "hashes": [ "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", @@ -1129,6 +1133,110 @@ "index": "pypi", "version": "==23.1.0" }, + "certifi": { + "hashes": [ + "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", + "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" + ], + "markers": "python_version >= '3.6'", + "version": "==2023.11.17" + }, + "charset-normalizer": { + "hashes": [ + "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", + "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", + "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", + "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", + "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", + "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", + "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", + "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", + "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", + "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", + "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", + "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", + "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", + "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", + "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", + "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", + "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", + "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", + "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", + "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", + "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", + "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", + "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", + "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", + "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", + "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", + "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", + "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", + "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", + "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", + "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", + "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", + "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", + "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", + "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", + "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", + "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", + "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", + "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", + "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", + "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", + "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", + "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", + "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", + "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", + "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", + "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", + "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", + "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", + "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", + "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", + "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", + "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", + "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", + "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", + "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", + "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", + "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", + "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", + "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", + "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", + "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", + "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", + "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", + "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", + "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", + "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", + "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", + "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", + "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", + "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", + "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", + "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", + "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", + "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", + "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", + "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", + "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", + "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", + "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", + "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", + "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", + "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", + "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", + "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", + "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", + "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", + "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", + "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", + "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.3.2" + }, "click": { "hashes": [ "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", @@ -1137,6 +1245,14 @@ "markers": "python_version >= '3.7'", "version": "==8.1.7" }, + "dill": { + "hashes": [ + "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e", + "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03" + ], + "markers": "python_version < '3.11'", + "version": "==0.3.7" + }, "django": { "hashes": [ "sha256:a477ab326ae7d8807dc25c186b951ab8c7648a3a23f9497763c37307a2b5ef87", @@ -1153,13 +1269,51 @@ "index": "pypi", "version": "==3.2.1" }, + "django-stubs": { + "extras": [ + "compatible-mypy" + ], + "hashes": [ + "sha256:2fcd257884a68dfa02de41ee5410ec805264d9b07d9b5b119e4dea82c7b8345e", + "sha256:e60b43de662a199db4b15c803c06669e0ac5035614af291cbd3b91591f7dcc94" + ], + "index": "pypi", + "version": "==4.2.6" + }, + "django-stubs-ext": { + "hashes": [ + "sha256:45a5d102417a412e3606e3c358adb4744988a92b7b58ccf3fd64bddd5d04d14c", + "sha256:519342ac0849cda1559746c9a563f03ff99f636b0ebe7c14b75e816a00dfddc3" + ], + "markers": "python_version >= '3.8'", + "version": "==4.2.7" + }, + "djangorestframework-stubs": { + "extras": [ + "compatible-mypy" + ], + "hashes": [ + "sha256:5be8275dd05d6629b3d1688929586ef7b6bc66b4f3f728b5e0389305f07c7a7f", + "sha256:8ee8719bfeb647b92cc200e15b3cc9813d2e4468c8190777a55a121542a4b2d4" + ], + "index": "pypi", + "version": "==3.14.4" + }, "exceptiongroup": { "hashes": [ - "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", - "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" + "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", + "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68" ], "markers": "python_version < '3.11'", - "version": "==1.1.3" + "version": "==1.2.0" + }, + "idna": { + "hashes": [ + "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", + "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + ], + "markers": "python_version >= '3.5'", + "version": "==3.6" }, "iniconfig": { "hashes": [ @@ -1169,6 +1323,55 @@ "markers": "python_version >= '3.7'", "version": "==2.0.0" }, + "isort": { + "hashes": [ + "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", + "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==5.12.0" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mypy": { + "hashes": [ + "sha256:19f905bcfd9e167159b3d63ecd8cb5e696151c3e59a1742e79bc3bcb540c42c7", + "sha256:21a1ad938fee7d2d96ca666c77b7c494c3c5bd88dff792220e1afbebb2925b5e", + "sha256:40b1844d2e8b232ed92e50a4bd11c48d2daa351f9deee6c194b83bf03e418b0c", + "sha256:41697773aa0bf53ff917aa077e2cde7aa50254f28750f9b88884acea38a16169", + "sha256:49ae115da099dcc0922a7a895c1eec82c1518109ea5c162ed50e3b3594c71208", + "sha256:4c46b51de523817a0045b150ed11b56f9fff55f12b9edd0f3ed35b15a2809de0", + "sha256:4cbe68ef919c28ea561165206a2dcb68591c50f3bcf777932323bc208d949cf1", + "sha256:4d01c00d09a0be62a4ca3f933e315455bde83f37f892ba4b08ce92f3cf44bcc1", + "sha256:59a0d7d24dfb26729e0a068639a6ce3500e31d6655df8557156c51c1cb874ce7", + "sha256:68351911e85145f582b5aa6cd9ad666c8958bcae897a1bfda8f4940472463c45", + "sha256:7274b0c57737bd3476d2229c6389b2ec9eefeb090bbaf77777e9d6b1b5a9d143", + "sha256:81af8adaa5e3099469e7623436881eff6b3b06db5ef75e6f5b6d4871263547e5", + "sha256:82e469518d3e9a321912955cc702d418773a2fd1e91c651280a1bda10622f02f", + "sha256:8b27958f8c76bed8edaa63da0739d76e4e9ad4ed325c814f9b3851425582a3cd", + "sha256:8c223fa57cb154c7eab5156856c231c3f5eace1e0bed9b32a24696b7ba3c3245", + "sha256:8f57e6b6927a49550da3d122f0cb983d400f843a8a82e65b3b380d3d7259468f", + "sha256:925cd6a3b7b55dfba252b7c4561892311c5358c6b5a601847015a1ad4eb7d332", + "sha256:a43ef1c8ddfdb9575691720b6352761f3f53d85f1b57d7745701041053deff30", + "sha256:a8032e00ce71c3ceb93eeba63963b864bf635a18f6c0c12da6c13c450eedb183", + "sha256:b96ae2c1279d1065413965c607712006205a9ac541895004a1e0d4f281f2ff9f", + "sha256:bb8ccb4724f7d8601938571bf3f24da0da791fe2db7be3d9e79849cb64e0ae85", + "sha256:bbaf4662e498c8c2e352da5f5bca5ab29d378895fa2d980630656178bd607c46", + "sha256:cfd13d47b29ed3bbaafaff7d8b21e90d827631afda134836962011acb5904b71", + "sha256:d4473c22cc296425bbbce7e9429588e76e05bc7342da359d6520b6427bf76660", + "sha256:d8fbb68711905f8912e5af474ca8b78d077447d8f3918997fecbf26943ff3cbb", + "sha256:e5012e5cc2ac628177eaac0e83d622b2dd499e28253d4107a08ecc59ede3fc2c", + "sha256:eb4f18589d196a4cbe5290b435d135dee96567e07c2b2d43b5c4621b6501531a" + ], + "index": "pypi", + "version": "==1.6.1" + }, "mypy-extensions": { "hashes": [ "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", @@ -1195,11 +1398,11 @@ }, "platformdirs": { "hashes": [ - "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3", - "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e" + "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380", + "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420" ], - "markers": "python_version >= '3.7'", - "version": "==3.11.0" + "markers": "python_version >= '3.8'", + "version": "==4.1.0" }, "pluggy": { "hashes": [ @@ -1217,6 +1420,30 @@ "index": "pypi", "version": "==1.4.2" }, + "pylint": { + "hashes": [ + "sha256:0d4c286ef6d2f66c8bfb527a7f8a629009e42c99707dec821a03e1b51a4c1496", + "sha256:60ed5f3a9ff8b61839ff0348b3624ceeb9e6c2a92c514d81c9cc273da3b6bcda" + ], + "index": "pypi", + "version": "==3.0.2" + }, + "pylint-django": { + "hashes": [ + "sha256:2f339e4bf55776958283395c5139c37700c91bd5ef1d8251ef6ac88b5abbba9b", + "sha256:5abd5c2228e0e5e2a4cb6d0b4fc1d1cef1e773d0be911412f4dd4fc1a1a440b7" + ], + "index": "pypi", + "version": "==2.5.5" + }, + "pylint-plugin-utils": { + "hashes": [ + "sha256:ae11664737aa2effbf26f973a9e0b6779ab7106ec0adc5fe104b0907ca04e507", + "sha256:d3cebf68a38ba3fba23a873809155562571386d4c1b03e5b4c4cc26c3eee93e4" + ], + "markers": "python_version >= '3.7' and python_version < '4.0'", + "version": "==0.8.2" + }, "pyparsing": { "hashes": [ "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", @@ -1256,6 +1483,14 @@ ], "version": "==2023.3.post1" }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.31.0" + }, "sqlparse": { "hashes": [ "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", @@ -1272,6 +1507,36 @@ "markers": "python_version < '3.11'", "version": "==2.0.1" }, + "tomlkit": { + "hashes": [ + "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4", + "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.12.3" + }, + "types-pytz": { + "hashes": [ + "sha256:1999a123a3dc0e39a2ef6d19f3f8584211de9e6a77fe7a0259f04a524e90a5cf", + "sha256:cc23d0192cd49c8f6bba44ee0c81e4586a8f30204970fc0894d209a6b08dab9a" + ], + "version": "==2023.3.1.1" + }, + "types-pyyaml": { + "hashes": [ + "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062", + "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24" + ], + "version": "==6.0.12.12" + }, + "types-requests": { + "hashes": [ + "sha256:b32b9a86beffa876c0c3ac99a4cd3b8b51e973fb8e3bd4e0a6bb32c7efad80fc", + "sha256:dc5852a76f1eaf60eafa81a2e50aefa3d1f015c34cf0cba130930866b1b22a92" + ], + "markers": "python_version >= '3.7'", + "version": "==2.31.0.10" + }, "typing-extensions": { "hashes": [ "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", @@ -1279,6 +1544,14 @@ ], "markers": "python_version >= '3.8'", "version": "==4.8.0" + }, + "urllib3": { + "hashes": [ + "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3", + "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54" + ], + "markers": "python_version >= '3.8'", + "version": "==2.1.0" } } } diff --git a/codeforlife/__init__.py b/codeforlife/__init__.py index 1d8216ae..9d552079 100644 --- a/codeforlife/__init__.py +++ b/codeforlife/__init__.py @@ -1,14 +1,29 @@ +""" +© Ocado Group +Created on 09/12/2023 at 11:02:54(+00:00). + +Entry point to the Code for Life package. +""" + +import typing as t from pathlib import Path +from .version import __version__ + +# ------------------------------------------------------------------------------ +# Package setup. +# ------------------------------------------------------------------------------ BASE_DIR = Path(__file__).resolve().parent DATA_DIR = BASE_DIR.joinpath("data") +if t.TYPE_CHECKING: + import django_stubs_ext -from .version import __version__ + django_stubs_ext.monkeypatch() + +# ------------------------------------------------------------------------------ -from . import ( - kurono, - service, - user, -) +# NOTE: These imports need to come after the package setup. +# pylint: disable=wrong-import-position +from . import kurono, service, user diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 9322030d..4618e0cb 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -1,21 +1,170 @@ -"""Helpers for module "django.db.models". -https://docs.djangoproject.com/en/3.2/ref/models/ +""" +© Ocado Group +Created on 04/12/2023 at 14:36:56(+00:00). + +Base models. Tests at: codeforlife.user.tests.models.test_abstract """ import typing as t +from datetime import timedelta -from django.db.models import Model as _Model +from django.core.exceptions import ObjectDoesNotExist +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django_stubs_ext.db.models import TypedModelMeta +from .fields import * -class Model(_Model): - """A base class for all Django models. - Args: - _Model (django.db.models.Model): Django's model class. - """ +class Model(models.Model): + """Provide type hints for general model attributes.""" id: int pk: int + objects: models.Manager + DoesNotExist: t.Type[ObjectDoesNotExist] + + class Meta(TypedModelMeta): + abstract = True AnyModel = t.TypeVar("AnyModel", bound=Model) + + +class WarehouseModel(Model): + """To be inherited by all models whose data is to be warehoused.""" + + class QuerySet(models.QuerySet): + """Custom queryset to support CFL's system's operations.""" + + model: "WarehouseModel" # type: ignore[assignment] + + def update(self, **kwargs): + """Updates all models in the queryset and notes when they were last + saved. + + Args: + last_saved_at: When these models were last modified. + + Returns: + The number of models matched. + """ + + kwargs["last_saved_at"] = timezone.now() + return super().update(**kwargs) + + def delete(self, wait: t.Optional[timedelta] = None): + """Schedules all models in the queryset for deletion. + + Args: + wait: How long to wait before these model are deleted. If not + set, the class-level default value is used. To delete + immediately, set wait to 0 with timedelta(). + """ + + if wait is None: + wait = self.model.delete_wait + + if wait == timedelta(): + super().delete() + else: + self.update(delete_after=timezone.now() + wait) + + class Manager(models.Manager[AnyModel], t.Generic[AnyModel]): + """Custom Manager for all warehouse model managers to inherit.""" + + def get_queryset(self): + """Get custom query set. + + Returns: + A warehouse query set. + """ + + return WarehouseModel.QuerySet( + model=self.model, + using=self._db, + hints=self._hints, # type: ignore[attr-defined] + ) + + def filter(self, *args, **kwargs): + """A stub that return our custom queryset.""" + + return t.cast( + WarehouseModel.QuerySet, + super().filter(*args, **kwargs), + ) + + def exclude(self, *args, **kwargs): + """A stub that return our custom queryset.""" + + return t.cast( + WarehouseModel.QuerySet, + super().exclude(*args, **kwargs), + ) + + def all(self): + """A stub that return our custom queryset.""" + + return t.cast( + WarehouseModel.QuerySet, + super().all(), + ) + + objects: Manager = Manager() + + # Default for how long to wait before a model is deleted. + delete_wait = timedelta(days=3) + + last_saved_at = models.DateTimeField( + _("last saved at"), + auto_now=True, + help_text=_( + "Record the last time the model was saved. This is used by our data" + " warehouse to know what data was modified since the last scheduled" + " data transfer from the database to the data warehouse." + ), + ) + + delete_after = models.DateTimeField( + _("delete after"), + null=True, + blank=True, + help_text=_( + "When this data is scheduled for deletion. Set to null if not" + " scheduled for deletion. This is used by our data warehouse to" + " transfer data that's been scheduled for deletion before it's" + " actually deleted. Data will actually be deleted in a CRON job" + " after this point in time." + ), + ) + + class Meta(TypedModelMeta): + abstract = True + + # pylint: disable-next=arguments-differ + def delete( # type: ignore[override] + self, + *args, + wait: t.Optional[timedelta] = None, + **kwargs, + ): + """Schedules the deletion of this model. + + Args: + wait: How long to wait before this model is deleted. If not set, the + class-level default value is used. To delete immediately, set + wait to 0 with timedelta(). + """ + + if wait is None: + wait = self.delete_wait + + if wait == timedelta(): + super().delete(*args, **kwargs) + else: + self.delete_after = timezone.now() + wait + self.save(*args, **kwargs) + + +AnyWarehouseModel = t.TypeVar("AnyWarehouseModel", bound=WarehouseModel) diff --git a/codeforlife/models/fields.py b/codeforlife/models/fields.py new file mode 100644 index 00000000..59ca74c0 --- /dev/null +++ b/codeforlife/models/fields.py @@ -0,0 +1,385 @@ +from django.db.models import TextChoices +from django.utils.translation import gettext_lazy as _ + + +class Country(TextChoices): + """ISO 3166-1 alpha-2 codes for each country.""" + + AF = "AF", _("Afghanistan") + AX = "AX", _("Åland Islands") + AL = "AL", _("Albania") + DZ = "DZ", _("Algeria") + AS = "AS", _("American Samoa") + AD = "AD", _("Andorra") + AO = "AO", _("Angola") + AI = "AI", _("Anguilla") + AQ = "AQ", _("Antarctica") + AG = "AG", _("Antigua and Barbuda") + AR = "AR", _("Argentina") + AM = "AM", _("Armenia") + AW = "AW", _("Aruba") + AU = "AU", _("Australia") + AT = "AT", _("Austria") + AZ = "AZ", _("Azerbaijan") + BS = "BS", _("Bahamas") + BH = "BH", _("Bahrain") + BD = "BD", _("Bangladesh") + BB = "BB", _("Barbados") + BY = "BY", _("Belarus") + BE = "BE", _("Belgium") + BZ = "BZ", _("Belize") + BJ = "BJ", _("Benin") + BM = "BM", _("Bermuda") + BT = "BT", _("Bhutan") + BO = "BO", _("Bolivia, Plurinational State of") + BQ = "BQ", _("Bonaire, Sint Eustatius and Saba") + BA = "BA", _("Bosnia and Herzegovina") + BW = "BW", _("Botswana") + BV = "BV", _("Bouvet Island") + BR = "BR", _("Brazil") + IO = "IO", _("British Indian Ocean Territory") + BN = "BN", _("Brunei Darussalam") + BG = "BG", _("Bulgaria") + BF = "BF", _("Burkina Faso") + BI = "BI", _("Burundi") + KH = "KH", _("Cambodia") + CM = "CM", _("Cameroon") + CA = "CA", _("Canada") + CV = "CV", _("Cape Verde") + KY = "KY", _("Cayman Islands") + CF = "CF", _("Central African Republic") + TD = "TD", _("Chad") + CL = "CL", _("Chile") + CN = "CN", _("China") + CX = "CX", _("Christmas Island") + CC = "CC", _("Cocos (Keeling) Islands") + CO = "CO", _("Colombia") + KM = "KM", _("Comoros") + CG = "CG", _("Congo") + CD = "CD", _("Congo, the Democratic Republic of the") + CK = "CK", _("Cook Islands") + CR = "CR", _("Costa Rica") + CI = "CI", _("Côte d'Ivoire") + HR = "HR", _("Croatia") + CU = "CU", _("Cuba") + CW = "CW", _("Curaçao") + CY = "CY", _("Cyprus") + CZ = "CZ", _("Czech Republic") + DK = "DK", _("Denmark") + DJ = "DJ", _("Djibouti") + DM = "DM", _("Dominica") + DO = "DO", _("Dominican Republic") + EC = "EC", _("Ecuador") + EG = "EG", _("Egypt") + SV = "SV", _("El Salvador") + GQ = "GQ", _("Equatorial Guinea") + ER = "ER", _("Eritrea") + EE = "EE", _("Estonia") + ET = "ET", _("Ethiopia") + FK = "FK", _("Falkland Islands (Malvinas)") + FO = "FO", _("Faroe Islands") + FJ = "FJ", _("Fiji") + FI = "FI", _("Finland") + FR = "FR", _("France") + GF = "GF", _("French Guiana") + PF = "PF", _("French Polynesia") + TF = "TF", _("French Southern Territories") + GA = "GA", _("Gabon") + GM = "GM", _("Gambia") + GE = "GE", _("Georgia") + DE = "DE", _("Germany") + GH = "GH", _("Ghana") + GI = "GI", _("Gibraltar") + GR = "GR", _("Greece") + GL = "GL", _("Greenland") + GD = "GD", _("Grenada") + GP = "GP", _("Guadeloupe") + GU = "GU", _("Guam") + GT = "GT", _("Guatemala") + GG = "GG", _("Guernsey") + GN = "GN", _("Guinea") + GW = "GW", _("Guinea-Bissau") + GY = "GY", _("Guyana") + HT = "HT", _("Haiti") + HM = "HM", _("Heard Island and McDonald Islands") + VA = "VA", _("Holy See (Vatican City State)") + HN = "HN", _("Honduras") + HK = "HK", _("Hong Kong") + HU = "HU", _("Hungary") + IS = "IS", _("Iceland") + IN = "IN", _("India") + ID = "ID", _("Indonesia") + IR = "IR", _("Iran, Islamic Republic of") + IQ = "IQ", _("Iraq") + IE = "IE", _("Ireland") + IM = "IM", _("Isle of Man") + IL = "IL", _("Israel") + IT = "IT", _("Italy") + JM = "JM", _("Jamaica") + JP = "JP", _("Japan") + JE = "JE", _("Jersey") + JO = "JO", _("Jordan") + KZ = "KZ", _("Kazakhstan") + KE = "KE", _("Kenya") + KI = "KI", _("Kiribati") + KP = "KP", _("Korea, Democratic People's Republic of") + KR = "KR", _("Korea, Republic of") + KW = "KW", _("Kuwait") + KG = "KG", _("Kyrgyzstan") + LA = "LA", _("Lao People's Democratic Republic") + LV = "LV", _("Latvia") + LB = "LB", _("Lebanon") + LS = "LS", _("Lesotho") + LR = "LR", _("Liberia") + LY = "LY", _("Libya") + LI = "LI", _("Liechtenstein") + LT = "LT", _("Lithuania") + LU = "LU", _("Luxembourg") + MO = "MO", _("Macao") + MK = "MK", _("Macedonia, the Former Yugoslav Republic of") + MG = "MG", _("Madagascar") + MW = "MW", _("Malawi") + MY = "MY", _("Malaysia") + MV = "MV", _("Maldives") + ML = "ML", _("Mali") + MT = "MT", _("Malta") + MH = "MH", _("Marshall Islands") + MQ = "MQ", _("Martinique") + MR = "MR", _("Mauritania") + MU = "MU", _("Mauritius") + YT = "YT", _("Mayotte") + MX = "MX", _("Mexico") + FM = "FM", _("Micronesia, Federated States of") + MD = "MD", _("Moldova, Republic of") + MC = "MC", _("Monaco") + MN = "MN", _("Mongolia") + ME = "ME", _("Montenegro") + MS = "MS", _("Montserrat") + MA = "MA", _("Morocco") + MZ = "MZ", _("Mozambique") + MM = "MM", _("Myanmar") + NA = "NA", _("Namibia") + NR = "NR", _("Nauru") + NP = "NP", _("Nepal") + NL = "NL", _("Netherlands") + NC = "NC", _("New Caledonia") + NZ = "NZ", _("New Zealand") + NI = "NI", _("Nicaragua") + NE = "NE", _("Niger") + NG = "NG", _("Nigeria") + NU = "NU", _("Niue") + NF = "NF", _("Norfolk Island") + MP = "MP", _("Northern Mariana Islands") + NO = "NO", _("Norway") + OM = "OM", _("Oman") + PK = "PK", _("Pakistan") + PW = "PW", _("Palau") + PS = "PS", _("Palestine, State of") + PA = "PA", _("Panama") + PG = "PG", _("Papua New Guinea") + PY = "PY", _("Paraguay") + PE = "PE", _("Peru") + PH = "PH", _("Philippines") + PN = "PN", _("Pitcairn") + PL = "PL", _("Poland") + PT = "PT", _("Portugal") + PR = "PR", _("Puerto Rico") + QA = "QA", _("Qatar") + RE = "RE", _("Réunion") + RO = "RO", _("Romania") + RU = "RU", _("Russian Federation") + RW = "RW", _("Rwanda") + BL = "BL", _("Saint Barthélemy") + SH = "SH", _("Saint Helena, Ascension and Tristan da Cunha") + KN = "KN", _("Saint Kitts and Nevis") + LC = "LC", _("Saint Lucia") + MF = "MF", _("Saint Martin (French part)") + PM = "PM", _("Saint Pierre and Miquelon") + VC = "VC", _("Saint Vincent and the Grenadines") + WS = "WS", _("Samoa") + SM = "SM", _("San Marino") + ST = "ST", _("Sao Tome and Principe") + SA = "SA", _("Saudi Arabia") + SN = "SN", _("Senegal") + RS = "RS", _("Serbia") + SC = "SC", _("Seychelles") + SL = "SL", _("Sierra Leone") + SG = "SG", _("Singapore") + SX = "SX", _("Sint Maarten (Dutch part)") + SK = "SK", _("Slovakia") + SI = "SI", _("Slovenia") + SB = "SB", _("Solomon Islands") + SO = "SO", _("Somalia") + ZA = "ZA", _("South Africa") + GS = "GS", _("South Georgia and the South Sandwich Islands") + SS = "SS", _("South Sudan") + ES = "ES", _("Spain") + LK = "LK", _("Sri Lanka") + SD = "SD", _("Sudan") + SR = "SR", _("Suriname") + SJ = "SJ", _("Svalbard and Jan Mayen") + SZ = "SZ", _("Swaziland") + SE = "SE", _("Sweden") + CH = "CH", _("Switzerland") + SY = "SY", _("Syrian Arab Republic") + TW = "TW", _("Taiwan, Province of China") + TJ = "TJ", _("Tajikistan") + TZ = "TZ", _("Tanzania, United Republic of") + TH = "TH", _("Thailand") + TL = "TL", _("Timor-Leste") + TG = "TG", _("Togo") + TK = "TK", _("Tokelau") + TO = "TO", _("Tonga") + TT = "TT", _("Trinidad and Tobago") + TN = "TN", _("Tunisia") + TR = "TR", _("Turkey") + TM = "TM", _("Turkmenistan") + TC = "TC", _("Turks and Caicos Islands") + TV = "TV", _("Tuvalu") + UG = "UG", _("Uganda") + UA = "UA", _("Ukraine") + AE = "AE", _("United Arab Emirates") + GB = "GB", _("United Kingdom") + US = "US", _("United States") + UM = "UM", _("United States Minor Outlying Islands") + UY = "UY", _("Uruguay") + UZ = "UZ", _("Uzbekistan") + VU = "VU", _("Vanuatu") + VE = "VE", _("Venezuela, Bolivarian Republic of") + VN = "VN", _("Viet Nam") + VG = "VG", _("Virgin Islands, British") + VI = "VI", _("Virgin Islands, U.S.") + WF = "WF", _("Wallis and Futuna") + EH = "EH", _("Western Sahara") + YE = "YE", _("Yemen") + ZM = "ZM", _("Zambia") + ZW = "ZW", _("Zimbabwe") + + +class UkCounty(TextChoices): + ABERDEEN_CITY = "Aberdeen City", _("Aberdeen City") + ABERDEENSHIRE = "Aberdeenshire", _("Aberdeenshire") + ANGUS = "Angus", _("Angus") + ARGYLL_AND_BUTE = "Argyll and Bute", _("Argyll and Bute") + BEDFORDSHIRE = "Bedfordshire", _("Bedfordshire") + BELFAST = "Belfast", _("Belfast") + BELFAST_GREATER = "Belfast Greater", _("Belfast Greater") + BERKSHIRE = "Berkshire", _("Berkshire") + BLAENAU_GWENT = "Blaenau Gwent", _("Blaenau Gwent") + BRIDGEND = "Bridgend", _("Bridgend") + BUCKINGHAMSHIRE = "Buckinghamshire", _("Buckinghamshire") + CAERPHILLY = "Caerphilly", _("Caerphilly") + CAMBRIDGESHIRE = "Cambridgeshire", _("Cambridgeshire") + CARDIFF = "Cardiff", _("Cardiff") + CARMARTHENSHIRE = "Carmarthenshire", _("Carmarthenshire") + CEREDIGION = "Ceredigion", _("Ceredigion") + CHANNEL_ISLANDS = "Channel Islands", _("Channel Islands") + CHESHIRE = "Cheshire", _("Cheshire") + CITY_OF_EDINBURGH = "City of Edinburgh", _("City of Edinburgh") + CLACKMANNANSHIRE = "Clackmannanshire", _("Clackmannanshire") + CONWY = "Conwy", _("Conwy") + CORNWALL = "Cornwall", _("Cornwall") + COUNTY_ANTRIM = "County Antrim", _("County Antrim") + COUNTY_ARMAGH = "County Armagh", _("County Armagh") + COUNTY_DOWN = "County Down", _("County Down") + COUNTY_FERMANAGH = "County Fermanagh", _("County Fermanagh") + COUNTY_LONDONDERRY = "County Londonderry", _("County Londonderry") + COUNTY_TYRONE = "County Tyrone", _("County Tyrone") + COUNTY_OF_BRISTOL = "County of Bristol", _("County of Bristol") + CUMBRIA = "Cumbria", _("Cumbria") + DENBIGHSHIRE = "Denbighshire", _("Denbighshire") + DERBYSHIRE = "Derbyshire", _("Derbyshire") + DEVON = "Devon", _("Devon") + DORSET = "Dorset", _("Dorset") + DUMFRIES_AND_GALLOWAY = "Dumfries and Galloway", _("Dumfries and Galloway") + DUNBARTONSHIRE = "Dunbartonshire", _("Dunbartonshire") + DUNDEE_CITY = "Dundee City", _("Dundee City") + DURHAM = "Durham", _("Durham") + EAST_AYRSHIRE = "East Ayrshire", _("East Ayrshire") + EAST_DUNBARTONSHIRE = "East Dunbartonshire", _("East Dunbartonshire") + EAST_LOTHIAN = "East Lothian", _("East Lothian") + EAST_RENFREWSHIRE = "East Renfrewshire", _("East Renfrewshire") + EAST_RIDING_OF_YORKSHIRE = "East Riding of Yorkshire", _( + "East Riding of Yorkshire" + ) + EAST_SUSSEX = "East Sussex", _("East Sussex") + ESSEX = "Essex", _("Essex") + FALKIRK = "Falkirk", _("Falkirk") + FIFE = "Fife", _("Fife") + FLINTSHIRE = "Flintshire", _("Flintshire") + GLASGOW_CITY = "Glasgow City", _("Glasgow City") + GLOUCESTERSHIRE = "Gloucestershire", _("Gloucestershire") + GREATER_LONDON = "Greater London", _("Greater London") + GREATER_MANCHESTER = "Greater Manchester", _("Greater Manchester") + GUERNSEY_CHANNEL_ISLANDS = "Guernsey Channel Islands", _( + "Guernsey Channel Islands" + ) + GWYNEDD = "Gwynedd", _("Gwynedd") + HAMPSHIRE = "Hampshire", _("Hampshire") + HEREFORD_AND_WORCESTER = "Hereford and Worcester", _( + "Hereford and Worcester" + ) + HEREFORDSHIRE = "Herefordshire", _("Herefordshire") + HERTFORDSHIRE = "Hertfordshire", _("Hertfordshire") + HIGHLAND = "Highland", _("Highland") + INVERCLYDE = "Inverclyde", _("Inverclyde") + INVERNESS = "Inverness", _("Inverness") + ISLE_OF_ANGLESEY = "Isle of Anglesey", _("Isle of Anglesey") + ISLE_OF_BARRA = "Isle of Barra", _("Isle of Barra") + ISLE_OF_MAN = "Isle of Man", _("Isle of Man") + ISLE_OF_WIGHT = "Isle of Wight", _("Isle of Wight") + JERSEY_CHANNEL_ISLANDS = "Jersey Channel Islands", _( + "Jersey Channel Islands" + ) + KENT = "Kent", _("Kent") + LANCASHIRE = "Lancashire", _("Lancashire") + LEICESTERSHIRE = "Leicestershire", _("Leicestershire") + LINCOLNSHIRE = "Lincolnshire", _("Lincolnshire") + MERSEYSIDE = "Merseyside", _("Merseyside") + MERTHYR_TYDFIL = "Merthyr Tydfil", _("Merthyr Tydfil") + MIDLOTHIAN = "Midlothian", _("Midlothian") + MONMOUTHSHIRE = "Monmouthshire", _("Monmouthshire") + MORAY = "Moray", _("Moray") + NEATH_PORT_TALBOT = "Neath Port Talbot", _("Neath Port Talbot") + NEWPORT = "Newport", _("Newport") + NORFOLK = "Norfolk", _("Norfolk") + NORTH_AYRSHIRE = "North Ayrshire", _("North Ayrshire") + NORTH_LANARKSHIRE = "North Lanarkshire", _("North Lanarkshire") + NORTH_YORKSHIRE = "North Yorkshire", _("North Yorkshire") + NORTHAMPTONSHIRE = "Northamptonshire", _("Northamptonshire") + NORTHUMBERLAND = "Northumberland", _("Northumberland") + NOTTINGHAMSHIRE = "Nottinghamshire", _("Nottinghamshire") + ORKNEY = "Orkney", _("Orkney") + ORKNEY_ISLANDS = "Orkney Islands", _("Orkney Islands") + OXFORDSHIRE = "Oxfordshire", _("Oxfordshire") + PEMBROKESHIRE = "Pembrokeshire", _("Pembrokeshire") + PERTH_AND_KINROSS = "Perth and Kinross", _("Perth and Kinross") + POWYS = "Powys", _("Powys") + RENFREWSHIRE = "Renfrewshire", _("Renfrewshire") + RHONDDA_CYNON_TAFF = "Rhondda Cynon Taff", _("Rhondda Cynon Taff") + RUTLAND = "Rutland", _("Rutland") + SCOTTISH_BORDERS = "Scottish Borders", _("Scottish Borders") + SHETLAND_ISLANDS = "Shetland Islands", _("Shetland Islands") + SHROPSHIRE = "Shropshire", _("Shropshire") + SOMERSET = "Somerset", _("Somerset") + SOUTH_AYRSHIRE = "South Ayrshire", _("South Ayrshire") + SOUTH_LANARKSHIRE = "South Lanarkshire", _("South Lanarkshire") + SOUTH_YORKSHIRE = "South Yorkshire", _("South Yorkshire") + STAFFORDSHIRE = "Staffordshire", _("Staffordshire") + STIRLING = "Stirling", _("Stirling") + SUFFOLK = "Suffolk", _("Suffolk") + SURREY = "Surrey", _("Surrey") + SWANSEA = "Swansea", _("Swansea") + TORFAEN = "Torfaen", _("Torfaen") + TYNE_AND_WEAR = "Tyne and Wear", _("Tyne and Wear") + VALE_OF_GLAMORGAN = "Vale of Glamorgan", _("Vale of Glamorgan") + WARWICKSHIRE = "Warwickshire", _("Warwickshire") + WEST_DUNBART = "West Dunbart", _("West Dunbart") + WEST_LOTHIAN = "West Lothian", _("West Lothian") + WEST_MIDLANDS = "West Midlands", _("West Midlands") + WEST_SUSSEX = "West Sussex", _("West Sussex") + WEST_YORKSHIRE = "West Yorkshire", _("West Yorkshire") + WESTERN_ISLES = "Western Isles", _("Western Isles") + WILTSHIRE = "Wiltshire", _("Wiltshire") + WORCESTERSHIRE = "Worcestershire", _("Worcestershire") + WREXHAM = "Wrexham", _("Wrexham") diff --git a/codeforlife/settings/django.py b/codeforlife/settings/django.py index 5d33718b..2ec9ef00 100644 --- a/codeforlife/settings/django.py +++ b/codeforlife/settings/django.py @@ -19,6 +19,7 @@ # https://docs.djangoproject.com/en/3.2/topics/auth/default/ LOGIN_URL = f"{SERVICE_API_URL}/session/expired/" +AUTH_USER_MODEL = "user.User" # Authentication backends # https://docs.djangoproject.com/en/3.2/ref/settings/#authentication-backends diff --git a/codeforlife/tests/__init__.py b/codeforlife/tests/__init__.py index 80ebc8c4..daaa7e0d 100644 --- a/codeforlife/tests/__init__.py +++ b/codeforlife/tests/__init__.py @@ -1,2 +1,10 @@ -from .api import APITestCase, APIClient +""" +© Ocado Group +Created on 08/12/2023 at 18:05:20(+00:00). + +All test helpers. +""" + +from .api import APIClient, APITestCase from .cron import CronTestCase, CronTestClient +from .model import ModelTestCase diff --git a/codeforlife/tests/model.py b/codeforlife/tests/model.py new file mode 100644 index 00000000..ff9941f1 --- /dev/null +++ b/codeforlife/tests/model.py @@ -0,0 +1,54 @@ +""" +© Ocado Group +Created on 08/12/2023 at 18:05:47(+00:00). + +Test helpers for Django models. +""" + +import typing as t + +from django.db.utils import IntegrityError +from django.test import TestCase + +from ..models import AnyModel, Model + + +class ModelTestCase(TestCase, t.Generic[AnyModel]): + """Base for all model test cases.""" + + @classmethod + def get_model_class(cls) -> t.Type[AnyModel]: + """Get the model's class. + + Returns: + The model's class. + """ + + # pylint: disable-next=no-member + return t.get_args(cls.__orig_bases__[0])[ # type: ignore[attr-defined] + 0 + ] + + def assert_raises_integrity_error(self, *args, **kwargs): + """Assert the code block raises an integrity error. + + Returns: + Error catcher that will assert if an integrity error is raised. + """ + + return self.assertRaises(IntegrityError, *args, **kwargs) + + def assert_does_not_exist(self, model_or_pk: t.Union[AnyModel, t.Any]): + """Asserts the model does not exist. + + Args: + model_or_pk: The model itself or its primary key. + """ + + if isinstance(model_or_pk, Model): + with self.assertRaises(model_or_pk.DoesNotExist): + model_or_pk.refresh_from_db() + else: + model_class = self.get_model_class() + with self.assertRaises(model_class.DoesNotExist): + model_class.objects.get(pk=model_or_pk) diff --git a/codeforlife/user/fixtures/classes.json b/codeforlife/user/fixtures/classes.json new file mode 100644 index 00000000..083c531d --- /dev/null +++ b/codeforlife/user/fixtures/classes.json @@ -0,0 +1,22 @@ +[ + { + "model": "user.Class", + "pk": "AB123", + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "name": "Example Class 1", + "teacher": 1, + "school": 1 + } + }, + { + "model": "user.Class", + "pk": "AB456", + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "name": "Example Class 2", + "teacher": 2, + "school": 1 + } + } +] \ No newline at end of file diff --git a/codeforlife/user/fixtures/schools.json b/codeforlife/user/fixtures/schools.json new file mode 100644 index 00000000..7d75eef3 --- /dev/null +++ b/codeforlife/user/fixtures/schools.json @@ -0,0 +1,12 @@ +[ + { + "model": "user.School", + "pk": 1, + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "name": "Example School", + "country": "GB", + "uk_county": "Surrey" + } + } +] \ No newline at end of file diff --git a/codeforlife/user/fixtures/students.json b/codeforlife/user/fixtures/students.json new file mode 100644 index 00000000..bc8d65a0 --- /dev/null +++ b/codeforlife/user/fixtures/students.json @@ -0,0 +1,20 @@ +[ + { + "model": "user.Student", + "pk": 1, + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "school": 1, + "klass": "AB123" + } + }, + { + "model": "user.Student", + "pk": 2, + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "school": 1, + "klass": "AB456" + } + } +] \ No newline at end of file diff --git a/codeforlife/user/fixtures/teachers.json b/codeforlife/user/fixtures/teachers.json new file mode 100644 index 00000000..3d7cd7a2 --- /dev/null +++ b/codeforlife/user/fixtures/teachers.json @@ -0,0 +1,20 @@ +[ + { + "model": "user.Teacher", + "pk": 1, + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "is_admin": true, + "school": 1 + } + }, + { + "model": "user.Teacher", + "pk": 2, + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "is_admin": false, + "school": 1 + } + } +] \ No newline at end of file diff --git a/codeforlife/user/fixtures/users.json b/codeforlife/user/fixtures/users.json new file mode 100644 index 00000000..2d3e67c0 --- /dev/null +++ b/codeforlife/user/fixtures/users.json @@ -0,0 +1,57 @@ +[ + { + "model": "user.User", + "pk": 1, + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@codeforlife.com", + "password": "password", + "teacher": 1 + } + }, + { + "model": "user.User", + "pk": 2, + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "first_name": "Jane", + "last_name": "Doe", + "email": "jane.doe@codeforlife.com", + "password": "password", + "teacher": 2 + } + }, + { + "model": "user.User", + "pk": 3, + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "first_name": "SpongeBob", + "password": "password", + "student": 1 + } + }, + { + "model": "user.User", + "pk": 4, + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "first_name": "Patrick", + "password": "password", + "student": 2 + } + }, + { + "model": "user.User", + "pk": 5, + "fields": { + "last_saved_at": "2023-01-01 00:00:00.0+00:00", + "first_name": "Indiana", + "last_name": "Jones", + "email": "indiana.jones@codeforlife.com", + "password": "password" + } + } +] \ No newline at end of file diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index 034c7cbe..b346fa28 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,9 +1,10 @@ -# Generated by Django 3.2.20 on 2023-09-29 17:53 +# Generated by Django 3.2.20 on 2023-12-15 17:11 -import django.contrib.auth.models +from django.conf import settings import django.core.validators from django.db import migrations, models import django.db.models.deletion +import django.utils.timezone class Migration(migrations.Migration): @@ -18,16 +19,79 @@ class Migration(migrations.Migration): migrations.CreateModel( name='User', fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this point in time.", null=True, verbose_name='delete after')), + ('first_name', models.CharField(max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=False, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='date joined')), + ('otp_secret', models.CharField(editable=False, help_text='Secret used to generate a OTP.', max_length=40, null=True, verbose_name='OTP secret')), + ('last_otp_for_time', models.DateTimeField(editable=False, help_text='Used to prevent replay attacks, where the same OTP is used for different times.', null=True, verbose_name='last OTP for-time')), ], options={ - 'proxy': True, - 'indexes': [], - 'constraints': [], + 'verbose_name': 'user', + 'verbose_name_plural': 'users', }, - bases=('auth.user',), - managers=[ - ('objects', django.contrib.auth.models.UserManager()), + ), + migrations.CreateModel( + name='AuthFactor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this point in time.", null=True, verbose_name='delete after')), + ('type', models.TextField(choices=[('otp', 'one-time password')], help_text='The type of authentication factor.', verbose_name='auth factor type')), ], + options={ + 'verbose_name': 'auth factor', + 'verbose_name_plural': 'auth factors', + }, + ), + migrations.CreateModel( + name='Class', + fields=[ + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this point in time.", null=True, verbose_name='delete after')), + ('id', models.CharField(editable=False, help_text='Uniquely identifies a class.', max_length=5, primary_key=True, serialize=False, validators=[django.core.validators.MinLengthValidator(5), django.core.validators.RegexValidator(code='id_not_upper_alphanumeric', message='ID must be alphanumeric with upper case characters.', regex='^[0-9A-Z]*$')], verbose_name='identifier')), + ('name', models.CharField(max_length=200, verbose_name='name')), + ('read_classmates_data', models.BooleanField(default=False, help_text="Designates whether students in this class can see their fellow classmates' data.", verbose_name='read classmates data')), + ('receive_requests_until', models.DateTimeField(help_text="A point in the future until which the class can receive requests from students to join. Set to null if it's not accepting requests.", null=True, verbose_name='accept student join requests until')), + ], + options={ + 'verbose_name': 'class', + 'verbose_name_plural': 'classes', + }, + ), + migrations.CreateModel( + name='OtpBypassToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(max_length=8, validators=[django.core.validators.MinLengthValidator(8)])), + ], + options={ + 'verbose_name': 'OTP bypass token', + 'verbose_name_plural': 'OTP bypass tokens', + }, + ), + migrations.CreateModel( + name='School', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this point in time.", null=True, verbose_name='delete after')), + ('name', models.CharField(help_text="The school's name.", max_length=200, unique=True, verbose_name='name')), + ('country', models.TextField(blank=True, choices=[('AF', 'Afghanistan'), ('AX', 'Åland Islands'), ('AL', 'Albania'), ('DZ', 'Algeria'), ('AS', 'American Samoa'), ('AD', 'Andorra'), ('AO', 'Angola'), ('AI', 'Anguilla'), ('AQ', 'Antarctica'), ('AG', 'Antigua and Barbuda'), ('AR', 'Argentina'), ('AM', 'Armenia'), ('AW', 'Aruba'), ('AU', 'Australia'), ('AT', 'Austria'), ('AZ', 'Azerbaijan'), ('BS', 'Bahamas'), ('BH', 'Bahrain'), ('BD', 'Bangladesh'), ('BB', 'Barbados'), ('BY', 'Belarus'), ('BE', 'Belgium'), ('BZ', 'Belize'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BT', 'Bhutan'), ('BO', 'Bolivia, Plurinational State of'), ('BQ', 'Bonaire, Sint Eustatius and Saba'), ('BA', 'Bosnia and Herzegovina'), ('BW', 'Botswana'), ('BV', 'Bouvet Island'), ('BR', 'Brazil'), ('IO', 'British Indian Ocean Territory'), ('BN', 'Brunei Darussalam'), ('BG', 'Bulgaria'), ('BF', 'Burkina Faso'), ('BI', 'Burundi'), ('KH', 'Cambodia'), ('CM', 'Cameroon'), ('CA', 'Canada'), ('CV', 'Cape Verde'), ('KY', 'Cayman Islands'), ('CF', 'Central African Republic'), ('TD', 'Chad'), ('CL', 'Chile'), ('CN', 'China'), ('CX', 'Christmas Island'), ('CC', 'Cocos (Keeling) Islands'), ('CO', 'Colombia'), ('KM', 'Comoros'), ('CG', 'Congo'), ('CD', 'Congo, the Democratic Republic of the'), ('CK', 'Cook Islands'), ('CR', 'Costa Rica'), ('CI', "Côte d'Ivoire"), ('HR', 'Croatia'), ('CU', 'Cuba'), ('CW', 'Curaçao'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DK', 'Denmark'), ('DJ', 'Djibouti'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('EC', 'Ecuador'), ('EG', 'Egypt'), ('SV', 'El Salvador'), ('GQ', 'Equatorial Guinea'), ('ER', 'Eritrea'), ('EE', 'Estonia'), ('ET', 'Ethiopia'), ('FK', 'Falkland Islands (Malvinas)'), ('FO', 'Faroe Islands'), ('FJ', 'Fiji'), ('FI', 'Finland'), ('FR', 'France'), ('GF', 'French Guiana'), ('PF', 'French Polynesia'), ('TF', 'French Southern Territories'), ('GA', 'Gabon'), ('GM', 'Gambia'), ('GE', 'Georgia'), ('DE', 'Germany'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GR', 'Greece'), ('GL', 'Greenland'), ('GD', 'Grenada'), ('GP', 'Guadeloupe'), ('GU', 'Guam'), ('GT', 'Guatemala'), ('GG', 'Guernsey'), ('GN', 'Guinea'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HT', 'Haiti'), ('HM', 'Heard Island and McDonald Islands'), ('VA', 'Holy See (Vatican City State)'), ('HN', 'Honduras'), ('HK', 'Hong Kong'), ('HU', 'Hungary'), ('IS', 'Iceland'), ('IN', 'India'), ('ID', 'Indonesia'), ('IR', 'Iran, Islamic Republic of'), ('IQ', 'Iraq'), ('IE', 'Ireland'), ('IM', 'Isle of Man'), ('IL', 'Israel'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JP', 'Japan'), ('JE', 'Jersey'), ('JO', 'Jordan'), ('KZ', 'Kazakhstan'), ('KE', 'Kenya'), ('KI', 'Kiribati'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KG', 'Kyrgyzstan'), ('LA', "Lao People's Democratic Republic"), ('LV', 'Latvia'), ('LB', 'Lebanon'), ('LS', 'Lesotho'), ('LR', 'Liberia'), ('LY', 'Libya'), ('LI', 'Liechtenstein'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('MO', 'Macao'), ('MK', 'Macedonia, the Former Yugoslav Republic of'), ('MG', 'Madagascar'), ('MW', 'Malawi'), ('MY', 'Malaysia'), ('MV', 'Maldives'), ('ML', 'Mali'), ('MT', 'Malta'), ('MH', 'Marshall Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MU', 'Mauritius'), ('YT', 'Mayotte'), ('MX', 'Mexico'), ('FM', 'Micronesia, Federated States of'), ('MD', 'Moldova, Republic of'), ('MC', 'Monaco'), ('MN', 'Mongolia'), ('ME', 'Montenegro'), ('MS', 'Montserrat'), ('MA', 'Morocco'), ('MZ', 'Mozambique'), ('MM', 'Myanmar'), ('NA', 'Namibia'), ('NR', 'Nauru'), ('NP', 'Nepal'), ('NL', 'Netherlands'), ('NC', 'New Caledonia'), ('NZ', 'New Zealand'), ('NI', 'Nicaragua'), ('NE', 'Niger'), ('NG', 'Nigeria'), ('NU', 'Niue'), ('NF', 'Norfolk Island'), ('MP', 'Northern Mariana Islands'), ('NO', 'Norway'), ('OM', 'Oman'), ('PK', 'Pakistan'), ('PW', 'Palau'), ('PS', 'Palestine, State of'), ('PA', 'Panama'), ('PG', 'Papua New Guinea'), ('PY', 'Paraguay'), ('PE', 'Peru'), ('PH', 'Philippines'), ('PN', 'Pitcairn'), ('PL', 'Poland'), ('PT', 'Portugal'), ('PR', 'Puerto Rico'), ('QA', 'Qatar'), ('RE', 'Réunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('BL', 'Saint Barthélemy'), ('SH', 'Saint Helena, Ascension and Tristan da Cunha'), ('KN', 'Saint Kitts and Nevis'), ('LC', 'Saint Lucia'), ('MF', 'Saint Martin (French part)'), ('PM', 'Saint Pierre and Miquelon'), ('VC', 'Saint Vincent and the Grenadines'), ('WS', 'Samoa'), ('SM', 'San Marino'), ('ST', 'Sao Tome and Principe'), ('SA', 'Saudi Arabia'), ('SN', 'Senegal'), ('RS', 'Serbia'), ('SC', 'Seychelles'), ('SL', 'Sierra Leone'), ('SG', 'Singapore'), ('SX', 'Sint Maarten (Dutch part)'), ('SK', 'Slovakia'), ('SI', 'Slovenia'), ('SB', 'Solomon Islands'), ('SO', 'Somalia'), ('ZA', 'South Africa'), ('GS', 'South Georgia and the South Sandwich Islands'), ('SS', 'South Sudan'), ('ES', 'Spain'), ('LK', 'Sri Lanka'), ('SD', 'Sudan'), ('SR', 'Suriname'), ('SJ', 'Svalbard and Jan Mayen'), ('SZ', 'Swaziland'), ('SE', 'Sweden'), ('CH', 'Switzerland'), ('SY', 'Syrian Arab Republic'), ('TW', 'Taiwan, Province of China'), ('TJ', 'Tajikistan'), ('TZ', 'Tanzania, United Republic of'), ('TH', 'Thailand'), ('TL', 'Timor-Leste'), ('TG', 'Togo'), ('TK', 'Tokelau'), ('TO', 'Tonga'), ('TT', 'Trinidad and Tobago'), ('TN', 'Tunisia'), ('TR', 'Turkey'), ('TM', 'Turkmenistan'), ('TC', 'Turks and Caicos Islands'), ('TV', 'Tuvalu'), ('UG', 'Uganda'), ('UA', 'Ukraine'), ('AE', 'United Arab Emirates'), ('GB', 'United Kingdom'), ('US', 'United States'), ('UM', 'United States Minor Outlying Islands'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VU', 'Vanuatu'), ('VE', 'Venezuela, Bolivarian Republic of'), ('VN', 'Viet Nam'), ('VG', 'Virgin Islands, British'), ('VI', 'Virgin Islands, U.S.'), ('WF', 'Wallis and Futuna'), ('EH', 'Western Sahara'), ('YE', 'Yemen'), ('ZM', 'Zambia'), ('ZW', 'Zimbabwe')], help_text="The school's country.", null=True, verbose_name='country')), + ('uk_county', models.TextField(blank=True, choices=[('Aberdeen City', 'Aberdeen City'), ('Aberdeenshire', 'Aberdeenshire'), ('Angus', 'Angus'), ('Argyll and Bute', 'Argyll and Bute'), ('Bedfordshire', 'Bedfordshire'), ('Belfast', 'Belfast'), ('Belfast Greater', 'Belfast Greater'), ('Berkshire', 'Berkshire'), ('Blaenau Gwent', 'Blaenau Gwent'), ('Bridgend', 'Bridgend'), ('Buckinghamshire', 'Buckinghamshire'), ('Caerphilly', 'Caerphilly'), ('Cambridgeshire', 'Cambridgeshire'), ('Cardiff', 'Cardiff'), ('Carmarthenshire', 'Carmarthenshire'), ('Ceredigion', 'Ceredigion'), ('Channel Islands', 'Channel Islands'), ('Cheshire', 'Cheshire'), ('City of Edinburgh', 'City of Edinburgh'), ('Clackmannanshire', 'Clackmannanshire'), ('Conwy', 'Conwy'), ('Cornwall', 'Cornwall'), ('County Antrim', 'County Antrim'), ('County Armagh', 'County Armagh'), ('County Down', 'County Down'), ('County Fermanagh', 'County Fermanagh'), ('County Londonderry', 'County Londonderry'), ('County Tyrone', 'County Tyrone'), ('County of Bristol', 'County of Bristol'), ('Cumbria', 'Cumbria'), ('Denbighshire', 'Denbighshire'), ('Derbyshire', 'Derbyshire'), ('Devon', 'Devon'), ('Dorset', 'Dorset'), ('Dumfries and Galloway', 'Dumfries and Galloway'), ('Dunbartonshire', 'Dunbartonshire'), ('Dundee City', 'Dundee City'), ('Durham', 'Durham'), ('East Ayrshire', 'East Ayrshire'), ('East Dunbartonshire', 'East Dunbartonshire'), ('East Lothian', 'East Lothian'), ('East Renfrewshire', 'East Renfrewshire'), ('East Riding of Yorkshire', 'East Riding of Yorkshire'), ('East Sussex', 'East Sussex'), ('Essex', 'Essex'), ('Falkirk', 'Falkirk'), ('Fife', 'Fife'), ('Flintshire', 'Flintshire'), ('Glasgow City', 'Glasgow City'), ('Gloucestershire', 'Gloucestershire'), ('Greater London', 'Greater London'), ('Greater Manchester', 'Greater Manchester'), ('Guernsey Channel Islands', 'Guernsey Channel Islands'), ('Gwynedd', 'Gwynedd'), ('Hampshire', 'Hampshire'), ('Hereford and Worcester', 'Hereford and Worcester'), ('Herefordshire', 'Herefordshire'), ('Hertfordshire', 'Hertfordshire'), ('Highland', 'Highland'), ('Inverclyde', 'Inverclyde'), ('Inverness', 'Inverness'), ('Isle of Anglesey', 'Isle of Anglesey'), ('Isle of Barra', 'Isle of Barra'), ('Isle of Man', 'Isle of Man'), ('Isle of Wight', 'Isle of Wight'), ('Jersey Channel Islands', 'Jersey Channel Islands'), ('Kent', 'Kent'), ('Lancashire', 'Lancashire'), ('Leicestershire', 'Leicestershire'), ('Lincolnshire', 'Lincolnshire'), ('Merseyside', 'Merseyside'), ('Merthyr Tydfil', 'Merthyr Tydfil'), ('Midlothian', 'Midlothian'), ('Monmouthshire', 'Monmouthshire'), ('Moray', 'Moray'), ('Neath Port Talbot', 'Neath Port Talbot'), ('Newport', 'Newport'), ('Norfolk', 'Norfolk'), ('North Ayrshire', 'North Ayrshire'), ('North Lanarkshire', 'North Lanarkshire'), ('North Yorkshire', 'North Yorkshire'), ('Northamptonshire', 'Northamptonshire'), ('Northumberland', 'Northumberland'), ('Nottinghamshire', 'Nottinghamshire'), ('Orkney', 'Orkney'), ('Orkney Islands', 'Orkney Islands'), ('Oxfordshire', 'Oxfordshire'), ('Pembrokeshire', 'Pembrokeshire'), ('Perth and Kinross', 'Perth and Kinross'), ('Powys', 'Powys'), ('Renfrewshire', 'Renfrewshire'), ('Rhondda Cynon Taff', 'Rhondda Cynon Taff'), ('Rutland', 'Rutland'), ('Scottish Borders', 'Scottish Borders'), ('Shetland Islands', 'Shetland Islands'), ('Shropshire', 'Shropshire'), ('Somerset', 'Somerset'), ('South Ayrshire', 'South Ayrshire'), ('South Lanarkshire', 'South Lanarkshire'), ('South Yorkshire', 'South Yorkshire'), ('Staffordshire', 'Staffordshire'), ('Stirling', 'Stirling'), ('Suffolk', 'Suffolk'), ('Surrey', 'Surrey'), ('Swansea', 'Swansea'), ('Torfaen', 'Torfaen'), ('Tyne and Wear', 'Tyne and Wear'), ('Vale of Glamorgan', 'Vale of Glamorgan'), ('Warwickshire', 'Warwickshire'), ('West Dunbart', 'West Dunbart'), ('West Lothian', 'West Lothian'), ('West Midlands', 'West Midlands'), ('West Sussex', 'West Sussex'), ('West Yorkshire', 'West Yorkshire'), ('Western Isles', 'Western Isles'), ('Wiltshire', 'Wiltshire'), ('Worcestershire', 'Worcestershire'), ('Wrexham', 'Wrexham')], help_text="The school's county within the United Kingdom. This value may only be set if the school's country is set to UK.", null=True, verbose_name='united kingdom county')), + ], + options={ + 'verbose_name': 'school', + 'verbose_name_plural': 'schools', + }, ), migrations.CreateModel( name='Session', @@ -35,7 +99,7 @@ class Migration(migrations.Migration): ('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='user.user')), + ('user', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'session', @@ -44,36 +108,124 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='AuthFactor', + name='Teacher', 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='user.user')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this point in time.", null=True, verbose_name='delete after')), + ('is_admin', models.BooleanField(default=False, help_text='Designates if the teacher has admin privileges.', verbose_name='is administrator')), + ('school', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teachers', to='user.school')), ], options={ - 'unique_together': {('user', 'type')}, + 'verbose_name': 'teacher', + 'verbose_name_plural': 'teachers', }, ), migrations.CreateModel( - name='SessionAuthFactor', + name='Student', 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='session_auth_factors', to='user.authfactor')), - ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='session_auth_factors', to='user.session')), + ('last_saved_at', models.DateTimeField(auto_now=True, help_text='Record the last time the model was saved. This is used by our data warehouse to know what data was modified since the last scheduled data transfer from the database to the data warehouse.', verbose_name='last saved at')), + ('delete_after', models.DateTimeField(blank=True, help_text="When this data is scheduled for deletion. Set to null if not scheduled for deletion. This is used by our data warehouse to transfer data that's been scheduled for deletion before it's actually deleted. Data will actually be deleted in a CRON job after this point in time.", null=True, verbose_name='delete after')), + ('auto_gen_password', models.CharField(editable=False, help_text='An auto-generated password that allows student to log directly into their account.', max_length=64, validators=[django.core.validators.MinLengthValidator(64)], verbose_name='automatically generated password')), + ('klass', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='students', to='user.class')), + ('school', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='students', to='user.school')), ], options={ - 'unique_together': {('session', 'auth_factor')}, + 'verbose_name': 'student', + 'verbose_name_plural': 'students', }, ), migrations.CreateModel( - name='OtpBypassToken', + name='SessionAuthFactor', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token', models.CharField(max_length=8, validators=[django.core.validators.MinLengthValidator(8)])), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='otp_bypass_tokens', to='user.user')), + ('auth_factor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='session_auth_factors', to='user.authfactor')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='session_auth_factors', to='user.session')), ], options={ - 'unique_together': {('user', 'token')}, + 'verbose_name': 'session auth factor', + 'verbose_name_plural': 'session auth factors', }, ), + migrations.AddConstraint( + model_name='school', + constraint=models.CheckConstraint(check=models.Q(('uk_county__isnull', True), ('country', 'GB'), _connector='OR'), name='school__no_uk_county_if_country_not_uk'), + ), + migrations.AddField( + model_name='otpbypasstoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='otp_bypass_tokens', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='class', + name='school', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='classes', to='user.school'), + ), + migrations.AddField( + model_name='class', + name='teacher', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='classes', to='user.teacher'), + ), + migrations.AddField( + model_name='authfactor', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_factors', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='user', + name='groups', + field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'), + ), + migrations.AddField( + model_name='user', + name='student', + field=models.OneToOneField(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='user.student'), + ), + migrations.AddField( + model_name='user', + name='teacher', + field=models.OneToOneField(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='user.teacher'), + ), + migrations.AddField( + model_name='user', + name='user_permissions', + field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'), + ), + migrations.AlterUniqueTogether( + name='sessionauthfactor', + unique_together={('session', 'auth_factor')}, + ), + migrations.AlterUniqueTogether( + name='otpbypasstoken', + unique_together={('user', 'token')}, + ), + migrations.AlterUniqueTogether( + name='class', + unique_together={('name', 'school')}, + ), + migrations.AlterUniqueTogether( + name='authfactor', + unique_together={('user', 'type')}, + ), + migrations.AddConstraint( + model_name='user', + constraint=models.CheckConstraint(check=models.Q(('student__isnull', False), ('teacher__isnull', False), _negated=True), name='user__profile'), + ), + migrations.AddConstraint( + model_name='user', + constraint=models.CheckConstraint(check=models.Q(models.Q(('email__isnull', False), ('teacher__isnull', False)), models.Q(('email__isnull', True), ('student__isnull', False)), models.Q(('email__isnull', False), ('student__isnull', True), ('teacher__isnull', True)), _connector='OR'), name='user__email'), + ), + migrations.AddConstraint( + model_name='user', + constraint=models.CheckConstraint(check=models.Q(models.Q(('last_name__isnull', False), ('teacher__isnull', False)), models.Q(('last_name__isnull', True), ('student__isnull', False)), models.Q(('last_name__isnull', False), ('student__isnull', True), ('teacher__isnull', True)), _connector='OR'), name='user__last_name'), + ), + migrations.AddConstraint( + model_name='user', + constraint=models.CheckConstraint(check=models.Q(('is_staff', True), ('student__isnull', False), _negated=True), name='user__is_staff'), + ), + migrations.AddConstraint( + model_name='user', + constraint=models.CheckConstraint(check=models.Q(('is_superuser', True), ('student__isnull', False), _negated=True), name='user__is_superuser'), + ), ] diff --git a/codeforlife/user/models/__init__.py b/codeforlife/user/models/__init__.py index b61114af..15d46a06 100644 --- a/codeforlife/user/models/__init__.py +++ b/codeforlife/user/models/__init__.py @@ -1,12 +1,16 @@ -# from .other import * -# from .session import UserSession -# from .teacher_invitation import SchoolTeacherInvitation +""" +© Ocado Group +Created on 08/12/2023 at 12:28:11(+00:00). + +Shortcut to all user models. +""" + from .auth_factor import AuthFactor -from .klass import Class # 'class' is a reserved keyword +from .klass import Class from .otp_bypass_token import OtpBypassToken from .school import School from .session import Session from .session_auth_factor import SessionAuthFactor from .student import Student from .teacher import Teacher -from .user import User, UserProfile # TODO: remove UserProfile +from .user import User diff --git a/codeforlife/user/models/auth_factor.py b/codeforlife/user/models/auth_factor.py index bec978e4..cb1d3b05 100644 --- a/codeforlife/user/models/auth_factor.py +++ b/codeforlife/user/models/auth_factor.py @@ -1,23 +1,48 @@ +""" +© Ocado Group +Created on 05/12/2023 at 17:47:31(+00:00). + +Auth factor model. +""" + from django.db import models from django.utils.translation import gettext_lazy as _ +from django_stubs_ext.db.models import TypedModelMeta + +from ...models import WarehouseModel +from . import user as _user + -from . import user +class AuthFactor(WarehouseModel): + """A user's enabled authentication factors.""" + # pylint: disable-next=missing-class-docstring + class Manager(WarehouseModel.Manager["AuthFactor"]): + pass + + objects: Manager = Manager() -class AuthFactor(models.Model): class Type(models.TextChoices): + """The type of authentication factor.""" + OTP = "otp", _("one-time password") - user: "user.User" = models.ForeignKey( + user: "_user.User" = models.ForeignKey( # type: ignore[assignment] "user.User", related_name="auth_factors", on_delete=models.CASCADE, ) - type = models.TextField(choices=Type.choices) + type = models.TextField( + _("auth factor type"), + choices=Type.choices, + help_text=_("The type of authentication factor."), + ) - class Meta: + class Meta(TypedModelMeta): + verbose_name = _("auth factor") + verbose_name_plural = _("auth factors") unique_together = ["user", "type"] def __str__(self): - return self.type + return str(self.type) diff --git a/codeforlife/user/models/class_student_join_request.py b/codeforlife/user/models/class_student_join_request.py new file mode 100644 index 00000000..b13608c1 --- /dev/null +++ b/codeforlife/user/models/class_student_join_request.py @@ -0,0 +1,40 @@ +# """ +# © Ocado Group +# Created on 05/12/2023 at 17:46:22(+00:00). + +# Class student join request model. +# """ + +# from django.db import models + +# from ...models import WarehouseModel +# from . import klass as _class +# from . import student as _student + + +# # TODO: move to portal +# class ClassStudentJoinRequest(WarehouseModel): +# """A request from a student to join a class.""" + +# klass: "_class.Class" = models.ForeignKey( +# "user.Class", +# related_name="student_join_requests", +# on_delete=models.CASCADE, +# ) + +# student: "_student.Student" = models.ForeignKey( +# "user.Student", +# related_name="class_join_requests", +# on_delete=models.CASCADE, +# ) + +# # created_at = models.DateTimeField( +# # _("created at"), +# # auto_now_add=True, +# # help_text=_("When the teacher was invited to the school."), +# # ) + +# class Meta: +# unique_together = ["klass", "student"] +# # TODO: check student is independent +# # assert class is receiving requests diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index fa7f12f7..578d7c0f 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -1,102 +1,90 @@ -# from uuid import uuid4 -# from datetime import timedelta - -# from django.db import models -# from django.utils import timezone - -# from .teacher import Teacher - - -# class ClassModelManager(models.Manager): -# def all_members(self, user): -# members = [] -# if hasattr(user, "teacher"): -# members.append(user.teacher) -# if user.teacher.has_school(): -# classes = user.teacher.class_teacher.all() -# for c in classes: -# members.extend(c.students.all()) -# else: -# c = user.student.class_field -# members.append(c.teacher) -# members.extend(c.students.all()) -# return members - -# # Filter out non active classes by default -# def get_queryset(self): -# return super().get_queryset().filter(is_active=True) - - -# class Class(models.Model): -# name = models.CharField(max_length=200) -# teacher = models.ForeignKey( -# Teacher, related_name="class_teacher", on_delete=models.CASCADE -# ) -# access_code = models.CharField(max_length=5, null=True) -# classmates_data_viewable = models.BooleanField(default=False) -# always_accept_requests = models.BooleanField(default=False) -# accept_requests_until = models.DateTimeField(null=True) -# creation_time = models.DateTimeField(default=timezone.now, null=True) -# is_active = models.BooleanField(default=True) -# created_by = models.ForeignKey( -# Teacher, -# null=True, -# blank=True, -# related_name="created_classes", -# on_delete=models.SET_NULL, -# ) - -# objects = ClassModelManager() - -# def __str__(self): -# return self.name - -# @property -# def active_game(self): -# games = self.game_set.filter(game_class=self, is_archived=False) -# if len(games) >= 1: -# assert ( -# len(games) == 1 -# ) # there should NOT be more than one active game -# return games[0] -# return None - -# def has_students(self): -# students = self.students.all() -# return students.count() != 0 - -# def get_requests_message(self): -# if self.always_accept_requests: -# external_requests_message = ( -# "This class is currently set to always accept requests." -# ) -# elif ( -# self.accept_requests_until is not None -# and (self.accept_requests_until - timezone.now()) >= timedelta() -# ): -# external_requests_message = ( -# "This class is accepting external requests until " -# + self.accept_requests_until.strftime("%d-%m-%Y %H:%M") -# + " " -# + timezone.get_current_timezone_name() -# ) -# else: -# external_requests_message = ( -# "This class is not currently accepting external requests." -# ) - -# return external_requests_message - -# def anonymise(self): -# self.name = uuid4().hex -# self.access_code = "" -# self.is_active = False -# self.save() - -# # Remove independent students' requests to join this class -# self.class_request.clear() - -# class Meta(object): -# verbose_name_plural = "classes" - -from common.models import Class +""" +© Ocado Group +Created on 05/12/2023 at 17:44:48(+00:00). + +Class model. + +NOTE: This module has been named "klass" as "class" is a reserved keyword. +""" + +from django.core.validators import MinLengthValidator, RegexValidator +from django.db import models +from django.db.models.query import QuerySet +from django.utils.translation import gettext_lazy as _ +from django_stubs_ext.db.models import TypedModelMeta + +from ...models import WarehouseModel +from . import school as _school +from . import student as _student +from . import teacher as _teacher + + +class Class(WarehouseModel): + """A collection of students owned by a teacher.""" + + # pylint: disable-next=missing-class-docstring + class Manager(WarehouseModel.Manager["Class"]): + pass + + objects: Manager = Manager() + + pk: str # type: ignore[assignment] + students: QuerySet["_student.Student"] + + id = models.CharField( # type: ignore[assignment] + _("identifier"), + primary_key=True, + editable=False, + max_length=5, + help_text=_("Uniquely identifies a class."), + validators=[ + MinLengthValidator(5), + RegexValidator( + regex=r"^[0-9A-Z]*$", + message="ID must be alphanumeric with upper case characters.", + code="id_not_upper_alphanumeric", + ), + ], + ) + + teacher: "_teacher.Teacher" = models.ForeignKey( # type: ignore[assignment] + "user.Teacher", + related_name="classes", + on_delete=models.CASCADE, + ) + + school: "_school.School" = models.ForeignKey( # type: ignore[assignment] + "user.School", + related_name="classes", + on_delete=models.CASCADE, + ) + + name = models.CharField( + _("name"), + max_length=200, + ) + + # TODO: phase out and use django's permission system. + read_classmates_data = models.BooleanField( + _("read classmates data"), + default=False, + help_text=_( + "Designates whether students in this class can see their fellow" + " classmates' data." + ), + ) + + receive_requests_until = models.DateTimeField( + _("accept student join requests until"), + null=True, + help_text=_( + "A point in the future until which the class can receive requests" + " from students to join. Set to null if it's not accepting" + " requests." + ), + ) + + class Meta(TypedModelMeta): + verbose_name = _("class") + verbose_name_plural = _("classes") + unique_together = ["name", "school"] diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py deleted file mode 100644 index f58f7b20..00000000 --- a/codeforlife/user/models/other.py +++ /dev/null @@ -1,65 +0,0 @@ -from django.db import models -from django.utils import timezone - -from .student import Student - - -# TODO: cleanup these other models. - - -# ----------------------------------------------------------------------- -# Below are models used for data tracking and maintenance -# ----------------------------------------------------------------------- -class JoinReleaseStudent(models.Model): - """ - To keep track when a student is released to be independent student or - joins a class to be a school student. - """ - - JOIN = "join" - RELEASE = "release" - - student = models.ForeignKey( - Student, related_name="student", on_delete=models.CASCADE - ) - # either "release" or "join" - action_type = models.CharField(max_length=64) - action_time = models.DateTimeField(default=timezone.now) - - -class DailyActivity(models.Model): - """ - A model to record sets of daily activity. Currently used to record the amount of - student details download clicks, through the CSV and login cards methods, per day. - """ - - date = models.DateField(default=timezone.now) - csv_click_count = models.PositiveIntegerField(default=0) - login_cards_click_count = models.PositiveIntegerField(default=0) - primary_coding_club_downloads = models.PositiveIntegerField(default=0) - python_coding_club_downloads = models.PositiveIntegerField(default=0) - level_control_submits = models.PositiveBigIntegerField(default=0) - teacher_lockout_resets = models.PositiveIntegerField(default=0) - indy_lockout_resets = models.PositiveIntegerField(default=0) - school_student_lockout_resets = models.PositiveIntegerField(default=0) - - class Meta: - verbose_name_plural = "Daily activities" - - def __str__(self): - return f"Activity on {self.date}: CSV clicks: {self.csv_click_count}, login cards clicks: {self.login_cards_click_count}, primary pack downloads: {self.primary_coding_club_downloads}, python pack downloads: {self.python_coding_club_downloads}, level control submits: {self.level_control_submits}, teacher lockout resets: {self.teacher_lockout_resets}, indy lockout resets: {self.indy_lockout_resets}, school student lockout resets: {self.school_student_lockout_resets}" - - -class DynamicElement(models.Model): - """ - This model is meant to allow us to quickly update some elements dynamically on the website without having to - redeploy everytime. For example, if a maintenance banner needs to be added, we check the box in the Django admin - panel, edit the text and it'll show immediately on the website. - """ - - name = models.CharField(max_length=64, unique=True, editable=False) - active = models.BooleanField(default=False) - text = models.TextField(null=True, blank=True) - - def __str__(self) -> str: - return self.name diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index fbd22ad8..6fd303c8 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -1,3 +1,10 @@ +""" +© Ocado Group +Created on 05/12/2023 at 17:44:33(+00:00). + +OTP bypass token model. +""" + import typing as t from itertools import groupby @@ -5,26 +12,52 @@ from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_stubs_ext.db.models import TypedModelMeta -from . import user +from . import user as _user class OtpBypassToken(models.Model): + """ + A one-time-use token that a user can use to bypass their OTP auth factor. + Each user has a limited number of OTP-bypass tokens. + """ + max_count = 10 max_count_validation_error = ValidationError( f"Exceeded max count of {max_count}" ) + # pylint: disable-next=missing-class-docstring class Manager(models.Manager["OtpBypassToken"]): - def create(self, token: str, **kwargs): + def create(self, token: str, **kwargs): # type: ignore[override] + """Create an OTP-bypass token. + + Args: + token: The token value to be hashed. + + Returns: + A OtpBypassToken instance. + """ + return super().create(token=make_password(token), **kwargs) - def bulk_create( + def bulk_create( # type: ignore[override] self, otp_bypass_tokens: t.List["OtpBypassToken"], *args, **kwargs, ): + """Bulk create OTP-bypass tokens. + + Args: + otp_bypass_tokens: The token values to be hashed. + + Returns: + Many OtpBypassToken instances. + """ + def key(otp_bypass_token: OtpBypassToken): return otp_bypass_token.user.id @@ -44,7 +77,7 @@ def key(otp_bypass_token: OtpBypassToken): objects: Manager = Manager() - user: "user.User" = models.ForeignKey( + user: "_user.User" = models.ForeignKey( # type: ignore[assignment] "user.User", related_name="otp_bypass_tokens", on_delete=models.CASCADE, @@ -55,7 +88,9 @@ def key(otp_bypass_token: OtpBypassToken): validators=[MinLengthValidator(8)], ) - class Meta: + class Meta(TypedModelMeta): + verbose_name = _("OTP bypass token") + verbose_name_plural = _("OTP bypass tokens") unique_together = ["user", "token"] def save(self, *args, **kwargs): @@ -69,6 +104,15 @@ def save(self, *args, **kwargs): return super().save(*args, **kwargs) def check_token(self, token: str): + """Check if the token matches. + + Args: + token: Token to check. + + Returns: + A boolean designating if the token matches. + """ + if check_password(token, self.token): self.delete() return True diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index 969c9411..7dbe7f2a 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -1,50 +1,72 @@ -# from uuid import uuid4 - -# from django.db import models -# from django.utils import timezone -# from django_countries.fields import CountryField - - -# class SchoolModelManager(models.Manager): -# # Filter out inactive schools by default -# def get_queryset(self): -# return super().get_queryset().filter(is_active=True) - - -# class School(models.Model): -# name = models.CharField(max_length=200) -# postcode = models.CharField(max_length=10, null=True) -# country = CountryField(blank_label="(select country)") -# creation_time = models.DateTimeField(default=timezone.now, null=True) -# is_active = models.BooleanField(default=True) - -# objects = SchoolModelManager() - -# def __str__(self): -# return self.name - -# def classes(self): -# teachers = self.school_teacher.all() -# if teachers: -# classes = [] -# for teacher in teachers: -# if teacher.class_teacher.all(): -# classes.extend(list(teacher.class_teacher.all())) -# return classes -# return None - -# def admins(self): -# teachers = self.school_teacher.all() -# return ( -# [teacher for teacher in teachers if teacher.is_admin] -# if teachers -# else None -# ) - -# def anonymise(self): -# self.name = uuid4().hex -# self.postcode = "" -# self.is_active = False -# self.save() - -from common.models import School +""" +© Ocado Group +Created on 05/12/2023 at 17:44:05(+00:00). + +School model. +""" + +from django.db import models +from django.db.models import Q +from django.db.models.query import QuerySet +from django.utils.translation import gettext_lazy as _ +from django_stubs_ext.db.models import TypedModelMeta + +from ...models import WarehouseModel +from ...models.fields import Country, UkCounty +from . import klass as _class +from . import student as _student +from . import teacher as _teacher + + +class School(WarehouseModel): + """A collection of teachers and students.""" + + # pylint: disable-next=missing-class-docstring + class Manager(WarehouseModel.Manager["School"]): + pass + + objects: Manager = Manager() + + teachers: QuerySet["_teacher.Teacher"] + students: QuerySet["_student.Student"] + classes: QuerySet["_class.Class"] + + # Shortcuts for convenience. + Country = Country + UkCounty = UkCounty + + name = models.CharField( + _("name"), + max_length=200, + unique=True, + help_text=_("The school's name."), + ) + + country = models.TextField( + _("country"), + choices=Country.choices, + null=True, + blank=True, + help_text=_("The school's country."), + ) + + uk_county = models.TextField( + _("united kingdom county"), + choices=UkCounty.choices, + null=True, + blank=True, + help_text=_( + "The school's county within the United Kingdom. This value may only" + " be set if the school's country is set to UK." + ), + ) + + class Meta(TypedModelMeta): + verbose_name = _("school") + verbose_name_plural = _("schools") + constraints = [ + models.CheckConstraint( + check=Q(uk_county__isnull=True) | Q(country=Country.GB), + name="school__no_uk_county_if_country_not_uk", + ), + ] diff --git a/codeforlife/user/models/school_teacher_invitation.py b/codeforlife/user/models/school_teacher_invitation.py new file mode 100644 index 00000000..ff4e9dd6 --- /dev/null +++ b/codeforlife/user/models/school_teacher_invitation.py @@ -0,0 +1,54 @@ +# """ +# © Ocado Group +# Created on 05/12/2023 at 17:44:14(+00:00). + +# School teacher invitation model. +# """ + +# from datetime import timedelta + +# from django.db import models +# from django.utils import timezone +# from django.utils.translation import gettext_lazy as _ + +# from ...models import WarehouseModel +# from . import school as _school +# from . import teacher as _teacher + + +# def _set_expires_at(): +# return lambda: timezone.now() + timedelta(days=7) + + +# # TODO: move to portal +# class SchoolTeacherInvitation(WarehouseModel): +# """An invitation for a teacher to join a school.""" + +# school: "_school.School" = models.ForeignKey( +# "user.School", +# related_name="teacher_invitations", +# on_delete=models.CASCADE, +# ) + +# teacher: "_teacher.Teacher" = models.ForeignKey( +# "user.Teacher", +# related_name="school_invitations", +# on_delete=models.CASCADE, +# ) + +# expires_at = models.DateTimeField( +# _("is expired"), +# default=_set_expires_at, +# help_text=_("When the teacher was invited to the school."), +# ) + +# class Meta: +# unique_together = ["school", "teacher"] + +# @property +# def is_expired(self): +# return self.expires_at < timezone.now() + +# def refresh(self): +# self.expires_at = _set_expires_at() +# self.save() diff --git a/codeforlife/user/models/session.py b/codeforlife/user/models/session.py index 9becb473..d15c3753 100644 --- a/codeforlife/user/models/session.py +++ b/codeforlife/user/models/session.py @@ -1,33 +1,22 @@ -# from django.db import models -# from django.utils import timezone +""" +© Ocado Group +Created on 04/12/2023 at 17:20:33(+00:00). -# from .classroom import Class -# from .school import School -# from .user import User - - -# class UserSession(models.Model): -# user = models.ForeignKey(User, on_delete=models.CASCADE) -# login_time = models.DateTimeField(default=timezone.now) -# school = models.ForeignKey(School, null=True, on_delete=models.SET_NULL) -# class_field = models.ForeignKey(Class, null=True, on_delete=models.SET_NULL) -# login_type = models.CharField( -# max_length=100, null=True -# ) # for student login - -# def __str__(self): -# return f"{self.user} login: {self.login_time} type: {self.login_type}" +Session model and store. +""" import typing as t from django.contrib.auth import SESSION_KEY from django.contrib.sessions.backends.db import SessionStore as DBStore from django.contrib.sessions.base_session import AbstractBaseSession +from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db.models.query import QuerySet from django.utils import timezone -from . import session_auth_factor, user +from . import session_auth_factor as _session_auth_factor +from . import user as _user class Session(AbstractBaseSession): @@ -36,21 +25,36 @@ class Session(AbstractBaseSession): https://docs.djangoproject.com/en/3.2/topics/http/sessions/#example """ - session_auth_factors: QuerySet["session_auth_factor.SessionAuthFactor"] + DoesNotExist: t.Type[ObjectDoesNotExist] - user: "user.User" = models.OneToOneField( + session_auth_factors: QuerySet["_session_auth_factor.SessionAuthFactor"] + + user: t.Optional[ + "_user.User" + ] = models.OneToOneField( # type: ignore[assignment] "user.User", null=True, - blank=True, on_delete=models.CASCADE, ) @property def is_expired(self): + """Checks if the expiry date is in the past. + + Returns: + A flag designating if the session expired. + """ + return self.expire_date < timezone.now() @property def store(self): + """Creates a store for this session. + + Returns: + A session store instance. + """ + return self.get_session_store_class()(self.session_key) @classmethod @@ -64,6 +68,7 @@ class SessionStore(DBStore): 1. creating only one session per user; 2. setting a session's auth factors; 3. clearing a user's expired sessions. + https://docs.djangoproject.com/en/3.2/topics/http/sessions/#example """ @@ -83,10 +88,10 @@ def create_model_instance(self, data): except Session.DoesNotExist: # Associate session to user. session = Session.objects.get(session_key=self.session_key) - session.user = user.User.objects.get(id=user_id) - session_auth_factor.SessionAuthFactor.objects.bulk_create( + session.user = _user.User.objects.get(id=user_id) + _session_auth_factor.SessionAuthFactor.objects.bulk_create( [ - session_auth_factor.SessionAuthFactor( + _session_auth_factor.SessionAuthFactor( session=session, auth_factor=auth_factor, ) diff --git a/codeforlife/user/models/session_auth_factor.py b/codeforlife/user/models/session_auth_factor.py index d7e43e5f..c0eb764a 100644 --- a/codeforlife/user/models/session_auth_factor.py +++ b/codeforlife/user/models/session_auth_factor.py @@ -1,22 +1,41 @@ +""" +© Ocado Group +Created on 05/12/2023 at 17:43:52(+00:00). + +Session auth factor model. +""" + from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_stubs_ext.db.models import TypedModelMeta -from . import auth_factor, session +from . import auth_factor as _auth_factor +from . import session as _session class SessionAuthFactor(models.Model): - session: "session.Session" = models.ForeignKey( + """ + A pending auth factor for a user's session. If any auth factors are still + pending, the user is not authenticated. + """ + + session: "_session.Session" = models.ForeignKey( # type: ignore[assignment] "user.Session", related_name="session_auth_factors", on_delete=models.CASCADE, ) - auth_factor: "auth_factor.AuthFactor" = models.ForeignKey( - "user.AuthFactor", - related_name="session_auth_factors", - on_delete=models.CASCADE, + auth_factor: "_auth_factor.AuthFactor" = ( + models.ForeignKey( # type: ignore[assignment] + "user.AuthFactor", + related_name="session_auth_factors", + on_delete=models.CASCADE, + ) ) - class Meta: + class Meta(TypedModelMeta): + verbose_name = _("session auth factor") + verbose_name_plural = _("session auth factors") unique_together = ["session", "auth_factor"] def __str__(self): diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index 066ae75f..63b938dd 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -1,73 +1,150 @@ -# from uuid import uuid4 - -# from django.db import models - -# from .user import User -# from .classroom import Class - - -# class StudentModelManager(models.Manager): -# def get_random_username(self): -# while True: -# random_username = uuid4().hex[:30] # generate a random username -# if not User.objects.filter(username=random_username).exists(): -# return random_username - -# def schoolFactory(self, klass, name, password, login_id=None): -# user = User.objects.create_user( -# username=self.get_random_username(), -# password=password, -# first_name=name, -# ) - -# return Student.objects.create( -# class_field=klass, user=user, login_id=login_id -# ) - -# def independentStudentFactory(self, name, email, password): -# user = User.objects.create_user( -# username=email, email=email, password=password, first_name=name -# ) - -# return Student.objects.create(user=user) - - -# class Student(models.Model): -# class_field = models.ForeignKey( -# Class, -# related_name="students", -# null=True, -# blank=True, -# on_delete=models.CASCADE, -# ) -# # hashed uuid used for the unique direct login url -# login_id = models.CharField(max_length=64, null=True) -# user = models.OneToOneField( -# User, -# related_name="student", -# null=True, -# blank=True, -# on_delete=models.CASCADE, -# ) -# pending_class_request = models.ForeignKey( -# Class, -# related_name="class_request", -# null=True, -# blank=True, -# on_delete=models.SET_NULL, -# ) -# blocked_time = models.DateTimeField(null=True, blank=True) - -# objects = StudentModelManager() - -# def is_independent(self): -# return not self.class_field - -# def __str__(self): -# return f"{self.user.first_name} {self.user.last_name}" - - -# def stripStudentName(name): -# return re.sub("[ \t]+", " ", name.strip()) - -from common.models import Student +""" +© Ocado Group +Created on 05/12/2023 at 17:43:33(+00:00). + +Student model. +""" + +import typing as t + +from django.contrib.auth.hashers import make_password +from django.core.validators import MinLengthValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_stubs_ext.db.models import TypedModelMeta + +from ...models import WarehouseModel +from . import klass as _class +from . import school as _school +from . import user as _user + + +class Student(WarehouseModel): + """A user's student profile.""" + + # pylint: disable-next=missing-class-docstring + class Manager(WarehouseModel.Manager["Student"]): + def create( # type: ignore[override] + self, + auto_gen_password: str, + **fields, + ): + """Create a student. + + Args: + auto_gen_password: The student's auto-generated password. + + Returns: + A student instance. + """ + + return super().create( + **fields, + auto_gen_password=make_password(auto_gen_password), + ) + + def bulk_create( # type: ignore[override] + self, + students: t.Iterable["Student"], + *args, + **kwargs, + ): + """Bulk create students. + + Args: + students: An iteration of student objects. + + Returns: + A list of student instances. + """ + + for student in students: + student.auto_gen_password = make_password( + student.auto_gen_password + ) + + return super().bulk_create(students, *args, **kwargs) + + def create_user(self, student: t.Dict[str, t.Any], **fields): + """Create a user with a student profile. + + Args: + student: The student fields. + + Returns: + A user with a student profile. + """ + + return _user.User.objects.create_user( + **fields, + student=self.create(**student), + ) + + def bulk_create_users( + self, + student_users: t.List[t.Tuple["Student", "_user.User"]], + *args, + **kwargs, + ): + """Bulk create users with student profiles. + + Args: + student_users: A list of tuples where the first object is the + student profile and the second is the user whom the student + profile belongs to. + + Returns: + A list of users with a student profile. + """ + + students = [student for (student, _) in student_users] + users = [user for (_, user) in student_users] + + students = self.bulk_create(students, *args, **kwargs) + + for student, user in zip(students, users): + user.student = student + + return _user.User.objects.bulk_create(users, *args, **kwargs) + + objects: Manager = Manager() + + user: "_user.User" + + school: "_school.School" = models.ForeignKey( # type: ignore[assignment] + "user.School", + related_name="students", + editable=False, + on_delete=models.CASCADE, + ) + + klass: "_class.Class" = models.ForeignKey( # type: ignore[assignment] + "user.Class", + related_name="students", + on_delete=models.CASCADE, + ) + + auto_gen_password = models.CharField( + _("automatically generated password"), + max_length=64, + editable=False, + help_text=_( + "An auto-generated password that allows student to log directly" + " into their account." + ), + validators=[MinLengthValidator(64)], + ) + + class Meta(TypedModelMeta): + verbose_name = _("student") + verbose_name_plural = _("students") + + @property + def teacher(self): + """The student's teacher. + + Returns: + The student's class-teacher. + """ + + return self.klass.teacher diff --git a/codeforlife/user/models/teacher.py b/codeforlife/user/models/teacher.py index d53ab4f1..2d1d78ac 100644 --- a/codeforlife/user/models/teacher.py +++ b/codeforlife/user/models/teacher.py @@ -1,68 +1,76 @@ -# from django.db import models - -# from .user import User -# from .school import School - - -# class TeacherModelManager(models.Manager): -# def factory(self, first_name, last_name, email, password): -# user = User.objects.create_user( -# username=email, -# email=email, -# password=password, -# first_name=first_name, -# last_name=last_name, -# ) - -# return Teacher.objects.create(user=user) - -# # Filter out non active teachers by default -# def get_queryset(self): -# return super().get_queryset().filter(user__is_active=True) - - -# class Teacher(models.Model): -# user = models.OneToOneField( -# User, -# related_name="teacher", -# null=True, -# blank=True, -# on_delete=models.CASCADE, -# ) -# school = models.ForeignKey( -# School, -# related_name="school_teacher", -# null=True, -# blank=True, -# on_delete=models.SET_NULL, -# ) -# is_admin = models.BooleanField(default=False) -# blocked_time = models.DateTimeField(null=True, blank=True) -# invited_by = models.ForeignKey( -# "self", -# related_name="invited_teachers", -# null=True, -# blank=True, -# on_delete=models.SET_NULL, -# ) - -# objects = TeacherModelManager() - -# def teaches(self, userprofile): -# if hasattr(userprofile, "student"): -# student = userprofile.student -# return ( -# not student.is_independent() -# and student.class_field.teacher == self -# ) - -# def has_school(self): -# return self.school is not (None or "") - -# def has_class(self): -# return self.class_teacher.exists() - -# def __str__(self): -# return f"{self.user.first_name} {self.user.last_name}" - -from common.models import Teacher +""" +© Ocado Group +Created on 05/12/2023 at 17:43:14(+00:00). + +Teacher model. +""" + +import typing as t + +from django.db import models +from django.db.models.query import QuerySet +from django.utils.translation import gettext_lazy as _ +from django_stubs_ext.db.models import TypedModelMeta + +from ...models import WarehouseModel +from . import klass as _class +from . import school as _school +from . import student as _student +from . import user as _user + + +class Teacher(WarehouseModel): + """A user's teacher profile.""" + + # pylint: disable-next=missing-class-docstring + class Manager(WarehouseModel.Manager["Teacher"]): + def create_user(self, teacher: t.Dict[str, t.Any], **fields): + """Create a user with a teacher profile. + + Args: + user: The user fields. + + Returns: + A teacher profile. + """ + + return _user.User.objects.create_user( + **fields, + teacher=self.create(**teacher), + ) + + objects: Manager = Manager() + + user: "_user.User" + classes: QuerySet["_class.Class"] + + school: t.Optional[ + "_school.School" + ] = models.ForeignKey( # type: ignore[assignment] + "user.School", + related_name="teachers", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + + # TODO: phase out and use django's permission system. + is_admin = models.BooleanField( + _("is administrator"), + default=False, + help_text=_("Designates if the teacher has admin privileges."), + ) + + class Meta(TypedModelMeta): + verbose_name = _("teacher") + verbose_name_plural = _("teachers") + + @property + def students(self) -> QuerySet["_student.Student"]: + """All students in this teacher's classes. + + Returns: + A queryset + """ + + return _student.Student.objects.filter(klass__in=self.classes.all()) diff --git a/codeforlife/user/models/teacher_invitation.py b/codeforlife/user/models/teacher_invitation.py deleted file mode 100644 index d6c2e9ca..00000000 --- a/codeforlife/user/models/teacher_invitation.py +++ /dev/null @@ -1,47 +0,0 @@ -from uuid import uuid4 - -from django.db import models -from django.utils import timezone - -from .school import School -from .teacher import Teacher - - -class SchoolTeacherInvitationModelManager(models.Manager): - # Filter out inactive invitations by default - def get_queryset(self): - return super().get_queryset().filter(is_active=True) - - -class SchoolTeacherInvitation(models.Model): - token = models.CharField(max_length=32) - school = models.ForeignKey( - School, - related_name="teacher_invitations", - null=True, - on_delete=models.SET_NULL, - ) - from_teacher = models.ForeignKey( - Teacher, - related_name="school_invitations", - null=True, - on_delete=models.SET_NULL, - ) - creation_time = models.DateTimeField(default=timezone.now, null=True) - is_active = models.BooleanField(default=True) - - objects = SchoolTeacherInvitationModelManager() - - @property - def is_expired(self): - return self.expiry < timezone.now() - - def __str__(self): - return f"School teacher invitation for {self.invited_teacher_email} to {self.school.name}" - - def anonymise(self): - self.invited_teacher_first_name = uuid4().hex - self.invited_teacher_last_name = uuid4().hex - self.invited_teacher_email = uuid4().hex - self.is_active = False - self.save() diff --git a/codeforlife/user/models/user.py b/codeforlife/user/models/user.py index 553012ea..5206df39 100644 --- a/codeforlife/user/models/user.py +++ b/codeforlife/user/models/user.py @@ -1,109 +1,284 @@ -# from datetime import timedelta -# from enum import Enum +""" +© Ocado Group +Created on 04/12/2023 at 17:19:37(+00:00). -# from django.contrib.auth.models import AbstractUser -# from django.contrib.auth.models import UserManager as AbstractUserManager -# from django.db import models -# from django.utils import timezone +User model. +""" +import typing as t -# class UserManager(AbstractUserManager): -# def create_user(self, username, email=None, password=None, **extra_fields): -# return super().create_user(username, email, password, **extra_fields) +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import ( + AbstractBaseUser, + BaseUserManager, + PermissionsMixin, +) +from django.db import models +from django.db.models import Q +from django.db.models.query import QuerySet +from django.db.utils import IntegrityError +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django_stubs_ext.db.models import TypedModelMeta -# def create_superuser( -# self, username, email=None, password=None, **extra_fields -# ): -# return super().create_superuser( -# username, email, password, **extra_fields -# ) +from ...models import WarehouseModel +from . import auth_factor as _auth_factor +from . import otp_bypass_token as _otp_bypass_token +from . import session as _session +from . import student as _student +from . import teacher as _teacher -# class User(AbstractUser): -# class Type(str, Enum): -# TEACHER = "teacher" -# DEP_STUDENT = "dependent-student" -# INDEP_STUDENT = "independent-student" +class User(AbstractBaseUser, WarehouseModel, PermissionsMixin): + """A user within the CFL system.""" -# developer = models.BooleanField(default=False) -# is_verified = models.BooleanField(default=False) + USERNAME_FIELD = "id" -# objects: UserManager = UserManager() + class Manager(BaseUserManager["User"], WarehouseModel.Manager["User"]): + """ + https://docs.djangoproject.com/en/3.2/topics/auth/customizing/#writing-a-manager-for-a-custom-user-model -# def __str__(self): -# return self.get_full_name() + Custom user manager for custom user model. + """ -# @property -# def joined_recently(self): -# return timezone.now() - timedelta(days=7) <= self.date_joined + def create(self, **kwargs): + """Prevent calling create to maintain data integrity.""" -import typing as t + raise IntegrityError("Must call create_user instead.") -from common.models import UserProfile -from django.contrib.auth.models import User as _User -from django.db.models.query import QuerySet -from django.utils.translation import gettext_lazy as _ + def _create_user( + self, + password: str, + email: t.Optional[str] = None, + **fields, + ): + if email: + email = self.normalize_email(email) -from . import auth_factor, otp_bypass_token, session -from .student import Student -from .teacher import Teacher + user = User( + **fields, + password=make_password(password), + email=email, + ) + user.save(using=self._db, _from_manager=True) + return user + def create_user(self, password: str, first_name: str, **fields): + """Create a user. -class User(_User): - id: int - auth_factors: QuerySet["auth_factor.AuthFactor"] - otp_bypass_tokens: QuerySet["otp_bypass_token.OtpBypassToken"] - session: "session.Session" - userprofile: UserProfile + https://github.com/django/django/blob/19bc11f636ca2b5b80c3d9ad5b489e43abad52bb/django/contrib/auth/models.py#L149C9-L149C20 - class Meta: - proxy = True + Args: + password: The user's non-hashed password. + first_name: The user's first name. - @property - def is_authenticated(self): - """ - Check if the user has any pending auth factors. - """ + Returns: + A user instance. + """ - try: - return not self.session.session_auth_factors.exists() - except session.Session.DoesNotExist: - return False + fields.setdefault("is_staff", False) + fields.setdefault("is_superuser", False) + return self._create_user(password, first_name=first_name, **fields) - @property - def student(self) -> t.Optional[Student]: - try: - return self.new_student - except Student.DoesNotExist: - return None + def create_superuser(self, password: str, first_name: str, **fields): + """Create a super user. - @property - def teacher(self) -> t.Optional[Teacher]: - try: - return self.new_teacher - except Teacher.DoesNotExist: - return None + https://github.com/django/django/blob/19bc11f636ca2b5b80c3d9ad5b489e43abad52bb/django/contrib/auth/models.py#L154C9-L154C25 - @property - def is_student(self): - return self.student is not None + Args: + password: The user's non-hashed password. + first_name: The user's first name. - @property - def is_teacher(self): - return self.teacher is not None + Raises: + ValueError: If is_staff is not True. + ValueError: If is_superuser is not True. - @property - def otp_secret(self): - return self.userprofile.otp_secret + Returns: + A user instance. + """ - @property - def last_otp_for_time(self): - return self.userprofile.last_otp_for_time + fields.setdefault("is_staff", True) + fields.setdefault("is_superuser", True) - @property - def is_verified(self): - return self.userprofile.is_verified + if fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(password, first_name=first_name, **fields) + + objects: Manager = Manager() + + session: "_session.Session" + auth_factors: QuerySet["_auth_factor.AuthFactor"] + otp_bypass_tokens: QuerySet["_otp_bypass_token.OtpBypassToken"] + + first_name = models.CharField( + _("first name"), + max_length=150, + ) + + last_name = models.CharField( + _("last name"), + max_length=150, + null=True, + blank=True, + ) + + email = models.EmailField( + _("email address"), + unique=True, + null=True, + blank=True, + ) + + 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=False, + 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, + editable=False, + ) + + otp_secret = models.CharField( + _("OTP secret"), + max_length=40, + null=True, + editable=False, + help_text=_("Secret used to generate a OTP."), + ) + + last_otp_for_time = models.DateTimeField( + _("last OTP for-time"), + null=True, + editable=False, + help_text=_( + "Used to prevent replay attacks, where the same OTP is used for" + " different times." + ), + ) + + teacher: t.Optional[ + "_teacher.Teacher" + ] = models.OneToOneField( # type: ignore[assignment] + "user.Teacher", + null=True, + editable=False, + on_delete=models.CASCADE, + ) + + student: t.Optional[ + "_student.Student" + ] = models.OneToOneField( # type: ignore[assignment] + "user.Student", + null=True, + editable=False, + on_delete=models.CASCADE, + ) + + class Meta(TypedModelMeta): + verbose_name = _("user") + verbose_name_plural = _("users") + constraints = [ + # pylint: disable=unsupported-binary-operation + models.CheckConstraint( + check=~Q( + teacher__isnull=False, + student__isnull=False, + ), + name="user__profile", + ), + models.CheckConstraint( + check=( + Q( + teacher__isnull=False, + email__isnull=False, + ) + | Q( + student__isnull=False, + email__isnull=True, + ) + | Q( + teacher__isnull=True, + student__isnull=True, + email__isnull=False, + ) + ), + name="user__email", + ), + models.CheckConstraint( + check=( + Q( + teacher__isnull=False, + last_name__isnull=False, + ) + | Q( + student__isnull=False, + last_name__isnull=True, + ) + | Q( + teacher__isnull=True, + student__isnull=True, + last_name__isnull=False, + ) + ), + name="user__last_name", + ), + models.CheckConstraint( + check=~Q( + student__isnull=False, + is_staff=True, + ), + name="user__is_staff", + ), + models.CheckConstraint( + check=~Q( + student__isnull=False, + is_superuser=True, + ), + name="user__is_superuser", + ), + # pylint: enable=unsupported-binary-operation + ] @property - def aimmo_badges(self): - return self.userprofile.aimmo_badges + def is_authenticated(self): + """Check if the user has any pending auth factors.""" + + try: + return not self.session.session_auth_factors.exists() + except _session.Session.DoesNotExist: + return False + + def save(self, *args, **kwargs): + if self.id is None and not kwargs.pop("_from_manager", False): + raise IntegrityError("Must call create_user from manager instead.") + + if ( + self.student + # pylint: disable-next=no-member + and self.student.klass.students.filter( + user__first_name=self.first_name + ).exists() + ): + raise IntegrityError( + "Another student in the class already has first name" + f' "{self.first_name}".' + ) + + super().save(*args, **kwargs) diff --git a/codeforlife/user/tests/models/test_klass.py b/codeforlife/user/tests/models/test_klass.py new file mode 100644 index 00000000..0c924386 --- /dev/null +++ b/codeforlife/user/tests/models/test_klass.py @@ -0,0 +1,20 @@ +""" +© Ocado Group +Created on 08/12/2023 at 17:43:11(+00:00). +""" + +from ....tests import ModelTestCase +from ...models import Class + + +class TestClass(ModelTestCase[Class]): + """Tests the Class model.""" + + # TODO: test docstrings. + + def test_id__validators__regex(self): + """ + Check the regex validation of a class' ID. + """ + + raise NotImplementedError() # TODO diff --git a/codeforlife/user/tests/models/test_otp_bypass_token.py b/codeforlife/user/tests/models/test_otp_bypass_token.py index a6683d5c..2854b158 100644 --- a/codeforlife/user/tests/models/test_otp_bypass_token.py +++ b/codeforlife/user/tests/models/test_otp_bypass_token.py @@ -10,6 +10,9 @@ class TestOtpBypassToken(TestCase): def setUp(self): self.user = User.objects.get(id=2) + # TODO: test docstrings. + # TODO: fix unit tests. + def test_bulk_create(self): token = get_random_string(8) otp_bypass_tokens = OtpBypassToken.objects.bulk_create( diff --git a/codeforlife/user/tests/models/test_school.py b/codeforlife/user/tests/models/test_school.py new file mode 100644 index 00000000..77985a36 --- /dev/null +++ b/codeforlife/user/tests/models/test_school.py @@ -0,0 +1,25 @@ +""" +© Ocado Group +Created on 08/12/2023 at 17:43:11(+00:00). +""" + +from ....tests import ModelTestCase +from ...models import School + + +class TestSchool(ModelTestCase[School]): + """Tests the School model.""" + + # TODO: test docstrings. + + def test_constraints__no_uk_county_if_country_not_uk(self): + """ + Cannot have set a UK county if the country is not set to UK. + """ + + with self.assert_raises_integrity_error(): + School.objects.create( + name="name", + country=School.Country.US, + uk_county=School.UkCounty.ABERDEEN_CITY, + ) diff --git a/codeforlife/user/tests/models/test_student.py b/codeforlife/user/tests/models/test_student.py new file mode 100644 index 00000000..b82ec039 --- /dev/null +++ b/codeforlife/user/tests/models/test_student.py @@ -0,0 +1,48 @@ +""" +© Ocado Group +Created on 08/12/2023 at 17:43:11(+00:00). +""" + +from ....tests import ModelTestCase +from ...models import Student + + +class TestStudent(ModelTestCase[Student]): + """Tests the Student model.""" + + # TODO: test docstrings. + + def test_objects__create(self): + """ + Create a student. + """ + + raise NotImplementedError() # TODO + + def test_objects__bulk_create(self): + """ + Bulk create many students. + """ + + raise NotImplementedError() # TODO + + def test_objects__create_user(self): + """ + Create a user with a student profile. + """ + + raise NotImplementedError() # TODO + + def test_objects__bulk_create_users(self): + """ + Bulk create many users with a student profile. + """ + + raise NotImplementedError() # TODO + + def test_teacher(self): + """ + Get student's teacher. + """ + + raise NotImplementedError() # TODO diff --git a/codeforlife/user/tests/models/test_teacher.py b/codeforlife/user/tests/models/test_teacher.py new file mode 100644 index 00000000..6695cd99 --- /dev/null +++ b/codeforlife/user/tests/models/test_teacher.py @@ -0,0 +1,57 @@ +""" +© Ocado Group +Created on 08/12/2023 at 17:43:11(+00:00). +""" + +from ....tests import ModelTestCase +from ...models import Student, Teacher + + +class TestTeacher(ModelTestCase[Teacher]): + """Tests the Teacher model.""" + + fixtures = [ + "users", + "teachers", + "schools", + "classes", + "students", + ] + + def setUp(self): + self.teacher__1 = Teacher.objects.get(pk=1) + self.student__1 = Student.objects.get(pk=1) + + # TODO: test docstrings. + + def test_objects__create_user(self): + """ + Create a user with a teacher profile. + """ + + teacher_fields = {"is_admin": True} + user_fields = { + "first_name": "first_name", + "last_name": "last_name", + "email": "example@codeforlife.com", + "password": "password", + } + + user = Teacher.objects.create_user( + teacher=teacher_fields, + **user_fields, + ) + + assert user.first_name == user_fields["first_name"] + assert user.last_name == user_fields["last_name"] + assert user.email == user_fields["email"] + assert user.password != user_fields["password"] + assert user.check_password(user_fields["password"]) + assert user.teacher.is_admin == teacher_fields["is_admin"] + + def test_students(self): + """ + Get all students from all classes. + """ + + assert list(self.teacher__1.students) == [self.student__1] diff --git a/codeforlife/user/tests/models/test_user.py b/codeforlife/user/tests/models/test_user.py new file mode 100644 index 00000000..11633193 --- /dev/null +++ b/codeforlife/user/tests/models/test_user.py @@ -0,0 +1,319 @@ +""" +© Ocado Group +Created on 08/12/2023 at 17:37:30(+00:00). +""" + +from ....tests import ModelTestCase +from ...models import Class, School, Student, Teacher, User + + +class TestUser(ModelTestCase[User]): + """Tests the User model.""" + + fixtures = [ + "users", + "teachers", + "schools", + "classes", + "students", + ] + + def setUp(self): + self.klass__AB123 = Class.objects.get(pk="AB123") + self.school__1 = School.objects.get(pk=1) + self.student__1 = Student.objects.get(pk=1) + + # TODO: test docstrings. + + def test_save__create(self): + """ + Cannot create a user calling save. + """ + + with self.assert_raises_integrity_error(): + User( + first_name="first_name", + last_name="last_name", + email="example@codeforlife.com", + password="password", + ).save() + + def test_save__first_name__student(self): + """ + Students must have a unique name per class. + """ + + student = Student.objects.create( + auto_gen_password="password", + klass=self.student__1.klass, + school=self.student__1.school, + ) + + with self.assert_raises_integrity_error(): + User.objects.create_user( + first_name=self.student__1.user.first_name, + password="password", + student=student, + ) + + def test_constraints__profile(self): + """ + Cannot be a student and a teacher. + """ + + teacher = Teacher.objects.create() + student = Student.objects.create( + auto_gen_password="password", + klass=self.klass__AB123, + school=self.school__1, + ) + + with self.assert_raises_integrity_error(): + User.objects.create_user( + password="password", + first_name="student_and_teacher", + last_name="last_name", + student=student, + teacher=teacher, + ) + + def test_constraints__email__teacher(self): + """ + Teachers must have an email. + """ + + teacher = Teacher.objects.create() + + with self.assert_raises_integrity_error(): + User.objects.create_user( + password="password", + first_name="teacher", + last_name="last_name", + teacher=teacher, + ) + + def test_constraints__email__student(self): + """ + Student cannot have an email. + """ + + student = Student.objects.create( + auto_gen_password="password", + klass=self.klass__AB123, + school=self.school__1, + ) + + with self.assert_raises_integrity_error(): + User.objects.create_user( + password="password", + first_name="student", + student=student, + email="student@codeforlife.com", + ) + + def test_constraints__email__indy(self): + """ + Independents must have an email. + """ + + with self.assert_raises_integrity_error(): + User.objects.create_user( + password="password", + first_name="first_name", + last_name="last_name", + ) + + def test_constraints__last_name__teacher(self): + """ + Teachers must have a last name. + """ + + teacher = Teacher.objects.create() + + with self.assert_raises_integrity_error(): + User.objects.create_user( + password="password", + first_name="teacher", + email="teacher@codeforlife.com", + teacher=teacher, + ) + + def test_constraints__last_name__students(self): + """ + Students can't have a last name. + """ + + student = Student.objects.create( + auto_gen_password="password", + klass=self.klass__AB123, + school=self.school__1, + ) + + with self.assert_raises_integrity_error(): + User.objects.create_user( + password="password", + first_name="student", + last_name="last_name", + student=student, + ) + + def test_constraints__last_name__indy(self): + """ + Independents must have a last name. + """ + + with self.assert_raises_integrity_error(): + User.objects.create_user( + password="password", + first_name="Indiana", + email="independent@codeforlife.com", + ) + + def test_constraints__is_staff(self): + """ + Students cannot be a staff user. + """ + + with self.assert_raises_integrity_error(): + User.objects.create_user( + first_name="Indiana", + password="password", + student=Student.objects.create( + auto_gen_password="password", + klass=self.klass__AB123, + school=self.school__1, + ), + is_staff=True, + ) + + def test_constraints__is_superuser(self): + """ + Students cannot be a super user. + """ + + with self.assert_raises_integrity_error(): + User.objects.create_user( + first_name="Indiana", + password="password", + student=Student.objects.create( + auto_gen_password="password", + klass=self.klass__AB123, + school=self.school__1, + ), + is_superuser=True, + ) + + def test_objects__create(self): + """ + Cannot call objects.create. + """ + + with self.assert_raises_integrity_error(): + User.objects.create() + + def test_objects__create_user__teacher(self): + """ + Create a teacher user. + """ + + user_fields = { + "first_name": "first_name", + "last_name": "last_name", + "email": "example@codeforlife.com", + "password": "password", + "teacher": Teacher.objects.create(), + } + + user = User.objects.create_user(**user_fields) # type: ignore[arg-type] + assert user.first_name == user_fields["first_name"] + assert user.last_name == user_fields["last_name"] + assert user.email == user_fields["email"] + assert user.password != user_fields["password"] + assert user.check_password(user_fields["password"]) + assert user.teacher == user_fields["teacher"] + + def test_objects__create_user__student(self): + """ + Create a student user. + """ + + user_fields = { + "first_name": "first_name", + "password": "password", + "student": Student.objects.create( + auto_gen_password="password", + klass=self.klass__AB123, + school=self.school__1, + ), + } + + user = User.objects.create_user(**user_fields) # type: ignore[arg-type] + assert user.first_name == user_fields["first_name"] + assert user.password != user_fields["password"] + assert user.check_password(user_fields["password"]) + assert user.student == user_fields["student"] + + def test_objects__create_user__indy(self): + """ + Create an independent user. + """ + + user_fields = { + "first_name": "first_name", + "last_name": "last_name", + "email": "example@codeforlife.com", + "password": "password", + } + + user = User.objects.create_user(**user_fields) + assert user.first_name == user_fields["first_name"] + assert user.last_name == user_fields["last_name"] + assert user.email == user_fields["email"] + assert user.password != user_fields["password"] + assert user.check_password(user_fields["password"]) + + def test_objects__create_superuser__teacher(self): + """ + Create a teacher super user. + """ + + user_fields = { + "first_name": "first_name", + "last_name": "last_name", + "email": "example@codeforlife.com", + "password": "password", + "teacher": Teacher.objects.create(), + } + + user = User.objects.create_superuser( + **user_fields # type: ignore[arg-type] + ) + assert user.first_name == user_fields["first_name"] + assert user.last_name == user_fields["last_name"] + assert user.email == user_fields["email"] + assert user.password != user_fields["password"] + assert user.check_password(user_fields["password"]) + assert user.teacher == user_fields["teacher"] + assert user.is_staff + assert user.is_superuser + + def test_objects__create_superuser__indy(self): + """ + Create an independent super user. + """ + + user_fields = { + "first_name": "first_name", + "last_name": "last_name", + "email": "example@codeforlife.com", + "password": "password", + } + + user = User.objects.create_superuser(**user_fields) + assert user.first_name == user_fields["first_name"] + assert user.last_name == user_fields["last_name"] + assert user.email == user_fields["email"] + assert user.password != user_fields["password"] + assert user.check_password(user_fields["password"]) + assert user.is_staff + assert user.is_superuser diff --git a/codeforlife/user/tests/models/test_warehouse.py b/codeforlife/user/tests/models/test_warehouse.py new file mode 100644 index 00000000..a0ebea97 --- /dev/null +++ b/codeforlife/user/tests/models/test_warehouse.py @@ -0,0 +1,137 @@ +""" +© Ocado Group +Created on 08/12/2023 at 15:48:38(+00:00). +""" + +from datetime import timedelta +from unittest.mock import patch + +from django.utils import timezone + +from ....tests import ModelTestCase +from ...models import User + + +class TestWarehouse(ModelTestCase[User]): + """ + Tests the abstract model inherited by other models. + + Abstract model path: codeforlife.models + """ + + # TODO: group fixtures by scenarios, not model classes. + fixtures = [ + "users", + "teachers", + "schools", + "classes", + "students", + ] + + def setUp(self): + self.user__1 = User.objects.get(pk=1) + self.user__2 = User.objects.get(pk=2) + + # TODO: test docstrings. + + def test_delete__wait(self): + """ + Set a model's deletion schedule. + """ + + now = timezone.now() + with patch.object(timezone, "now", return_value=now) as timezone_now: + self.user__1.delete() + + assert timezone_now.call_count == 2 + assert self.user__1.delete_after == now + User.delete_wait + assert self.user__1.last_saved_at == now + + def test_delete__now(self): + """ + Delete a model now. + """ + + self.user__1.delete(wait=timedelta()) + self.assert_does_not_exist(self.user__1) + + def test_objects__delete__wait(self): + """ + Set many models deletion schedules. + """ + + now = timezone.now() + with patch.object(timezone, "now", return_value=now) as timezone_now: + User.objects.filter( + pk__in=[ + self.user__1.pk, + self.user__2.pk, + ] + ).delete() + + assert timezone_now.call_count == 2 + + self.user__1.refresh_from_db() + assert self.user__1.delete_after == now + User.delete_wait + assert self.user__1.last_saved_at == now + + self.user__2.refresh_from_db() + assert self.user__2.delete_after == now + User.delete_wait + assert self.user__2.last_saved_at == now + + def test_objects__delete__now(self): + """ + Delete many models now. + """ + + User.objects.filter( + pk__in=[ + self.user__1.pk, + self.user__2.pk, + ] + ).delete(wait=timedelta()) + + self.assert_does_not_exist(self.user__1) + self.assert_does_not_exist(self.user__2) + + def test_objects__create(self): + """ + Creating a model records when it was first saved. + """ + + now = timezone.now() + with patch.object(timezone, "now", return_value=now) as timezone_now: + user = User.objects.create_user( + password="password", + first_name="first_name", + last_name="last_name", + email="example@email.com", + ) + + assert timezone_now.call_count == 1 + assert user.last_saved_at == now + + def test_objects__bulk_create(self): + """ + Bulk creating models records when they were first saved. + """ + + now = timezone.now() + with patch.object(timezone, "now", return_value=now) as timezone_now: + users = User.objects.bulk_create( + [ + User( + first_name="first_name_1", + last_name="last_name_1", + email="example_1@email.com", + ), + User( + first_name="first_name_2", + last_name="last_name_2", + email="example_2@email.com", + ), + ] + ) + + assert timezone_now.call_count == 2 + assert all(user.last_saved_at == now for user in users) diff --git a/codeforlife/user/tests/views/test_klass.py b/codeforlife/user/tests/views/test_klass.py index 87ba9d6b..5118ca5c 100644 --- a/codeforlife/user/tests/views/test_klass.py +++ b/codeforlife/user/tests/views/test_klass.py @@ -63,3 +63,4 @@ def test_retrieve__student__same_school__in_class(self): self._retrieve_class(user.student.class_field) # TODO: other retrieve and list tests + # TODO: fix unit tests. diff --git a/manage.py b/manage.py index a317e0a8..b374b966 100644 --- a/manage.py +++ b/manage.py @@ -13,10 +13,6 @@ "django.contrib.staticfiles", "django.contrib.sites", "codeforlife.user", - "aimmo", # TODO: remove this - "game", # TODO: remove this - "common", # TODO: remove this - "portal", # TODO: remove this ] MIDDLEWARE = [ @@ -50,6 +46,8 @@ } } +AUTH_USER_MODEL = "user.User" + if __name__ == "__main__": import os import sys diff --git a/pyproject.toml b/pyproject.toml index 75a801c6..9a504ad8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,3 +11,16 @@ extend-exclude = "^/codeforlife/user/migrations/" [tool.pytest.ini_options] env = ["DJANGO_SETTINGS_MODULE=manage"] + +[tool.mypy] +plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"] +check_untyped_defs = true + +[tool.django-stubs] +django_settings_module = "manage" + +[tool.pylint.main] +init-hook = "import os; os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'manage')" + +[tool.pylint.format] +max-line-length = 80