From 21309b6d22755f8c9c02bc0037410cc6087a245e Mon Sep 17 00:00:00 2001 From: Stefan Kairinos <118008817+SKairinos@users.noreply.github.com> Date: Tue, 24 Oct 2023 11:46:08 +0100 Subject: [PATCH] fix: foundation of breaking circular dependencies (#17) * create user filter set * ci[setup]: sync dependencies [skip ci] * default installed django apps * rename filter to filterset * fix: include user urls in service * rename results to data * read only fields * update read and write fields * id is read only * add class viewset * use access_code as lookup * read only class fields * fix: viewsets * test retrieve user * add doc string * finish user tests * support indy students * add school tests * test filtering user list * indy student is forbidden * user_urls_path * general tests and relocate login methods * fix urls * feedback pt.1 * update filters * fix indy tests * fix user queryset * sort importd * sort imports * final feedback * whitespaces Co-Authored-By: cfl-bot --- Pipfile | 1 + Pipfile.lock | 343 ++++++----- codeforlife/pagination.py | 20 + codeforlife/settings/django.py | 10 + codeforlife/settings/third_party.py | 13 + codeforlife/tests/__init__.py | 1 + codeforlife/tests/api.py | 281 +++++++++ codeforlife/tests/cron.py | 21 +- codeforlife/urls.py | 14 + codeforlife/user/filters/__init__.py | 1 + codeforlife/user/filters/user.py | 14 + codeforlife/user/models/__init__.py | 13 +- codeforlife/user/models/classroom.py | 100 ---- codeforlife/user/models/klass.py | 102 ++++ codeforlife/user/models/school.py | 98 +-- codeforlife/user/models/student.py | 144 ++--- codeforlife/user/models/teacher.py | 110 ++-- codeforlife/user/models/user.py | 45 +- codeforlife/user/permissions/__init__.py | 2 + .../user/permissions/is_school_member.py | 18 + .../user/permissions/is_school_teacher.py | 15 + codeforlife/user/serializers/__init__.py | 5 + codeforlife/user/serializers/klass.py | 15 + codeforlife/user/serializers/school.py | 13 + codeforlife/user/serializers/student.py | 12 + codeforlife/user/serializers/teacher.py | 12 + codeforlife/user/serializers/user.py | 18 + codeforlife/user/tests/test_user.py | 25 - codeforlife/user/tests/views/__init__.py | 0 codeforlife/user/tests/views/test_klass.py | 65 ++ codeforlife/user/tests/views/test_school.py | 245 ++++++++ codeforlife/user/tests/views/test_user.py | 561 ++++++++++++++++++ codeforlife/user/urls.py | 13 +- codeforlife/user/views/__init__.py | 3 + codeforlife/user/views/klass.py | 23 + codeforlife/user/views/school.py | 22 + codeforlife/user/views/user.py | 37 ++ setup.py | 13 +- 38 files changed, 1953 insertions(+), 495 deletions(-) create mode 100644 codeforlife/pagination.py create mode 100644 codeforlife/tests/api.py create mode 100644 codeforlife/user/filters/__init__.py create mode 100644 codeforlife/user/filters/user.py delete mode 100644 codeforlife/user/models/classroom.py create mode 100644 codeforlife/user/models/klass.py create mode 100644 codeforlife/user/permissions/__init__.py create mode 100644 codeforlife/user/permissions/is_school_member.py create mode 100644 codeforlife/user/permissions/is_school_teacher.py create mode 100644 codeforlife/user/serializers/__init__.py create mode 100644 codeforlife/user/serializers/klass.py create mode 100644 codeforlife/user/serializers/school.py create mode 100644 codeforlife/user/serializers/student.py create mode 100644 codeforlife/user/serializers/teacher.py create mode 100644 codeforlife/user/serializers/user.py delete mode 100644 codeforlife/user/tests/test_user.py create mode 100644 codeforlife/user/tests/views/__init__.py create mode 100644 codeforlife/user/tests/views/test_klass.py create mode 100644 codeforlife/user/tests/views/test_school.py create mode 100644 codeforlife/user/tests/views/test_user.py create mode 100644 codeforlife/user/views/__init__.py create mode 100644 codeforlife/user/views/klass.py create mode 100644 codeforlife/user/views/school.py create mode 100644 codeforlife/user/views/user.py diff --git a/Pipfile b/Pipfile index 13d9a95f..f29865cd 100644 --- a/Pipfile +++ b/Pipfile @@ -6,6 +6,7 @@ name = "pypi" [packages] django = "==3.2.20" djangorestframework = "==3.13.1" +django-filter = "==23.2" django-countries = "==7.3.1" django-two-factor-auth = "==1.13.2" django-cors-headers = "==4.1.0" diff --git a/Pipfile.lock b/Pipfile.lock index c43f6514..830b2c3a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "aa3e3eedfa674929a89e7496ab35a21a63986025b3ade8f19b29004d0afffda4" + "sha256": "4e77ad0e846637e2f1e9ce14086a2f878f3efb1df90745b14d7aa156b4a02bd9" }, "pipfile-spec": 6, "requires": { @@ -66,84 +66,99 @@ }, "charset-normalizer": { "hashes": [ - "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96", - "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c", - "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710", - "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706", - "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020", - "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252", - "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad", - "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329", - "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a", - "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f", - "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6", - "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4", - "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a", - "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46", - "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2", - "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23", - "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace", - "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd", - "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982", - "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10", - "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2", - "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea", - "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09", - "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5", - "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149", - "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489", - "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9", - "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80", - "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592", - "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3", - "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6", - "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed", - "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c", - "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200", - "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a", - "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e", - "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d", - "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6", - "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623", - "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669", - "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3", - "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa", - "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9", - "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2", - "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f", - "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1", - "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4", - "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a", - "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8", - "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3", - "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029", - "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f", - "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959", - "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22", - "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7", - "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952", - "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346", - "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e", - "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d", - "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299", - "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd", - "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a", - "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3", - "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037", - "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94", - "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c", - "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858", - "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a", - "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449", - "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c", - "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918", - "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1", - "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c", - "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", - "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" + "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" ], "markers": "python_full_version >= '3.7.0'", - "version": "==3.2.0" + "version": "==3.3.0" }, "click": { "hashes": [ @@ -215,6 +230,14 @@ ], "version": "==3.7" }, + "django-filter": { + "hashes": [ + "sha256:2fe15f78108475eda525692813205fa6f9e8c1caf1ae65daa5862d403c6dbf00", + "sha256:d12d8e0fc6d3eb26641e553e5d53b191eb8cec611427d4bdce0becb1f7c172b5" + ], + "index": "pypi", + "version": "==23.2" + }, "django-formtools": { "hashes": [ "sha256:304fa777b8ef9e0693ce7833f885cb89ba46b0e46fc23b01176900a93f46742f", @@ -343,81 +366,79 @@ }, "google-auth": { "hashes": [ - "sha256:9800802266366a2a87890fb2d04923fc0c0d4368af0b86db18edd94a62386ea1", - "sha256:d38bdf4fa1e7c5a35e574861bce55784fd08afadb4e48f99f284f1e487ce702d" + "sha256:6864247895eea5d13b9c57c9e03abb49cb94ce2dc7c58e91cba3248c7477c9e3", + "sha256:a8f4608e65c244ead9e0538f181a96c6e11199ec114d41f1d7b1bffa96937bda" ], "markers": "python_version >= '3.7'", - "version": "==2.23.1" + "version": "==2.23.3" }, "greenlet": { "hashes": [ - "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a", - "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a", - "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1", - "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43", - "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33", - "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8", - "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088", - "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca", - "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343", - "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645", - "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db", - "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df", - "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3", - "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86", - "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2", - "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a", - "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf", - "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7", - "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394", - "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40", - "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3", - "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6", - "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74", - "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0", - "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3", - "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91", - "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5", - "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9", - "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417", - "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8", - "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b", - "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6", - "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb", - "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73", - "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b", - "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df", - "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9", - "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f", - "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0", - "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857", - "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a", - "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249", - "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30", - "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292", - "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b", - "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d", - "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b", - "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c", - "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca", - "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7", - "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75", - "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae", - "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47", - "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b", - "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470", - "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c", - "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564", - "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9", - "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099", - "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0", - "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5", - "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19", - "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1", - "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526" + "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" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.0.2" + "markers": "python_version >= '3.7'", + "version": "==3.0.0" }, "hypothesis": { "hashes": [ @@ -1015,27 +1036,27 @@ }, "urllib3": { "hashes": [ - "sha256:13abf37382ea2ce6fb744d4dad67838eec857c9f4f57009891805e0b5e123594", - "sha256:ef16afa8ba34a1f989db38e1dbbe0c302e4289a47856990d0682e374563ce35e" + "sha256:7a7c7003b000adf9e7ca2a377c9688bbc54ed41b985789ed576570342a375cd2", + "sha256:b19e1a85d206b56d7df1d5e683df4a7725252a964e3993648dd0fb5a1c157564" ], "markers": "python_version >= '3.7'", - "version": "==2.0.5" + "version": "==2.0.6" }, "websocket-client": { "hashes": [ - "sha256:3aad25d31284266bcfcfd1fd8a743f63282305a364b8d0948a43bd606acc652f", - "sha256:6cfc30d051ebabb73a5fa246efdcc14c8fbebbd0330f8984ac3bb6d9edd2ad03" + "sha256:084072e0a7f5f347ef2ac3d8698a5e0b4ffbfcab607628cadabc650fc9a83a24", + "sha256:b3324019b3c28572086c4a319f91d1dcd44e6e11cd340232978c684a7650d0df" ], "markers": "python_version >= '3.8'", - "version": "==1.6.3" + "version": "==1.6.4" }, "werkzeug": { "hashes": [ - "sha256:2b8c0e447b4b9dbcc85dd97b6eeb4dcbaf6c8b6c3be0bd654e25553e0a2157d8", - "sha256:effc12dba7f3bd72e605ce49807bbe692bd729c3bb122a3b91747a6ae77df528" + "sha256:3ffff4dcc32db52ef3cc94dff3000a3c2846890f3a5a51800a27b909c5e770f0", + "sha256:cbb2600f7eabe51dbc0502f58be0b3e1b96b893b05695ea2b35b43d4de2d9962" ], "markers": "python_version >= '3.8'", - "version": "==2.3.7" + "version": "==3.0.0" }, "xlrd": { "hashes": [ @@ -1158,11 +1179,11 @@ }, "packaging": { "hashes": [ - "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", - "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" ], "markers": "python_version >= '3.7'", - "version": "==23.1" + "version": "==23.2" }, "pathspec": { "hashes": [ @@ -1174,11 +1195,11 @@ }, "platformdirs": { "hashes": [ - "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d", - "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d" + "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3", + "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e" ], "markers": "python_version >= '3.7'", - "version": "==3.10.0" + "version": "==3.11.0" }, "pluggy": { "hashes": [ diff --git a/codeforlife/pagination.py b/codeforlife/pagination.py new file mode 100644 index 00000000..45739043 --- /dev/null +++ b/codeforlife/pagination.py @@ -0,0 +1,20 @@ +from rest_framework.pagination import ( + LimitOffsetPagination as _LimitOffsetPagination, +) +from rest_framework.response import Response + + +class LimitOffsetPagination(_LimitOffsetPagination): + default_limit = 50 + max_limit = 150 + + def get_paginated_response(self, data): + return Response( + { + "count": self.count, + "offset": self.offset, + "limit": self.limit, + "max_limit": self.max_limit, + "data": data, + } + ) diff --git a/codeforlife/settings/django.py b/codeforlife/settings/django.py index 51a8beb2..5d33718b 100644 --- a/codeforlife/settings/django.py +++ b/codeforlife/settings/django.py @@ -122,3 +122,13 @@ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] + +# Installed Apps +# https://docs.djangoproject.com/en/3.2/ref/settings/#installed-apps + +INSTALLED_APPS = [ + "codeforlife.user", + "corsheaders", + "rest_framework", + "django_filters", +] diff --git a/codeforlife/settings/third_party.py b/codeforlife/settings/third_party.py index 4da01a78..11afa12e 100644 --- a/codeforlife/settings/third_party.py +++ b/codeforlife/settings/third_party.py @@ -10,3 +10,16 @@ CORS_ALLOW_ALL_ORIGINS = DEBUG CORS_ALLOW_CREDENTIALS = True CORS_ALLOWED_ORIGINS = ["https://www.codeforlife.education"] + +# REST framework +# https://www.django-rest-framework.org/api-guide/settings/#settings + +REST_FRAMEWORK = { + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_FILTER_BACKENDS": [ + "django_filters.rest_framework.DjangoFilterBackend" + ], + "DEFAULT_PAGINATION_CLASS": "codeforlife.pagination.LimitOffsetPagination", +} diff --git a/codeforlife/tests/__init__.py b/codeforlife/tests/__init__.py index e415ed2d..80ebc8c4 100644 --- a/codeforlife/tests/__init__.py +++ b/codeforlife/tests/__init__.py @@ -1 +1,2 @@ +from .api import APITestCase, APIClient from .cron import CronTestCase, CronTestClient diff --git a/codeforlife/tests/api.py b/codeforlife/tests/api.py new file mode 100644 index 00000000..b68eb10a --- /dev/null +++ b/codeforlife/tests/api.py @@ -0,0 +1,281 @@ +import typing as t +from unittest.mock import patch + +from django.db.models import Model +from django.db.models.query import QuerySet +from django.urls import reverse +from django.utils import timezone +from django.utils.http import urlencode +from pyotp import TOTP +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer +from rest_framework.test import APIClient as _APIClient +from rest_framework.test import APITestCase as _APITestCase +from rest_framework.viewsets import ModelViewSet + +from ..user.models import AuthFactor, User + +AnyModelViewSet = t.TypeVar("AnyModelViewSet", bound=ModelViewSet) +AnyModelSerializer = t.TypeVar("AnyModelSerializer", bound=ModelSerializer) +AnyModel = t.TypeVar("AnyModel", bound=Model) + + +class APIClient(_APIClient): + StatusCodeAssertion = t.Optional[t.Union[int, t.Callable[[int], bool]]] + ListFilters = t.Optional[t.Dict[str, str]] + + @staticmethod + def status_code_is_ok(status_code: int): + return 200 <= status_code < 300 + + def generic( + self, + method, + path, + data="", + content_type="application/octet-stream", + secure=False, + status_code_assertion: StatusCodeAssertion = None, + **extra, + ): + wsgi_response = super().generic( + method, path, data, content_type, secure, **extra + ) + + # Use a custom kwarg to handle the common case of checking the + # response's status code. + if status_code_assertion is None: + status_code_assertion = self.status_code_is_ok + elif isinstance(status_code_assertion, int): + expected_status_code = status_code_assertion + status_code_assertion = ( + lambda status_code: status_code == expected_status_code + ) + assert status_code_assertion( + wsgi_response.status_code + ), f"Unexpected status code: {wsgi_response.status_code}." + + return wsgi_response + + def login(self, **credentials): + assert super().login( + **credentials + ), f"Failed to login with credentials: {credentials}." + + user = User.objects.get(session=self.session.session_key) + + if user.session.session_auth_factors.filter( + auth_factor__type=AuthFactor.Type.OTP + ).exists(): + now = timezone.now() + otp = TOTP(user.otp_secret).at(now) + with patch.object(timezone, "now", return_value=now): + assert super().login( + otp=otp + ), f'Failed to login with OTP "{otp}" at {now}.' + + assert user.is_authenticated, "Failed to authenticate user." + + return user + + def login_teacher(self, is_admin: bool, **credentials): + user = self.login(**credentials) + assert user.teacher + assert user.teacher.school + assert is_admin == user.teacher.is_admin + return user + + def login_student(self, **credentials): + user = self.login(**credentials) + assert user.student + assert user.student.class_field.teacher.school + return user + + def login_indy_student(self, **credentials): + user = self.login(**credentials) + assert user.student + assert not user.student.class_field + return user + + @staticmethod + def assert_data_equals_model( + data: t.Dict[str, t.Any], + model: AnyModel, + model_serializer_class: t.Type[AnyModelSerializer], + ): + assert ( + data == model_serializer_class(model).data + ), "Data does not equal serialized model." + + def retrieve( + self, + basename: str, + model: AnyModel, + model_serializer_class: t.Type[AnyModelSerializer], + status_code_assertion: StatusCodeAssertion = None, + model_view_set_class: t.Type[AnyModelViewSet] = None, + **kwargs, + ): + lookup_field = ( + "pk" + if model_view_set_class is None + else model_view_set_class.lookup_field + ) + + response: Response = self.get( + reverse( + f"{basename}-detail", + kwargs={lookup_field: getattr(model, lookup_field)}, + ), + status_code_assertion=status_code_assertion, + **kwargs, + ) + + if self.status_code_is_ok(response.status_code): + self.assert_data_equals_model( + response.json(), + model, + model_serializer_class, + ) + + return response + + def list( + self, + basename: str, + models: t.Iterable[AnyModel], + model_serializer_class: t.Type[AnyModelSerializer], + status_code_assertion: StatusCodeAssertion = None, + filters: ListFilters = None, + **kwargs, + ): + model_class: t.Type[AnyModel] = model_serializer_class.Meta.model + assert model_class.objects.difference( + model_class.objects.filter(pk__in=[model.pk for model in models]) + ).exists(), "List must exclude some models for a valid test." + + response: Response = self.get( + f"{reverse(f'{basename}-list')}?{urlencode(filters or {})}", + status_code_assertion=status_code_assertion, + **kwargs, + ) + + if self.status_code_is_ok(response.status_code): + for data, model in zip(response.json()["data"], models): + self.assert_data_equals_model( + data, + model, + model_serializer_class, + ) + + return response + + +class APITestCase(_APITestCase): + client: APIClient + client_class = APIClient + + def get_other_user( + self, + user: User, + other_users: QuerySet[User], + is_teacher: bool, + ): + """ + Get a different user. + """ + + other_user = other_users.first() + assert other_user + assert user != other_user + assert other_user.is_teacher if is_teacher else other_user.is_student + return other_user + + def get_other_school_user( + self, + user: User, + other_users: QuerySet[User], + is_teacher: bool, + ): + """ + Get a different user that is in a school. + - the provided user does not have to be in a school. + - the other user has to be in a school. + """ + + other_user = self.get_other_user(user, other_users, is_teacher) + assert ( + other_user.teacher.school + if is_teacher + else other_user.student.class_field.teacher.school + ) + return other_user + + def get_another_school_user( + self, + user: User, + other_users: QuerySet[User], + is_teacher: bool, + same_school: bool, + same_class: bool = None, + ): + """ + Get a different user that is also in a school. + - the provided user has to be in a school. + - the other user has to be in a school. + """ + + other_user = self.get_other_school_user(user, other_users, is_teacher) + + school = ( + user.teacher.school + if user.teacher + else user.student.class_field.teacher.school + ) + assert school + + other_school = ( + other_user.teacher.school + if is_teacher + else other_user.student.class_field.teacher.school + ) + assert other_school + + if same_school: + assert school == other_school + + # Cannot assert that 2 teachers are in the same class since a class + # can only have 1 teacher. + if not (user.is_teacher and other_user.is_teacher): + # At this point, same_class needs to be set. + assert same_class is not None, "same_class must be set." + + # If one of the users is a teacher. + if user.is_teacher or is_teacher: + # Get the teacher. + teacher = other_user if is_teacher else user + + # Get the student's class' teacher. + class_teacher = ( + user if is_teacher else other_user + ).student.class_field.teacher.new_user + + # Assert the teacher is the class' teacher. + assert ( + teacher == class_teacher + if same_class + else teacher != class_teacher + ) + # Else, both users are students. + else: + assert ( + user.student.class_field + == other_user.student.class_field + if same_class + else user.student.class_field + != other_user.student.class_field + ) + else: + assert school != other_school + + return other_user diff --git a/codeforlife/tests/cron.py b/codeforlife/tests/cron.py index 5a45b287..a510ff6e 100644 --- a/codeforlife/tests/cron.py +++ b/codeforlife/tests/cron.py @@ -1,28 +1,11 @@ -from rest_framework.test import APIClient, APITestCase +from .api import APIClient, APITestCase class CronTestClient(APIClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs, HTTP_X_APPENGINE_CRON="true") - def generic( - self, - method, - path, - data="", - content_type="application/octet-stream", - secure=False, - **extra, - ): - wsgi_response = super().generic( - method, path, data, content_type, secure, **extra - ) - assert ( - 200 <= wsgi_response.status_code < 300 - ), f"Response has error status code: {wsgi_response.status_code}" - - return wsgi_response - class CronTestCase(APITestCase): + client: CronTestClient client_class = CronTestClient diff --git a/codeforlife/urls.py b/codeforlife/urls.py index e43627dc..ff3178d6 100644 --- a/codeforlife/urls.py +++ b/codeforlife/urls.py @@ -12,7 +12,9 @@ def service_urlpatterns( api_urls_path: str = "api.urls", frontend_template_name: str = "frontend.html", + include_user_urls: bool = True, ): + # Specific url patterns. urlpatterns = [ path( "admin/", @@ -42,6 +44,18 @@ def service_urlpatterns( ), name="session-expired", ), + ] + + # General url patterns. + if include_user_urls: + urlpatterns.append( + path( + "api/", + include("codeforlife.user.urls"), + name="user", + ) + ) + urlpatterns += [ path( "api/", include(api_urls_path), diff --git a/codeforlife/user/filters/__init__.py b/codeforlife/user/filters/__init__.py new file mode 100644 index 00000000..7f31a373 --- /dev/null +++ b/codeforlife/user/filters/__init__.py @@ -0,0 +1 @@ +from .user import UserFilterSet diff --git a/codeforlife/user/filters/user.py b/codeforlife/user/filters/user.py new file mode 100644 index 00000000..c8991667 --- /dev/null +++ b/codeforlife/user/filters/user.py @@ -0,0 +1,14 @@ +from django_filters import rest_framework as filters + +from ..models import User + + +class UserFilterSet(filters.FilterSet): + students_in_class = filters.CharFilter( + "new_student__class_field", + "exact", + ) + + class Meta: + model = User + fields = ["students_in_class"] diff --git a/codeforlife/user/models/__init__.py b/codeforlife/user/models/__init__.py index a8b856db..b61114af 100644 --- a/codeforlife/user/models/__init__.py +++ b/codeforlife/user/models/__init__.py @@ -1,13 +1,12 @@ -# from .classroom import Class - -# # from .other import * -# from .school import School +# from .other import * # from .session import UserSession -# from .student import Student # from .teacher_invitation import SchoolTeacherInvitation -# from .teacher import Teacher from .auth_factor import AuthFactor +from .klass import Class # 'class' is a reserved keyword from .otp_bypass_token import OtpBypassToken +from .school import School from .session import Session from .session_auth_factor import SessionAuthFactor -from .user import User +from .student import Student +from .teacher import Teacher +from .user import User, UserProfile # TODO: remove UserProfile diff --git a/codeforlife/user/models/classroom.py b/codeforlife/user/models/classroom.py deleted file mode 100644 index a743a025..00000000 --- a/codeforlife/user/models/classroom.py +++ /dev/null @@ -1,100 +0,0 @@ -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" diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py new file mode 100644 index 00000000..fa7f12f7 --- /dev/null +++ b/codeforlife/user/models/klass.py @@ -0,0 +1,102 @@ +# 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 diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index 5e418451..969c9411 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -1,48 +1,50 @@ -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 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 diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index acead871..066ae75f 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -1,71 +1,73 @@ -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 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 diff --git a/codeforlife/user/models/teacher.py b/codeforlife/user/models/teacher.py index 1d239813..d53ab4f1 100644 --- a/codeforlife/user/models/teacher.py +++ b/codeforlife/user/models/teacher.py @@ -1,66 +1,68 @@ -from django.db import models +# from django.db import models -from .user import User -from .school import School +# 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, - ) +# 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) +# 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) +# # 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, - ) +# 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() +# 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 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_school(self): +# return self.school is not (None or "") - def has_class(self): - return self.class_teacher.exists() +# def has_class(self): +# return self.class_teacher.exists() - def __str__(self): - return f"{self.user.first_name} {self.user.last_name}" +# def __str__(self): +# return f"{self.user.first_name} {self.user.last_name}" + +from common.models import Teacher diff --git a/codeforlife/user/models/user.py b/codeforlife/user/models/user.py index 881fd94e..553012ea 100644 --- a/codeforlife/user/models/user.py +++ b/codeforlife/user/models/user.py @@ -37,15 +37,20 @@ # def joined_recently(self): # return timezone.now() - timedelta(days=7) <= self.date_joined +import typing as t + 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 _ from . import auth_factor, otp_bypass_token, session +from .student import Student +from .teacher import Teacher class User(_User): + id: int auth_factors: QuerySet["auth_factor.AuthFactor"] otp_bypass_tokens: QuerySet["otp_bypass_token.OtpBypassToken"] session: "session.Session" @@ -61,6 +66,44 @@ def is_authenticated(self): """ try: - return not self.session.session_auth_factors + return not self.session.session_auth_factors.exists() except session.Session.DoesNotExist: return False + + @property + def student(self) -> t.Optional[Student]: + try: + return self.new_student + except Student.DoesNotExist: + return None + + @property + def teacher(self) -> t.Optional[Teacher]: + try: + return self.new_teacher + except Teacher.DoesNotExist: + return None + + @property + def is_student(self): + return self.student is not None + + @property + def is_teacher(self): + return self.teacher is not None + + @property + def otp_secret(self): + return self.userprofile.otp_secret + + @property + def last_otp_for_time(self): + return self.userprofile.last_otp_for_time + + @property + def is_verified(self): + return self.userprofile.is_verified + + @property + def aimmo_badges(self): + return self.userprofile.aimmo_badges diff --git a/codeforlife/user/permissions/__init__.py b/codeforlife/user/permissions/__init__.py new file mode 100644 index 00000000..cb7689b5 --- /dev/null +++ b/codeforlife/user/permissions/__init__.py @@ -0,0 +1,2 @@ +from .is_school_member import IsSchoolMember +from .is_school_teacher import IsSchoolTeacher diff --git a/codeforlife/user/permissions/is_school_member.py b/codeforlife/user/permissions/is_school_member.py new file mode 100644 index 00000000..43894cdf --- /dev/null +++ b/codeforlife/user/permissions/is_school_member.py @@ -0,0 +1,18 @@ +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.views import View + +from ..models import User + + +class IsSchoolMember(BasePermission): + def has_permission(self, request: Request, view: View): + user = request.user + return isinstance(user, User) and ( + (user.is_teacher and user.teacher.school is not None) + or ( + user.student is not None + # TODO: should be user.student.school is not None + and user.student.class_field is not None + ) + ) diff --git a/codeforlife/user/permissions/is_school_teacher.py b/codeforlife/user/permissions/is_school_teacher.py new file mode 100644 index 00000000..ece94675 --- /dev/null +++ b/codeforlife/user/permissions/is_school_teacher.py @@ -0,0 +1,15 @@ +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.views import View + +from ..models import User + + +class IsSchoolTeacher(BasePermission): + def has_permission(self, request: Request, view: View): + user = request.user + return ( + isinstance(user, User) + and user.is_teacher + and user.teacher.school is not None + ) diff --git a/codeforlife/user/serializers/__init__.py b/codeforlife/user/serializers/__init__.py new file mode 100644 index 00000000..0b580d16 --- /dev/null +++ b/codeforlife/user/serializers/__init__.py @@ -0,0 +1,5 @@ +from .klass import ClassSerializer +from .school import SchoolSerializer +from .student import StudentSerializer +from .teacher import TeacherSerializer +from .user import UserSerializer diff --git a/codeforlife/user/serializers/klass.py b/codeforlife/user/serializers/klass.py new file mode 100644 index 00000000..9b9a81cd --- /dev/null +++ b/codeforlife/user/serializers/klass.py @@ -0,0 +1,15 @@ +from rest_framework import serializers + +from ..models import Class + + +class ClassSerializer(serializers.ModelSerializer): + class Meta: + model = Class + fields = "__all__" + extra_kwargs = { + "id": {"read_only": True}, + "access_code": {"read_only": True}, + "creation_time": {"read_only": True}, + "created_by": {"read_only": True}, + } diff --git a/codeforlife/user/serializers/school.py b/codeforlife/user/serializers/school.py new file mode 100644 index 00000000..e61b3be3 --- /dev/null +++ b/codeforlife/user/serializers/school.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from ..models import School + + +class SchoolSerializer(serializers.ModelSerializer): + class Meta: + model = School + fields = "__all__" + extra_kwargs = { + "id": {"read_only": True}, + "creation_time": {"read_only": True}, + } diff --git a/codeforlife/user/serializers/student.py b/codeforlife/user/serializers/student.py new file mode 100644 index 00000000..660e03f7 --- /dev/null +++ b/codeforlife/user/serializers/student.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + +from ..models import Student + + +class StudentSerializer(serializers.ModelSerializer): + class Meta: + model = Student + fields = "__all__" + extra_kwargs = { + "id": {"read_only": True}, + } diff --git a/codeforlife/user/serializers/teacher.py b/codeforlife/user/serializers/teacher.py new file mode 100644 index 00000000..160c63a7 --- /dev/null +++ b/codeforlife/user/serializers/teacher.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + +from ..models import Teacher + + +class TeacherSerializer(serializers.ModelSerializer): + class Meta: + model = Teacher + fields = "__all__" + extra_kwargs = { + "id": {"read_only": True}, + } diff --git a/codeforlife/user/serializers/user.py b/codeforlife/user/serializers/user.py new file mode 100644 index 00000000..5b3e9934 --- /dev/null +++ b/codeforlife/user/serializers/user.py @@ -0,0 +1,18 @@ +from rest_framework import serializers + +from ..models import User +from .student import StudentSerializer +from .teacher import TeacherSerializer + + +class UserSerializer(serializers.ModelSerializer): + student = StudentSerializer(source="new_student") + teacher = TeacherSerializer(source="new_teacher") + + class Meta: + model = User + fields = "__all__" + extra_kwargs = { + "id": {"read_only": True}, + "password": {"write_only": True}, + } diff --git a/codeforlife/user/tests/test_user.py b/codeforlife/user/tests/test_user.py deleted file mode 100644 index 5936d1d1..00000000 --- a/codeforlife/user/tests/test_user.py +++ /dev/null @@ -1,25 +0,0 @@ -# import django -# import pytest - -# django.setup() - -# from ..models import User - - -# @pytest.mark.django_db -# def test_user_import(): -# """Basic test to ensure importing the package does not raise an error.""" - -# user_kwargs = { -# "username": "test teacher", -# "first_name": "Albert", -# "last_name": "Einstein", -# "email": "alberteinstein@codeforlife.com", -# "password": "Password1", -# } - -# user = User.objects.create_user(**user_kwargs) -# assert user.username == user_kwargs["username"] -# assert user.first_name == user_kwargs["first_name"] -# assert user.last_name == user_kwargs["last_name"] -# assert user.email == user_kwargs["email"] diff --git a/codeforlife/user/tests/views/__init__.py b/codeforlife/user/tests/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/codeforlife/user/tests/views/test_klass.py b/codeforlife/user/tests/views/test_klass.py new file mode 100644 index 00000000..87ba9d6b --- /dev/null +++ b/codeforlife/user/tests/views/test_klass.py @@ -0,0 +1,65 @@ +from ....tests import APIClient, APITestCase +from ...models import Class +from ...serializers import ClassSerializer +from ...views import ClassViewSet + + +class TestClassViewSet(APITestCase): + """ + Base naming convention: + test_{action} + + action: The view set action. + https://www.django-rest-framework.org/api-guide/viewsets/#viewset-actions + """ + + def _login_student(self): + return self.client.login_student( + email="leonardodavinci@codeforlife.com", + password="Password1", + ) + + """ + Retrieve naming convention: + test_retrieve__{user_type}__{same_school}__{in_class} + + user_type: The type of user that is making the request. Options: + - teacher: A non-admin teacher. + - admin_teacher: An admin teacher. + - student: A school student. + - indy_student: A non-school student. + + same_school: A flag for if the class is in the same school that the user is + in. Options: + - same_school: The class is in the same school as the user. + - not_same_school: The class is not in the same school as the user. + + in_class: A flag for if the user is in the class. Options: + - in_class: The user is in the class. + - not_in_class: The user is not in the class. + """ + + def _retrieve_class( + self, + klass: Class, + status_code_assertion: APIClient.StatusCodeAssertion = None, + ): + return self.client.retrieve( + "class", + klass, + ClassSerializer, + status_code_assertion, + ClassViewSet, + ) + + def test_retrieve__student__same_school__in_class(self): + """ + Student can retrieve a class from the same school and a class they are + in. + """ + + user = self._login_student() + + self._retrieve_class(user.student.class_field) + + # TODO: other retrieve and list tests diff --git a/codeforlife/user/tests/views/test_school.py b/codeforlife/user/tests/views/test_school.py new file mode 100644 index 00000000..50d332ee --- /dev/null +++ b/codeforlife/user/tests/views/test_school.py @@ -0,0 +1,245 @@ +import typing as t + +from rest_framework import status +from rest_framework.permissions import IsAuthenticated + +from ....tests import APIClient, APITestCase +from ...models import Class, School, Student, Teacher, User, UserProfile +from ...serializers import SchoolSerializer +from ...views import SchoolViewSet + + +class TestSchoolViewSet(APITestCase): + """ + Base naming convention: + test_{action} + + action: The view set action. + https://www.django-rest-framework.org/api-guide/viewsets/#viewset-actions + """ + + # TODO: replace this setup with data fixtures. + def setUp(self): + school = School.objects.create( + name="ExampleSchool", + country="UK", + ) + + user = User.objects.create( + first_name="Example", + last_name="Teacher", + email="example.teacher@codeforlife.com", + username="example.teacher@codeforlife.com", + ) + + user_profile = UserProfile.objects.create(user=user) + + teacher = Teacher.objects.create( + user=user_profile, + new_user=user, + school=school, + ) + + klass = Class.objects.create( + name="ExampleClass", + teacher=teacher, + created_by=teacher, + ) + + user = User.objects.create( + first_name="Example", + last_name="Student", + email="example.student@codeforlife.com", + username="example.student@codeforlife.com", + ) + + user_profile = UserProfile.objects.create(user=user) + + Student.objects.create( + class_field=klass, + user=user_profile, + new_user=user, + ) + + def _login_teacher(self): + return self.client.login_teacher( + email="alberteinstein@codeforlife.com", + password="Password1", + is_admin=True, + ) + + def _login_student(self): + return self.client.login_student( + email="leonardodavinci@codeforlife.com", + password="Password1", + ) + + def _login_indy_student(self): + return self.client.login_indy_student( + email="indianajones@codeforlife.com", + password="Password1", + ) + + """ + Retrieve naming convention: + test_retrieve__{user_type}__{same_school} + + user_type: The type of user that is making the request. Options: + - teacher: A teacher. + - student: A school student. + - indy_student: A non-school student. + + same_school: A flag for if the school is the same school that the user + is in. Options: + - same_school: The other user is from the same school. + - not_same_school: The other user is not from the same school. + """ + + def _retrieve_school( + self, + school: School, + status_code_assertion: APIClient.StatusCodeAssertion = None, + ): + return self.client.retrieve( + "school", + school, + SchoolSerializer, + status_code_assertion, + ) + + def test_retrieve__indy_student(self): + """ + Independent student cannot retrieve any school. + """ + + self._login_indy_student() + + school = School.objects.first() + assert school + + self._retrieve_school( + school, + status_code_assertion=status.HTTP_403_FORBIDDEN, + ) + + def test_retrieve__teacher__same_school(self): + """ + Teacher can retrieve the same school they are in. + """ + + user = self._login_teacher() + + self._retrieve_school(user.teacher.school) + + def test_retrieve__student__same_school(self): + """ + Student can retrieve the same school they are in. + """ + + user = self._login_student() + + self._retrieve_school(user.student.class_field.teacher.school) + + def test_retrieve__teacher__not_same_school(self): + """ + Teacher cannot retrieve a school they are not in. + """ + + user = self._login_teacher() + + school = School.objects.exclude(id=user.teacher.school.id).first() + assert school + + self._retrieve_school( + school, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + def test_retrieve__student__not_same_school(self): + """ + Student cannot retrieve a school they are not in. + """ + + user = self._login_student() + + school = School.objects.exclude( + id=user.student.class_field.teacher.school.id + ).first() + assert school + + self._retrieve_school( + school, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + """ + List naming convention: + test_list__{user_type} + + user_type: The type of user that is making the request. Options: + - teacher: A teacher. + - student: A school student. + - indy_student: A non-school student. + """ + + def _list_schools( + self, + schools: t.Iterable[School], + status_code_assertion: APIClient.StatusCodeAssertion = None, + ): + return self.client.list( + "school", + schools, + SchoolSerializer, + status_code_assertion, + ) + + def test_list__indy_student(self): + """ + Independent student cannot list any schools. + """ + + self._login_indy_student() + + self._list_schools( + [], + status_code_assertion=status.HTTP_403_FORBIDDEN, + ) + + def test_list__teacher(self): + """ + Teacher can list only the school they are in. + """ + + user = self._login_teacher() + + self._list_schools([user.teacher.school]) + + def test_list__student(self): + """ + Student can list only the school they are in. + """ + + user = self._login_student() + + self._list_schools([user.student.class_field.teacher.school]) + + """ + General tests that apply to all actions. + """ + + def test_all__requires_authentication(self): + """ + User must be authenticated to call any endpoint. + """ + + assert IsAuthenticated in SchoolViewSet.permission_classes + + def test_all__only_http_get(self): + """ + These model are read-only. + """ + + assert [name.lower() for name in SchoolViewSet.http_method_names] == [ + "get" + ] diff --git a/codeforlife/user/tests/views/test_user.py b/codeforlife/user/tests/views/test_user.py new file mode 100644 index 00000000..4cb9a1c9 --- /dev/null +++ b/codeforlife/user/tests/views/test_user.py @@ -0,0 +1,561 @@ +import typing as t + +from rest_framework import status +from rest_framework.permissions import IsAuthenticated + +from ....tests import APIClient, APITestCase +from ...models import Class, School, Student, Teacher, User, UserProfile +from ...serializers import UserSerializer +from ...views import UserViewSet + + +class TestUserViewSet(APITestCase): + """ + Base naming convention: + test_{action} + + action: The view set action. + https://www.django-rest-framework.org/api-guide/viewsets/#viewset-actions + """ + + # TODO: replace this setup with data fixtures. + def setUp(self): + school = School.objects.create( + name="ExampleSchool", + country="UK", + ) + + user = User.objects.create( + first_name="Example", + last_name="Teacher", + email="example.teacher@codeforlife.com", + username="example.teacher@codeforlife.com", + ) + + user_profile = UserProfile.objects.create(user=user) + + teacher = Teacher.objects.create( + user=user_profile, + new_user=user, + school=school, + ) + + klass = Class.objects.create( + name="ExampleClass", + teacher=teacher, + created_by=teacher, + ) + + user = User.objects.create( + first_name="Example", + last_name="Student", + email="example.student@codeforlife.com", + username="example.student@codeforlife.com", + ) + + user_profile = UserProfile.objects.create(user=user) + + Student.objects.create( + class_field=klass, + user=user_profile, + new_user=user, + ) + + def _login_teacher(self): + return self.client.login_teacher( + email="maxplanck@codeforlife.com", + password="Password1", + is_admin=False, + ) + + def _login_admin_teacher(self): + return self.client.login_teacher( + email="alberteinstein@codeforlife.com", + password="Password1", + is_admin=True, + ) + + def _login_student(self): + return self.client.login_student( + email="leonardodavinci@codeforlife.com", + password="Password1", + ) + + def _login_indy_student(self): + return self.client.login_indy_student( + email="indianajones@codeforlife.com", + password="Password1", + ) + + """ + Retrieve naming convention: + test_retrieve__{user_type}__{other_user_type}__{same_school}__{same_class} + + user_type: The type of user that is making the request. Options: + - teacher: A non-admin teacher. + - admin_teacher: An admin teacher. + - student: A school student. + - indy_student: A non-school student. + + other_user_type: The type of user whose data is being requested. Options: + - self: User's own data. + - teacher: Another teacher's data. + - student: Another student's data. + + same_school: A flag for if the other user is from the same school. Options: + - same_school: The other user is from the same school. + - not_same_school: The other user is not from the same school. + + same_class: A flag for if the other user is from the same class. Options: + - same_class: The other user is from the same class. + - not_same_class: The other user is not from the same class. + """ + + def _retrieve_user( + self, + user: User, + status_code_assertion: APIClient.StatusCodeAssertion = None, + ): + return self.client.retrieve( + "user", + user, + UserSerializer, + status_code_assertion, + ) + + def test_retrieve__teacher__self(self): + """ + Teacher can retrieve their own user data. + """ + + user = self._login_teacher() + + self._retrieve_user(user) + + def test_retrieve__student__self(self): + """ + Student can retrieve their own user data. + """ + + user = self._login_student() + + self._retrieve_user(user) + + def test_retrieve__indy_student__self(self): + """ + Independent student can retrieve their own user data. + """ + + user = self._login_indy_student() + + self._retrieve_user(user) + + def test_retrieve__teacher__teacher__same_school(self): + """ + Teacher can retrieve another teacher from the same school. + """ + + user = self._login_teacher() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.exclude(id=user.id).filter( + new_teacher__school=user.teacher.school + ), + is_teacher=True, + same_school=True, + ) + + self._retrieve_user(other_user) + + def test_retrieve__teacher__student__same_school__same_class(self): + """ + Teacher can retrieve a student from the same school and class. + """ + + user = self._login_teacher() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.filter( + new_student__class_field__teacher__school=user.teacher.school, + new_student__class_field__teacher=user.teacher, + ), + is_teacher=False, + same_school=True, + same_class=True, + ) + + self._retrieve_user(other_user) + + def test_retrieve__teacher__student__same_school__not_same_class(self): + """ + Teacher cannot retrieve a student from the same school and a different + class. + """ + + user = self._login_teacher() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.filter( + new_student__class_field__teacher__school=user.teacher.school + ).exclude(new_student__class_field__teacher=user.teacher), + is_teacher=False, + same_school=True, + same_class=False, + ) + + self._retrieve_user( + other_user, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + def test_retrieve__admin_teacher__student__same_school__same_class(self): + """ + Admin teacher can retrieve a student from the same school and class. + """ + + user = self._login_admin_teacher() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.filter( + new_student__class_field__teacher__school=user.teacher.school, + new_student__class_field__teacher=user.teacher, + ), + is_teacher=False, + same_school=True, + same_class=True, + ) + + self._retrieve_user(other_user) + + def test_retrieve__admin_teacher__student__same_school__not_same_class( + self, + ): + """ + Admin teacher can retrieve a student from the same school and a + different class. + """ + + user = self._login_admin_teacher() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.filter( + new_student__class_field__teacher__school=user.teacher.school + ).exclude(new_student__class_field__teacher=user.teacher), + is_teacher=False, + same_school=True, + same_class=False, + ) + + self._retrieve_user(other_user) + + def test_retrieve__student__teacher__same_school__same_class(self): + """ + Student cannot retrieve a teacher from the same school and class. + """ + + user = self._login_student() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.filter( + new_teacher__school=user.student.class_field.teacher.school, + new_teacher__class_teacher=user.student.class_field, + ), + is_teacher=True, + same_school=True, + same_class=True, + ) + + self._retrieve_user( + other_user, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + def test_retrieve__student__teacher__same_school__not_same_class(self): + """ + Student cannot retrieve a teacher from the same school and a different + class. + """ + + user = self._login_student() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.filter( + new_teacher__school=user.student.class_field.teacher.school + ).exclude(new_teacher__class_teacher=user.student.class_field), + is_teacher=True, + same_school=True, + same_class=False, + ) + + self._retrieve_user( + other_user, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + def test_retrieve__student__student__same_school__same_class(self): + """ + Student can retrieve another student from the same school and class. + """ + + user = self._login_student() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.exclude(id=user.id).filter( + new_student__class_field__teacher__school=user.student.class_field.teacher.school, + new_student__class_field=user.student.class_field, + ), + is_teacher=False, + same_school=True, + same_class=True, + ) + + self._retrieve_user(other_user) + + def test_retrieve__student__student__same_school__not_same_class(self): + """ + Student cannot retrieve another student from the same school and a + different class. + """ + + user = self._login_student() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.exclude(id=user.id) + .filter( + new_student__class_field__teacher__school=user.student.class_field.teacher.school, + ) + .exclude(new_student__class_field=user.student.class_field), + is_teacher=False, + same_school=True, + same_class=False, + ) + + self._retrieve_user( + other_user, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + def test_retrieve__teacher__teacher__not_same_school(self): + """ + Teacher cannot retrieve another teacher from another school. + """ + + user = self._login_teacher() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.exclude( + new_teacher__school=user.teacher.school + ).filter(new_teacher__school__isnull=False), + is_teacher=True, + same_school=False, + ) + + self._retrieve_user( + other_user, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + def test_retrieve__teacher__student__not_same_school(self): + """ + Teacher cannot retrieve a student from another school. + """ + + user = self._login_teacher() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.exclude( + new_student__class_field__teacher__school=user.teacher.school + ).filter(new_student__class_field__teacher__school__isnull=False), + is_teacher=False, + same_school=False, + ) + + self._retrieve_user( + other_user, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + def test_retrieve__student__teacher__not_same_school(self): + """ + Student cannot retrieve a teacher from another school. + """ + + user = self._login_student() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.exclude( + new_teacher__school=user.student.class_field.teacher.school + ).filter(new_teacher__school__isnull=False), + is_teacher=True, + same_school=False, + ) + + self._retrieve_user( + other_user, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + def test_retrieve__student__student__not_same_school(self): + """ + Student cannot retrieve another student from another school. + """ + + user = self._login_student() + + other_user = self.get_another_school_user( + user, + other_users=User.objects.exclude( + new_student__class_field__teacher__school=user.student.class_field.teacher.school + ).filter(new_student__class_field__teacher__school__isnull=False), + is_teacher=False, + same_school=False, + ) + + self._retrieve_user( + other_user, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + def test_retrieve__indy_student__teacher(self): + """ + Independent student cannot retrieve a teacher. + """ + + user = self._login_indy_student() + + other_user = self.get_other_school_user( + user, + other_users=User.objects.filter(new_teacher__school__isnull=False), + is_teacher=True, + ) + + self._retrieve_user( + other_user, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + def test_retrieve__indy_student__student(self): + """ + Independent student cannot retrieve a student. + """ + + user = self._login_indy_student() + + other_user = self.get_other_school_user( + user, + other_users=User.objects.filter( + new_student__class_field__teacher__school__isnull=False + ), + is_teacher=False, + ) + + self._retrieve_user( + other_user, + status_code_assertion=status.HTTP_404_NOT_FOUND, + ) + + """ + List naming convention: + test_list__{user_type}__{filters} + + user_type: The type of user that is making the request. Options: + - teacher: A teacher. + - student: A school student. + - indy_student: A non-school student. + + filters: Any search params used to dynamically filter the list. + """ + + def _list_users( + self, + users: t.Iterable[User], + status_code_assertion: APIClient.StatusCodeAssertion = None, + filters: APIClient.ListFilters = None, + ): + return self.client.list( + "user", + users, + UserSerializer, + status_code_assertion, + filters, + ) + + def test_list__teacher(self): + """ + Teacher can list all the users in the same school. + """ + + user = self._login_teacher() + + self._list_users( + User.objects.filter(new_teacher__school=user.teacher.school) + | User.objects.filter( + new_student__class_field__teacher__school=user.teacher.school, + new_student__class_field__teacher=user.teacher, + ) + ) + + def test_list__teacher__students_in_class(self): + """ + Teacher can list all the users in a class they own. + """ + + user = self._login_teacher() + + klass = user.teacher.class_teacher.first() + assert klass + + self._list_users( + User.objects.filter(new_student__class_field=klass), + filters={"students_in_class": klass.id}, + ) + + def test_list__student(self): + """ + Student can list only themself. + """ + + user = self._login_student() + + self._list_users([user]) + + def test_list__indy_student(self): + """ + Independent student can list only themself. + """ + + user = self._login_indy_student() + + self._list_users([user]) + + """ + General tests that apply to all actions. + """ + + def test_all__requires_authentication(self): + """ + User must be authenticated to call any endpoint. + """ + + assert IsAuthenticated in UserViewSet.permission_classes + + def test_all__only_http_get(self): + """ + These model are read-only. + """ + + assert [name.lower() for name in UserViewSet.http_method_names] == [ + "get" + ] diff --git a/codeforlife/user/urls.py b/codeforlife/user/urls.py index 083932c6..e797e5a4 100644 --- a/codeforlife/user/urls.py +++ b/codeforlife/user/urls.py @@ -1,6 +1,13 @@ -from django.contrib import admin -from django.urls import path +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ClassViewSet, UserViewSet, SchoolViewSet + +router = DefaultRouter() +router.register("classes", ClassViewSet, basename="class") +router.register("users", UserViewSet, basename="user") +router.register("schools", SchoolViewSet, basename="school") urlpatterns = [ - path("admin/", admin.site.urls), + path("", include(router.urls)), ] diff --git a/codeforlife/user/views/__init__.py b/codeforlife/user/views/__init__.py new file mode 100644 index 00000000..ffcd7b4e --- /dev/null +++ b/codeforlife/user/views/__init__.py @@ -0,0 +1,3 @@ +from .klass import ClassViewSet +from .school import SchoolViewSet +from .user import UserViewSet diff --git a/codeforlife/user/views/klass.py b/codeforlife/user/views/klass.py new file mode 100644 index 00000000..bc863e88 --- /dev/null +++ b/codeforlife/user/views/klass.py @@ -0,0 +1,23 @@ +from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import ModelViewSet + +from ..models import Class, User +from ..permissions import IsSchoolMember +from ..serializers import ClassSerializer + + +class ClassViewSet(ModelViewSet): + http_method_names = ["get"] + lookup_field = "access_code" + serializer_class = ClassSerializer + permission_classes = [IsAuthenticated, IsSchoolMember] + + def get_queryset(self): + user: User = self.request.user + if user.is_student: + return Class.objects.filter(students=user.student) + elif user.teacher.is_admin: + # TODO: add school field to class object + return Class.objects.filter(teacher__school=user.teacher.school) + else: + return Class.objects.filter(teacher=user.teacher) diff --git a/codeforlife/user/views/school.py b/codeforlife/user/views/school.py new file mode 100644 index 00000000..0c12d9cd --- /dev/null +++ b/codeforlife/user/views/school.py @@ -0,0 +1,22 @@ +from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import ModelViewSet + +from ..models import School, User +from ..permissions import IsSchoolMember +from ..serializers import SchoolSerializer + + +class SchoolViewSet(ModelViewSet): + http_method_names = ["get"] + serializer_class = SchoolSerializer + permission_classes = [IsAuthenticated, IsSchoolMember] + + def get_queryset(self): + user: User = self.request.user + if user.is_student: + return School.objects.filter( + # TODO: should be user.student.school_id + id=user.student.class_field.teacher.school_id + ) + else: + return School.objects.filter(id=user.teacher.school_id) diff --git a/codeforlife/user/views/user.py b/codeforlife/user/views/user.py new file mode 100644 index 00000000..25b0ba37 --- /dev/null +++ b/codeforlife/user/views/user.py @@ -0,0 +1,37 @@ +from rest_framework.viewsets import ModelViewSet + +from ..filters import UserFilterSet +from ..models import User +from ..serializers import UserSerializer + + +class UserViewSet(ModelViewSet): + http_method_names = ["get"] + serializer_class = UserSerializer + filterset_class = UserFilterSet + + def get_queryset(self): + user: User = self.request.user + if user.is_student: + if user.student.class_field is None: + return User.objects.filter(id=user.id) + + return User.objects.filter( + new_student__class_field=user.student.class_field + ) + + teachers = User.objects.filter( + new_teacher__school=user.teacher.school_id + ) + students = ( + User.objects.filter( + # TODO: add school foreign key to student model. + new_student__class_field__teacher__school=user.teacher.school_id, + ) + if user.teacher.is_admin + else User.objects.filter( + new_student__class_field__teacher=user.teacher + ) + ) + + return teachers | students diff --git a/setup.py b/setup.py index fbbc09e0..abe572fc 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ "cachetools==5.3.1; python_version >= '3.7'", "certifi==2023.7.22; python_version >= '3.6'", "cfl-common==6.37.1", - "charset-normalizer==3.2.0; python_full_version >= '3.7.0'", + "charset-normalizer==3.3.0; python_full_version >= '3.7.0'", "click==8.1.7; python_version >= '3.7'", "codeforlife-portal==6.37.1", "defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", @@ -45,6 +45,7 @@ "django-cors-headers==4.1.0", "django-countries==7.3.1", "django-csp==3.7", + "django-filter==23.2", "django-formtools==2.2", "django-import-export==3.3.1; python_version >= '3.8'", "django-js-reverse==0.9.1", @@ -62,8 +63,8 @@ "et-xmlfile==1.1.0; python_version >= '3.6'", "eventlet==0.31.0", "flask==2.2.3", - "google-auth==2.23.1; python_version >= '3.7'", - "greenlet==2.0.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "google-auth==2.23.3; python_version >= '3.7'", + "greenlet==3.0.0; python_version >= '3.7'", "hypothesis==5.41.3; python_version >= '3.6'", "idna==3.4; python_version >= '3.5'", "importlib-metadata==4.13.0", @@ -105,9 +106,9 @@ "tablib[html,ods,xls,xlsx,yaml]==3.5.0; python_version >= '3.8'", "typing-extensions==4.8.0; python_version >= '3.8'", "tzdata==2023.3; python_version >= '2'", - "urllib3==2.0.5; python_version >= '3.7'", - "websocket-client==1.6.3; python_version >= '3.8'", - "werkzeug==2.3.7; python_version >= '3.8'", + "urllib3==2.0.6; python_version >= '3.7'", + "websocket-client==1.6.4; python_version >= '3.8'", + "werkzeug==3.0.0; python_version >= '3.8'", "xlrd==2.0.1", "xlwt==1.3.0", "zipp==3.17.0; python_version >= '3.8'",