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