From 8473d037146f5441d7fceeee56d6a6520416aaa5 Mon Sep 17 00:00:00 2001 From: Stefan Kairinos Date: Thu, 1 Feb 2024 10:07:03 +0000 Subject: [PATCH] Bulk create students (#259) * rename * feat: bulk create students * fix test * feedback * use new py package * feedback * feedback * feedback: added unit tests * feedback --- backend/Pipfile | 2 +- backend/Pipfile.lock | 133 +++++++++--------- backend/api/fixtures/non_school_teacher.json | 29 ++++ backend/api/fixtures/school_1.json | 112 +++++++++++++++ backend/api/fixtures/school_2.json | 86 +++++++++++ backend/api/serializers/__init__.py | 2 + backend/api/serializers/student.py | 50 +++++++ backend/api/serializers/teacher.py | 12 ++ backend/api/serializers/user.py | 131 ++++++++++++++++- backend/api/signals/user.py | 4 +- backend/api/tests/serializers/__init__.py | 4 + backend/api/tests/serializers/test_student.py | 97 +++++++++++++ backend/api/tests/serializers/test_user.py | 69 +++++++++ backend/api/tests/signals/test_user.py | 2 +- backend/api/tests/views/test_user.py | 35 ++++- backend/api/views/user.py | 7 + run | 2 +- 17 files changed, 703 insertions(+), 74 deletions(-) create mode 100644 backend/api/fixtures/non_school_teacher.json create mode 100644 backend/api/fixtures/school_1.json create mode 100644 backend/api/fixtures/school_2.json create mode 100644 backend/api/serializers/student.py create mode 100644 backend/api/serializers/teacher.py create mode 100644 backend/api/tests/serializers/__init__.py create mode 100644 backend/api/tests/serializers/test_student.py create mode 100644 backend/api/tests/serializers/test_user.py diff --git a/backend/Pipfile b/backend/Pipfile index dbec7652..1cef8113 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -28,7 +28,7 @@ google-cloud-logging = "==1.*" google-auth = "==2.*" google-cloud-container = "==2.3.0" # "django-anymail[amazon_ses]" = "==7.0.*" -codeforlife = {ref = "v0.9.6", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"} +codeforlife = {ref = "v0.11.2", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"} django = "==3.2.20" djangorestframework = "==3.13.1" django-cors-headers = "==4.1.0" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index f1995e4d..5a78e12d 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b5d163672ae6fa1c185023cee8a31e22991c62a2b782442b60f4cc3036cc93e6" + "sha256": "d31045f406dc9ec7fe3710a0225aba8dd242965211101e99514f68dd1a2c1076" }, "pipfile-spec": 6, "requires": { @@ -170,7 +170,7 @@ }, "codeforlife": { "git": "https://github.com/ocadotechnology/codeforlife-package-python.git", - "ref": "db3cfe8fd8d889a705d3c66a4183745389fca7a3" + "ref": "279ff34c1f467d729d014c8b1b49dab30372674c" }, "codeforlife-portal": { "hashes": [ @@ -816,7 +816,6 @@ "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184", "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5" ], - "markers": "python_version >= '3.6'", "version": "==3.1.2" }, "pandas": { @@ -1317,7 +1316,6 @@ "sha256:6a33ee89877bd9abc1158129f6e94be74e2679636b8a205b43b85206c3f0bbdd", "sha256:f72f148f54442c6b056bf931dbc34f986fd0c3b0b6b5a58d013c9aef274d0c88" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==2.0.1" }, "xlwt": { @@ -1499,61 +1497,61 @@ "toml" ], "hashes": [ - "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca", - "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471", - "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a", - "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058", - "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85", - "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143", - "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446", - "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590", - "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a", - "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105", - "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9", - "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a", - "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac", - "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25", - "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2", - "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450", - "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932", - "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba", - "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137", - "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae", - "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614", - "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70", - "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e", - "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505", - "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870", - "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc", - "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451", - "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7", - "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e", - "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566", - "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5", - "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26", - "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2", - "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42", - "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555", - "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43", - "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed", - "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa", - "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516", - "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952", - "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd", - "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09", - "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c", - "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f", - "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6", - "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1", - "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0", - "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e", - "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9", - "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9", - "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e", - "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06" + "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61", + "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1", + "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7", + "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7", + "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75", + "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd", + "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35", + "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04", + "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6", + "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042", + "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166", + "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1", + "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d", + "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c", + "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66", + "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70", + "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1", + "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676", + "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630", + "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a", + "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74", + "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad", + "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19", + "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6", + "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448", + "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018", + "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218", + "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756", + "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54", + "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45", + "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628", + "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968", + "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d", + "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25", + "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60", + "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950", + "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06", + "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295", + "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b", + "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c", + "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc", + "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74", + "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1", + "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee", + "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011", + "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156", + "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766", + "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5", + "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581", + "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016", + "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c", + "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3" ], "markers": "python_version >= '3.8'", - "version": "==7.4.0" + "version": "==7.4.1" }, "defusedxml": { "hashes": [ @@ -1573,11 +1571,11 @@ }, "dill": { "hashes": [ - "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e", - "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03" + "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca", + "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7" ], "markers": "python_version < '3.11'", - "version": "==0.3.7" + "version": "==0.3.8" }, "django": { "hashes": [ @@ -1768,7 +1766,6 @@ "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184", "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5" ], - "markers": "python_version >= '3.6'", "version": "==3.1.2" }, "packaging": { @@ -1789,11 +1786,11 @@ }, "platformdirs": { "hashes": [ - "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380", - "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420" + "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", + "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768" ], "markers": "python_version >= '3.8'", - "version": "==4.1.0" + "version": "==4.2.0" }, "pluggy": { "hashes": [ @@ -2043,10 +2040,11 @@ }, "types-pytz": { "hashes": [ - "sha256:1999a123a3dc0e39a2ef6d19f3f8584211de9e6a77fe7a0259f04a524e90a5cf", - "sha256:cc23d0192cd49c8f6bba44ee0c81e4586a8f30204970fc0894d209a6b08dab9a" + "sha256:33676a90bf04b19f92c33eec8581136bea2f35ddd12759e579a624a006fd387a", + "sha256:6ce76a9f8fd22bd39b01a59c35bfa2db39b60d11a2f77145e97b730de7e64fe0" ], - "version": "==2023.3.1.1" + "markers": "python_version >= '3.8'", + "version": "==2023.4.0.20240130" }, "types-pyyaml": { "hashes": [ @@ -2122,7 +2120,6 @@ "sha256:6a33ee89877bd9abc1158129f6e94be74e2679636b8a205b43b85206c3f0bbdd", "sha256:f72f148f54442c6b056bf931dbc34f986fd0c3b0b6b5a58d013c9aef274d0c88" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==2.0.1" }, "xlwt": { diff --git a/backend/api/fixtures/non_school_teacher.json b/backend/api/fixtures/non_school_teacher.json new file mode 100644 index 00000000..bfaacaf5 --- /dev/null +++ b/backend/api/fixtures/non_school_teacher.json @@ -0,0 +1,29 @@ +[ + { + "model": "auth.user", + "pk": 22, + "fields": { + "first_name": "John", + "last_name": "Doe", + "username": "teacher@noschool.com", + "email": "teacher@noschool.com", + "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" + } + }, + { + "model": "common.userprofile", + "pk": 22, + "fields": { + "user": 22, + "is_verified": true + } + }, + { + "model": "common.teacher", + "pk": 5, + "fields": { + "user": 22, + "new_user": 22 + } + } +] \ No newline at end of file diff --git a/backend/api/fixtures/school_1.json b/backend/api/fixtures/school_1.json new file mode 100644 index 00000000..578938c8 --- /dev/null +++ b/backend/api/fixtures/school_1.json @@ -0,0 +1,112 @@ +[ + { + "model": "common.school", + "pk": 2, + "fields": { + "name": "School 1", + "country": "GB", + "county": "Hertfordshire" + } + }, + { + "model": "auth.user", + "pk": 23, + "fields": { + "first_name": "John", + "last_name": "Doe", + "username": "teacher@school1.com", + "email": "teacher@school1.com", + "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" + } + }, + { + "model": "common.userprofile", + "pk": 23, + "fields": { + "user": 23, + "is_verified": true + } + }, + { + "model": "common.teacher", + "pk": 6, + "fields": { + "user": 23, + "new_user": 23, + "school": 2 + } + }, + { + "model": "common.class", + "pk": 6, + "fields": { + "name": "Class 1 @ School 1", + "access_code": "ZZ111", + "teacher": 6 + } + }, + { + "model": "auth.user", + "pk": 27, + "fields": { + "first_name": "Student1", + "username": "111111111111111111111111111111", + "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" + } + }, + { + "model": "common.userprofile", + "pk": 27, + "fields": { + "user": 27, + "is_verified": true + } + }, + { + "model": "common.student", + "pk": 17, + "fields": { + "user": 27, + "new_user": 27, + "class_field": 6 + } + }, + { + "model": "auth.user", + "pk": 24, + "fields": { + "first_name": "Jane", + "last_name": "Doe", + "username": "admin.teacher@school1.com", + "email": "admin.teacher@school1.com", + "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" + } + }, + { + "model": "common.userprofile", + "pk": 24, + "fields": { + "user": 24, + "is_verified": true + } + }, + { + "model": "common.teacher", + "pk": 7, + "fields": { + "user": 24, + "new_user": 24, + "school": 2, + "is_admin": true + } + }, + { + "model": "common.class", + "pk": 7, + "fields": { + "name": "Class 2 @ School 1", + "access_code": "ZZ222", + "teacher": 7 + } + } +] \ No newline at end of file diff --git a/backend/api/fixtures/school_2.json b/backend/api/fixtures/school_2.json new file mode 100644 index 00000000..9c4074b8 --- /dev/null +++ b/backend/api/fixtures/school_2.json @@ -0,0 +1,86 @@ +[ + { + "model": "common.school", + "pk": 3, + "fields": { + "name": "School 2", + "country": "GB", + "county": "Hertfordshire" + } + }, + { + "model": "auth.user", + "pk": 25, + "fields": { + "first_name": "John", + "last_name": "Doe", + "username": "teacher@school2.com", + "email": "teacher@school2.com", + "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" + } + }, + { + "model": "common.userprofile", + "pk": 25, + "fields": { + "user": 25, + "is_verified": true + } + }, + { + "model": "common.teacher", + "pk": 8, + "fields": { + "user": 25, + "new_user": 25, + "school": 3 + } + }, + { + "model": "common.class", + "pk": 8, + "fields": { + "name": "Class 1 @ School 2", + "access_code": "XX111", + "teacher": 8 + } + }, + { + "model": "auth.user", + "pk": 26, + "fields": { + "first_name": "Jane", + "last_name": "Doe", + "username": "admin.teacher@school2.com", + "email": "admin.teacher@school2.com", + "password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0=" + } + }, + { + "model": "common.userprofile", + "pk": 26, + "fields": { + "user": 26, + "is_verified": true + } + }, + { + "model": "common.teacher", + "pk": 9, + "fields": { + "user": 26, + "new_user": 26, + "school": 3, + "is_admin": true + } + }, + { + "model": "common.class", + "pk": 9, + "fields": { + "name": "Class 2 @ School 2", + "access_code": "XX222", + "teacher": 9 + } + } +] \ No newline at end of file diff --git a/backend/api/serializers/__init__.py b/backend/api/serializers/__init__.py index 94753e37..2d829ea6 100644 --- a/backend/api/serializers/__init__.py +++ b/backend/api/serializers/__init__.py @@ -6,4 +6,6 @@ from .auth_factor import AuthFactorSerializer from .klass import ClassSerializer from .school import SchoolSerializer +from .student import StudentSerializer +from .teacher import TeacherSerializer from .user import UserSerializer diff --git a/backend/api/serializers/student.py b/backend/api/serializers/student.py new file mode 100644 index 00000000..8fd466ef --- /dev/null +++ b/backend/api/serializers/student.py @@ -0,0 +1,50 @@ +""" +© Ocado Group +Created on 29/01/2024 at 10:14:59(+00:00). +""" + +import typing as t + +from codeforlife.user.models import Class, Teacher +from codeforlife.user.serializers import StudentSerializer as _StudentSerializer +from rest_framework import serializers + + +# pylint: disable-next=missing-class-docstring,too-many-ancestors +class StudentSerializer(_StudentSerializer): + klass = serializers.CharField(source="class_field.access_code") + + class Meta(_StudentSerializer.Meta): + pass + + # pylint: disable-next=missing-function-docstring + def validate_klass(self, value: str): + # Only teachers can manage students. + teacher = t.cast(Teacher, self.request_user.teacher) + + if teacher.school is None: + raise serializers.ValidationError( + "The requesting teacher must be in a school.", + code="teacher_not_in_school", + ) + + try: + klass = Class.objects.get(access_code=value) + except Class.DoesNotExist as ex: + raise serializers.ValidationError( + "Class does not exist.", + code="does_not_exist", + ) from ex + + if klass.teacher.school_id != teacher.school_id: + raise serializers.ValidationError( + "Class must belong to the same school as requesting teacher.", + code="teacher_not_in_same_school", + ) + if not teacher.is_admin and klass.teacher != teacher: + raise serializers.ValidationError( + "The requesting teacher must be an admin or own the class.", + code="teacher_not_admin_or_class_owner", + ) + + return value diff --git a/backend/api/serializers/teacher.py b/backend/api/serializers/teacher.py new file mode 100644 index 00000000..3cf51d79 --- /dev/null +++ b/backend/api/serializers/teacher.py @@ -0,0 +1,12 @@ +""" +© Ocado Group +Created on 29/01/2024 at 10:13:58(+00:00). +""" + +from codeforlife.user.serializers import TeacherSerializer as _TeacherSerializer + + +# pylint: disable-next=missing-class-docstring +class TeacherSerializer(_TeacherSerializer): + class Meta(_TeacherSerializer.Meta): + pass diff --git a/backend/api/serializers/user.py b/backend/api/serializers/user.py index 5cde120c..677d2a5a 100644 --- a/backend/api/serializers/user.py +++ b/backend/api/serializers/user.py @@ -3,16 +3,145 @@ Created on 18/01/2024 at 15:14:32(+00:00). """ +import string +import typing as t +from itertools import groupby + +from codeforlife.serializers import ModelListSerializer +from codeforlife.user.models import Class, Student, User, UserProfile from codeforlife.user.serializers import UserSerializer as _UserSerializer +from django.contrib.auth.hashers import make_password +from django.utils.crypto import get_random_string from rest_framework import serializers +from .student import StudentSerializer +from .teacher import TeacherSerializer + # pylint: disable-next=missing-class-docstring +class UserListSerializer(ModelListSerializer[User]): + def create(self, validated_data): + classes = { + klass.access_code: klass + for klass in Class.objects.filter( + access_code__in={ + user_fields["new_student"]["class_field"]["access_code"] + for user_fields in validated_data + } + ) + } + + # TODO: replace this logic with bulk creates for each object when we + # switch to PostgreSQL. + users: t.List[User] = [] + for user_fields in validated_data: + password = get_random_string( + length=6, + allowed_chars=string.ascii_lowercase, + ) + + user = User.objects.create_user( + first_name=user_fields["first_name"], + username=get_random_string(length=30), + password=make_password(password), + ) + users.append(user) + + # pylint: disable-next=protected-access + user._password = password + + login_id = None + while ( + login_id is None + or Student.objects.filter(login_id=login_id).exists() + ): + login_id = get_random_string(length=64) + + Student.objects.create( + class_field=classes[ + user_fields["new_student"]["class_field"]["access_code"] + ], + user=UserProfile.objects.create(user=user), + new_user=user, + login_id=login_id, + ) + + return users + + def validate(self, attrs): + super().validate(attrs) + + def get_access_code(user_fields: t.Dict[str, t.Any]): + return user_fields["new_student"]["class_field"]["access_code"] + + def get_first_name(user_fields: t.Dict[str, t.Any]): + return user_fields["first_name"] + + attrs.sort(key=get_access_code) + for access_code, group in groupby(attrs, key=get_access_code): + # Validate first name is not specified more than once in data. + data = list(group) + data.sort(key=get_first_name) + for first_name, group in groupby(data, key=get_first_name): + if len(list(group)) > 1: + raise serializers.ValidationError( + f'First name "{first_name}" is specified more than once' + f" in data for class {access_code}.", + code="first_name_not_unique_per_class_in_data", + ) + + # Validate first names are not already taken in class. + if User.objects.filter( + first_name__in=list(map(get_first_name, data)), + new_student__class_field__access_code=access_code, + ).exists(): + raise serializers.ValidationError( + "One or more first names is already taken in class" + f" {access_code}.", + code="first_name_not_unique_per_class_in_db", + ) + + return attrs + + +# pylint: disable-next=missing-class-docstring,too-many-ancestors class UserSerializer(_UserSerializer): - current_password = serializers.CharField(write_only=True) + student = StudentSerializer(source="new_student", required=False) + teacher = TeacherSerializer(source="new_teacher", required=False) + current_password = serializers.CharField( + write_only=True, + required=False, + ) class Meta(_UserSerializer.Meta): fields = [ *_UserSerializer.Meta.fields, + "password", "current_password", ] + extra_kwargs = { + **_UserSerializer.Meta.extra_kwargs, + "first_name": {"read_only": False}, + "password": {"write_only": True, "required": False}, + } + list_serializer_class = UserListSerializer + + def validate(self, attrs): + # TODO: make current password required when changing self-profile. + + return attrs + + def to_representation(self, instance: User): + representation = super().to_representation(instance) + + # Return student's auto-generated password. + if ( + representation["student"] is not None + and self.request_user.teacher is not None + ): + # pylint: disable-next=protected-access + password = instance._password + if password is not None: + representation["password"] = password + + return representation diff --git a/backend/api/signals/user.py b/backend/api/signals/user.py index 26578dee..7d3ba107 100644 --- a/backend/api/signals/user.py +++ b/backend/api/signals/user.py @@ -30,7 +30,9 @@ def user__pre_save__otp_secret(sender, instance: UserProfile, *args, **kwargs): def user__pre_save__email(sender, instance: User, *args, **kwargs): """Before a user's email field is updated.""" - if previous_values_are_unequal(instance, {"email"}): + if instance.teacher is not None and previous_values_are_unequal( + instance, {"email"} + ): instance.username = instance.email diff --git a/backend/api/tests/serializers/__init__.py b/backend/api/tests/serializers/__init__.py new file mode 100644 index 00000000..723a80aa --- /dev/null +++ b/backend/api/tests/serializers/__init__.py @@ -0,0 +1,4 @@ +""" +© Ocado Group +Created on 30/01/2024 at 19:03:37(+00:00). +""" diff --git a/backend/api/tests/serializers/test_student.py b/backend/api/tests/serializers/test_student.py new file mode 100644 index 00000000..c6ba9053 --- /dev/null +++ b/backend/api/tests/serializers/test_student.py @@ -0,0 +1,97 @@ +""" +© Ocado Group +Created on 30/01/2024 at 19:03:45(+00:00). +""" + +from codeforlife.tests import ModelSerializerTestCase +from codeforlife.user.models import Class, Student, User + +from ...serializers import StudentSerializer + + +# pylint: disable-next=missing-class-docstring +class StudentSerializerTestCase(ModelSerializerTestCase[Student]): + model_serializer_class = StudentSerializer + fixtures = [ + "non_school_teacher", + "school_1", + "school_2", + ] + + def test_validate_klass__teacher_not_in_school(self): + """ + Requesting teacher cannot assign a student to a class if they're not in + a school. + """ + + user = User.objects.get(email="teacher@noschool.com") + assert user.teacher and not user.teacher.school + + self.assert_validate_field( + name="klass", + value="", + error_code="teacher_not_in_school", + user=user, + ) + + def test_validate_klass__does_not_exist(self): + """ + Requesting teacher cannot assign a student to a class that doesn't + exist. + """ + + user = User.objects.get(email="teacher@school1.com") + assert user.teacher and user.teacher.school + + self.assert_validate_field( + name="klass", + value="", + error_code="does_not_exist", + user=user, + ) + + def test_validate_klass__teacher_not_in_same_school(self): + """ + Requesting teacher cannot assign a student to a class if they're not in + the same school. + """ + + user = User.objects.get(email="teacher@school1.com") + assert user.teacher and user.teacher.school + + klass = Class.objects.exclude( + teacher__school=user.teacher.school + ).first() + assert klass is not None + + self.assert_validate_field( + name="klass", + value=klass.access_code, + error_code="teacher_not_in_same_school", + user=user, + ) + + def test_validate_klass__teacher_not_admin_or_class_owner(self): + """ + Requesting teacher cannot assign a student to a class if they're not an + admin or they don't own the class. + """ + + user = User.objects.get(email="teacher@school1.com") + assert ( + user.teacher and user.teacher.school and not user.teacher.is_admin + ) + + klass = ( + Class.objects.filter(teacher__school=user.teacher.school) + .exclude(teacher=user.teacher) + .first() + ) + assert klass is not None + + self.assert_validate_field( + name="klass", + value=klass.access_code, + error_code="teacher_not_admin_or_class_owner", + user=user, + ) diff --git a/backend/api/tests/serializers/test_user.py b/backend/api/tests/serializers/test_user.py new file mode 100644 index 00000000..59ab6691 --- /dev/null +++ b/backend/api/tests/serializers/test_user.py @@ -0,0 +1,69 @@ +""" +© Ocado Group +Created on 31/01/2024 at 16:07:32(+00:00). +""" + +from codeforlife.tests import ModelSerializerTestCase +from codeforlife.user.models import Class, Student, User + +from ...serializers import UserSerializer + + +# pylint: disable-next=missing-class-docstring +class UserSerializerTestCase(ModelSerializerTestCase[User]): + model_serializer_class = UserSerializer + fixtures = ["school_1"] + + def test_validate__first_name_not_unique_per_class_in_data(self): + """ + First name must be unique per class in data. + """ + + self.assert_validate( + attrs=[ + { + "first_name": "Peter", + "new_student": { + "class_field": { + "access_code": "ZZ111", + }, + }, + }, + { + "first_name": "Peter", + "new_student": { + "class_field": { + "access_code": "ZZ111", + }, + }, + }, + ], + error_code="first_name_not_unique_per_class_in_data", + many=True, + ) + + def test_validate__first_name_not_unique_per_class_in_db(self): + """ + First name must be unique per class in database. + """ + + klass = Class.objects.get(name="Class 1 @ School 1") + assert klass is not None + + student = Student.objects.filter(class_field=klass).first() + assert student is not None + + self.assert_validate( + attrs=[ + { + "first_name": student.new_user.first_name, + "new_student": { + "class_field": { + "access_code": klass.access_code, + }, + }, + }, + ], + error_code="first_name_not_unique_per_class_in_db", + many=True, + ) diff --git a/backend/api/tests/signals/test_user.py b/backend/api/tests/signals/test_user.py index c44c7041..0a3bbbab 100644 --- a/backend/api/tests/signals/test_user.py +++ b/backend/api/tests/signals/test_user.py @@ -25,7 +25,7 @@ def test_pre_save__email(self): Updating the email field also updates the username field. """ - user = User.objects.first() + user = User.objects.filter(new_teacher__isnull=False).first() assert user is not None email = "example@codeforelife.com" diff --git a/backend/api/tests/views/test_user.py b/backend/api/tests/views/test_user.py index f69a4608..f0bf8385 100644 --- a/backend/api/tests/views/test_user.py +++ b/backend/api/tests/views/test_user.py @@ -3,8 +3,10 @@ Created on 20/01/2024 at 10:58:52(+00:00). """ +import typing as t + from codeforlife.tests import ModelViewSetTestCase -from codeforlife.user.models import User +from codeforlife.user.models import Class, User from ...views import UserViewSet @@ -21,6 +23,13 @@ class TestUserViewSet(ModelViewSetTestCase[User]): basename = "user" model_view_set_class = UserViewSet + def _login_teacher(self): + return self.client.login_teacher( + email="maxplanck@codeforlife.com", + password="Password1", + is_admin=False, + ) + def test_is_unique_email(self): """ Check email is unique. @@ -41,3 +50,27 @@ def test_is_unique_email(self): ) self.assertTrue(response.json()) + + def test_bulk_create__students(self): + """ + Teacher can bulk create students. + """ + + user = self._login_teacher() + assert user.teacher.school is not None + + klass: t.Optional[Class] = user.teacher.class_teacher.first() + assert klass is not None + + self.client.bulk_create( + [ + { + "first_name": "Peter", + "student": {"klass": klass.access_code}, + }, + { + "first_name": "Mary", + "student": {"klass": klass.access_code}, + }, + ] + ) diff --git a/backend/api/views/user.py b/backend/api/views/user.py index 9cb6d706..813fd35d 100644 --- a/backend/api/views/user.py +++ b/backend/api/views/user.py @@ -6,6 +6,7 @@ import typing as t from codeforlife.user.models import User +from codeforlife.user.permissions import IsTeacher from codeforlife.user.views import UserViewSet as _UserViewSet from rest_framework.decorators import action from rest_framework.permissions import AllowAny @@ -19,6 +20,12 @@ class UserViewSet(_UserViewSet): http_method_names = ["get", "post", "patch", "delete"] serializer_class = UserSerializer + def get_permissions(self): + if self.action == "bulk": + return [IsTeacher()] + + return super().get_permissions() + @action(detail=False, methods=["post"], permission_classes=[AllowAny]) def is_unique_email(self, request): """Checks if an email is unique.""" diff --git a/run b/run index ef8a4518..5965ed75 100755 --- a/run +++ b/run @@ -3,7 +3,7 @@ set -e cd "${BASH_SOURCE%/*}" -source ./setup.sh +source ./setup cd frontend