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/ COPY Pipfile* /app/
WORKDIR /app/ WORKDIR /app/
RUN apt update && \ RUN apt update && \
apt install -y jq gcc libpq5 libpq-dev && \ apt install -y jq gcc libpq5 libpq-dev libmagic1 && \
jq -r '.default | to_entries[] | .key + .value.version' Pipfile.lock > requirements.txt && \ jq -r '.default | to_entries[] | .key + .value.version' Pipfile.lock > requirements.txt && \
sed -i "s/psycopg2-binary/psycopg2/g" 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 classquiz/ /app/classquiz/
COPY image_cleanup.py /app/image_cleanup.py COPY image_cleanup.py /app/image_cleanup.py
+6 -3
View File
@@ -30,13 +30,15 @@ pillow = ">=9.1.1"
authlib = "*" authlib = "*"
httpx = "*" httpx = "*"
itsdangerous = "*" itsdangerous = "*"
puremagic = "*"
py-avataaars-no-png = "*" py-avataaars-no-png = "*"
cryptography = "*" cryptography = "*"
scheduler = "*"
webauthn = "*" webauthn = "*"
pyotp = "*" pyotp = "*"
minio = "*" minio = "*"
xxhash = "*"
arq = "*"
thumbhash-python = "==1.0.0"
python-magic = "*"
[dev-packages] [dev-packages]
coverage = "*" coverage = "*"
@@ -50,9 +52,10 @@ pre-commit = "*"
python-socketio = {extras = ["client"], version = "*"} python-socketio = {extras = ["client"], version = "*"}
[requires] [requires]
python_version = "3.10" python_version = "3.11"
[scripts] [scripts]
format = "black ." format = "black ."
lint = "flake8 classquiz" lint = "flake8 classquiz"
test = "coverage run -m pytest --lf -v --asyncio-mode=strict classquiz/tests" 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": { "_meta": {
"hash": { "hash": {
"sha256": "0f27e59c80a47f8948353baf55cc7495131b532f2718bcd2ba08f25a40dc3dab" "sha256": "f10247b4d32925c62f8f9c99a6b3d3bf0073f630d09cf15a7df884f86b78b0a2"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
"python_version": "3.10" "python_version": "3.11"
}, },
"sources": [ "sources": [
{ {
@@ -127,11 +127,11 @@
}, },
"alembic": { "alembic": {
"hashes": [ "hashes": [
"sha256:32a69b13a613aeb7e8093f242da60eff9daed13c0df02fff279c1b06c32965d2", "sha256:6a810a6b012c88b33458fceb869aef09ac75d6ace5291915ba7fae44de372c01",
"sha256:b2e0a6cfd3a8ce936a1168320bcbe94aefa3f4463cd773a968a55071beb3cd37" "sha256:dc871798a601fab38332e38d6ddb38d5e734f60034baeb8e2db5b642fccd8ab8"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.10.3" "version": "==1.11.1"
}, },
"anyio": { "anyio": {
"hashes": [ "hashes": [
@@ -176,6 +176,14 @@
"markers": "python_version >= '3.6'", "markers": "python_version >= '3.6'",
"version": "==21.2.0" "version": "==21.2.0"
}, },
"arq": {
"hashes": [
"sha256:d176ebadfba920c039dc578814d19b7814d67fa15f82fdccccaedb4330d65dae",
"sha256:db072d0f39c0bc06b436db67ae1f315c81abc1527563b828955670531815290b"
],
"index": "pypi",
"version": "==0.25.0"
},
"asn1crypto": { "asn1crypto": {
"hashes": [ "hashes": [
"sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c",
@@ -188,7 +196,7 @@
"sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15",
"sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c" "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"
], ],
"markers": "python_full_version <= '3.11.2'", "markers": "python_version >= '3.6'",
"version": "==4.0.2" "version": "==4.0.2"
}, },
"asyncpg": { "asyncpg": {
@@ -472,7 +480,7 @@
"sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df",
"sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"
], ],
"markers": "python_version >= '3.7'", "markers": "python_full_version >= '3.7.0'",
"version": "==3.1.0" "version": "==3.1.0"
}, },
"click": { "click": {
@@ -483,30 +491,44 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==8.1.3" "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": { "cryptography": {
"hashes": [ "hashes": [
"sha256:0ddaee209d1cf1f180f1efa338a68c4621154de0afaef92b89486f5f96047c55", "sha256:059e348f9a3c1950937e1b5d7ba1f8e968508ab181e75fc32b879452f08356db",
"sha256:14754bcdae909d66ff24b7b5f166d69340ccc6cb15731670435efd5719294895", "sha256:1a5472d40c8f8e91ff7a3d8ac6dfa363d8e3138b961529c996f3e2df0c7a411a",
"sha256:344c6de9f8bda3c425b3a41b319522ba3208551b70c2ae00099c205f0d9fd3be", "sha256:1a8e6c2de6fbbcc5e14fd27fb24414507cb3333198ea9ab1258d916f00bc3039",
"sha256:34d405ea69a8b34566ba3dfb0521379b210ea5d560fafedf9f800a9a94a41928", "sha256:1fee5aacc7367487b4e22484d3c7e547992ed726d14864ee33c0176ae43b0d7c",
"sha256:3680248309d340fda9611498a5319b0193a8dbdb73586a1acf8109d06f25b92d", "sha256:5d092fdfedaec4cbbffbf98cddc915ba145313a6fdaab83c6e67f4e6c218e6f3",
"sha256:3c5ef25d060c80d6d9f7f9892e1d41bb1c79b78ce74805b8cb4aa373cb7d5ec8", "sha256:5f0ff6e18d13a3de56f609dd1fd11470918f770c6bd5d00d632076c727d35485",
"sha256:4ab14d567f7bbe7f1cdff1c53d5324ed4d3fc8bd17c481b395db224fb405c237", "sha256:7bfc55a5eae8b86a287747053140ba221afc65eb06207bedf6e019b8934b477c",
"sha256:5c1f7293c31ebc72163a9a0df246f890d65f66b4a40d9ec80081969ba8c78cc9", "sha256:7fa01527046ca5facdf973eef2535a27fec4cb651e4daec4d043ef63f6ecd4ca",
"sha256:6b71f64beeea341c9b4f963b48ee3b62d62d57ba93eb120e1196b31dc1025e78", "sha256:8dde71c4169ec5ccc1087bb7521d54251c016f126f922ab2dfe6649170a3b8c5",
"sha256:7d92f0248d38faa411d17f4107fc0bce0c42cae0b0ba5415505df72d751bf62d", "sha256:8f4ab7021127a9b4323537300a2acfb450124b2def3756f64dc3a3d2160ee4b5",
"sha256:8362565b3835ceacf4dc8f3b56471a2289cf51ac80946f9087e66dc283a810e0", "sha256:948224d76c4b6457349d47c0c98657557f429b4e93057cf5a2f71d603e2fc3a3",
"sha256:84a165379cb9d411d58ed739e4af3396e544eac190805a54ba2e0322feb55c46", "sha256:9a6c7a3c87d595608a39980ebaa04d5a37f94024c9f24eb7d10262b92f739ddb",
"sha256:88ff107f211ea696455ea8d911389f6d2b276aabf3231bf72c8853d22db755c5", "sha256:b46e37db3cc267b4dea1f56da7346c9727e1209aa98487179ee8ebed09d21e43",
"sha256:9f65e842cb02550fac96536edb1d17f24c0a338fd84eaf582be25926e993dde4", "sha256:b4ceb5324b998ce2003bc17d519080b4ec8d5b7b70794cbd2836101406a9be31",
"sha256:a4fc68d1c5b951cfb72dfd54702afdbbf0fb7acdc9b7dc4301bbf2225a27714d", "sha256:cb33ccf15e89f7ed89b235cff9d49e2e62c6c981a6061c9c8bb47ed7951190bc",
"sha256:b7f2f5c525a642cecad24ee8670443ba27ac1fab81bba4cc24c7b6b41f2d0c75", "sha256:d198820aba55660b4d74f7b5fd1f17db3aa5eb3e6893b0a41b75e84e4f9e0e4b",
"sha256:b846d59a8d5a9ba87e2c3d757ca019fa576793e8758174d3868aecb88d6fc8eb", "sha256:d34579085401d3f49762d2f7d6634d6b6c2ae1242202e860f4d26b046e3a1006",
"sha256:bf8fc66012ca857d62f6a347007e166ed59c0bc150cefa49f28376ebe7d992a2", "sha256:eb8163f5e549a22888c18b0d53d6bb62a20510060a22fd5a995ec8a05268df8a",
"sha256:f5d0bf9b252f30a31664b6f64432b4730bb7038339bd18b1fafe129cfc2be9be" "sha256:f73bff05db2a3e5974a6fd248af2566134d8981fd7ab012e5dd4ddb1d9a70699"
], ],
"index": "pypi", "index": "pypi",
"version": "==41.0.0" "version": "==41.0.1"
}, },
"databases": { "databases": {
"hashes": [ "hashes": [
@@ -534,27 +556,19 @@
}, },
"email-validator": { "email-validator": {
"hashes": [ "hashes": [
"sha256:07c61b62ee446e39274b18204afa8e422baf64570776045272b04d10c02f64f6", "sha256:1ff6e86044200c56ae23595695c54e9614f4a9551e0e393614f764860b3d7900",
"sha256:f4904f4145c11f8de5897afbb8d7db4b465c57f13db1c8c106c16d02bceebd9a" "sha256:2466ba57cda361fb7309fd3d5a225723c788ca4bbad32a0ebd5373b99730285c"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.0.0" "version": "==2.0.0.post2"
},
"exceptiongroup": {
"hashes": [
"sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e",
"sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"
],
"markers": "python_version < '3.11'",
"version": "==1.1.1"
}, },
"fastapi": { "fastapi": {
"hashes": [ "hashes": [
"sha256:9569f0a381f8a457ec479d90fa01005cfddaae07546eb1f3fa035bc4797ae7d5", "sha256:95d757511c596409930bd20673358d4a4d709004edb85c5d24d6ffc48fabcbf2",
"sha256:a870d443e5405982e1667dfe372663abf10754f246866056336d7f01c21dab07" "sha256:b53248ee45f64f19bb7600953696e3edf94b0f7de94df1e5433fc5c6136fa986"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.95.1" "version": "==0.97.0"
}, },
"frozenlist": { "frozenlist": {
"hashes": [ "hashes": [
@@ -718,6 +732,100 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==0.14.0" "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": { "httpcore": {
"hashes": [ "hashes": [
"sha256:125f8375ab60036db632f34f4b627a9ad085048eef7cb7d2616fea0f739f98af", "sha256:125f8375ab60036db632f34f4b627a9ad085048eef7cb7d2616fea0f739f98af",
@@ -774,11 +882,11 @@
}, },
"httpx": { "httpx": {
"hashes": [ "hashes": [
"sha256:447556b50c1921c351ea54b4fe79d91b724ed2b027462ab9a329465d147d5a4e", "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd",
"sha256:507d676fc3e26110d41df7d35ebd8b3b8585052450f4097401c9be59d928c63e" "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.24.0" "version": "==0.24.1"
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
@@ -814,75 +922,75 @@
}, },
"markupsafe": { "markupsafe": {
"hashes": [ "hashes": [
"sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed", "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e",
"sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc", "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e",
"sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2", "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431",
"sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460", "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686",
"sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7", "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559",
"sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0", "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc",
"sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1", "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c",
"sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa", "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0",
"sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03", "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4",
"sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323", "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9",
"sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65", "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575",
"sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013", "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba",
"sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036", "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d",
"sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f", "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3",
"sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4", "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00",
"sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419", "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155",
"sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2", "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac",
"sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619", "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52",
"sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a", "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f",
"sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a", "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8",
"sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd", "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b",
"sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7", "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24",
"sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666", "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea",
"sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65", "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198",
"sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859", "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0",
"sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625", "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee",
"sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff", "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be",
"sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156", "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2",
"sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd", "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707",
"sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba", "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6",
"sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f", "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58",
"sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1", "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779",
"sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094", "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636",
"sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a", "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c",
"sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513", "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad",
"sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed", "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee",
"sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d", "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc",
"sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3", "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2",
"sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147", "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48",
"sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c", "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7",
"sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603", "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e",
"sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601", "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b",
"sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a", "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa",
"sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1", "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5",
"sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d", "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e",
"sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3", "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb",
"sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54", "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9",
"sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2", "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57",
"sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6", "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc",
"sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58" "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2.1.2" "version": "==2.1.3"
}, },
"meilisearch": { "meilisearch": {
"hashes": [ "hashes": [
"sha256:597ec5eaf6b726428c0e04ecf05af19379aa097093225dab0c6bfb892d03a90d", "sha256:db084bc4e90608e3aa20d318de1cafc06db5dbd2e1561003634fa05e5025bc5e",
"sha256:e35ad840a42554b575ddb72c6aa5d9f7202ab2a6777fada5752269fe453379f4" "sha256:ff3ab30c92a8b1ae8ddbab9f7b94a0830ccbf6bf84f209664e2b1de076541e76"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.26.0" "version": "==0.28.0"
}, },
"minio": { "minio": {
"hashes": [ "hashes": [
"sha256:230faf1d0db1c3ce09aef86b0eb38e994a64769e34dad4b77febf59bc90d8ab0", "sha256:1afdf01c1bc8b57ddd12d438e3e168d625465b56f4d1c2af7576744c688e84c6",
"sha256:ee5cbd4ba73f71b73555209f3411d5da3e9742bbe3fd038c362042d6d2527256" "sha256:fcf8ac2cef310d5ddff2bef2c42f4e5a8bb546b87bca5bf8832135db054ca4e1"
], ],
"index": "pypi", "index": "pypi",
"version": "==7.1.14" "version": "==7.1.15"
}, },
"multidict": { "multidict": {
"hashes": [ "hashes": [
@@ -1122,14 +1230,6 @@
], ],
"version": "==2.9.6" "version": "==2.9.6"
}, },
"puremagic": {
"hashes": [
"sha256:349e179f04bcc14eb0e2c3941cd9ee4ea74881ab6ccac63ea0c37c98f7a80b22",
"sha256:6e46aa78113a466abc9f69e6e8a4ce90eb57d908dafb809597012621061462bd"
],
"index": "pypi",
"version": "==1.15"
},
"py-avataaars-no-png": { "py-avataaars-no-png": {
"hashes": [ "hashes": [
"sha256:9cd593fe93d6e223626903da7310ad53e857b3fd856ce7e7f2b1faa401740054", "sha256:9cd593fe93d6e223626903da7310ad53e857b3fd856ce7e7f2b1faa401740054",
@@ -1195,6 +1295,14 @@
"index": "pypi", "index": "pypi",
"version": "==1.10.4" "version": "==1.10.4"
}, },
"pygments": {
"hashes": [
"sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c",
"sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"
],
"markers": "python_version >= '3.7'",
"version": "==2.15.1"
},
"pyopenssl": { "pyopenssl": {
"hashes": [ "hashes": [
"sha256:24f0dc5227396b3e831f4c7f602b950a5e9833d292c8e4a2e06b709292806ae2", "sha256:24f0dc5227396b3e831f4c7f602b950a5e9833d292c8e4a2e06b709292806ae2",
@@ -1241,6 +1349,14 @@
"index": "pypi", "index": "pypi",
"version": "==3.3.0" "version": "==3.3.0"
}, },
"python-magic": {
"hashes": [
"sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b",
"sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3"
],
"index": "pypi",
"version": "==0.4.27"
},
"python-multipart": { "python-multipart": {
"hashes": [ "hashes": [
"sha256:e9925a80bb668529f1b67c7fdb0a5dacdd7cbfc6fb0bff3ea443fe22bdd62132", "sha256:e9925a80bb668529f1b67c7fdb0a5dacdd7cbfc6fb0bff3ea443fe22bdd62132",
@@ -1312,11 +1428,11 @@
}, },
"redis": { "redis": {
"hashes": [ "hashes": [
"sha256:2c19e6767c474f2e85167909061d525ed65bea9301c0770bb151e041b7ac89a2", "sha256:77929bc7f5dab9adf3acba2d3bb7d7658f1e0c2f1cafe7eb36434e751c471119",
"sha256:73ec35da4da267d6847e47f68730fdd5f62e2ca69e3ef5885c6a78a9374c3893" "sha256:dc87a0bdef6c8bfe1ef1e1c40be7034390c2ae02d92dcd0c7ca1729443899880"
], ],
"index": "pypi", "index": "pypi",
"version": "==4.5.4" "version": "==4.5.5"
}, },
"requests": { "requests": {
"hashes": [ "hashes": [
@@ -1326,29 +1442,28 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2.31.0" "version": "==2.31.0"
}, },
"rich": {
"hashes": [
"sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e",
"sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0"
],
"version": "==12.6.0"
},
"rsa": { "rsa": {
"hashes": [ "hashes": [
"sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7",
"sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21" "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"
], ],
"markers": "python_version >= '3.6' and python_version < '4.0'", "markers": "python_version >= '3.6' and python_version < '4'",
"version": "==4.9" "version": "==4.9"
}, },
"scheduler": {
"hashes": [
"sha256:48e8fe16f4a0cbd24f169a2ed4f65b99fb0c3a7f4a28f4dfbf26cc901ab4112b",
"sha256:5ae5181d8462d89307bdae588b47f290a2fb7e3fa1073650354e01eb87a265f4"
],
"index": "pypi",
"version": "==0.8.4"
},
"sentry-sdk": { "sentry-sdk": {
"hashes": [ "hashes": [
"sha256:7ae78bd921981a5010ab540d6bdf3b793659a4db8cccf7f16180702d48a80d84", "sha256:79afb7c896014038e358401ad1d36889f97a129dfa8031c49b3f238cd1aa3935",
"sha256:885a11c69df23e53eb281d003b9ff15a5bdfa43d8a2a53589be52104a1b4582f" "sha256:aa796423eb6a2f4a8cd7a5b02ba6558cb10aab4ccdc0537f63a47b038c520c38"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.19.1" "version": "==1.25.1"
}, },
"setuptools": { "setuptools": {
"hashes": [ "hashes": [
@@ -1358,6 +1473,13 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==67.8.0" "version": "==67.8.0"
}, },
"shellingham": {
"hashes": [
"sha256:368bf8c00754fd4f55afb7bbb86e272df77e4dc76ac29dbcbb81a59e9fc15744",
"sha256:823bc5fb5c34d60f285b624e7264f4dda254bc803a3774a147bf99c0e3004a28"
],
"version": "==1.5.0.post1"
},
"six": { "six": {
"hashes": [ "hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
@@ -1423,19 +1545,30 @@
}, },
"starlette": { "starlette": {
"hashes": [ "hashes": [
"sha256:41da799057ea8620e4667a3e69a5b1923ebd32b1819c8fa75634bbe8d8bea9bd", "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75",
"sha256:e87fce5d7cbdde34b76f0ac69013fd9d190d581d80681493016666e6f96c6d5e" "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==0.26.1" "version": "==0.27.0"
}, },
"typeguard": { "thumbhash-python": {
"hashes": [ "hashes": [
"sha256:194fb3dbcb06ea9caf7088f3befee014de57961689f9c859ac5239b1ef61d987", "sha256:17ad1f8cb8fb4dd3c0738cc2c60e8c5ef18c345290b7ff5f22fa16973c7b356c",
"sha256:c4a40af0ba8a41077221271b46d0a6d8d46045443e4d887887c69254ca861952" "sha256:755379a45c994cc0bcb06a7cac41880fb7966d65373560b1631e96ad2a59c6a6"
], ],
"markers": "python_full_version >= '3.7.4'", "index": "pypi",
"version": "==4.0.0" "version": "==1.0.0"
},
"typer": {
"extras": [
"all"
],
"hashes": [
"sha256:b5e704f4e48ec263de1c0b3a2387cd405a13767d2f907f44c1a08cbad96f606d",
"sha256:ff797846578a9f2a201b53442aedeb543319466870fbe1c701eab66dd7681165"
],
"markers": "python_version >= '3.6'",
"version": "==0.7.0"
}, },
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
@@ -1447,22 +1580,22 @@
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
"sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc", "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1",
"sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e" "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2.0.2" "version": "==2.0.3"
}, },
"uvicorn": { "uvicorn": {
"extras": [ "extras": [
"standard" "standard"
], ],
"hashes": [ "hashes": [
"sha256:0fac9cb342ba099e0d582966005f3fdba5b0290579fed4a6266dc702ca7bb032", "sha256:79277ae03db57ce7d9aa0567830bbb51d7a612f54d6e1e3e92da3ef24c2c8ed8",
"sha256:e47cac98a6da10cd41e6fd036d472c6f58ede6c5dbee3dbee3ef7a100ed97742" "sha256:e9434d3bbf05f310e762147f769c9f21235ee118ba2d2bf1155a7196448bd996"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.21.1" "version": "==0.22.0"
}, },
"uvloop": { "uvloop": {
"hashes": [ "hashes": [
@@ -1528,11 +1661,11 @@
}, },
"webauthn": { "webauthn": {
"hashes": [ "hashes": [
"sha256:0c981eb2575d66fa0a6586c2bfb24d9812dff8e158009a9030bc867717727709", "sha256:53914b8529df3e70bec0e41438eea8d99442ad3e54e32b86ce1ba83513bcf30e",
"sha256:1b87d55acd86234475591e2f60163232ba98d91685f235803f258fc891ebb6c0" "sha256:caca3cc62045923c542819169c22122bdb81efa0246c9d5208d073942d806bf3"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.8.0" "version": "==1.8.1"
}, },
"webencodings": { "webencodings": {
"hashes": [ "hashes": [
@@ -1618,11 +1751,115 @@
}, },
"xlsxwriter": { "xlsxwriter": {
"hashes": [ "hashes": [
"sha256:02913b50b74c00f165933d5da3e3a02cab4204cb4932722a1b342c5c71034122", "sha256:331508ff39d610ecdaf979e458840bc1eab6e6a02cfd5d08f044f0f73636236f",
"sha256:b70a147d36235d1ee835cfd037396f789db1f76740a0e5c917d54137169341de" "sha256:78751099a770273f1c98b8d6643351f68f98ae8e6acf9d09d37dc6798f8cd3de"
], ],
"index": "pypi", "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": { "yarl": {
"hashes": [ "hashes": [
@@ -1839,7 +2076,7 @@
"sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df",
"sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"
], ],
"markers": "python_version >= '3.7'", "markers": "python_full_version >= '3.7.0'",
"version": "==3.1.0" "version": "==3.1.0"
}, },
"click": { "click": {
@@ -1852,60 +2089,69 @@
}, },
"coverage": { "coverage": {
"hashes": [ "hashes": [
"sha256:06ddd9c0249a0546997fdda5a30fbcb40f23926df0a874a60a8a185bc3a87d93", "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f",
"sha256:0743b0035d4b0e32bc1df5de70fba3059662ace5b9a2a86a9f894cfe66569013", "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2",
"sha256:0f3736a5d34e091b0a611964c6262fd68ca4363df56185902528f0b75dbb9c1f", "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a",
"sha256:1127b16220f7bfb3f1049ed4a62d26d81970a723544e8252db0efde853268e21", "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a",
"sha256:172db976ae6327ed4728e2507daf8a4de73c7cc89796483e0a9198fd2e47b462", "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01",
"sha256:182eb9ac3f2b4874a1f41b78b87db20b66da6b9cdc32737fbbf4fea0c35b23fc", "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6",
"sha256:1bb1e77a9a311346294621be905ea8a2c30d3ad371fc15bb72e98bfcfae532df", "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7",
"sha256:1fd78b911aea9cec3b7e1e2622c8018d51c0d2bbcf8faaf53c2497eb114911c1", "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f",
"sha256:20d1a2a76bb4eb00e4d36b9699f9b7aba93271c9c29220ad4c6a9581a0320235", "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02",
"sha256:21b154aba06df42e4b96fc915512ab39595105f6c483991287021ed95776d934", "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c",
"sha256:2c2e58e45fe53fab81f85474e5d4d226eeab0f27b45aa062856c89389da2f0d9", "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063",
"sha256:2c3b2803e730dc2797a017335827e9da6da0e84c745ce0f552e66400abdfb9a1", "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a",
"sha256:3146b8e16fa60427e03884301bf8209221f5761ac754ee6b267642a2fd354c48", "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5",
"sha256:344e714bd0fe921fc72d97404ebbdbf9127bac0ca1ff66d7b79efc143cf7c0c4", "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959",
"sha256:387065e420aed3c71b61af7e82c7b6bc1c592f7e3c7a66e9f78dd178699da4fe", "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97",
"sha256:3f04becd4fcda03c0160d0da9c8f0c246bc78f2f7af0feea1ec0930e7c93fa4a", "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6",
"sha256:4a42e1eff0ca9a7cb7dc9ecda41dfc7cbc17cb1d02117214be0561bd1134772b", "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f",
"sha256:4ea748802cc0de4de92ef8244dd84ffd793bd2e7be784cd8394d557a3c751e21", "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9",
"sha256:55416d7385774285b6e2a5feca0af9652f7f444a4fa3d29d8ab052fafef9d00d", "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5",
"sha256:5d0391fb4cfc171ce40437f67eb050a340fdbd0f9f49d6353a387f1b7f9dd4fa", "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f",
"sha256:63cdeaac4ae85a179a8d6bc09b77b564c096250d759eed343a89d91bce8b6367", "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562",
"sha256:72fcae5bcac3333a4cf3b8f34eec99cea1187acd55af723bcbd559adfdcb5535", "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe",
"sha256:7c4ed4e9f3b123aa403ab424430b426a1992e6f4c8fd3cb56ea520446e04d152", "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9",
"sha256:83957d349838a636e768251c7e9979e899a569794b44c3728eaebd11d848e58e", "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f",
"sha256:87ecc7c9a1a9f912e306997ffee020297ccb5ea388421fe62a2a02747e4d5539", "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb",
"sha256:8f69770f5ca1994cb32c38965e95f57504d3aea96b6c024624fdd5bb1aa494a1", "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb",
"sha256:8f6c930fd70d91ddee53194e93029e3ef2aabe26725aa3c2753df057e296b925", "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1",
"sha256:965ee3e782c7892befc25575fa171b521d33798132692df428a09efacaffe8d0", "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb",
"sha256:974bc90d6f6c1e59ceb1516ab00cf1cdfbb2e555795d49fa9571d611f449bcb2", "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250",
"sha256:981b4df72c93e3bc04478153df516d385317628bd9c10be699c93c26ddcca8ab", "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e",
"sha256:aa784405f0c640940595fa0f14064d8e84aff0b0f762fa18393e2760a2cf5841", "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511",
"sha256:ae7863a1d8db6a014b6f2ff9c1582ab1aad55a6d25bac19710a8df68921b6e30", "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5",
"sha256:aeae2aa38395b18106e552833f2a50c27ea0000122bde421c31d11ed7e6f9c91", "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59",
"sha256:b2317d5ed777bf5a033e83d4f1389fd4ef045763141d8f10eb09a7035cee774c", "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2",
"sha256:be19931a8dcbe6ab464f3339966856996b12a00f9fe53f346ab3be872d03e257", "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d",
"sha256:be9824c1c874b73b96288c6d3de793bf7f3a597770205068c6163ea1f326e8b9", "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3",
"sha256:c0045f8f23a5fb30b2eb3b8a83664d8dc4fb58faddf8155d7109166adb9f2040", "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4",
"sha256:c86bd45d1659b1ae3d0ba1909326b03598affbc9ed71520e0ff8c31a993ad911", "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de",
"sha256:ca0f34363e2634deffd390a0fef1aa99168ae9ed2af01af4a1f5865e362f8623", "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9",
"sha256:d298c2815fa4891edd9abe5ad6e6cb4207104c7dd9fd13aea3fdebf6f9b91259", "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833",
"sha256:d2a3a6146fe9319926e1d477842ca2a63fe99af5ae690b1f5c11e6af074a6b5c", "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0",
"sha256:dfd393094cd82ceb9b40df4c77976015a314b267d498268a076e940fe7be6b79", "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9",
"sha256:e58c0d41d336569d63d1b113bd573db8363bc4146f39444125b7f8060e4e04f5", "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d",
"sha256:ea3f5bc91d7d457da7d48c7a732beaf79d0c8131df3ab278e6bba6297e23c6c4", "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050",
"sha256:ea53151d87c52e98133eb8ac78f1206498c015849662ca8dc246255265d9c3c4", "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d",
"sha256:eb0edc3ce9760d2f21637766c3aa04822030e7451981ce569a1b3456b7053f22", "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6",
"sha256:f649dd53833b495c3ebd04d6eec58479454a1784987af8afb77540d6c1767abd", "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353",
"sha256:f760073fcf8f3d6933178d67754f4f2d4e924e321f4bb0dcef0424ca0215eba1", "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb",
"sha256:fa546d66639d69aa967bf08156eb8c9d0cd6f6de84be9e8c9819f52ad499c910", "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e",
"sha256:fd214917cabdd6f673a29d708574e9fbdb892cb77eb426d0eae3490d95ca7859", "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8",
"sha256:fff5aaa6becf2c6a1699ae6a39e2e6fb0672c2d42eca8eb0cafa91cf2e9bd312" "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495",
"sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2",
"sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd",
"sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27",
"sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1",
"sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818",
"sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4",
"sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e",
"sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850",
"sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"
], ],
"index": "pypi", "index": "pypi",
"version": "==7.2.3" "version": "==7.2.7"
}, },
"distlib": { "distlib": {
"hashes": [ "hashes": [
@@ -1914,21 +2160,13 @@
], ],
"version": "==0.3.6" "version": "==0.3.6"
}, },
"exceptiongroup": {
"hashes": [
"sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e",
"sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"
],
"markers": "python_version < '3.11'",
"version": "==1.1.1"
},
"filelock": { "filelock": {
"hashes": [ "hashes": [
"sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9", "sha256:42f1e4ff2b497311213d61ad7aac5fed9050608e5309573f101eefa94143134a",
"sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718" "sha256:82b1f7da46f0ae42abf1bc78e548667f484ac59d2bcec38c713cee7e2eb51e83"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==3.12.0" "version": "==3.12.1"
}, },
"flake8": { "flake8": {
"hashes": [ "hashes": [
@@ -2004,11 +2242,11 @@
}, },
"platformdirs": { "platformdirs": {
"hashes": [ "hashes": [
"sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f", "sha256:0ade98a4895e87dc51d47151f7d2ec290365a585151d97b4d8d6312ed6132fed",
"sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5" "sha256:e48fabd87db8f3a7df7150a4a5ea22c546ee8bc39bc2473244730d4b56d2cc4e"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==3.5.1" "version": "==3.5.3"
}, },
"pluggy": { "pluggy": {
"hashes": [ "hashes": [
@@ -2020,11 +2258,11 @@
}, },
"pre-commit": { "pre-commit": {
"hashes": [ "hashes": [
"sha256:0b4210aea813fe81144e87c5a291f09ea66f199f367fa1df41b55e1d26e1e2b4", "sha256:66e37bec2d882de1f17f88075047ef8962581f83c234ac08da21a0c58953d1f0",
"sha256:5b808fcbda4afbccf6d6633a56663fed35b6c2bc08096fd3d47ce197ac351d9d" "sha256:8056bc52181efadf4aac792b1f4f255dfd2fb5a350ded7335d251a68561e8cb6"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.2.2" "version": "==3.3.2"
}, },
"pycodestyle": { "pycodestyle": {
"hashes": [ "hashes": [
@@ -2044,11 +2282,11 @@
}, },
"pytest": { "pytest": {
"hashes": [ "hashes": [
"sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362", "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295",
"sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3" "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b"
], ],
"index": "pypi", "index": "pypi",
"version": "==7.3.1" "version": "==7.3.2"
}, },
"pytest-asyncio": { "pytest-asyncio": {
"hashes": [ "hashes": [
@@ -2150,21 +2388,13 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==67.8.0" "version": "==67.8.0"
}, },
"tomli": {
"hashes": [
"sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
"sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
],
"markers": "python_version < '3.11'",
"version": "==2.0.1"
},
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
"sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc", "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1",
"sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e" "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2.0.2" "version": "==2.0.3"
}, },
"virtualenv": { "virtualenv": {
"hashes": [ "hashes": [
@@ -2176,10 +2406,10 @@
}, },
"websocket-client": { "websocket-client": {
"hashes": [ "hashes": [
"sha256:c7d67c13b928645f259d9b847ab5b57fd2d127213ca41ebd880de1f553b7c23b", "sha256:3566f8467cd350874c4913816355642a4942f6c1ed1e9406e3d42fae6d6c072a",
"sha256:f8c64e28cd700e7ba1f04350d66422b6833b82a796b525a51e740b8cc8dab4b1" "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 # 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 # 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/. # file, You can obtain one at https://mozilla.org/MPL/2.0/.
import asyncio
import sentry_sdk import sentry_sdk
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@@ -11,7 +10,6 @@ from starlette.middleware.sessions import SessionMiddleware
from classquiz.config import settings from classquiz.config import settings
from classquiz.db import database from classquiz.db import database
from datetime import timedelta
from classquiz.oauth import rememberme_middleware from classquiz.oauth import rememberme_middleware
from classquiz.routers import ( from classquiz.routers import (
@@ -36,8 +34,7 @@ from classquiz.routers import (
quiztivity, quiztivity,
) )
from classquiz.socket_server import sio from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks from classquiz.helpers import meilisearch_init, telemetry_ping
from scheduler.asyncio import Scheduler
settings = settings() settings = settings()
if settings.sentry_dsn: if settings.sentry_dsn:
@@ -58,13 +55,6 @@ async def sentry_exception(request: Request, call_next):
raise e 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") @app.on_event("startup")
async def startup() -> None: async def startup() -> None:
database_ = app.state.database database_ = app.state.database
@@ -72,7 +62,6 @@ async def startup() -> None:
await database_.connect() await database_.connect()
await meilisearch_init() await meilisearch_init()
await telemetry_ping() await telemetry_ping()
asyncio.create_task(background_tasks())
@app.on_event("shutdown") @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 from pydantic import BaseSettings, RedisDsn, PostgresDsn, BaseModel
import meilisearch as MeiliSearch import meilisearch as MeiliSearch
from typing import Optional from typing import Optional
from arq import create_pool
from arq.connections import RedisSettings, ArqRedis
from classquiz.storage import Storage from classquiz.storage import Storage
@@ -48,12 +50,10 @@ class Settings(BaseSettings):
github_client_secret: Optional[str] github_client_secret: Optional[str]
custom_openid_provider: CustomOpenIDProvider | None = None custom_openid_provider: CustomOpenIDProvider | None = None
telemetry_enabled: bool = True telemetry_enabled: bool = True
free_storage_limit: int = 1074000000
# storage_backend # storage_backend
storage_backend: str | None = "deta" storage_backend: str | None = "local"
# if storage_backend == "deta":
deta_project_key: str | None
deta_project_id: str | None
# if storage_backend == "local": # if storage_backend == "local":
storage_path: str | None storage_path: str | None
@@ -70,16 +70,24 @@ class Settings(BaseSettings):
env_nested_delimiter = "__" env_nested_delimiter = "__"
async def initialize_arq():
global arq
arq = await create_pool(RedisSettings.from_dsn(settings.redis))
@lru_cache() @lru_cache()
def settings() -> Settings: def settings() -> Settings:
return 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( storage: Storage = Storage(
backend=settings().storage_backend, backend=settings().storage_backend,
deta_key=settings().deta_project_key,
deta_id=settings().deta_project_id,
storage_path=settings().storage_path, storage_path=settings().storage_path,
access_key=settings().s3_access_key, access_key=settings().s3_access_key,
secret_key=settings().s3_secret_key, secret_key=settings().s3_secret_key,
+104 -9
View File
@@ -8,6 +8,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
import ormar import ormar
from ormar import ReferentialAction
from pydantic import BaseModel, Json, validator from pydantic import BaseModel, Json, validator
from enum import Enum from enum import Enum
from . import metadata, database from . import metadata, database
@@ -42,6 +43,7 @@ class User(ormar.Model):
require_password: bool = ormar.Boolean(default=True, nullable=False) 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()) 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) 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: class Meta:
tablename = "users" tablename = "users"
@@ -57,7 +59,7 @@ class FidoCredentials(ormar.Model):
id: bytes = ormar.LargeBinary(max_length=256) id: bytes = ormar.LargeBinary(max_length=256)
public_key: bytes = ormar.LargeBinary(max_length=256) public_key: bytes = ormar.LargeBinary(max_length=256)
sign_count: int = ormar.Integer() sign_count: int = ormar.Integer()
user: Optional[User] = ormar.ForeignKey(User) user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
class Meta: class Meta:
tablename = "fido_credentials" tablename = "fido_credentials"
@@ -67,7 +69,7 @@ class FidoCredentials(ormar.Model):
class ApiKey(ormar.Model): class ApiKey(ormar.Model):
key: str = ormar.String(max_length=48, min_length=48, primary_key=True) 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: class Meta:
tablename = "api_keys" tablename = "api_keys"
@@ -81,7 +83,7 @@ class UserSession(ormar.Model):
""" """
id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4()) 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) session_key: str = ormar.String(unique=True, max_length=64)
created_at: datetime = ormar.DateTime(default=datetime.now()) created_at: datetime = ormar.DateTime(default=datetime.now())
ip_address: str = ormar.String(max_length=100, nullable=True) ip_address: str = ormar.String(max_length=100, nullable=True)
@@ -180,7 +182,7 @@ class Quiz(ormar.Model):
description: str = ormar.Text(nullable=True) description: str = ormar.Text(nullable=True)
created_at: datetime = ormar.DateTime(default=datetime.now()) created_at: datetime = ormar.DateTime(default=datetime.now())
updated_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) questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False)
imported_from_kahoot: Optional[bool] = ormar.Boolean(default=False, nullable=True) imported_from_kahoot: Optional[bool] = ormar.Boolean(default=False, nullable=True)
cover_image: Optional[str] = ormar.Text(nullable=True, unique=False) cover_image: Optional[str] = ormar.Text(nullable=True, unique=False)
@@ -306,8 +308,8 @@ class GameInLobby(BaseModel):
class GameResults(ormar.Model): class GameResults(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True) id: uuid.UUID = ormar.UUID(primary_key=True)
quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz) quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz, ondelete=ReferentialAction.CASCADE)
user: uuid.UUID | User = ormar.ForeignKey(User) user: uuid.UUID | User = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
timestamp: datetime = ormar.DateTime(default=datetime.now(), nullable=False) timestamp: datetime = ormar.DateTime(default=datetime.now(), nullable=False)
player_count: int = ormar.Integer(nullable=False, default=0) player_count: int = ormar.Integer(nullable=False, default=0)
note: str | None = ormar.Text(nullable=True) note: str | None = ormar.Text(nullable=True)
@@ -333,7 +335,7 @@ class QuizTivity(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True) id: uuid.UUID = ormar.UUID(primary_key=True)
title: str = ormar.Text(nullable=False) title: str = ormar.Text(nullable=False)
created_at: datetime = ormar.DateTime(nullable=False, server_default=func.now()) 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) pages: list[QuizTivityPage] = ormar.JSON(nullable=False)
class Meta: class Meta:
@@ -346,8 +348,8 @@ class QuizTivityShare(ormar.Model):
id: uuid.UUID = ormar.UUID(primary_key=True) id: uuid.UUID = ormar.UUID(primary_key=True)
name: str | None = ormar.Text(nullable=True) name: str | None = ormar.Text(nullable=True)
expire_at: datetime | None = ormar.DateTime(nullable=True) expire_at: datetime | None = ormar.DateTime(nullable=True)
quiztivity: QuizTivity | None = ormar.ForeignKey(QuizTivity) quiztivity: QuizTivity | None = ormar.ForeignKey(QuizTivity, ondelete=ReferentialAction.CASCADE)
user: User | None = ormar.ForeignKey(User) user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE)
class Meta: class Meta:
tablename = "quiztivityshares" tablename = "quiztivityshares"
@@ -378,3 +380,96 @@ class PublicQuizTivityShare(BaseModel):
quiztivity=OnlyId(id=data.quiztivity.id), quiztivity=OnlyId(id=data.quiztivity.id),
user=OnlyId(id=data.user.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/. # file, You can obtain one at https://mozilla.org/MPL/2.0/.
import asyncio import asyncio
import uuid
from typing import Optional from typing import Optional
import ormar.exceptions 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"]) worksheet.write(i + 1, 1, question["time"])
try: try:
async with ClientSession() as session, session.get(question["image"]) as response: async with ClientSession() as session, session.get(
img_data = BytesIO(await response.read()) f"{settings.root_address}/api/v1/storage/download/{question['image']}"
worksheet.insert_image(i + 1, 2, question["image"], {"image_data": img_data}) ) as response:
image = Image.open(img_data) if "image" in response.headers.get("Content-Type"):
worksheet.set_row(i + 1, image.height) img_data = BytesIO(await response.read())
worksheet.set_column(2, 2, image.width) 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: except TypeError:
pass pass
answer_amount = len(answer_data) answer_amount = len(answer_data)
@@ -175,3 +179,36 @@ def check_hashcash(data: str, input_data: str, claim_in: Optional[str] = "19") -
return False return False
some_error = [version == "1", claim == claim_in, res == input_data, ext == ""] some_error = [version == "1", claim == claim_in, res == input_data, ext == ""]
return all(el is True for el in some_error) 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/. # file, You can obtain one at https://mozilla.org/MPL/2.0/.
import html import html
import io
import json import json
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -10,8 +11,8 @@ from datetime import datetime
import bleach import bleach
from aiohttp import ClientSession from aiohttp import ClientSession
from classquiz.config import settings, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ from classquiz.config import settings, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, arq
from classquiz.db.models import Quiz, ABCDQuizAnswer, QuizQuestion, User from classquiz.db.models import Quiz, ABCDQuizAnswer, QuizQuestion, User, StorageItem
from classquiz.kahoot_importer.get import get as get_quiz from classquiz.kahoot_importer.get import get as get_quiz
from classquiz.helpers import get_meili_data from classquiz.helpers import get_meili_data
@@ -26,6 +27,25 @@ async def _download_image(url: str) -> bytes:
DEFAULT_COLORS = ["#D6EDC9", "#B07156", "#7F7057", "#4E6E58"] 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: async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
""" """
Imports a quiz from Kahoot. Imports a quiz from Kahoot.
@@ -41,15 +61,15 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
quiz_id = uuid.uuid4() quiz_id = uuid.uuid4()
meilisearch.delete_index(settings.meilisearch_index) meilisearch.delete_index(settings.meilisearch_index)
meilisearch.create_index(settings.meilisearch_index) meilisearch.create_index(settings.meilisearch_index)
uploaded_images: list[StorageItem] = []
for q in quiz.kahoot.questions: for q in quiz.kahoot.questions:
answers: list[ABCDQuizAnswer] = [] answers: list[ABCDQuizAnswer] = []
image = None image = None
if q.image is not None and q.image != "": if q.image is not None and q.image != "":
image_bytes = await _download_image(q.image) image_obj = await handle_image_upload(q.image, user)
image_name = f"{quiz_id}--{uuid.uuid4()}" uploaded_images.append(image_obj)
image = await storage.upload(file_name=image_name, file_data=image_bytes) image = image_obj.id.hex
image = f"{settings.root_address}/api/v1/storage/download/{image_name}"
for i, a in enumerate(q.choices): for i, a in enumerate(q.choices):
answers.append( answers.append(
( (
@@ -71,10 +91,9 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
) )
cover = None cover = None
if quiz.kahoot.cover != "" and quiz.kahoot.cover is not None: if quiz.kahoot.cover != "" and quiz.kahoot.cover is not None:
image_bytes = await _download_image(quiz.kahoot.cover) img_obj = await handle_image_upload(quiz.kahoot.cover, user)
image_name = f"{quiz_id}--{uuid.uuid4()}" uploaded_images.append(img_obj)
await storage.upload(file_name=image_name, file_data=image_bytes) cover = img_obj.id.hex
cover = f"{settings.root_address}/api/v1/storage/download/{image_name}"
quiz_data = Quiz( quiz_data = Quiz(
id=quiz_id, id=quiz_id,
public=True, public=True,
@@ -89,4 +108,7 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
kahoot_id=uuid.UUID(kahoot_quiz_id), kahoot_id=uuid.UUID(kahoot_quiz_id),
) )
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz_data)]) 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 asyncio
import html import html
import re
import uuid import uuid
from typing import Optional from typing import Optional
import asyncpg.exceptions import asyncpg.exceptions
import bleach import bleach
from fastapi import APIRouter, File, UploadFile, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel from pydantic import BaseModel
from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, server_regex from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, arq
from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType, StorageItem
import puremagic
from classquiz.auth import get_current_user from classquiz.auth import get_current_user
import os import os
from datetime import datetime from datetime import datetime
from uuid import UUID 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 from classquiz.storage.errors import DeletionFailedError
settings = settings() 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) 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") @router.post("/finish")
async def finish_edit(edit_id: str, quiz_input: QuizInput): async def finish_edit(edit_id: str, quiz_input: QuizInput):
session_data = await redis.get(f"edit_session:{edit_id}") 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( quiz_input.questions[i].answers[i2].answer = html.unescape(
bleach.clean(answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True) 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 = [] images_to_delete = []
old_quiz_data: Quiz = await Quiz.objects.get_or_none(id=session_data.quiz_id, user_id=session_data.user_id) 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): for i, question in enumerate(quiz_input.questions):
image = question.image image = question.image
quiz_input.questions[i].question = html.unescape( quiz_input.questions[i].question = html.unescape(
@@ -167,17 +100,7 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
) )
if image == "": if image == "":
question.image = None question.image = None
mark_image_for_deletion(question.image, i, old_quiz_data) if image is not None and not check_image_string(image)[0]:
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:
raise HTTPException(status_code=400, detail="Image URL(s) aren't valid!") raise HTTPException(status_code=400, detail="Image URL(s) aren't valid!")
if quiz_input.cover_image == "": 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: # if quiz_input.background_image is None and old_quiz_data.background_image is not None:
# mark_image_for_deletion(quiz_input.background_image) # 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") raise HTTPException(status_code=400, detail="image url is not valid")
if session_data.edit: if session_data.edit:
await arq.enqueue_job("quiz_update", old_quiz_data, old_quiz_data.id, _defer_by=2)
quiz = old_quiz_data quiz = old_quiz_data
meilisearch.index(settings.meilisearch_index).update_documents([await get_meili_data(quiz)]) meilisearch.index(settings.meilisearch_index).update_documents([await get_meili_data(quiz)])
if not quiz_input.public: 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: for image in images_to_delete:
if image is not None: if image is not None:
try: try:
await storage.delete([re.search(extract_file_name_re, image).group(1)]) await storage.delete([image])
except DeletionFailedError: except DeletionFailedError:
pass pass
await redis.srem("edit_sessions", edit_id) await redis.srem("edit_sessions", edit_id)
await redis.delete(f"edit_session:{edit_id}") await redis.delete(f"edit_session:{edit_id}")
await redis.delete(f"edit_session:{edit_id}:images") await redis.delete(f"edit_session:{edit_id}:images")
return await quiz.update() await quiz.update()
return quiz
else: else:
quiz = Quiz( quiz = Quiz(
**quiz_input.dict(), **quiz_input.dict(),
@@ -222,6 +150,7 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
) )
await redis.delete("global_quiz_count") await redis.delete("global_quiz_count")
if quiz_input.public: if quiz_input.public:
meilisearch.index(settings.meilisearch_index).add_documents([await get_meili_data(quiz)]) 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.srem("edit_sessions", edit_id)
await redis.delete(f"edit_session:{edit_id}") await redis.delete(f"edit_session:{edit_id}")
await redis.delete(f"edit_session:{edit_id}:images") await redis.delete(f"edit_session:{edit_id}:images")
return await quiz.save() await quiz.save()
except asyncpg.exceptions.UniqueViolationError: except asyncpg.exceptions.UniqueViolationError:
raise HTTPException(status_code=400, detail="The quiz already exists") 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 # 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 # 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/. # file, You can obtain one at https://mozilla.org/MPL/2.0/.
import io
import json import json
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -10,10 +11,11 @@ from aiohttp import ClientSession
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from classquiz.auth import get_current_user from classquiz.auth import get_current_user
from classquiz.config import storage, settings from classquiz.config import storage, settings, arq
from classquiz.db.models import Quiz, User from classquiz.db.models import Quiz, User, StorageItem
import gzip import gzip
import urllib.parse import urllib.parse
import magic
router = APIRouter() router = APIRouter()
settings = settings() 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(): for image_key in image_urls.keys():
bin_data = bin_data + image_delimiter + str(image_key).encode("utf-8") + image_index_delimiter bin_data = bin_data + image_delimiter + str(image_key).encode("utf-8") + image_index_delimiter
image_data = None 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() image_data = await resp.read()
bin_data = bin_data + image_data 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("/") @router.post("/")
async def import_quiz(file: UploadFile = File(), user: User = Depends(get_current_user)): 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() data = await file.read()
[split_data, images] = data.split(quiz_delimiter) [split_data, images] = data.split(quiz_delimiter)
decompressed_quiz = gzip.decompress(split_data) 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) image_splits = images.split(image_delimiter)
quiz_id = uuid.uuid4() quiz_id = uuid.uuid4()
image_urls = {} image_urls = {}
print(len(data))
for image_split in image_splits: for image_split in image_splits:
res = image_split.split(image_index_delimiter) res = image_split.split(image_index_delimiter)
if len(res) != 2: if len(res) != 2:
continue continue
[index, image_data] = res [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")) index = int(index.decode("utf-8"))
image_name = f"{quiz_id}--{uuid.uuid4()}" file_id = uuid.uuid4()
await storage.upload(file_name=image_name, file_data=image_data) file_obj = StorageItem(
image = f"{settings.root_address}/api/v1/storage/download/{image_name}" 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 image_urls[index] = image
quiz_dict["created_at"] = datetime.fromisoformat(quiz_dict["created_at"]) quiz_dict["created_at"] = datetime.fromisoformat(quiz_dict["created_at"])
quiz_dict["updated_at"] = datetime.fromisoformat(quiz_dict["updated_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 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 import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import ValidationError, BaseModel from pydantic import ValidationError, BaseModel
import bleach
from classquiz.auth import get_current_user from classquiz.auth import get_current_user
from classquiz.config import redis, settings, storage, meilisearch 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 from classquiz.kahoot_importer.import_quiz import import_quiz
import html
import urllib.parse import urllib.parse
settings = settings() settings = settings()
@@ -29,33 +27,6 @@ settings = settings()
router = APIRouter() 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}") @router.get("/get/{quiz_id}")
async def get_quiz_from_id(quiz_id: str, user: User | None = Depends(get_current_user)): async def get_quiz_from_id(quiz_id: str, user: User | None = Depends(get_current_user)):
try: 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.") 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}") @router.post("/import/{quiz_id}")
async def import_quiz_route(quiz_id: str, user: User = Depends(get_current_user)): 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: try:
return await import_quiz(quiz_id, user) 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") 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: for question in quiz.questions:
try: try:
if question["image"] is not None and not str(question["image"]).startswith("https://i.imgur.com/"): 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: except KeyError:
pass pass
if len(pics_to_delete) != 0: 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 # 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 # 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/. # 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, UploadFile, File, Depends, Request, Response
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse, RedirectResponse 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 classquiz.storage.errors import DownloadingFailedError
from uuid import uuid4, UUID
settings = settings() settings = settings()
router = APIRouter() 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}") @router.get("/download/{file_name}")
async def download_file(file_name: str): 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") 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": if storage.backend == "s3":
print("redir") return RedirectResponse(url=await storage.get_url(file_name, 300), headers=headers_from_storage_item(item))
return RedirectResponse(url=await storage.get_url(file_name, 300))
try: try:
download = await storage.download(file_name) download = await storage.download(file_name)
except DownloadingFailedError: except DownloadingFailedError:
@@ -34,8 +58,198 @@ async def download_file(file_name: str):
def iter_file(): def iter_file():
yield from download 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( return StreamingResponse(
iter_file(), iter_file(),
media_type="image/*", media_type=media_type,
headers={"Cache-Control": "public, immutable, max-age=31536000"}, 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 # 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/. # file, You can obtain one at https://mozilla.org/MPL/2.0/.
from io import BytesIO from typing import Optional, BinaryIO
from typing import Optional
from .deta_storage import DetaStorage
from .local_storage import LocalStorage from .local_storage import LocalStorage
from .s3_storage import S3Storage from .s3_storage import S3Storage
from typing import Generator
class Storage: class Storage:
def __init__( def __init__(
self, self,
backend: str, backend: str,
deta_key: Optional[str],
deta_id: Optional[str],
storage_path: Optional[str], storage_path: Optional[str],
access_key: str | None = None, access_key: str | None = None,
secret_key: str | None = None, secret_key: str | None = None,
@@ -23,65 +20,45 @@ class Storage:
base_url: str | None = None, base_url: str | None = None,
): ):
self.backend = backend 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.access_key = access_key
self.secret_key = secret_key self.secret_key = secret_key
self.bucket_name = bucket_name self.bucket_name = bucket_name
self.base_url = base_url self.base_url = base_url
self.deta_instance = None self.instance: LocalStorage | S3Storage | None = 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,
)
elif backend == "local": if backend == "local":
if storage_path is None: if storage_path is None:
raise ValueError("storage_path must be provided") raise ValueError("storage_path must be provided")
else: else:
self.local_instance = LocalStorage(base_path=storage_path) self.instance = LocalStorage(base_path=storage_path)
elif backend == "s3": elif backend == "s3":
if access_key is None or secret_key is None or bucket_name is None or base_url is None: 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") 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 base_url=base_url, access_key=access_key, secret_key=secret_key, bucket_name=bucket_name
) )
else: else:
raise NotImplementedError(f"Backend {backend} not implemented") raise NotImplementedError(f"Backend {backend} not implemented")
async def download(self, file_name: str) -> BytesIO | None: async def download(self, file_name: str) -> Generator | None:
if self.backend == "deta": """
return await self.deta_instance.download(file_name) No support for s3 since it doesn't make sense relaying the traffic through this backend
elif self.backend == "local": :param file_name:
return await self.local_instance.get_file(file_name) :return:
elif self.backend == "s3": """
return await self.s3_instance.download(file_name) yield self.instance.download(file_name)
async def upload(self, file_name: str, file_data: bytes) -> None: async def upload(self, file_name: str, file_data: BinaryIO, mime_type: str | None = None) -> None:
if self.backend == "deta": return await self.instance.upload(file=file_data, file_name=file_name, mime_type=mime_type)
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 delete(self, file_names: [str]) -> None: async def delete(self, file_names: [str]) -> None:
if self.backend == "deta": return await self.instance.delete(file_names=file_names)
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)
async def get_url(self, file_name: str, expiry: int) -> str: async def get_url(self, file_name: str, expiry: int) -> str:
if self.backend == "s3": 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 # 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/. # file, You can obtain one at https://mozilla.org/MPL/2.0/.
import io
import os import os
from typing import BinaryIO, Generator
import aiofiles import aiofiles
import aiofiles.os 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: class LocalStorage:
def __init__(self, base_path: str): def __init__(self, base_path: str):
self.base_path = base_path 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: try:
async with aiofiles.open(file=os.path.join(self.base_path, file_name), mode="rb") as f: 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: 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: 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: for i in file_names:
try: try:
await aiofiles.os.remove(os.path.join(self.base_path, i)) await aiofiles.os.remove(os.path.join(self.base_path, i))
except FileNotFoundError: except FileNotFoundError:
pass pass
return None 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 hashlib
import hmac import hmac
from datetime import datetime, timedelta from datetime import datetime, timedelta
from io import BytesIO from typing import Tuple, BinaryIO, Generator
from typing import Tuple
from aiohttp import ClientSession from aiohttp import ClientSession
import minio import minio
@@ -113,17 +112,7 @@ class S3Storage:
return headers, request_url return headers, request_url
async def download(self, file_name: str): 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="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:
headers, url = self._generate_aws_signature_v4(method="PUT", path=f"/{file_name}") 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: async with ClientSession() as session, session.put(url, headers=headers, data=file) as resp:
if resp.status == 200: if resp.status == 200:
@@ -145,3 +134,25 @@ class S3Storage:
return self.client.presigned_get_object( return self.client.presigned_get_object(
object_name=file_name, bucket_name=self.bucket_name, expires=timedelta(seconds=expire) 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 REDIS_URL: redis://redis:6379/0?decode_responses=True
API_URL: http://api:80 API_URL: http://api:80
api: api:
build: build: &build_cfg
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
restart: always restart: &restart always
depends_on: depends_on: &depends
- db - db
- redis - redis
environment: environment: &env_vars
DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz" DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz"
REDIS: "redis://redis:6379/0?decode_responses=True"
MAIL_ADDRESS: "email@email@email.email" MAIL_ADDRESS: "email@email@email.email"
MAIL_PASSWORD: "PASSWORT" MAIL_PASSWORD: "PASSWORT"
MAIL_USERNAME: "email@email@email.email" MAIL_USERNAME: "email@email@email.email"
@@ -34,6 +35,8 @@ services:
SKIP_EMAIL_VERIFICATION: True SKIP_EMAIL_VERIFICATION: True
HCAPTCHA_KEY: "HCAPTCHA_PRIVATE_KEY" HCAPTCHA_KEY: "HCAPTCHA_PRIVATE_KEY"
MEILISEARCH_URL: "http://meilisearch:7700" MEILISEARCH_URL: "http://meilisearch:7700"
STORAGE_BACKEND: "local"
STORAGE_PATH: "/app/data"
redis: redis:
image: redis:alpine image: redis:alpine
restart: always restart: always
@@ -67,6 +70,14 @@ services:
MEILI_NO_ANALYTICS: true MEILI_NO_ANALYTICS: true
volumes: volumes:
- meilisearch-data:/data.ms - meilisearch-data:/data.ms
worker:
build: *build_cfg
environment: *env_vars
depends_on: *depends
restart: *restart
command: arq classquiz.worker.WorkerSettings
volumes: volumes:
data: data:
meilisearch-data: meilisearch-data:
+2 -2
View File
@@ -22,7 +22,7 @@ COPY package*.json ./
COPY pnpm-lock.yaml ./ COPY pnpm-lock.yaml ./
# run npm install in our local machine # 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 the generated modules and all other files to the container
COPY . . COPY . .
@@ -38,7 +38,7 @@ FROM node:19-bullseye-slim
WORKDIR /app WORKDIR /app
COPY --from=builder /usr/src/app/package.json . COPY --from=builder /usr/src/app/package.json .
COPY --from=builder /usr/src/app/pnpm-lock.yaml . 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 files from previous step
COPY --from=builder /usr/src/app/build . COPY --from=builder /usr/src/app/build .
COPY --from=builder /usr/src/app/node_modules ./node_modules COPY --from=builder /usr/src/app/node_modules ./node_modules
+5
View File
@@ -23,6 +23,8 @@
"@ckeditor/ckeditor5-theme-lark": "^37.1.0", "@ckeditor/ckeditor5-theme-lark": "^37.1.0",
"@felte/reporter-tippy": "^1.1.5", "@felte/reporter-tippy": "^1.1.5",
"@felte/validator-yup": "^1.0.11", "@felte/validator-yup": "^1.0.11",
"@ffmpeg/core": "^0.11.0",
"@ffmpeg/ffmpeg": "^0.11.6",
"@fontsource/marck-script": "^4.5.11", "@fontsource/marck-script": "^4.5.11",
"@sentry/browser": "^7.48.0", "@sentry/browser": "^7.48.0",
"@sentry/tracing": "^7.48.0", "@sentry/tracing": "^7.48.0",
@@ -42,6 +44,7 @@
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/eslint-plugin": "^5.59.0",
"@typescript-eslint/parser": "^5.59.0", "@typescript-eslint/parser": "^5.59.0",
"@unlazy/svelte": "^0.8.9",
"@uppy/compressor": "^1.0.2", "@uppy/compressor": "^1.0.2",
"@uppy/core": "^3.2.0", "@uppy/core": "^3.2.0",
"@uppy/dashboard": "^3.4.0", "@uppy/dashboard": "^3.4.0",
@@ -89,11 +92,13 @@
"svelte-tippy": "^1.3.2", "svelte-tippy": "^1.3.2",
"swiper": "^8.4.7", "swiper": "^8.4.7",
"tailwindcss": "^3.3.1", "tailwindcss": "^3.3.1",
"thumbhash": "^0.1.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tslib": "^2.5.0", "tslib": "^2.5.0",
"typescript": "~5.0.4", "typescript": "~5.0.4",
"ua-parser-js": "^1.0.35", "ua-parser-js": "^1.0.35",
"vite": "^4.2.2", "vite": "^4.2.2",
"vite-plugin-cross-origin-isolation": "^0.1.6",
"vite-plugin-iso-import": "^1.0.0", "vite-plugin-iso-import": "^1.0.0",
"yup": "^1.1.1" "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 CircularTimer from '$lib/play/circular_progress.svelte';
import Spinner from '$lib/Spinner.svelte'; import Spinner from '$lib/Spinner.svelte';
import { get_foreground_color } from '$lib/helpers'; import { get_foreground_color } from '$lib/helpers';
import MediaComponent from '$lib/editor/MediaComponent.svelte';
export let game_token: string; export let game_token: string;
export let quiz_data: QuizData; export let quiz_data: QuizData;
@@ -223,11 +224,11 @@
</div> </div>
</div> </div>
{#if quiz_data.questions[selected_question].image !== null} {#if quiz_data.questions[selected_question].image !== null}
<div> <div class="flex w-full">
<img <MediaComponent
src={quiz_data.questions[selected_question].image} src={quiz_data.questions[selected_question].image}
class="max-h-[20vh] object-cover mx-auto mb-8 w-auto" muted={false}
alt="Content for Question" css_classes="max-h-[20vh] object-cover mx-auto mb-8 w-auto"
/> />
</div> </div>
{/if} {/if}
@@ -320,7 +321,7 @@
<div class="h-[30vh] m-auto w-auto mt-12"> <div class="h-[30vh] m-auto w-auto mt-12">
<img <img
class="max-h-full max-w-full block" 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" alt="Not provided"
/> />
</div> </div>
@@ -158,7 +158,7 @@
<div class="h-[20vh] m-auto w-auto"> <div class="h-[20vh] m-auto w-auto">
<img <img
class="max-h-full max-w-full block" class="max-h-full max-w-full block"
src={quiz.cover_image} src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided" alt="Not provided"
loading="lazy" loading="lazy"
/> />
@@ -302,7 +302,7 @@
{#if visibleImages?.[i]?.[q]} {#if visibleImages?.[i]?.[q]}
<img <img
class="max-h-full max-w-full block" class="max-h-full max-w-full block"
src={question.image} src="/api/v1/storage/download/{question.image}"
alt="Not provided" alt="Not provided"
/> />
{/if} {/if}
+7 -5
View File
@@ -4,7 +4,7 @@
file, You can obtain one at https://mozilla.org/MPL/2.0/. file, You can obtain one at https://mozilla.org/MPL/2.0/.
--> -->
<script lang="ts"> <script lang="ts">
import { alertModal } from '$lib/stores'; // import { alertModal } from '$lib/stores';
import { captcha_enabled } from '$lib/config'; import { captcha_enabled } from '$lib/config';
import StartGameBackground from './start_game_background.svg'; import StartGameBackground from './start_game_background.svg';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -54,14 +54,16 @@
); );
} }
if (res.status !== 200) { if (res.status !== 200) {
alertModal.set({ /* alertModal.set({
open: true, open: true,
title: 'Start failed', title: 'Start failed',
body: `Failed to start game, ${await res.text()}` body: `Failed to start game, ${await res.text()}`
}); });*/
alertModal.subscribe((_) => { /*alertModal.subscribe((_) => {
window.location.assign('/account/login?returnTo=/dashboard'); window.location.assign('/account/login?returnTo=/dashboard');
}); });*/
alert('Starting game failed');
window.location.assign('/account/login?returnTo=/dashboard');
} else { } else {
const data = await res.json(); const data = await res.json();
// eslint-disable-next-line no-undef // 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/. - file, You can obtain one at https://mozilla.org/MPL/2.0/.
--> -->
<script lang="ts"> <script lang="ts">
import { mint } from '$lib/hashcash'; // import { mint } from '$lib/hashcash';
import { dataSchema } from '$lib/yupSchemas'; import { dataSchema } from '$lib/yupSchemas';
import type { EditorData, Question } from './quiz_types'; import type { EditorData, Question } from './quiz_types';
import Sidebar from '$lib/editor/sidebar.svelte'; import Sidebar from '$lib/editor/sidebar.svelte';
@@ -22,22 +22,6 @@
export let quiz_id: string | null; export let quiz_id: string | null;
let selected_question = -1; let selected_question = -1;
let imgur_links_valid = false; 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) => { const validateInput = async (data: EditorData) => {
// console.log("input", data) // console.log("input", data)
@@ -72,7 +56,6 @@
$: imgur_links_valid = checkIfAllQuestionImagesComplyWithRegex(data.questions); $: imgur_links_valid = checkIfAllQuestionImagesComplyWithRegex(data.questions);
let edit_id; let edit_id;
let confirm_to_leave = true; let confirm_to_leave = true;
let pow_data;
const getEditID = async () => { const getEditID = async () => {
let res; let res;
@@ -88,7 +71,6 @@
if (res.status === 200) { if (res.status === 200) {
const json = await res.json(); const json = await res.json();
edit_id = json.token; edit_id = json.token;
setPOWdata();
} else { } else {
alert('Error!'); alert('Error!');
} }
@@ -123,13 +105,6 @@
alert('Error'); 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> </script>
<svelte:window on:beforeunload={confirmUnload} /> <svelte:window on:beforeunload={confirmUnload} />
@@ -177,15 +152,9 @@
</div> </div>
<div class="w-full h-full"> <div class="w-full h-full">
{#if selected_question === -1} {#if selected_question === -1}
<SettingsCard bind:data bind:pow_salt bind:edit_id bind:pow_data /> <SettingsCard bind:data bind:edit_id />
{:else} {:else}
<QuizCard <QuizCard bind:data bind:selected_question bind:edit_id />
bind:data
bind:selected_question
bind:edit_id
bind:pow_data
bind:pow_salt
/>
{/if} {/if}
</div> </div>
</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 { reach } from 'yup';
import { dataSchema } from '$lib/yupSchemas'; import { dataSchema } from '$lib/yupSchemas';
import Spinner from '../Spinner.svelte'; import Spinner from '../Spinner.svelte';
import { createTippy } from 'svelte-tippy'; // import { createTippy } from 'svelte-tippy';
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
// import MediaComponent from "$lib/editor/MediaComponent.svelte";
const { t } = getLocalization(); const { t } = getLocalization();
const tippy = createTippy({ /* const tippy = createTippy({
arrow: true, arrow: true,
animation: 'perspective-subtle', animation: 'perspective-subtle',
placement: 'top' placement: 'top'
}); });*/
export let data: EditorData; export let data: EditorData;
export let selected_question: number; export let selected_question: number;
export let edit_id: string; export let edit_id: string;
export let pow_data;
export let pow_salt: string;
let uppyOpen = false; let uppyOpen = false;
let unique = {}; let unique = {};
@@ -51,6 +50,17 @@
selected_question; selected_question;
set_unique(); 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) { if (typeof data.questions[selected_question].type !== QuizQuestionType) {
console.log(data.questions[selected_question].type !== QuizQuestionType.ABCD || data.questions[selected_question].type !== QuizQuestionType.RANGE) console.log(data.questions[selected_question].type !== QuizQuestionType.ABCD || data.questions[selected_question].type !== QuizQuestionType.RANGE)
@@ -60,7 +70,7 @@
</script> </script>
<div class="w-full max-h-full pb-20 px-20 h-full"> <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="h-12 bg-gray-300 rounded-t-lg dark:bg-gray-500">
<div class="flex align-middle p-4 gap-3"> <div class="flex align-middle p-4 gap-3">
<span <span
@@ -110,7 +120,7 @@
class="rounded-full absolute -top-2 -right-2 opacity-70 hover:opacity-100 transition" class="rounded-full absolute -top-2 -right-2 opacity-70 hover:opacity-100 transition"
type="button" type="button"
on:click={() => { on:click={() => {
data.questions[selected_question].image = ''; data.questions[selected_question].image = null;
}} }}
> >
<svg <svg
@@ -128,22 +138,11 @@
/> />
</svg> </svg>
</button> </button>
<img {#await import('$lib/editor/MediaComponent.svelte') then c}
src={data.questions[selected_question].image} <svelte:component this={c.default} bind:src={image_url} />
alt="not available" {/await}
class="max-h-64 h-auto w-auto"
/>
</div> </div>
</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} {:else}
{#await import('$lib/editor/uploader.svelte')} {#await import('$lib/editor/uploader.svelte')}
<Spinner my_20={false} /> <Spinner my_20={false} />
@@ -154,8 +153,7 @@
bind:edit_id bind:edit_id
bind:data bind:data
bind:selected_question bind:selected_question
bind:pow_data video_upload={true}
bind:pow_salt
/> />
{/await} {/await}
{/if} {/if}
+5 -18
View File
@@ -8,9 +8,6 @@
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import Spinner from '$lib/Spinner.svelte'; import Spinner from '$lib/Spinner.svelte';
export let pow_data;
export let pow_salt;
const { t } = getLocalization(); const { t } = getLocalization();
let uppyOpen = false; let uppyOpen = false;
@@ -42,7 +39,7 @@
<div <div
class="dark:bg-gray-700 h-full" class="dark:bg-gray-700 h-full"
style="background-repeat: no-repeat;background-size: 100% 100%;background-image: {data.background_image 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`}" : `unset`}"
> >
<div class="flex justify-center pt-10 w-full"> <div class="flex justify-center pt-10 w-full">
@@ -68,18 +65,14 @@
{#if data.cover_image != undefined && data.cover_image !== ''} {#if data.cover_image != undefined && data.cover_image !== ''}
<div class="flex justify-center pt-10 w-full max-h-72 w-full"> <div class="flex justify-center pt-10 w-full max-h-72 w-full">
<img <img
src={data.cover_image} src="/api/v1/storage/download/{data.cover_image}"
alt="not available" alt="not available"
class="max-h-72 h-auto w-auto" class="max-h-72 h-auto w-auto"
on:contextmenu|preventDefault={() => { on:contextmenu|preventDefault={() => {
data.cover_image = ''; data.cover_image = null;
}} }}
/> />
</div> </div>
{:else if pow_data === undefined}
<a href="/docs/pow" target="_blank" class="cursor-help">
<Spinner my_20={false} />
</a>
{:else} {:else}
{#await import('$lib/editor/uploader.svelte')} {#await import('$lib/editor/uploader.svelte')}
<Spinner my_20={false} /> <Spinner my_20={false} />
@@ -89,8 +82,7 @@
bind:modalOpen={uppyOpen} bind:modalOpen={uppyOpen}
bind:edit_id bind:edit_id
bind:data bind:data
bind:pow_data video_upload={false}
bind:pow_salt
/> />
{/await} {/await}
{/if} {/if}
@@ -191,10 +183,6 @@
class="mt-10 bg-red-500 p-2 rounded-lg border-2 border-black transition hover:bg-red-400" class="mt-10 bg-red-500 p-2 rounded-lg border-2 border-black transition hover:bg-red-400"
>Remove Background-Image</button >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} {:else}
{#await import('$lib/editor/uploader.svelte')} {#await import('$lib/editor/uploader.svelte')}
<div class="pt-10"> <div class="pt-10">
@@ -207,8 +195,7 @@
bind:edit_id bind:edit_id
bind:data bind:data
selected_question={-1} selected_question={-1}
bind:pow_data video_upload={false}
bind:pow_salt
/> />
{/await} {/await}
{/if} {/if}
+2 -2
View File
@@ -201,11 +201,11 @@
{#if question.image} {#if question.image}
<div class="flex justify-center align-middle pb-0.5"> <div class="flex justify-center align-middle pb-0.5">
<img <img
src={question.image} src="/api/v1/storage/download/{question.image}"
class="h-10 border rounded-lg" class="h-10 border rounded-lg"
alt="Not available" alt="Not available"
use:tippy={{ 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 allowHTML: true
}} }}
/> />
+128 -30
View File
@@ -12,6 +12,7 @@
import Dashboard from '@uppy/dashboard'; import Dashboard from '@uppy/dashboard';
import Compressor from '@uppy/compressor'; import Compressor from '@uppy/compressor';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import BrownButton from '$lib/components/buttons/brown.svelte';
// CSS imports // CSS imports
import '@uppy/core/dist/style.css'; import '@uppy/core/dist/style.css';
@@ -21,6 +22,8 @@
import '@uppy/image-editor/dist/style.css'; import '@uppy/image-editor/dist/style.css';
import type { EditorData } from '../quiz_types'; import type { EditorData } from '../quiz_types';
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import { onMount } from 'svelte';
import Library from '$lib/editor/uploader/Library.svelte';
const { t } = getLocalization(); const { t } = getLocalization();
@@ -28,10 +31,24 @@
export let edit_id: string; export let edit_id: string;
export let data: EditorData; export let data: EditorData;
export let selected_question: number; export let selected_question: number;
export let pow_data; export let video_upload = false;
export let pow_salt: string; 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() const uppy = new Uppy()
.use(DropTarget, { .use(DropTarget, {
target: document.body target: document.body
@@ -45,59 +62,140 @@
quality: 0.6 quality: 0.6
}) })
.use(XHRUpload, { .use(XHRUpload, {
endpoint: `/api/v1/editor/image?edit_id=${edit_id}&pow_data=${pow_data}` endpoint: `/api/v1/storage/`
}); });
const props = { const props = {
inline: true, inline: true,
restrictions: { restrictions: {
maxFileSize: 2_000_000, maxFileSize: 10_490_000,
maxNumberOfFiles: 1, maxNumberOfFiles: 1,
allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'] allowedFileTypes: ['image/*']
// allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp']
} }
}; };
let image_id; let image_id;
uppy.on('upload-success', (file, response) => { uppy.on('upload-success', (file, response) => {
image_id = response.body.id; image_id = response.body.id;
pow_salt = response.body.pow_data;
console.log(pow_salt, response.body);
pow_data = undefined;
}); });
uppy.on('complete', (_) => { uppy.on('complete', (_) => {
console.log(pow_data);
if (selected_question === undefined) { 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) { } else if (selected_question === -1) {
data.background_image = `${window.location.origin}/api/v1/storage/download/${image_id}`; data.background_image = image_id;
} else { } else {
data.questions[ data.questions[selected_question].image = image_id;
selected_question
].image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
} }
console.log(selected_question, data); console.log(selected_question, data);
modalOpen = false; 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> </script>
{#if modalOpen} {#if modalOpen}
<div <div
class="w-full h-full absolute top-0 left-0 bg-opacity-60 z-20 flex justify-center" class="w-screen h-screen fixed top-0 left-0 bg-opacity-50 bg-black z-20 flex justify-center"
transition:fade|local on:click={handle_on_click}
transition:fade|local={{ duration: 100 }}
> >
<div> {#if selected_type === null}
<button <div class="m-auto w-1/3 h-auto bg-white dark:bg-gray-700 p-4 rounded">
type="button" <h1 class="text-3xl text-center mb-4">{$t('uploader.select_upload_type')}</h1>
class="rounded-t-lg bg-black text-white px-1" <div class="flex flex-row gap-4">
on:click={() => { <div class="w-full">
modalOpen = false; <BrownButton
}} on:click={() => {
>Close selected_type = AvailableUploadTypes.Image;
</button> }}
<div> >{$t('words.image')}
<SvelteDashboard {uppy} width="100%" {props} /> </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>
</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> </div>
{/if} {/if}
<div class="flex justify-center w-full pt-10" transition:fade|local> <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", "quiz": "Quiz",
"quiztivity": "Quiztivity", "quiztivity": "Quiztivity",
"next": "Next", "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": { "editor": {
"time_in_seconds": "Time in seconds", "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..." "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": { "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": { "avatar_settings": {
"skin_color": "Skin color", "skin_color": "Skin color",
@@ -384,5 +394,29 @@
"popover": { "popover": {
"copied_to_clipboard": "Copied to clipboard!" "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 { onDestroy, onMount } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { alertModal } from '../stores'; // import { alertModal } from '../stores';
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import BrownButton from '$lib/components/buttons/brown.svelte'; import BrownButton from '$lib/components/buttons/brown.svelte';
@@ -72,20 +72,22 @@
custom_field = json.custom_field; custom_field = json.custom_field;
} }
if (res.status === 404) { if (res.status === 404) {
alertModal.set({ /* alertModal.set({
open: true, open: true,
title: 'Game not found', title: 'Game not found',
body: 'The game pin you entered seems invalid.' body: 'The game pin you entered seems invalid.'
}); });*/
alert('Game not found');
game_pin = ''; game_pin = '';
return; return;
} }
if (res.status !== 200) { if (res.status !== 200) {
alertModal.set({ /* alertModal.set({
open: true, open: true,
body: `Unknown error with response-code ${res.status}`, body: `Unknown error with response-code ${res.status}`,
title: 'Unknown Error' title: 'Unknown Error'
}); });*/
alert('Unknown error');
return; return;
} }
}; };
@@ -122,16 +124,13 @@
if (import.meta.env.VITE_SENTRY !== null) { if (import.meta.env.VITE_SENTRY !== null) {
Sentry.captureException(e); Sentry.captureException(e);
} }
alertModal.set({ /* alertModal.set({
open: true, open: true,
body: "The captcha failed, which is normal, but most of the time it's fixed by reloading!", body: "The captcha failed, which is normal, but most of the time it's fixed by reloading!",
title: 'Captcha failed' title: 'Captcha failed'
}); });*/
alertModal.subscribe((data) => { alert('Captcha failed!');
if (!data.open) { window.location.reload();
window.location.reload();
}
});
} }
} else if (import.meta.env.VITE_RECAPTCHA) { } else if (import.meta.env.VITE_RECAPTCHA) {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
+1 -1
View File
@@ -154,7 +154,7 @@
{#if question.image !== null && game_mode !== 'kahoot'} {#if question.image !== null && game_mode !== 'kahoot'}
<div class="max-h-full"> <div class="max-h-full">
<img <img
src={question.image} src="/api/v1/storage/download/{question.image}"
class="object-cover mx-auto mb-8 max-h-[90%]" class="object-cover mx-auto mb-8 max-h-[90%]"
alt="Content for Question" alt="Content for Question"
/> />
+5 -1
View File
@@ -15,7 +15,11 @@
{#if cover_image} {#if cover_image}
<div class="flex justify-center align-middle items-center"> <div class="flex justify-center align-middle items-center">
<div class="h-[30vh] m-auto w-auto mt-12"> <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>
</div> </div>
{/if} {/if}
+3 -3
View File
@@ -11,6 +11,7 @@
import Spinner from '$lib/Spinner.svelte'; import Spinner from '$lib/Spinner.svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import BrownButton from '$lib/components/buttons/brown.svelte'; import BrownButton from '$lib/components/buttons/brown.svelte';
import MediaComponent from '$lib/editor/MediaComponent.svelte';
export let question: Question; export let question: Question;
@@ -93,10 +94,9 @@
<h1 class="text-3xl text-center">{@html question.question}</h1> <h1 class="text-3xl text-center">{@html question.question}</h1>
{#if question.image !== null} {#if question.image !== null}
<div> <div>
<img <MediaComponent
src={question.image} src={question.image}
class="max-h-[40vh] object-cover mx-auto mb-8 w-auto" css_classes="max-h-[40vh] object-cover mx-auto mb-8 w-auto"
alt="Content for Question"
/> />
</div> </div>
{/if} {/if}
@@ -46,7 +46,7 @@
{#if data.cover_image != undefined && data.cover_image !== ''} {#if data.cover_image != undefined && data.cover_image !== ''}
<div class="flex justify-center pt-10 w-full max-h-72 w-full"> <div class="flex justify-center pt-10 w-full max-h-72 w-full">
<img <img
src={data.cover_image} src="/api/v1/storage/download/{data.cover_image}"
alt="not available" alt="not available"
class="max-h-72 h-auto w-auto" class="max-h-72 h-auto w-auto"
on:contextmenu|preventDefault={() => { on:contextmenu|preventDefault={() => {
+16
View File
@@ -88,3 +88,19 @@ export interface EditorData {
background_color?: string; background_color?: string;
background_image?: 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({ yup.object({
question: yup.string().required('A question-title is required').max(299), question: yup.string().required('A question-title is required').max(299),
time: yup.number().required().positive('The time has to be positive'), time: yup.number().required().positive('The time has to be positive'),
image: yup image: yup.string().nullable().lowercase(),
.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(),
answers: yup.lazy((v) => { answers: yup.lazy((v) => {
if (Array.isArray(v)) { if (Array.isArray(v)) {
if (typeof v[0].right === 'boolean') { if (typeof v[0].right === 'boolean') {
+5 -4
View File
@@ -6,12 +6,12 @@
<script> <script>
import '../app.css'; import '../app.css';
import Navbar from '$lib/navbar.svelte'; 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 * as Sentry from '@sentry/browser';
import { BrowserTracing } from '@sentry/tracing'; import { BrowserTracing } from '@sentry/tracing';
import { initLocalizationContext } from '$lib/i18n'; import { initLocalizationContext } from '$lib/i18n';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import Alert from '$lib/modals/alert.svelte'; // import Alert from '$lib/modals/alert.svelte';
/* afterNavigate(() => { /* afterNavigate(() => {
if (browser) { if (browser) {
@@ -91,7 +91,8 @@
{:else} {:else}
<slot /> <slot />
{/if} {/if}
{#if $alertModal.open ?? false}
<!--{#if $alertModal.open ?? false}
<div <div
class="fixed inset-0 h-screen w-screen bg-black z-30 bg-opacity-60 items-center justify-center content-center" 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} class:hidden={!$alertModal.open}
@@ -104,7 +105,7 @@
bind:open={$alertModal.open} bind:open={$alertModal.open}
/> />
</div> </div>
{/if} {/if}-->
<style lang="scss"> <style lang="scss">
:global(html:not(.dark)) { :global(html:not(.dark)) {
@@ -5,7 +5,6 @@
--> -->
<script lang="ts"> <script lang="ts">
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import { alertModal } from '../../../lib/stores';
export let session_data; export let session_data;
export let selected_method; export let selected_method;
@@ -37,19 +36,16 @@
try { try {
data = await res.json(); data = await res.json();
} catch { } catch {
alertModal.set({ alert("This shouldn't happen");
open: true,
body: "This shouldn't happen. Please try again.",
title: 'Unknown error'
});
window.location.reload(); window.location.reload();
} }
if (data.detail === 'wrong credentials') { if (data.detail === 'wrong credentials') {
alertModal.set({ /* alertModal.set({
open: true, open: true,
body: 'Please try again. Your email and or password were incorrect.', body: 'Please try again. Your email and or password were incorrect.',
title: 'Wrong Credentials' title: 'Wrong Credentials'
}); });*/
alert('Wrong credentials');
} }
} }
}; };
@@ -5,7 +5,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import { alertModal } from '$lib/stores'; // import { alertModal } from '$lib/stores';
export let session_data; export let session_data;
export let selected_method; export let selected_method;
@@ -44,19 +44,21 @@
try { try {
data = await res.json(); data = await res.json();
} catch { } catch {
alertModal.set({ /* alertModal.set({
open: true, open: true,
body: "This shouldn't happen. Please try again.", body: "This shouldn't happen. Please try again.",
title: 'Unknown error' title: 'Unknown error'
}); });*/
alert('Unknown error');
window.location.reload(); window.location.reload();
} }
if (data.detail === 'totp wrong') { if (data.detail === 'totp wrong') {
alertModal.set({ /* alertModal.set({
open: true, open: true,
body: 'Wrong Totp-Code. please try again.', body: 'Wrong Totp-Code. please try again.',
title: 'Totp Error' title: 'Totp Error'
}); });*/
alert('TOTP code was incorrect');
totp = ''; totp = '';
} }
} }
@@ -6,7 +6,7 @@
<script lang="ts"> <script lang="ts">
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from '@simplewebauthn/browser';
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import { alertModal } from '$lib/stores'; // import { alertModal } from '$lib/stores';
const { t } = getLocalization(); const { t } = getLocalization();
export let session_data; export let session_data;
@@ -24,11 +24,12 @@
asseResp = await startAuthentication(data); asseResp = await startAuthentication(data);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alertModal.set({ /* alertModal.set({
open: true, open: true,
body: e, body: e,
title: 'Unknown error' title: 'Unknown error'
}); });*/
alert('Unknown error');
isLoading = false; isLoading = false;
} }
const res = await fetch( const res = await fetch(
@@ -51,19 +52,21 @@
try { try {
data = await res.json(); data = await res.json();
} catch { } catch {
alertModal.set({ /* alertModal.set({
open: true, open: true,
body: "This shouldn't happen. Please try again.", body: "This shouldn't happen. Please try again.",
title: 'Unknown error' title: 'Unknown error'
}); });*/
alert('Unknown error');
window.location.reload(); window.location.reload();
} }
if (data.detail === 'webauthn failed') { if (data.detail === 'webauthn failed') {
alertModal.set({ /* alertModal.set({
open: true, open: true,
body: 'Webauthn failed. Please try again.', body: 'Webauthn failed. Please try again.',
title: 'Webauthn Error' title: 'Webauthn Error'
}); });*/
alert('Webauthn failed');
} }
} }
isLoading = false; isLoading = false;
+7 -4
View File
@@ -135,9 +135,12 @@
{/if} {/if}
<BrownButton href="/import">{$t('words.import')}</BrownButton> <BrownButton href="/import">{$t('words.import')}</BrownButton>
<BrownButton href="/results">{$t('words.results')}</BrownButton> <BrownButton href="/results">{$t('words.results')}</BrownButton>
<BrownButton href="/account/settings"> <div class="flex gap-2">
{$t('words.settings')} <BrownButton href="/edit/files">{$t('words.files_library')}</BrownButton>
</BrownButton> <BrownButton href="/account/settings">
{$t('words.settings')}
</BrownButton>
</div>
</div> </div>
{#if quizzes.length !== 0} {#if quizzes.length !== 0}
<div class="flex justify-center pt-4 w-full"> <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"> <div class="hidden lg:flex w-auto h-full items-center relative">
{#if quiz.cover_image} {#if quiz.cover_image}
<img <img
src={quiz.cover_image} src="/api/v1/storage/download/{quiz.cover_image}"
alt="user provided" alt="user provided"
loading="lazy" loading="lazy"
class="shrink-0 max-w-full max-h-full absolute" 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: Start Caddy:
<pre><code>caddy run</code></pre> <pre><code>caddy run</code></pre>
</li> </li>
<li>
Start the background-worker:
<pre><code>arq classquiz.worker.WorkerSettings</code></pre>
</li>
</ol> </ol>
<p>If you're done developing: <code>./run_tests.sh -</code></p> <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> <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> <h3>Storage Provider</h3>
<p> <p>
You'll have to set up a storage provider for some pictures (these getting imported from 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. Kahoot!). For now, you can use <a href="https://min.io/" target="_blank">Minio (S3)</a> or
Please note that I would <b>NOT</b> use the local file system because of the local filesystem. Please not that I'd recommend Minio for larger instances, since it can
<a href="https://wiki.owasp.org/index.php/Path_Traversal">Path Traversals</a>. I tried to be scaled and the media doesn't have to streamed through the (comparatively) slow ClassQuiz
prevent these attacks, but I really wouldn't trust it, because it's a regex! You'll have to server. Now, that you've decided on a storage backend, you can set the
set the <code>STORAGE_BACKEND</code>-environment-variable to either <code>s3</code> or
<code>STORAGE_BACKEND</code>-environment-variable to either <code>deta</code> or <code>local</code>. If you ask yourself what happened with deta, I've decided to remove it,
<code>local</code>. since the went all-in with their spaces and I think that hardly anyone used it anyway.
</p> </p>
<h4>If you chose Deta...</h4> <h4>If you chose Minio (S3)...</h4>
<p> <p>
...you'll also have to set the <code>DETA_PROJECT_KEY</code> and the ...you'll also have to set the <code>S3_ACCESS_KEY</code>, <code>S3_SECRET_KEY</code> and
<code>DETA_PROJECT_ID</code>. the
<code>S3_BASE_URL</code>. The <code>S3_BUCKET_NAME</code> can also be set, but defaults to
<code>classquiz</code>.
</p> </p>
<h4>If you chose the local filesystem...</h4> <h4>If you chose the local filesystem...</h4>
<p> <p>
@@ -165,15 +167,15 @@ services:
REDIS_URL: redis://redis:6379/0?decode_responses=True # don't change REDIS_URL: redis://redis:6379/0?decode_responses=True # don't change
API_URL: http://api:80 # don't change API_URL: http://api:80 # don't change
api: api:
build: build: &build_cfg
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
restart: always restart: &restart always
depends_on: depends_on: &depends
- db - db
- redis - redis
environment: environment: &env_vars
ROOT_ADDRESS: "https://classquiz.de" # Base-URL (change it) ROOT_ADDRESS: "https://classquiz.de" # Base-URL (change it)
DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz" # don't change DB_URL: "postgresql://postgres:classquiz@db:5432/classquiz" # don't change
MAIL_ADDRESS: "classquiz@mawoka.eu" # Email-Address (change it) MAIL_ADDRESS: "classquiz@mawoka.eu" # Email-Address (change it)
@@ -188,11 +190,13 @@ services:
MEILISEARCH_URL: "http://meilisearch:7700" # don't change MEILISEARCH_URL: "http://meilisearch:7700" # don't change
ACCESS_TOKEN_EXPIRE_MINUTES: 30 # don't change ACCESS_TOKEN_EXPIRE_MINUTES: 30 # don't change
HCAPTCHA_KEY: "" # Private hCaptcha key for verification (change it) 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" # If STORAGE_BACKEND is "s3"
DETA_PROJECT_KEY: "YOUR_DETA_PROJECT_KEY" S3_ACCESS_KEY=YOUR_ACCESS_KEY
DETA_PROJECT_ID: "YOUR_DETA_PROJECT_ID" S3_SECRET_KEY=YOUR_SECRET_KEY
S3_BASE_URL=YOUR_S3_BASE_URL
# If STORAGE_BACKEND is "local" # If STORAGE_BACKEND is "local"
STORAGE_PATH: "/var/storage" 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_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. 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: redis:
image: redis:alpine image: redis:alpine
@@ -241,6 +247,14 @@ services:
MEILI_NO_ANALYTICS: true MEILI_NO_ANALYTICS: true
volumes: volumes:
- meilisearch-data:/data.ms - meilisearch-data:/data.ms
worker:
build: *build_cfg
environment: *env_vars
restart: *restart
depends_on: *depends
command: arq classquiz.worker.WorkerSettings
volumes: volumes:
data: data:
meilisearch-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"> <script lang="ts">
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import { navbarVisible, alertModal } from '$lib/stores'; import { navbarVisible } from '$lib/stores';
navbarVisible.set(true); navbarVisible.set(true);
@@ -35,17 +35,19 @@
if (res.status === 200) { if (res.status === 200) {
window.location.href = '/dashboard'; window.location.href = '/dashboard';
} else if (res.status === 400) { } else if (res.status === 400) {
alertModal.set({ /* alertModal.set({
open: true, open: true,
title: 'Import failed', title: 'Import failed',
body: "This quiz isn't (yet) supported!" body: "This quiz isn't (yet) supported!"
}); });*/
alert("This quiz isn't (yet) supported!");
} else { } else {
alertModal.set({ /* alertModal.set({
open: true, open: true,
title: 'Import failed', title: 'Import failed',
body: 'Unknown error while importing the quiz!' body: 'Unknown error while importing the quiz!'
}); });*/
alert('Import failed with unknown reason');
} }
is_loading = false; is_loading = false;
}; };
@@ -61,11 +63,12 @@
if (res.status === 200) { if (res.status === 200) {
window.location.href = '/dashboard'; window.location.href = '/dashboard';
} else { } else {
alertModal.set({ /* alertModal.set({
open: true, open: true,
title: 'Import failed', title: 'Import failed',
body: 'Something went wrong!' body: 'Something went wrong!'
}); });*/
alert('Something went wrong!');
} }
is_loading = false; is_loading = false;
}; };
+2 -1
View File
@@ -253,7 +253,8 @@
{#if game_data.questions[selected_question].image !== null} {#if game_data.questions[selected_question].image !== null}
<div> <div>
<img <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" class="max-h-[20vh] object-cover mx-auto mb-8 w-auto"
alt="Content for Question" alt="Content for Question"
/> />
@@ -82,7 +82,7 @@
<div class="h-[20vh] m-auto w-auto max-h-[18vh]"> <div class="h-[20vh] m-auto w-auto max-h-[18vh]">
<img <img
class="max-h-full max-w-full block" class="max-h-full max-w-full block"
src={quiz.cover_image} src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided" alt="Not provided"
loading="lazy" loading="lazy"
/> />
@@ -13,6 +13,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Spinner from '$lib/Spinner.svelte'; import Spinner from '$lib/Spinner.svelte';
import GrayButton from '$lib/components/buttons/gray.svelte'; import GrayButton from '$lib/components/buttons/gray.svelte';
import MediaComponent from '$lib/editor/MediaComponent.svelte';
const tippy = createTippy({ const tippy = createTippy({
arrow: true, arrow: true,
@@ -79,7 +80,7 @@
<div class="h-[15vh] m-auto w-auto my-3"> <div class="h-[15vh] m-auto w-auto my-3">
<img <img
class="max-h-full max-w-full block" class="max-h-full max-w-full block"
src={quiz.cover_image} src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided" alt="Not provided"
/> />
</div> </div>
@@ -189,7 +190,12 @@
<!-- </label>--> <!-- </label>-->
{#if question.image} {#if question.image}
<span> <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> </span>
{/if} {/if}
<p <p
+15 -2
View File
@@ -1,8 +1,21 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
/** @type {import('vite').UserConfig} */ /** @type {import("vite").UserConfig} */
const config = { 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: { server: {
port: 3000 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 ###