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