From eaad7a3d9aaeff6e147f1f6e14a651893b01864c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 20:42:08 +0000 Subject: [PATCH 01/46] :arrow_up: Bump authlib from 1.6.4 to 1.6.5 Bumps [authlib](https://github.com/authlib/authlib) from 1.6.4 to 1.6.5. - [Release notes](https://github.com/authlib/authlib/releases) - [Changelog](https://github.com/authlib/authlib/blob/main/docs/changelog.rst) - [Commits](https://github.com/authlib/authlib/compare/v1.6.4...v1.6.5) --- updated-dependencies: - dependency-name: authlib dependency-version: 1.6.5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Pipfile.lock | 116 +++++++++++++++++++++++++-------------------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 94d3fd4..825db85 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -226,12 +226,12 @@ }, "authlib": { "hashes": [ - "sha256:104b0442a43061dc8bc23b133d1d06a2b0a9c2e3e33f34c4338929e816287649", - "sha256:39313d2a2caac3ecf6d8f95fbebdfd30ae6ea6ae6a6db794d976405fdd9aa796" + "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", + "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==1.6.4" + "version": "==1.6.5" }, "bidict": { "hashes": [ @@ -518,64 +518,64 @@ }, "cryptography": { "hashes": [ - "sha256:0a17377fa52563d730248ba1f68185461fff36e8bc75d8787a7dd2e20a802b7a", - "sha256:0ca4be2af48c24df689a150d9cd37404f689e2968e247b6b8ff09bff5bcd786f", - "sha256:0d1922d9280e08cde90b518a10cd66831f632960a8d08cb3418922d83fce6f12", - "sha256:0dfb7c88d4462a0cfdd0d87a3c245a7bc3feb59de101f6ff88194f740f72eda6", - "sha256:0ff483716be32690c14636e54a1f6e2e1b7bf8e22ca50b989f88fa1b2d287080", - "sha256:13e67c4d3fb8b6bc4ef778a7ccdd8df4cd15b4bcc18f4239c8440891a11245cc", - "sha256:15b5fd9358803b0d1cc42505a18d8bca81dabb35b5cfbfea1505092e13a9d96d", - "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475", - "sha256:2dd339ba3345b908fa3141ddba4025568fa6fd398eabce3ef72a29ac2d73ad75", - "sha256:341fb7a26bc9d6093c1b124b9f13acc283d2d51da440b98b55ab3f79f2522ead", - "sha256:34f04b7311174469ab3ac2647469743720f8b6c8b046f238e5cb27905695eb2a", - "sha256:41c281a74df173876da1dc9a9b6953d387f06e3d3ed9284e3baae3ab3f40883a", - "sha256:449ef2b321bec7d97ef2c944173275ebdab78f3abdd005400cc409e27cd159ab", - "sha256:45f790934ac1018adeba46a0f7289b2b8fe76ba774a88c7f1922213a56c98bc1", - "sha256:48948940d0ae00483e85e9154bb42997d0b77c21e43a77b7773c8c80de532ac5", - "sha256:4c49eda9a23019e11d32a0eb51a27b3e7ddedde91e099c0ac6373e3aacc0d2ee", - "sha256:504e464944f2c003a0785b81668fe23c06f3b037e9cb9f68a7c672246319f277", - "sha256:534b96c0831855e29fc3b069b085fd185aa5353033631a585d5cd4dd5d40d657", - "sha256:6ef1488967e729948d424d09c94753d0167ce59afba8d0f6c07a22b629c557b2", - "sha256:7176a5ab56fac98d706921f6416a05e5aff7df0e4b91516f450f8627cda22af3", - "sha256:7411c910fb2a412053cf33cfad0153ee20d27e256c6c3f14d7d7d1d9fec59fd5", - "sha256:757af4f6341ce7a1e47c326ca2a81f41d236070217e5fbbad61bbfe299d55d28", - "sha256:7823bc7cdf0b747ecfb096d004cc41573c2f5c7e3a29861603a2871b43d3ef32", - "sha256:7fab1187b6c6b2f11a326f33b036f7168f5b996aedd0c059f9738915e4e8f53a", - "sha256:84ef1f145de5aee82ea2447224dc23f065ff4cc5791bb3b506615957a6ba8128", - "sha256:92e8cfe8bd7dd86eac0a677499894862cd5cc2fd74de917daa881d00871ac8e7", - "sha256:9394c7d5a7565ac5f7d9ba38b2617448eba384d7b107b262d63890079fad77ca", - "sha256:9495d78f52c804b5ec8878b5b8c7873aa8e63db9cd9ee387ff2db3fffe4df784", - "sha256:9873bf7c1f2a6330bdfe8621e7ce64b725784f9f0c3a6a55c3047af5849f920e", - "sha256:9babb7818fdd71394e576cf26c5452df77a355eac1a27ddfa24096665a27f8fd", - "sha256:9e8776dac9e660c22241b6587fae51a67b4b0147daa4d176b172c3ff768ad736", - "sha256:9ed64e5083fa806709e74fc5ea067dfef9090e5b7a2320a49be3c9df3583a2d8", - "sha256:9f2c4cc63be3ef43c0221861177cee5d14b505cd4d4599a89e2cd273c4d3542a", - "sha256:9f40642a140c0c8649987027867242b801486865277cbabc8c6059ddef16dc8b", - "sha256:af84e8e99f1a82cea149e253014ea9dc89f75b82c87bb6c7242203186f465129", - "sha256:b9c79af2c3058430d911ff1a5b2b96bbfe8da47d5ed961639ce4681886614e70", - "sha256:c52fded6383f7e20eaf70a60aeddd796b3677c3ad2922c801be330db62778e05", - "sha256:cbb8e769d4cac884bb28e3ff620ef1001b75588a5c83c9c9f1fdc9afbe7f29b0", - "sha256:d84c40bdb8674c29fa192373498b6cb1e84f882889d21a471b45d1f868d8d44b", - "sha256:db5597a4c7353b2e5fb05a8e6cb74b56a4658a2b7bf3cb6b1821ae7e7fd6eaa0", - "sha256:e22801b61613ebdebf7deb18b507919e107547a1d39a3b57f5f855032dd7cfb8", - "sha256:e34da95e29daf8a71cb2841fd55df0511539a6cdf33e6f77c1e95e44006b9b46", - "sha256:e46710a240a41d594953012213ea8ca398cd2448fbc5d0f1be8160b5511104a0", - "sha256:e94eb5fa32a8a9f9bf991f424f002913e3dd7c699ef552db9b14ba6a76a6313b", - "sha256:ec13b7105117dbc9afd023300fb9954d72ca855c274fe563e72428ece10191c0", - "sha256:ed570874e88f213437f5cf758f9ef26cbfc3f336d889b1e592ee11283bb8d1c7", - "sha256:ed957044e368ed295257ae3d212b95456bd9756df490e1ac4538857f67531fcc", - "sha256:ef648d2c690703501714588b2ba640facd50fd16548133b11b2859e8655a69da", - "sha256:efc9e51c3e595267ff84adf56e9b357db89ab2279d7e375ffcaf8f678606f3d9", - "sha256:f736ab8036796f5a119ff8211deda416f8c15ce03776db704a7a4e17381cb2ef", - "sha256:f7a24ea78de345cfa7f6a8d3bde8b242c7fac27f2bd78fa23474ca38dfaeeab9", - "sha256:f7de12fa0eee6234de9a9ce0ffcfa6ce97361db7a50b09b65c63ac58e5f22fc7", - "sha256:f9b55038b5c6c47559aa33626d8ecd092f354e23de3c6975e4bb205df128a2a0", - "sha256:fd4b5e2ee4e60425711ec65c33add4e7a626adef79d66f62ba0acfd493af282d" + "sha256:04911b149eae142ccd8c9a68892a70c21613864afb47aba92d8c7ed9cc001023", + "sha256:07c0eb6657c0e9cca5891f4e35081dbf985c8131825e21d99b4f440a8f496f36", + "sha256:0b507c8e033307e37af61cb9f7159b416173bdf5b41d11c4df2e499a1d8e007c", + "sha256:0c7ffe8c9b1fcbb07a26d7c9fa5e857c2fe80d72d7b9e0353dcf1d2180ae60ee", + "sha256:1a88634851d9b8de8bb53726f4300ab191d3b2f42595e2581a54b26aba71b7cc", + "sha256:1d3b3edd145953832e09607986f2bd86f85d1dc9c48ced41808b18009d9f30e5", + "sha256:1e3b6428a3d56043bff0bb85b41c535734204e599c1c0977e1d0f261b02f3ad5", + "sha256:1fd1a69086926b623ef8126b4c33d5399ce9e2f3fac07c9c734c2a4ec38b6d02", + "sha256:218abd64a2e72f8472c2102febb596793347a3e65fafbb4ad50519969da44470", + "sha256:21b6fc8c71a3f9a604f028a329e5560009cc4a3a828bfea5fcba8eb7647d88fe", + "sha256:27c53b4f6a682a1b645fbf1cd5058c72cf2f5aeba7d74314c36838c7cbc06e0f", + "sha256:2b9cad9cf71d0c45566624ff76654e9bae5f8a25970c250a26ccfc73f8553e2d", + "sha256:2fafb6aa24e702bbf74de4cb23bfa2c3beb7ab7683a299062b69724c92e0fa73", + "sha256:3f37aa12b2d91e157827d90ce78f6180f0c02319468a0aea86ab5a9566da644b", + "sha256:48b983089378f50cba258f7f7aa28198c3f6e13e607eaf10472c26320332ca9a", + "sha256:48c01988ecbb32979bb98731f5c2b2f79042a6c58cc9a319c8c2f9987c7f68f9", + "sha256:4a766d2a5d8127364fd936572c6e6757682fc5dfcbdba1632d4554943199f2fa", + "sha256:512c0250065e0a6b286b2db4bbcc2e67d810acd53eb81733e71314340366279e", + "sha256:5840f05518caa86b09d23f8b9405a7b6d5400085aa14a72a98fdf5cf1568c0d2", + "sha256:5e38f203160a48b93010b07493c15f2babb4e0f2319bbd001885adb3f3696d21", + "sha256:6b275e398ab3a7905e168c036aad54b5969d63d3d9099a0a66cc147a3cc983be", + "sha256:7282d8f092b5be7172d6472f29b0631f39f18512a3642aefe52c3c0e0ccfad5a", + "sha256:747b6f4a4a23d5a215aadd1d0b12233b4119c4313df83ab4137631d43672cc90", + "sha256:758cfc7f4c38c5c5274b55a57ef1910107436f4ae842478c4989abbd24bd5acb", + "sha256:8b16c1ede6a937c291d41176934268e4ccac2c6521c69d3f5961c5a1e11e039e", + "sha256:8b9bf67b11ef9e28f4d78ff88b04ed0929fcd0e4f70bb0f704cfc32a5c6311ee", + "sha256:8e2ad4d1a5899b7caa3a450e33ee2734be7cc0689010964703a7c4bcc8dd4fd0", + "sha256:9066cfd7f146f291869a9898b01df1c9b0e314bfa182cef432043f13fc462c92", + "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", + "sha256:97e83bf4f2f2c084d8dd792d13841d0a9b241643151686010866bbd076b19659", + "sha256:9bd26f2f75a925fdf5e0a446c0de2714f17819bf560b44b7480e4dd632ad6c46", + "sha256:9bdc25e4e01b261a8fda4e98618f1c9515febcecebc9566ddf4a70c63967043b", + "sha256:9ec3f2e2173f36a9679d3b06d3d01121ab9b57c979de1e6a244b98d51fea1b20", + "sha256:9f13b040649bc18e7eb37936009b24fd31ca095a5c647be8bb6aaf1761142bd1", + "sha256:a08e7401a94c002e79dc3bc5231b6558cd4b2280ee525c4673f650a37e2c7685", + "sha256:a61c154cc5488272a6c4b86e8d5beff4639cdb173d75325ce464d723cda0052b", + "sha256:bb7fb9cd44c2582aa5990cf61a4183e6f54eea3172e54963787ba47287edd135", + "sha256:bca3f0ce67e5a2a2cf524e86f44697c4323a86e0fd7ba857de1c30d52c11ede1", + "sha256:bda55e8dbe8533937956c996beaa20266a8eca3570402e52ae52ed60de1faca8", + "sha256:be939b99d4e091eec9a2bcf41aaf8f351f312cd19ff74b5c83480f08a8a43e0b", + "sha256:c4b93af7920cdf80f71650769464ccf1fb49a4b56ae0024173c24c48eb6b1612", + "sha256:cb5e8daac840e8879407acbe689a174f5ebaf344a062f8918e526824eb5d97af", + "sha256:d19f5f48883752b5ab34cff9e2f7e4a7f216296f33714e77d1beb03d108632b6", + "sha256:d30bc11d35743bf4ddf76674a0a369ec8a21f87aaa09b0661b04c5f6c46e8d7b", + "sha256:e12b61e0b86611e3f4c1756686d9086c1d36e6fd15326f5658112ad1f1cc8807", + "sha256:e6f6775eaaa08c0eec73e301f7592f4367ccde5e4e4df8e58320f2ebf161ea2c", + "sha256:e7155c0b004e936d381b15425273aee1cebc94f879c0ce82b0d7fecbf755d53a", + "sha256:e8633996579961f9b5a3008683344c2558d38420029d3c0bc7ff77c17949a4e1", + "sha256:f25a41f5b34b371a06dad3f01799706631331adc7d6c05253f5bca22068c7a34", + "sha256:f3e32ab7dd1b1ef67b9232c4cf5e2ee4cd517d4316ea910acaaa9c5712a1c663", + "sha256:f9b2dc7668418fb6f221e4bf701f716e05e8eadb4f1988a2487b11aedf8abe62", + "sha256:fab8f805e9675e61ed8538f192aad70500fa6afb33a8803932999b1049363a08", + "sha256:fe245cf4a73c20592f0f48da39748b3513db114465be78f0a36da847221bd1b4", + "sha256:ff798ad7a957a5021dcbab78dfff681f0cf15744d0e6af62bd6746984d9c9e9c" ], "index": "pypi", "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", - "version": "==46.0.1" + "version": "==46.0.2" }, "databases": { "hashes": [ From e9e5d90317c83e39b196dd4e6127a730652883d9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 21:42:55 +0000 Subject: [PATCH 02/46] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/fsfe/reuse-tool: v6.0.0 → v6.1.2](https://github.com/fsfe/reuse-tool/compare/v6.0.0...v6.1.2) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59d2de2..1fb2943 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,6 +36,6 @@ repos: # so --exclude in .flake8 does not work exclude: ^migrations/versions/ - repo: https://github.com/fsfe/reuse-tool - rev: v6.0.0 + rev: v6.1.2 hooks: - id: reuse From af4c3d65a3df1d84cf265fa098ea31272a3f35e3 Mon Sep 17 00:00:00 2001 From: Dead Scissors Date: Mon, 15 Sep 2025 22:53:30 +0200 Subject: [PATCH 03/46] :globe_with_meridians: Translated (Arabic) Currently translated at 68.3% (261 of 382 strings) Translation: ClassQuiz/Frontend Translate-URL: https://hosted.weblate.org/projects/classquiz/frontend/ar/ --- frontend/src/lib/i18n/locales/ar.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/i18n/locales/ar.json b/frontend/src/lib/i18n/locales/ar.json index aa28fc6..79c5e07 100644 --- a/frontend/src/lib/i18n/locales/ar.json +++ b/frontend/src/lib/i18n/locales/ar.json @@ -45,7 +45,8 @@ "see_all_quizzes": "رؤية جميع الاختبارات الخاصة بك", "download_quizzes_content": "يمكن تنزيل الاختبارات كملف واحد واستيرادها في أي وقت. يتيح لك هذا أيضًا نقل اختباراتك إلى مثيلات ClassQuiz الأخرى.", "no_player_limit": "لا يوجد حد لعدد اللاعبين", - "no_player_limit_content": "ليس لدى ClassQuiz حد اصطناعي للاعبين. من الناحية النظرية، يمكن لأكثر من 1000 لاعب لعب نفس الاختبار، ولكن يتم اختباره مع ما يصل إلى 300 لاعب في كل اختبار." + "no_player_limit_content": "ليس لدى ClassQuiz حد اصطناعي للاعبين. من الناحية النظرية، يمكن لأكثر من 1000 لاعب لعب نفس الاختبار، ولكن يتم اختباره مع ما يصل إلى 300 لاعب في كل اختبار.", + "import_quiz_from_kahoot_and_edit": "أحضر اختباراتك من Kahoot وعدلها في ClassQuiz !" }, "result_page": { "correct_answer_plural": "", @@ -355,7 +356,9 @@ "need_more_help": "هل تريد المزيد من المساعدة؟", "visit_docs": "قم بزيارة المستندات.", "enable_reorder": "تمكين وضع إعادة الترتيب", - "disable_reorder": "تعطيل وضع إعادة الترتيب" + "disable_reorder": "تعطيل وضع إعادة الترتيب", + "advanced_settings": "الإعدادات المتقدمة", + "hide_question_results": "هل تريد إخفاء نتائج كل سؤال؟" }, "controllers": { "last_seen": "", From 2657a9d8665936f7d328b178269927ae89d798df Mon Sep 17 00:00:00 2001 From: c4ukhpbae Date: Thu, 16 Oct 2025 14:32:09 +0200 Subject: [PATCH 04/46] :globe_with_meridians: Translated (Czech) Currently translated at 1.3% (5 of 382 strings) Translation: ClassQuiz/Frontend Translate-URL: https://hosted.weblate.org/projects/classquiz/frontend/cs/ --- frontend/src/lib/i18n/locales/cs.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/i18n/locales/cs.json b/frontend/src/lib/i18n/locales/cs.json index 3259c73..cae6a7b 100644 --- a/frontend/src/lib/i18n/locales/cs.json +++ b/frontend/src/lib/i18n/locales/cs.json @@ -131,7 +131,7 @@ "find_or_explore": "", "completely_free_content": "", "stats": "", - "see_how_many_true_and_false": "", + "see_how_many_true_and_false": "Podívejte se, kolik z nich mělo pravdu nebo se mýlilo", "download_quizzes": "", "dark_mode_content": "", "multilingual": "", @@ -145,12 +145,12 @@ "get_ranking_and_winners": "", "meta": { "description": "ClassQuiz je kvízová aplikace podobná aplikaci Kahoot! pro studenty, která má otevřený zdrojový kód a je zdarma.", - "title": "" + "title": "Domů" }, "list_winners": "", "students_site": "", "self_hostable": "", - "see_what_true_and_false": "", + "see_what_true_and_false": "Podívejte se, co bylo správné nebo špatné", "german_server": "", "create_a_quiz_from_scratch": "", "teachers_site": "", From 1b644be0e36e632a5158056fb6494b329251d55a Mon Sep 17 00:00:00 2001 From: Mawoka Date: Sat, 25 Oct 2025 11:03:52 +0200 Subject: [PATCH 05/46] Fix docker-compose (minio) and add basic logger --- classquiz/__init__.py | 40 +++++++++++++++---- classquiz/config.py | 8 +++- classquiz/helpers/__init__.py | 13 +++--- docker-compose.dev.yml | 21 +++++----- frontend/src/lib/i18n/locales/je.json.license | 3 ++ frontend/src/lib/i18n/locales/lb.json.license | 3 ++ .../src/lib/i18n/locales/pt_BR.json.license | 3 ++ frontend/src/lib/i18n/locales/ro.json.license | 3 ++ 8 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 frontend/src/lib/i18n/locales/je.json.license create mode 100644 frontend/src/lib/i18n/locales/lb.json.license create mode 100644 frontend/src/lib/i18n/locales/pt_BR.json.license create mode 100644 frontend/src/lib/i18n/locales/ro.json.license diff --git a/classquiz/__init__.py b/classquiz/__init__.py index bdfdd83..dc2d025 100644 --- a/classquiz/__init__.py +++ b/classquiz/__init__.py @@ -77,12 +77,25 @@ async def auth_middleware_wrapper(request: Request, call_next): return await rememberme_middleware(request, call_next) -app.include_router(moderation.router, tags=["moderation"], prefix="/api/v1/moderation", include_in_schema=True) +app.include_router( + moderation.router, + tags=["moderation"], + prefix="/api/v1/moderation", + include_in_schema=True, +) app.include_router(pixabay.router, tags=["pixabay"], prefix="/api/v1/pixabay", include_in_schema=True) -app.include_router(quiztivity.router, tags=["quiztivity"], prefix="/api/v1/quiztivity", include_in_schema=True) +app.include_router( + quiztivity.router, + tags=["quiztivity"], + prefix="/api/v1/quiztivity", + include_in_schema=True, +) app.include_router( - box_controller.router, tags=["boxcontroller"], prefix="/api/v1/box-controller", include_in_schema=True + box_controller.router, + tags=["boxcontroller"], + prefix="/api/v1/box-controller", + include_in_schema=True, ) app.include_router(results.router, tags=["results"], prefix="/api/v1/results", include_in_schema=True) app.include_router(remote.router, tags=["remote"], prefix="/api/v1/remote", include_in_schema=True) @@ -94,15 +107,28 @@ app.include_router(quiz.router, tags=["quiz"], prefix="/api/v1/quiz", include_in app.include_router(utils.router, tags=["utils"], prefix="/api/v1/utils", include_in_schema=True) app.include_router(stats.router, tags=["stats"], prefix="/api/v1/stats", include_in_schema=True) app.include_router(storage.router, tags=["storage"], prefix="/api/v1/storage", include_in_schema=True) -app.include_router(search.router, tags=["search"], prefix="/api/v1/search", include_in_schema=True), +app.include_router(search.router, tags=["search"], prefix="/api/v1/search", include_in_schema=True) app.include_router(live.router, tags=["live"], prefix="/api/v1/live", include_in_schema=True) app.include_router( - testing_routes.router, tags=["internal", "testing"], prefix="/api/v1/internal/testing", include_in_schema=False + testing_routes.router, + tags=["internal", "testing"], + prefix="/api/v1/internal/testing", + include_in_schema=False, ) app.include_router(editor.router, tags=["editor"], prefix="/api/v1/editor", include_in_schema=True) -app.include_router(eximport.router, tags=["export", "import"], prefix="/api/v1/eximport", include_in_schema=True) +app.include_router( + eximport.router, + tags=["export", "import"], + prefix="/api/v1/eximport", + include_in_schema=True, +) app.include_router(sitemap.router, tags=["sitemap"], prefix="/api/v1/sitemap", include_in_schema=True) -app.include_router(community.router, tags=["community"], prefix="/api/v1/community", include_in_schema=True) +app.include_router( + community.router, + tags=["community"], + prefix="/api/v1/community", + include_in_schema=True, +) app.include_router(avatar.router, tags=["avatar"], prefix="/api/v1/avatar", include_in_schema=True) app.include_router(admin.router, tags=["admin"], prefix="/api/v1/admin", include_in_schema=True) app.mount("/", ASGIApp(sio)) diff --git a/classquiz/config.py b/classquiz/config.py index f88ae93..383cd8f 100644 --- a/classquiz/config.py +++ b/classquiz/config.py @@ -12,9 +12,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict import meilisearch as MeiliSearch from arq import create_pool from arq.connections import RedisSettings, ArqRedis +import logging from classquiz.storage import Storage +LOGGER = logging.getLogger(f"uvicorn.{__name__}") + class CustomOpenIDProvider(BaseModel): scopes: str | None = "openid email profile" @@ -29,7 +32,10 @@ class Settings(BaseSettings): """ model_config = SettingsConfigDict( - env_file=".env", extra="ignore", env_nested_delimiter="__", env_file_encoding="utf-8" + env_file=".env", + extra="ignore", + env_nested_delimiter="__", + env_file_encoding="utf-8", ) root_address: str = "http://127.0.0.1:8000" redis: RedisDsn = "redis://localhost:6379/0?decode_responses=True" diff --git a/classquiz/helpers/__init__.py b/classquiz/helpers/__init__.py index f6679bc..0f20b85 100644 --- a/classquiz/helpers/__init__.py +++ b/classquiz/helpers/__init__.py @@ -18,7 +18,7 @@ import xlsxwriter from aiohttp import ClientSession from io import BytesIO from PIL import Image -from classquiz.config import meilisearch, settings +from classquiz.config import meilisearch, settings, LOGGER from classquiz.helpers.hashcash import check as hc_check settings = settings() @@ -147,7 +147,10 @@ async def handle_import_from_excel(data: BinaryIO, user: User) -> Quiz: if answer is None: continue if len(str(answer)) > 150: - raise HTTPException(status_code=400, detail=f"Answer {a + 1} in Question {i + 1} is longer than 150") + raise HTTPException( + status_code=400, + detail=f"Answer {a + 1} in Question {i + 1} is longer than 150", + ) if len(answers) < 2: raise HTTPException(status_code=400, detail=f"Less than 2 answers in Question {i + 1}") correct_answers: str = str(row[6]) @@ -200,7 +203,7 @@ async def meilisearch_init(): classquiz_index_found = False # +++ Check if the index does not exist and creates it if not classquiz_index_found: - print("Creating MeiliSearch Index") + LOGGER.debug("Creating MeiliSearch Index") meilisearch.create_index(settings.meilisearch_index) await asyncio.sleep(1) # --- END @@ -211,7 +214,7 @@ async def meilisearch_init(): number_of_docs = i[1] break if number_of_docs != quiz_count: - print("MeiliSearch and Database got out of sync, syncthing them") + LOGGER.warn("MeiliSearch and Database got out of sync, syncthing them") meilisearch.delete_index(settings.meilisearch_index) meilisearch.create_index(settings.meilisearch_index) quizzes = await Quiz.objects.filter(public=True).all() @@ -221,7 +224,7 @@ async def meilisearch_init(): meilisearch.index(settings.meilisearch_index).add_documents(meili_data) # --- END --- meilisearch.index(settings.meilisearch_index).update_settings({"sortableAttributes": ["created_at"]}) - print("Finished MeiliSearch synchronisation") + LOGGER.info("Finished MeiliSearch synchronisation") async def telemetry_ping(): diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1bfc10f..91b546b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,10 +4,10 @@ services: db: -# build: -# context: pg_uuidv7 -# args: -# PG_MAJOR_VERSION: 16 + # build: + # context: pg_uuidv7 + # args: + # PG_MAJOR_VERSION: 16 image: postgres:16 environment: POSTGRES_PASSWORD: mysecretpassword @@ -34,21 +34,24 @@ services: privileged: true restart: unless-stopped minio: - image: bitnami/minio + image: docker.io/minio/minio environment: - MINIO_ROOT_USER: fediprint - MINIO_ROOT_PASSWORD: fediprint + MINIO_ROOT_USER: classquiz + MINIO_ROOT_PASSWORD: classquiz + MINIO_ADDRESS: ":9000" + MINIO_CONSOLE_ADDRESS: ":9001" volumes: - - minio:/bitnami/minio/data + - minio:/data ports: - 9000:9000 - 9001:9001 restart: unless-stopped + command: minio server /data redis: image: redis:alpine restart: always healthcheck: - test: [ "CMD", "redis-cli","ping" ] + test: ["CMD", "redis-cli", "ping"] ports: - 6379:6379 volumes: diff --git a/frontend/src/lib/i18n/locales/je.json.license b/frontend/src/lib/i18n/locales/je.json.license new file mode 100644 index 0000000..37230df --- /dev/null +++ b/frontend/src/lib/i18n/locales/je.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 Marlon W (Mawoka) + +SPDX-License-Identifier: MPL-2.0 diff --git a/frontend/src/lib/i18n/locales/lb.json.license b/frontend/src/lib/i18n/locales/lb.json.license new file mode 100644 index 0000000..37230df --- /dev/null +++ b/frontend/src/lib/i18n/locales/lb.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 Marlon W (Mawoka) + +SPDX-License-Identifier: MPL-2.0 diff --git a/frontend/src/lib/i18n/locales/pt_BR.json.license b/frontend/src/lib/i18n/locales/pt_BR.json.license new file mode 100644 index 0000000..37230df --- /dev/null +++ b/frontend/src/lib/i18n/locales/pt_BR.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 Marlon W (Mawoka) + +SPDX-License-Identifier: MPL-2.0 diff --git a/frontend/src/lib/i18n/locales/ro.json.license b/frontend/src/lib/i18n/locales/ro.json.license new file mode 100644 index 0000000..37230df --- /dev/null +++ b/frontend/src/lib/i18n/locales/ro.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 Marlon W (Mawoka) + +SPDX-License-Identifier: MPL-2.0 From 6dba05dd561266617aebb9b058ab008a955726c3 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Sat, 25 Oct 2025 11:04:03 +0200 Subject: [PATCH 06/46] Fix docker-compose (minio) and add basic logger --- frontend/src/lib/i18n/locales/he.json.license | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 frontend/src/lib/i18n/locales/he.json.license diff --git a/frontend/src/lib/i18n/locales/he.json.license b/frontend/src/lib/i18n/locales/he.json.license new file mode 100644 index 0000000..37230df --- /dev/null +++ b/frontend/src/lib/i18n/locales/he.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 Marlon W (Mawoka) + +SPDX-License-Identifier: MPL-2.0 From f3abe4d2c4a638d58b8c0eb6256135acf0d36caa Mon Sep 17 00:00:00 2001 From: Mawoka Date: Sat, 25 Oct 2025 13:38:33 +0200 Subject: [PATCH 07/46] Cleanup in socket server --- classquiz/db/models.py | 57 +++- classquiz/socket_server/__init__.py | 288 ++++-------------- classquiz/socket_server/helpers.py | 133 ++++++++ classquiz/socket_server/models.py | 68 +++++ .../src/lib/components/commandpalette.svelte | 4 + frontend/src/routes/+layout.svelte | 2 +- .../src/routes/account/login/+page.svelte | 2 +- 7 files changed, 326 insertions(+), 228 deletions(-) create mode 100644 classquiz/socket_server/helpers.py create mode 100644 classquiz/socket_server/models.py diff --git a/classquiz/db/models.py b/classquiz/db/models.py index 57ab111..c0ab082 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -1,16 +1,26 @@ # SPDX-FileCopyrightText: 2023 Marlon W (Mawoka) # # SPDX-License-Identifier: MPL-2.0 +from __future__ import annotations import os import uuid from datetime import datetime -from typing import Optional +from typing import Optional, Self +from classquiz.config import redis +import json import ormar from ormar import ReferentialAction -from pydantic import BaseModel, Json, field_validator, ConfigDict, RootModel, ValidationInfo +from pydantic import ( + BaseModel, + Json, + field_validator, + ConfigDict, + RootModel, + ValidationInfo, +) from enum import Enum from . import metadata, database from .quiztivity import QuizTivityPage @@ -242,11 +252,34 @@ class PlayGame(BaseModel): custom_field: str | None = None question_show: bool = False + @classmethod + async def get_from_redis(self, game_pin: str) -> Self: + redis_data: str = await redis.get(f"game:{game_pin}") + data = self.model_validate_json(redis_data) + return data + + async def save(self, game_pin: str, ex: int = 7200): + await redis.set(f"game:{game_pin}", self.model_dump_json(), ex=ex) + + def to_player_data(self) -> dict: + return ( + { + **json.loads(self.model_dump_json(exclude={"quiz_id", "questions", "user_id"})), + "question_count": len(self.questions), + }, + ) + class GamePlayer(BaseModel): username: str sid: str | None = None + async def to_player_stack(self, game_pin: str): + await redis.sadd( + f"game_session:{game_pin}:players", + self.model_dump_json(), + ) + class GameAnswer2(BaseModel): username: str @@ -265,6 +298,18 @@ class GameSession(BaseModel): # players: list[GamePlayer | None] answers: list[GameAnswer1 | None] + @classmethod + async def get_from_redis(self, game_pin: str) -> Self: + redis_data = await redis.get(f"game_session:{game_pin}") + return self.model_validate_json(redis_data) + + async def save(self, game_pin: str, ex: int = 7200): + await redis.set( + f"game_session:{game_pin}", + self.model_dump_json(), + ex=7200, + ) + class UpdatePassword(BaseModel): old_password: str @@ -294,6 +339,14 @@ class AnswerDataList(RootModel): def __len__(self) -> int: return len(self.root) + @classmethod + async def get_redis_or_empty(self, game_pin: str, question_number: str) -> Self: + redis_res = await redis.get(f"game_session:{game_pin}:{question_number}") + if redis_res is None: + return self([]) + else: + return self.model_validate_json(redis_res) + class GameInLobby(BaseModel): game_pin: str diff --git a/classquiz/socket_server/__init__.py b/classquiz/socket_server/__init__.py index 755ce80..73301a1 100644 --- a/classquiz/socket_server/__init__.py +++ b/classquiz/socket_server/__init__.py @@ -9,7 +9,6 @@ import json import os import random -import aiohttp import socketio from cryptography.fernet import Fernet @@ -19,14 +18,24 @@ from classquiz.db.models import ( QuizQuestionType, GameSession, GamePlayer, - QuizQuestion, VotingQuizAnswer, AnswerDataList, AnswerData, ) -from pydantic import BaseModel, ValidationError, field_validator, ValidationInfo +from pydantic import BaseModel, ValidationError from datetime import datetime +from classquiz.socket_server.helpers import check_answer, check_captcha +from .models import ( + RejoinGameData, + JoinGameData, + ReturnQuestion, + SubmitAnswerData, + RegisterAsAdminData, + KickPlayerInput, + ConnectSessionIdEvent, +) + from classquiz.socket_server.export_helpers import save_quiz_to_storage from classquiz.socket_server.session import get_session, save_session @@ -74,19 +83,6 @@ async def set_answer(answers, game_pin: str, q_index: int, data: AnswerData) -> return answers -class _JoinGameData(BaseModel): - username: str - game_pin: str - captcha: str | None = None - custom_field: str | None = None - - -class _RejoinGameData(BaseModel): - old_sid: str - game_pin: str - username: str - - @sio.event async def rejoin_game(sid: str, data: dict): redis_res = await redis.get(f"game:{data['game_pin']}") @@ -94,7 +90,7 @@ async def rejoin_game(sid: str, data: dict): await sio.emit("game_not_found", room=sid) return try: - data = _RejoinGameData(**data) + data = RejoinGameData(**data) except ValidationError as e: await sio.emit("error", room=sid) print(e) @@ -106,10 +102,12 @@ async def rejoin_game(sid: str, data: dict): await sio.emit("time_sync", encrypted_datetime, room=sid) await redis.set(redis_sid_key, sid) await redis.srem( - f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=data.old_sid).model_dump_json() + f"game_session:{data.game_pin}:players", + GamePlayer(username=data.username, sid=data.old_sid).model_dump_json(), ) await redis.sadd( - f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).model_dump_json() + f"game_session:{data.game_pin}:players", + GamePlayer(username=data.username, sid=sid).model_dump_json(), ) game_data = PlayGame.model_validate_json(redis_res) session = { @@ -122,10 +120,7 @@ async def rejoin_game(sid: str, data: dict): await sio.enter_room(sid, data.game_pin) await sio.emit( "rejoined_game", - { - **json.loads(game_data.model_dump_json(exclude={"quiz_id", "questions", "user_id"})), - "question_count": len(game_data.questions), - }, + game_data.to_player_data(), room=sid, ) @@ -137,7 +132,7 @@ async def join_game(sid: str, data: dict): await sio.emit("game_not_found", room=sid) return try: - data = _JoinGameData(**data) + data = JoinGameData(**data) except ValidationError as e: await sio.emit("error", room=sid) print(e) @@ -148,42 +143,9 @@ async def join_game(sid: str, data: dict): return # +++ START checking captcha +++ if game_data.captcha_enabled: - async with aiohttp.ClientSession() as session: - try: - if settings.hcaptcha_key is not None: - try: - async with session.post( - "https://hcaptcha.com/siteverify", - data={ - "response": data.captcha, - "secret": settings.hcaptcha_key, - }, - ) as resp: - resp_data = await resp.model_dump_json() - if not resp_data["success"]: - print("CAPTCHA FAILED") - return - except KeyError: - print("CAPTCHA FAILED") - return - elif settings.recaptcha_key is not None: - async with session.post( - "https://www.google.com/recaptcha/api/siteverify", - data={ - "secret": settings.recaptcha_key, - "response": data.captcha, - }, - ) as resp: - try: - resp_data = await resp.model_dump_json() - if not resp_data["success"]: - print("CAPTCHA FAILED") - return - except KeyError: - print("CAPTCHA FAILED") - return - except TypeError: - pass + captcha_res = check_captcha(data.captcha) + if not captcha_res: + return # --- END checking captcha --- if await redis.get(f"game_session:{data.game_pin}:players:{data.username}") is not None: await sio.emit("username_already_exists", room=sid) @@ -198,18 +160,12 @@ async def join_game(sid: str, data: dict): await save_session(sid, sio, session) await sio.emit( "joined_game", - { - **json.loads(game_data.model_dump_json(exclude={"quiz_id", "questions", "user_id"})), - "question_count": len(game_data.questions), - }, + game_data.to_player_data(), room=sid, ) - redis_res = await redis.get(f"game_session:{data.game_pin}") - redis_res = GameSession.model_validate_json(redis_res) await redis.set(f"game_session:{data.game_pin}:players:{data.username}", sid, ex=7200) - await redis.sadd( - f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).model_dump_json() - ) + await GamePlayer(username=data.username, sid=sid).to_player_stack(data.game_pin) + if data.custom_field == "": data.custom_field = None if data.custom_field is not None: @@ -236,49 +192,36 @@ async def start_game(sid: str, _data: dict): session = await get_session(sid, sio) if not session["admin"]: return - game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}")) + game_data = await PlayGame.get_from_redis(session["game_pin"]) game_data.started = True - await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json(), ex=7200) + await game_data.save(session["game_pin"]) await redis.delete(f"game_in_lobby:{game_data.user_id.hex}") await sio.emit("start_game", room=session["game_pin"]) -class _RegisterAsAdminData(BaseModel): - game_pin: str - game_id: str - - @sio.event async def register_as_admin(sid: str, data: dict): try: - data = _RegisterAsAdminData(**data) + data = RegisterAsAdminData(**data) except ValidationError as e: await sio.emit("error", room=sid) print(e) return game_pin = data.game_pin game_id = data.game_id - if (await redis.get(f"game_session:{game_pin}")) is None: - await redis.set( - f"game_session:{game_pin}", - GameSession(admin=sid, game_id=game_id, answers=[]).model_dump_json(), - ex=7200, - ) - - await sio.emit( - "registered_as_admin", - {"game_id": game_id, "game": await redis.get(f"game:{game_pin}")}, - room=sid, - ) - session = {} - session["game_pin"] = game_pin - session["admin"] = True - session["remote"] = False - await save_session(sid, sio, session) - await sio.enter_room(sid, game_pin) - await sio.enter_room(sid, f"admin:{data.game_pin}") - else: + if await redis.get(f"game_session:{game_pin}") is not None: await sio.emit("already_registered_as_admin", room=sid) + return + GameSession(admin=sid, game_id=game_id, answers=[]).save(game_pin) + await sio.emit( + "registered_as_admin", + {"game_id": game_id, "game": await redis.get(f"game:{game_pin}")}, + room=sid, + ) + session = {"game_pin": game_pin, "admin": True, "remote": False} + await save_session(sid, sio, session) + await sio.enter_room(sid, game_pin) + await sio.enter_room(sid, f"admin:{data.game_pin}") @sio.event @@ -286,57 +229,25 @@ async def get_question_results(sid: str, data: dict): session = await get_session(sid, sio) if not session["admin"]: return - - redis_res = await redis.get(f"game_session:{session['game_pin']}:{data['question_number']}") - if redis_res is None: - redis_res = [] - else: - redis_res = AnswerDataList.model_validate_json(redis_res).model_dump() - game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}")) - game_data.question_show = False - await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json()) game_pin = session["game_pin"] - - await sio.emit("question_results", redis_res, room=game_pin) - - -class ABCDQuizAnswerWithoutSolution(BaseModel): - answer: str - color: str | None = None - - -class RangeQuizAnswerWithoutSolution(BaseModel): - min: int - max: int - - -class ReturnQuestion(QuizQuestion): - answers: list[ABCDQuizAnswerWithoutSolution] | RangeQuizAnswerWithoutSolution | list[VotingQuizAnswer] - type: QuizQuestionType = QuizQuestionType.ABCD - - @field_validator("answers") - def answers_not_none_if_abcd_type(cls, v, info: ValidationInfo): - if info.data["type"] == QuizQuestionType.ABCD and type(v[0]) is not ABCDQuizAnswerWithoutSolution: - raise ValueError("Answers can't be none if type is ABCD") - if info.data["type"] == QuizQuestionType.RANGE and type(v) is not RangeQuizAnswerWithoutSolution: - raise ValueError("Answer must be from type RangeQuizAnswer if type is RANGE") - # skipcq: PTC-W0047 - if info.data["type"] == QuizQuestionType.VOTING and type(v[0]) is not VotingQuizAnswer: - pass - return v + answer_data_list = await AnswerDataList.get_redis_or_empty(game_pin, data["question_number"]) + game_data = await PlayGame.get_from_redis(game_pin) + game_data.question_show = False + await game_data.save(game_pin) + await sio.emit("question_results", answer_data_list.model_dump(), room=game_pin) @sio.event -async def set_question_number(sid, data: str): +async def set_question_number(sid: str, data: str): # data is just a number (as a str) of the question session = await get_session(sid, sio) if not session["admin"]: return game_pin = session["game_pin"] - game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}")) + game_data = await PlayGame.get_from_redis(session["game_pin"]) game_data.current_question = int(float(data)) game_data.question_show = True - await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json(), ex=7200) + game_data.save(session["game_pin"]) await redis.set(f"game:{session['game_pin']}:current_time", datetime.now().isoformat(), ex=7200) temp_return = game_data.model_dump(include={"questions"})["questions"][int(float(data))] if game_data.questions[int(float(data))].type == QuizQuestionType.SLIDE: @@ -364,80 +275,21 @@ async def set_question_number(sid, data: str): ) -class _SubmitAnswerDataOrderType(BaseModel): - answer: str - - -class _SubmitAnswerData(BaseModel): - question_index: int - answer: str | int - complex_answer: list[_SubmitAnswerDataOrderType] | None = None - - @sio.event async def submit_answer(sid: str, data: dict): now = datetime.now() try: - data = _SubmitAnswerData(**data) + data = SubmitAnswerData(**data) except ValidationError as e: await sio.emit("error", room=sid) print(e) return data.answer = str(data.answer) session = await get_session(sid, sio) - game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}")) - answer_right = False - if game_data.questions[int(float(data.question_index))].type == QuizQuestionType.ABCD: - for answer in game_data.questions[int(float(data.question_index))].answers: - if answer.answer == data.answer and answer.right: - answer_right = True - break - elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.RANGE: - if ( - game_data.questions[int(float(data.question_index))].answers.min_correct - <= int(float(data.answer)) - <= game_data.questions[int(float(data.question_index))].answers.max_correct - ): - answer_right = True - elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.VOTING: - answer_right = False - elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.ORDER: - if data.complex_answer is None: - answer_right = False - else: - question = game_data.questions[int(float(data.question_index))] - correct_answers = [] - for a in question.answers: - correct_answers.append({"answer": a.answer}) - answer_order = [] - for a in data.model_dump()["complex_answer"]: - answer_order.append(a["answer"]) - data.answer = ", ".join(answer_order) - if correct_answers == data.model_dump()["complex_answer"]: - answer_right = True - - elif game_data.questions[int(float(data.question_index))].type == QuizQuestionType.TEXT: - answer_right = False - for q in game_data.questions[int(float(data.question_index))].answers: - if q.case_sensitive: - if data.answer == q.answer: - answer_right = True - break - else: - if data.answer.lower() == q.answer.lower(): - answer_right = True - break - elif game_data.questions[int(data.question_index)].type == QuizQuestionType.CHECK: - correct_string = "" - for i, a in enumerate(game_data.questions[int(float(data.question_index))].answers): - if a.right: - correct_string += str(i) - answer_right = bool(correct_string == data.answer) - else: - raise NotImplementedError - latency = int(float((await get_session(sid, sio))["ping"])) + game_data = await PlayGame.get_from_redis(session["game_pin"]) + (answer_right, answer) = check_answer(game_data, data) + latency = int(float(session["ping"])) time_q_started = datetime.fromisoformat(await redis.get(f"game:{session['game_pin']}:current_time")) - diff = (time_q_started - now).total_seconds() * 1000 # - timedelta(milliseconds=latency) score = 0 if answer_right: @@ -450,7 +302,7 @@ async def submit_answer(sid: str, data: dict): await redis.hincrby(f"game_session:{session['game_pin']}:player_scores", session["username"], score) answer_data = AnswerData( username=session["username"], - answer=data.answer, + answer=answer, right=answer_right, time_taken=abs(diff) - latency, score=score, @@ -465,26 +317,18 @@ async def submit_answer(sid: str, data: dict): player_count = await redis.scard(f"game_session:{session['game_pin']}:players") await sio.emit("player_answer", {}) if len(answers) == player_count: - # await sio.emit( - # "question_results", - # await redis.get(f"game_session:{session['game_pin']}:{data.question_index}"), - # room=session["game_pin"], - # ) - game_data = PlayGame.model_validate_json(await redis.get(f"game:{session['game_pin']}")) + game_data = await PlayGame.get_from_redis(session["game_pin"]) game_data.question_show = False - await redis.set(f"game:{session['game_pin']}", game_data.model_dump_json()) + await game_data.save(session["game_pin"]) await sio.emit("everyone_answered", {}) -# await redis.set(f"game_data:{session['game_pin']}", json.dumps(data)) - - @sio.event async def get_final_results(sid: str, _data: dict): session: dict = await get_session(sid, sio) - game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}"))) if not session["admin"]: return + game_data = await PlayGame.get_from_redis(session["game_pin"]) results = await generate_final_results(game_data, session["game_pin"]) await sio.emit("final_results", results, room=session["game_pin"]) @@ -494,7 +338,7 @@ async def get_export_token(sid: str): session = await get_session(sid, sio) if not session["admin"]: return - game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}"))) + game_data = await PlayGame.get_from_redis(session["game_pin"]) results = await generate_final_results(game_data, session["game_pin"]) token = os.urandom(32).hex() await redis.set(f"export_token:{token}", json.dumps(results), ex=7200) @@ -504,10 +348,14 @@ async def get_export_token(sid: str): @sio.event async def show_solutions(sid: str, _data: dict): session: dict = await get_session(sid, sio) - game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}"))) + game_data = await PlayGame.get_from_redis(session["game_pin"]) if not session["admin"]: return - await sio.emit("solutions", game_data.questions[game_data.current_question].model_dump(), room=session["game_pin"]) + await sio.emit( + "solutions", + game_data.questions[game_data.current_question].model_dump(), + room=session["game_pin"], + ) @sio.event @@ -521,14 +369,10 @@ async def echo_time_sync(sid: str, data: str): await save_session(sid, sio, session) -class _KickPlayerInput(BaseModel): - username: str - - @sio.event async def kick_player(sid: str, data: dict): try: - data = _KickPlayerInput(**data) + data = KickPlayerInput(**data) except ValidationError as e: await sio.emit("error", room=sid) print(e) @@ -604,10 +448,6 @@ async def save_quiz(sid: str): await sio.emit("results_saved_successfully") -class ConnectSessionIdEvent(BaseModel): - session_id: str - - @sio.event async def connect(sid: str, _environ, _auth): session_id = os.urandom(16).hex() diff --git a/classquiz/socket_server/helpers.py b/classquiz/socket_server/helpers.py new file mode 100644 index 0000000..e79fbdf --- /dev/null +++ b/classquiz/socket_server/helpers.py @@ -0,0 +1,133 @@ +# SPDX-FileCopyrightText: 2025 Marlon W (Mawoka) +# +# SPDX-License-Identifier: MPL-2.0 +import aiohttp +from classquiz.config import settings +from classquiz.db.models import ( + PlayGame, + QuizQuestionType, + TextQuizAnswer, + ABCDQuizAnswer, + VotingQuizAnswer, + RangeQuizAnswer, +) +from classquiz.socket_server.models import SubmitAnswerData +from .models import SubmitAnswerDataOrderType + + +async def check_captcha(captcha_data: str) -> bool: + async with aiohttp.ClientSession() as session: + try: + if settings.hcaptcha_key is not None: + try: + async with session.post( + "https://hcaptcha.com/siteverify", + data={ + "response": captcha_data, + "secret": settings.hcaptcha_key, + }, + ) as resp: + resp_data = await resp.model_dump_json() + if not resp_data["success"]: + print("CAPTCHA FAILED") + return + except KeyError: + return False + elif settings.recaptcha_key is not None: + async with session.post( + "https://www.google.com/recaptcha/api/siteverify", + data={ + "secret": settings.recaptcha_key, + "response": captcha_data, + }, + ) as resp: + try: + resp_data = await resp.model_dump_json() + if not resp_data["success"]: + return False + except KeyError: + return False + except TypeError: + pass + return False + return True + + +def check_answer(game_data: PlayGame, data: SubmitAnswerData) -> (bool, str): + q_i = int(float(data.question_index)) + q_type = game_data.questions[q_i].type + q_answers = game_data.questions[q_i].answers + q_answer = data.answer + if q_type == QuizQuestionType.ABCD: + return (check_abcd_question, data.answer) + elif q_type == QuizQuestionType.RANGE: + return ( + check_range_question(q_answer, q_answers), + q_answer, + ) + elif q_type == QuizQuestionType.VOTING: + return (False, q_answer) + elif q_type == QuizQuestionType.ORDER: + return check_order_question(q_answer, q_answers) + elif q_type == QuizQuestionType.TEXT: + return ( + check_text_question(q_answer, q_answers), + q_answer, + ) + + elif q_type == QuizQuestionType.CHECK: + return ( + check_check_question(q_answer, q_answers), + q_answer, + ) + else: + return (False, q_answer) + return (False, q_answer) + + +def check_abcd_question(answer: str, answers: ABCDQuizAnswer) -> bool: + for a in answers: + if a.answer == answer and a.right: + return True + return False + + +def check_range_question(answer: str, answers: RangeQuizAnswer) -> bool: + if answers.min_correct <= int(float(answer)) <= answers.max_correct: + return answers.min_correct <= int(float(answer)) <= answers.max_correct + + +def check_order_question( + complex_answer: list[SubmitAnswerDataOrderType] | None, + answer: str, + answers: list[VotingQuizAnswer], +) -> (bool, str): + if complex_answer is None: + return (False, answer) + correct_answers = [] + for a in answers: + correct_answers.append({"answer": a.answer}) + answer_order = [] + for a in complex_answer.model_dump(): + answer_order.append(a["answer"]) + answer = ", ".join(answer_order) + return (correct_answers == complex_answer.model_dump(), answer) + + +def check_text_question(answer: str, answers: list[TextQuizAnswer]) -> bool: + for q in answers: + if q.case_sensitive: + if answer == q.answer: + return True + else: + if answer.lower() == q.answer.lower(): + return True + return False + + +def check_check_question(answer: str, answers: list[ABCDQuizAnswer]) -> bool: + correct_string = "" + for i, a in enumerate(answers): + if a.right: + correct_string += str(i) + return bool(correct_string == answer) diff --git a/classquiz/socket_server/models.py b/classquiz/socket_server/models.py new file mode 100644 index 0000000..4bae14a --- /dev/null +++ b/classquiz/socket_server/models.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025 Marlon W (Mawoka) +# +# SPDX-License-Identifier: MPL-2.0 + +from pydantic import BaseModel, field_validator, ValidationInfo +from classquiz.db.models import QuizQuestion, QuizQuestionType, VotingQuizAnswer + + +class JoinGameData(BaseModel): + username: str + game_pin: str + captcha: str | None = None + custom_field: str | None = None + + +class RejoinGameData(BaseModel): + old_sid: str + game_pin: str + username: str + + +class RegisterAsAdminData(BaseModel): + game_pin: str + game_id: str + + +class ABCDQuizAnswerWithoutSolution(BaseModel): + answer: str + color: str | None = None + + +class RangeQuizAnswerWithoutSolution(BaseModel): + min: int + max: int + + +class ReturnQuestion(QuizQuestion): + answers: list[ABCDQuizAnswerWithoutSolution] | RangeQuizAnswerWithoutSolution | list[VotingQuizAnswer] + type: QuizQuestionType = QuizQuestionType.ABCD + + @field_validator("answers") + def answers_not_none_if_abcd_type(cls, v, info: ValidationInfo): + if info.data["type"] == QuizQuestionType.ABCD and type(v[0]) is not ABCDQuizAnswerWithoutSolution: + raise ValueError("Answers can't be none if type is ABCD") + if info.data["type"] == QuizQuestionType.RANGE and type(v) is not RangeQuizAnswerWithoutSolution: + raise ValueError("Answer must be from type RangeQuizAnswer if type is RANGE") + # skipcq: PTC-W0047 + if info.data["type"] == QuizQuestionType.VOTING and type(v[0]) is not VotingQuizAnswer: + pass + return v + + +class SubmitAnswerDataOrderType(BaseModel): + answer: str + + +class SubmitAnswerData(BaseModel): + question_index: int + answer: str | int + complex_answer: list[SubmitAnswerDataOrderType] | None = None + + +class KickPlayerInput(BaseModel): + username: str + + +class ConnectSessionIdEvent(BaseModel): + session_id: str diff --git a/frontend/src/lib/components/commandpalette.svelte b/frontend/src/lib/components/commandpalette.svelte index 5bffadd..eb47cf2 100644 --- a/frontend/src/lib/components/commandpalette.svelte +++ b/frontend/src/lib/components/commandpalette.svelte @@ -240,6 +240,7 @@ This should be okay, right? class="fixed top-0 left-0 w-screen h-screen flex bg-black/50 z-50" onclick={close_on_outside} onkeyup={close_on_outside} + role="generic" transition:fade|global={{ duration: 60 }} >
@@ -253,6 +254,7 @@ This should be okay, right? type="text" class="col-start-1 row-start-1 bg-transparent w-full p-4 outline-hidden bg-gray-700 rounded-sm" bind:value={input} + autofocus />
@@ -264,6 +266,8 @@ This should be okay, right? class:bg-gray-700={selected !== i} onmouseenter={() => (selected = i)} onmousedown={execute_action} + tabindex="-2" + role="button" >

{vi.title}

diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index db1ab13..1a3a14d 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -108,7 +108,7 @@ SPDX-License-Identifier: MPL-2.0
{/if} - +{@render children?.()} @@ -39,7 +35,7 @@ SPDX-License-Identifier: MPL-2.0 class:cursor-not-allowed={disabled} class:pointer-events-none={disabled} class="text-black hover:bg-bg-[#B07156]/80 w-full px-4 py-2 leading-5 transition-all duration-200 transform bg-[#B07156] rounded-sm text-center outline-hidden hover:cursor-pointer" - onclick={bubble('click')} + {onclick} class:flex class:justify-center={flex} > @@ -50,7 +46,7 @@ SPDX-License-Identifier: MPL-2.0 {disabled} {type} class="text-black hover:cursor-pointer hover:opacity-80 w-full px-4 py-2 leading-5 transition-all duration-200 transform bg-[#B07156] rounded-sm text-center focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 outline-hidden" - onclick={bubble('click')} + {onclick} class:flex class:justify-center={flex} > diff --git a/frontend/src/lib/components/buttons/gray.svelte b/frontend/src/lib/components/buttons/gray.svelte index 65705a6..3d94a99 100644 --- a/frontend/src/lib/components/buttons/gray.svelte +++ b/frontend/src/lib/components/buttons/gray.svelte @@ -5,16 +5,13 @@ SPDX-License-Identifier: MPL-2.0 --> @@ -31,7 +29,7 @@ SPDX-License-Identifier: MPL-2.0 {href} {target} class="w-full px-4 py-2 leading-5 text-black dark:text-white transition-colors duration-200 transform bg-gray-50 dark:bg-gray-700 rounded-sm text-center hover:bg-gray-300 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-gray-600" - onclick={bubble('click')} + {onclick} class:flex class:block={!flex} class:justify-center={flex} @@ -42,7 +40,7 @@ SPDX-License-Identifier: MPL-2.0
{/await} {/key} @@ -287,7 +289,7 @@ SPDX-License-Identifier: MPL-2.0 />
- (advanced_options_open = false)} + (advanced_options_open = false)} >{$t('words.close')}
diff --git a/frontend/src/lib/editor/sidebar.svelte b/frontend/src/lib/editor/sidebar.svelte index 96f45ef..7c0b021 100644 --- a/frontend/src/lib/editor/sidebar.svelte +++ b/frontend/src/lib/editor/sidebar.svelte @@ -76,7 +76,7 @@ SPDX-License-Identifier: MPL-2.0
- (reorder_mode = !reorder_mode)} + (reorder_mode = !reorder_mode)} >{#if reorder_mode}{$t('editor.disable_reorder')}{:else}{$t( 'editor.enable_reorder' )}{/if}
{ + onclick={() => { selected_type = AvailableUploadTypes.Image; }} >{$t('words.image')} @@ -159,7 +159,7 @@ SPDX-License-Identifier: MPL-2.0
{ + onclick={() => { selected_type = AvailableUploadTypes.Video; }} >{$t('words.video')} @@ -168,7 +168,7 @@ SPDX-License-Identifier: MPL-2.0 {#if library_enabled}
{ + onclick={() => { selected_type = AvailableUploadTypes.Library; }} >{$t('words.library')} @@ -177,7 +177,7 @@ SPDX-License-Identifier: MPL-2.0 {/if}
{ + onclick={() => { selected_type = AvailableUploadTypes.Pixabay; }} >Pixabay @@ -202,7 +202,7 @@ SPDX-License-Identifier: MPL-2.0 {$t('uploader.upload_video_popup_notice')}

{:else} - {$t('uploader.upload_video')} {/if} diff --git a/frontend/src/lib/editor/uploader/Library.svelte b/frontend/src/lib/editor/uploader/Library.svelte index c423be1..955e230 100644 --- a/frontend/src/lib/editor/uploader/Library.svelte +++ b/frontend/src/lib/editor/uploader/Library.svelte @@ -58,7 +58,7 @@ SPDX-License-Identifier: MPL-2.0

{image.filename ?? 'No name available'}

{ + onclick={() => { set_image(image.id); }}>{$t('words.select')} diff --git a/frontend/src/lib/editor/uploader/Pixabay.svelte b/frontend/src/lib/editor/uploader/Pixabay.svelte index 8827ceb..50c351e 100644 --- a/frontend/src/lib/editor/uploader/Pixabay.svelte +++ b/frontend/src/lib/editor/uploader/Pixabay.svelte @@ -93,7 +93,7 @@ SPDX-License-Identifier: MPL-2.0 />
{ + onclick={() => { set_image(image.id); }}>{$t('words.select')} @@ -102,14 +102,14 @@ SPDX-License-Identifier: MPL-2.0
{ + onclick={() => { page -= 1; fetched_data = fetch_data(); }} >{$t('uploader.previous_page')} { + onclick={() => { page += 1; fetched_data = fetch_data(); }}>{$t('uploader.next_page')} { + onclick={() => { socket.emit('start_game', ''); }} >{$t('admin_page.start_game')} diff --git a/frontend/src/lib/play/join.svelte b/frontend/src/lib/play/join.svelte index 01401f1..9b26051 100644 --- a/frontend/src/lib/play/join.svelte +++ b/frontend/src/lib/play/join.svelte @@ -25,7 +25,11 @@ SPDX-License-Identifier: MPL-2.0 username: any; } - let { game_pin = $bindable(), game_mode = $bindable(), username = $bindable() }: Props = $props(); + let { + game_pin = $bindable(), + game_mode = $bindable(), + username = $bindable() + }: Props = $props(); let custom_field = $state(); let custom_field_value = $state(); let captcha_enabled = $state(); @@ -207,7 +211,10 @@ SPDX-License-Identifier: MPL-2.0 {#if game_pin === '' || game_pin.length < 6}
-
+

{$t('words.game_pin')}

- {$t('words.submit')}
diff --git a/frontend/src/lib/play/question.svelte b/frontend/src/lib/play/question.svelte index 17a8568..4722a6a 100644 --- a/frontend/src/lib/play/question.svelte +++ b/frontend/src/lib/play/question.svelte @@ -82,9 +82,6 @@ SPDX-License-Identifier: MPL-2.0 }); const selectAnswer = (answer: string) => { - if (selected_answer !== undefined) { - return; - } selected_answer = answer; //timer_res = '0'; socket.emit('submit_answer', { @@ -244,9 +241,7 @@ SPDX-License-Identifier: MPL-2.0
- selectAnswer(slider_value[0])} + selectAnswer(slider_value[0])} >{$t('words.submit')}
@@ -274,8 +269,8 @@ SPDX-License-Identifier: MPL-2.0
{ + disabled={!text_input || text_input.length === 0} + onclick={() => { selectAnswer(text_input); }} > @@ -370,8 +365,8 @@ SPDX-License-Identifier: MPL-2.0
{ + disabled={selected_answer} + onclick={() => { select_complex_answer(question.answers); }}>{$t('words.submit')} @@ -383,17 +378,18 @@ SPDX-License-Identifier: MPL-2.0 {:then c}
selectAnswer(selected_answer)} + type="button" + disabled={selected_answer === undefined} + onclick={() => selectAnswer(selected_answer)} >{$t('words.submit')}
diff --git a/frontend/src/lib/play/questions/check.svelte b/frontend/src/lib/play/questions/check.svelte index 5e5dd4d..c8feb2d 100644 --- a/frontend/src/lib/play/questions/check.svelte +++ b/frontend/src/lib/play/questions/check.svelte @@ -12,7 +12,6 @@ SPDX-License-Identifier: MPL-2.0 // import CircularTimer from '$lib/play/circular_progress.svelte'; const default_colors = ['#D6EDC9', '#B07156', '#7F7057', '#4E6E58']; - interface Props { question: Question; selected_answer?: string; @@ -23,10 +22,10 @@ SPDX-License-Identifier: MPL-2.0 let { question, - selected_answer = $bindable(''), + selected_answer = $bindable(), game_mode, - timer_res = $bindable(), - circular_progress = $bindable() + timer_res, + circular_progress }: Props = $props(); let _selected_answers = $state([false, false, false, false]); @@ -58,7 +57,7 @@ SPDX-License-Identifier: MPL-2.0
- +
diff --git a/frontend/src/lib/practice/question.svelte b/frontend/src/lib/practice/question.svelte index 032526e..b49e0e8 100644 --- a/frontend/src/lib/practice/question.svelte +++ b/frontend/src/lib/practice/question.svelte @@ -45,7 +45,10 @@ SPDX-License-Identifier: MPL-2.0 if (question.type === QuizQuestionType.RANGE) { slider_value[0] = (question.answers.max - question.answers.min) / 2 + question.answers.min; } - let slider_values = $state([question.answers.min_correct ?? 0, question.answers.max_correct ?? 0]); + let slider_values = $state([ + question.answers.min_correct ?? 0, + question.answers.max_correct ?? 0 + ]); let text_input = $state(); timer(question.time); @@ -347,7 +350,7 @@ SPDX-License-Identifier: MPL-2.0 {/each} { + onclick={() => { timer_res = '0'; }}>{$t('words.submit')} diff --git a/frontend/src/lib/quiztivity/components/abcd/edit.svelte b/frontend/src/lib/quiztivity/components/abcd/edit.svelte index 99d31d8..ed3e1f3 100644 --- a/frontend/src/lib/quiztivity/components/abcd/edit.svelte +++ b/frontend/src/lib/quiztivity/components/abcd/edit.svelte @@ -84,7 +84,7 @@ SPDX-License-Identifier: MPL-2.0 {#if data.answers.length < 4}
{ + onclick={() => { data.answers = [...data.answers, { ...{ answer: '', correct: false } }]; }}>{$t('editor_page.add_an_answer')} diff --git a/frontend/src/lib/quiztivity/components/memory/edit.svelte b/frontend/src/lib/quiztivity/components/memory/edit.svelte index db9b5f4..6f75480 100644 --- a/frontend/src/lib/quiztivity/components/memory/edit.svelte +++ b/frontend/src/lib/quiztivity/components/memory/edit.svelte @@ -96,7 +96,7 @@ SPDX-License-Identifier: MPL-2.0
- {$t('quiztivity.memory.editor.add_pair')}
@@ -115,13 +115,13 @@ SPDX-License-Identifier: MPL-2.0
{ + onclick={() => { move_card_left(i); }} disabled={i === 0}>{$t('quiztivity.editor.move_left')} { + onclick={() => { move_card_right(i); }} disabled={i + 1 === data.cards.length} diff --git a/frontend/src/lib/quiztivity/editor.svelte b/frontend/src/lib/quiztivity/editor.svelte index 84dddf1..7401921 100644 --- a/frontend/src/lib/quiztivity/editor.svelte +++ b/frontend/src/lib/quiztivity/editor.svelte @@ -83,7 +83,7 @@ SPDX-License-Identifier: MPL-2.0 {#if data.id}
{ + onclick={() => { shares_menu_open = true; }}>{$t('quiztivity.editor.open_shares_menu')} @@ -99,7 +99,7 @@ SPDX-License-Identifier: MPL-2.0
{ + onclick={() => { dispatch('save'); }} disabled={!data.title}>{$t('words.save')}
{ + onclick={() => { selected_type = null; }}>{$t('quiztivity.editor.add_new')} - {$t('quiztivity.editor.delete')} {$t('quiztivity.editor.move_left')} {$t('quiztivity.editor.move_right')} @@ -140,7 +140,7 @@ SPDX-License-Identifier: MPL-2.0 class:opacity-0={selected_slide !== i} > { + onclick={() => { selected_slide = selected_slide === i ? null : i; }} > @@ -149,7 +149,7 @@ SPDX-License-Identifier: MPL-2.0 )}{/if} { + onclick={() => { opened_slide = i; }}>{$t('words.edit')} @@ -164,7 +164,7 @@ SPDX-License-Identifier: MPL-2.0
{ + onclick={() => { opened_slide = null; }}>{$t('words.back')} diff --git a/frontend/src/lib/quiztivity/shares_popover.svelte b/frontend/src/lib/quiztivity/shares_popover.svelte index a8807d8..07dd507 100644 --- a/frontend/src/lib/quiztivity/shares_popover.svelte +++ b/frontend/src/lib/quiztivity/shares_popover.svelte @@ -117,7 +117,7 @@ SPDX-License-Identifier: MPL-2.0 ? !navigator.canShare({ title: 'title', url: `${window.location.origin}/quiztivity` - }) + }) : false; } catch { return false; @@ -137,7 +137,7 @@ SPDX-License-Identifier: MPL-2.0 >
{ + onclick={() => { add_shares_open = !add_shares_open; }}>{$t('quiztivity.editor.shares.add_new_share')} @@ -175,7 +175,7 @@ SPDX-License-Identifier: MPL-2.0 { + onclick={() => { navigator.share({ title: 'Quiztivity on ClassQuiz', text: 'Play this Quiztivity now on ClassQuiz!', @@ -204,7 +204,7 @@ SPDX-License-Identifier: MPL-2.0
{ + onclick={() => { copyToClipboard( `${window.location.origin}/quiztivity/share/${share.id}?ref=copy` ); @@ -242,7 +242,7 @@ SPDX-License-Identifier: MPL-2.0

{ + onclick={() => { delete_share(share.id); }}>{$t('words.delete')} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index b4fc503..2003bc7 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -322,7 +322,7 @@ SPDX-License-Identifier: MPL-2.0
{#if quiz.type === 'quiz'} { + onclick={() => { start_game = quiz.id; }} flex={true} @@ -365,7 +365,7 @@ SPDX-License-Identifier: MPL-2.0 {/if} { + onclick={() => { deleteQuiz(quiz.id, quiz.type); }} flex={true} @@ -387,7 +387,7 @@ SPDX-License-Identifier: MPL-2.0
(download_id = quiz.id)} + onclick={() => (download_id = quiz.id)} flex={true} disabled={quiz.type !== 'quiz'} > diff --git a/frontend/src/routes/dashboard/Analytics.svelte b/frontend/src/routes/dashboard/Analytics.svelte index 202d2d9..ae1af6d 100644 --- a/frontend/src/routes/dashboard/Analytics.svelte +++ b/frontend/src/routes/dashboard/Analytics.svelte @@ -33,7 +33,7 @@ SPDX-License-Identifier: MPL-2.0 {#if quiz}
{ + onclick={() => { edit_popup = image; }} >{$t('file_dashboard.edit_details')} {#if image.quiztivities.length === 0 && image.quizzes.length === 0} { + onclick={() => { delete_image(image.id); }}>{$t('file_dashboard.delete_image')} diff --git a/frontend/src/routes/edit/videos/+page.svelte b/frontend/src/routes/edit/videos/+page.svelte index 8d79a97..71a010c 100644 --- a/frontend/src/routes/edit/videos/+page.svelte +++ b/frontend/src/routes/edit/videos/+page.svelte @@ -13,7 +13,7 @@ SPDX-License-Identifier: MPL-2.0 const { t } = getLocalization(); let file_input: HTMLInputElement = $state(); - navbarVisible.visible= false; + navbarVisible.visible = false; let stats: { progress: number; time_elapsed: number; speed: number } = $state({ progress: 0, @@ -197,7 +197,7 @@ SPDX-License-Identifier: MPL-2.0
- + {$t('words.upload')} {file_size_in_mi ? `(${file_size_in_mi.toFixed(2)} Mi)` : ''} @@ -208,7 +208,7 @@ SPDX-License-Identifier: MPL-2.0 >
- {$t('words.submit')}
diff --git a/frontend/src/routes/quiztivity/play/navigation_bar.svelte b/frontend/src/routes/quiztivity/play/navigation_bar.svelte index caa000f..757eea3 100644 --- a/frontend/src/routes/quiztivity/play/navigation_bar.svelte +++ b/frontend/src/routes/quiztivity/play/navigation_bar.svelte @@ -22,7 +22,7 @@ SPDX-License-Identifier: MPL-2.0
{ + onclick={() => { current_slide_index -= 1; }}>{$t('words.back')} @@ -33,7 +33,7 @@ SPDX-License-Identifier: MPL-2.0
{ + onclick={() => { current_slide_index += 1; }}>{$t('words.next')} diff --git a/frontend/src/routes/results/+page.svelte b/frontend/src/routes/results/+page.svelte index 01a25cb..7d9466e 100644 --- a/frontend/src/routes/results/+page.svelte +++ b/frontend/src/routes/results/+page.svelte @@ -43,7 +43,7 @@ SPDX-License-Identifier: MPL-2.0 {result.title}{@html result.title}
{/if} diff --git a/frontend/src/routes/results/[result_id]/general_overview.svelte b/frontend/src/routes/results/[result_id]/general_overview.svelte index 65df49c..46e81ae 100644 --- a/frontend/src/routes/results/[result_id]/general_overview.svelte +++ b/frontend/src/routes/results/[result_id]/general_overview.svelte @@ -10,30 +10,23 @@ SPDX-License-Identifier: MPL-2.0 const { t } = getLocalization(); - interface Props { questions: Question[]; answers: { - username: string; - answer: string; - right: boolean; - tike_taken: number; - score: number; - }[][]; + username: string; + answer: string; + right: boolean; + tike_taken: number; + score: number; + }[][]; scores: { - [key: string]: string; - }; + [key: string]: string; + }; title: string; timestamp: string; } - let { - questions, - answers, - scores, - title, - timestamp - }: Props = $props(); + let { questions, answers, scores, title, timestamp }: Props = $props(); const usernames = Object.keys(scores); @@ -49,7 +42,7 @@ SPDX-License-Identifier: MPL-2.0

- {$t('results_page.general_overview.sentence', { + {@html $t('results_page.general_overview.sentence', { title, date: new Date(timestamp).toLocaleString(), player_count: usernames.length, diff --git a/frontend/src/routes/results/[result_id]/player_overview.svelte b/frontend/src/routes/results/[result_id]/player_overview.svelte index fe73d6d..2e1f7e5 100644 --- a/frontend/src/routes/results/[result_id]/player_overview.svelte +++ b/frontend/src/routes/results/[result_id]/player_overview.svelte @@ -10,16 +10,29 @@ SPDX-License-Identifier: MPL-2.0 const { t } = getLocalization(); interface Props { scores: { - [key: string]: string; - }; + [key: string]: string; + }; custom_field: { - [key: string]: string; - }; + [key: string]: string; + }; + answers: { [key: string]: any }[]; } - let { scores, custom_field }: Props = $props(); + let { scores, custom_field, answers }: Props = $props(); let usernames = Object.keys(scores); + const correctCounts = {}; + answers.forEach((questionAnswers) => { + questionAnswers.forEach((answer) => { + const user = answer.username; + if (!correctCounts[user]) { + correctCounts[user] = 0; + } + if (answer.right) { + correctCounts[user] += 1; + } + }); + }); console.log(custom_field); @@ -31,6 +44,9 @@ SPDX-License-Identifier: MPL-2.0 {$t('result_page.player_name')} + {$t('result_page.player_correct_questions')} + {$t('result_page.player_score')} {#if Object.keys(custom_field).length !== 0} {uname} + {correctCounts[uname]} {scores[uname]} {#if custom_field[uname]} { + onclick={() => { start_game = quiz.id; }} flex={true} @@ -188,7 +188,7 @@ SPDX-License-Identifier: MPL-2.0

{#if logged_in} - (download_id = quiz.id)}> + (download_id = quiz.id)}>
- (mod_rating = null)}>Not Checked + (mod_rating = null)}>Not Checked
- (mod_rating = 0)}>Ok + (mod_rating = 0)}>Ok
- (mod_rating = 1)}>Attention + (mod_rating = 1)}>Attention
- (mod_rating = 2)}>NFSW + (mod_rating = 2)}>NFSW
- (mod_rating = 3)}>Plausibility Checked + (mod_rating = 3)}>Plausibility Checked
- (mod_rating = 4)}>Fact Checked + (mod_rating = 4)}>Fact Checked
- (mod_rating = 5)}>Exceptional + (mod_rating = 5)}>Exceptional
- Submit + Submit
From f4e3bb1529a9ed18874d53aae771891832684167 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Sun, 26 Oct 2025 09:00:37 +0100 Subject: [PATCH 12/46] Fix typo in Authlib cache for OAuth --- classquiz/oauth/init_oauth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classquiz/oauth/init_oauth.py b/classquiz/oauth/init_oauth.py index 91c6bcf..24f9539 100644 --- a/classquiz/oauth/init_oauth.py +++ b/classquiz/oauth/init_oauth.py @@ -29,7 +29,7 @@ class RedisCache: await redis.set(f"authlib:{key}", data, ex=expires) async def delete(self, key: str) -> None: - await redis.delete(key) + await redis.delete(f"authlib:{key}") @lru_cache() From 0c23d9b9c1169730a108576391f532e6cd82fe86 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Sun, 26 Oct 2025 09:08:22 +0100 Subject: [PATCH 13/46] Enable more translations in UI --- frontend/src/lib/i18n/i18n-service.ts | 7 + frontend/src/lib/language-toggle.svelte | 189 +++++++++++++----------- 2 files changed, 110 insertions(+), 86 deletions(-) diff --git a/frontend/src/lib/i18n/i18n-service.ts b/frontend/src/lib/i18n/i18n-service.ts index 8c7d442..3b85b3f 100644 --- a/frontend/src/lib/i18n/i18n-service.ts +++ b/frontend/src/lib/i18n/i18n-service.ts @@ -19,6 +19,9 @@ import uk from './locales/uk.json'; import nl from './locales/nl.json'; import hu from './locales/hu.json'; import vi from './locales/vi.json'; +import ta from './locales/ta.json'; +import pt_BR from './locales/pt_BR.json'; +import ja from './locales/ja.json'; // import uz from './locales/uz.json' // import zh_Hans from './locales/zh_Hans.json'; import LanguageDetector from 'i18next-browser-languagedetector'; @@ -82,6 +85,10 @@ export class I18nService { this.i18n.addResourceBundle('nl', 'translation', nl); this.i18n.addResourceBundle('hu', 'translation', hu); this.i18n.addResourceBundle('vi', 'translation', vi); + this.i18n.addResourceBundle('ta', 'translation', ta); + this.i18n.addResourceBundle('pt_BR', 'translation', pt_BR); + this.i18n.addResourceBundle('ja', 'translation', ja); + // this.i18n.addResourceBundle('uz', 'translation', uz); } diff --git a/frontend/src/lib/language-toggle.svelte b/frontend/src/lib/language-toggle.svelte index ec29e40..5432642 100644 --- a/frontend/src/lib/language-toggle.svelte +++ b/frontend/src/lib/language-toggle.svelte @@ -56,94 +56,111 @@ SPDX-License-Identifier: MPL-2.0 interface Props { languages?: Array<{ - flag: string; - name: string; - code: string; - }>; + flag: string; + name: string; + code: string; + }>; } - let { languages = [ - { - code: 'de', - name: 'Deutsch', - flag: '🇩🇪' - }, - { - code: 'en', - name: 'English', - flag: '🇺🇲' - }, - { - code: 'tr', - name: 'Türkçe', - flag: '🇹🇷' - }, - { - code: 'fr', - name: 'Français', - flag: '🇫🇷' - }, - { - code: 'id', - name: 'Bahasa Indonesia', - flag: '🇮🇩' - }, - { - code: 'ca', - name: 'Català', - flag: '🇪🇸' - }, - { - code: 'it', - name: 'Italiano', - flag: '🇮🇹' - }, - { - code: 'es', - name: 'Español', - flag: '🇪🇸' - }, - { - code: 'nb_NO', - name: 'Norsk', - flag: '🇳🇴' - }, - { - code: 'zh_Hant', - name: 'Chinese (traditional)', - flag: '🇨🇳' - }, - { - code: 'pl', - name: 'Polski', - flag: '🇵🇱' - }, - { - code: 'pt', - name: 'Português', - flag: '🇵🇹' - }, - { - code: 'uk', - name: 'Українська', - flag: '🇺🇦' - }, - { - code: 'nl', - name: 'Nederlands', - flag: '🇳🇱' - }, - { - code: 'hu', - name: 'Magyar', - flag: '🇭🇺' - }, - { - code: 'vi', - name: 'tiếng Việt', - flag: '🇻🇳' - } - ] }: Props = $props(); + let { + languages = [ + { + code: 'de', + name: 'Deutsch', + flag: '🇩🇪' + }, + { + code: 'en', + name: 'English', + flag: '🇺🇲' + }, + { + code: 'tr', + name: 'Türkçe', + flag: '🇹🇷' + }, + { + code: 'fr', + name: 'Français', + flag: '🇫🇷' + }, + { + code: 'id', + name: 'Bahasa Indonesia', + flag: '🇮🇩' + }, + { + code: 'ca', + name: 'Català', + flag: '🇪🇸' + }, + { + code: 'it', + name: 'Italiano', + flag: '🇮🇹' + }, + { + code: 'es', + name: 'Español', + flag: '🇪🇸' + }, + { + code: 'nb_NO', + name: 'Norsk', + flag: '🇳🇴' + }, + { + code: 'zh_Hant', + name: 'Chinese (traditional)', + flag: '🇨🇳' + }, + { + code: 'pl', + name: 'Polski', + flag: '🇵🇱' + }, + { + code: 'pt', + name: 'Português', + flag: '🇵🇹' + }, + { + code: 'uk', + name: 'Українська', + flag: '🇺🇦' + }, + { + code: 'nl', + name: 'Nederlands', + flag: '🇳🇱' + }, + { + code: 'hu', + name: 'Magyar', + flag: '🇭🇺' + }, + { + code: 'vi', + name: 'tiếng Việt', + flag: '🇻🇳' + }, + { + code: 'ta', + flag: '🇮🇳', + name: 'Tamil' + }, + { + code: 'pt_BR', + flag: '🇧🇷', + name: 'Brazil' + }, + { + code: 'ja', + flag: '🇯🇵', + name: 'Japan' + } + ] + }: Props = $props(); const get_selected_language = (): string => { return localStorage.getItem('language'); }; From 41fe3e3ad54f6625eef1ad0d33af9eceb91be9c6 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Sun, 26 Oct 2025 11:53:53 +0100 Subject: [PATCH 14/46] Further cleanup, mostly with svelte 5 migration placeholder cleanup --- classquiz/routers/results.py | 10 +- frontend/src/app.html | 1 - frontend/src/env.d.ts | 11 +- frontend/src/hooks.server.ts | 7 - frontend/src/lib/admin.svelte | 16 +-- .../src/lib/components/commandpalette.svelte | 18 +-- .../lib/components/popover/smalltop.svelte | 9 -- frontend/src/lib/editor.svelte | 44 +----- frontend/src/lib/editor/MediaComponent.svelte | 13 +- .../src/lib/editor/OrderEditorPart.svelte | 7 +- .../lib/editor/RangeSelectorEditorPart.svelte | 13 -- .../src/lib/editor/VotingEditorPart.svelte | 7 +- frontend/src/lib/editor/settings-card.svelte | 30 ++-- .../lib/editor/slides/settings_menu.svelte | 4 +- frontend/src/lib/editor/uploader.svelte | 14 +- .../src/lib/editor/uploader/Pixabay.svelte | 7 +- frontend/src/lib/i18n/i18n-service.ts | 13 -- frontend/src/lib/inline-editor.svelte | 35 ----- .../src/lib/inline-editor_new_trash.svelte | 103 -------------- frontend/src/lib/language-toggle.svelte | 49 +------ .../src/lib/play/admin/final_results.svelte | 43 +++--- .../lib/play/admin/game_not_started.svelte | 9 ++ frontend/src/lib/play/admin/question.svelte | 19 +-- frontend/src/lib/play/admin/results.svelte | 36 ++--- frontend/src/lib/play/admin/slide.svelte | 2 +- frontend/src/lib/play/audio_player.svelte | 6 +- .../src/lib/play/circular_progress.svelte | 21 +-- frontend/src/lib/play/end.svelte | 5 - frontend/src/lib/play/join.svelte | 32 ++--- frontend/src/lib/play/question.svelte | 64 ++------- frontend/src/lib/play/results_kahoot.svelte | 40 ++---- frontend/src/lib/play/show_results.svelte | 123 ----------------- frontend/src/lib/practice/title_screen.svelte | 13 +- frontend/src/lib/quiz_image_uncover.svelte | 5 - .../components/markdown/edit.svelte | 8 +- .../components/markdown/play.svelte | 10 +- frontend/src/lib/quiztivity/editor.svelte | 4 +- .../src/lib/quiztivity/shares_popover.svelte | 7 +- frontend/src/lib/search-card.svelte | 4 - frontend/src/routes/+layout.svelte | 63 +-------- frontend/src/routes/+layout.ts | 1 - frontend/src/routes/+page.server.ts | 3 +- frontend/src/routes/+page.svelte | 128 +++--------------- .../account/controllers/add/+page.svelte | 13 +- .../src/routes/account/login/+page.svelte | 17 +-- .../account/login/backup_component.svelte | 12 +- .../account/login/password_component.svelte | 7 +- .../routes/account/login/select_method.svelte | 4 +- .../routes/account/login/start_window.svelte | 13 +- .../account/login/totp_component.svelte | 17 +-- .../account/password-reset/+page.svelte | 19 +-- .../account/reset-password/+page.svelte | 7 +- .../src/routes/account/settings/+page.svelte | 25 ++-- .../account/settings/avatar/+page.svelte | 6 - frontend/src/routes/admin/+page.svelte | 28 ++-- frontend/src/routes/dashboard/+page.svelte | 19 --- frontend/src/routes/edit/files/+page.svelte | 7 +- .../src/routes/edit/files/uploader.svelte | 4 +- frontend/src/routes/import/+page.svelte | 41 ++---- frontend/src/routes/play/+page.svelte | 37 ++--- .../src/routes/quiztivity/play/+page.svelte | 7 +- frontend/src/routes/remote/+page.svelte | 37 +++-- frontend/src/routes/results/+page.ts | 4 +- .../routes/results/[result_id]/+page.svelte | 2 - .../src/routes/results/[result_id]/+page.ts | 7 +- .../[result_id]/general_overview.svelte | 10 +- .../[result_id]/player_overview.svelte | 1 - .../[result_id]/question_overview.svelte | 16 +-- ...ng.svelte => question_tab_dropdown.svelte} | 2 - frontend/src/routes/search/+page.svelte | 8 +- .../src/routes/user/[user_id]/+page.svelte | 17 +-- frontend/src/routes/user/[user_id]/+page.ts | 6 +- .../src/routes/view/[quiz_id]/+page.server.ts | 3 +- .../src/routes/view/[quiz_id]/+page.svelte | 10 +- 74 files changed, 309 insertions(+), 1154 deletions(-) delete mode 100644 frontend/src/lib/inline-editor_new_trash.svelte delete mode 100644 frontend/src/lib/play/show_results.svelte delete mode 100644 frontend/src/lib/quiz_image_uncover.svelte rename frontend/src/routes/results/[result_id]/{question_tab_thing.svelte => question_tab_dropdown.svelte} (99%) diff --git a/classquiz/routers/results.py b/classquiz/routers/results.py index 2fcd4f4..4c481b0 100644 --- a/classquiz/routers/results.py +++ b/classquiz/routers/results.py @@ -15,8 +15,14 @@ router = APIRouter() @router.get("/list") -async def list_game_results(user: User = Depends(get_current_user)) -> list[GameResults]: - results = await GameResults.objects.select_related(GameResults.quiz).all(user=user.id) +async def list_game_results( + user: User = Depends(get_current_user), +) -> list[GameResults]: + results = ( + await GameResults.objects.select_related(GameResults.quiz) + .order_by(GameResults.timestamp.desc()) + .all(user=user.id) + ) return results diff --git a/frontend/src/app.html b/frontend/src/app.html index a305b2a..5deb574 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -9,7 +9,6 @@ SPDX-License-Identifier: MPL-2.0 - {#if open} diff --git a/frontend/src/lib/editor.svelte b/frontend/src/lib/editor.svelte index b8a3004..6472058 100644 --- a/frontend/src/lib/editor.svelte +++ b/frontend/src/lib/editor.svelte @@ -5,9 +5,6 @@ SPDX-License-Identifier: MPL-2.0 --> @@ -57,11 +55,6 @@ SPDX-License-Identifier: MPL-2.0 : `unset`}" >
- {#await import('$lib/inline-editor.svelte')} {:then c} @@ -70,7 +63,6 @@ SPDX-License-Identifier: MPL-2.0