Merge pull request #241 from mawoka-myblock/new-storage

This commit is contained in:
Marlon
2023-06-12 20:02:07 +02:00
committed by GitHub
63 changed files with 3470 additions and 1634 deletions
+6 -5
View File
@@ -1,13 +1,14 @@
FROM python:3.10-slim
FROM python:3.11-slim
COPY Pipfile* /app/
WORKDIR /app/
RUN apt update && \
apt install -y jq gcc libpq5 libpq-dev && \
jq -r '.default | to_entries[] | .key + .value.version' Pipfile.lock > requirements.txt && \
sed -i "s/psycopg2-binary/psycopg2/g" requirements.txt
apt install -y jq gcc libpq5 libpq-dev libmagic1 && \
jq -r '.default | to_entries[] | .key + .value.version' Pipfile.lock > requirements.txt && \
sed -i "s/psycopg2-binary/psycopg2/g" requirements.txt
RUN pip install -r requirements.txt
RUN pip install -r requirements.txt && \
apt remove -y jq gcc
COPY classquiz/ /app/classquiz/
COPY image_cleanup.py /app/image_cleanup.py
+6 -3
View File
@@ -30,13 +30,15 @@ pillow = ">=9.1.1"
authlib = "*"
httpx = "*"
itsdangerous = "*"
puremagic = "*"
py-avataaars-no-png = "*"
cryptography = "*"
scheduler = "*"
webauthn = "*"
pyotp = "*"
minio = "*"
xxhash = "*"
arq = "*"
thumbhash-python = "==1.0.0"
python-magic = "*"
[dev-packages]
coverage = "*"
@@ -50,9 +52,10 @@ pre-commit = "*"
python-socketio = {extras = ["client"], version = "*"}
[requires]
python_version = "3.10"
python_version = "3.11"
[scripts]
format = "black ."
lint = "flake8 classquiz"
test = "coverage run -m pytest --lf -v --asyncio-mode=strict classquiz/tests"
worker = "arq classquiz.worker.WorkerSettings"
Generated
+461 -231
View File
@@ -1,11 +1,11 @@
{
"_meta": {
"hash": {
"sha256": "0f27e59c80a47f8948353baf55cc7495131b532f2718bcd2ba08f25a40dc3dab"
"sha256": "f10247b4d32925c62f8f9c99a6b3d3bf0073f630d09cf15a7df884f86b78b0a2"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.10"
"python_version": "3.11"
},
"sources": [
{
@@ -127,11 +127,11 @@
},
"alembic": {
"hashes": [
"sha256:32a69b13a613aeb7e8093f242da60eff9daed13c0df02fff279c1b06c32965d2",
"sha256:b2e0a6cfd3a8ce936a1168320bcbe94aefa3f4463cd773a968a55071beb3cd37"
"sha256:6a810a6b012c88b33458fceb869aef09ac75d6ace5291915ba7fae44de372c01",
"sha256:dc871798a601fab38332e38d6ddb38d5e734f60034baeb8e2db5b642fccd8ab8"
],
"index": "pypi",
"version": "==1.10.3"
"version": "==1.11.1"
},
"anyio": {
"hashes": [
@@ -176,6 +176,14 @@
"markers": "python_version >= '3.6'",
"version": "==21.2.0"
},
"arq": {
"hashes": [
"sha256:d176ebadfba920c039dc578814d19b7814d67fa15f82fdccccaedb4330d65dae",
"sha256:db072d0f39c0bc06b436db67ae1f315c81abc1527563b828955670531815290b"
],
"index": "pypi",
"version": "==0.25.0"
},
"asn1crypto": {
"hashes": [
"sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c",
@@ -188,7 +196,7 @@
"sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15",
"sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"
],
"markers": "python_full_version <= '3.11.2'",
"markers": "python_version >= '3.6'",
"version": "==4.0.2"
},
"asyncpg": {
@@ -472,7 +480,7 @@
"sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df",
"sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"
],
"markers": "python_version >= '3.7'",
"markers": "python_full_version >= '3.7.0'",
"version": "==3.1.0"
},
"click": {
@@ -483,30 +491,44 @@
"markers": "python_version >= '3.7'",
"version": "==8.1.3"
},
"colorama": {
"hashes": [
"sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44",
"sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"
],
"version": "==0.4.6"
},
"commonmark": {
"hashes": [
"sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60",
"sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"
],
"version": "==0.9.1"
},
"cryptography": {
"hashes": [
"sha256:0ddaee209d1cf1f180f1efa338a68c4621154de0afaef92b89486f5f96047c55",
"sha256:14754bcdae909d66ff24b7b5f166d69340ccc6cb15731670435efd5719294895",
"sha256:344c6de9f8bda3c425b3a41b319522ba3208551b70c2ae00099c205f0d9fd3be",
"sha256:34d405ea69a8b34566ba3dfb0521379b210ea5d560fafedf9f800a9a94a41928",
"sha256:3680248309d340fda9611498a5319b0193a8dbdb73586a1acf8109d06f25b92d",
"sha256:3c5ef25d060c80d6d9f7f9892e1d41bb1c79b78ce74805b8cb4aa373cb7d5ec8",
"sha256:4ab14d567f7bbe7f1cdff1c53d5324ed4d3fc8bd17c481b395db224fb405c237",
"sha256:5c1f7293c31ebc72163a9a0df246f890d65f66b4a40d9ec80081969ba8c78cc9",
"sha256:6b71f64beeea341c9b4f963b48ee3b62d62d57ba93eb120e1196b31dc1025e78",
"sha256:7d92f0248d38faa411d17f4107fc0bce0c42cae0b0ba5415505df72d751bf62d",
"sha256:8362565b3835ceacf4dc8f3b56471a2289cf51ac80946f9087e66dc283a810e0",
"sha256:84a165379cb9d411d58ed739e4af3396e544eac190805a54ba2e0322feb55c46",
"sha256:88ff107f211ea696455ea8d911389f6d2b276aabf3231bf72c8853d22db755c5",
"sha256:9f65e842cb02550fac96536edb1d17f24c0a338fd84eaf582be25926e993dde4",
"sha256:a4fc68d1c5b951cfb72dfd54702afdbbf0fb7acdc9b7dc4301bbf2225a27714d",
"sha256:b7f2f5c525a642cecad24ee8670443ba27ac1fab81bba4cc24c7b6b41f2d0c75",
"sha256:b846d59a8d5a9ba87e2c3d757ca019fa576793e8758174d3868aecb88d6fc8eb",
"sha256:bf8fc66012ca857d62f6a347007e166ed59c0bc150cefa49f28376ebe7d992a2",
"sha256:f5d0bf9b252f30a31664b6f64432b4730bb7038339bd18b1fafe129cfc2be9be"
"sha256:059e348f9a3c1950937e1b5d7ba1f8e968508ab181e75fc32b879452f08356db",
"sha256:1a5472d40c8f8e91ff7a3d8ac6dfa363d8e3138b961529c996f3e2df0c7a411a",
"sha256:1a8e6c2de6fbbcc5e14fd27fb24414507cb3333198ea9ab1258d916f00bc3039",
"sha256:1fee5aacc7367487b4e22484d3c7e547992ed726d14864ee33c0176ae43b0d7c",
"sha256:5d092fdfedaec4cbbffbf98cddc915ba145313a6fdaab83c6e67f4e6c218e6f3",
"sha256:5f0ff6e18d13a3de56f609dd1fd11470918f770c6bd5d00d632076c727d35485",
"sha256:7bfc55a5eae8b86a287747053140ba221afc65eb06207bedf6e019b8934b477c",
"sha256:7fa01527046ca5facdf973eef2535a27fec4cb651e4daec4d043ef63f6ecd4ca",
"sha256:8dde71c4169ec5ccc1087bb7521d54251c016f126f922ab2dfe6649170a3b8c5",
"sha256:8f4ab7021127a9b4323537300a2acfb450124b2def3756f64dc3a3d2160ee4b5",
"sha256:948224d76c4b6457349d47c0c98657557f429b4e93057cf5a2f71d603e2fc3a3",
"sha256:9a6c7a3c87d595608a39980ebaa04d5a37f94024c9f24eb7d10262b92f739ddb",
"sha256:b46e37db3cc267b4dea1f56da7346c9727e1209aa98487179ee8ebed09d21e43",
"sha256:b4ceb5324b998ce2003bc17d519080b4ec8d5b7b70794cbd2836101406a9be31",
"sha256:cb33ccf15e89f7ed89b235cff9d49e2e62c6c981a6061c9c8bb47ed7951190bc",
"sha256:d198820aba55660b4d74f7b5fd1f17db3aa5eb3e6893b0a41b75e84e4f9e0e4b",
"sha256:d34579085401d3f49762d2f7d6634d6b6c2ae1242202e860f4d26b046e3a1006",
"sha256:eb8163f5e549a22888c18b0d53d6bb62a20510060a22fd5a995ec8a05268df8a",
"sha256:f73bff05db2a3e5974a6fd248af2566134d8981fd7ab012e5dd4ddb1d9a70699"
],
"index": "pypi",
"version": "==41.0.0"
"version": "==41.0.1"
},
"databases": {
"hashes": [
@@ -534,27 +556,19 @@
},
"email-validator": {
"hashes": [
"sha256:07c61b62ee446e39274b18204afa8e422baf64570776045272b04d10c02f64f6",
"sha256:f4904f4145c11f8de5897afbb8d7db4b465c57f13db1c8c106c16d02bceebd9a"
"sha256:1ff6e86044200c56ae23595695c54e9614f4a9551e0e393614f764860b3d7900",
"sha256:2466ba57cda361fb7309fd3d5a225723c788ca4bbad32a0ebd5373b99730285c"
],
"index": "pypi",
"version": "==2.0.0"
},
"exceptiongroup": {
"hashes": [
"sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e",
"sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"
],
"markers": "python_version < '3.11'",
"version": "==1.1.1"
"version": "==2.0.0.post2"
},
"fastapi": {
"hashes": [
"sha256:9569f0a381f8a457ec479d90fa01005cfddaae07546eb1f3fa035bc4797ae7d5",
"sha256:a870d443e5405982e1667dfe372663abf10754f246866056336d7f01c21dab07"
"sha256:95d757511c596409930bd20673358d4a4d709004edb85c5d24d6ffc48fabcbf2",
"sha256:b53248ee45f64f19bb7600953696e3edf94b0f7de94df1e5433fc5c6136fa986"
],
"index": "pypi",
"version": "==0.95.1"
"version": "==0.97.0"
},
"frozenlist": {
"hashes": [
@@ -718,6 +732,100 @@
"markers": "python_version >= '3.7'",
"version": "==0.14.0"
},
"hiredis": {
"hashes": [
"sha256:071c5814b850574036506a8118034f97c3cbf2fe9947ff45a27b07a48da56240",
"sha256:08415ea74c1c29b9d6a4ca3dd0e810dc1af343c1d1d442e15ba133b11ab5be6a",
"sha256:126623b03c31cb6ac3e0d138feb6fcc36dd43dd34fc7da7b7a0c38b5d75bc896",
"sha256:14824e457e4f5cda685c3345d125da13949bcf3bb1c88eb5d248c8d2c3dee08f",
"sha256:15c2a551f3b8a26f7940d6ee10b837810201754b8d7e6f6b1391655370882c5a",
"sha256:17e938d9d3ee92e1adbff361706f1c36cc60eeb3e3eeca7a3a353eae344f4c91",
"sha256:1cadb0ac7ba3babfd804e425946bec9717b320564a1390f163a54af9365a720a",
"sha256:1d274d5c511dfc03f83f997d3238eaa9b6ee3f982640979f509373cced891e98",
"sha256:20f509e3a1a20d6e5f5794fc37ceb21f70f409101fcfe7a8bde783894d51b369",
"sha256:227c5b4bcb60f89008c275d596e4a7b6625a6b3c827b8a66ae582eace7051f71",
"sha256:232d0a70519865741ba56e1dfefd160a580ae78c30a1517bad47b3cf95a3bc7d",
"sha256:2443659c76b226267e2a04dbbb21bc2a3f91aa53bdc0c22964632753ae43a247",
"sha256:2d7e459fe7313925f395148d36d9b7f4f8dac65be06e45d7af356b187cef65fc",
"sha256:2fb9300959a0048138791f3d68359d61a788574ec9556bddf1fec07f2dbc5320",
"sha256:334f2738700b20faa04a0d813366fb16ed17287430a6b50584161d5ad31ca6d7",
"sha256:33a94d264e6e12a79d9bb8af333b01dc286b9f39c99072ab5fef94ce1f018e17",
"sha256:33bc4721632ef9708fa44e5df0066053fccc8e65410a2c48573192517a533b48",
"sha256:33ee3ea5cad3a8cb339352cd230b411eb437a2e75d7736c4899acab32056ccdb",
"sha256:3753df5f873d473f055e1f8837bfad0bd3b277c86f3c9bf058c58f14204cd901",
"sha256:3759f4789ae1913b7df278dfc9e8749205b7a106f888cd2903d19461e24a7697",
"sha256:3b7fe075e91b9d9cff40eba4fb6a8eff74964d3979a39be9a9ef58b1b4cb3604",
"sha256:3bf4b5bae472630c229518e4a814b1b68f10a3d9b00aeaec45f1a330f03a0251",
"sha256:3f006c28c885deb99b670a5a66f367a175ab8955b0374029bad7111f5357dcd4",
"sha256:3f5446068197b35a11ccc697720c41879c8657e2e761aaa8311783aac84cef20",
"sha256:3fa6811a618653164f918b891a0fa07052bd71a799defa5c44d167cac5557b26",
"sha256:46525fbd84523cac75af5bf524bc74aaac848beaf31b142d2df8a787d9b4bbc4",
"sha256:477c34c4489666dc73cb5e89dafe2617c3e13da1298917f73d55aac4696bd793",
"sha256:4b3e974ad15eb32b1f537730dea70b93a4c3db7b026de3ad2b59da49c6f7454d",
"sha256:4c3b8be557e08b234774925622e196f0ee36fe4eab66cd19df934d3efd8f3743",
"sha256:4e3e3e31423f888d396b1fc1f936936e52af868ac1ec17dd15e3eeba9dd4de24",
"sha256:4e43e2b5acaad09cf48c032f7e4926392bb3a3f01854416cf6d82ebff94d5467",
"sha256:4ed68a3b1ccb4313d2a42546fd7e7439ad4745918a48b6c9bcaa61e1e3e42634",
"sha256:4f674e309cd055ee7a48304ceb8cf43265d859faf4d7d01d270ce45e976ae9d3",
"sha256:50171f985e17970f87d5a29e16603d1e5b03bdbf5c2691a37e6c912942a6b657",
"sha256:51341e70b467004dcbec3a6ce8c478d2d6241e0f6b01e4c56764afd5022e1e9d",
"sha256:5a4bcef114fc071d5f52c386c47f35aae0a5b43673197b9288a15b584da8fa3a",
"sha256:5a5c8019ff94988d56eb49b15de76fe83f6b42536d76edeb6565dbf7fe14b973",
"sha256:5cda592405bbd29d53942e0389dc3fa77b49c362640210d7e94a10c14a677d4d",
"sha256:5e6674a017629284ef373b50496d9fb1a89b85a20a7fa100ecd109484ec748e5",
"sha256:5e7bb4dd524f50b71c20ef5a12bd61da9b463f8894b18a06130942fe31509881",
"sha256:60c4e3c258eafaab21b174b17270a0cc093718d61cdbde8c03f85ec4bf835343",
"sha256:61995eb826009d99ed8590747bc0da683a5f4fbb4faa8788166bf3810845cd5c",
"sha256:61a72e4a523cdfc521762137559c08dfa360a3caef63620be58c699d1717dac1",
"sha256:69536b821dd1bc78058a6e7541743f8d82bf2d981b91280b14c4daa6cdc7faba",
"sha256:6ccdcb635dae85b006592f78e32d97f4bc7541cb27829d505f9c7fefcef48298",
"sha256:6f88cafe46612b6fa68e6dea49e25bebf160598bba00101caa51cc8c1f18d597",
"sha256:6f969edc851efe23010e0f53a64269f2629a9364135e9ec81c842e8b2277d0c1",
"sha256:77924b0d32fd1f493d3df15d9609ddf9d94c31a364022a6bf6b525ce9da75bea",
"sha256:7df645b6b7800e8b748c217fbd6a4ca8361bcb9a1ae6206cc02377833ec8a1aa",
"sha256:7e17d04ea58ab8cf3f2dc52e875db16077c6357846006780086fff3189fb199d",
"sha256:7f2b34a6444b8f9c1e9f84bd2c639388e5d14f128afd14a869dfb3d9af893aa2",
"sha256:818dfd310aa1020a13cd08ee48e116dd8c3bb2e23b8161f8ac4df587dd5093d7",
"sha256:89a258424158eb8b3ed9f65548d68998da334ef155d09488c5637723eb1cd697",
"sha256:8eceffca3941775b646cd585cd19b275d382de43cc3327d22f7c75d7b003d481",
"sha256:8f280ab4e043b089777b43b4227bdc2035f88da5072ab36588e0ccf77d45d058",
"sha256:8f9dbe12f011a9b784f58faecc171d22465bb532c310bd588d769ba79a59ef5a",
"sha256:9076ce8429785c85f824650735791738de7143f61f43ae9ed83e163c0ca0fa44",
"sha256:95d2305fd2a7b179cacb48b10f618872fc565c175f9f62b854e8d1acac3e8a9e",
"sha256:96d9ea6c8d4cbdeee2e0d43379ce2881e4af0454b00570677c59f33f2531cd38",
"sha256:9944a2cac25ffe049a7e89f306e11b900640837d1ef38d9be0eaa4a4e2b73a52",
"sha256:9a1a80a8fa767f2fdc3870316a54b84fe9fc09fa6ab6a2686783de6a228a4604",
"sha256:9cd32326dfa6ce87edf754153b0105aca64486bebe93b9600ccff74fa0b224df",
"sha256:9f4a65276f6ecdebe75f2a53f578fbc40e8d2860658420d5e0611c56bbf5054c",
"sha256:a286ded34eb16501002e3713b3130c987366eee2ba0d58c33c72f27778e31676",
"sha256:a2df98f5e071320c7d84e8bd07c0542acdd0a7519307fc31774d60e4b842ec4f",
"sha256:a7205497d7276a81fe92951a29616ef96562ed2f91a02066f72b6f93cb34b40e",
"sha256:aa17a3b22b3726d54d7af20394f65d4a1735a842a4e0f557dc67a90f6965c4bc",
"sha256:af33f370be90b48bbaf0dab32decbdcc522b1fa95d109020a963282086518a8e",
"sha256:b17baf702c6e5b4bb66e1281a3efbb1d749c9d06cdb92b665ad81e03118f78fc",
"sha256:b4f3d06dc16671b88a13ae85d8ca92534c0b637d59e49f0558d040a691246422",
"sha256:b9953d87418ac228f508d93898ab572775e4d3b0eeb886a1a7734553bcdaf291",
"sha256:b9a7c987e161e3c58f992c63b7e26fea7fe0777f3b975799d23d65bbb8cb5899",
"sha256:c6cb613148422c523945cdb8b6bed617856f2602fd8750e33773ede2616e55d5",
"sha256:c9b9e5bde7030cae83aa900b5bd660decc65afd2db8c400f3c568c815a47ca2a",
"sha256:cc36a9dded458d4e37492fe3e619c6c83caae794d26ad925adbce61d592f8428",
"sha256:cd2614f17e261f72efc2f19f5e5ff2ee19e2296570c0dcf33409e22be30710de",
"sha256:d115790f18daa99b5c11a506e48923b630ef712e9e4b40482af942c3d40638b8",
"sha256:d194decd9608f11c777946f596f31d5aacad13972a0a87829ae1e6f2d26c1885",
"sha256:d1a4ce40ba11da9382c14da31f4f9e88c18f7d294f523decd0fadfb81f51ad18",
"sha256:d1be9e30e675f5bc1cb534633324578f6f0944a1bcffe53242cf632f554f83b6",
"sha256:d20891e3f33803b26d54c77fd5745878497091e33f4bbbdd454cf6e71aee8890",
"sha256:d27e560eefb57914d742a837f1da98d3b29cb22eff013c8023b7cf52ae6e051d",
"sha256:dcb0569dd5bfe6004658cd0f229efa699a3169dcb4f77bd72e188adda302063d",
"sha256:e62ec131816c6120eff40dffe43424e140264a15fa4ab88c301bd6a595913af3",
"sha256:e75163773a309e56a9b58165cf5a50e0f84b755f6ff863b2c01a38918fe92daa",
"sha256:ec58fb7c2062f835595c12f0f02dcda76d0eb0831423cc191d1e18c9276648de",
"sha256:f1eadbcd3de55ac42310ff82550d3302cb4efcd4e17d76646a17b6e7004bb42b",
"sha256:f2dcb8389fa3d453927b1299f46bdb38473c293c8269d5c777d33ea0e526b610",
"sha256:ffaf841546905d90ff189de7397aa56413b1ce5e54547f17a98f0ebf3a3b0a3b"
],
"version": "==2.2.3"
},
"httpcore": {
"hashes": [
"sha256:125f8375ab60036db632f34f4b627a9ad085048eef7cb7d2616fea0f739f98af",
@@ -774,11 +882,11 @@
},
"httpx": {
"hashes": [
"sha256:447556b50c1921c351ea54b4fe79d91b724ed2b027462ab9a329465d147d5a4e",
"sha256:507d676fc3e26110d41df7d35ebd8b3b8585052450f4097401c9be59d928c63e"
"sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd",
"sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"
],
"index": "pypi",
"version": "==0.24.0"
"version": "==0.24.1"
},
"idna": {
"hashes": [
@@ -814,75 +922,75 @@
},
"markupsafe": {
"hashes": [
"sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed",
"sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc",
"sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2",
"sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460",
"sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7",
"sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0",
"sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1",
"sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa",
"sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03",
"sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323",
"sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65",
"sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013",
"sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036",
"sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f",
"sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4",
"sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419",
"sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2",
"sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619",
"sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a",
"sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a",
"sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd",
"sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7",
"sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666",
"sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65",
"sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859",
"sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625",
"sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff",
"sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156",
"sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd",
"sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba",
"sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f",
"sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1",
"sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094",
"sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a",
"sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513",
"sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed",
"sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d",
"sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3",
"sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147",
"sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c",
"sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603",
"sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601",
"sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a",
"sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1",
"sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d",
"sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3",
"sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54",
"sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2",
"sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6",
"sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"
"sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e",
"sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e",
"sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431",
"sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686",
"sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559",
"sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc",
"sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c",
"sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0",
"sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4",
"sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9",
"sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575",
"sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba",
"sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d",
"sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3",
"sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00",
"sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155",
"sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac",
"sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52",
"sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f",
"sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8",
"sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b",
"sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24",
"sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea",
"sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198",
"sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0",
"sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee",
"sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be",
"sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2",
"sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707",
"sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6",
"sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58",
"sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779",
"sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636",
"sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c",
"sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad",
"sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee",
"sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc",
"sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2",
"sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48",
"sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7",
"sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e",
"sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b",
"sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa",
"sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5",
"sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e",
"sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb",
"sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9",
"sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57",
"sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc",
"sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"
],
"markers": "python_version >= '3.7'",
"version": "==2.1.2"
"version": "==2.1.3"
},
"meilisearch": {
"hashes": [
"sha256:597ec5eaf6b726428c0e04ecf05af19379aa097093225dab0c6bfb892d03a90d",
"sha256:e35ad840a42554b575ddb72c6aa5d9f7202ab2a6777fada5752269fe453379f4"
"sha256:db084bc4e90608e3aa20d318de1cafc06db5dbd2e1561003634fa05e5025bc5e",
"sha256:ff3ab30c92a8b1ae8ddbab9f7b94a0830ccbf6bf84f209664e2b1de076541e76"
],
"index": "pypi",
"version": "==0.26.0"
"version": "==0.28.0"
},
"minio": {
"hashes": [
"sha256:230faf1d0db1c3ce09aef86b0eb38e994a64769e34dad4b77febf59bc90d8ab0",
"sha256:ee5cbd4ba73f71b73555209f3411d5da3e9742bbe3fd038c362042d6d2527256"
"sha256:1afdf01c1bc8b57ddd12d438e3e168d625465b56f4d1c2af7576744c688e84c6",
"sha256:fcf8ac2cef310d5ddff2bef2c42f4e5a8bb546b87bca5bf8832135db054ca4e1"
],
"index": "pypi",
"version": "==7.1.14"
"version": "==7.1.15"
},
"multidict": {
"hashes": [
@@ -1122,14 +1230,6 @@
],
"version": "==2.9.6"
},
"puremagic": {
"hashes": [
"sha256:349e179f04bcc14eb0e2c3941cd9ee4ea74881ab6ccac63ea0c37c98f7a80b22",
"sha256:6e46aa78113a466abc9f69e6e8a4ce90eb57d908dafb809597012621061462bd"
],
"index": "pypi",
"version": "==1.15"
},
"py-avataaars-no-png": {
"hashes": [
"sha256:9cd593fe93d6e223626903da7310ad53e857b3fd856ce7e7f2b1faa401740054",
@@ -1195,6 +1295,14 @@
"index": "pypi",
"version": "==1.10.4"
},
"pygments": {
"hashes": [
"sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c",
"sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"
],
"markers": "python_version >= '3.7'",
"version": "==2.15.1"
},
"pyopenssl": {
"hashes": [
"sha256:24f0dc5227396b3e831f4c7f602b950a5e9833d292c8e4a2e06b709292806ae2",
@@ -1241,6 +1349,14 @@
"index": "pypi",
"version": "==3.3.0"
},
"python-magic": {
"hashes": [
"sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b",
"sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3"
],
"index": "pypi",
"version": "==0.4.27"
},
"python-multipart": {
"hashes": [
"sha256:e9925a80bb668529f1b67c7fdb0a5dacdd7cbfc6fb0bff3ea443fe22bdd62132",
@@ -1312,11 +1428,11 @@
},
"redis": {
"hashes": [
"sha256:2c19e6767c474f2e85167909061d525ed65bea9301c0770bb151e041b7ac89a2",
"sha256:73ec35da4da267d6847e47f68730fdd5f62e2ca69e3ef5885c6a78a9374c3893"
"sha256:77929bc7f5dab9adf3acba2d3bb7d7658f1e0c2f1cafe7eb36434e751c471119",
"sha256:dc87a0bdef6c8bfe1ef1e1c40be7034390c2ae02d92dcd0c7ca1729443899880"
],
"index": "pypi",
"version": "==4.5.4"
"version": "==4.5.5"
},
"requests": {
"hashes": [
@@ -1326,29 +1442,28 @@
"markers": "python_version >= '3.7'",
"version": "==2.31.0"
},
"rich": {
"hashes": [
"sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e",
"sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0"
],
"version": "==12.6.0"
},
"rsa": {
"hashes": [
"sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7",
"sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"
],
"markers": "python_version >= '3.6' and python_version < '4.0'",
"markers": "python_version >= '3.6' and python_version < '4'",
"version": "==4.9"
},
"scheduler": {
"hashes": [
"sha256:48e8fe16f4a0cbd24f169a2ed4f65b99fb0c3a7f4a28f4dfbf26cc901ab4112b",
"sha256:5ae5181d8462d89307bdae588b47f290a2fb7e3fa1073650354e01eb87a265f4"
],
"index": "pypi",
"version": "==0.8.4"
},
"sentry-sdk": {
"hashes": [
"sha256:7ae78bd921981a5010ab540d6bdf3b793659a4db8cccf7f16180702d48a80d84",
"sha256:885a11c69df23e53eb281d003b9ff15a5bdfa43d8a2a53589be52104a1b4582f"
"sha256:79afb7c896014038e358401ad1d36889f97a129dfa8031c49b3f238cd1aa3935",
"sha256:aa796423eb6a2f4a8cd7a5b02ba6558cb10aab4ccdc0537f63a47b038c520c38"
],
"index": "pypi",
"version": "==1.19.1"
"version": "==1.25.1"
},
"setuptools": {
"hashes": [
@@ -1358,6 +1473,13 @@
"markers": "python_version >= '3.7'",
"version": "==67.8.0"
},
"shellingham": {
"hashes": [
"sha256:368bf8c00754fd4f55afb7bbb86e272df77e4dc76ac29dbcbb81a59e9fc15744",
"sha256:823bc5fb5c34d60f285b624e7264f4dda254bc803a3774a147bf99c0e3004a28"
],
"version": "==1.5.0.post1"
},
"six": {
"hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
@@ -1423,19 +1545,30 @@
},
"starlette": {
"hashes": [
"sha256:41da799057ea8620e4667a3e69a5b1923ebd32b1819c8fa75634bbe8d8bea9bd",
"sha256:e87fce5d7cbdde34b76f0ac69013fd9d190d581d80681493016666e6f96c6d5e"
"sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75",
"sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91"
],
"markers": "python_version >= '3.7'",
"version": "==0.26.1"
"version": "==0.27.0"
},
"typeguard": {
"thumbhash-python": {
"hashes": [
"sha256:194fb3dbcb06ea9caf7088f3befee014de57961689f9c859ac5239b1ef61d987",
"sha256:c4a40af0ba8a41077221271b46d0a6d8d46045443e4d887887c69254ca861952"
"sha256:17ad1f8cb8fb4dd3c0738cc2c60e8c5ef18c345290b7ff5f22fa16973c7b356c",
"sha256:755379a45c994cc0bcb06a7cac41880fb7966d65373560b1631e96ad2a59c6a6"
],
"markers": "python_full_version >= '3.7.4'",
"version": "==4.0.0"
"index": "pypi",
"version": "==1.0.0"
},
"typer": {
"extras": [
"all"
],
"hashes": [
"sha256:b5e704f4e48ec263de1c0b3a2387cd405a13767d2f907f44c1a08cbad96f606d",
"sha256:ff797846578a9f2a201b53442aedeb543319466870fbe1c701eab66dd7681165"
],
"markers": "python_version >= '3.6'",
"version": "==0.7.0"
},
"typing-extensions": {
"hashes": [
@@ -1447,22 +1580,22 @@
},
"urllib3": {
"hashes": [
"sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc",
"sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"
"sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1",
"sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"
],
"markers": "python_version >= '3.7'",
"version": "==2.0.2"
"version": "==2.0.3"
},
"uvicorn": {
"extras": [
"standard"
],
"hashes": [
"sha256:0fac9cb342ba099e0d582966005f3fdba5b0290579fed4a6266dc702ca7bb032",
"sha256:e47cac98a6da10cd41e6fd036d472c6f58ede6c5dbee3dbee3ef7a100ed97742"
"sha256:79277ae03db57ce7d9aa0567830bbb51d7a612f54d6e1e3e92da3ef24c2c8ed8",
"sha256:e9434d3bbf05f310e762147f769c9f21235ee118ba2d2bf1155a7196448bd996"
],
"index": "pypi",
"version": "==0.21.1"
"version": "==0.22.0"
},
"uvloop": {
"hashes": [
@@ -1528,11 +1661,11 @@
},
"webauthn": {
"hashes": [
"sha256:0c981eb2575d66fa0a6586c2bfb24d9812dff8e158009a9030bc867717727709",
"sha256:1b87d55acd86234475591e2f60163232ba98d91685f235803f258fc891ebb6c0"
"sha256:53914b8529df3e70bec0e41438eea8d99442ad3e54e32b86ce1ba83513bcf30e",
"sha256:caca3cc62045923c542819169c22122bdb81efa0246c9d5208d073942d806bf3"
],
"index": "pypi",
"version": "==1.8.0"
"version": "==1.8.1"
},
"webencodings": {
"hashes": [
@@ -1618,11 +1751,115 @@
},
"xlsxwriter": {
"hashes": [
"sha256:02913b50b74c00f165933d5da3e3a02cab4204cb4932722a1b342c5c71034122",
"sha256:b70a147d36235d1ee835cfd037396f789db1f76740a0e5c917d54137169341de"
"sha256:331508ff39d610ecdaf979e458840bc1eab6e6a02cfd5d08f044f0f73636236f",
"sha256:78751099a770273f1c98b8d6643351f68f98ae8e6acf9d09d37dc6798f8cd3de"
],
"index": "pypi",
"version": "==3.1.0"
"version": "==3.1.2"
},
"xxhash": {
"hashes": [
"sha256:01f36b671ff55cb1d5c2f6058b799b697fd0ae4b4582bba6ed0999678068172a",
"sha256:02badf3754e2133de254a4688798c4d80f0060635087abcb461415cb3eb82115",
"sha256:052fd0efdd5525c2dbc61bebb423d92aa619c4905bba605afbf1e985a562a231",
"sha256:0a2cdfb5cae9fafb9f7b65fd52ecd60cf7d72c13bb2591ea59aaefa03d5a8827",
"sha256:0a6d58ba5865475e53d6c2c4fa6a62e2721e7875e146e2681e5337a6948f12e7",
"sha256:0d54ac023eef7e3ac9f0b8841ae8a376b933043bc2ad428121346c6fa61c491c",
"sha256:0dcb419bf7b0bc77d366e5005c25682249c5521a63fd36c51f584bd91bb13bd5",
"sha256:0eea848758e4823a01abdbcccb021a03c1ee4100411cbeeb7a5c36a202a0c13c",
"sha256:11bf87dc7bb8c3b0b5e24b7b941a9a19d8c1f88120b6a03a17264086bc8bb023",
"sha256:17b65454c5accbb079c45eca546c27c4782f5175aa320758fafac896b1549d27",
"sha256:1a42994f0d42b55514785356722d9031f064fd34e495b3a589e96db68ee0179d",
"sha256:1afb9b9d27fd675b436cb110c15979976d92d761ad6e66799b83756402f3a974",
"sha256:1afd47af8955c5db730f630ad53ae798cf7fae0acb64cebb3cf94d35c47dd088",
"sha256:1bdd57973e2b802ef32553d7bebf9402dac1557874dbe5c908b499ea917662cd",
"sha256:20181cbaed033c72cb881b2a1d13c629cd1228f113046133469c9a48cfcbcd36",
"sha256:2198c4901a0223c48f6ec0a978b60bca4f4f7229a11ca4dc96ca325dd6a29115",
"sha256:2408d49260b0a4a7cc6ba445aebf38e073aeaf482f8e32767ca477e32ccbbf9e",
"sha256:26cb52174a7e96a17acad27a3ca65b24713610ac479c99ac9640843822d3bebf",
"sha256:2783d41487ce6d379fdfaa7332fca5187bf7010b9bddcf20cafba923bc1dc665",
"sha256:3126df6520cbdbaddd87ce74794b2b6c45dd2cf6ac2b600a374b8cdb76a2548c",
"sha256:314ec0bd21f0ee8d30f2bd82ed3759314bd317ddbbd8555668f3d20ab7a8899a",
"sha256:368265392cb696dd53907e2328b5a8c1bee81cf2142d0cc743caf1c1047abb36",
"sha256:3a26eeb4625a6e61cedc8c1b39b89327c9c7e1a8c2c4d786fe3f178eb839ede6",
"sha256:3a68d1e8a390b660d94b9360ae5baa8c21a101bd9c4790a8b30781bada9f1fc6",
"sha256:3b1f3c6d67fa9f49c4ff6b25ce0e7143bab88a5bc0f4116dd290c92337d0ecc7",
"sha256:3d4b15c00e807b1d3d0b612338c814739dec310b80fb069bd732b98ddc709ad7",
"sha256:3f4152fd0bf8b03b79f2f900fd6087a66866537e94b5a11fd0fd99ef7efe5c42",
"sha256:498843b66b9ca416e9d03037e5875c8d0c0ab9037527e22df3b39aa5163214cd",
"sha256:49f51fab7b762da7c2cee0a3d575184d3b9be5e2f64f26cae2dd286258ac9b3c",
"sha256:4b948a03f89f5c72d69d40975af8af241111f0643228796558dc1cae8f5560b0",
"sha256:4ec1f57127879b419a2c8d2db9d9978eb26c61ae17e5972197830430ae78d25b",
"sha256:50ce82a71b22a3069c02e914bf842118a53065e2ec1c6fb54786e03608ab89cc",
"sha256:5384f1d9f30876f5d5b618464fb19ff7ce6c0fe4c690fbaafd1c52adc3aae807",
"sha256:561076ca0dcef2fbc20b2bc2765bff099e002e96041ae9dbe910a863ca6ee3ea",
"sha256:59dc8bfacf89b8f5be54d55bc3b4bd6d74d0c5320c8a63d2538ac7df5b96f1d5",
"sha256:5daff3fb5bfef30bc5a2cb143810d376d43461445aa17aece7210de52adbe151",
"sha256:61b0bcf946fdfd8ab5f09179dc2b5c74d1ef47cedfc6ed0ec01fdf0ee8682dd3",
"sha256:61e6aa1d30c2af692aa88c4dd48709426e8b37bff6a574ee2de677579c34a3d6",
"sha256:649cdf19df175925ad87289ead6f760cd840730ee85abc5eb43be326a0a24d97",
"sha256:66b8a90b28c13c2aae7a71b32638ceb14cefc2a1c8cf23d8d50dfb64dfac7aaf",
"sha256:6b7c9aa77bbce61a5e681bd39cb6a804338474dcc90abe3c543592aa5d6c9a9b",
"sha256:75aa692936942ccb2e8fd6a386c81c61630ac1b6d6e921698122db8a930579c3",
"sha256:75bb5be3c5de702a547715f320ecf5c8014aeca750ed5147ca75389bd22e7343",
"sha256:761df3c7e2c5270088b691c5a8121004f84318177da1ca1db64222ec83c44871",
"sha256:77709139af5123c578ab06cf999429cdb9ab211047acd0c787e098dcb3f1cb4d",
"sha256:7deae3a312feb5c17c97cbf18129f83cbd3f1f9ec25b0f50e2bd9697befb22e7",
"sha256:82daaab720866bf690b20b49de5640b0c27e3b8eea2d08aa75bdca2b0f0cfb63",
"sha256:883dc3d3942620f4c7dbc3fd6162f50a67f050b714e47da77444e3bcea7d91cc",
"sha256:89585adc73395a10306d2e2036e50d6c4ac0cf8dd47edf914c25488871b64f6d",
"sha256:8970f6a411a9839a02b23b7e90bbbba4a6de52ace009274998566dc43f36ca18",
"sha256:91687671fd9d484a4e201ad266d366b695a45a1f2b41be93d116ba60f1b8f3b3",
"sha256:919bc1b010aa6ff0eb918838ff73a435aed9e9a19c3202b91acecd296bf75607",
"sha256:92fd765591c83e5c5f409b33eac1d3266c03d3d11c71a7dbade36d5cdee4fbc0",
"sha256:994e4741d5ed70fc2a335a91ef79343c6b1089d7dfe6e955dd06f8ffe82bede6",
"sha256:9b94749130ef3119375c599bfce82142c2500ef9ed3280089157ee37662a7137",
"sha256:9d3f686e3d1c8900c5459eee02b60c7399e20ec5c6402364068a343c83a61d90",
"sha256:9eba0c7c12126b12f7fcbea5513f28c950d28f33d2a227f74b50b77789e478e8",
"sha256:a0e1bd0260c1da35c1883321ce2707ceea07127816ab625e1226ec95177b561a",
"sha256:a0f7a16138279d707db778a63264d1d6016ac13ffd3f1e99f54b2855d6c0d8e1",
"sha256:a32d546a1752e4ee7805d6db57944f7224afa7428d22867006b6486e4195c1f3",
"sha256:a433f6162b18d52f7068175d00bd5b1563b7405f926a48d888a97b90a160c40d",
"sha256:a892b4b139126a86bfdcb97cd912a2f8c4e8623869c3ef7b50871451dd7afeb0",
"sha256:a910b1193cd90af17228f5d6069816646df0148f14f53eefa6b2b11a1dedfcd0",
"sha256:aabdbc082030f8df613e2d2ea1f974e7ad36a539bdfc40d36f34e55c7e4b8e94",
"sha256:add774341c09853b1612c64a526032d95ab1683053325403e1afbe3ad2f374c5",
"sha256:ae521ed9287f86aac979eeac43af762f03d9d9797b2272185fb9ddd810391216",
"sha256:af44b9e59c4b2926a4e3c7f9d29949ff42fcea28637ff6b8182e654461932be8",
"sha256:b0c094d5e65a46dbf3fe0928ff20873a747e6abfd2ed4b675beeb2750624bc2e",
"sha256:b0d16775094423088ffa357d09fbbb9ab48d2fb721d42c0856b801c86f616eec",
"sha256:b5019fb33711c30e54e4e57ae0ca70af9d35b589d385ac04acd6954452fa73bb",
"sha256:baa99cebf95c1885db21e119395f222a706a2bb75a545f0672880a442137725e",
"sha256:bb6d8ce31dc25faf4da92991320e211fa7f42de010ef51937b1dc565a4926501",
"sha256:bbc30c98ab006ab9fc47e5ed439c00f706bc9d4441ff52693b8b6fea335163e0",
"sha256:c55fa832fc3fe64e0d29da5dc9b50ba66ca93312107cec2709300ea3d3bab5c7",
"sha256:c5e8db6e1ee7267b7c412ad0afd5863bf7a95286b8333a5958c8097c69f94cf5",
"sha256:c5f3e33fe6cbab481727f9aeb136a213aed7e33cd1ca27bd75e916ffacc18411",
"sha256:cc8878935671490efe9275fb4190a6062b73277bd273237179b9b5a2aa436153",
"sha256:ce7c3ce28f94302df95eaea7c9c1e2c974b6d15d78a0c82142a97939d7b6c082",
"sha256:cead7c0307977a00b3f784cff676e72c147adbcada19a2e6fc2ddf54f37cf387",
"sha256:d2d15a707e7f689531eb4134eccb0f8bf3844bb8255ad50823aa39708d9e6755",
"sha256:d4d4519123aac73c93159eb8f61db9682393862dd669e7eae034ecd0a35eadac",
"sha256:d93a44d0104d1b9b10de4e7aadf747f6efc1d7ec5ed0aa3f233a720725dd31bd",
"sha256:dad638cde3a5357ad3163b80b3127df61fb5b5e34e9e05a87697144400ba03c7",
"sha256:e0773cd5c438ffcd5dbff91cdd503574f88a4b960e70cedeb67736583a17a918",
"sha256:e172c1ee40507ae3b8d220f4048aaca204f203e1e4197e8e652f5c814f61d1aa",
"sha256:e4af8bc5c3fcc2192c266421c6aa2daab1a18e002cb8e66ef672030e46ae25cf",
"sha256:e57d94a1552af67f67b27db5dba0b03783ea69d5ca2af2f40e098f0ba3ce3f5f",
"sha256:e6b2ba4ff53dd5f57d728095e3def7375eb19c90621ce3b41b256de84ec61cfd",
"sha256:e8be562e2ce3e481d9209b6f254c3d7c5ff920eb256aba2380d2fb5ba75d4f87",
"sha256:e8ed3bd2b8bb3277710843ca63e4f5c3ee6f8f80b083be5b19a7a9905420d11e",
"sha256:e998efb190653f70e0f30d92b39fc645145369a4823bee46af8ddfc244aa969d",
"sha256:eaa3ea15025b56076d806b248948612289b093e8dcda8d013776b3848dffff15",
"sha256:f4ce006215497993ae77c612c1883ca4f3973899573ce0c52fee91f0d39c4561",
"sha256:f7b79f0f302396d8e0d444826ceb3d07b61977793886ebae04e82796c02e42dc",
"sha256:f94163ebe2d5546e6a5977e96d83621f4689c1054053428cf8d4c28b10f92f69",
"sha256:f988daf25f31726d5b9d0be6af636ca9000898f9ea43a57eac594daea25b0948",
"sha256:fbcd613a5e76b1495fc24db9c37a6b7ee5f214fd85979187ec4e032abfc12ded",
"sha256:fe454aeab348c42f56d6f7434ff758a3ef90787ac81b9ad5a363cd61b90a1b0b"
],
"index": "pypi",
"version": "==3.2.0"
},
"yarl": {
"hashes": [
@@ -1839,7 +2076,7 @@
"sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df",
"sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"
],
"markers": "python_version >= '3.7'",
"markers": "python_full_version >= '3.7.0'",
"version": "==3.1.0"
},
"click": {
@@ -1852,60 +2089,69 @@
},
"coverage": {
"hashes": [
"sha256:06ddd9c0249a0546997fdda5a30fbcb40f23926df0a874a60a8a185bc3a87d93",
"sha256:0743b0035d4b0e32bc1df5de70fba3059662ace5b9a2a86a9f894cfe66569013",
"sha256:0f3736a5d34e091b0a611964c6262fd68ca4363df56185902528f0b75dbb9c1f",
"sha256:1127b16220f7bfb3f1049ed4a62d26d81970a723544e8252db0efde853268e21",
"sha256:172db976ae6327ed4728e2507daf8a4de73c7cc89796483e0a9198fd2e47b462",
"sha256:182eb9ac3f2b4874a1f41b78b87db20b66da6b9cdc32737fbbf4fea0c35b23fc",
"sha256:1bb1e77a9a311346294621be905ea8a2c30d3ad371fc15bb72e98bfcfae532df",
"sha256:1fd78b911aea9cec3b7e1e2622c8018d51c0d2bbcf8faaf53c2497eb114911c1",
"sha256:20d1a2a76bb4eb00e4d36b9699f9b7aba93271c9c29220ad4c6a9581a0320235",
"sha256:21b154aba06df42e4b96fc915512ab39595105f6c483991287021ed95776d934",
"sha256:2c2e58e45fe53fab81f85474e5d4d226eeab0f27b45aa062856c89389da2f0d9",
"sha256:2c3b2803e730dc2797a017335827e9da6da0e84c745ce0f552e66400abdfb9a1",
"sha256:3146b8e16fa60427e03884301bf8209221f5761ac754ee6b267642a2fd354c48",
"sha256:344e714bd0fe921fc72d97404ebbdbf9127bac0ca1ff66d7b79efc143cf7c0c4",
"sha256:387065e420aed3c71b61af7e82c7b6bc1c592f7e3c7a66e9f78dd178699da4fe",
"sha256:3f04becd4fcda03c0160d0da9c8f0c246bc78f2f7af0feea1ec0930e7c93fa4a",
"sha256:4a42e1eff0ca9a7cb7dc9ecda41dfc7cbc17cb1d02117214be0561bd1134772b",
"sha256:4ea748802cc0de4de92ef8244dd84ffd793bd2e7be784cd8394d557a3c751e21",
"sha256:55416d7385774285b6e2a5feca0af9652f7f444a4fa3d29d8ab052fafef9d00d",
"sha256:5d0391fb4cfc171ce40437f67eb050a340fdbd0f9f49d6353a387f1b7f9dd4fa",
"sha256:63cdeaac4ae85a179a8d6bc09b77b564c096250d759eed343a89d91bce8b6367",
"sha256:72fcae5bcac3333a4cf3b8f34eec99cea1187acd55af723bcbd559adfdcb5535",
"sha256:7c4ed4e9f3b123aa403ab424430b426a1992e6f4c8fd3cb56ea520446e04d152",
"sha256:83957d349838a636e768251c7e9979e899a569794b44c3728eaebd11d848e58e",
"sha256:87ecc7c9a1a9f912e306997ffee020297ccb5ea388421fe62a2a02747e4d5539",
"sha256:8f69770f5ca1994cb32c38965e95f57504d3aea96b6c024624fdd5bb1aa494a1",
"sha256:8f6c930fd70d91ddee53194e93029e3ef2aabe26725aa3c2753df057e296b925",
"sha256:965ee3e782c7892befc25575fa171b521d33798132692df428a09efacaffe8d0",
"sha256:974bc90d6f6c1e59ceb1516ab00cf1cdfbb2e555795d49fa9571d611f449bcb2",
"sha256:981b4df72c93e3bc04478153df516d385317628bd9c10be699c93c26ddcca8ab",
"sha256:aa784405f0c640940595fa0f14064d8e84aff0b0f762fa18393e2760a2cf5841",
"sha256:ae7863a1d8db6a014b6f2ff9c1582ab1aad55a6d25bac19710a8df68921b6e30",
"sha256:aeae2aa38395b18106e552833f2a50c27ea0000122bde421c31d11ed7e6f9c91",
"sha256:b2317d5ed777bf5a033e83d4f1389fd4ef045763141d8f10eb09a7035cee774c",
"sha256:be19931a8dcbe6ab464f3339966856996b12a00f9fe53f346ab3be872d03e257",
"sha256:be9824c1c874b73b96288c6d3de793bf7f3a597770205068c6163ea1f326e8b9",
"sha256:c0045f8f23a5fb30b2eb3b8a83664d8dc4fb58faddf8155d7109166adb9f2040",
"sha256:c86bd45d1659b1ae3d0ba1909326b03598affbc9ed71520e0ff8c31a993ad911",
"sha256:ca0f34363e2634deffd390a0fef1aa99168ae9ed2af01af4a1f5865e362f8623",
"sha256:d298c2815fa4891edd9abe5ad6e6cb4207104c7dd9fd13aea3fdebf6f9b91259",
"sha256:d2a3a6146fe9319926e1d477842ca2a63fe99af5ae690b1f5c11e6af074a6b5c",
"sha256:dfd393094cd82ceb9b40df4c77976015a314b267d498268a076e940fe7be6b79",
"sha256:e58c0d41d336569d63d1b113bd573db8363bc4146f39444125b7f8060e4e04f5",
"sha256:ea3f5bc91d7d457da7d48c7a732beaf79d0c8131df3ab278e6bba6297e23c6c4",
"sha256:ea53151d87c52e98133eb8ac78f1206498c015849662ca8dc246255265d9c3c4",
"sha256:eb0edc3ce9760d2f21637766c3aa04822030e7451981ce569a1b3456b7053f22",
"sha256:f649dd53833b495c3ebd04d6eec58479454a1784987af8afb77540d6c1767abd",
"sha256:f760073fcf8f3d6933178d67754f4f2d4e924e321f4bb0dcef0424ca0215eba1",
"sha256:fa546d66639d69aa967bf08156eb8c9d0cd6f6de84be9e8c9819f52ad499c910",
"sha256:fd214917cabdd6f673a29d708574e9fbdb892cb77eb426d0eae3490d95ca7859",
"sha256:fff5aaa6becf2c6a1699ae6a39e2e6fb0672c2d42eca8eb0cafa91cf2e9bd312"
"sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f",
"sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2",
"sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a",
"sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a",
"sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01",
"sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6",
"sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7",
"sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f",
"sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02",
"sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c",
"sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063",
"sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a",
"sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5",
"sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959",
"sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97",
"sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6",
"sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f",
"sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9",
"sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5",
"sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f",
"sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562",
"sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe",
"sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9",
"sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f",
"sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb",
"sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb",
"sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1",
"sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb",
"sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250",
"sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e",
"sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511",
"sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5",
"sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59",
"sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2",
"sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d",
"sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3",
"sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4",
"sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de",
"sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9",
"sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833",
"sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0",
"sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9",
"sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d",
"sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050",
"sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d",
"sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6",
"sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353",
"sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb",
"sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e",
"sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8",
"sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495",
"sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2",
"sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd",
"sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27",
"sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1",
"sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818",
"sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4",
"sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e",
"sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850",
"sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"
],
"index": "pypi",
"version": "==7.2.3"
"version": "==7.2.7"
},
"distlib": {
"hashes": [
@@ -1914,21 +2160,13 @@
],
"version": "==0.3.6"
},
"exceptiongroup": {
"hashes": [
"sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e",
"sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"
],
"markers": "python_version < '3.11'",
"version": "==1.1.1"
},
"filelock": {
"hashes": [
"sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9",
"sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"
"sha256:42f1e4ff2b497311213d61ad7aac5fed9050608e5309573f101eefa94143134a",
"sha256:82b1f7da46f0ae42abf1bc78e548667f484ac59d2bcec38c713cee7e2eb51e83"
],
"markers": "python_version >= '3.7'",
"version": "==3.12.0"
"version": "==3.12.1"
},
"flake8": {
"hashes": [
@@ -2004,11 +2242,11 @@
},
"platformdirs": {
"hashes": [
"sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f",
"sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"
"sha256:0ade98a4895e87dc51d47151f7d2ec290365a585151d97b4d8d6312ed6132fed",
"sha256:e48fabd87db8f3a7df7150a4a5ea22c546ee8bc39bc2473244730d4b56d2cc4e"
],
"markers": "python_version >= '3.7'",
"version": "==3.5.1"
"version": "==3.5.3"
},
"pluggy": {
"hashes": [
@@ -2020,11 +2258,11 @@
},
"pre-commit": {
"hashes": [
"sha256:0b4210aea813fe81144e87c5a291f09ea66f199f367fa1df41b55e1d26e1e2b4",
"sha256:5b808fcbda4afbccf6d6633a56663fed35b6c2bc08096fd3d47ce197ac351d9d"
"sha256:66e37bec2d882de1f17f88075047ef8962581f83c234ac08da21a0c58953d1f0",
"sha256:8056bc52181efadf4aac792b1f4f255dfd2fb5a350ded7335d251a68561e8cb6"
],
"index": "pypi",
"version": "==3.2.2"
"version": "==3.3.2"
},
"pycodestyle": {
"hashes": [
@@ -2044,11 +2282,11 @@
},
"pytest": {
"hashes": [
"sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362",
"sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"
"sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295",
"sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b"
],
"index": "pypi",
"version": "==7.3.1"
"version": "==7.3.2"
},
"pytest-asyncio": {
"hashes": [
@@ -2150,21 +2388,13 @@
"markers": "python_version >= '3.7'",
"version": "==67.8.0"
},
"tomli": {
"hashes": [
"sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
"sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
],
"markers": "python_version < '3.11'",
"version": "==2.0.1"
},
"urllib3": {
"hashes": [
"sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc",
"sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"
"sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1",
"sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"
],
"markers": "python_version >= '3.7'",
"version": "==2.0.2"
"version": "==2.0.3"
},
"virtualenv": {
"hashes": [
@@ -2176,10 +2406,10 @@
},
"websocket-client": {
"hashes": [
"sha256:c7d67c13b928645f259d9b847ab5b57fd2d127213ca41ebd880de1f553b7c23b",
"sha256:f8c64e28cd700e7ba1f04350d66422b6833b82a796b525a51e740b8cc8dab4b1"
"sha256:3566f8467cd350874c4913816355642a4942f6c1ed1e9406e3d42fae6d6c072a",
"sha256:b96f3bce3e54e3486ebe6504bc22bd4c140392bd2eb71764db29be8f2639aa65"
],
"version": "==1.5.2"
"version": "==1.5.3"
}
}
}
+1 -12
View File
@@ -1,7 +1,6 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import asyncio
import sentry_sdk
from fastapi import FastAPI, Request
@@ -11,7 +10,6 @@ from starlette.middleware.sessions import SessionMiddleware
from classquiz.config import settings
from classquiz.db import database
from datetime import timedelta
from classquiz.oauth import rememberme_middleware
from classquiz.routers import (
@@ -36,8 +34,7 @@ from classquiz.routers import (
quiztivity,
)
from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks
from scheduler.asyncio import Scheduler
from classquiz.helpers import meilisearch_init, telemetry_ping
settings = settings()
if settings.sentry_dsn:
@@ -58,13 +55,6 @@ async def sentry_exception(request: Request, call_next):
raise e
async def background_tasks():
schedule = Scheduler()
schedule.cyclic(timedelta(hours=6), bg_tasks.clean_editor_images_up)
while True:
await asyncio.sleep(1)
@app.on_event("startup")
async def startup() -> None:
database_ = app.state.database
@@ -72,7 +62,6 @@ async def startup() -> None:
await database_.connect()
await meilisearch_init()
await telemetry_ping()
asyncio.create_task(background_tasks())
@app.on_event("shutdown")
+15 -7
View File
@@ -9,6 +9,8 @@ import redis as redis_base_lib
from pydantic import BaseSettings, RedisDsn, PostgresDsn, BaseModel
import meilisearch as MeiliSearch
from typing import Optional
from arq import create_pool
from arq.connections import RedisSettings, ArqRedis
from classquiz.storage import Storage
@@ -48,12 +50,10 @@ class Settings(BaseSettings):
github_client_secret: Optional[str]
custom_openid_provider: CustomOpenIDProvider | None = None
telemetry_enabled: bool = True
free_storage_limit: int = 1074000000
# storage_backend
storage_backend: str | None = "deta"
# if storage_backend == "deta":
deta_project_key: str | None
deta_project_id: str | None
storage_backend: str | None = "local"
# if storage_backend == "local":
storage_path: str | None
@@ -70,16 +70,24 @@ class Settings(BaseSettings):
env_nested_delimiter = "__"
async def initialize_arq():
global arq
arq = await create_pool(RedisSettings.from_dsn(settings.redis))
@lru_cache()
def settings() -> Settings:
return Settings()
redis: redis_base_lib.client.Redis = redis_lib.Redis().from_url(settings().redis)
# asyncio.run(initialize_arq())
pool = redis_lib.ConnectionPool().from_url(settings().redis)
redis: redis_base_lib.client.Redis = redis_lib.Redis(connection_pool=pool)
arq: ArqRedis = ArqRedis(pool_or_conn=pool)
storage: Storage = Storage(
backend=settings().storage_backend,
deta_key=settings().deta_project_key,
deta_id=settings().deta_project_id,
storage_path=settings().storage_path,
access_key=settings().s3_access_key,
secret_key=settings().s3_secret_key,
+104 -9
View File
@@ -8,6 +8,7 @@ from datetime import datetime
from typing import Optional
import ormar
from ormar import ReferentialAction
from pydantic import BaseModel, Json, validator
from enum import Enum
from . import metadata, database
@@ -42,6 +43,7 @@ class User(ormar.Model):
require_password: bool = ormar.Boolean(default=True, nullable=False)
backup_code: str = ormar.String(max_length=64, min_length=64, nullable=False, default=os.urandom(32).hex())
totp_secret: str = ormar.String(max_length=32, min_length=32, nullable=True, default=None)
storage_used: int = ormar.BigInteger(nullable=False, default=0, minimum=0)
class Meta:
tablename = "users"
@@ -57,7 +59,7 @@ class FidoCredentials(ormar.Model):
id: bytes = ormar.LargeBinary(max_length=256)
public_key: bytes = ormar.LargeBinary(max_length=256)
sign_count: int = ormar.Integer()
user: Optional[User] = ormar.ForeignKey(User)
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
class Meta:
tablename = "fido_credentials"
@@ -67,7 +69,7 @@ class FidoCredentials(ormar.Model):
class ApiKey(ormar.Model):
key: str = ormar.String(max_length=48, min_length=48, primary_key=True)
user: Optional[User] = ormar.ForeignKey(User)
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
class Meta:
tablename = "api_keys"
@@ -81,7 +83,7 @@ class UserSession(ormar.Model):
"""
id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4())
user: Optional[User] = ormar.ForeignKey(User)
user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
session_key: str = ormar.String(unique=True, max_length=64)
created_at: datetime = ormar.DateTime(default=datetime.now())
ip_address: str = ormar.String(max_length=100, nullable=True)
@@ -180,7 +182,7 @@ class Quiz(ormar.Model):
description: str = ormar.Text(nullable=True)
created_at: datetime = ormar.DateTime(default=datetime.now())
updated_at: datetime = ormar.DateTime(default=datetime.now())
user_id: uuid.UUID = ormar.ForeignKey(User)
user_id: uuid.UUID = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False)
imported_from_kahoot: Optional[bool] = ormar.Boolean(default=False, nullable=True)
cover_image: Optional[str] = ormar.Text(nullable=True, unique=False)
@@ -306,8 +308,8 @@ class GameInLobby(BaseModel):
class GameResults(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz)
user: uuid.UUID | User = ormar.ForeignKey(User)
quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz, ondelete=ReferentialAction.CASCADE)
user: uuid.UUID | User = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
timestamp: datetime = ormar.DateTime(default=datetime.now(), nullable=False)
player_count: int = ormar.Integer(nullable=False, default=0)
note: str | None = ormar.Text(nullable=True)
@@ -333,7 +335,7 @@ class QuizTivity(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
title: str = ormar.Text(nullable=False)
created_at: datetime = ormar.DateTime(nullable=False, server_default=func.now())
user: User | None = ormar.ForeignKey(User)
user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
pages: list[QuizTivityPage] = ormar.JSON(nullable=False)
class Meta:
@@ -346,8 +348,8 @@ class QuizTivityShare(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
name: str | None = ormar.Text(nullable=True)
expire_at: datetime | None = ormar.DateTime(nullable=True)
quiztivity: QuizTivity | None = ormar.ForeignKey(QuizTivity)
user: User | None = ormar.ForeignKey(User)
quiztivity: QuizTivity | None = ormar.ForeignKey(QuizTivity, ondelete=ReferentialAction.CASCADE)
user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
class Meta:
tablename = "quiztivityshares"
@@ -378,3 +380,96 @@ class PublicQuizTivityShare(BaseModel):
quiztivity=OnlyId(id=data.quiztivity.id),
user=OnlyId(id=data.user.id),
)
class StorageItem(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True)
uploaded_at: datetime = ormar.DateTime(nullable=False, default=datetime.now())
mime_type: str = ormar.Text(nullable=False)
hash: bytes | None = ormar.LargeBinary(nullable=True, min_length=16, max_length=16)
user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.SET_NULL)
size: int = ormar.BigInteger(nullable=False)
storage_path: str | None = ormar.Text(nullable=True)
deleted_at: datetime | None = ormar.DateTime(nullable=True, default=None)
quiztivities: list[QuizTivity] | None = ormar.ManyToMany(QuizTivity)
quizzes: list[Quiz] | None = ormar.ManyToMany(Quiz)
alt_text: str | None = ormar.Text(default=None, nullable=True)
filename: str | None = ormar.Text(default=None, nullable=True)
thumbhash: str | None = ormar.Text(default=None, nullable=True)
server: str | None = ormar.Text(default=None, nullable=True)
imported: bool = ormar.Boolean(default=False, nullable=True)
class Meta:
tablename = "storage_items"
metadata = metadata
database = database
class PublicStorageItem(BaseModel):
id: uuid.UUID
uploaded_at: datetime
mime_type: str
hash: str | None
size: int
deleted_at: datetime | None
alt_text: str | None
filename: str | None
thumbhash: str | None
server: str | None
imported: bool
@classmethod
def from_db_model(cls, data: StorageItem):
hash_data = None
if data.hash is not None:
hash_data = data.hash.hex()
return cls(
id=data.id,
uploaded_at=data.uploaded_at,
mime_type=data.mime_type,
hash=hash_data,
size=data.size,
deleted_at=data.deleted_at,
alt_text=data.alt_text,
filename=data.filename,
thumbhash=data.thumbhash,
server=data.server,
imported=data.imported,
)
class PrivateStorageItem(PublicStorageItem):
quizzes: list[OnlyId]
quiztivities: list[OnlyId]
@classmethod
def from_db_model(cls, data: StorageItem):
hash_data = None
if data.hash is not None:
hash_data = data.hash.hex()
quiztivities = []
quizzes = []
for quiz in data.quizzes:
quizzes.append(OnlyId(id=quiz.id))
for quiztivity in data.quiztivities:
quiztivities.append(OnlyId(id=quiztivity.id))
return cls(
id=data.id,
uploaded_at=data.uploaded_at,
mime_type=data.mime_type,
hash=hash_data,
size=data.size,
deleted_at=data.deleted_at,
alt_text=data.alt_text,
filename=data.filename,
quiztivities=quiztivities,
quizzes=quizzes,
thumbhash=data.thumbhash,
server=data.server,
imported=data.imported,
)
class UpdateStorageItem(BaseModel):
filename: str
alt_text: str
+43 -6
View File
@@ -3,6 +3,7 @@
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import asyncio
import uuid
from typing import Optional
import ormar.exceptions
@@ -64,12 +65,15 @@ async def generate_spreadsheet(quiz_results: dict, quiz: Quiz, player_fields: di
worksheet.write(i + 1, 1, question["time"])
try:
async with ClientSession() as session, session.get(question["image"]) as response:
img_data = BytesIO(await response.read())
worksheet.insert_image(i + 1, 2, question["image"], {"image_data": img_data})
image = Image.open(img_data)
worksheet.set_row(i + 1, image.height)
worksheet.set_column(2, 2, image.width)
async with ClientSession() as session, session.get(
f"{settings.root_address}/api/v1/storage/download/{question['image']}"
) as response:
if "image" in response.headers.get("Content-Type"):
img_data = BytesIO(await response.read())
worksheet.insert_image(i + 1, 2, question["image"], {"image_data": img_data})
image = Image.open(img_data)
worksheet.set_row(i + 1, image.height)
worksheet.set_column(2, 2, image.width)
except TypeError:
pass
answer_amount = len(answer_data)
@@ -175,3 +179,36 @@ def check_hashcash(data: str, input_data: str, claim_in: Optional[str] = "19") -
return False
some_error = [version == "1", claim == claim_in, res == input_data, ext == ""]
return all(el is True for el in some_error)
def check_image_string(image: str) -> (bool, uuid.UUID | None):
# Valid formats: {uuid} and {uuid}--{uuid}
try:
parsed_uuid = uuid.UUID(image)
return True, parsed_uuid
except ValueError:
pass
split_image = image.split("--")
if len(split_image) != 2:
return False, None
try:
uuid.UUID(split_image[0])
uuid.UUID(split_image[1])
return True, None
except ValueError:
return False, None
def extract_image_ids_from_quiz(quiz: Quiz) -> list[str | uuid.UUID]:
quiz_images = []
if quiz.background_image is not None:
quiz_images.append(quiz.background_image)
if quiz.cover_image is not None:
quiz_images.append(quiz.cover_image)
for question in quiz.questions:
if question["image"] is None:
continue
quiz_images.append(question["image"])
return quiz_images
-21
View File
@@ -1,21 +0,0 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from classquiz.config import redis, storage
from classquiz.storage.errors import DeletionFailedError
async def clean_editor_images_up():
print("Cleaning images up")
edit_sessions = await redis.smembers("edit_sessions")
for session_id in edit_sessions:
session = await redis.get(f"edit_session:{session_id}")
if session is None:
images = await redis.lrange(f"edit_session:{session_id}:images", 0, 3000)
if len(images) != 0:
try:
await storage.delete(images)
except DeletionFailedError:
print("Deletion Error", images)
await redis.srem("edit_sessions", session_id)
await redis.delete(f"edit_session:{session_id}:images")
+33 -11
View File
@@ -3,6 +3,7 @@
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import html
import io
import json
import uuid
from datetime import datetime
@@ -10,8 +11,8 @@ from datetime import datetime
import bleach
from aiohttp import ClientSession
from classquiz.config import settings, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ
from classquiz.db.models import Quiz, ABCDQuizAnswer, QuizQuestion, User
from classquiz.config import settings, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, arq
from classquiz.db.models import Quiz, ABCDQuizAnswer, QuizQuestion, User, StorageItem
from classquiz.kahoot_importer.get import get as get_quiz
from classquiz.helpers import get_meili_data
@@ -26,6 +27,25 @@ async def _download_image(url: str) -> bytes:
DEFAULT_COLORS = ["#D6EDC9", "#B07156", "#7F7057", "#4E6E58"]
async def handle_image_upload(url: str, user: User) -> StorageItem:
image_bytes = await _download_image(url)
file_obj = StorageItem(
id=uuid.uuid4(),
uploaded_at=datetime.now(),
mime_type="application/octet-stream",
hash=None,
user=user,
size=0,
deleted_at=None,
alt_text=None,
imported=True,
)
await file_obj.save()
await storage.upload(file_name=file_obj.id.hex, file_data=io.BytesIO(image_bytes))
await arq.enqueue_job("calculate_hash", file_obj.id.hex)
return file_obj
async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
"""
Imports a quiz from Kahoot.
@@ -41,15 +61,15 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
quiz_id = uuid.uuid4()
meilisearch.delete_index(settings.meilisearch_index)
meilisearch.create_index(settings.meilisearch_index)
uploaded_images: list[StorageItem] = []
for q in quiz.kahoot.questions:
answers: list[ABCDQuizAnswer] = []
image = None
if q.image is not None and q.image != "":
image_bytes = await _download_image(q.image)
image_name = f"{quiz_id}--{uuid.uuid4()}"
image = await storage.upload(file_name=image_name, file_data=image_bytes)
image = f"{settings.root_address}/api/v1/storage/download/{image_name}"
image_obj = await handle_image_upload(q.image, user)
uploaded_images.append(image_obj)
image = image_obj.id.hex
for i, a in enumerate(q.choices):
answers.append(
(
@@ -71,10 +91,9 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
)
cover = None
if quiz.kahoot.cover != "" and quiz.kahoot.cover is not None:
image_bytes = await _download_image(quiz.kahoot.cover)
image_name = f"{quiz_id}--{uuid.uuid4()}"
await storage.upload(file_name=image_name, file_data=image_bytes)
cover = f"{settings.root_address}/api/v1/storage/download/{image_name}"
img_obj = await handle_image_upload(quiz.kahoot.cover, user)
uploaded_images.append(img_obj)
cover = img_obj.id.hex
quiz_data = Quiz(
id=quiz_id,
public=True,
@@ -89,4 +108,7 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
kahoot_id=uuid.UUID(kahoot_quiz_id),
)
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz_data)])
return await quiz_data.save()
await quiz_data.save()
for img in uploaded_images:
await quiz_data.storageitems.add(img)
return quiz_data
+21 -86
View File
@@ -4,24 +4,22 @@
import asyncio
import html
import re
import uuid
from typing import Optional
import asyncpg.exceptions
import bleach
from fastapi import APIRouter, File, UploadFile, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, server_regex
from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType
import puremagic
from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, arq
from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType, StorageItem
from classquiz.auth import get_current_user
import os
from datetime import datetime
from uuid import UUID
from classquiz.helpers import get_meili_data, check_hashcash
from classquiz.helpers import get_meili_data, check_image_string, extract_image_ids_from_quiz
from classquiz.storage.errors import DeletionFailedError
settings = settings()
@@ -67,56 +65,6 @@ async def init_editor(edit: bool, quiz_id: Optional[UUID] = None, user: User = D
return InitEditorResponse(token=edit_id)
class GetPowData(BaseModel):
data: str
@router.get("/pow", response_model=GetPowData)
async def get_pow_data(edit_id: str):
session_data = await redis.get(f"edit_session:{edit_id}")
if session_data is None:
raise HTTPException(status_code=401, detail="Edit ID not found!")
random_str = os.urandom(8).hex()
await redis.set(f"edit_session:{edit_id}:pow", random_str, ex=3800)
return GetPowData(data=random_str)
class UploadImageReturn(BaseModel):
id: str
pow_data: str
@router.post("/image", response_model=UploadImageReturn)
async def upload_image(edit_id: str, pow_data: str, file: UploadFile = File()):
session_data = await redis.get(f"edit_session:{edit_id}")
pow_data_server = await redis.get(f"edit_session:{edit_id}:pow")
uploaded_images = await redis.llen(f"edit_session:{edit_id}:images")
if pow_data_server is None:
raise HTTPException(status_code=401, detail="Edit ID not found!")
if session_data is None:
raise HTTPException(status_code=401, detail="Edit ID not found!")
if uploaded_images == 0 and not check_hashcash(pow_data, pow_data_server, "8"):
raise HTTPException(status_code=401, detail="Edit ID not found!")
if uploaded_images != 0 and not check_hashcash(pow_data, pow_data_server, "8"):
raise HTTPException(status_code=401, detail="Edit ID not found!")
file_bytes = await file.read()
if len(file_bytes) > 2000000:
raise HTTPException(status_code=400, detail="File too large")
try:
pm_data = puremagic.magic_string(file_bytes)[0]
except puremagic.PureError:
raise HTTPException(status_code=400, detail="Image couldn't be identified!")
if pm_data.extension not in allowed_image_extensions:
raise HTTPException(status_code=400, detail="Image-type now allowed!")
session_data = EditSessionData.parse_raw(session_data)
file_name = f"{session_data.quiz_id}--{uuid.uuid4()}"
await storage.upload(file_name=file_name, file_data=file_bytes)
await redis.lpush(f"edit_session:{edit_id}:images", file_name)
random_str = os.urandom(8).hex()
await redis.set(f"edit_session:{edit_id}:pow", random_str, ex=3800)
return UploadImageReturn(id=file_name, pow_data=random_str)
@router.post("/finish")
async def finish_edit(edit_id: str, quiz_input: QuizInput):
session_data = await redis.get(f"edit_session:{edit_id}")
@@ -141,25 +89,10 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
quiz_input.questions[i].answers[i2].answer = html.unescape(
bleach.clean(answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)
)
image_id_regex = r"^.{36}--.{36}$"
imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$"
extract_file_name_re = r"^.*/api/v1/storage/download/(.{36}--.{36})$"
images_to_delete = []
old_quiz_data: Quiz = await Quiz.objects.get_or_none(id=session_data.quiz_id, user_id=session_data.user_id)
def mark_image_for_deletion(new: str | None, index: int, old_quiz: Quiz | None):
if old_quiz is None:
return
try:
# Why does this work or not throw an error (TODO)
if new == old_quiz.questions[index]["image"]:
return
else:
images_to_delete.append(old_quiz.questions[index]["image"])
except IndexError:
pass
for i, question in enumerate(quiz_input.questions):
image = question.image
quiz_input.questions[i].question = html.unescape(
@@ -167,17 +100,7 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
)
if image == "":
question.image = None
mark_image_for_deletion(question.image, i, old_quiz_data)
elif image is None:
mark_image_for_deletion(question.image, i, old_quiz_data)
elif bool(re.match(image_id_regex, question.image)):
question.image = f"{settings.root_address}/api/v1/storage/download/{image}"
mark_image_for_deletion(question.image, i, old_quiz_data)
elif bool(re.match(imgur_regex, image)):
mark_image_for_deletion(question.image, i, old_quiz_data)
elif bool(re.match(server_regex, image)):
mark_image_for_deletion(question.image, i, old_quiz_data)
else:
if image is not None and not check_image_string(image)[0]:
raise HTTPException(status_code=400, detail="Image URL(s) aren't valid!")
if quiz_input.cover_image == "":
@@ -186,10 +109,14 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
# if quiz_input.background_image is None and old_quiz_data.background_image is not None:
# mark_image_for_deletion(quiz_input.background_image)
if quiz_input.cover_image is not None and not bool(re.match(server_regex, quiz_input.cover_image)):
if quiz_input.cover_image is not None and not check_image_string(quiz_input.cover_image)[0]:
raise HTTPException(status_code=400, detail="image url is not valid")
if quiz_input.background_image is not None and not check_image_string(quiz_input.background_image)[0]:
raise HTTPException(status_code=400, detail="image url is not valid")
if session_data.edit:
await arq.enqueue_job("quiz_update", old_quiz_data, old_quiz_data.id, _defer_by=2)
quiz = old_quiz_data
meilisearch.index(settings.meilisearch_index).update_documents([await get_meili_data(quiz)])
if not quiz_input.public:
@@ -207,13 +134,14 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
for image in images_to_delete:
if image is not None:
try:
await storage.delete([re.search(extract_file_name_re, image).group(1)])
await storage.delete([image])
except DeletionFailedError:
pass
await redis.srem("edit_sessions", edit_id)
await redis.delete(f"edit_session:{edit_id}")
await redis.delete(f"edit_session:{edit_id}:images")
return await quiz.update()
await quiz.update()
return quiz
else:
quiz = Quiz(
**quiz_input.dict(),
@@ -222,6 +150,7 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
created_at=datetime.now(),
updated_at=datetime.now(),
)
await redis.delete("global_quiz_count")
if quiz_input.public:
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz)])
@@ -229,6 +158,12 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
await redis.srem("edit_sessions", edit_id)
await redis.delete(f"edit_session:{edit_id}")
await redis.delete(f"edit_session:{edit_id}:images")
return await quiz.save()
await quiz.save()
except asyncpg.exceptions.UniqueViolationError:
raise HTTPException(status_code=400, detail="The quiz already exists")
new_images = extract_image_ids_from_quiz(quiz)
for image in new_images:
item = await StorageItem.objects.get_or_none(id=uuid.UUID(image))
if item is None:
continue
await quiz.storageitems.add(item)
+29 -6
View File
@@ -1,6 +1,7 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import io
import json
import uuid
from datetime import datetime
@@ -10,10 +11,11 @@ from aiohttp import ClientSession
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from fastapi.responses import StreamingResponse
from classquiz.auth import get_current_user
from classquiz.config import storage, settings
from classquiz.db.models import Quiz, User
from classquiz.config import storage, settings, arq
from classquiz.db.models import Quiz, User, StorageItem
import gzip
import urllib.parse
import magic
router = APIRouter()
settings = settings()
@@ -49,7 +51,9 @@ async def export_quiz(quiz_id: uuid.UUID, user: User = Depends(get_current_user)
for image_key in image_urls.keys():
bin_data = bin_data + image_delimiter + str(image_key).encode("utf-8") + image_index_delimiter
image_data = None
async with ClientSession() as session, session.get(image_urls[image_key]) as resp:
async with ClientSession() as session, session.get(
f"{settings.root_address}/api/v1/storage/download/{image_urls[image_key]}"
) as resp:
image_data = await resp.read()
bin_data = bin_data + image_data
@@ -68,6 +72,8 @@ async def export_quiz(quiz_id: uuid.UUID, user: User = Depends(get_current_user)
@router.post("/")
async def import_quiz(file: UploadFile = File(), user: User = Depends(get_current_user)):
if user.storage_used > settings.free_storage_limit:
raise HTTPException(status_code=409, detail="Storage limit reached")
data = await file.read()
[split_data, images] = data.split(quiz_delimiter)
decompressed_quiz = gzip.decompress(split_data)
@@ -75,15 +81,32 @@ async def import_quiz(file: UploadFile = File(), user: User = Depends(get_curren
image_splits = images.split(image_delimiter)
quiz_id = uuid.uuid4()
image_urls = {}
print(len(data))
for image_split in image_splits:
res = image_split.split(image_index_delimiter)
if len(res) != 2:
continue
[index, image_data] = res
print(len(image_data))
img_data = io.BytesIO(image_data)
mime_type = magic.from_buffer(img_data.read(2048), mime=True)
print(mime_type)
index = int(index.decode("utf-8"))
image_name = f"{quiz_id}--{uuid.uuid4()}"
await storage.upload(file_name=image_name, file_data=image_data)
image = f"{settings.root_address}/api/v1/storage/download/{image_name}"
file_id = uuid.uuid4()
file_obj = StorageItem(
id=file_id,
uploaded_at=datetime.now(),
mime_type=mime_type,
hash=None,
user=user,
size=0,
deleted_at=None,
alt_text=None,
)
await storage.upload(file_name=file_id.hex, file_data=img_data, mime_type=mime_type)
await file_obj.save()
await arq.enqueue_job("calculate_hash", file_id.hex)
image = file_id.hex
image_urls[index] = image
quiz_dict["created_at"] = datetime.fromisoformat(quiz_dict["created_at"])
quiz_dict["updated_at"] = datetime.fromisoformat(quiz_dict["updated_at"])
+9 -79
View File
@@ -11,17 +11,15 @@ from random import randint
import ormar.exceptions
from classquiz.helpers import get_meili_data, generate_spreadsheet
from classquiz.helpers import generate_spreadsheet
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import ValidationError, BaseModel
import bleach
from classquiz.auth import get_current_user
from classquiz.config import redis, settings, storage, meilisearch
from classquiz.db.models import Quiz, QuizInput, User, PlayGame, GameInLobby, QuizQuestion
from classquiz.db.models import Quiz, User, PlayGame, GameInLobby, QuizQuestion
from classquiz.kahoot_importer.import_quiz import import_quiz
import html
import urllib.parse
settings = settings()
@@ -29,33 +27,6 @@ settings = settings()
router = APIRouter()
@router.post("/create", deprecated=True)
async def create_quiz_lol(quiz_input: QuizInput, user: User = Depends(get_current_user)):
imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$"
server_regex = rf"^{re.escape(settings.root_address)}/api/v1/storage/download/.{36}--.{36}$"
quiz_input.title = html.unescape(bleach.clean(quiz_input.title, tags=[], strip=True))
quiz_input.description = html.unescape(bleach.clean(quiz_input.description, tags=[], strip=True))
for question in quiz_input.questions:
if question.image == "":
question.image = None
if (
question.image is not None
and not re.match(imgur_regex, question.image)
and not re.match(server_regex, question.image)
):
raise HTTPException(status_code=400, detail="image url is not valid")
if quiz_input.cover_image == "":
quiz_input.cover_image = None
if quiz_input.cover_image is not None and not bool(re.match(server_regex, quiz_input.cover_image)):
raise HTTPException(status_code=400, detail="image url is not valid")
quiz = Quiz(**quiz_input.dict(), user_id=user.id, id=uuid.uuid4())
await redis.delete("global_quiz_count")
if quiz_input.public:
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz)])
return await quiz.save()
@router.get("/get/{quiz_id}")
async def get_quiz_from_id(quiz_id: str, user: User | None = Depends(get_current_user)):
try:
@@ -213,57 +184,14 @@ async def get_quiz_list(user: User = Depends(get_current_user), page_size: int |
raise HTTPException(status_code=400, detail="Invalid page(size). page(size) have to be greater than 0.")
@router.put("/update/{quiz_id}", deprecated=True)
async def update_quiz(quiz_id: str, quiz_input: QuizInput, user: User = Depends(get_current_user)):
imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$"
server_regex = rf"^{re.escape(settings.root_address)}/api/v1/storage/download/.{{36}}--.{{36}}$"
for question in quiz_input.questions:
if question.image == "":
question.image = None
if (
question.image is not None
and not bool(re.match(server_regex, question.image))
and not bool(re.match(imgur_regex, question.image))
):
raise HTTPException(status_code=400, detail="image url is not valid")
try:
quiz_id = uuid.UUID(quiz_id)
except ValueError:
raise HTTPException(status_code=400, detail="badly formed quiz id")
# Check Cover-Image
if quiz_input.cover_image == "":
quiz_input.cover_image = None
if quiz_input.cover_image is not None and not bool(re.match(server_regex, quiz_input.cover_image)):
raise HTTPException(status_code=400, detail="image url is not valid")
quiz = await Quiz.objects.get_or_none(id=quiz_id, user_id=user.id)
if quiz is None:
return JSONResponse(status_code=404, content={"detail": "quiz not found"})
else:
quiz_input.description = html.unescape(bleach.clean(quiz_input.description, tags=[], strip=True))
quiz_input.title = html.unescape(bleach.clean(quiz_input.title, tags=[], strip=True))
meilisearch.index(settings.meilisearch_index).update_documents([await get_meili_data(quiz)])
if quiz.public and not quiz_input.public:
meilisearch.index(settings.meilisearch_index).delete_document(str(quiz.id))
if not quiz.public and quiz_input.public:
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz)])
quiz.title = quiz_input.title
quiz.cover_image = quiz_input.cover_image
quiz.public = quiz_input.public
quiz.description = quiz_input.description
quiz.updated_at = datetime.now()
quiz.questions = quiz_input.dict()["questions"]
return await quiz.update()
@router.post("/import/{quiz_id}")
async def import_quiz_route(quiz_id: str, user: User = Depends(get_current_user)):
if user.storage_used > settings.free_storage_limit:
raise HTTPException(status_code=409, detail="Storage limit reached")
try:
return await import_quiz(quiz_id, user)
except ValidationError:
except ValidationError as e:
print(e)
raise HTTPException(status_code=400, detail="This quiz isn't (yet) supported")
@@ -282,7 +210,9 @@ async def delete_quiz(quiz_id: str, user: User = Depends(get_current_user)):
for question in quiz.questions:
try:
if question["image"] is not None and not str(question["image"]).startswith("https://i.imgur.com/"):
pics_to_delete.append(pic_name_regex.match(question["image"]).group(1))
old_image_to_delete = pic_name_regex.match(question["image"])
if old_image_to_delete is not None:
pics_to_delete.append(old_image_to_delete.group(1))
except KeyError:
pass
if len(pics_to_delete) != 0:
+224 -10
View File
@@ -1,29 +1,53 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from datetime import datetime, timedelta
from tempfile import SpooledTemporaryFile
import re
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Request, Response
from fastapi.responses import StreamingResponse, RedirectResponse
from pydantic import BaseModel
from classquiz.config import settings, storage
from classquiz.auth import get_current_user
from classquiz.config import settings, storage, arq
from classquiz.db.models import User, StorageItem, PublicStorageItem, UpdateStorageItem, PrivateStorageItem
from classquiz.helpers import check_image_string
from classquiz.storage.errors import DownloadingFailedError
from uuid import uuid4, UUID
settings = settings()
router = APIRouter()
file_regex = r"^[a-z0-9]{8}-[a-z0-9-]{27}--[a-z0-9-]{36}$"
def headers_from_storage_item(item: StorageItem) -> dict[str, str]:
base_headers = {"Content-Type": item.mime_type}
if item.hash is not None:
base_headers["X-Hash"] = item.hash.hex()
if item.thumbhash is not None:
base_headers["X-Thumbhash"] = item.thumbhash
if item.alt_text is not None:
base_headers["X-Alt-Text"] = item.alt_text
if item.size != 0:
base_headers["Content-Size"] = str(item.size)
return base_headers
@router.get("/download/{file_name}")
async def download_file(file_name: str):
if not re.match(file_regex, file_name):
item = None
checked_image_string = check_image_string(file_name)
if not checked_image_string[0]:
raise HTTPException(status_code=400, detail="Invalid file name")
if checked_image_string[1] is not None:
item = await StorageItem.objects.get_or_none(id=checked_image_string[1])
if item is None:
raise HTTPException(status_code=404, detail="File not found")
file_name = item.storage_path
if file_name is None:
file_name = item.id.hex
if storage.backend == "s3":
print("redir")
return RedirectResponse(url=await storage.get_url(file_name, 300))
return RedirectResponse(url=await storage.get_url(file_name, 300), headers=headers_from_storage_item(item))
try:
download = await storage.download(file_name)
except DownloadingFailedError:
@@ -34,8 +58,198 @@ async def download_file(file_name: str):
def iter_file():
yield from download
media_type = "image/*"
if item is not None:
media_type = item.mime_type
headers = {"Cache-Control": "public, immutable, max-age=31536000"}
if item is not None:
headers = {**headers, **headers_from_storage_item(item)}
return StreamingResponse(
iter_file(),
media_type="image/*",
headers={"Cache-Control": "public, immutable, max-age=31536000"},
media_type=media_type,
headers=headers,
)
@router.get("/info/{file_name}")
async def get_basic_file_info(file_name: str) -> Response:
checked_image_string = check_image_string(file_name)
if not checked_image_string[0]:
raise HTTPException(status_code=404, detail="Invalid file name")
if checked_image_string[1] is not None:
item = await StorageItem.objects.get_or_none(id=checked_image_string[1])
if item is None:
raise HTTPException(status_code=404, detail="File not found")
# return PublicStorageItem.from_db_model(item)
storage_file_name = item.storage_path
if storage_file_name is None:
storage_file_name = item.id.hex
resp = Response(status_code=200, headers=headers_from_storage_item(item))
else:
resp = Response(status_code=200, headers={"Content-Type": "image/*"})
return resp
@router.head("/download/{file_name}")
async def download_file_head(file_name: str) -> Response:
checked_image_string = check_image_string(file_name)
if not checked_image_string[0]:
raise HTTPException(status_code=404, detail="Invalid file name")
if checked_image_string[1] is not None:
item = await StorageItem.objects.get_or_none(id=checked_image_string[1])
if item is None:
raise HTTPException(status_code=404, detail="File not found")
# return PublicStorageItem.from_db_model(item)
storage_file_name = item.storage_path
if storage_file_name is None:
storage_file_name = item.id.hex
resp = Response(status_code=200, headers=headers_from_storage_item(item))
else:
resp = Response(status_code=200, headers={"Content-Type": "image/*"})
storage_file_name = file_name
if storage.backend == "s3":
resp.status_code = 307
resp.headers.append("Location", await storage.get_url(storage_file_name, 300))
return resp
@router.post("/")
async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)) -> PublicStorageItem:
if user.storage_used > settings.free_storage_limit:
raise HTTPException(status_code=409, detail="Storage limit reached")
file_id = uuid4()
size = 0
file_obj = StorageItem(
id=file_id,
uploaded_at=datetime.now(),
mime_type=file.content_type,
hash=None,
user=user,
size=size,
deleted_at=None,
alt_text=None,
)
await storage.upload(file_name=file_id.hex, file_data=file.file, mime_type=file.content_type)
await file_obj.save()
await arq.enqueue_job("calculate_hash", file_id.hex)
return PublicStorageItem.from_db_model(file_obj)
@router.post("/raw")
async def upload_raw_file(request: Request, user: User = Depends(get_current_user)) -> PublicStorageItem:
if user.storage_used > settings.free_storage_limit:
raise HTTPException(status_code=409, detail="Storage limit reached")
file_id = uuid4()
data_file = SpooledTemporaryFile(max_size=1000)
async for chunk in request.stream():
data_file.write(chunk)
data_file.seek(0)
file_obj = StorageItem(
id=file_id,
uploaded_at=datetime.now(),
mime_type=request.headers.get("Content-Type"),
hash=None,
user=user,
size=0,
deleted_at=None,
alt_text=None,
)
# https://github.com/VirusTotal/vt-py/issues/119#issuecomment-1261246867
await storage.upload(
file_name=file_id.hex, file_data=data_file._file, mime_type=request.headers.get("Content-Type")
)
await file_obj.save()
await arq.enqueue_job("calculate_hash", file_id.hex)
return PublicStorageItem.from_db_model(file_obj)
@router.get("/meta/{file_id}")
async def get_file_info(file_id: UUID, user: User = Depends(get_current_user)) -> PublicStorageItem:
file_data = await StorageItem.objects.get_or_none(id=file_id, user=user, deleted_at=None)
if file_data is None:
raise HTTPException(status_code=404, detail="File not found")
return PublicStorageItem.from_db_model(file_data)
@router.delete("/meta/{file_id}")
async def mark_file_as_deleted(file_id: UUID, user: User = Depends(get_current_user)):
file_data = await StorageItem.objects.get_or_none(id=file_id, user=user, deleted_at=None)
if file_data is None:
raise HTTPException(status_code=404, detail="File not found")
storage_path = file_data.storage_path
if storage_path is None:
storage_path = file_data.id.hex
await storage.delete(storage_path)
file_data.deleted_at = datetime.now()
await file_data.update()
return
@router.put("/meta/{file_id}")
async def update_image_data(
file_id: UUID, data: UpdateStorageItem, user: User = Depends(get_current_user)
) -> PublicStorageItem:
file_data = await StorageItem.objects.get_or_none(id=file_id, user=user, deleted_at=None)
if file_data is None:
raise HTTPException(status_code=404, detail="File not found")
file_data.filename = data.filename
file_data.alt_text = data.alt_text
await file_data.update()
return PublicStorageItem.from_db_model(file_data)
@router.get("/list")
async def list_images(
since: datetime | None = None, user: User = Depends(get_current_user)
) -> list[PrivateStorageItem]:
if since is None:
since = datetime.now() - timedelta(days=2)
storage_items = (
await StorageItem.objects.filter(user=user)
.filter(StorageItem.uploaded_at > since)
.filter(StorageItem.deleted_at == None) # noqa: E711
.order_by(StorageItem.uploaded_at.desc())
.select_related([StorageItem.quizzes, StorageItem.quiztivities])
.all()
)
if len(storage_items) == 0:
raise HTTPException(status_code=404, detail="No items found")
return_items: list[PrivateStorageItem] = []
for item in storage_items:
# print(item.quizzes)
return_items.append(PrivateStorageItem.from_db_model(item))
return return_items
@router.get("/list/last")
async def get_latest_images(count: int = 50, user: User = Depends(get_current_user)) -> list[PrivateStorageItem]:
if count > 50:
count = 50
items = (
await StorageItem.objects.filter(user=user)
.limit(count)
.select_related([StorageItem.quizzes, StorageItem.quiztivities])
.order_by(StorageItem.uploaded_at.desc())
.all()
)
return_items: list[PrivateStorageItem] = []
for item in items:
return_items.append(PrivateStorageItem.from_db_model(item))
return return_items
class ReturnGetStorageLimit(BaseModel):
limit: int
limit_reached: bool
used: int
@router.get("/limit")
async def get_storage_limit(user: User = Depends(get_current_user)) -> ReturnGetStorageLimit:
user = await User.objects.get_or_none(id=user.id)
if user.storage_used > settings.free_storage_limit:
return ReturnGetStorageLimit(limit=settings.free_storage_limit, limit_reached=True, used=user.storage_used)
else:
return ReturnGetStorageLimit(limit=settings.free_storage_limit, limit_reached=False, used=user.storage_used)
+20 -43
View File
@@ -2,20 +2,17 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from io import BytesIO
from typing import Optional
from typing import Optional, BinaryIO
from .deta_storage import DetaStorage
from .local_storage import LocalStorage
from .s3_storage import S3Storage
from typing import Generator
class Storage:
def __init__(
self,
backend: str,
deta_key: Optional[str],
deta_id: Optional[str],
storage_path: Optional[str],
access_key: str | None = None,
secret_key: str | None = None,
@@ -23,65 +20,45 @@ class Storage:
base_url: str | None = None,
):
self.backend = backend
self.deta_key: str | None = deta_key
self.deta_id: str | None = deta_id
# self.deta_base_url = f"https://drive.deta.sh/v1/{deta_id}/classquiz1"
self.access_key = access_key
self.secret_key = secret_key
self.bucket_name = bucket_name
self.base_url = base_url
self.deta_instance = None
if backend == "deta":
if deta_key is None or deta_id is None:
raise ValueError("deta_key and deta_id must be provided")
if self.base_url is None:
self.base_url = f"https://drive.deta.sh/v1/{deta_id}/classquiz1"
self.deta_instance = DetaStorage(
deta_base_url=self.base_url,
deta_key=self.deta_key,
deta_id=self.deta_id,
)
self.instance: LocalStorage | S3Storage | None = None
elif backend == "local":
if backend == "local":
if storage_path is None:
raise ValueError("storage_path must be provided")
else:
self.local_instance = LocalStorage(base_path=storage_path)
self.instance = LocalStorage(base_path=storage_path)
elif backend == "s3":
if access_key is None or secret_key is None or bucket_name is None or base_url is None:
raise ValueError("Not all parameters given")
self.s3_instance = S3Storage(
self.instance = S3Storage(
base_url=base_url, access_key=access_key, secret_key=secret_key, bucket_name=bucket_name
)
else:
raise NotImplementedError(f"Backend {backend} not implemented")
async def download(self, file_name: str) -> BytesIO | None:
if self.backend == "deta":
return await self.deta_instance.download(file_name)
elif self.backend == "local":
return await self.local_instance.get_file(file_name)
elif self.backend == "s3":
return await self.s3_instance.download(file_name)
async def download(self, file_name: str) -> Generator | None:
"""
No support for s3 since it doesn't make sense relaying the traffic through this backend
:param file_name:
:return:
"""
yield self.instance.download(file_name)
async def upload(self, file_name: str, file_data: bytes) -> None:
if self.backend == "deta":
return await self.deta_instance.upload(file=file_data, file_name=file_name)
elif self.backend == "local":
return await self.local_instance.write_file(file_name=file_name, data=file_data)
elif self.backend == "s3":
return await self.s3_instance.upload(file=file_data, file_name=file_name)
async def upload(self, file_name: str, file_data: BinaryIO, mime_type: str | None = None) -> None:
return await self.instance.upload(file=file_data, file_name=file_name, mime_type=mime_type)
async def delete(self, file_names: [str]) -> None:
if self.backend == "deta":
return await self.deta_instance.delete(file_names=file_names)
elif self.backend == "local":
return await self.local_instance.delete_file(file_names=file_names)
elif self.backend == "s3":
return await self.s3_instance.delete(file_names=file_names)
return await self.instance.delete(file_names=file_names)
async def get_url(self, file_name: str, expiry: int) -> str:
if self.backend == "s3":
return self.s3_instance.get_url(file_name=file_name, expire=expiry)
return self.instance.get_url(file_name=file_name, expire=expiry)
async def get_file_size(self, file_name: str) -> int | None:
return self.instance.size(file_name)
-58
View File
@@ -1,58 +0,0 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from io import BytesIO
from classquiz.storage.errors import DeletionFailedError, SavingFailedError, DownloadingFailedError
from aiohttp import ClientSession
class DetaStorage:
def __init__(self, deta_base_url: str, deta_id: str, deta_key: str):
self.deta_url = deta_base_url
self.deta_id = deta_id
self.deta_key = deta_key
self.headers = {
"X-Api-Key": self.deta_key,
}
async def download(self, file_name: str) -> BytesIO | None:
"""
:param file_name: The name of the file to be downloaded
:return: Either bytes if successful download or None if failed
"""
async with ClientSession(headers=self.headers) as session, session.get(
f"{self.deta_url}/files/download?name={file_name}"
) as response:
if response.status == 200:
return BytesIO(await response.read())
elif response.status == 404:
return None
else:
raise DownloadingFailedError
async def upload(self, file: bytes, file_name: str) -> None:
"""
:param file: The file in bytes
:param file_name: The name of the file
:return:
"""
async with ClientSession(headers=self.headers) as session, session.post(
f"{self.deta_url}/files?name={file_name}", data=file
) as response:
if response.status == 201:
return None
else:
print(response.status, await response.json())
raise SavingFailedError
async def delete(self, file_names: [str]) -> None:
async with ClientSession(headers=self.headers) as session, session.delete(
f"{self.deta_url}/files", json={"names": file_names}
) as response:
if response.status == 200:
return None
else:
raise DeletionFailedError
+20 -7
View File
@@ -2,32 +2,45 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import io
import os
from typing import BinaryIO, Generator
import aiofiles
import aiofiles.os
_DEFAULT_CHUNK_SIZE = 32768 # bytes; arbitrary
async def aioshutil_copyfileobj(async_fsrc, async_fdst, *, chunksize: int = _DEFAULT_CHUNK_SIZE) -> None:
while (chunk := await async_fsrc.read(chunksize)) != b"":
await async_fdst.write(chunk)
class LocalStorage:
def __init__(self, base_path: str):
self.base_path = base_path
async def get_file(self, file_name: str) -> io.BytesIO | None:
async def download(self, file_name: str) -> Generator | None:
try:
async with aiofiles.open(file=os.path.join(self.base_path, file_name), mode="rb") as f:
return io.BytesIO(await f.read())
yield f.read()
except FileNotFoundError:
return None
yield None
async def write_file(self, file_name: str, data: bytes) -> None:
async def upload(self, file_name: str, data: BinaryIO, mime_type: str | None = None) -> None:
async with aiofiles.open(file=os.path.join(self.base_path, file_name), mode="wb") as f:
await f.write(data)
await aioshutil_copyfileobj(data, f)
async def delete_file(self, file_names: [str]) -> None:
async def delete(self, file_names: [str]) -> None:
for i in file_names:
try:
await aiofiles.os.remove(os.path.join(self.base_path, i))
except FileNotFoundError:
pass
return None
def size(self, file_name: str) -> int | None:
try:
return os.stat(os.path.join(self.base_path, file_name))
except FileNotFoundError:
return None
+24 -13
View File
@@ -8,8 +8,7 @@
import hashlib
import hmac
from datetime import datetime, timedelta
from io import BytesIO
from typing import Tuple
from typing import Tuple, BinaryIO, Generator
from aiohttp import ClientSession
import minio
@@ -113,17 +112,7 @@ class S3Storage:
return headers, request_url
async def download(self, file_name: str):
headers, url = self._generate_aws_signature_v4(method="GET", path=f"/{file_name}")
async with ClientSession() as session, session.get(url, headers=headers) as resp:
if resp.status == 200:
return BytesIO(await resp.read())
elif resp.status == 404:
return None
else:
raise DownloadingFailedError
async def upload(self, file: bytes, file_name: str, content_type: str | None = "application/octet-stream") -> None:
async def upload(self, file: BinaryIO, file_name: str, mime_type: str | None = "application/octet-stream") -> None:
headers, url = self._generate_aws_signature_v4(method="PUT", path=f"/{file_name}")
async with ClientSession() as session, session.put(url, headers=headers, data=file) as resp:
if resp.status == 200:
@@ -145,3 +134,25 @@ class S3Storage:
return self.client.presigned_get_object(
object_name=file_name, bucket_name=self.bucket_name, expires=timedelta(seconds=expire)
)
def size(self, file_name: str) -> int | None:
res = self.client.stat_object(bucket_name=self.bucket_name, object_name=file_name)
if res is None:
return None
return res.size
async def download(self, file_name: str) -> Generator:
headers, url = self._generate_aws_signature_v4(method="GET", path=f"/{file_name}")
async with ClientSession() as session, session.get(url, headers=headers) as resp:
if resp.status == 200:
async for i in resp.content.iter_chunked(1024):
yield i
elif resp.status == 404:
yield None
else:
raise DownloadingFailedError
# client = httpx.AsyncClient()
# async with client.stream("GET", url, headers=headers) as resp:
# if resp.status == 200:
# yield resp.aiter_bytes()
+29
View File
@@ -0,0 +1,29 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from arq import cron
from arq.connections import RedisSettings
from classquiz import settings
from classquiz.db import database
from classquiz.worker.storage import clean_editor_images_up, calculate_hash, quiz_update
async def startup(ctx):
ctx["db"] = database
if not ctx["db"].is_connected:
await ctx["db"].connect()
async def shutdown(ctx):
if ctx["db"].is_connected:
await ctx["db"].disconnect()
class WorkerSettings:
# functions = [add_track]
functions = [calculate_hash, quiz_update]
cron_jobs = [cron(clean_editor_images_up, hour={0, 6, 12, 18}, minute=0)]
on_startup = startup
on_shutdown = shutdown
redis_settings = RedisSettings.from_dsn(settings.redis)
+108
View File
@@ -0,0 +1,108 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import uuid
import ormar.exceptions
from arq.worker import Retry
import xxhash
from classquiz.config import redis, storage
from tempfile import SpooledTemporaryFile
from classquiz.db.models import StorageItem, Quiz, User
from classquiz.helpers import extract_image_ids_from_quiz
from classquiz.storage.errors import DeletionFailedError
from thumbhash import image_to_thumbhash
async def clean_editor_images_up(ctx):
print("Cleaning images up")
edit_sessions = await redis.smembers("edit_sessions")
for session_id in edit_sessions:
session = await redis.get(f"edit_session:{session_id}")
if session is None:
images = await redis.lrange(f"edit_session:{session_id}:images", 0, 3000)
if len(images) != 0:
try:
await storage.delete(images)
except DeletionFailedError:
print("Deletion Error", images)
await redis.srem("edit_sessions", session_id)
await redis.delete(f"edit_session:{session_id}:images")
async def calculate_hash(ctx, file_id_as_str: str):
file_id = uuid.UUID(file_id_as_str)
file_data: StorageItem = await StorageItem.objects.select_related(StorageItem.user).get(id=file_id)
file_path = file_id.hex
if file_data.storage_path is not None:
file_path = file_data.storage_path
file = SpooledTemporaryFile()
file_data.size = await storage.get_file_size(file_name=file_path)
if file_data.size is None:
file_data.size = 0
file_bytes = storage.download(file_path)
if file_bytes is None:
print("Retry raised!")
raise Retry(defer=ctx["job_try"] * 10)
async for chunk in file_bytes:
async for c in chunk:
file.write(c)
if 0 < file_data.size < 20_970_000: # greater than 0 but smaller than 20mbytes
file_data.thumbhash = image_to_thumbhash(file)
hash_obj = xxhash.xxh3_128()
# assert hash_obj.block_size == 64
while chunk := file.read(6400):
hash_obj.update(chunk)
file_data.hash = hash_obj.digest()
print("Got hash!")
await file_data.update()
file.close()
user: User | None = await User.objects.get_or_none(id=file_data.user.id)
if user is None:
return
user.storage_used += file_data.size
await user.update()
async def quiz_update(ctx, old_quiz: Quiz, quiz_id: uuid.UUID):
new_quiz: Quiz = await Quiz.objects.get(id=quiz_id)
old_images = extract_image_ids_from_quiz(old_quiz)
new_images = extract_image_ids_from_quiz(new_quiz)
# If images are identical, then return
if sorted(old_images) == sorted(new_images):
print("Nothing's changed")
return
print("Change detected")
removed_images = list(set(old_images) - set(new_images))
added_images = list(set(new_images) - set(old_images))
change_made = False
# print("added:", added_images)
# print("removed:", removed_images)
for image in removed_images:
if "--" in image:
await storage.delete([image])
else:
item = await StorageItem.objects.get_or_none(id=uuid.UUID(image))
if item is None:
continue
# print("removed item")
try:
await new_quiz.storageitems.remove(item)
except ormar.exceptions.NoMatch:
continue
change_made = True
for image in added_images:
if "--" not in image:
item = await StorageItem.objects.get_or_none(id=uuid.UUID(image))
if item is None:
continue
# print("added item")
await new_quiz.storageitems.add(item)
change_made = True
if change_made:
await new_quiz.update()
+15 -4
View File
@@ -13,16 +13,17 @@ services:
REDIS_URL: redis://redis:6379/0?decode_responses=True
API_URL: http://api:80
api:
build:
build: &build_cfg
context: .
dockerfile: Dockerfile
restart: always
depends_on:
restart: &restart always
depends_on: &depends
- db
- redis
environment:
environment: &env_vars
DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz"
REDIS: "redis://redis:6379/0?decode_responses=True"
MAIL_ADDRESS: "email@email@email.email"
MAIL_PASSWORD: "PASSWORT"
MAIL_USERNAME: "email@email@email.email"
@@ -34,6 +35,8 @@ services:
SKIP_EMAIL_VERIFICATION: True
HCAPTCHA_KEY: "HCAPTCHA_PRIVATE_KEY"
MEILISEARCH_URL: "http://meilisearch:7700"
STORAGE_BACKEND: "local"
STORAGE_PATH: "/app/data"
redis:
image: redis:alpine
restart: always
@@ -67,6 +70,14 @@ services:
MEILI_NO_ANALYTICS: true
volumes:
- meilisearch-data:/data.ms
worker:
build: *build_cfg
environment: *env_vars
depends_on: *depends
restart: *restart
command: arq classquiz.worker.WorkerSettings
volumes:
data:
meilisearch-data:
+2 -2
View File
@@ -22,7 +22,7 @@ COPY package*.json ./
COPY pnpm-lock.yaml ./
# run npm install in our local machine
RUN corepack enable && corepack prepare pnpm@7.26.2 --activate && pnpm i
RUN corepack enable && corepack prepare pnpm@8.6.1 --activate && pnpm i
# copy the generated modules and all other files to the container
COPY . .
@@ -38,7 +38,7 @@ FROM node:19-bullseye-slim
WORKDIR /app
COPY --from=builder /usr/src/app/package.json .
COPY --from=builder /usr/src/app/pnpm-lock.yaml .
RUN corepack enable && corepack prepare pnpm@7.26.2 --activate && pnpm i
RUN corepack enable && corepack prepare pnpm@8.6.1 --activate && pnpm i
# copy files from previous step
COPY --from=builder /usr/src/app/build .
COPY --from=builder /usr/src/app/node_modules ./node_modules
+5
View File
@@ -23,6 +23,8 @@
"@ckeditor/ckeditor5-theme-lark": "^37.1.0",
"@felte/reporter-tippy": "^1.1.5",
"@felte/validator-yup": "^1.0.11",
"@ffmpeg/core": "^0.11.0",
"@ffmpeg/ffmpeg": "^0.11.6",
"@fontsource/marck-script": "^4.5.11",
"@sentry/browser": "^7.48.0",
"@sentry/tracing": "^7.48.0",
@@ -42,6 +44,7 @@
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.59.0",
"@typescript-eslint/parser": "^5.59.0",
"@unlazy/svelte": "^0.8.9",
"@uppy/compressor": "^1.0.2",
"@uppy/core": "^3.2.0",
"@uppy/dashboard": "^3.4.0",
@@ -89,11 +92,13 @@
"svelte-tippy": "^1.3.2",
"swiper": "^8.4.7",
"tailwindcss": "^3.3.1",
"thumbhash": "^0.1.1",
"tippy.js": "^6.3.7",
"tslib": "^2.5.0",
"typescript": "~5.0.4",
"ua-parser-js": "^1.0.35",
"vite": "^4.2.2",
"vite-plugin-cross-origin-isolation": "^0.1.6",
"vite-plugin-iso-import": "^1.0.0",
"yup": "^1.1.1"
},
+1048 -812
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -14,6 +14,7 @@
import CircularTimer from '$lib/play/circular_progress.svelte';
import Spinner from '$lib/Spinner.svelte';
import { get_foreground_color } from '$lib/helpers';
import MediaComponent from '$lib/editor/MediaComponent.svelte';
export let game_token: string;
export let quiz_data: QuizData;
@@ -223,11 +224,11 @@
</div>
</div>
{#if quiz_data.questions[selected_question].image !== null}
<div>
<img
<div class="flex w-full">
<MediaComponent
src={quiz_data.questions[selected_question].image}
class="max-h-[20vh] object-cover mx-auto mb-8 w-auto"
alt="Content for Question"
muted={false}
css_classes="max-h-[20vh] object-cover mx-auto mb-8 w-auto"
/>
</div>
{/if}
@@ -320,7 +321,7 @@
<div class="h-[30vh] m-auto w-auto mt-12">
<img
class="max-h-full max-w-full block"
src={quiz_data.cover_image}
src="/api/v1/storage/download/{quiz_data.cover_image}"
alt="Not provided"
/>
</div>
@@ -158,7 +158,7 @@
<div class="h-[20vh] m-auto w-auto">
<img
class="max-h-full max-w-full block"
src={quiz.cover_image}
src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided"
loading="lazy"
/>
@@ -302,7 +302,7 @@
{#if visibleImages?.[i]?.[q]}
<img
class="max-h-full max-w-full block"
src={question.image}
src="/api/v1/storage/download/{question.image}"
alt="Not provided"
/>
{/if}
+7 -5
View File
@@ -4,7 +4,7 @@
file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import { alertModal } from '$lib/stores';
// import { alertModal } from '$lib/stores';
import { captcha_enabled } from '$lib/config';
import StartGameBackground from './start_game_background.svg';
import { fade } from 'svelte/transition';
@@ -54,14 +54,16 @@
);
}
if (res.status !== 200) {
alertModal.set({
/* alertModal.set({
open: true,
title: 'Start failed',
body: `Failed to start game, ${await res.text()}`
});
alertModal.subscribe((_) => {
});*/
/*alertModal.subscribe((_) => {
window.location.assign('/account/login?returnTo=/dashboard');
});
});*/
alert('Starting game failed');
window.location.assign('/account/login?returnTo=/dashboard');
} else {
const data = await res.json();
// eslint-disable-next-line no-undef
+3 -34
View File
@@ -4,7 +4,7 @@
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import { mint } from '$lib/hashcash';
// import { mint } from '$lib/hashcash';
import { dataSchema } from '$lib/yupSchemas';
import type { EditorData, Question } from './quiz_types';
import Sidebar from '$lib/editor/sidebar.svelte';
@@ -22,22 +22,6 @@
export let quiz_id: string | null;
let selected_question = -1;
let imgur_links_valid = false;
let pow_salt;
const computePOW = async (salt: string) => {
if (pow_salt === undefined) {
return;
}
console.log('Computing POW');
pow_data = await mint(salt, 16, '', 8, false);
pow_salt = undefined;
return;
};
$: {
pow_salt;
computePOW(pow_salt);
}
const validateInput = async (data: EditorData) => {
// console.log("input", data)
@@ -72,7 +56,6 @@
$: imgur_links_valid = checkIfAllQuestionImagesComplyWithRegex(data.questions);
let edit_id;
let confirm_to_leave = true;
let pow_data;
const getEditID = async () => {
let res;
@@ -88,7 +71,6 @@
if (res.status === 200) {
const json = await res.json();
edit_id = json.token;
setPOWdata();
} else {
alert('Error!');
}
@@ -123,13 +105,6 @@
alert('Error');
}
};
const setPOWdata = async () => {
const res = await fetch(`/api/v1/editor/pow?edit_id=${edit_id}`);
const data = (await res.json()).data;
console.log(data);
pow_data = await mint(data, 16);
console.log(pow_data);
};
</script>
<svelte:window on:beforeunload={confirmUnload} />
@@ -177,15 +152,9 @@
</div>
<div class="w-full h-full">
{#if selected_question === -1}
<SettingsCard bind:data bind:pow_salt bind:edit_id bind:pow_data />
<SettingsCard bind:data bind:edit_id />
{:else}
<QuizCard
bind:data
bind:selected_question
bind:edit_id
bind:pow_data
bind:pow_salt
/>
<QuizCard bind:data bind:selected_question bind:edit_id />
{/if}
</div>
</div>
@@ -0,0 +1,67 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import { browser } from '$app/environment';
export let src: string;
export let css_classes = 'max-h-64 h-auto w-auto';
export let muted = true;
let type: 'img' | 'video' | undefined = undefined;
const get_media = async () => {
if (!browser) {
return;
}
const res = await fetch(`/api/v1/storage/info/${src}`);
console.log('Headers', res.headers);
const fileType = res.headers.get('Content-Type');
console.log(fileType, 'fileType');
if (fileType.includes('video')) {
console.log('Setting type to video');
type = 'video';
} else {
type = 'img';
console.log(res);
const data = await fetch(`/api/v1/storage/download/${src}`);
return {
data: URL.createObjectURL(await data.blob()),
alt_text: res.headers.get('X-Alt-Text')
};
}
};
const update_url = () => {
media = get_media();
};
let media = get_media();
$: {
src;
update_url();
}
</script>
{#await media}
<p>Placeholder</p>
{:then data}
{#if type === 'img'}
<img src={data.data} alt={data.alt_text ?? 'Not available'} class={css_classes} />
{:else if type === 'video'}
<video
class={css_classes}
disablepictureinpicture
x-webkit-airplay="deny"
controls
autoplay
loop
{muted}
preload="metadata"
>
<source src="/api/v1/storage/download/{src}" />
</video>
{:else}
<p>Unknown media type</p>
{/if}
{/await}
+24 -26
View File
@@ -10,22 +10,21 @@
import { reach } from 'yup';
import { dataSchema } from '$lib/yupSchemas';
import Spinner from '../Spinner.svelte';
import { createTippy } from 'svelte-tippy';
// import { createTippy } from 'svelte-tippy';
import { getLocalization } from '$lib/i18n';
// import MediaComponent from "$lib/editor/MediaComponent.svelte";
const { t } = getLocalization();
const tippy = createTippy({
arrow: true,
animation: 'perspective-subtle',
placement: 'top'
});
/* const tippy = createTippy({
arrow: true,
animation: 'perspective-subtle',
placement: 'top'
});*/
export let data: EditorData;
export let selected_question: number;
export let edit_id: string;
export let pow_data;
export let pow_salt: string;
let uppyOpen = false;
let unique = {};
@@ -51,6 +50,17 @@
selected_question;
set_unique();
}
let image_url = '';
const update_image_url = () => {
image_url = data.questions[selected_question].image;
console.log('updated!');
};
$: {
update_image_url();
selected_question;
data.questions;
}
/*
if (typeof data.questions[selected_question].type !== QuizQuestionType) {
console.log(data.questions[selected_question].type !== QuizQuestionType.ABCD || data.questions[selected_question].type !== QuizQuestionType.RANGE)
@@ -60,7 +70,7 @@
</script>
<div class="w-full max-h-full pb-20 px-20 h-full">
<div class="rounded-lg bg-white w-full h-full border-gray-500 drop-shadow-2xl dark:bg-gray-700">
<div class="rounded-lg bg-white w-full h-full border-gray-500 dark:bg-gray-700 shadow-2xl">
<div class="h-12 bg-gray-300 rounded-t-lg dark:bg-gray-500">
<div class="flex align-middle p-4 gap-3">
<span
@@ -110,7 +120,7 @@
class="rounded-full absolute -top-2 -right-2 opacity-70 hover:opacity-100 transition"
type="button"
on:click={() => {
data.questions[selected_question].image = '';
data.questions[selected_question].image = null;
}}
>
<svg
@@ -128,22 +138,11 @@
/>
</svg>
</button>
<img
src={data.questions[selected_question].image}
alt="not available"
class="max-h-64 h-auto w-auto"
/>
{#await import('$lib/editor/MediaComponent.svelte') then c}
<svelte:component this={c.default} bind:src={image_url} />
{/await}
</div>
</div>
{:else if pow_data === undefined}
<a
href="/docs/pow"
target="_blank"
use:tippy={{ content: "Click to learn why it's loading so long." }}
class="cursor-help"
>
<Spinner my_20={false} />
</a>
{:else}
{#await import('$lib/editor/uploader.svelte')}
<Spinner my_20={false} />
@@ -154,8 +153,7 @@
bind:edit_id
bind:data
bind:selected_question
bind:pow_data
bind:pow_salt
video_upload={true}
/>
{/await}
{/if}
+5 -18
View File
@@ -8,9 +8,6 @@
import { getLocalization } from '$lib/i18n';
import Spinner from '$lib/Spinner.svelte';
export let pow_data;
export let pow_salt;
const { t } = getLocalization();
let uppyOpen = false;
@@ -42,7 +39,7 @@
<div
class="dark:bg-gray-700 h-full"
style="background-repeat: no-repeat;background-size: 100% 100%;background-image: {data.background_image
? `url("${data.background_image}")`
? `url("/api/v1/storage/download/${data.background_image}")`
: `unset`}"
>
<div class="flex justify-center pt-10 w-full">
@@ -68,18 +65,14 @@
{#if data.cover_image != undefined && data.cover_image !== ''}
<div class="flex justify-center pt-10 w-full max-h-72 w-full">
<img
src={data.cover_image}
src="/api/v1/storage/download/{data.cover_image}"
alt="not available"
class="max-h-72 h-auto w-auto"
on:contextmenu|preventDefault={() => {
data.cover_image = '';
data.cover_image = null;
}}
/>
</div>
{:else if pow_data === undefined}
<a href="/docs/pow" target="_blank" class="cursor-help">
<Spinner my_20={false} />
</a>
{:else}
{#await import('$lib/editor/uploader.svelte')}
<Spinner my_20={false} />
@@ -89,8 +82,7 @@
bind:modalOpen={uppyOpen}
bind:edit_id
bind:data
bind:pow_data
bind:pow_salt
video_upload={false}
/>
{/await}
{/if}
@@ -191,10 +183,6 @@
class="mt-10 bg-red-500 p-2 rounded-lg border-2 border-black transition hover:bg-red-400"
>Remove Background-Image</button
>
{:else if pow_data === undefined}
<a href="/docs/pow" target="_blank" class="cursor-help pt-10">
<Spinner my_20={false} />
</a>
{:else}
{#await import('$lib/editor/uploader.svelte')}
<div class="pt-10">
@@ -207,8 +195,7 @@
bind:edit_id
bind:data
selected_question={-1}
bind:pow_data
bind:pow_salt
video_upload={false}
/>
{/await}
{/if}
+2 -2
View File
@@ -201,11 +201,11 @@
{#if question.image}
<div class="flex justify-center align-middle pb-0.5">
<img
src={question.image}
src="/api/v1/storage/download/{question.image}"
class="h-10 border rounded-lg"
alt="Not available"
use:tippy={{
content: `<img src='${question.image}' alt='Not available' class='rounded'>`,
content: `<img src='/api/v1/storage/download/${question.image}' alt='Not available' class='rounded'>`,
allowHTML: true
}}
/>
+128 -30
View File
@@ -12,6 +12,7 @@
import Dashboard from '@uppy/dashboard';
import Compressor from '@uppy/compressor';
import { fade } from 'svelte/transition';
import BrownButton from '$lib/components/buttons/brown.svelte';
// CSS imports
import '@uppy/core/dist/style.css';
@@ -21,6 +22,8 @@
import '@uppy/image-editor/dist/style.css';
import type { EditorData } from '../quiz_types';
import { getLocalization } from '$lib/i18n';
import { onMount } from 'svelte';
import Library from '$lib/editor/uploader/Library.svelte';
const { t } = getLocalization();
@@ -28,10 +31,24 @@
export let edit_id: string;
export let data: EditorData;
export let selected_question: number;
export let pow_data;
export let pow_salt: string;
export let video_upload = false;
export let library_enabled = true;
// eslint-disable-next-line no-undef
let video_popup: undefined | WindowProxy = undefined;
let selected_type: AvailableUploadTypes | null = null;
// eslint-disable-next-line no-unused-vars
enum AvailableUploadTypes {
// eslint-disable-next-line no-unused-vars
Image,
// eslint-disable-next-line no-unused-vars
Video,
// eslint-disable-next-line no-unused-vars
Library
}
console.log(pow_data);
const uppy = new Uppy()
.use(DropTarget, {
target: document.body
@@ -45,59 +62,140 @@
quality: 0.6
})
.use(XHRUpload, {
endpoint: `/api/v1/editor/image?edit_id=${edit_id}&pow_data=${pow_data}`
endpoint: `/api/v1/storage/`
});
const props = {
inline: true,
restrictions: {
maxFileSize: 2_000_000,
maxFileSize: 10_490_000,
maxNumberOfFiles: 1,
allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp']
allowedFileTypes: ['image/*']
// allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp']
}
};
let image_id;
uppy.on('upload-success', (file, response) => {
image_id = response.body.id;
pow_salt = response.body.pow_data;
console.log(pow_salt, response.body);
pow_data = undefined;
});
uppy.on('complete', (_) => {
console.log(pow_data);
if (selected_question === undefined) {
data.cover_image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
data.cover_image = image_id;
} else if (selected_question === -1) {
data.background_image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
data.background_image = image_id;
} else {
data.questions[
selected_question
].image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
data.questions[selected_question].image = image_id;
}
console.log(selected_question, data);
modalOpen = false;
selected_type = null;
});
onMount(() => {
window.addEventListener('storage', (e) => {
if (e.key !== 'video_upload_id') {
return;
}
localStorage.removeItem('video_upload_id');
data.questions[selected_question].image = e.newValue;
selected_type = null;
});
});
const upload_video = async () => {
video_popup = window.open(
'/edit/videos',
'_blank',
'popup=true,toolbar=false,menubar=false,location=false,'
);
video_popup.addEventListener('beforeunload', () => {
console.log('Unloaded');
video_popup = undefined;
});
};
const handle_on_click = (e: Event) => {
if (e.target === e.currentTarget) {
modalOpen = false;
selected_type = null;
}
};
onMount(() => {
window.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
modalOpen = false;
selected_type = null;
}
});
});
console.log(edit_id);
</script>
{#if modalOpen}
<div
class="w-full h-full absolute top-0 left-0 bg-opacity-60 z-20 flex justify-center"
transition:fade|local
class="w-screen h-screen fixed top-0 left-0 bg-opacity-50 bg-black z-20 flex justify-center"
on:click={handle_on_click}
transition:fade|local={{ duration: 100 }}
>
<div>
<button
type="button"
class="rounded-t-lg bg-black text-white px-1"
on:click={() => {
modalOpen = false;
}}
>Close
</button>
<div>
<SvelteDashboard {uppy} width="100%" {props} />
{#if selected_type === null}
<div class="m-auto w-1/3 h-auto bg-white dark:bg-gray-700 p-4 rounded">
<h1 class="text-3xl text-center mb-4">{$t('uploader.select_upload_type')}</h1>
<div class="flex flex-row gap-4">
<div class="w-full">
<BrownButton
on:click={() => {
selected_type = AvailableUploadTypes.Image;
}}
>{$t('words.image')}
</BrownButton>
</div>
<div class="w-full">
<BrownButton
disabled={!video_upload}
on:click={() => {
selected_type = AvailableUploadTypes.Video;
}}
>{$t('words.video')}
</BrownButton>
</div>
{#if library_enabled}
<div class="w-full">
<BrownButton
on:click={() => {
selected_type = AvailableUploadTypes.Library;
}}
>{$t('words.library')}
</BrownButton>
</div>
{/if}
</div>
</div>
</div>
{:else if selected_type === AvailableUploadTypes.Image}
<div class="m-auto w-1/3 h-5/6" transition:fade|local={{ duration: 100 }}>
<div>
<SvelteDashboard {uppy} width="100%" {props} />
</div>
</div>
{:else if selected_type === AvailableUploadTypes.Video}
<div
class="m-auto w-1/3 h-auto bg-white dark:bg-gray-700 p-4 rounded"
transition:fade|local={{ duration: 100 }}
>
<h1 class="text-3xl text-center mb-4">{$t('uploader.upload_a_video')}</h1>
{#if video_popup}
<p class="text-center">
{$t('uploader.upload_video_popup_notice')}
</p>
{:else}
<BrownButton on:click={upload_video} type="button"
>{$t('uploader.upload_video')}</BrownButton
>
{/if}
</div>
{:else if selected_type === AvailableUploadTypes.Library}
<div>
<Library bind:data {selected_question} bind:modalOpen />
</div>
{/if}
</div>
{/if}
<div class="flex justify-center w-full pt-10" transition:fade|local>
@@ -0,0 +1,65 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { PrivateImageData } from '$lib/quiz_types';
import Spinner from '$lib/Spinner.svelte';
import type { EditorData } from '$lib/quiz_types';
import BrownButton from '$lib/components/buttons/brown.svelte';
import { getLocalization } from '$lib/i18n';
export let data: EditorData;
export let selected_question: number;
export let modalOpen: boolean;
const { t } = getLocalization();
const fetch_images = async (): Promise<PrivateImageData> => {
const response = await fetch('/api/v1/storage/list/last?count=50');
return await response.json();
};
let image_fetch = fetch_images();
const set_image = (id: string) => {
if (selected_question === undefined) {
data.cover_image = id;
} else if (selected_question === -1) {
data.background_image = id;
} else {
data.questions[selected_question].image = id;
}
modalOpen = false;
};
</script>
{#await image_fetch}
<Spinner />
{:then images}
<div class="flex w-screen p-8 h-screen">
<div
class="flex flex-col w-1/3 m-auto overflow-scroll h-full rounded p-4 gap-4 bg-white dark:bg-gray-700"
>
{#each images as image}
<div class="rounded border-2 border-[#B07156] p-2 flex-col flex gap-2">
<div>
<img
src="/api/v1/storage/download/{image.id}"
loading="lazy"
alt={image.alt_text}
class="object-contain w-full h-full max-h-full rounded"
/>
</div>
<p class="text-center">{image.filename ?? 'No name available'}</p>
<BrownButton
on:click={() => {
set_image(image.id);
}}>{$t('words.select')}</BrownButton
>
</div>
{/each}
</div>
</div>
{/await}
+36
View File
@@ -0,0 +1,36 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import Spinner from '$lib/Spinner.svelte';
export let files: {
id: string;
uploaded_at: string;
mime_type?: string;
hash?: string;
size: number;
deleted_at?: string;
alt_text?: string;
filename?: string;
thumbhash?: string;
server?: string;
quizzes: { id: string }[];
quiztivities: { id: string }[];
}[];
const get_files = async () => {
const res = await fetch('/api/v1/storage/list');
files = await res.json();
};
if (!files) {
get_files();
}
</script>
{#if files}{:else}
<Spinner />
{/if}
+36 -2
View File
@@ -175,7 +175,13 @@
"quiz": "Quiz",
"quiztivity": "Quiztivity",
"next": "Next",
"check_choice": "Check Choice"
"check_choice": "Check Choice",
"video": "Video",
"library": "Library",
"progress": "Progress",
"speed": "Speed",
"upload": "Upload",
"files_library": "Files Library"
},
"editor": {
"time_in_seconds": "Time in seconds",
@@ -277,7 +283,11 @@
"unknown_error_text": "That shouldn't happen. It's probably my fault, not yours, but maybe you have a magical power to break stuff..."
},
"uploader": {
"add_image": "Add image"
"add_image": "Add image",
"select_upload_type": "Select the Upload Type",
"upload_a_video": "Upload a Video",
"upload_video_popup_notice": "The popup is open; have a look at it for further information",
"upload_video": "Upload Video"
},
"avatar_settings": {
"skin_color": "Skin color",
@@ -384,5 +394,29 @@
"popover": {
"copied_to_clipboard": "Copied to clipboard!"
}
},
"public_user_page": {
"joined_on": "Joined on {{date}}",
"no_original_quizzes": "This user doesn't have any original quizzes"
},
"file_dashboard": {
"not_available": "Not available",
"missing": "MISSING!",
"unset": "Unset",
"size": "Size: {{size}} Mib",
"caption": "Caption: {{caption}}",
"filename": "Filename: {{filename}}",
"uploaded": "Uploaded: {{date}}",
"Imported": "Imported: {{yes_or_no}}",
"edit_details": "Edit details",
"delete_image": "Delete image",
"edit_the_image": "Edit the image",
"filename_word": "Filename",
"alt_text": "Alt(ernate) text / Caption",
"storage_usage": "You've used {{used}} Mib out of {{total}} MiB of storage. That's equivalent to {{percent}}% of your storage."
},
"video_uploader": {
"time_elapsed": "Time elapsed",
"time_remaining": "Time remaining"
}
}
+11 -12
View File
@@ -8,7 +8,7 @@
import { onDestroy, onMount } from 'svelte';
import { browser } from '$app/environment';
import * as Sentry from '@sentry/browser';
import { alertModal } from '../stores';
// import { alertModal } from '../stores';
import { getLocalization } from '$lib/i18n';
import Cookies from 'js-cookie';
import BrownButton from '$lib/components/buttons/brown.svelte';
@@ -72,20 +72,22 @@
custom_field = json.custom_field;
}
if (res.status === 404) {
alertModal.set({
/* alertModal.set({
open: true,
title: 'Game not found',
body: 'The game pin you entered seems invalid.'
});
});*/
alert('Game not found');
game_pin = '';
return;
}
if (res.status !== 200) {
alertModal.set({
/* alertModal.set({
open: true,
body: `Unknown error with response-code ${res.status}`,
title: 'Unknown Error'
});
});*/
alert('Unknown error');
return;
}
};
@@ -122,16 +124,13 @@
if (import.meta.env.VITE_SENTRY !== null) {
Sentry.captureException(e);
}
alertModal.set({
/* alertModal.set({
open: true,
body: "The captcha failed, which is normal, but most of the time it's fixed by reloading!",
title: 'Captcha failed'
});
alertModal.subscribe((data) => {
if (!data.open) {
window.location.reload();
}
});
});*/
alert('Captcha failed!');
window.location.reload();
}
} else if (import.meta.env.VITE_RECAPTCHA) {
// eslint-disable-next-line no-undef
+1 -1
View File
@@ -154,7 +154,7 @@
{#if question.image !== null && game_mode !== 'kahoot'}
<div class="max-h-full">
<img
src={question.image}
src="/api/v1/storage/download/{question.image}"
class="object-cover mx-auto mb-8 max-h-[90%]"
alt="Content for Question"
/>
+5 -1
View File
@@ -15,7 +15,11 @@
{#if cover_image}
<div class="flex justify-center align-middle items-center">
<div class="h-[30vh] m-auto w-auto mt-12">
<img class="max-h-full max-w-full block" src={cover_image} alt="Not provided" />
<img
class="max-h-full max-w-full block"
src="/api/v1/storage/download/{cover_image}"
alt="Not provided"
/>
</div>
</div>
{/if}
+3 -3
View File
@@ -11,6 +11,7 @@
import Spinner from '$lib/Spinner.svelte';
import { flip } from 'svelte/animate';
import BrownButton from '$lib/components/buttons/brown.svelte';
import MediaComponent from '$lib/editor/MediaComponent.svelte';
export let question: Question;
@@ -93,10 +94,9 @@
<h1 class="text-3xl text-center">{@html question.question}</h1>
{#if question.image !== null}
<div>
<img
<MediaComponent
src={question.image}
class="max-h-[40vh] object-cover mx-auto mb-8 w-auto"
alt="Content for Question"
css_classes="max-h-[40vh] object-cover mx-auto mb-8 w-auto"
/>
</div>
{/if}
@@ -46,7 +46,7 @@
{#if data.cover_image != undefined && data.cover_image !== ''}
<div class="flex justify-center pt-10 w-full max-h-72 w-full">
<img
src={data.cover_image}
src="/api/v1/storage/download/{data.cover_image}"
alt="not available"
class="max-h-72 h-auto w-auto"
on:contextmenu|preventDefault={() => {
+16
View File
@@ -88,3 +88,19 @@ export interface EditorData {
background_color?: string;
background_image?: string;
}
export interface PrivateImageData {
id: string;
uploaded_at: string;
mime_type: string;
hash?: string;
size?: number;
deleted_at?: string;
alt_text?: string;
filename?: string;
thumbhash?: string;
server?: string;
imported: boolean;
quizzes: { id: string }[];
quiztivities: { id: string }[];
}
+1 -8
View File
@@ -65,14 +65,7 @@ export const dataSchema = yup.object({
yup.object({
question: yup.string().required('A question-title is required').max(299),
time: yup.number().required().positive('The time has to be positive'),
image: yup
.string()
.nullable()
.matches(
/^(http(|s):\/\/.*(|:)\d*\/api\/v1\/storage\/download\/.{36}--.{36}|https:\/\/i\.imgur\.com\/.{7}.(jpg|png|gif))$|^$/,
"The image-url isn't valid"
)
.lowercase(),
image: yup.string().nullable().lowercase(),
answers: yup.lazy((v) => {
if (Array.isArray(v)) {
if (typeof v[0].right === 'boolean') {
+5 -4
View File
@@ -6,12 +6,12 @@
<script>
import '../app.css';
import Navbar from '$lib/navbar.svelte';
import { navbarVisible, pathname, alertModal } from '$lib/stores';
import { navbarVisible, pathname } from '$lib/stores';
import * as Sentry from '@sentry/browser';
import { BrowserTracing } from '@sentry/tracing';
import { initLocalizationContext } from '$lib/i18n';
import { browser } from '$app/environment';
import Alert from '$lib/modals/alert.svelte';
// import Alert from '$lib/modals/alert.svelte';
/* afterNavigate(() => {
if (browser) {
@@ -91,7 +91,8 @@
{:else}
<slot />
{/if}
{#if $alertModal.open ?? false}
<!--{#if $alertModal.open ?? false}
<div
class="fixed inset-0 h-screen w-screen bg-black z-30 bg-opacity-60 items-center justify-center content-center"
class:hidden={!$alertModal.open}
@@ -104,7 +105,7 @@
bind:open={$alertModal.open}
/>
</div>
{/if}
{/if}-->
<style lang="scss">
:global(html:not(.dark)) {
@@ -5,7 +5,6 @@
-->
<script lang="ts">
import { getLocalization } from '$lib/i18n';
import { alertModal } from '../../../lib/stores';
export let session_data;
export let selected_method;
@@ -37,19 +36,16 @@
try {
data = await res.json();
} catch {
alertModal.set({
open: true,
body: "This shouldn't happen. Please try again.",
title: 'Unknown error'
});
alert("This shouldn't happen");
window.location.reload();
}
if (data.detail === 'wrong credentials') {
alertModal.set({
/* alertModal.set({
open: true,
body: 'Please try again. Your email and or password were incorrect.',
title: 'Wrong Credentials'
});
});*/
alert('Wrong credentials');
}
}
};
@@ -5,7 +5,7 @@
-->
<script lang="ts">
import { getLocalization } from '$lib/i18n';
import { alertModal } from '$lib/stores';
// import { alertModal } from '$lib/stores';
export let session_data;
export let selected_method;
@@ -44,19 +44,21 @@
try {
data = await res.json();
} catch {
alertModal.set({
/* alertModal.set({
open: true,
body: "This shouldn't happen. Please try again.",
title: 'Unknown error'
});
});*/
alert('Unknown error');
window.location.reload();
}
if (data.detail === 'totp wrong') {
alertModal.set({
/* alertModal.set({
open: true,
body: 'Wrong Totp-Code. please try again.',
title: 'Totp Error'
});
});*/
alert('TOTP code was incorrect');
totp = '';
}
}
@@ -6,7 +6,7 @@
<script lang="ts">
import { startAuthentication } from '@simplewebauthn/browser';
import { getLocalization } from '$lib/i18n';
import { alertModal } from '$lib/stores';
// import { alertModal } from '$lib/stores';
const { t } = getLocalization();
export let session_data;
@@ -24,11 +24,12 @@
asseResp = await startAuthentication(data);
} catch (e) {
console.error(e);
alertModal.set({
/* alertModal.set({
open: true,
body: e,
title: 'Unknown error'
});
});*/
alert('Unknown error');
isLoading = false;
}
const res = await fetch(
@@ -51,19 +52,21 @@
try {
data = await res.json();
} catch {
alertModal.set({
/* alertModal.set({
open: true,
body: "This shouldn't happen. Please try again.",
title: 'Unknown error'
});
});*/
alert('Unknown error');
window.location.reload();
}
if (data.detail === 'webauthn failed') {
alertModal.set({
/* alertModal.set({
open: true,
body: 'Webauthn failed. Please try again.',
title: 'Webauthn Error'
});
});*/
alert('Webauthn failed');
}
}
isLoading = false;
+7 -4
View File
@@ -135,9 +135,12 @@
{/if}
<BrownButton href="/import">{$t('words.import')}</BrownButton>
<BrownButton href="/results">{$t('words.results')}</BrownButton>
<BrownButton href="/account/settings">
{$t('words.settings')}
</BrownButton>
<div class="flex gap-2">
<BrownButton href="/edit/files">{$t('words.files_library')}</BrownButton>
<BrownButton href="/account/settings">
{$t('words.settings')}
</BrownButton>
</div>
</div>
{#if quizzes.length !== 0}
<div class="flex justify-center pt-4 w-full">
@@ -180,7 +183,7 @@
<div class="hidden lg:flex w-auto h-full items-center relative">
{#if quiz.cover_image}
<img
src={quiz.cover_image}
src="/api/v1/storage/download/{quiz.cover_image}"
alt="user provided"
loading="lazy"
class="shrink-0 max-w-full max-h-full absolute"
@@ -0,0 +1,34 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { PageData } from './$types';
// import FileDahboard from "$lib/files/dashboard.svelte"
// import { thumbHashToDataURL } from 'thumbhash';
export let data: PageData;
const files = data.files;
/* const base64ToBytes = (value: string) => {
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
const decodedData =
typeof Buffer !== 'undefined'
? Buffer.from(base64, 'base64')
: Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return new Uint8Array(decodedData);
};*/
// let images_loaded = [];
</script>
<div class="w-full h-full">
<div class="grid grid-cols-2 w-full h-full gap-4">
{#each files as file, i}
<div class="w-full h-full flex" />
{/each}
</div>
</div>
@@ -0,0 +1,13 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { PageLoad } from './$types';
export const load = (async ({ fetch }) => {
const res = await fetch('/api/v1/storage/list');
if (res.ok) {
return { files: await res.json() };
}
}) satisfies PageLoad;
@@ -87,6 +87,10 @@
Start Caddy:
<pre><code>caddy run</code></pre>
</li>
<li>
Start the background-worker:
<pre><code>arq classquiz.worker.WorkerSettings</code></pre>
</li>
</ol>
<p>If you're done developing: <code>./run_tests.sh -</code></p>
<p>If you want to run all the tests: <code>./run_tests.sh a</code></p>
+32 -18
View File
@@ -96,18 +96,20 @@
<h3>Storage Provider</h3>
<p>
You'll have to set up a storage provider for some pictures (these getting imported from
KAHOOT!). For now, you can use <a href="https://deta.sh">Deta</a> or the local filesystem.
Please note that I would <b>NOT</b> use the local file system because of
<a href="https://wiki.owasp.org/index.php/Path_Traversal">Path Traversals</a>. I tried to
prevent these attacks, but I really wouldn't trust it, because it's a regex! You'll have to
set the
<code>STORAGE_BACKEND</code>-environment-variable to either <code>deta</code> or
<code>local</code>.
Kahoot!). For now, you can use <a href="https://min.io/" target="_blank">Minio (S3)</a> or
the local filesystem. Please not that I'd recommend Minio for larger instances, since it can
be scaled and the media doesn't have to streamed through the (comparatively) slow ClassQuiz
server. Now, that you've decided on a storage backend, you can set the
<code>STORAGE_BACKEND</code>-environment-variable to either <code>s3</code> or
<code>local</code>. If you ask yourself what happened with deta, I've decided to remove it,
since the went all-in with their spaces and I think that hardly anyone used it anyway.
</p>
<h4>If you chose Deta...</h4>
<h4>If you chose Minio (S3)...</h4>
<p>
...you'll also have to set the <code>DETA_PROJECT_KEY</code> and the
<code>DETA_PROJECT_ID</code>.
...you'll also have to set the <code>S3_ACCESS_KEY</code>, <code>S3_SECRET_KEY</code> and
the
<code>S3_BASE_URL</code>. The <code>S3_BUCKET_NAME</code> can also be set, but defaults to
<code>classquiz</code>.
</p>
<h4>If you chose the local filesystem...</h4>
<p>
@@ -165,15 +167,15 @@ services:
REDIS_URL: redis://redis:6379/0?decode_responses=True # don't change
API_URL: http://api:80 # don't change
api:
build:
build: &build_cfg
context: .
dockerfile: Dockerfile
restart: always
depends_on:
restart: &restart always
depends_on: &depends
- db
- redis
environment:
environment: &env_vars
ROOT_ADDRESS: "https://classquiz.de" # Base-URL (change it)
DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz" # don't change
MAIL_ADDRESS: "classquiz@mawoka.eu" # Email-Address (change it)
@@ -188,11 +190,13 @@ services:
MEILISEARCH_URL: "http://meilisearch:7700" # don't change
ACCESS_TOKEN_EXPIRE_MINUTES: 30 # don't change
HCAPTCHA_KEY: "" # Private hCaptcha key for verification (change it)
STORAGE_BACKEND: "deta" # MUST BE EITHER "deta" OR "local"
STORAGE_BACKEND: "local" # MUST BE EITHER "s3" OR "local"
FREE_STORAGE_LIMIT: "1074000000" # Free storage limit in bytes (default: 1GB)
# If STORAGE_BACKEND is "deta"
DETA_PROJECT_KEY: "YOUR_DETA_PROJECT_KEY"
DETA_PROJECT_ID: "YOUR_DETA_PROJECT_ID"
# If STORAGE_BACKEND is "s3"
S3_ACCESS_KEY=YOUR_ACCESS_KEY
S3_SECRET_KEY=YOUR_SECRET_KEY
S3_BASE_URL=YOUR_S3_BASE_URL
# If STORAGE_BACKEND is "local"
STORAGE_PATH: "/var/storage"
@@ -205,6 +209,8 @@ services:
GITHUB_CLIENT_ID: # Your GitHub-Client ID, or leave it unset if you don't want it.
GITHUB_CLIENT_SECRET: # Your GitHub-Client Secret, or leave it unset if you don't want it.
volumes: # Only needed if you chose the "local" storage-backend
- ./uploads:/var/storage
redis:
image: redis:alpine
@@ -241,6 +247,14 @@ services:
MEILI_NO_ANALYTICS: true
volumes:
- meilisearch-data:/data.ms
worker:
build: *build_cfg
environment: *env_vars
restart: *restart
depends_on: *depends
command: arq classquiz.worker.WorkerSettings
volumes:
data:
meilisearch-data:
+157
View File
@@ -0,0 +1,157 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import type { PageData } from './$types';
import { fade } from 'svelte/transition';
// import MediaComponent from '$lib/editor/MediaComponent.svelte';
import BrownButton from '$lib/components/buttons/brown.svelte';
import { onMount } from 'svelte';
import Uploader from './uploader.svelte';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let data: PageData;
let edit_popup = null;
const images = data.images;
const close_popup_handler = (e: Event) => {
if (e.target !== e.currentTarget) return;
edit_popup = null;
};
onMount(() => {
window.onkeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
edit_popup = null;
}
};
});
const save_image_metadata = async () => {
await fetch(`/api/v1/storage/meta/${edit_popup.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ filename: edit_popup.filename, alt_text: edit_popup.alt_text })
});
edit_popup = null;
window.location.reload();
};
const delete_image = async (id: string) => {
await fetch(`/api/v1/storage/meta/${id}`, { method: 'DELETE' });
window.location.reload();
};
</script>
<div>
<h2 class="text-center text-4xl">
{$t('file_dashboard.storage_usage', {
used: (data.storage_usage.used / (1024 * 1024)).toFixed(2),
total: (data.storage_usage.limit / (1024 * 1024)).toFixed(0),
percent: ((data.storage_usage.used / data.storage_usage.limit) * 100).toFixed(0)
})}
</h2>
<Uploader />
<div class="grid grid-cols-1 lg:grid-cols-2 p-4 gap-4">
{#each images as image}
<div
class="border-2 border-[#B07156] rounded p-2 grid grid-cols-2 hover:opacity-100 transition-all"
class:opacity-40={image.quiztivities.length === 0 && image.quizzes.length === 0}
>
<img
src="/api/v1/storage/download/{image.id}"
class="m-auto h-auto w-auto max-h-[30vh]"
loading="lazy"
alt={image.alt_text || $t('file_dashboard.not_available')}
/>
<div class="flex flex-col my-auto ml-4">
<p>
{$t('file_dashboard.size', {
size: (image.size / (1024 * 1024)).toFixed(2)
})}
</p>
<p>
{$t('file_dashboard.caption', {
caption: image.alt_text ?? $t('file_dashboard.missing')
})}
</p>
<p>
{$t('file_dashboard.filename', {
filename: image.filename ?? $t('file_dashboard.missing')
})}
</p>
<p>
{$t('file_dashboard.uploaded', {
date: new Date(image.uploaded_at).toLocaleString()
})}
</p>
<p>
{$t('file_dashboard.imported', {
yes_or_no: image.imported ? $t('words.yes') : $t('words.no')
})}
</p>
<div class="flex flex-col gap-2">
<BrownButton
on:click={() => {
edit_popup = image;
}}
>{$t('file_dashboard.edit_details')}
</BrownButton>
{#if image.quiztivities.length === 0 && image.quizzes.length === 0}
<BrownButton
on:click={() => {
delete_image(image.id);
}}>{$t('file_dashboard.delete_image')}</BrownButton
>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{#if edit_popup}
<div
transition:fade|local={{ duration: 100 }}
class="fixed top-0 left-0 h-screen w-screen z-40 flex bg-black bg-opacity-50"
on:click={close_popup_handler}
>
<div class="w-auto h-auto m-auto rounded bg-white dark:bg-gray-700 p-4">
<h1 class="text-2xl text-center">{$t('file_dashboard.edit_the_image')}</h1>
<form class="flex flex-col" on:submit|preventDefault={save_image_metadata}>
<div class="flex flex-row">
<div class="flex flex-col mr-4">
<label for="name" class="m-auto">{$t('file_dashboard.filename_word')}</label
>
<label for="alt_text" class="m-auto">{$t('file_dashboard.alt_text')}</label>
</div>
<div class="flex flex-col gap-3">
<input
class="rounded outline-none dark:bg-gray-500 p-0.5 border-4 border-transparent"
id="name"
type="text"
bind:value={edit_popup.filename}
/>
<input
class:border-red-700={!edit_popup.alt_text}
class="transition rounded outline-none dark:bg-gray-500 p-0.5 border-4 border-transparent"
id="alt_text"
type="text"
bind:value={edit_popup.alt_text}
/>
</div>
</div>
<div class="mt-4">
<BrownButton type="submit">{$t('words.save')}</BrownButton>
</div>
</form>
</div>
</div>
{/if}
+19
View File
@@ -0,0 +1,19 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { PageLoad } from './$types';
import type { PrivateImageData } from '$lib/quiz_types';
export const load = (async ({ fetch }) => {
const res = await fetch('/api/v1/storage/list');
const res2 = await fetch('/api/v1/storage/limit');
const json: PrivateImageData[] = await res.json();
const storage_usage: { limit: number; limit_reached: boolean; used: number } =
await res2.json();
return {
images: json,
storage_usage
};
}) satisfies PageLoad;
@@ -0,0 +1,33 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import Spinner from '$lib/Spinner.svelte';
let uppyOpen = false;
let edit_id = null;
let selected_question = undefined;
let data = { cover_image: undefined };
$: {
if (data.cover_image) {
window.location.reload();
}
}
</script>
{#await import('$lib/editor/uploader.svelte')}
<Spinner my_20={false} />
{:then c}
<svelte:component
this={c.default}
bind:modalOpen={uppyOpen}
bind:edit_id
bind:data
bind:selected_question
video_upload={true}
library_enabled={false}
/>
{/await}
@@ -0,0 +1,14 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { PageServerLoad } from './$types';
export const load = (async ({ setHeaders }) => {
setHeaders({
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin'
});
}) satisfies PageServerLoad;
@@ -0,0 +1,207 @@
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<!--
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-->
<script lang="ts">
import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';
import BrownButton from '$lib/components/buttons/brown.svelte';
import { navbarVisible } from '$lib/stores';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
let file_input: HTMLInputElement;
navbarVisible.set(false);
let stats: { progress: number; time_elapsed: number; speed: number } = {
progress: 0,
time_elapsed: 0,
speed: 0
};
let upload_stats: { progress: number; upload_started: undefined | Date; time_elapsed: number } =
{
progress: 0,
upload_started: undefined,
time_elapsed: 0
};
const speed_extraction_regex = /speed=(\d+\.\d+)x(?![\s\S]*speed=\d+\.\d+x)/;
let last_time_elapsed = 0;
// eslint-disable-next-line no-unused-vars
enum Status {
// eslint-disable-next-line no-unused-vars
Idle,
// eslint-disable-next-line no-unused-vars
Compressing,
// eslint-disable-next-line no-unused-vars
CompressDone,
// eslint-disable-next-line no-unused-vars
Uploading,
// eslint-disable-next-line no-unused-vars
Done
}
let status: Status = Status.Idle;
let file_size_in_mi: undefined | number = undefined;
let original_file_size_in_mi: undefined | number = undefined;
let file_data: undefined | Blob = undefined;
const compress_video = async () => {
if (status !== Status.Idle) {
return;
}
status = Status.Compressing;
const ffmpeg = createFFmpeg({
// log: true,
progress: (p) => {
if (stats.progress >= 1) {
stats.time_elapsed = last_time_elapsed;
return;
}
stats.time_elapsed = p.time ?? 0;
last_time_elapsed = stats.time_elapsed;
stats.progress = p.ratio ?? 0;
}
});
const file = file_input.files[0];
original_file_size_in_mi = file.size / 1_048_576;
ffmpeg.setLogger(({ message }) => {
const speed_match = message.match(speed_extraction_regex);
stats.speed = speed_match ? parseFloat(speed_match[1]) : 0;
});
await ffmpeg.load();
ffmpeg.FS('writeFile', file.name, await fetchFile(file));
await ffmpeg.run(
'-i',
file.name,
'-vcodec',
'libx264',
'-crf',
'20',
'-vf',
'scale=1080:-2',
'out.mp4'
);
const data = await ffmpeg.FS('readFile', 'out.mp4');
file_size_in_mi = data.length / 1_048_576;
file_data = new Blob([data]);
status = Status.CompressDone;
stats.progress = 1;
};
const upload_video = async () => {
status = Status.Uploading;
upload_stats.upload_started = new Date();
/* const progressTrackingStream = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk);
bytesUploaded += chunk.byteLength;
console.log("upload progress:", bytesUploaded / totalBytes);
upload_stats.progress = bytesUploaded / totalBytes;
upload_stats.time_elapsed = (new Date() - upload_stats.upload_started) / 1000;
},
flush(_controller) {
console.log("completed stream");
}
});*/
/* const response = await fetch("/api/v1/storage/raw", {
method: "POST",
headers: {
"Content-Type": "video/mp4"
},
body: file_data.stream().pipeThrough(progressTrackingStream),
duplex: "half"
});
const json = await response.json()*/
const xhr = new XMLHttpRequest();
const success = await new Promise((resolve) => {
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
upload_stats.progress = event.loaded / event.total;
upload_stats.time_elapsed = (new Date() - upload_stats.upload_started) / 1000;
}
});
xhr.addEventListener('loadend', () => {
resolve({
success: xhr.readyState === 4 && xhr.status === 200,
body: xhr.responseText
});
});
xhr.open('POST', '/api/v1/storage/raw', true);
xhr.setRequestHeader('Content-Type', 'video/mp4');
xhr.send(file_data);
});
console.log('success:', success);
const json = JSON.parse(success.body);
localStorage.setItem('video_upload_id', json.id);
console.log(json, 'done');
status = Status.Done;
window.close();
};
</script>
<div class="w-screen h-screen m-auto flex flex-col dark:bg-gray-700 p-2 bg-white">
<input type="file" bind:this={file_input} class="m-auto" accept="video/mp4" />
<div class="mt-auto flex justify-center">
{#if status === Status.Compressing || status === Status.CompressDone}
<table>
<tr>
<th>{$t('words.speed')}</th>
<th>{$t('words.progress')}</th>
<th>{$t('video_uploader.time_elapsed')}</th>
<th>{$t('video_uploader.time_remaining')}</th>
</tr>
<tr>
<td>{stats.speed}</td>
<td>{Math.round(stats.progress * 100)}%</td>
<td>{upload_stats.time_elapsed.toFixed(2)}s</td>
<td
>{(stats.time_elapsed / stats.progress - upload_stats.time_elapsed).toFixed(
2
)}s</td
>
</tr>
</table>
{:else if status === Status.Uploading}
<table>
<tr>
<th>{$t('words.progress')}</th>
<th>{$t('video_uploader.time_elapsed')}</th>
<th>{$t('video_uploader.time_remaining')}</th>
</tr>
<tr>
<td>{Math.round(upload_stats.progress * 100)}%</td>
<td>{stats.time_elapsed.toFixed(2)}s</td>
<td>{(stats.time_elapsed / stats.progress - stats.time_elapsed).toFixed(2)}s</td
>
</tr>
</table>
{/if}
</div>
<div class="flex relative my-2">
<div class="w-full">
<BrownButton disabled={status !== Status.CompressDone} on:click={upload_video}>
{$t('words.upload')}
{file_size_in_mi ? `(${file_size_in_mi.toFixed(2)} Mi)` : ''}</BrownButton
>
</div>
<span
class="h-full transition-all absolute rounded bg-black bg-opacity-50"
style="width: {100 - stats.progress * 100}%"
/>
</div>
<div class="flex justify-center">
<BrownButton on:click={compress_video} disabled={status !== Status.Idle}
>{$t('words.submit')}</BrownButton
>
</div>
</div>
+10 -7
View File
@@ -5,7 +5,7 @@
-->
<script lang="ts">
import { getLocalization } from '$lib/i18n';
import { navbarVisible, alertModal } from '$lib/stores';
import { navbarVisible } from '$lib/stores';
navbarVisible.set(true);
@@ -35,17 +35,19 @@
if (res.status === 200) {
window.location.href = '/dashboard';
} else if (res.status === 400) {
alertModal.set({
/* alertModal.set({
open: true,
title: 'Import failed',
body: "This quiz isn't (yet) supported!"
});
});*/
alert("This quiz isn't (yet) supported!");
} else {
alertModal.set({
/* alertModal.set({
open: true,
title: 'Import failed',
body: 'Unknown error while importing the quiz!'
});
});*/
alert('Import failed with unknown reason');
}
is_loading = false;
};
@@ -61,11 +63,12 @@
if (res.status === 200) {
window.location.href = '/dashboard';
} else {
alertModal.set({
/* alertModal.set({
open: true,
title: 'Import failed',
body: 'Something went wrong!'
});
});*/
alert('Something went wrong!');
}
is_loading = false;
};
+2 -1
View File
@@ -253,7 +253,8 @@
{#if game_data.questions[selected_question].image !== null}
<div>
<img
src={game_data.questions[selected_question].image}
src="/api/v1/storage/download/{game_data.questions[selected_question]
.image}"
class="max-h-[20vh] object-cover mx-auto mb-8 w-auto"
alt="Content for Question"
/>
@@ -82,7 +82,7 @@
<div class="h-[20vh] m-auto w-auto max-h-[18vh]">
<img
class="max-h-full max-w-full block"
src={quiz.cover_image}
src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided"
loading="lazy"
/>
@@ -13,6 +13,7 @@
import { onMount } from 'svelte';
import Spinner from '$lib/Spinner.svelte';
import GrayButton from '$lib/components/buttons/gray.svelte';
import MediaComponent from '$lib/editor/MediaComponent.svelte';
const tippy = createTippy({
arrow: true,
@@ -79,7 +80,7 @@
<div class="h-[15vh] m-auto w-auto my-3">
<img
class="max-h-full max-w-full block"
src={quiz.cover_image}
src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided"
/>
</div>
@@ -189,7 +190,12 @@
<!-- </label>-->
{#if question.image}
<span>
<img class="pl-8" src={question.image} alt="Not provided" />
<MediaComponent
css_classes="mx-auto"
src={question.image}
alt="Not provided"
muted={true}
/>
</span>
{/if}
<p
+15 -2
View File
@@ -1,8 +1,21 @@
import { sveltekit } from '@sveltejs/kit/vite';
/** @type {import('vite').UserConfig} */
/** @type {import("vite").UserConfig} */
const config = {
plugins: [sveltekit()],
plugins: [
sveltekit(),
{
name: 'configure-response-headers',
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
/* res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
res.setHeader("Access-Control-Allow-Origin", "https://ncs3.classquiz.de");*/
next();
});
}
}
],
server: {
port: 3000
},
@@ -0,0 +1,160 @@
"""New storage
Revision ID: 89c4b5d547aa
Revises: 01d7a503e360
Create Date: 2023-05-29 19:24:49.275211
"""
from alembic import op
import sqlalchemy as sa
import ormar
import re
import json
from sqlalchemy.orm import Session
# revision identifiers, used by Alembic.
revision = "89c4b5d547aa"
down_revision = "8ac2bed1718e"
branch_labels = None
depends_on = None
magic_regex = r"/api/v1/storage/download/(.{36}--.{36})"
def upgrade() -> None:
## ADDED STORAGE ITEM
op.create_table(
"storage_items",
sa.Column("id", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=False),
sa.Column("uploaded_at", sa.DateTime(), nullable=False),
sa.Column("mime_type", sa.Text(), nullable=False),
sa.Column("hash", sa.LargeBinary(length=16), nullable=True),
sa.Column("user", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
sa.Column("size", sa.BigInteger(), nullable=False),
sa.Column("storage_path", sa.Text(), nullable=True),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.Column("alt_text", sa.Text(), nullable=True),
sa.Column("filename", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["user"], ["users.id"], name="fk_storage_items_users_id_user"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"storageitems_quizs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("quiz", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
sa.Column("storageitem", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
sa.ForeignKeyConstraint(
["quiz"], ["quiz.id"], name="fk_storageitems_quizs_quiz_quiz_id", onupdate="CASCADE", ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["storageitem"],
["storage_items.id"],
name="fk_storageitems_quizs_storage_items_storageitem_id",
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"storageitems_quiztivitys",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("quiztivity", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
sa.Column("storageitem", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True),
sa.ForeignKeyConstraint(
["quiztivity"],
["quiztivitys.id"],
name="fk_storageitems_quiztivitys_quiztivitys_quiztivity_id",
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["storageitem"],
["storage_items.id"],
name="fk_storageitems_quiztivitys_storage_items_storageitem_id",
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
## DON'T INCLUDE HOST IN IMAGE URLS
from classquiz.config import settings
settings = settings()
conn = op.get_bind()
session = Session(bind=conn)
all_cover_images = session.execute("SELECT cover_image, id from quiz where cover_image is not null;")
for cover_image, id in all_cover_images:
try:
new_cover_image = re.search(magic_regex, cover_image).group(1)
session.execute(f"UPDATE quiz SET cover_image = '{new_cover_image}' WHERE id='{id}';")
except AttributeError:
continue
all_background_images = session.execute("SELECT background_image, id from quiz where background_image is not null;")
for bg_image, id in all_background_images:
try:
new_bg_image = re.search(magic_regex, bg_image).group(1)
session.execute(f"UPDATE quiz SET cover_image = '{new_bg_image}' WHERE id='{id}';")
except AttributeError:
continue
all_questions = session.execute("SELECT questions, id from quiz;")
question_image_regex = rf"{settings.root_address}/api/v1/storage/download/(?=.{{36}}--.{{36}})"
for question, id in all_questions:
question_as_json = json.dumps(question)
result = re.sub(question_image_regex, "", question_as_json)
session.execute(f"UPDATE quiz SET questions = '{result}' WHERE id='{id}';")
## ADDED THUMBHASH AND SERVER STORAGE ITEM
op.add_column("storage_items", sa.Column("thumbhash", sa.Text(), nullable=True))
op.add_column("storage_items", sa.Column("server", sa.Text(), nullable=True))
op.add_column("storage_items", sa.Column("imported", sa.Boolean(), nullable=True))
## ADDED STORAGE USED TO USER
op.add_column("users", sa.Column("storage_used", sa.BigInteger(), nullable=True))
conn = op.get_bind()
session = Session(bind=conn)
session.execute("UPDATE users SET storage_used=0 WHERE storage_used is NULL")
op.alter_column("users", "storage_used", nullable=False)
def downgrade() -> None:
## ADDED STORAGE USED TO USER
op.drop_column("users", "storage_used")
## ADDED THUMBHASH AND SERVER STORAGE ITEM
op.drop_column("storage_items", "imported")
op.drop_column("storage_items", "server")
op.drop_column("storage_items", "thumbhash")
## DON'T INCLUDE HOST IN IMAGE URLS
from classquiz.config import settings
settings = settings()
conn = op.get_bind()
session = Session(bind=conn)
all_cover_images = session.execute("SELECT cover_image, id from quiz where cover_image is not null;")
for cover_image, id in all_cover_images:
new_cover_image = f"{settings.root_address}/api/v1/storage/download/{cover_image}"
# print(new_cover_image, id)
session.execute(f"UPDATE quiz SET cover_image = '{new_cover_image}' WHERE id='{id}';")
all_background_images = session.execute("SELECT background_image, id from quiz where background_image is not null;")
for bg_image, id in all_background_images:
new_bg_image = f"{settings.root_address}/api/v1/storage/download/{bg_image}"
# print(new_cover_image, id)
session.execute(f"UPDATE quiz SET cover_image = '{new_bg_image}' WHERE id='{id}';")
all_questions = session.execute("SELECT questions, id from quiz;")
question_image_regex = r"(?=.{36}--.{36})"
for question, id in all_questions:
question_as_json = json.dumps(question)
result = re.sub(question_image_regex, f"{settings.root_address}/api/v1/storage/download/", question_as_json)
session.execute(f"UPDATE quiz SET questions = '{result}' WHERE id='{id}';")
## ADDED STORAGE ITEM
op.drop_table("storageitems_quiztivitys")
op.drop_table("storageitems_quizs")
op.drop_table("storage_items")
@@ -0,0 +1,86 @@
"""Added DeleteAction to models
Revision ID: a1841b1918b3
Revises: 89c4b5d547aa
Create Date: 2023-06-09 13:13:00.865587
"""
from alembic import op
import sqlalchemy as sa
import ormar
# revision identifiers, used by Alembic.
revision = "a1841b1918b3"
down_revision = "89c4b5d547aa"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint("fk_api_keys_users_id_user", "api_keys", type_="foreignkey")
op.create_foreign_key("fk_api_keys_users_id_user", "api_keys", "users", ["user"], ["id"], ondelete="CASCADE")
op.drop_constraint("fk_fido_credentials_users_id_user", "fido_credentials", type_="foreignkey")
op.create_foreign_key(
"fk_fido_credentials_users_id_user", "fido_credentials", "users", ["user"], ["id"], ondelete="CASCADE"
)
op.drop_constraint("fk_game_results_quiz_id_quiz", "game_results", type_="foreignkey")
op.drop_constraint("fk_game_results_users_id_user", "game_results", type_="foreignkey")
op.create_foreign_key(
"fk_game_results_users_id_user", "game_results", "users", ["user"], ["id"], ondelete="CASCADE"
)
op.create_foreign_key("fk_game_results_quiz_id_quiz", "game_results", "quiz", ["quiz"], ["id"], ondelete="CASCADE")
op.drop_constraint("fk_quiz_users_id_user_id", "quiz", type_="foreignkey")
op.create_foreign_key("fk_quiz_users_id_user_id", "quiz", "users", ["user_id"], ["id"], ondelete="CASCADE")
op.drop_constraint("fk_quiztivitys_users_id_user", "quiztivitys", type_="foreignkey")
op.create_foreign_key("fk_quiztivitys_users_id_user", "quiztivitys", "users", ["user"], ["id"], ondelete="CASCADE")
op.drop_constraint("fk_quiztivityshares_quiztivitys_id_quiztivity", "quiztivityshares", type_="foreignkey")
op.drop_constraint("fk_quiztivityshares_users_id_user", "quiztivityshares", type_="foreignkey")
op.create_foreign_key(
"fk_quiztivityshares_quiztivitys_id_quiztivity",
"quiztivityshares",
"quiztivitys",
["quiztivity"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_quiztivityshares_users_id_user", "quiztivityshares", "users", ["user"], ["id"], ondelete="CASCADE"
)
op.drop_constraint("fk_storage_items_users_id_user", "storage_items", type_="foreignkey")
op.create_foreign_key(
"fk_storage_items_users_id_user", "storage_items", "users", ["user"], ["id"], ondelete="SET NULL"
)
op.drop_constraint("fk_user_sessions_users_id_user", "user_sessions", type_="foreignkey")
op.create_foreign_key(
"fk_user_sessions_users_id_user", "user_sessions", "users", ["user"], ["id"], ondelete="CASCADE"
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint("fk_user_sessions_users_id_user", "user_sessions", type_="foreignkey")
op.create_foreign_key("fk_user_sessions_users_id_user", "user_sessions", "users", ["user"], ["id"])
op.drop_constraint("fk_storage_items_users_id_user", "storage_items", type_="foreignkey")
op.create_foreign_key("fk_storage_items_users_id_user", "storage_items", "users", ["user"], ["id"])
op.drop_constraint("fk_quiztivityshares_users_id_user", "quiztivityshares", type_="foreignkey")
op.drop_constraint("fk_quiztivityshares_quiztivitys_id_quiztivity", "quiztivityshares", type_="foreignkey")
op.create_foreign_key("fk_quiztivityshares_users_id_user", "quiztivityshares", "users", ["user"], ["id"])
op.create_foreign_key(
"fk_quiztivityshares_quiztivitys_id_quiztivity", "quiztivityshares", "quiztivitys", ["quiztivity"], ["id"]
)
op.drop_constraint("fk_quiztivitys_users_id_user", "quiztivitys", type_="foreignkey")
op.create_foreign_key("fk_quiztivitys_users_id_user", "quiztivitys", "users", ["user"], ["id"])
op.drop_constraint("fk_quiz_users_id_user_id", "quiz", type_="foreignkey")
op.create_foreign_key("fk_quiz_users_id_user_id", "quiz", "users", ["user_id"], ["id"])
op.drop_constraint("fk_game_results_quiz_id_quiz", "game_results", type_="foreignkey")
op.drop_constraint("fk_game_results_users_id_user", "game_results", type_="foreignkey")
op.create_foreign_key("fk_game_results_users_id_user", "game_results", "users", ["user"], ["id"])
op.create_foreign_key("fk_game_results_quiz_id_quiz", "game_results", "quiz", ["quiz"], ["id"])
op.drop_constraint("fk_fido_credentials_users_id_user", "fido_credentials", type_="foreignkey")
op.create_foreign_key("fk_fido_credentials_users_id_user", "fido_credentials", "users", ["user"], ["id"])
op.drop_constraint("fk_api_keys_users_id_user", "api_keys", type_="foreignkey")
op.create_foreign_key("fk_api_keys_users_id_user", "api_keys", "users", ["user"], ["id"])
# ### end Alembic commands ###