From f3abe4d2c4a638d58b8c0eb6256135acf0d36caa Mon Sep 17 00:00:00 2001 From: Mawoka Date: Sat, 25 Oct 2025 13:38:33 +0200 Subject: [PATCH 1/5] 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