diff --git a/.gitpod.Dockerfile b/.gitpod.Dockerfile
index b82c0695b..54b5facc1 100644
--- a/.gitpod.Dockerfile
+++ b/.gitpod.Dockerfile
@@ -18,7 +18,7 @@ RUN sudo apt-get update \
# RUN pyenv update && pyenv install 3.12.3 && pyenv global 3.12.3
-RUN pyenv install 3.12.3 && pyenv global 3.12.3
+RUN pyenv install 3.12.2 && pyenv global 3.12.2
RUN pip install pipenv yapf
# remove PIP_USER environment
diff --git a/Pipfile.lock b/Pipfile.lock
index bf215bb7c..bb4197d61 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -158,11 +158,11 @@
},
"annotated-types": {
"hashes": [
- "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43",
- "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"
+ "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53",
+ "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"
],
"markers": "python_version >= '3.8'",
- "version": "==0.6.0"
+ "version": "==0.7.0"
},
"anyio": {
"hashes": [
@@ -1021,12 +1021,12 @@
},
"google-cloud-bigquery": {
"hashes": [
- "sha256:7ecdb207727d513b1bce1f213dbb926ed2e1d4f0122778de00f0e56d19d47a01",
- "sha256:dc0a4a47ab541a34aa1dc1f48539d88c091adc0637da7744d7fab6f3bc8886d5"
+ "sha256:4b4597f9291b42102c9667d3b4528f801d4c8f24ef2b12dd1ecb881273330955",
+ "sha256:9fb72884fdbec9c4643cea6b7f21e1ecf3eb61d5305f87493d271dc801647a9e"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
- "version": "==3.23.0"
+ "version": "==3.23.1"
},
"google-cloud-bigquery-storage": {
"hashes": [
@@ -1278,59 +1278,59 @@
"sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da",
"sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"
],
- "markers": "python_version >= '3.11' and platform_python_implementation == 'CPython'",
+ "markers": "python_version >= '3.7'",
"version": "==3.0.3"
},
"grpcio": {
"hashes": [
- "sha256:01799e8649f9e94ba7db1aeb3452188048b0019dc37696b0f5ce212c87c560c3",
- "sha256:0697563d1d84d6985e40ec5ec596ff41b52abb3fd91ec240e8cb44a63b895094",
- "sha256:08e1559fd3b3b4468486b26b0af64a3904a8dbc78d8d936af9c1cf9636eb3e8b",
- "sha256:166e5c460e5d7d4656ff9e63b13e1f6029b122104c1633d5f37eaea348d7356d",
- "sha256:1ff737cf29b5b801619f10e59b581869e32f400159e8b12d7a97e7e3bdeee6a2",
- "sha256:219bb1848cd2c90348c79ed0a6b0ea51866bc7e72fa6e205e459fedab5770172",
- "sha256:259e11932230d70ef24a21b9fb5bb947eb4703f57865a404054400ee92f42f5d",
- "sha256:2e93aca840c29d4ab5db93f94ed0a0ca899e241f2e8aec6334ab3575dc46125c",
- "sha256:3a6d1f9ea965e750db7b4ee6f9fdef5fdf135abe8a249e75d84b0a3e0c668a1b",
- "sha256:50344663068041b34a992c19c600236e7abb42d6ec32567916b87b4c8b8833b3",
- "sha256:56cdf96ff82e3cc90dbe8bac260352993f23e8e256e063c327b6cf9c88daf7a9",
- "sha256:5c039ef01516039fa39da8a8a43a95b64e288f79f42a17e6c2904a02a319b357",
- "sha256:6426e1fb92d006e47476d42b8f240c1d916a6d4423c5258ccc5b105e43438f61",
- "sha256:65bf975639a1f93bee63ca60d2e4951f1b543f498d581869922910a476ead2f5",
- "sha256:6a1a3642d76f887aa4009d92f71eb37809abceb3b7b5a1eec9c554a246f20e3a",
- "sha256:6ef0ad92873672a2a3767cb827b64741c363ebaa27e7f21659e4e31f4d750280",
- "sha256:756fed02dacd24e8f488f295a913f250b56b98fb793f41d5b2de6c44fb762434",
- "sha256:75f701ff645858a2b16bc8c9fc68af215a8bb2d5a9b647448129de6e85d52bce",
- "sha256:8064d986d3a64ba21e498b9a376cbc5d6ab2e8ab0e288d39f266f0fca169b90d",
- "sha256:878b1d88d0137df60e6b09b74cdb73db123f9579232c8456f53e9abc4f62eb3c",
- "sha256:8f3f6883ce54a7a5f47db43289a0a4c776487912de1a0e2cc83fdaec9685cc9f",
- "sha256:91b73d3f1340fefa1e1716c8c1ec9930c676d6b10a3513ab6c26004cb02d8b3f",
- "sha256:93a46794cc96c3a674cdfb59ef9ce84d46185fe9421baf2268ccb556f8f81f57",
- "sha256:93f45f27f516548e23e4ec3fbab21b060416007dbe768a111fc4611464cc773f",
- "sha256:9e350cb096e5c67832e9b6e018cf8a0d2a53b2a958f6251615173165269a91b0",
- "sha256:a2d60cd1d58817bc5985fae6168d8b5655c4981d448d0f5b6194bbcc038090d2",
- "sha256:a3abfe0b0f6798dedd2e9e92e881d9acd0fdb62ae27dcbbfa7654a57e24060c0",
- "sha256:a44624aad77bf8ca198c55af811fd28f2b3eaf0a50ec5b57b06c034416ef2d0a",
- "sha256:a7b19dfc74d0be7032ca1eda0ed545e582ee46cd65c162f9e9fc6b26ef827dc6",
- "sha256:ad2ac8903b2eae071055a927ef74121ed52d69468e91d9bcbd028bd0e554be6d",
- "sha256:b005292369d9c1f80bf70c1db1c17c6c342da7576f1c689e8eee4fb0c256af85",
- "sha256:b2e44f59316716532a993ca2966636df6fbe7be4ab6f099de6815570ebe4383a",
- "sha256:b3afbd9d6827fa6f475a4f91db55e441113f6d3eb9b7ebb8fb806e5bb6d6bd0d",
- "sha256:b416252ac5588d9dfb8a30a191451adbf534e9ce5f56bb02cd193f12d8845b7f",
- "sha256:b5194775fec7dc3dbd6a935102bb156cd2c35efe1685b0a46c67b927c74f0cfb",
- "sha256:cacdef0348a08e475a721967f48206a2254a1b26ee7637638d9e081761a5ba86",
- "sha256:cd1e68776262dd44dedd7381b1a0ad09d9930ffb405f737d64f505eb7f77d6c7",
- "sha256:cdcda1156dcc41e042d1e899ba1f5c2e9f3cd7625b3d6ebfa619806a4c1aadda",
- "sha256:cf8dae9cc0412cb86c8de5a8f3be395c5119a370f3ce2e69c8b7d46bb9872c8d",
- "sha256:d2497769895bb03efe3187fb1888fc20e98a5f18b3d14b606167dacda5789434",
- "sha256:e3b77eaefc74d7eb861d3ffbdf91b50a1bb1639514ebe764c47773b833fa2d91",
- "sha256:e48cee31bc5f5a31fb2f3b573764bd563aaa5472342860edcc7039525b53e46a",
- "sha256:e4cbb2100ee46d024c45920d16e888ee5d3cf47c66e316210bc236d5bebc42b3",
- "sha256:f28f8b2db7b86c77916829d64ab21ff49a9d8289ea1564a2b2a3a8ed9ffcccd3",
- "sha256:f3023e14805c61bc439fb40ca545ac3d5740ce66120a678a3c6c2c55b70343d1",
- "sha256:fdf348ae69c6ff484402cfdb14e18c1b0054ac2420079d575c53a60b9b2853ae"
- ],
- "version": "==1.63.0"
+ "sha256:01615bbcae6875eee8091e6b9414072f4e4b00d8b7e141f89635bdae7cf784e5",
+ "sha256:02cc9cc3f816d30f7993d0d408043b4a7d6a02346d251694d8ab1f78cc723e7e",
+ "sha256:0b2dfe6dcace264807d9123d483d4c43274e3f8c39f90ff51de538245d7a4145",
+ "sha256:0da1d921f8e4bcee307aeef6c7095eb26e617c471f8cb1c454fd389c5c296d1e",
+ "sha256:0f30596cdcbed3c98024fb4f1d91745146385b3f9fd10c9f2270cbfe2ed7ed91",
+ "sha256:1ce4cd5a61d4532651079e7aae0fedf9a80e613eed895d5b9743e66b52d15812",
+ "sha256:1f279ad72dd7d64412e10f2443f9f34872a938c67387863c4cd2fb837f53e7d2",
+ "sha256:1f5de082d936e0208ce8db9095821361dfa97af8767a6607ae71425ac8ace15c",
+ "sha256:1f8ea18b928e539046bb5f9c124d717fbf00cc4b2d960ae0b8468562846f5aa1",
+ "sha256:2186d76a7e383e1466e0ea2b0febc343ffeae13928c63c6ec6826533c2d69590",
+ "sha256:23b6887bb21d77649d022fa1859e05853fdc2e60682fd86c3db652a555a282e0",
+ "sha256:257baf07f53a571c215eebe9679c3058a313fd1d1f7c4eede5a8660108c52d9c",
+ "sha256:2a18090371d138a57714ee9bffd6c9c9cb2e02ce42c681aac093ae1e7189ed21",
+ "sha256:2e8fabe2cc57a369638ab1ad8e6043721014fdf9a13baa7c0e35995d3a4a7618",
+ "sha256:3161a8f8bb38077a6470508c1a7301cd54301c53b8a34bb83e3c9764874ecabd",
+ "sha256:31890b24d47b62cc27da49a462efe3d02f3c120edb0e6c46dcc0025506acf004",
+ "sha256:3550493ac1d23198d46dc9c9b24b411cef613798dc31160c7138568ec26bc9b4",
+ "sha256:3b09c3d9de95461214a11d82cc0e6a46a6f4e1f91834b50782f932895215e5db",
+ "sha256:3d2004e85cf5213995d09408501f82c8534700d2babeb81dfdba2a3bff0bb396",
+ "sha256:46b8b43ba6a2a8f3103f103f97996cad507bcfd72359af6516363c48793d5a7b",
+ "sha256:579dd9fb11bc73f0de061cab5f8b2def21480fd99eb3743ed041ad6a1913ee2f",
+ "sha256:597191370951b477b7a1441e1aaa5cacebeb46a3b0bd240ec3bb2f28298c7553",
+ "sha256:59c68df3a934a586c3473d15956d23a618b8f05b5e7a3a904d40300e9c69cbf0",
+ "sha256:5a56797dea8c02e7d3a85dfea879f286175cf4d14fbd9ab3ef2477277b927baa",
+ "sha256:650a8150a9b288f40d5b7c1d5400cc11724eae50bd1f501a66e1ea949173649b",
+ "sha256:6d5541eb460d73a07418524fb64dcfe0adfbcd32e2dac0f8f90ce5b9dd6c046c",
+ "sha256:6ec5ed15b4ffe56e2c6bc76af45e6b591c9be0224b3fb090adfb205c9012367d",
+ "sha256:73f84f9e5985a532e47880b3924867de16fa1aa513fff9b26106220c253c70c5",
+ "sha256:753cb58683ba0c545306f4e17dabf468d29cb6f6b11832e1e432160bb3f8403c",
+ "sha256:7c1f5b2298244472bcda49b599be04579f26425af0fd80d3f2eb5fd8bc84d106",
+ "sha256:7e013428ab472892830287dd082b7d129f4d8afef49227a28223a77337555eaa",
+ "sha256:7f17572dc9acd5e6dfd3014d10c0b533e9f79cd9517fc10b0225746f4c24b58e",
+ "sha256:85fda90b81da25993aa47fae66cae747b921f8f6777550895fb62375b776a231",
+ "sha256:874c741c8a66f0834f653a69e7e64b4e67fcd4a8d40296919b93bab2ccc780ba",
+ "sha256:8d598b5d5e2c9115d7fb7e2cb5508d14286af506a75950762aa1372d60e41851",
+ "sha256:8de0399b983f8676a7ccfdd45e5b2caec74a7e3cc576c6b1eecf3b3680deda5e",
+ "sha256:a053584079b793a54bece4a7d1d1b5c0645bdbee729215cd433703dc2532f72b",
+ "sha256:a54362f03d4dcfae63be455d0a7d4c1403673498b92c6bfe22157d935b57c7a9",
+ "sha256:aca4f15427d2df592e0c8f3d38847e25135e4092d7f70f02452c0e90d6a02d6d",
+ "sha256:b2cbdfba18408389a1371f8c2af1659119e1831e5ed24c240cae9e27b4abc38d",
+ "sha256:b52e1ec7185512103dd47d41cf34ea78e7a7361ba460187ddd2416b480e0938c",
+ "sha256:c46fb6bfca17bfc49f011eb53416e61472fa96caa0979b4329176bdd38cbbf2a",
+ "sha256:c56c91bd2923ddb6e7ed28ebb66d15633b03e0df22206f22dfcdde08047e0a48",
+ "sha256:cf4c8daed18ae2be2f1fc7d613a76ee2a2e28fdf2412d5c128be23144d28283d",
+ "sha256:d7b7bf346391dffa182fba42506adf3a84f4a718a05e445b37824136047686a1",
+ "sha256:d9171f025a196f5bcfec7e8e7ffb7c3535f7d60aecd3503f9e250296c7cfc150"
+ ],
+ "version": "==1.64.0"
},
"grpcio-status": {
"hashes": [
@@ -1532,7 +1532,6 @@
"sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc",
"sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"
],
- "markers": "python_version >= '3.5'",
"version": "==3.7"
},
"incremental": {
@@ -1661,6 +1660,7 @@
"sha256:19b4e485cd07b7d83e3fe3b72132e7df70bfac22b14fe4bf7a23822c3a35bff5",
"sha256:1a2569a1f15ae6c8c64108a2cd2b4a858fc1e13d25846be0666fc144715e32ab",
"sha256:1a7aca7964ac4bb07680d5c9d63b9d7028cace3e2d43175cb50bba8c5ad33316",
+ "sha256:1b590b39ef90c6b22ec0be925b211298e810b4856909c8ca60d27ffbca6c12e6",
"sha256:1d8a701774dfc42a2f0b8ccdfe7dbc140500d1049e0632a611985d943fcf12df",
"sha256:1e275ea572389e41e8b039ac076a46cb87ee6b8542df3fff26f5baab43713bca",
"sha256:2304d3c93f2258ccf2cf7a6ba8c761d76ef84948d87bf9664e14d203da2cd264",
@@ -1716,6 +1716,7 @@
"sha256:79d1fb9252e7e2cfe4de6e9a6610c7cbb99b9708e2c3e29057f487de5a9eaefa",
"sha256:7ce7ad8abebe737ad6143d9d3bf94b88b93365ea30a5b81f6877ec9c0dee0a48",
"sha256:7ed07b3062b055d7a7f9d6557a251cc655eed0b3152b76de619516621c56f5d3",
+ "sha256:7ff762670cada8e05b32bf1e4dc50b140790909caa8303cfddc4d702b71ea184",
"sha256:8268cbcd48c5375f46e000adb1390572c98879eb4f77910c6053d25cc3ac2c67",
"sha256:875a3f90d7eb5c5d77e529080d95140eacb3c6d13ad5b616ee8095447b1d22e7",
"sha256:89feb82ca055af0fe797a2323ec9043b26bc371365847dbe83c7fd2e2f181c34",
@@ -1733,6 +1734,7 @@
"sha256:a233bb68625a85126ac9f1fc66d24337d6e8a0f9207b688eec2e7c880f012ec0",
"sha256:a2f6a1bc2460e643785a2cde17293bd7a8f990884b822f7bca47bee0a82fc66b",
"sha256:a6d17e0370d2516d5bb9062c7b4cb731cff921fc875644c3d751ad857ba9c5b1",
+ "sha256:a6d2092797b388342c1bc932077ad232f914351932353e2e8706851c870bca1f",
"sha256:ab67ed772c584b7ef2379797bf14b82df9aa5f7438c5b9a09624dd834c1c1aaf",
"sha256:ac6540c9fff6e3813d29d0403ee7a81897f1d8ecc09a8ff84d2eea70ede1cdbf",
"sha256:ae4073a60ab98529ab8a72ebf429f2a8cc612619a8c04e08bed27450d52103c0",
@@ -1751,6 +1753,7 @@
"sha256:bcc98f911f10278d1daf14b87d65325851a1d29153caaf146877ec37031d5f36",
"sha256:be49ad33819d7dcc28a309b86d4ed98e1a65f3075c6acd3cd4fe32103235222b",
"sha256:bec4bd9133420c5c52d562469c754f27c5c9e36ee06abc169612c959bd7dbb07",
+ "sha256:c2faf60c583af0d135e853c86ac2735ce178f0e338a3c7f9ae8f622fd2eb788c",
"sha256:c689d0d5381f56de7bd6966a4541bff6e08bf8d3871bbd89a0c6ab18aa699573",
"sha256:c7079d5eb1c1315a858bbf180000757db8ad904a89476653232db835c3114001",
"sha256:cb3942960f0beb9f46e2a71a3aca220d1ca32feb5a398656be934320804c0df9",
@@ -2795,11 +2798,11 @@
},
"pytest": {
"hashes": [
- "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233",
- "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"
+ "sha256:5046e5b46d8e4cac199c373041f26be56fdb81eb4e67dc11d4e10811fc3408fd",
+ "sha256:faccc5d332b8c3719f40283d0d44aa5cf101cec36f88cde9ed8f2bc0538612b1"
],
"markers": "python_version >= '3.8'",
- "version": "==8.2.0"
+ "version": "==8.2.1"
},
"pytest-django": {
"hashes": [
@@ -2824,7 +2827,7 @@
"sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
"sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==2.9.0.post0"
},
"python-frontmatter": {
@@ -3025,12 +3028,12 @@
},
"requests": {
"hashes": [
- "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f",
- "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"
+ "sha256:dd951ff5ecf3e3b3aa26b40703ba77495dab41da839ae72ef3c8e5d8e2433289",
+ "sha256:fc06670dd0ed212426dfeb94fc1b983d917c4f9847c863f313c9dfaaffb7c23c"
],
"index": "pypi",
- "markers": "python_version >= '3.7'",
- "version": "==2.31.0"
+ "markers": "python_version >= '3.8'",
+ "version": "==2.32.2"
},
"rpds-py": {
"hashes": [
@@ -3178,18 +3181,18 @@
},
"setuptools": {
"hashes": [
- "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987",
- "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"
+ "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4",
+ "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0"
],
"markers": "python_version >= '3.8'",
- "version": "==69.5.1"
+ "version": "==70.0.0"
},
"six": {
"hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==1.16.0"
},
"sniffio": {
@@ -4174,67 +4177,67 @@
"sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da",
"sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"
],
- "markers": "python_version >= '3.11' and platform_python_implementation == 'CPython'",
+ "markers": "python_version >= '3.7'",
"version": "==3.0.3"
},
"griffe": {
"hashes": [
- "sha256:85cb2868d026ea51c89bdd589ad3ccc94abc5bd8d5d948e3d4450778a2a05b4a",
- "sha256:90fe5c90e1b0ca7dd6fee78f9009f4e01b37dbc9ab484a9b2c1578915db1e571"
+ "sha256:12194c10ae07a7f46708741ad78419362cf8e5c883f449c7c48de1686611b853",
+ "sha256:84ce9243a9e63c07d55563a735a0d07ef70b46c455616c174010e7fc816f4648"
],
"markers": "python_version >= '3.8'",
- "version": "==0.45.0"
+ "version": "==0.45.1"
},
"grpcio": {
"hashes": [
- "sha256:01799e8649f9e94ba7db1aeb3452188048b0019dc37696b0f5ce212c87c560c3",
- "sha256:0697563d1d84d6985e40ec5ec596ff41b52abb3fd91ec240e8cb44a63b895094",
- "sha256:08e1559fd3b3b4468486b26b0af64a3904a8dbc78d8d936af9c1cf9636eb3e8b",
- "sha256:166e5c460e5d7d4656ff9e63b13e1f6029b122104c1633d5f37eaea348d7356d",
- "sha256:1ff737cf29b5b801619f10e59b581869e32f400159e8b12d7a97e7e3bdeee6a2",
- "sha256:219bb1848cd2c90348c79ed0a6b0ea51866bc7e72fa6e205e459fedab5770172",
- "sha256:259e11932230d70ef24a21b9fb5bb947eb4703f57865a404054400ee92f42f5d",
- "sha256:2e93aca840c29d4ab5db93f94ed0a0ca899e241f2e8aec6334ab3575dc46125c",
- "sha256:3a6d1f9ea965e750db7b4ee6f9fdef5fdf135abe8a249e75d84b0a3e0c668a1b",
- "sha256:50344663068041b34a992c19c600236e7abb42d6ec32567916b87b4c8b8833b3",
- "sha256:56cdf96ff82e3cc90dbe8bac260352993f23e8e256e063c327b6cf9c88daf7a9",
- "sha256:5c039ef01516039fa39da8a8a43a95b64e288f79f42a17e6c2904a02a319b357",
- "sha256:6426e1fb92d006e47476d42b8f240c1d916a6d4423c5258ccc5b105e43438f61",
- "sha256:65bf975639a1f93bee63ca60d2e4951f1b543f498d581869922910a476ead2f5",
- "sha256:6a1a3642d76f887aa4009d92f71eb37809abceb3b7b5a1eec9c554a246f20e3a",
- "sha256:6ef0ad92873672a2a3767cb827b64741c363ebaa27e7f21659e4e31f4d750280",
- "sha256:756fed02dacd24e8f488f295a913f250b56b98fb793f41d5b2de6c44fb762434",
- "sha256:75f701ff645858a2b16bc8c9fc68af215a8bb2d5a9b647448129de6e85d52bce",
- "sha256:8064d986d3a64ba21e498b9a376cbc5d6ab2e8ab0e288d39f266f0fca169b90d",
- "sha256:878b1d88d0137df60e6b09b74cdb73db123f9579232c8456f53e9abc4f62eb3c",
- "sha256:8f3f6883ce54a7a5f47db43289a0a4c776487912de1a0e2cc83fdaec9685cc9f",
- "sha256:91b73d3f1340fefa1e1716c8c1ec9930c676d6b10a3513ab6c26004cb02d8b3f",
- "sha256:93a46794cc96c3a674cdfb59ef9ce84d46185fe9421baf2268ccb556f8f81f57",
- "sha256:93f45f27f516548e23e4ec3fbab21b060416007dbe768a111fc4611464cc773f",
- "sha256:9e350cb096e5c67832e9b6e018cf8a0d2a53b2a958f6251615173165269a91b0",
- "sha256:a2d60cd1d58817bc5985fae6168d8b5655c4981d448d0f5b6194bbcc038090d2",
- "sha256:a3abfe0b0f6798dedd2e9e92e881d9acd0fdb62ae27dcbbfa7654a57e24060c0",
- "sha256:a44624aad77bf8ca198c55af811fd28f2b3eaf0a50ec5b57b06c034416ef2d0a",
- "sha256:a7b19dfc74d0be7032ca1eda0ed545e582ee46cd65c162f9e9fc6b26ef827dc6",
- "sha256:ad2ac8903b2eae071055a927ef74121ed52d69468e91d9bcbd028bd0e554be6d",
- "sha256:b005292369d9c1f80bf70c1db1c17c6c342da7576f1c689e8eee4fb0c256af85",
- "sha256:b2e44f59316716532a993ca2966636df6fbe7be4ab6f099de6815570ebe4383a",
- "sha256:b3afbd9d6827fa6f475a4f91db55e441113f6d3eb9b7ebb8fb806e5bb6d6bd0d",
- "sha256:b416252ac5588d9dfb8a30a191451adbf534e9ce5f56bb02cd193f12d8845b7f",
- "sha256:b5194775fec7dc3dbd6a935102bb156cd2c35efe1685b0a46c67b927c74f0cfb",
- "sha256:cacdef0348a08e475a721967f48206a2254a1b26ee7637638d9e081761a5ba86",
- "sha256:cd1e68776262dd44dedd7381b1a0ad09d9930ffb405f737d64f505eb7f77d6c7",
- "sha256:cdcda1156dcc41e042d1e899ba1f5c2e9f3cd7625b3d6ebfa619806a4c1aadda",
- "sha256:cf8dae9cc0412cb86c8de5a8f3be395c5119a370f3ce2e69c8b7d46bb9872c8d",
- "sha256:d2497769895bb03efe3187fb1888fc20e98a5f18b3d14b606167dacda5789434",
- "sha256:e3b77eaefc74d7eb861d3ffbdf91b50a1bb1639514ebe764c47773b833fa2d91",
- "sha256:e48cee31bc5f5a31fb2f3b573764bd563aaa5472342860edcc7039525b53e46a",
- "sha256:e4cbb2100ee46d024c45920d16e888ee5d3cf47c66e316210bc236d5bebc42b3",
- "sha256:f28f8b2db7b86c77916829d64ab21ff49a9d8289ea1564a2b2a3a8ed9ffcccd3",
- "sha256:f3023e14805c61bc439fb40ca545ac3d5740ce66120a678a3c6c2c55b70343d1",
- "sha256:fdf348ae69c6ff484402cfdb14e18c1b0054ac2420079d575c53a60b9b2853ae"
- ],
- "version": "==1.63.0"
+ "sha256:01615bbcae6875eee8091e6b9414072f4e4b00d8b7e141f89635bdae7cf784e5",
+ "sha256:02cc9cc3f816d30f7993d0d408043b4a7d6a02346d251694d8ab1f78cc723e7e",
+ "sha256:0b2dfe6dcace264807d9123d483d4c43274e3f8c39f90ff51de538245d7a4145",
+ "sha256:0da1d921f8e4bcee307aeef6c7095eb26e617c471f8cb1c454fd389c5c296d1e",
+ "sha256:0f30596cdcbed3c98024fb4f1d91745146385b3f9fd10c9f2270cbfe2ed7ed91",
+ "sha256:1ce4cd5a61d4532651079e7aae0fedf9a80e613eed895d5b9743e66b52d15812",
+ "sha256:1f279ad72dd7d64412e10f2443f9f34872a938c67387863c4cd2fb837f53e7d2",
+ "sha256:1f5de082d936e0208ce8db9095821361dfa97af8767a6607ae71425ac8ace15c",
+ "sha256:1f8ea18b928e539046bb5f9c124d717fbf00cc4b2d960ae0b8468562846f5aa1",
+ "sha256:2186d76a7e383e1466e0ea2b0febc343ffeae13928c63c6ec6826533c2d69590",
+ "sha256:23b6887bb21d77649d022fa1859e05853fdc2e60682fd86c3db652a555a282e0",
+ "sha256:257baf07f53a571c215eebe9679c3058a313fd1d1f7c4eede5a8660108c52d9c",
+ "sha256:2a18090371d138a57714ee9bffd6c9c9cb2e02ce42c681aac093ae1e7189ed21",
+ "sha256:2e8fabe2cc57a369638ab1ad8e6043721014fdf9a13baa7c0e35995d3a4a7618",
+ "sha256:3161a8f8bb38077a6470508c1a7301cd54301c53b8a34bb83e3c9764874ecabd",
+ "sha256:31890b24d47b62cc27da49a462efe3d02f3c120edb0e6c46dcc0025506acf004",
+ "sha256:3550493ac1d23198d46dc9c9b24b411cef613798dc31160c7138568ec26bc9b4",
+ "sha256:3b09c3d9de95461214a11d82cc0e6a46a6f4e1f91834b50782f932895215e5db",
+ "sha256:3d2004e85cf5213995d09408501f82c8534700d2babeb81dfdba2a3bff0bb396",
+ "sha256:46b8b43ba6a2a8f3103f103f97996cad507bcfd72359af6516363c48793d5a7b",
+ "sha256:579dd9fb11bc73f0de061cab5f8b2def21480fd99eb3743ed041ad6a1913ee2f",
+ "sha256:597191370951b477b7a1441e1aaa5cacebeb46a3b0bd240ec3bb2f28298c7553",
+ "sha256:59c68df3a934a586c3473d15956d23a618b8f05b5e7a3a904d40300e9c69cbf0",
+ "sha256:5a56797dea8c02e7d3a85dfea879f286175cf4d14fbd9ab3ef2477277b927baa",
+ "sha256:650a8150a9b288f40d5b7c1d5400cc11724eae50bd1f501a66e1ea949173649b",
+ "sha256:6d5541eb460d73a07418524fb64dcfe0adfbcd32e2dac0f8f90ce5b9dd6c046c",
+ "sha256:6ec5ed15b4ffe56e2c6bc76af45e6b591c9be0224b3fb090adfb205c9012367d",
+ "sha256:73f84f9e5985a532e47880b3924867de16fa1aa513fff9b26106220c253c70c5",
+ "sha256:753cb58683ba0c545306f4e17dabf468d29cb6f6b11832e1e432160bb3f8403c",
+ "sha256:7c1f5b2298244472bcda49b599be04579f26425af0fd80d3f2eb5fd8bc84d106",
+ "sha256:7e013428ab472892830287dd082b7d129f4d8afef49227a28223a77337555eaa",
+ "sha256:7f17572dc9acd5e6dfd3014d10c0b533e9f79cd9517fc10b0225746f4c24b58e",
+ "sha256:85fda90b81da25993aa47fae66cae747b921f8f6777550895fb62375b776a231",
+ "sha256:874c741c8a66f0834f653a69e7e64b4e67fcd4a8d40296919b93bab2ccc780ba",
+ "sha256:8d598b5d5e2c9115d7fb7e2cb5508d14286af506a75950762aa1372d60e41851",
+ "sha256:8de0399b983f8676a7ccfdd45e5b2caec74a7e3cc576c6b1eecf3b3680deda5e",
+ "sha256:a053584079b793a54bece4a7d1d1b5c0645bdbee729215cd433703dc2532f72b",
+ "sha256:a54362f03d4dcfae63be455d0a7d4c1403673498b92c6bfe22157d935b57c7a9",
+ "sha256:aca4f15427d2df592e0c8f3d38847e25135e4092d7f70f02452c0e90d6a02d6d",
+ "sha256:b2cbdfba18408389a1371f8c2af1659119e1831e5ed24c240cae9e27b4abc38d",
+ "sha256:b52e1ec7185512103dd47d41cf34ea78e7a7361ba460187ddd2416b480e0938c",
+ "sha256:c46fb6bfca17bfc49f011eb53416e61472fa96caa0979b4329176bdd38cbbf2a",
+ "sha256:c56c91bd2923ddb6e7ed28ebb66d15633b03e0df22206f22dfcdde08047e0a48",
+ "sha256:cf4c8daed18ae2be2f1fc7d613a76ee2a2e28fdf2412d5c128be23144d28283d",
+ "sha256:d7b7bf346391dffa182fba42506adf3a84f4a718a05e445b37824136047686a1",
+ "sha256:d9171f025a196f5bcfec7e8e7ffb7c3535f7d60aecd3503f9e250296c7cfc150"
+ ],
+ "version": "==1.64.0"
},
"grpcio-status": {
"hashes": [
@@ -4264,7 +4267,6 @@
"sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc",
"sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"
],
- "markers": "python_version >= '3.5'",
"version": "==3.7"
},
"importlib-metadata": {
@@ -4410,12 +4412,12 @@
},
"mkdocs-material": {
"hashes": [
- "sha256:4627fc3f15de2cba2bde9debc2fd59b9888ef494beabfe67eb352e23d14bf288",
- "sha256:ffd08a5beaef3cd135aceb58ded8b98bbbbf2b70e5b656f6a14a63c917d9b001"
+ "sha256:02d5aaba0ee755e707c3ef6e748f9acb7b3011187c0ea766db31af8905078a34",
+ "sha256:e12cd75954c535b61e716f359cf2a5056bf4514889d17161fdebd5df4b0153c6"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==9.5.23"
+ "version": "==9.5.24"
},
"mkdocs-material-extensions": {
"hashes": [
@@ -4601,25 +4603,25 @@
"sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad",
"sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742"
],
- "markers": "python_version >= '3.1'",
+ "markers": "python_version > '3.0'",
"version": "==3.1.2"
},
"pytest": {
"hashes": [
- "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233",
- "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"
+ "sha256:5046e5b46d8e4cac199c373041f26be56fdb81eb4e67dc11d4e10811fc3408fd",
+ "sha256:faccc5d332b8c3719f40283d0d44aa5cf101cec36f88cde9ed8f2bc0538612b1"
],
"markers": "python_version >= '3.8'",
- "version": "==8.2.0"
+ "version": "==8.2.1"
},
"pytest-asyncio": {
"hashes": [
- "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a",
- "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f"
+ "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b",
+ "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==0.23.6"
+ "version": "==0.23.7"
},
"pytest-cov": {
"hashes": [
@@ -4653,7 +4655,7 @@
"sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
"sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==2.9.0.post0"
},
"pyyaml": {
@@ -4809,12 +4811,12 @@
},
"requests": {
"hashes": [
- "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f",
- "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"
+ "sha256:dd951ff5ecf3e3b3aa26b40703ba77495dab41da839ae72ef3c8e5d8e2433289",
+ "sha256:fc06670dd0ed212426dfeb94fc1b983d917c4f9847c863f313c9dfaaffb7c23c"
],
"index": "pypi",
- "markers": "python_version >= '3.7'",
- "version": "==2.31.0"
+ "markers": "python_version >= '3.8'",
+ "version": "==2.32.2"
},
"requests-oauthlib": {
"hashes": [
@@ -4834,18 +4836,18 @@
},
"setuptools": {
"hashes": [
- "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987",
- "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"
+ "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4",
+ "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0"
],
"markers": "python_version >= '3.8'",
- "version": "==69.5.1"
+ "version": "==70.0.0"
},
"six": {
"hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==1.16.0"
},
"snowballstemmer": {
diff --git a/breathecode/assessment/actions.py b/breathecode/assessment/actions.py
index 000c6053b..d8fdbb463 100644
--- a/breathecode/assessment/actions.py
+++ b/breathecode/assessment/actions.py
@@ -9,49 +9,140 @@
logger = logging.getLogger(__name__)
-def create_from_asset(asset):
+def validate_quiz_json(quiz, allow_override=False):
+
+ if 'info' not in quiz:
+ raise ValidationException(f'Quiz is missing info json property')
+
+ if 'slug' not in quiz['info']:
+ raise ValidationException(f'Missing info.slug on quiz info')
+
+ _result = {'questions': []}
+
+ # We guarantee that "assessment" property will always be set to something (none or else)
+ _result['assessment'] = Assessment.objects.filter(slug=quiz['info']['slug']).first()
+ if not allow_override and _result['assessment']:
+ raise ValidationException(
+ f"There is already an assessment (maybe it's archived) with slug {quiz['info']['slug']}, rename your quiz info.slug or delete the previous assessment"
+ )
+
+ if 'id' in quiz: _result['id'] = quiz['id']
+ elif 'id' in quiz['info']: _result['id'] = quiz['info']['id']
+
+ if 'questions' not in quiz:
+ raise Exception(f'Missing "questions" property in quiz')
+
+ title = 'Untitled assessment'
+ if 'name' in quiz['info']: title = quiz['info']['name']
+ if 'title' in quiz['info']: title = quiz['info']['title']
+
+ _result['info'] = {
+ 'title': title,
+ 'slug': quiz['info']['slug'],
+ }
+
+ _index = 0
+ for question in quiz['questions']:
+ _index += 1
+
+ _question = {'id': question['id'] if 'id' in question else None}
+
+ title = ''
+ if 'q' in question: title = question['q']
+ elif 'title' in question: title = question['title']
+ else: raise Exception(f'Missing "title" property in quiz question #{_index}')
+
+ _question['title'] = title
+
+ options = []
+ if 'a' in question: options = question['a']
+ elif 'answers' in question: options = question['answers']
+ elif 'options' in question: options = question['options']
+ else: raise Exception('Missing "options" property in quiz question')
+
+ _question['options'] = []
+
+ o_index = 0
+ for option in options:
+ o_index += 1
+
+ _id = None
+ if 'id' in option:
+ _id = option['id']
+
+ title = 'Untitled option'
+ if 'option' in option: title = option['option']
+ elif 'title' in option: title = option['title']
+ else: raise Exception(f'Missing "title" property in option {str(o_index)}')
+
+ score = 0
+ if 'correct' in option: score = option['correct']
+ elif 'score' in option: score = option['score']
+ else: raise Exception(f'Missing "score" property in option {str(o_index)}')
+
+ _question['options'].append({'id': _id, 'title': title, 'score': int(score)})
+
+ _result['questions'].append(_question)
+
+ return _result
+
+
+def create_from_asset(asset, allow_override=False):
if asset.academy is None:
raise ValidationException(f'Asset {asset.slug} has not academy associated')
- a = asset.assessment
-
if asset.assessment is not None and asset.assessment.asset_set.count() > 1:
associated_assets = ','.join(asset.assessment.asset_set.all())
raise ValidationException('Assessment has more then one asset associated, please choose only one: ' +
associated_assets)
+ quiz = validate_quiz_json(asset.config, allow_override)
if asset.assessment is None:
- a = Assessment.objects.filter(slug=asset.slug).first()
- if a is not None:
- raise ValidationException(f'There is already an assessment with slug {asset.slug}')
-
- a = Assessment.objects.create(title=asset.title,
- lang=asset.lang,
- slug=asset.slug,
- academy=asset.academy,
- author=asset.author)
-
- if a.question_set.count() > 0:
+ a = quiz['assessment']
+ if not a:
+ a = Assessment.objects.create(title=quiz['info']['title'],
+ lang=asset.lang,
+ slug=quiz['info']['slug'],
+ academy=asset.academy,
+ author=asset.author)
+
+ if a is not None and a.question_set is not None and a.question_set.count() > 0:
raise ValidationException(
'Assessment already has questions, only empty assessments can by created from an asset')
a.save()
- quiz = asset.config
+
for question in quiz['questions']:
- q = Question(
- title=question['q'],
- lang=asset.lang,
- assessment=a,
- question_type='SELECT',
- )
- q.save()
- for option in question['a']:
- o = Option(
- title=option['option'],
- score=int(option['correct']),
- question=q,
+
+ q = None
+ if question['id']:
+ q = Question.filter(id=question['id']).first()
+ if not q:
+ raise ValidationException(f"Question with id {question['id']} not found for quiz {q.id}")
+
+ if not q:
+ q = Question(
+ lang=asset.lang,
+ assessment=a,
+ question_type='SELECT',
)
+
+ q.title = question['title']
+ q.save()
+
+ for option in question['options']:
+ o = None
+ if option['id']:
+ o = Option.filter(id=option['id']).first()
+ if not o:
+ raise ValidationException(f"Option with id {option['id']} not found for question {q.id}")
+
+ if not o:
+ o = Option(question=q)
+
+ o.title = option['title']
+ o.score = option['score']
o.save()
asset.assessment = a
diff --git a/breathecode/assessment/admin.py b/breathecode/assessment/admin.py
index f87e54b41..7c3628e1b 100644
--- a/breathecode/assessment/admin.py
+++ b/breathecode/assessment/admin.py
@@ -1,7 +1,11 @@
import logging
+import re
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin
-from .models import (Assessment, UserAssessment, UserProxy, Question, Option, AssessmentThreshold, Answer)
+from breathecode.utils.admin import change_field
+from django.utils.html import format_html
+from .models import (Assessment, UserAssessment, UserProxy, Question, Option, AssessmentThreshold, Answer,
+ AssessmentLayout)
from .actions import send_assestment
logger = logging.getLogger(__name__)
@@ -22,7 +26,6 @@ def send_bulk_assesment(modeladmin, request, queryset):
@admin.register(UserProxy)
class UserAdmin(UserAdmin):
list_display = ('username', 'email', 'first_name', 'last_name')
- actions = [send_bulk_assesment]
# Register your models here.
@@ -47,16 +50,47 @@ class QuestionAdmin(admin.ModelAdmin):
@admin.register(Option)
class OptionAdmin(admin.ModelAdmin):
search_fields = ['title', 'question__assessment__title']
- list_display = ['title', 'is_deleted', 'position', 'lang', 'score', 'question']
+ list_display = ['id', 'title', 'is_deleted', 'position', 'lang', 'score', 'question']
list_filter = ['lang', 'is_deleted']
-# Register your models here.
+def change_status_ANSWERED(modeladmin, request, queryset):
+ items = queryset.all()
+ for i in items:
+ i.status = 'ANSWERED'
+ i.save()
+
+
@admin.register(UserAssessment)
class UserAssessmentAdmin(admin.ModelAdmin):
search_fields = ['title', 'question__assessment__title']
- list_display = ['title', 'status', 'lang', 'owner', 'total_score', 'assessment']
- list_filter = ['lang']
+ readonly_fields = ('token', )
+ list_display = ['id', 'title', 'current_status', 'lang', 'owner', 'total_score', 'assessment', 'academy']
+ list_filter = ['lang', 'status', 'academy']
+ actions = [change_status_ANSWERED] + change_field(['DRAFT', 'SENT', 'ERROR', 'EXPIRED'], name='status')
+
+ def current_status(self, obj):
+ colors = {
+ 'DRAFT': 'bg-secondary',
+ 'SENT': 'bg-warning',
+ 'ANSWERED': 'bg-success',
+ 'ERROR': 'bg-error',
+ 'EXPIRED': 'bg-warning',
+ None: 'bg-error',
+ }
+
+ def from_status(s):
+ if s in colors:
+ return colors[s]
+ return ''
+
+ status = 'No status'
+ if obj.status_text is not None:
+ status = re.sub(r'[^\w\._\-]', ' ', obj.status_text)
+ return format_html(f"""
+ {obj.status} |
+ {status} |
+
""")
@admin.register(AssessmentThreshold)
@@ -64,10 +98,18 @@ class UserAssessmentThresholdAdmin(admin.ModelAdmin):
search_fields = ['assessment__slug', 'assessment__title']
list_display = ['id', 'score_threshold', 'assessment']
list_filter = ['assessment__slug']
+ actions = []
@admin.register(Answer)
class AnswerAdmin(admin.ModelAdmin):
- search_fields = ['user_assesment__owner', 'user_assesment__title']
- list_display = ['id', 'question', 'option', 'value']
- list_filter = ['user_assesment__assessment__slug']
+ search_fields = ['user_assessment__owner', 'user_assessment__title']
+ list_display = ['id', 'user_assessment', 'question', 'option', 'value']
+ list_filter = ['user_assessment__assessment__slug']
+
+
+@admin.register(AssessmentLayout)
+class AssessmentLayoutAdmin(admin.ModelAdmin):
+ search_fields = ['slug']
+ list_display = ['id', 'slug', 'academy']
+ list_filter = ['academy']
diff --git a/breathecode/assessment/apps.py b/breathecode/assessment/apps.py
index b997b8a3b..2f37516c9 100644
--- a/breathecode/assessment/apps.py
+++ b/breathecode/assessment/apps.py
@@ -1 +1,8 @@
from django.apps import AppConfig # noqa: F401
+
+
+class AssessmentConfig(AppConfig):
+ name = 'breathecode.assessment'
+
+ def ready(self):
+ from . import receivers # noqa: F401
diff --git a/breathecode/assessment/management/commands/close_open_user_assessments.py b/breathecode/assessment/management/commands/close_open_user_assessments.py
new file mode 100644
index 000000000..c2855fd55
--- /dev/null
+++ b/breathecode/assessment/management/commands/close_open_user_assessments.py
@@ -0,0 +1,20 @@
+import os
+from django.core.management.base import BaseCommand, CommandError
+from django.contrib.auth.models import User
+from django.utils import timezone
+from breathecode.admissions.models import CohortUser
+from django.db.models import Count
+
+HOST = os.environ.get('OLD_BREATHECODE_API')
+DATETIME_FORMAT = '%Y-%m-%d'
+
+
+class Command(BaseCommand):
+ help = 'Close user assessments and totalize scores'
+
+ def handle(self, *args, **options):
+
+ unfinished_ua = UserAssessment.objects.filter(finished_at__isnull=True, status='SENT')
+ total = unfinished_ua.count()
+ unfinished_ua.update(status='ERROR', status_text='Unfinished user assessment')
+ self.stdout.write(self.style.SUCCESS(f'{total} user assessments automatically closed with error'))
diff --git a/breathecode/assessment/migrations/0007_rename_user_assesment_answer_user_assessment_and_more.py b/breathecode/assessment/migrations/0007_rename_user_assesment_answer_user_assessment_and_more.py
new file mode 100644
index 000000000..c23982597
--- /dev/null
+++ b/breathecode/assessment/migrations/0007_rename_user_assesment_answer_user_assessment_and_more.py
@@ -0,0 +1,124 @@
+# Generated by Django 5.0.3 on 2024-05-02 04:38
+
+import datetime
+import django.core.validators
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('admissions', '0064_academy_legal_name'),
+ ('assessment', '0006_question_is_deleted'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='answer',
+ old_name='user_assesment',
+ new_name='user_assessment',
+ ),
+ migrations.AddField(
+ model_name='assessment',
+ name='is_archived',
+ field=models.BooleanField(
+ default=False,
+ help_text='If assessments have answers, they cannot be deleted but will be archived instead'),
+ ),
+ migrations.AddField(
+ model_name='assessment',
+ name='max_session_duration',
+ field=models.DurationField(default=datetime.timedelta(seconds=1800),
+ help_text='No more answers will be accepted after X amount of minutes'),
+ ),
+ migrations.AddField(
+ model_name='userassessment',
+ name='conversion_info',
+ field=models.JSONField(blank=True,
+ default=None,
+ help_text='UTMs and other conversion information.',
+ null=True),
+ ),
+ migrations.AddField(
+ model_name='userassessment',
+ name='has_marketing_consent',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='userassessment',
+ name='owner_email',
+ field=models.CharField(blank=True,
+ default=None,
+ help_text='If there is not registered owner we can use the email as reference',
+ max_length=150,
+ null=True),
+ ),
+ migrations.AddField(
+ model_name='userassessment',
+ name='owner_phone',
+ field=models.CharField(
+ blank=True,
+ default='',
+ max_length=17,
+ validators=[
+ django.core.validators.RegexValidator(
+ message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed.",
+ regex='^\\+?1?\\d{9,15}$')
+ ]),
+ ),
+ migrations.AddField(
+ model_name='userassessment',
+ name='status_text',
+ field=models.TextField(blank=True, default=None, null=True),
+ ),
+ migrations.AddField(
+ model_name='userassessment',
+ name='token',
+ field=models.CharField(default=None,
+ help_text='Auto-generated when a user assignment is created',
+ max_length=255,
+ unique=True),
+ preserve_default=False,
+ ),
+ migrations.AlterField(
+ model_name='userassessment',
+ name='owner',
+ field=models.ForeignKey(blank=True,
+ default=None,
+ help_text='How is answering the assessment',
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AlterField(
+ model_name='userassessment',
+ name='status',
+ field=models.CharField(choices=[('DRAFT', 'Draft'), ('SENT', 'Sent'), ('ERROR', 'Error'),
+ ('EXPIRED', 'Expired')],
+ default='DRAFT',
+ max_length=15),
+ ),
+ migrations.CreateModel(
+ name='AssessmentLayout',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('slug', models.SlugField(max_length=200, unique=True)),
+ ('additional_styles',
+ models.TextField(blank=True,
+ default=None,
+ help_text='This stylesheet will be included in the assessment if specified',
+ null=True)),
+ ('variables',
+ models.JSONField(blank=True,
+ default=None,
+ help_text='Additional params to be passed into the assessment content',
+ null=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('academy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='admissions.academy')),
+ ],
+ ),
+ ]
diff --git a/breathecode/assessment/migrations/0008_alter_answer_option_alter_userassessment_status.py b/breathecode/assessment/migrations/0008_alter_answer_option_alter_userassessment_status.py
new file mode 100644
index 000000000..f723506cf
--- /dev/null
+++ b/breathecode/assessment/migrations/0008_alter_answer_option_alter_userassessment_status.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.0.3 on 2024-05-02 19:26
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('assessment', '0007_rename_user_assesment_answer_user_assessment_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='answer',
+ name='option',
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ help_text='Will be null if open question, no options to pick. Or if option was deleted historically',
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to='assessment.option'),
+ ),
+ migrations.AlterField(
+ model_name='userassessment',
+ name='status',
+ field=models.CharField(choices=[('DRAFT', 'Draft'), ('SENT', 'Sent'), ('ANSWERED', 'Answered'),
+ ('ERROR', 'Error'), ('EXPIRED', 'Expired')],
+ default='DRAFT',
+ max_length=15),
+ ),
+ ]
diff --git a/breathecode/assessment/models.py b/breathecode/assessment/models.py
index 020c8af98..9df4d5944 100644
--- a/breathecode/assessment/models.py
+++ b/breathecode/assessment/models.py
@@ -1,6 +1,12 @@
from django.db import models
+import os
+import binascii
+import hashlib
+from datetime import timedelta
from django.contrib.auth.models import User
from breathecode.admissions.models import Academy
+from . import signals
+from django.core.validators import RegexValidator
__all__ = ['UserProxy', 'Assessment', 'Question', 'Option', 'UserAssessment', 'Answer']
@@ -21,6 +27,9 @@ def __init__(self, *args, **kwargs):
title = models.CharField(max_length=255, blank=True)
lang = models.CharField(max_length=3, blank=True, default='en')
+ max_session_duration = models.DurationField(default=timedelta(minutes=30),
+ help_text='No more answers will be accepted after X amount of minutes')
+
academy = models.ForeignKey(Academy,
on_delete=models.CASCADE,
default=None,
@@ -30,13 +39,15 @@ def __init__(self, *args, **kwargs):
author = models.ForeignKey(User, on_delete=models.SET_NULL, default=None, blank=True, null=True)
private = models.BooleanField(default=False)
+ is_archived = models.BooleanField(
+ default=False, help_text='If assessments have answers, they cannot be deleted but will be archived instead')
next = models.URLField(default=None, blank=True, null=True)
is_instant_feedback = models.BooleanField(
default=True, help_text='If true, users will know immediately if their answer was correct')
- # the original translation (will only be set if the quiz is a translation of anotherone)
+ # the original translation (will only be set if the quiz is a translation of another one)
original = models.ForeignKey(
'Assessment',
on_delete=models.CASCADE,
@@ -57,6 +68,59 @@ def __str__(self):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
+ def delete(self, *args, **kwargs):
+ # Only delete assessments without answers
+ if self.userassessment_set.count() == 0:
+ super().delete(*args, **kwargs)
+
+ # if assessment has answers we dont delete
+ self.is_archived = True
+ self.save()
+
+ def to_json(self, *args, **kwargs):
+
+ _json = {
+ 'info': {
+ 'id': self.id,
+ 'slug': self.slug,
+ 'title': self.title,
+ 'is_instant_feedback': self.is_instant_feedback,
+ },
+ 'questions': []
+ }
+ _questions = self.question_set.all()
+ for q in _questions:
+ _q = {'id': q.id, 'title': q.title, 'options': []}
+
+ _options = q.option_set.all()
+ for o in _options:
+ _q['options'].append({
+ 'id': o.id,
+ 'title': o.title,
+ 'score': o.score,
+ })
+
+ _json['questions'].append(_q)
+
+ return _json
+
+
+class AssessmentLayout(models.Model):
+
+ academy = models.ForeignKey(Academy, on_delete=models.CASCADE)
+ slug = models.SlugField(max_length=200, unique=True)
+ additional_styles = models.TextField(blank=True,
+ null=True,
+ default=None,
+ help_text='This stylesheet will be included in the assessment if specified')
+ variables = models.JSONField(default=None,
+ blank=True,
+ null=True,
+ help_text='Additional params to be passed into the assessment content')
+
+ created_at = models.DateTimeField(auto_now_add=True, editable=False)
+ updated_at = models.DateTimeField(auto_now=True, editable=False)
+
class AssessmentThreshold(models.Model):
@@ -148,26 +212,55 @@ def __str__(self):
DRAFT = 'DRAFT'
SENT = 'SENT'
ANSWERED = 'ANSWERED'
+ERROR = 'ERROR'
EXPIRED = 'EXPIRED'
SURVEY_STATUS = (
- (DRAFT, 'DRAFT'),
+ (DRAFT, 'Draft'),
(SENT, 'Sent'),
+ (ANSWERED, 'Answered'), # If marked as 'ANSWERED' the total_score will be auto-calculated
+ (ERROR, 'Error'),
(EXPIRED, 'Expired'),
)
class UserAssessment(models.Model):
+ _old_status = None
+
title = models.CharField(max_length=200, blank=True)
lang = models.CharField(max_length=3, blank=True, default='en')
academy = models.ForeignKey(Academy, on_delete=models.CASCADE, default=None, blank=True, null=True)
assessment = models.ForeignKey(Assessment, on_delete=models.CASCADE, default=None, blank=True, null=True)
- owner = models.ForeignKey(User, on_delete=models.CASCADE, default=None, blank=True, null=True)
+
+ owner = models.ForeignKey(User,
+ on_delete=models.CASCADE,
+ default=None,
+ blank=True,
+ null=True,
+ help_text='How is answering the assessment')
+ owner_email = models.CharField(max_length=150,
+ default=None,
+ blank=True,
+ null=True,
+ help_text='If there is not registered owner we can use the email as reference')
+ has_marketing_consent = models.BooleanField(default=False)
+ conversion_info = models.JSONField(default=None,
+ blank=True,
+ null=True,
+ help_text='UTMs and other conversion information.')
+ phone_regex = RegexValidator(
+ regex=r'^\+?1?\d{9,15}$',
+ message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed.")
+ owner_phone = models.CharField(validators=[phone_regex], max_length=17, blank=True,
+ default='') # validators should be a list
total_score = models.FloatField(help_text='Total sum of all chosen options in the assesment')
opened = models.BooleanField(default=False)
status = models.CharField(max_length=15, choices=SURVEY_STATUS, default=DRAFT)
+ status_text = models.TextField(default=None, blank=True, null=True)
+
+ token = models.CharField(max_length=255, unique=True, help_text='Auto-generated when a user assignment is created')
comment = models.CharField(max_length=255, default=None, blank=True, null=True)
@@ -177,16 +270,56 @@ class UserAssessment(models.Model):
created_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._old_status = self.status
+
+ def save(self, *args, **kwargs):
+ if not self.pk:
+ self.token = binascii.hexlify(os.urandom(20)).decode()
+
+ # Answer is being closed
+ if self.status != self._old_status:
+ signals.userassessment_status_updated.send(instance=self, sender=self.__class__)
+
+ return super().save(*args, **kwargs)
+
+ def get_score(self):
+
+ total_score = 0
+ answers = self.answer_set.all().order_by('created_at')
+ last_one = None
+ for a in answers:
+ last_one = a
+
+ # Ignore open text questions
+ if a.question.question_type == 'TEXT':
+ continue
+ if a.option: a.value = str(a.option.score)
+
+ try:
+ total_score += float(a.value)
+ except ValueError:
+ pass
+
+ return total_score, last_one
+
+ def __str__(self):
+ return self.title
+
class Answer(models.Model):
- user_assesment = models.ForeignKey(UserAssessment, on_delete=models.CASCADE, default=None, blank=True, null=True)
- option = models.ForeignKey(Option,
- on_delete=models.CASCADE,
- default=None,
- blank=True,
- null=True,
- help_text='Will be null if open question, no options to pick')
+ user_assessment = models.ForeignKey(UserAssessment, on_delete=models.CASCADE, default=None, blank=True, null=True)
+
+ # Do not implement many-to-many, its better to have many answers, one for each selected option
+ option = models.ForeignKey(
+ Option,
+ on_delete=models.SET_NULL,
+ default=None,
+ blank=True,
+ null=True,
+ help_text='Will be null if open question, no options to pick. Or if option was deleted historically')
question = models.ForeignKey(Question, on_delete=models.CASCADE, default=None, blank=True, null=True)
value = models.TextField()
diff --git a/breathecode/assessment/receivers.py b/breathecode/assessment/receivers.py
new file mode 100644
index 000000000..d2b967078
--- /dev/null
+++ b/breathecode/assessment/receivers.py
@@ -0,0 +1,17 @@
+import logging
+from typing import Any, Type
+from breathecode.admissions.signals import syllabus_asset_slug_updated
+from .signals import userassessment_status_updated
+from .models import UserAssessment
+from .tasks import async_close_userassignment
+from django.dispatch import receiver
+from breathecode.assignments import tasks
+
+logger = logging.getLogger(__name__)
+
+
+@receiver(userassessment_status_updated, sender=UserAssessment)
+def userassessment_status_updated(sender: Type[UserAssessment], instance: UserAssessment, **kwargs: Any):
+ logger.info('Processing userassessment_status_updated: ' + str(instance.id))
+ if instance.status == 'ANSWERED':
+ async_close_userassignment.delay(instance.id)
diff --git a/breathecode/assessment/serializers.py b/breathecode/assessment/serializers.py
index 501928961..ac5a2a2eb 100644
--- a/breathecode/assessment/serializers.py
+++ b/breathecode/assessment/serializers.py
@@ -1,7 +1,13 @@
from breathecode.utils import serpy
-from .models import Assessment, Question, Option
+from breathecode.utils.i18n import translation
+from django.contrib.auth.models import AnonymousUser
+from breathecode.admissions.models import Academy
+from .models import Assessment, Question, Option, UserAssessment, Answer
+from breathecode.marketing.models import AcademyAlias
from rest_framework import serializers
-
+from django.utils import timezone
+from breathecode.utils.datetime_integer import from_now, duration_to_str
+from capyc.rest_framework.exceptions import ValidationException
class UserSerializer(serpy.Serializer):
id = serpy.Field()
@@ -9,11 +15,47 @@ class UserSerializer(serpy.Serializer):
last_name = serpy.Field()
+class AcademySmallSerializer(serpy.Serializer):
+ id = serpy.Field()
+ slug = serpy.Field()
+ name = serpy.Field()
+
+
class AssessmentSmallSerializer(serpy.Serializer):
+ id = serpy.Field()
slug = serpy.Field()
title = serpy.Field()
+class QuestionSmallSerializer(serpy.Serializer):
+ id = serpy.Field()
+ title = serpy.Field()
+ question_type = serpy.Field()
+ is_deleted = serpy.Field()
+ position = serpy.Field()
+
+
+class OptionSmallSerializer(serpy.Serializer):
+ id = serpy.Field()
+ title = serpy.Field()
+ score = serpy.Field()
+
+
+class AnswerSmallSerializer(serpy.Serializer):
+ id = serpy.Field()
+ option = OptionSmallSerializer()
+ question = QuestionSmallSerializer()
+ value = serpy.Field()
+
+
+class GetAssessmentLayoutSerializer(serpy.Serializer):
+ slug = serpy.Field()
+ additional_styles = serpy.Field()
+ variables = serpy.Field()
+ created_at = serpy.Field()
+ academy = AcademySmallSerializer()
+
+
class GetAssessmentThresholdSerializer(serpy.Serializer):
success_next = serpy.Field()
fail_next = serpy.Field()
@@ -58,6 +100,85 @@ def get_translations(self, obj):
return [t.lang for t in obj.translations.all()]
+class SmallUserAssessmentSerializer(serpy.Serializer):
+ id = serpy.Field()
+ title = serpy.Field()
+
+ assessment = AssessmentSmallSerializer()
+
+ owner = UserSerializer(required=False)
+ owner_email = serpy.Field()
+
+ total_score = serpy.Field()
+
+ started_at = serpy.Field()
+ finished_at = serpy.Field()
+
+ created_at = serpy.Field()
+
+
+class GetUserAssessmentSerializer(serpy.Serializer):
+ id = serpy.Field()
+ token = serpy.Field()
+ title = serpy.Field()
+ lang = serpy.Field()
+
+ academy = AcademySmallSerializer(required=False)
+ assessment = AssessmentSmallSerializer()
+
+ owner = UserSerializer(required=False)
+ owner_email = serpy.Field()
+ owner_phone = serpy.Field()
+
+ status = serpy.Field()
+ status_text = serpy.Field()
+
+ conversion_info = serpy.Field()
+ total_score = serpy.Field()
+ comment = serpy.Field()
+
+ started_at = serpy.Field()
+ finished_at = serpy.Field()
+
+ created_at = serpy.Field()
+
+
+class PublicUserAssessmentSerializer(serpy.Serializer):
+ id = serpy.Field()
+ token = serpy.Field()
+ title = serpy.Field()
+ lang = serpy.Field()
+
+ academy = AcademySmallSerializer(required=False)
+ assessment = AssessmentSmallSerializer()
+
+ owner = UserSerializer(required=False)
+ owner_email = serpy.Field()
+ owner_phone = serpy.Field()
+
+ status = serpy.Field()
+ status_text = serpy.Field()
+
+ conversion_info = serpy.Field()
+ comment = serpy.Field()
+
+ started_at = serpy.Field()
+ finished_at = serpy.Field()
+
+ created_at = serpy.Field()
+
+ summary = serpy.MethodField()
+
+ def get_summary(self, obj):
+ total_score, last_one = obj.get_score()
+
+ last_answer = None
+ if last_one is not None:
+ last_answer = AnswerSmallSerializer(last_one).data
+
+ return {'last_answer': last_answer, 'live_score': total_score}
+
+
class GetAssessmentBigSerializer(GetAssessmentSerializer):
questions = serpy.MethodField()
is_instant_feedback = serpy.Field()
@@ -81,8 +202,188 @@ class Meta:
exclude = ('created_at', 'updated_at', 'assessment')
+class AnswerSerializer(serializers.ModelSerializer):
+ token = serializers.CharField()
+
+ class Meta:
+ model = Answer
+ exclude = ('created_at', 'updated_at')
+
+ def validate(self, data):
+
+ lang = self.context['lang']
+ validated_data = {**data}
+ del validated_data['token']
+
+ uass = UserAssessment.objects.filter(token=data['token']).first()
+ if not uass:
+ raise ValidationException(
+ translation(lang,
+ en=f'user assessment not found for this token',
+ es=f'No se han encontrado un user assessment con ese token',
+ slug='not-found'))
+ validated_data['user_assessment'] = uass
+
+ now = timezone.now()
+ session_duration = uass.created_at
+ max_duration = uass.created_at + uass.assessment.max_session_duration
+ if now > max_duration:
+ raise ValidationException(
+ f'User assessment session started {from_now(session_duration)} ago and it expires after {duration_to_str(uass.assessment.max_session_duration)}, no more updates can be made'
+ )
+
+ if 'option' in data and data['option']:
+ if Answer.objects.filter(option=data['option'], user_assessment=uass).count() > 0:
+ raise ValidationException(
+ translation(lang,
+ en=f'This answer has already been answered on this user assessment',
+ es=f'Esta opción ya fue respondida para este assessment',
+ slug='already-answered'))
+
+ return super().validate(validated_data)
+
+ def create(self, validated_data):
+
+ # copy the validated data just to do small last minute corrections
+ data = validated_data.copy()
+
+ if 'option' in data and data['option']:
+ data['question'] = data['option'].question
+
+ if data['question'].question_type == 'SELECT':
+ data['value'] = data['option'].score
+
+ return super().create({**data})
+
+
class AssessmentPUTSerializer(serializers.ModelSerializer):
class Meta:
model = Assessment
exclude = ('slug', 'academy', 'lang', 'author')
+
+
+class PostUserAssessmentSerializer(serializers.ModelSerializer):
+ owner_email = serializers.EmailField(required=False)
+
+ class Meta:
+ model = UserAssessment
+ exclude = ('total_score', 'created_at', 'updated_at', 'token', 'owner')
+ read_only_fields = ['id']
+
+ def validate(self, data):
+
+ lang = self.context['lang']
+ request = self.context['request']
+
+ if 'status' in data and data['status'] not in ['DRAFT', 'SENT']:
+ raise ValidationException(
+ translation(lang,
+ en=f'User assessment cannot be created with status {data["status"]}',
+ es=f'El user assessment no se puede crear con status {data["status"]}',
+ slug='invalid-status'))
+
+ academy = None
+ if 'Academy' in request.headers:
+ academy_id = request.headers['Academy']
+ academy = Academy.objects.filter(id=academy_id).first()
+
+ if not academy and 'academy' in data:
+ academy = data['academy']
+
+ if not academy and 'assessment' in data:
+ academy = data['assessment'].academy
+
+ if not academy:
+ raise ValidationException(
+ translation(lang,
+ en=f'Could not determine academy ownership of this user assessment',
+ es=f'No se ha podido determinar a que academia pertenece este user assessment',
+ slug='not-academy-detected'))
+
+ if not isinstance(request.user, AnonymousUser):
+ data['owner'] = request.user
+ elif 'owner_email' not in data or not data['owner_email']:
+ raise ValidationException(
+ translation(lang,
+ en=f'User assessment cannot be tracked because its missing owner information',
+ es=f'Este user assessment no puede registrarse porque no tiene informacion del owner',
+ slug='no-owner-detected'))
+
+ return super().validate({**data, 'academy': academy})
+
+ def create(self, validated_data):
+
+ # copy the validated data just to do small last minute corrections
+ data = validated_data.copy()
+
+ if data['academy'] is None:
+ data['status'] = 'ERROR'
+ data['status_text'] = 'Missing academy. Maybe the assessment.academy is null?'
+
+ # "us" language will become "en" language, its the right lang code
+ if 'lang' in data and data['lang'] == 'us':
+ data['lang'] = 'en'
+
+ if 'started_at' not in data or data['started_at'] is None:
+ data['started_at'] = timezone.now()
+
+ if 'title' not in data or not data['title']:
+ if 'owner_email' in data and data['owner_email']:
+ data['title'] = f"{data['assessment'].title} from {data['owner_email']}"
+ if 'owner' in data and data['owner']:
+ data['title'] = f"{data['assessment'].title} from {data['owner'].email}"
+
+ result = super().create({**data, 'total_score': 0, 'academy': validated_data['academy']})
+ return result
+
+
+class PUTUserAssessmentSerializer(serializers.ModelSerializer):
+ title = serializers.CharField(required=False)
+
+ class Meta:
+ model = UserAssessment
+ exclude = ('academy', 'assessment', 'lang', 'total_score', 'token', 'started_at', 'owner')
+ read_only_fields = [
+ 'id',
+ 'academy',
+ ]
+
+ def validate(self, data):
+
+ lang = self.context['lang']
+
+ if self.instance.status not in ['DRAFT', 'SENT', 'ERROR']:
+ raise ValidationException(
+ translation(lang,
+ en=f'User assessment cannot be updated because is {self.instance.status}',
+ es=f'El user assessment status no se puede editar mas porque esta {elf.instance.status}',
+ slug='invalid-status'))
+
+ return super().validate({**data})
+
+ def update(self, instance, validated_data):
+
+ # NOTE: User Assignments that are closed will be automatically scored with assessment.task.async_close_userassignment
+ now = timezone.now()
+ data = validated_data.copy()
+
+ # If not being closed
+ if validated_data['status'] != 'ANSWERED' or instance.status == validated_data['status']:
+ if now > (instance.created_at + instance.assessment.max_session_duration):
+ raise ValidationException(
+ f'Session started {from_now(instance.created_at)} ago and it expires after {duration_to_str(instance.assessment.max_session_duration)}, no more updates can be made'
+ )
+
+ # copy the validated data just to do small last minute corrections
+ data = validated_data.copy()
+ if 'status_text' in data: del data['status_text']
+
+ # "us" language will become "en" language, its the right lang code
+ if 'lang' in data and data['lang'] == 'us':
+ data['lang'] = 'en'
+
+ if 'started_at' not in data and instance.started_at is None:
+ data['started_at'] = now
+
+ return super().update(instance, data)
diff --git a/breathecode/assessment/signals.py b/breathecode/assessment/signals.py
new file mode 100644
index 000000000..31234556e
--- /dev/null
+++ b/breathecode/assessment/signals.py
@@ -0,0 +1,8 @@
+"""
+For each signal you want other apps to be able to receive, you have to
+declare a new variable here like this:
+"""
+from django import dispatch
+
+assessment_updated = dispatch.Signal()
+userassessment_status_updated = dispatch.Signal()
diff --git a/breathecode/assessment/tasks.py b/breathecode/assessment/tasks.py
new file mode 100644
index 000000000..4728ebe39
--- /dev/null
+++ b/breathecode/assessment/tasks.py
@@ -0,0 +1,38 @@
+import logging
+import os
+import re
+
+from celery import shared_task
+
+import breathecode.notify.actions as actions
+from .models import UserAssessment
+from breathecode.utils import TaskPriority
+
+# Get an instance of a logger
+logger = logging.getLogger(__name__)
+
+
+@shared_task(bind=True, priority=TaskPriority.ASSESSMENT.value)
+def async_close_userassignment(self, ua_id):
+ """Notify if the task was change."""
+ logger.info('Starting async_close_userassignment')
+
+ ua = UserAssessment.objects.filter(id=ua_id).first()
+ if not ua:
+ return False
+
+ score, last_answer = ua.get_score()
+
+ # Not one answer found for the user assessment
+ if last_answer is None:
+ ua.status = 'ERROR'
+ ua.status_text = 'No answers found for this user assessment session'
+ ua.save()
+ return True
+
+ ua.total_score = score
+ ua.status = 'ANSWERED'
+ ua.status_text = ''
+ ua.finished_at = last_answer.created_at
+ ua.save()
+ return True
diff --git a/breathecode/assessment/urls.py b/breathecode/assessment/urls.py
index 9dda864f5..0e49347c3 100644
--- a/breathecode/assessment/urls.py
+++ b/breathecode/assessment/urls.py
@@ -1,14 +1,24 @@
from django.urls import path
-from .views import (track_assesment_open, GetAssessmentView, GetThresholdView, AssessmentQuestionView,
- AssessmentOptionView)
+from .views import (TrackAssessmentView, GetAssessmentView, GetThresholdView, AssessmentQuestionView,
+ AssessmentOptionView, AcademyUserAssessmentView, AssessmentLayoutView, AcademyAssessmentLayoutView,
+ AnswerView, AcademyAnswerView)
app_name = 'assessment'
urlpatterns = [
# user assessments
- path('user/assesment//tracker.png', track_assesment_open),
+ path('user/assessment', TrackAssessmentView.as_view()),
+ path('user/assessment/', TrackAssessmentView.as_view()),
+ path('user/assessment//answer', AnswerView.as_view()),
+ path('user/assessment//answer/', AnswerView.as_view()),
+ path('academy/user/assessment//answer/', AcademyAnswerView.as_view()),
+ path('academy/user/assessment', AcademyUserAssessmentView.as_view()),
+ path('academy/user/assessment/', AcademyUserAssessmentView.as_view()),
path('', GetAssessmentView.as_view()),
+ path('layout/', AssessmentLayoutView.as_view()),
+ path('academy/layout', AcademyAssessmentLayoutView.as_view()),
+ path('academy/layout/', AcademyAssessmentLayoutView.as_view()),
path('/threshold', GetThresholdView.as_view()),
- path('', GetAssessmentView.as_view()),
path('/question/', AssessmentQuestionView.as_view()),
path('/option/', AssessmentOptionView.as_view()),
+ path('', GetAssessmentView.as_view()),
]
diff --git a/breathecode/assessment/views.py b/breathecode/assessment/views.py
index ae052742c..309ab08ec 100644
--- a/breathecode/assessment/views.py
+++ b/breathecode/assessment/views.py
@@ -6,13 +6,17 @@
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
+from breathecode.utils import (
+ APIViewExtensions,
+ GenerateLookupsMixin,
+)
from breathecode.authenticate.actions import get_user_language
from breathecode.utils import capable_of
from breathecode.utils.i18n import translation
from capyc.rest_framework.exceptions import ValidationException
-from .models import Assessment, AssessmentThreshold, Option, Question, UserAssessment
+from .models import Assessment, AssessmentThreshold, Option, Question, UserAssessment, Answer, AssessmentLayout
from .serializers import (
AssessmentPUTSerializer,
GetAssessmentBigSerializer,
@@ -20,23 +24,61 @@
GetAssessmentThresholdSerializer,
OptionSerializer,
QuestionSerializer,
+ GetAssessmentLayoutSerializer,
+ SmallUserAssessmentSerializer,
+ GetUserAssessmentSerializer,
+ PostUserAssessmentSerializer,
+ PUTUserAssessmentSerializer,
+ AnswerSerializer,
+ AnswerSmallSerializer,
+ PublicUserAssessmentSerializer,
)
-@api_view(['GET'])
-@permission_classes([AllowAny])
-def track_assesment_open(request, user_assessment_id=None):
+class TrackAssessmentView(APIView, GenerateLookupsMixin):
+ """
+ List all snippets, or create a new snippet.
+ """
+ permission_classes = [AllowAny]
+
+ def get(self, request, ua_token):
+ lang = get_user_language(request)
+ now = timezone.now()
+
+ single = UserAssessment.objects.filter(token=ua_token).first()
+ if single is None or now > single.created_at + single.assessment.max_session_duration:
+ raise ValidationException(translation(lang,
+ en=f'User assessment session does not exist or has already expired',
+ es=f'Esta sessión de evaluación no existe o ya ha expirado',
+ slug='not-found'),
+ code=404)
- ass = UserAssessment.objects.filter(id=user_assessment_id, status='SENT').first()
- if ass is not None:
- ass.status = 'OPENED'
- ass.opened_at = timezone.now()
- ass.save()
+ serializer = PublicUserAssessmentSerializer(single, many=False)
+ return Response(serializer.data, status=status.HTTP_200_OK)
- image = Image.new('RGB', (1, 1))
- response = HttpResponse(content_type='image/png')
- image.save(response, 'PNG')
- return response
+ def put(self, request, ua_token):
+ lang = get_user_language(request)
+ ass = UserAssessment.objects.filter(token=ua_token).first()
+ if not ass:
+ raise ValidationException('User Assessment not found', 404)
+
+ serializer = PUTUserAssessmentSerializer(ass, data=request.data, context={'request': request, 'lang': lang})
+ if serializer.is_valid():
+ serializer.save()
+ serializer = GetUserAssessmentSerializer(serializer.instance)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+ def post(self, request):
+
+ lang = get_user_language(request)
+ payload = request.data.copy()
+ serializer = PostUserAssessmentSerializer(data=payload, context={'request': request, 'lang': lang})
+ if serializer.is_valid():
+ serializer.save()
+ serializer = GetUserAssessmentSerializer(serializer.instance)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class GetAssessmentView(APIView):
@@ -52,9 +94,9 @@ def get(self, request, assessment_slug=None):
if 'lang' in self.request.GET:
lang = self.request.GET.get('lang')
- item = Assessment.objects.filter(slug=assessment_slug).first()
+ item = Assessment.objects.filter(slug=assessment_slug, is_archived=False).first()
if item is None:
- raise ValidationException('Assessment not found', 404)
+ raise ValidationException('Assessment not found or its archived', 404)
if lang is not None and item.lang != lang:
item = item.translations.filter(lang=lang).first()
@@ -76,6 +118,10 @@ def get(self, request, assessment_slug=None):
param = self.request.GET.get('lang')
lookup['lang'] = param
+ # user can specify include_archived on querystring to include archived assessments
+ if not 'include_archived' in self.request.GET or self.request.GET.get('include_archived') != 'true':
+ lookup['is_archived'] = False
+
if 'no_asset' in self.request.GET and self.request.GET.get('no_asset').lower() == 'true':
lookup['asset__isnull'] = True
@@ -93,13 +139,14 @@ def put(self, request, assessment_slug=None, academy_id=None):
lang = get_user_language(request)
- _assessment = Assessment.objects.filter(slug=assessment_slug, academy__id=academy_id).first()
+ _assessment = Assessment.objects.filter(slug=assessment_slug, academy__id=academy_id, is_archived=False).first()
if _assessment is None:
raise ValidationException(
- translation(lang,
- en=f'Assessment {assessment_slug} not found for academy {academy_id}',
- es=f'La evaluación {assessment_slug} no se encontró para la academia {academy_id}',
- slug='not-found'))
+ translation(
+ lang,
+ en=f'Assessment {assessment_slug} not found or its archived for academy {academy_id}',
+ es=f'La evaluación {assessment_slug} no se encontró o esta archivada para la academia {academy_id}',
+ slug='not-found'))
all_serializers = []
assessment_serializer = AssessmentPUTSerializer(_assessment,
@@ -201,6 +248,71 @@ def put(self, request, assessment_slug=None, academy_id=None):
return Response(assessment_serializer.data, status=status.HTTP_200_OK)
+class AssessmentLayoutView(APIView):
+ """
+ List all snippets, or create a new snippet.
+ """
+ permission_classes = [AllowAny]
+
+ def get(self, request, layout_slug):
+
+ item = AssessmentLayout.objects.filter(slug=layout_slug).first()
+ if item is None:
+ raise ValidationException('Assessment layout not found', 404)
+ serializer = GetAssessmentLayoutSerializer(item, many=False)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+
+class AcademyAssessmentLayoutView(APIView):
+ """
+ List all snippets, or create a new snippet.
+ """
+ permission_classes = [AllowAny]
+
+ @capable_of('read_assessment')
+ def get(self, request, academy_id, layout_slug=None):
+
+ if layout_slug:
+ item = AssessmentLayout.objects.filter(slug=layout_slug, academy__id=academy_id).first()
+ if item is None:
+ raise ValidationException('Assessment layout not found for this academy', 404)
+ serializer = GetAssessmentLayoutSerializer(items)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ # get original all assessments (assessments that have no parent)
+ items = AssessmentLayout.objects.filter(academy__id=academy_id)
+ lookup = {}
+
+ # if 'academy' in self.request.GET:
+ # param = self.request.GET.get('academy')
+ # lookup['academy__isnull'] = True
+
+ items = items.filter(**lookup).order_by('-created_at')
+
+ serializer = GetAssessmentLayoutSerializer(items, many=True)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+
+@api_view(['POST'])
+@permission_classes([AllowAny])
+def create_public_assessment(request):
+ data = request.data.copy()
+
+ # remove spaces from phone
+ if 'phone' in data:
+ data['phone'] = data['phone'].replace(' ', '')
+
+ serializer = PostFormEntrySerializer(data=data)
+ if serializer.is_valid():
+ serializer.save()
+
+ persist_single_lead.delay(serializer.data)
+
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+
class AssessmentOptionView(APIView):
@capable_of('crud_assessment')
@@ -293,3 +405,247 @@ def get(self, request, assessment_slug):
serializer = GetAssessmentThresholdSerializer(items, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
+
+
+class AcademyUserAssessmentView(APIView, GenerateLookupsMixin):
+ """
+ List all snippets, or create a new snippet.
+ """
+
+ extensions = APIViewExtensions(sort='-created_at', paginate=True)
+
+ @capable_of('read_user_assessment')
+ def get(self, request, academy_id=None, ua_id=None):
+ handler = self.extensions(request)
+
+ if ua_id is not None:
+ single = UserAssessment.objects.filter(id=ua_id, academy__id=academy_id).first()
+ if single is None:
+ raise ValidationException(f'UserAssessment {ua_id} not found', 404, slug='user-assessment-not-found')
+
+ serializer = GetUserAssessmentSerializer(single, many=False)
+ return handler.response(serializer.data)
+
+ items = UserAssessment.objects.filter(academy__id=academy_id)
+ lookup = {}
+
+ start = request.GET.get('started_at', None)
+ if start is not None:
+ start_date = datetime.datetime.strptime(start, '%Y-%m-%d').date()
+ lookup['started_at__gte'] = start_date
+
+ end = request.GET.get('finished_at', None)
+ if end is not None:
+ end_date = datetime.datetime.strptime(end, '%Y-%m-%d').date()
+ lookup['finished_at__lte'] = end_date
+
+ if 'status' in self.request.GET:
+ param = self.request.GET.get('status')
+ lookup['status'] = param
+
+ if 'opened' in self.request.GET:
+ param = self.request.GET.get('opened')
+ lookup['opened'] = param == 'true'
+
+ if 'course' in self.request.GET:
+ param = self.request.GET.get('course')
+ lookup['course__in'] = [x.strip() for x in param.split(',')]
+
+ if 'owner' in self.request.GET:
+ param = self.request.GET.get('owner')
+ lookup['owner__id'] = param
+ elif 'owner_email' in self.request.GET:
+ param = self.request.GET.get('owner_email')
+ lookup['owner_email'] = param
+
+ if 'lang' in self.request.GET:
+ param = self.request.GET.get('lang')
+ lookup['lang'] = param
+
+ items = items.filter(**lookup)
+ items = handler.queryset(items)
+
+ serializer = SmallUserAssessmentSerializer(items, many=True)
+ return handler.response(serializer.data)
+
+ @capable_of('crud_user_assessment')
+ def post(self, request, academy_id=None):
+
+ academy = Academy.objects.filter(id=academy_id).first()
+ if academy is None:
+ raise ValidationException(f'Academy {academy_id} not found', slug='academy-not-found')
+
+ # ignore the incoming location information and override with the session academy
+ data = {**request.data, 'location': academy.active_campaign_slug}
+
+ serializer = PostFormEntrySerializer(data=data, context={'request': request, 'academy': academy_id})
+ if serializer.is_valid():
+ serializer.save()
+ big_serializer = FormEntryBigSerializer(serializer.instance)
+ return Response(big_serializer.data, status=status.HTTP_201_CREATED)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+
+class AcademyAnswerView(APIView, GenerateLookupsMixin):
+ """
+ List all snippets, or create a new snippet.
+ """
+
+ extensions = APIViewExtensions(sort='-created_at', paginate=True)
+
+ @capable_of('read_user_assessment')
+ def get(self, request, academy_id, ua_id=None, answer_id=None):
+ handler = self.extensions(request)
+
+ if answer_id is not None:
+ single = Answer.objects.filter(id=answer_id,
+ user_assessment__id=ua_id,
+ user_assessment__academy__id=academy_id).first()
+ if single is None:
+ raise ValidationException(f'Answer {answer_id} not found on user assessment {ua_id}',
+ 404,
+ slug='answer-not-found')
+
+ serializer = AnswerSmallSerializer(single, many=False)
+ return handler.response(serializer.data)
+
+ items = Answer.objects.filter(assess, user_assessment__academy__id=academy_id)
+ lookup = {}
+
+ start = request.GET.get('starting_at', None)
+ if start is not None:
+ start_date = datetime.datetime.strptime(start, '%Y-%m-%d').date()
+ lookup['created_at__gte'] = start_date
+
+ end = request.GET.get('ending_at', None)
+ if end is not None:
+ end_date = datetime.datetime.strptime(end, '%Y-%m-%d').date()
+ lookup['created_at__lte'] = end_date
+
+ if 'user_assessments' in self.request.GET:
+ param = self.request.GET.get('user_assessments')
+ lookup['user_assessment__id__in'] = [x.strip() for x in param.split(',')]
+
+ if 'assessments' in self.request.GET:
+ param = self.request.GET.get('assessments')
+ lookup['question__assessment__id__in'] = [x.strip() for x in param.split(',')]
+
+ if 'questions' in self.request.GET:
+ param = self.request.GET.get('questions')
+ lookup['question__id__in'] = [x.strip() for x in param.split(',')]
+
+ if 'options' in self.request.GET:
+ param = self.request.GET.get('options')
+ lookup['option__id__in'] = [x.strip() for x in param.split(',')]
+
+ if 'owner' in self.request.GET:
+ param = self.request.GET.get('owner')
+ lookup['user_assessments__owner__id__in'] = [x.strip() for x in param.split(',')]
+
+ elif 'owner_email' in self.request.GET:
+ param = self.request.GET.get('owner_email')
+ lookup['owner_email'] = param
+
+ if 'lang' in self.request.GET:
+ param = self.request.GET.get('lang')
+ lookup['user_assessments__lang'] = param
+
+ items = items.filter(**lookup)
+ items = handler.queryset(items)
+
+ serializer = AnswerSmallSerializer(items, many=True)
+ return handler.response(serializer.data)
+
+
+class AnswerView(APIView, GenerateLookupsMixin):
+ """
+ List all snippets, or create a new snippet.
+ """
+ permission_classes = [AllowAny]
+
+ extensions = APIViewExtensions(sort='-created_at', paginate=True)
+
+ def get(self, request, token, answer_id=None):
+ handler = self.extensions(request)
+ lang = get_user_language(request)
+
+ if answer_id is not None:
+ single = Answer.objects.filter(id=answer_id, user_assessment__token=token).first()
+ if single is None:
+ raise ValidationException(f'Answer {answer_id} not found on user assessment',
+ 404,
+ slug='answer-not-found')
+
+ serializer = AnswerSmallSerializer(single, many=False)
+ return handler.response(serializer.data)
+
+ items = Answer.objects.filter(user_assessment__token=token)
+ lookup = {}
+
+ if 'questions' in self.request.GET:
+ param = self.request.GET.get('questions')
+ lookup['question__id__in'] = [x.strip() for x in param.split(',')]
+
+ if 'options' in self.request.GET:
+ param = self.request.GET.get('options')
+ lookup['option__id__in'] = [x.strip() for x in param.split(',')]
+
+ items = items.filter(**lookup)
+ items = handler.queryset(items)
+
+ serializer = AnswerSmallSerializer(items, many=True)
+ return handler.response(serializer.data)
+
+ def post(self, request, token):
+
+ lang = get_user_language(request)
+
+ data = {
+ **request.data,
+ 'token': token,
+ }
+ serializer = AnswerSerializer(data=data, context={'request': request, 'lang': lang})
+ if serializer.is_valid():
+ serializer.save()
+ big_serializer = AnswerSmallSerializer(serializer.instance)
+ return Response(big_serializer.data, status=status.HTTP_201_CREATED)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+ def delete(self, request, token, answer_id=None):
+
+ lang = get_user_language(request)
+ lookups = self.generate_lookups(request, many_fields=['id'])
+
+ if lookups and answer_id:
+ raise ValidationException(
+ translation(
+ lang,
+ en='answer_id must not be provided by url if deleting in bulk',
+ es='El answer_id no debe ser enviado como parte del path si se quiere una eliminacion masiva',
+ slug='bulk-querystring'))
+
+ uass = UserAssessment.objects.filter(token=token).first()
+ if not uass:
+ raise ValidationException(
+ translation(lang,
+ en=f'user assessment not found for this token',
+ es=f'No se han encontrado un user assessment con ese token',
+ slug='not-found'))
+
+ if lookups:
+ items = Answer.objects.filter(**lookups, user_assessment=uass)
+
+ for item in items:
+ item.delete()
+
+ return Response(None, status=status.HTTP_204_NO_CONTENT)
+
+ if answer_id is None:
+ raise ValidationException('Missing answer_id', code=400)
+
+ ans = Answer.objects.filter(id=answer_id, user_assessment=uass).first()
+ if ans is None:
+ raise ValidationException('Specified answer and token could not be found')
+
+ ans.delete()
+ return Response(None, status=status.HTTP_204_NO_CONTENT)
diff --git a/breathecode/assignments/models.py b/breathecode/assignments/models.py
index f6df567c8..4e3b874ce 100644
--- a/breathecode/assignments/models.py
+++ b/breathecode/assignments/models.py
@@ -116,9 +116,9 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._current_task_status = self.task_status
- def clean(self):
- if self.cohort is None:
- raise forms.ValidationError('Cohort is required')
+ # def clean(self):
+ # if self.cohort is None:
+ # raise forms.ValidationError('Cohort is required')
def save(self, *args, **kwargs):
# check the fields before saving
diff --git a/breathecode/assignments/serializers.py b/breathecode/assignments/serializers.py
index 07e3a3cf1..5403a0984 100644
--- a/breathecode/assignments/serializers.py
+++ b/breathecode/assignments/serializers.py
@@ -227,7 +227,7 @@ def validate(self, data):
return data
def update(self, instance, validated_data):
- if 'opened_at' in validated_data and (instance.opened_at is None
+ if 'opened_at' in validated_data and validated_data['opened_at'] is not None and (instance.opened_at is None
or validated_data['opened_at'] > instance.opened_at):
tasks_activity.add_activity.delay(self.context['request'].user.id,
'read_assignment',
diff --git a/breathecode/authenticate/management/commands/create_academy_roles.py b/breathecode/authenticate/management/commands/create_academy_roles.py
index 55231b798..509a4f802 100644
--- a/breathecode/authenticate/management/commands/create_academy_roles.py
+++ b/breathecode/authenticate/management/commands/create_academy_roles.py
@@ -424,6 +424,10 @@
'slug': 'crud_assessment',
'description': 'Manage student quizzes and assessments'
},
+ {
+ 'slug': 'read_user_assessment',
+ 'description': 'Read user assessment submissions'
+ },
]
ROLES = [
@@ -626,7 +630,7 @@ def extend_roles(roles: list[RoleType]) -> None:
extend(roles, ['staff']) + [
'read_assignment', 'crud_assignment', 'read_cohort_activity', 'read_nps_answers', 'classroom_activity',
'read_event', 'read_event_type', 'task_delivery_details', 'crud_cohort', 'read_cohort_log',
- 'crud_cohort_log', 'start_or_end_class', 'start_or_end_event'
+ 'crud_cohort_log', 'start_or_end_class', 'start_or_end_event', 'read_user_assessment'
]
})
roles.append({
@@ -711,6 +715,7 @@ def extend_roles(roles: list[RoleType]) -> None:
'crud_media',
'read_activity',
'read_lead',
+ 'read_user_assessment',
'read_won_lead',
'crud_review',
'crud_shortlink',
diff --git a/breathecode/marketing/models.py b/breathecode/marketing/models.py
index c47cc1a16..7ed6b4587 100644
--- a/breathecode/marketing/models.py
+++ b/breathecode/marketing/models.py
@@ -496,6 +496,9 @@ def save(self, *args, **kwargs):
if deal_status_modified: form_entry_won_or_lost.send(instance=self, sender=FormEntry)
if is_new_deal: new_form_entry_deal.send(instance=self, sender=FormEntry)
+ self.__old_deal_status = self.deal_status
+ self.__old_deal_id = self.ac_deal_id
+
def is_duplicate(self, incoming_lead):
duplicate_leads_delta_avoidance = timedelta(minutes=30)
if self.academy is not None and self.academy.activecampaignacademy is not None:
diff --git a/breathecode/registry/actions.py b/breathecode/registry/actions.py
index 1c7cae6bd..9e66babee 100644
--- a/breathecode/registry/actions.py
+++ b/breathecode/registry/actions.py
@@ -132,7 +132,7 @@ def pull_from_github(asset_slug, author_id=None, override_meta=False):
elif asset.asset_type in ['QUIZ']:
asset = pull_quiz_asset(g, asset)
else:
- asset = pull_learnpack_asset(g, asset, override_meta=override_meta)
+ asset = pull_learnpack_asset(g, asset, override_meta=True)
asset.status_text = 'Successfully Synched'
asset.sync_status = 'OK'
@@ -142,8 +142,9 @@ def pull_from_github(asset_slug, author_id=None, override_meta=False):
return asset
except Exception as e:
+ logger.exception(e)
message = ''
- if hasattr(e, 'data'):
+ if hasattr(e, 'data') and e.data:
message = e.data['message']
else:
message = str(e).replace('"', '\'')
@@ -204,7 +205,7 @@ def push_to_github(asset_slug, author=None):
return asset
except Exception as e:
- # raise e
+ logger.exception(e)
message = ''
if hasattr(e, 'data'):
message = e.data['message']
@@ -291,10 +292,19 @@ def push_github_asset(github, asset: Asset):
branch, file_path = result.groups()
logger.debug(f'Fetching readme: {file_path}')
- # we commit the raw readme, we don't want images to be replaced in the original github
- decoded_readme = base64.b64decode(asset.readme_raw.encode('utf-8')).decode('utf-8')
+ decoded_readme = None
+ if asset.asset_type in ['LESSON', 'ARTICLE']:
+ # we commit the raw readme, we don't want images to be replaced in the original github
+ decoded_readme = base64.b64decode(asset.readme_raw.encode('utf-8')).decode('utf-8')
+
+ elif asset.asset_type == 'QUIZ':
+ decoded_readme = json.dumps(asset.config, indent=4)
+
+ else:
+ raise Exception(f'Assets with type {asset.asset_type} cannot be commited to Github')
+
if decoded_readme is None or decoded_readme == 'None' or decoded_readme == '':
- raise Exception('The markdown content you are trying to push to Github is empty')
+ raise Exception('The content you are trying to push to Github is empty')
result = set_blob_content(repo, file_path, decoded_readme, file_name, branch=branch)
if 'commit' in result:
@@ -351,6 +361,9 @@ def pull_github_lesson(github, asset: Asset, override_meta=False):
if 'title' in fm and fm['title'] != '':
asset.title = fm['title']
+
+ if 'video' in fm and fm['video'] != '':
+ asset.intro_video_url = fm['video']
if 'authors' in fm and fm['authors'] != '':
asset.authors_username = ','.join(fm['authors'])
@@ -637,6 +650,107 @@ def create(self, delay=600):
return self.asset
+def process_asset_config(asset, config):
+
+ if not config:
+ raise Exception('No configuration json found')
+ # only replace title and description of English language
+ if 'title' in config:
+ if isinstance(config['title'], str):
+ if (lang == '' or asset.title == '' or asset.title is None):
+ asset.title = config['title']
+ elif isinstance(config['title'], dict) and asset.lang in config['title']:
+ asset.title = config['title'][asset.lang]
+
+ if 'description' in config:
+ if isinstance(config['description'], str):
+ # avoid replacing descriptions for other languages
+ if (lang == '' or asset.description == '' or asset.description is None):
+ asset.description = config['description']
+ # there are multiple translations, and the translation exists for this lang
+ elif isinstance(config['description'], dict) and asset.lang in config['description']:
+ asset.description = config['description'][asset.lang]
+
+ if 'preview' in config:
+ asset.preview = config['preview']
+ else:
+ raise Exception('Missing preview URL')
+
+ if 'video-id' in config:
+ asset.solution_video_url = get_video_url(str(config['video-id']))
+ asset.with_video = True
+
+ if 'video' in config and isinstance(config['video'], dict):
+ if 'intro' in config['video'] and config['video']['intro'] is not None:
+ if isinstance(config['video']['intro'], str):
+ asset.intro_video_url = get_video_url(str(config['video']['intro']))
+ else:
+ if 'en' in config['video']['intro']:
+ config['video']['intro']['us'] = config['video']['intro']['en']
+ elif 'us' in config['video']['intro']:
+ config['video']['intro']['en'] = config['video']['intro']['us']
+
+ if asset.lang in config['video']['intro']:
+ print('get_video_url', get_video_url(str(config['video']['intro'][asset.lang])))
+ asset.intro_video_url = get_video_url(str(config['video']['intro'][asset.lang]))
+
+ if 'solution' in config['video'] and config['video']['solution'] is not None:
+ if isinstance(config['video']['solution'], str):
+ asset.solution_video_url = get_video_url(str(config['video']['solution']))
+ asset.with_video = True
+ else:
+ if 'en' in config['video']['solution']:
+ config['video']['solution']['us'] = config['video']['solution']['en']
+ elif 'us' in config['video']['solution']:
+ config['video']['solution']['en'] = config['video']['solution']['us']
+
+ if asset.lang in config['video']['solution']:
+ asset.solution_video_url = get_video_url(str(config['video']['solution'][asset.lang]))
+ asset.with_video = True
+
+ if 'duration' in config:
+ asset.duration = config['duration']
+ if 'difficulty' in config:
+ asset.difficulty = config['difficulty'].upper()
+ if 'solution' in config:
+ asset.solution_url = config['solution']
+ asset.with_solutions = True
+
+ if 'projectType' in config:
+ asset.gitpod = config['projectType'] == 'tutorial'
+
+ if 'technologies' in config:
+ asset.technologies.clear()
+ for tech_slug in config['technologies']:
+ technology = AssetTechnology.get_or_create(tech_slug)
+ asset.technologies.add(technology)
+
+ if 'delivery' in config:
+ if 'instructions' in config['delivery']:
+ if isinstance(config['delivery']['instructions'], str):
+ asset.delivery_instructions = config['delivery']['instructions']
+ elif isinstance(config['delivery']['instructions'],
+ dict) and asset.lang in config['delivery']['instructions']:
+ asset.delivery_instructions = config['delivery']['instructions'][asset.lang]
+
+ if 'formats' in config['delivery']:
+ if isinstance(config['delivery']['formats'], list):
+ asset.delivery_formats = ','.join(config['delivery']['formats'])
+ elif isinstance(config['delivery']['formats'], str):
+ asset.delivery_formats = config['delivery']['formats']
+
+ if 'url' in asset.delivery_formats:
+ if 'regex' in config['delivery'] and isinstance(config['delivery']['regex'], str):
+ asset.delivery_regex_url = config['delivery']['regex'].replace('\\\\', '\\')
+ else:
+ asset.delivery_instructions = ''
+ asset.delivery_formats = 'url'
+ asset.delivery_regex_url = ''
+
+ asset.save()
+ return asset
+
+
def pull_learnpack_asset(github, asset: Asset, override_meta):
if asset.readme_url is None:
@@ -677,90 +791,12 @@ def pull_learnpack_asset(github, asset: Asset, override_meta):
base64_readme = str(readme_file.content)
asset.readme_raw = base64_readme
+ config = None
if learn_file is not None and (asset.last_synch_at is None or override_meta):
config = json.loads(learn_file.decoded_content.decode('utf-8'))
asset.config = config
- # only replace title and description of English language
- if 'title' in config:
- if isinstance(config['title'], str):
- if (lang == '' or asset.title == '' or asset.title is None):
- asset.title = config['title']
- elif isinstance(config['title'], dict) and asset.lang in config['title']:
- asset.title = config['title'][asset.lang]
-
- if 'description' in config:
- if isinstance(config['description'], str):
- # avoid replacing descriptions for other languages
- if (lang == '' or asset.description == '' or asset.description is None):
- asset.description = config['description']
- # there are multiple translations, and the translation exists for this lang
- elif isinstance(config['description'], dict) and asset.lang in config['description']:
- asset.description = config['description'][asset.lang]
-
- if 'preview' in config:
- asset.preview = config['preview']
- else:
- raise Exception('Missing preview URL')
-
- if 'video-id' in config:
- asset.solution_video_url = get_video_url(str(config['video-id']))
- asset.with_video = True
-
- if 'video' in config and isinstance(config['video'], dict):
- if 'intro' in config['video'] and config['video']['intro'] is not None:
- if isinstance(config['video']['intro'], str):
- asset.intro_video_url = get_video_url(str(config['video']['intro']))
- elif asset.lang in config['video']['intro']:
- asset.intro_video_url = get_video_url(str(config['video']['intro'][asset.lang]))
-
- if 'solution' in config['video'] and config['video']['solution'] is not None:
- if isinstance(config['video']['solution'], str):
- asset.solution_video_url = get_video_url(str(config['video']['solution']))
- asset.with_video = True
- elif asset.lang in config['video']['solution']:
- asset.solution_video_url = get_video_url(str(config['video']['solution'][asset.lang]))
- asset.with_video = True
-
- if 'duration' in config:
- asset.duration = config['duration']
- if 'difficulty' in config:
- asset.difficulty = config['difficulty'].upper()
- if 'solution' in config:
- asset.solution_url = config['solution']
- asset.with_solutions = True
-
- if 'projectType' in config:
- asset.gitpod = config['projectType'] == 'tutorial'
-
- if 'technologies' in config:
- asset.technologies.clear()
- for tech_slug in config['technologies']:
- technology = AssetTechnology.get_or_create(tech_slug)
- asset.technologies.add(technology)
-
- if 'delivery' in config:
- if 'instructions' in config['delivery']:
- if isinstance(config['delivery']['instructions'], str):
- asset.delivery_instructions = config['delivery']['instructions']
- elif isinstance(config['delivery']['instructions'],
- dict) and asset.lang in config['delivery']['instructions']:
- asset.delivery_instructions = config['delivery']['instructions'][asset.lang]
-
- if 'formats' in config['delivery']:
- if isinstance(config['delivery']['formats'], list):
- asset.delivery_formats = ','.join(config['delivery']['formats'])
- elif isinstance(config['delivery']['formats'], str):
- asset.delivery_formats = config['delivery']['formats']
-
- if 'url' in asset.delivery_formats:
- if 'regex' in config['delivery'] and isinstance(config['delivery']['regex'], str):
- asset.delivery_regex_url = config['delivery']['regex'].replace('\\\\', '\\')
- else:
- asset.delivery_instructions = ''
- asset.delivery_formats = 'url'
- asset.delivery_regex_url = ''
-
+ asset = process_asset_config(asset, config)
return asset
@@ -785,7 +821,32 @@ def pull_quiz_asset(github, asset: Asset):
encoded_config = get_blob_content(repo, file_path, branch=branch_name).content
decoded_config = Asset.decode(encoded_config)
- asset.config = json.loads(decoded_config)
+
+ _config = json.loads(decoded_config)
+ asset.config = _config
+
+ # "slug": "introduction-networking-es",
+ # "name": "Introducción a redes",
+ # "status": "draft",
+ # "main": "Bienvenido al mundo de las redes. Este primer paso te llevara a grandes cosas en el futuro...",
+ # "results": "¡Felicidades! Ahora el mundo estará un poco más seguro gracias a tí...",
+ # "technologies": ["redes"],
+ # "badges": [
+ # { "slug": "cybersecurity_guru", "points": 5 }
+ # ]
+ if 'info' in _config:
+ _config = _config['info']
+ if 'name' in _config and _config['name'] != '': asset.title = _config['name']
+
+ if 'main' in _config and _config['main']: asset.description = _config['main']
+ elif 'description' in _config and _config['description']: asset.description = _config['description']
+
+ if 'technologies' in _config and _config['technologies'] != '':
+ asset.technologies.clear()
+ for tech_slug in _config['technologies']:
+ technology = AssetTechnology.get_or_create(tech_slug)
+ asset.technologies.add(technology)
+
asset.save()
if asset.assessment is None:
@@ -927,7 +988,7 @@ def add_syllabus_translations(_json: dict):
for ass in day[asset_type]:
index += 1
slug = ass['slug'] if 'slug' in ass else ass
- _asset = Asset.objects.filter(slug=slug).first()
+ _asset = Asset.get_by_slug(slug)
if _asset is not None:
if 'slug' not in ass:
_json['days'][day_count][asset_type][index] = {
diff --git a/breathecode/registry/admin.py b/breathecode/registry/admin.py
index 47b6671bd..e3da3f293 100644
--- a/breathecode/registry/admin.py
+++ b/breathecode/registry/admin.py
@@ -12,7 +12,7 @@
from .tasks import (async_pull_from_github, async_test_asset, async_download_readme_images, async_remove_img_from_cloud,
async_upload_image_to_bucket, async_update_frontend_asset_cache)
from .actions import (get_user_from_github_username, AssetThumbnailGenerator, scan_asset_originality,
- add_syllabus_translations, clean_asset_readme)
+ add_syllabus_translations, clean_asset_readme, process_asset_config, push_to_github)
logger = logging.getLogger(__name__)
lang_flags = {
@@ -46,12 +46,32 @@ def make_internal(modeladmin, request, queryset):
queryset.update(external=False)
+def process_config_object(modeladmin, request, queryset):
+ assets = queryset.all()
+ for a in assets:
+ process_asset_config(a, a.config)
+
+
def pull_content_from_github(modeladmin, request, queryset):
queryset.update(sync_status='PENDING', status_text='Starting to sync...')
assets = queryset.all()
for a in assets:
- async_pull_from_github.delay(a.slug, request.user.id)
- # pull_from_github(a.slug) # uncomment for testing purposes
+ try:
+ async_pull_from_github.delay(a.slug, request.user.id)
+ # async_pull_from_github(a.slug, request.user.id) # uncomment for testing purposes
+ except Exception as e:
+ messages.error(request, a.slug + ': ' + str(e))
+
+
+def push_content_to_github(modeladmin, request, queryset):
+ queryset.update(sync_status='PENDING', status_text='Starting to sync...')
+ assets = queryset.all()
+ for a in assets:
+ # try:
+ push_to_github(a.slug, request.user)
+ # # async_push_github_asset(a.slug, request.user.id) # uncomment for testing purposes
+ # except Exception as e:
+ # messages.error(request, a.slug + ': ' + str(e))
def pull_content_from_github_override_meta(modeladmin, request, queryset):
@@ -335,8 +355,10 @@ class AssetAdmin(admin.ModelAdmin):
test_asset_integrity,
add_gitpod,
remove_gitpod,
+ process_config_object,
pull_content_from_github,
pull_content_from_github_override_meta,
+ push_content_to_github,
seo_optimization_off,
seo_optimization_on,
seo_report,
diff --git a/breathecode/registry/models.py b/breathecode/registry/models.py
index 1b69128ae..edb386986 100644
--- a/breathecode/registry/models.py
+++ b/breathecode/registry/models.py
@@ -631,6 +631,18 @@ def log_error(self, error_slug, status_text=None):
error.save()
return error
+ def generate_quiz_json(self):
+
+ if not self.assessment:
+ return None
+
+ config = self.assessment.to_json()
+ config['info']['description'] = self.description
+ config['lang'] = self.lang
+ config['technologies'] = [t.slug for t in self.technologies.all()]
+
+ return config
+
def get_tasks(self):
if self.readme is None:
diff --git a/breathecode/registry/receivers.py b/breathecode/registry/receivers.py
index e443e35c8..3c03640a5 100644
--- a/breathecode/registry/receivers.py
+++ b/breathecode/registry/receivers.py
@@ -1,9 +1,10 @@
import logging
import os
-from django.db.models.signals import post_delete
+from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
+from breathecode.assessment.models import Question, Option
from breathecode.admissions.models import SyllabusVersion
from breathecode.admissions.signals import syllabus_version_json_updated
from breathecode.assignments.models import Task
@@ -21,6 +22,7 @@
async_remove_img_from_cloud,
async_synchonize_repository_content,
async_update_frontend_asset_cache,
+ async_generate_quiz_config,
)
logger = logging.getLogger(__name__)
@@ -115,3 +117,36 @@ def post_webhook_received(sender, instance, **kwargs):
def syllabus_json_updated(sender, instance, **kwargs):
logger.debug(f'Syllabus Version json for {instance.syllabus.slug} was updated')
async_add_syllabus_translations.delay(instance.syllabus.slug, instance.version)
+
+
+## Keep assessment question and asset.config in synch
+
+
+@receiver(post_save, sender=Question)
+def model_a_saved(sender, instance, created, **kwargs):
+ if not instance.assessment.is_archived:
+ async_generate_quiz_config(instance.assessment.id)
+
+
+@receiver(post_save, sender=Option)
+def model_b_saved(sender, instance, created, **kwargs):
+ if not instance.question.assessment.is_archived:
+ async_generate_quiz_config(instance.question.assessment.id)
+
+
+@receiver(post_delete, sender=Question)
+def model_a_deleted(sender, instance, **kwargs):
+ try:
+ if instance.assessment and not instance.assessment.is_archived:
+ async_generate_quiz_config(instance.assessment.id)
+ except:
+ pass
+
+
+@receiver(post_delete, sender=Option)
+def model_b_deleted(sender, instance, **kwargs):
+ try:
+ if instance.assessment and not instance.assessment.is_archived:
+ async_generate_quiz_config(instance.question.assessment.id)
+ except:
+ pass
diff --git a/breathecode/registry/serializers.py b/breathecode/registry/serializers.py
index 4a47546a5..8bafafa56 100644
--- a/breathecode/registry/serializers.py
+++ b/breathecode/registry/serializers.py
@@ -807,4 +807,12 @@ def update(self, instance, validated_data):
elif validated_data['status'] != 'PUBLISHED':
data['published_at'] = None
+ # Check if preview img is being deleted
+ if 'preview' in validated_data:
+ if validated_data['preview'] == None and instance.preview != None:
+ hash = instance.preview.split('/')[-1]
+ if hash is not None:
+ from .tasks import async_remove_asset_preview_from_cloud
+ async_remove_asset_preview_from_cloud.delay(hash)
+
return super().update(instance, {**validated_data, **data})
diff --git a/breathecode/registry/tasks.py b/breathecode/registry/tasks.py
index 798cf2fb3..9b7e7b154 100644
--- a/breathecode/registry/tasks.py
+++ b/breathecode/registry/tasks.py
@@ -14,6 +14,7 @@
from task_manager.core.exceptions import AbortTask, RetryTask
from task_manager.django.decorators import task
+from breathecode.assessment.models import Assessment
from breathecode.admissions.models import SyllabusVersion
from breathecode.media.models import Media, MediaResolution
from breathecode.media.views import media_gallery_bucket
@@ -331,6 +332,27 @@ def async_remove_img_from_cloud(id, **_):
return True
+@task(priority=TaskPriority.ACADEMY.value)
+def async_remove_asset_preview_from_cloud(hash, **_):
+
+ logger.info('async_remove_asset_preview_from_cloud')
+
+ media = Media.objects.filter(hash=hash).first()
+ if media is None:
+ raise Exception(f'Media with hash {hash} not found')
+
+ media_name = media.name
+
+ storage = Storage()
+ extension = media.mime.split('/')[-1]
+ cloud_file = storage.file(screenshots_bucket(), media.hash + extension)
+ cloud_file.delete()
+ media.delete()
+
+ logger.info(f'Media name ({media_name}) was deleted from the cloud')
+ return True
+
+
@task(priority=TaskPriority.ACADEMY.value)
def async_upload_image_to_bucket(id, **_):
@@ -489,3 +511,18 @@ def async_add_syllabus_translations(syllabus_slug, version):
syllabus_version.json = add_syllabus_translations(syllabus_version.json)
syllabus_version.save()
+
+
+@shared_task(priority=TaskPriority.BACKGROUND.value)
+def async_generate_quiz_config(assessment_id):
+
+ assessment = Assessment.objects.filter(id=assessment_id, is_archived=False).first()
+ if assessment is None:
+ raise Exception(f'Assessment {assessment_id} not found or its archived')
+
+ assets = assessment.asset_set.all()
+ for a in assets:
+ a.config = a.generate_quiz_json()
+ a.save()
+
+ return True
diff --git a/breathecode/registry/views.py b/breathecode/registry/views.py
index 790899769..cc089947e 100644
--- a/breathecode/registry/views.py
+++ b/breathecode/registry/views.py
@@ -1074,7 +1074,7 @@ def post(self, request, academy_id=None):
'pk', flat=True).order_by('sort_priority')
delta = len(data['technologies']) - len(technology_ids)
if delta != 0:
- raise ValidationException(f'{delta} of the assigned technologies for this lesson are not found')
+ raise ValidationException(f'{delta} of the assigned technologies for this asset are not found')
data['technologies'] = technology_ids
diff --git a/breathecode/utils/decorators/task.py b/breathecode/utils/decorators/task.py
index ee52f9804..1c3f4460c 100644
--- a/breathecode/utils/decorators/task.py
+++ b/breathecode/utils/decorators/task.py
@@ -20,6 +20,7 @@ class TaskPriority(Enum):
MONITORING = 2 # monitoring tasks
ACTIVITY = 2 # user activity
BILL = 2 # postpaid billing
+ ASSESSMENT = 2 # user assessment
CACHE = 3 # cache
MARKETING = 4 # marketing purposes
OAUTH_CREDENTIALS = 5 # oauth tasks