Merge pull request #455 from mawoka-myblock/refactor

This commit is contained in:
Marlon
2025-10-26 08:55:52 +01:00
committed by GitHub
47 changed files with 560 additions and 379 deletions
+55 -2
View File
@@ -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
+2
View File
@@ -94,6 +94,8 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | int:
img_obj = await handle_image_upload(quiz.kahoot.cover, user)
uploaded_images.append(img_obj)
cover = img_obj.id.hex
if quiz.kahoot.description is None or quiz.kahoot.description == "":
quiz.kahoot.description = "Description Missing!"
quiz_data = Quiz(
id=quiz_id,
public=False,
+7 -3
View File
@@ -51,7 +51,7 @@ class GitHubOauthResponse(BaseModel):
events_url: Optional[str] = None
received_events_url: Optional[str] = None
type: Optional[str] = None
site_admin: Optional[str] = None
site_admin: Optional[bool] = None
name: Optional[str] = None
company: Optional[str] = None
blog: Optional[str] = None
@@ -103,7 +103,8 @@ async def auth(request: Request, response: Response):
except authlib.integrations.base_client.OAuthError:
return RedirectResponse("/account/oauth-error")
resp = await oauth.github.get("user", token=token)
user_data = GitHubOauthResponse(**resp.json())
data = resp.json()
user_data = GitHubOauthResponse(**data)
if user_data.email is None:
return RedirectResponse("/account/oauth-error?error=email")
user_in_db = await User.objects.get_or_none(email=user_data.email)
@@ -140,7 +141,10 @@ async def auth(request: Request, response: Response):
else:
raise HTTPException(status_code=500, detail=str(e))
user = await User.objects.get_or_none(
email=user_data.email, username=user_data.login, auth_type=UserAuthTypes.GITHUB, verified=True
email=user_data.email,
username=user_data.login,
auth_type=UserAuthTypes.GITHUB,
verified=True,
)
await log_user_in(user=user, request=request, response=response)
+24 -2
View File
@@ -4,15 +4,37 @@
from authlib.integrations.starlette_client import OAuth
from classquiz.config import settings
from classquiz.config import settings, redis
from functools import lru_cache
import json
settings = settings()
class RedisCache:
def __init__(self) -> None:
pass
async def get(self, key: str) -> str | None:
value = await redis.get(f"authlib:{key}")
if value is None:
return None
try:
return json.loads(value)
except json.JSONDecodeError:
return value
async def set(self, key: str, value: str, expires: int | None = None):
data = json.dumps(value)
await redis.set(f"authlib:{key}", data, ex=expires)
async def delete(self, key: str) -> None:
await redis.delete(key)
@lru_cache()
def init_oauth() -> OAuth:
oauth = OAuth()
oauth = OAuth(cache=RedisCache())
if settings.google_client_secret is not None and settings.google_client_id is not None:
oauth.register(
name="google",
+17 -5
View File
@@ -11,15 +11,27 @@ import asyncpg.exceptions
import bleach
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
import html
from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, arq
from classquiz.config import (
settings,
redis,
storage,
meilisearch,
ALLOWED_TAGS_FOR_QUIZ,
arq,
)
from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType, StorageItem
from classquiz.auth import get_current_user
import os
from datetime import datetime
from uuid import UUID
from classquiz.helpers import get_meili_data, check_image_string, extract_image_ids_from_quiz
from classquiz.helpers import (
get_meili_data,
check_image_string,
extract_image_ids_from_quiz,
)
from classquiz.storage.errors import DeletionFailedError
settings = settings()
@@ -79,15 +91,15 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
quiz_input.background_color = bleach.clean(quiz_input.background_color, tags=[], strip=True)
for i, question in enumerate(quiz_input.questions):
if question.type == QuizQuestionType.ABCD:
if question.type == QuizQuestionType.ABCD or question.type == QuizQuestionType.VOTING:
for i2, answer in enumerate(question.answers):
if answer.color is not None:
quiz_input.questions[i].answers[i2].color = bleach.clean(answer.color, tags=[], strip=True)
if answer.answer == "":
quiz_input.questions[i].answers[i2].answer = None
if answer.answer is not None:
quiz_input.questions[i].answers[i2].answer = bleach.clean(
answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True
quiz_input.questions[i].answers[i2].answer = html.unescape(
bleach.clean(answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)
)
images_to_delete = []
+66 -217
View File
@@ -9,7 +9,6 @@ import json
import os
import random
import aiohttp
import socketio
from cryptography.fernet import Fernet
@@ -19,14 +18,28 @@ 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,
has_already_answered,
)
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 +87,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 +94,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 +106,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 +124,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 +136,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 +147,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")
captcha_res = check_captcha(data.captcha)
if not captcha_res:
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
# --- 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 +164,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 +196,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,
)
if await redis.get(f"game_session:{game_pin}") is not None:
await sio.emit("already_registered_as_admin", room=sid)
return
await 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 = {}
session["game_pin"] = game_pin
session["admin"] = True
session["remote"] = False
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}")
else:
await sio.emit("already_registered_as_admin", room=sid)
@sio.event
@@ -286,57 +233,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)
await 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,93 +279,39 @@ 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"]))
question_index = int(float(data.question_index))
game_data = await PlayGame.get_from_redis(session["game_pin"])
already_answered = await has_already_answered(session["game_pin"], question_index, session["username"])
if already_answered:
await sio.emit("already_replied", room=sid)
return
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:
score = calculate_score(
abs(diff) - latency,
int(float(game_data.questions[int(float(data.question_index))].time)),
int(float(game_data.questions[question_index].time)),
)
if score > 1000:
score = 1000
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 +326,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 +347,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 +357,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 +378,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 +457,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()
+139
View File
@@ -0,0 +1,139 @@
# 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,
AnswerDataList,
)
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"]:
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(q_answer, q_answers), 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(data.complex_answer, 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 = [{"answer": a.answer} for a in answers]
submitted_answers = [{"answer": a.answer} for a in complex_answer]
answer_str = ", ".join(a["answer"] for a in submitted_answers)
is_correct = submitted_answers == correct_answers
return is_correct, answer_str
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)
async def has_already_answered(game_pin: str, q_index: int, username: str) -> bool:
answers = await AnswerDataList.get_redis_or_empty(game_pin, q_index)
if answers is None:
return False
else:
answers = list(filter(lambda a: a.username == username, answers.root))
return len(answers) > 0
+68
View File
@@ -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
@@ -5,19 +5,14 @@ SPDX-License-Identifier: MPL-2.0
-->
<script lang="ts">
import { createBubbler } from 'svelte/legacy';
const bubble = createBubbler();
interface Props {
disabled?: boolean;
flex?: boolean;
href?: undefined | string;
target?: undefined | string;
type?: undefined | string;
type?: 'button' | 'submit' | 'reset';
children?: import('svelte').Snippet;
onclick?: (event: MouseEvent) => void;
}
let {
@@ -26,7 +21,8 @@ SPDX-License-Identifier: MPL-2.0
href = undefined,
target = '_self',
type = 'button',
children
children,
onclick
}: Props = $props();
</script>
@@ -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}
>
@@ -5,16 +5,13 @@ SPDX-License-Identifier: MPL-2.0
-->
<script lang="ts">
import { createBubbler } from 'svelte/legacy';
const bubble = createBubbler();
interface Props {
disabled?: boolean;
flex?: boolean;
href?: undefined | string;
target?: undefined | string;
children?: import('svelte').Snippet;
onclick?: (event: MouseEvent) => void;
}
let {
@@ -22,7 +19,8 @@ SPDX-License-Identifier: MPL-2.0
flex = false,
href = undefined,
target = '_self',
children
children,
onclick
}: Props = $props();
</script>
@@ -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
<button
{disabled}
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:justify-center={flex}
>
@@ -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 }}
>
<div class="m-auto w-1/3 h-2/3 rounded-sm bg-black flex flex-col">
@@ -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
/>
</div>
<div class="flex flex-col p-2 gap-2 overflow-scroll">
@@ -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"
>
<div class="flex">
<h3 class="text-lg my-auto">{vi.title}</h3>
+7 -5
View File
@@ -34,7 +34,11 @@ SPDX-License-Identifier: MPL-2.0
edit_id: string;
}
let { data = $bindable(), selected_question = $bindable(), edit_id = $bindable() }: Props = $props();
let {
data = $bindable(),
selected_question = $bindable(),
edit_id = $bindable()
}: Props = $props();
let advanced_options_open = $state(false);
@@ -155,9 +159,7 @@ SPDX-License-Identifier: MPL-2.0
'questions[].question'
).isValidSync(data.questions[selected_question].question)}
>
<c.default
bind:text={data.questions[selected_question].question}
/>
<c.default bind:text={data.questions[selected_question].question} />
</div>
{/await}
{/key}
@@ -287,7 +289,7 @@ SPDX-License-Identifier: MPL-2.0
/>
</label>
<div class="mt-auto w-full">
<BrownButton on:click={() => (advanced_options_open = false)}
<BrownButton onclick={() => (advanced_options_open = false)}
>{$t('words.close')}</BrownButton
>
</div>
+1 -1
View File
@@ -76,7 +76,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="h-screen relative">
<div class="h-10 flex justify-center w-full p-1 absolute z-20">
<div>
<BrownButton on:click={() => (reorder_mode = !reorder_mode)}
<BrownButton onclick={() => (reorder_mode = !reorder_mode)}
>{#if reorder_mode}{$t('editor.disable_reorder')}{:else}{$t(
'editor.enable_reorder'
)}{/if}</BrownButton
+5 -5
View File
@@ -150,7 +150,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="flex flex-row gap-4">
<div class="w-full">
<BrownButton
on:click={() => {
onclick={() => {
selected_type = AvailableUploadTypes.Image;
}}
>{$t('words.image')}
@@ -159,7 +159,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="w-full">
<BrownButton
disabled={!video_upload}
on:click={() => {
onclick={() => {
selected_type = AvailableUploadTypes.Video;
}}
>{$t('words.video')}
@@ -168,7 +168,7 @@ SPDX-License-Identifier: MPL-2.0
{#if library_enabled}
<div class="w-full">
<BrownButton
on:click={() => {
onclick={() => {
selected_type = AvailableUploadTypes.Library;
}}
>{$t('words.library')}
@@ -177,7 +177,7 @@ SPDX-License-Identifier: MPL-2.0
{/if}
<div class="w-full">
<BrownButton
on:click={() => {
onclick={() => {
selected_type = AvailableUploadTypes.Pixabay;
}}
>Pixabay
@@ -202,7 +202,7 @@ SPDX-License-Identifier: MPL-2.0
{$t('uploader.upload_video_popup_notice')}
</p>
{:else}
<BrownButton on:click={upload_video} type="button"
<BrownButton onclick={upload_video} type="button"
>{$t('uploader.upload_video')}</BrownButton
>
{/if}
@@ -58,7 +58,7 @@ SPDX-License-Identifier: MPL-2.0
</div>
<p class="text-center">{image.filename ?? 'No name available'}</p>
<BrownButton
on:click={() => {
onclick={() => {
set_image(image.id);
}}>{$t('words.select')}</BrownButton
>
@@ -93,7 +93,7 @@ SPDX-License-Identifier: MPL-2.0
/>
</div>
<BrownButton
on:click={() => {
onclick={() => {
set_image(image.id);
}}>{$t('words.select')}</BrownButton
>
@@ -102,14 +102,14 @@ SPDX-License-Identifier: MPL-2.0
<div class="flex gap-2">
<BrownButton
disabled={page < 2}
on:click={() => {
onclick={() => {
page -= 1;
fetched_data = fetch_data();
}}
>{$t('uploader.previous_page')}
</BrownButton>
<BrownButton
on:click={() => {
onclick={() => {
page += 1;
fetched_data = fetch_data();
}}>{$t('uploader.next_page')}</BrownButton
+2 -1
View File
@@ -378,7 +378,8 @@
"time_taken": "benötigte Zeit",
"player_name": "Spielername",
"correct_answer_plural": "{{count}} richtige Antworten",
"player_score": "Spieler Score"
"player_score": "Spieler Score",
"player_correct_questions": "Richtige Antworten"
},
"navbar": {
"donate": "Spenden"
+2 -1
View File
@@ -366,7 +366,8 @@
"correct_answer": "{{count}} correct answer",
"correct_answer_plural": "{{count}} correct answers",
"time_taken": "Time taken",
"player_score": "Player Score"
"player_score": "Player Score",
"player_correct_questions": "Correct Answers"
},
"controllers": {
"add_new_controller": "Add new controller",
@@ -18,12 +18,7 @@ SPDX-License-Identifier: MPL-2.0
cqc_code: string;
}
let {
game_pin,
players = $bindable(),
socket,
cqc_code = $bindable()
}: Props = $props();
let { game_pin, players = $bindable(), socket, cqc_code = $bindable() }: Props = $props();
let fullscreen_open = $state(false);
const { t } = getLocalization();
@@ -97,7 +92,7 @@ SPDX-License-Identifier: MPL-2.0
<div>
<GrayButton
disabled={players.length < 1}
on:click={() => {
onclick={() => {
socket.emit('start_game', '');
}}
>{$t('admin_page.start_game')}
+10 -3
View File
@@ -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}
<div class="flex flex-col justify-center align-center w-screen h-screen">
<form onsubmit={preventDefault(bubble('submit'))} class="flex-col flex justify-center align-center mx-auto">
<form
onsubmit={preventDefault(bubble('submit'))}
class="flex-col flex justify-center align-center mx-auto"
>
<h1 class="text-lg text-center">{$t('words.game_pin')}</h1>
<input
class="border border-gray-400 self-center text-center text-black ring-0 outline-hidden p-2 rounded-lg focus:shadow-2xl transition-all"
@@ -244,7 +251,7 @@ SPDX-License-Identifier: MPL-2.0
{/if}
<div class="mt-2">
<BrownButton disabled={username.length <= 3} on:click={setUsername}
<BrownButton disabled={username.length <= 3} onclick={setUsername}
>{$t('words.submit')}</BrownButton
>
</div>
+10 -11
View File
@@ -241,9 +241,7 @@ SPDX-License-Identifier: MPL-2.0
</div>
<div class="flex justify-center">
<div class="w-1/2">
<BrownButton
disabled={selected_answer !== undefined}
on:click={() => selectAnswer(slider_value[0])}
<BrownButton onclick={() => selectAnswer(slider_value[0])}
>{$t('words.submit')}
</BrownButton>
</div>
@@ -262,7 +260,7 @@ SPDX-License-Identifier: MPL-2.0
<input
type="text"
bind:value={text_input}
disabled={selected_answer}
disabled={selected_answer !== undefined}
class="bg-gray-50 focus:ring text-gray-900 rounded-lg focus:ring-blue-500 block w-full p-2 dark:bg-gray-700 dark:text-white dark:focus:ring-blue-500 outline-hidden transition text-center disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
@@ -271,8 +269,8 @@ SPDX-License-Identifier: MPL-2.0
<div class="w-1/3">
<BrownButton
type="button"
disabled={selected_answer}
on:click={() => {
disabled={!text_input || text_input.length === 0}
onclick={() => {
selectAnswer(text_input);
}}
>
@@ -368,7 +366,7 @@ SPDX-License-Identifier: MPL-2.0
<BrownButton
type="button"
disabled={selected_answer}
on:click={() => {
onclick={() => {
select_complex_answer(question.answers);
}}>{$t('words.submit')}</BrownButton
>
@@ -380,17 +378,18 @@ SPDX-License-Identifier: MPL-2.0
<Spinner />
{:then c}
<c.default
bind:question
{question}
bind:selected_answer
bind:game_mode
{game_mode}
{timer_res}
{circular_progress}
/>
<div class="flex justify-center h-[5%]">
<div class="w-1/2">
<BrownButton
disabled={!selected_answer}
on:click={() => selectAnswer(selected_answer)}
type="button"
disabled={selected_answer === undefined}
onclick={() => selectAnswer(selected_answer)}
>{$t('words.submit')}
</BrownButton>
</div>
+4 -5
View File
@@ -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
<div
class="absolute top-0 bottom-0 left-0 right-0 m-auto rounded-full h-fit w-fit border-2 border-black shadow-2xl z-40"
>
<CircularTimer bind:text={timer_res} bind:progress={circular_progress} color="#ef4444" />
<CircularTimer text={timer_res} progress={circular_progress} color="#ef4444" />
</div>
<div class="grid grid-rows-2 grid-flow-col auto-cols-auto gap-2 w-full p-4 h-full">
+5 -2
View File
@@ -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}
<BrownButton
type="button"
on:click={() => {
onclick={() => {
timer_res = '0';
}}>{$t('words.submit')}</BrownButton
>
@@ -84,7 +84,7 @@ SPDX-License-Identifier: MPL-2.0
{#if data.answers.length < 4}
<div class="rounded-sm p-6 bg-gray-700">
<BrownButton
on:click={() => {
onclick={() => {
data.answers = [...data.answers, { ...{ answer: '', correct: false } }];
}}>{$t('editor_page.add_an_answer')}</BrownButton
>
@@ -96,7 +96,7 @@ SPDX-License-Identifier: MPL-2.0
</div>
</div>
<div class="flex justify-center p-2">
<BrownButton on:click={add_card}
<BrownButton onclick={add_card}
>{$t('quiztivity.memory.editor.add_pair')}</BrownButton
>
</div>
@@ -115,13 +115,13 @@ SPDX-License-Identifier: MPL-2.0
</div>
<div class="grid grid-cols-2 p-2 gap-2">
<BrownButton
on:click={() => {
onclick={() => {
move_card_left(i);
}}
disabled={i === 0}>{$t('quiztivity.editor.move_left')}</BrownButton
>
<BrownButton
on:click={() => {
onclick={() => {
move_card_right(i);
}}
disabled={i + 1 === data.cards.length}
+9 -9
View File
@@ -83,7 +83,7 @@ SPDX-License-Identifier: MPL-2.0
{#if data.id}
<div class="mr-auto w-fit pl-2">
<BrownButton
on:click={() => {
onclick={() => {
shares_menu_open = true;
}}>{$t('quiztivity.editor.open_shares_menu')}</BrownButton
>
@@ -99,7 +99,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="self-end pr-2 w-full">
<div class="ml-auto w-fit">
<BrownButton
on:click={() => {
onclick={() => {
dispatch('save');
}}
disabled={!data.title}>{$t('words.save')}</BrownButton
@@ -109,20 +109,20 @@ SPDX-License-Identifier: MPL-2.0
</div>
<div class="flex flex-row gap-2 w-full p-2">
<BrownButton
on:click={() => {
onclick={() => {
selected_type = null;
}}>{$t('quiztivity.editor.add_new')}</BrownButton
>
<BrownButton on:click={delete_slide} disabled={selected_slide === null}
<BrownButton onclick={delete_slide} disabled={selected_slide === null}
>{$t('quiztivity.editor.delete')}</BrownButton
>
<BrownButton
on:click={move_slide_left}
onclick={move_slide_left}
disabled={selected_slide === null || selected_slide === 0}
>{$t('quiztivity.editor.move_left')}</BrownButton
>
<BrownButton
on:click={move_slide_right}
onclick={move_slide_right}
disabled={selected_slide === null || selected_slide === data.pages.length - 1}
>{$t('quiztivity.editor.move_right')}</BrownButton
>
@@ -140,7 +140,7 @@ SPDX-License-Identifier: MPL-2.0
class:opacity-0={selected_slide !== i}
>
<BrownButton
on:click={() => {
onclick={() => {
selected_slide = selected_slide === i ? null : i;
}}
>
@@ -149,7 +149,7 @@ SPDX-License-Identifier: MPL-2.0
)}{/if}
</BrownButton>
<BrownButton
on:click={() => {
onclick={() => {
opened_slide = i;
}}>{$t('words.edit')}</BrownButton
>
@@ -164,7 +164,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="h-full">
<div class="mb-2">
<BrownButton
on:click={() => {
onclick={() => {
opened_slide = null;
}}>{$t('words.back')}</BrownButton
>
@@ -137,7 +137,7 @@ SPDX-License-Identifier: MPL-2.0
>
<div class="flex justify-center flex-col">
<BrownButton
on:click={() => {
onclick={() => {
add_shares_open = !add_shares_open;
}}>{$t('quiztivity.editor.shares.add_new_share')}</BrownButton
>
@@ -175,7 +175,7 @@ SPDX-License-Identifier: MPL-2.0
<BrownButton
flex={true}
disabled={share_available()}
on:click={() => {
onclick={() => {
navigator.share({
title: 'Quiztivity on ClassQuiz',
text: 'Play this Quiztivity now on ClassQuiz!',
@@ -204,7 +204,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="w-full mx-auto">
<BrownButton
flex={true}
on:click={() => {
onclick={() => {
copyToClipboard(
`${window.location.origin}/quiztivity/share/${share.id}?ref=copy`
);
@@ -242,7 +242,7 @@ SPDX-License-Identifier: MPL-2.0
</p>
<div class="w-fit my-auto ml-auto">
<BrownButton
on:click={() => {
onclick={() => {
delete_share(share.id);
}}>{$t('words.delete')}</BrownButton
>
+1 -1
View File
@@ -108,7 +108,7 @@ SPDX-License-Identifier: MPL-2.0
<!-- extra content above slot -->
</div>
{/if}
<slot />
{@render children?.()}
<CommandPalette />
<!--{#if $alertModal.open ?? false}
+1 -1
View File
@@ -322,7 +322,7 @@ SPDX-License-Identifier: MPL-2.0
</div>
<!--<div
class="m-2 rounded-lg p-2 bg-opacity-40 bg-white transition-all cursor-pointer lg:h-full"
on:click={() => {
onclick={() => {
selected_create_thing = SelectedCreateThing.Import;
}}
class:shadow-2xl={selected_create_thing === SelectedCreateThing.Import}
@@ -131,7 +131,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="mx-auto">
<BrownButton
disabled={newest_version === controller.os_version}
on:click={allow_update_to_version}
onclick={allow_update_to_version}
>
{#if newest_version === controller.os_version}
{$t('controllers.already_latest_version')}
@@ -88,7 +88,7 @@ SPDX-License-Identifier: MPL-2.0
<svelte:head>
<title>ClassQuiz - Login</title>
</svelte:head>
<div class="flex items-center justify-center h-full px-4">
<div class="flex items-center justify-center h-screen">
{#if verified}
<VerifiedBadge />
{/if}
@@ -214,7 +214,7 @@ SPDX-License-Identifier: MPL-2.0
</form>
<div>
<div class="w-fit">
<BrownButton on:click={add_api_key}
<BrownButton onclick={add_api_key}
>{$t('settings_page.add_api_key')}</BrownButton
>
</div>
@@ -226,7 +226,7 @@ SPDX-License-Identifier: MPL-2.0
{key.key}
<div class="inline-block">
<BrownButton
on:click={() => {
onclick={() => {
delete_api_key(key.key);
}}
>{$t('words.delete')}
@@ -95,7 +95,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="flex pl-2">
<div class="mr-auto">
<BrownButton
on:click={() => {
onclick={() => {
index = index - 1;
}}
disabled={index < 1}>{$t('words.back')}</BrownButton
@@ -150,12 +150,12 @@ SPDX-License-Identifier: MPL-2.0
/>
<div class="m-auto grid grid-cols-2 gap-4" in:fade|global={{ delay: 3500 }}>
<BrownButton
on:click={() => {
onclick={() => {
index = 0;
finished = false;
}}>{$t('avatar_settings.start_over')}</BrownButton
>
<BrownButton on:click={save_avatar} flex={true} disabled={save_finished === true}>
<BrownButton onclick={save_avatar} flex={true} disabled={save_finished === true}>
{#if save_finished === undefined}{$t('words.save')}
{:else if save_finished === true}
<svg
@@ -175,7 +175,7 @@ SPDX-License-Identifier: MPL-2.0
</BrownButton>
<BrownButton href="/account/settings">{$t('avatar_settings.go_back')}</BrownButton>
<BrownButton
on:click={() => {
onclick={() => {
finished = false;
}}>{$t('words.close')}</BrownButton
>
@@ -162,7 +162,7 @@ SPDX-License-Identifier: MPL-2.0
<h2 class="text-center text-2xl">{$t('security_settings.backup_code')}</h2>
<div class="flex h-full w-full justify-center">
<div class="m-auto">
<BrownButton on:click={get_backup_code}
<BrownButton onclick={get_backup_code}
>{$t('security_settings.get_backup_code')}</BrownButton
>
</div>
@@ -238,7 +238,7 @@ SPDX-License-Identifier: MPL-2.0
</div>
<div class="flex justify-center">
<div class="m-auto">
<BrownButton on:click={add_security_key}
<BrownButton onclick={add_security_key}
>{$t('security_settings.add_security_key')}</BrownButton
>
</div>
@@ -271,11 +271,11 @@ SPDX-License-Identifier: MPL-2.0
<div class="flex justify-center">
<div class="m-auto">
{#if totp_activated}
<BrownButton on:click={disable_totp}
<BrownButton onclick={disable_totp}
>{$t('security_settings.disable_totp')}</BrownButton
>
{:else}
<BrownButton on:click={enable_totp}
<BrownButton onclick={enable_totp}
>{$t('security_settings.enable_totp')}</BrownButton
>
{/if}
+2 -2
View File
@@ -165,7 +165,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="w-screen flex justify-center mt-16">
<div class="w-fit">
{#if export_token === undefined}
<GrayButton on:click={request_answer_export}
<GrayButton onclick={request_answer_export}
>{$t('admin_page.request_export_results')}</GrayButton
>
{:else}
@@ -179,7 +179,7 @@ SPDX-License-Identifier: MPL-2.0
</div>
<div class="w-screen flex justify-center mt-2">
<div class="w-fit">
<GrayButton on:click={save_quiz} flex={true} disabled={results_saved}>
<GrayButton onclick={save_quiz} flex={true} disabled={results_saved}>
{#if results_saved}
<svg
class="w-4 h-4"
+5 -5
View File
@@ -161,7 +161,7 @@ SPDX-License-Identifier: MPL-2.0
</div>
{:else}
<BrownButton
on:click={() => {
onclick={() => {
create_button_clicked = true;
}}>{$t('words.create')}</BrownButton
>
@@ -267,7 +267,7 @@ SPDX-License-Identifier: MPL-2.0
</BrownButton>
<BrownButton
flex={true}
on:click={() => (analytics_quiz_selected = quiz)}
onclick={() => (analytics_quiz_selected = quiz)}
>
<!-- heroicons/legacy-outline/ChartBar -->
<svg
@@ -311,7 +311,7 @@ SPDX-License-Identifier: MPL-2.0
</BrownButton>
{#if quiz.type === 'quiz'}
<BrownButton
on:click={() => {
onclick={() => {
start_game = quiz.id;
}}
flex={true}
@@ -365,7 +365,7 @@ SPDX-License-Identifier: MPL-2.0
{/if}
<BrownButton
on:click={() => {
onclick={() => {
deleteQuiz(quiz.id, quiz.type);
}}
flex={true}
@@ -387,7 +387,7 @@ SPDX-License-Identifier: MPL-2.0
</svg>
</BrownButton>
<BrownButton
on:click={() => (download_id = quiz.id)}
onclick={() => (download_id = quiz.id)}
flex={true}
disabled={quiz.type !== 'quiz'}
><!-- heroicons/download -->
@@ -33,7 +33,7 @@ SPDX-License-Identifier: MPL-2.0
{#if quiz}
<div
class="fixed w-full h-full top-0 flex bg-black/50 z-50 overflow-scroll"
on:click={on_parent_click}
onclick={on_parent_click}
transition:fade={{ duration: 100 }}
>
<div
+2 -2
View File
@@ -104,14 +104,14 @@ SPDX-License-Identifier: MPL-2.0
</p>
<div class="flex flex-col gap-2">
<BrownButton
on:click={() => {
onclick={() => {
edit_popup = image;
}}
>{$t('file_dashboard.edit_details')}
</BrownButton>
{#if image.quiztivities.length === 0 && image.quizzes.length === 0}
<BrownButton
on:click={() => {
onclick={() => {
delete_image(image.id);
}}>{$t('file_dashboard.delete_image')}</BrownButton
>
+2 -2
View File
@@ -197,7 +197,7 @@ SPDX-License-Identifier: MPL-2.0
</div>
<div class="flex relative my-2">
<div class="w-full">
<BrownButton disabled={status !== Status.CompressDone} on:click={upload_video}>
<BrownButton disabled={status !== Status.CompressDone} onclick={upload_video}>
{$t('words.upload')}
{file_size_in_mi ? `(${file_size_in_mi.toFixed(2)} Mi)` : ''}</BrownButton
>
@@ -208,7 +208,7 @@ SPDX-License-Identifier: MPL-2.0
></span>
</div>
<div class="flex justify-center">
<BrownButton on:click={compress_video} disabled={status !== Status.Idle}
<BrownButton onclick={compress_video} disabled={status !== Status.Idle}
>{$t('words.submit')}</BrownButton
>
</div>
@@ -22,7 +22,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="my-auto justify-start ml-4">
<BrownButton
disabled={current_slide_index < 1}
on:click={() => {
onclick={() => {
current_slide_index -= 1;
}}>{$t('words.back')}</BrownButton
>
@@ -33,7 +33,7 @@ SPDX-License-Identifier: MPL-2.0
<div class="my-auto justify-end mr-4">
<BrownButton
disabled={current_slide_index === 1}
on:click={() => {
onclick={() => {
current_slide_index += 1;
}}>{$t('words.next')}</BrownButton
>
+1 -1
View File
@@ -43,7 +43,7 @@ SPDX-License-Identifier: MPL-2.0
<tr class="text-left">
<td class="border-r dark:border-gray-500 p-1 border-gray-300"
><a href="/results/{result.id}" class="underline text-lg"
>{result.title}</a
>{@html result.title}</a
></td
>
<td class="border-r dark:border-gray-500 p-1 border-gray-300"
@@ -95,6 +95,7 @@ SPDX-License-Identifier: MPL-2.0
<PlayerOverview
custom_field={data.results.custom_field_data}
scores={data.results.player_scores}
answers={data.results.answers}
/>
</div>
{/if}
@@ -10,7 +10,6 @@ SPDX-License-Identifier: MPL-2.0
const { t } = getLocalization();
interface Props {
questions: Question[];
answers: {
@@ -27,13 +26,7 @@ SPDX-License-Identifier: MPL-2.0
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
<div class="w-full">
<div class="flex justify-center w-full">
<p class="text-3xl w-5/6 text-center">
{$t('results_page.general_overview.sentence', {
{@html $t('results_page.general_overview.sentence', {
title,
date: new Date(timestamp).toLocaleString(),
player_count: usernames.length,
@@ -15,11 +15,24 @@ SPDX-License-Identifier: MPL-2.0
custom_field: {
[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);
</script>
@@ -31,6 +44,9 @@ SPDX-License-Identifier: MPL-2.0
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('result_page.player_name')}
</th>
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('result_page.player_correct_questions')}
</th>
<th class="p-1 mx-auto">{$t('result_page.player_score')}</th>
{#if Object.keys(custom_field).length !== 0}
<th class="border-l dark:border-gray-500 p-1 mx-auto border-gray-300"
@@ -43,6 +59,9 @@ SPDX-License-Identifier: MPL-2.0
{#each usernames as uname}
<tr class="text-left">
<td class="border-r dark:border-gray-500 p-1 border-gray-300">{uname}</td>
<td class="border-r dark:border-gray-500 p-1 border-gray-300"
>{correctCounts[uname]}</td
>
<td class="p-1">{scores[uname]}</td>
{#if custom_field[uname]}
<td class="border-l dark:border-gray-500 p-1 border-gray-300"
@@ -124,7 +124,7 @@ SPDX-License-Identifier: MPL-2.0
{#if logged_in}
<div class="w-full">
<GrayButton
on:click={() => {
onclick={() => {
start_game = quiz.id;
}}
flex={true}
@@ -188,7 +188,7 @@ SPDX-License-Identifier: MPL-2.0
</div>
<div class="w-full">
{#if logged_in}
<GrayButton flex={true} on:click={() => (download_id = quiz.id)}>
<GrayButton flex={true} onclick={() => (download_id = quiz.id)}>
<svg
class="w-5 h-5 inline-block"
fill="none"
@@ -35,25 +35,25 @@ SPDX-License-Identifier: MPL-2.0
<div class="rounded-sm border-2 border-[#B07156] flex flex-col w-fit gap-2 p-2">
<div class:opacity-50={mod_rating !== null && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = null)}>Not Checked</BrownButton>
<BrownButton onclick={() => (mod_rating = null)}>Not Checked</BrownButton>
</div>
<div class:opacity-50={mod_rating !== 0 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 0)}>Ok</BrownButton>
<BrownButton onclick={() => (mod_rating = 0)}>Ok</BrownButton>
</div>
<div class:opacity-50={mod_rating !== 1 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 1)}>Attention</BrownButton>
<BrownButton onclick={() => (mod_rating = 1)}>Attention</BrownButton>
</div>
<div class:opacity-50={mod_rating !== 2 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 2)}>NFSW</BrownButton>
<BrownButton onclick={() => (mod_rating = 2)}>NFSW</BrownButton>
</div>
<div class:opacity-50={mod_rating !== 3 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 3)}>Plausibility Checked</BrownButton>
<BrownButton onclick={() => (mod_rating = 3)}>Plausibility Checked</BrownButton>
</div>
<div class:opacity-50={mod_rating !== 4 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 4)}>Fact Checked</BrownButton>
<BrownButton onclick={() => (mod_rating = 4)}>Fact Checked</BrownButton>
</div>
<div class:opacity-50={mod_rating !== 5 && mod_rating !== undefined} class="transition">
<BrownButton on:click={() => (mod_rating = 5)}>Exceptional</BrownButton>
<BrownButton onclick={() => (mod_rating = 5)}>Exceptional</BrownButton>
</div>
<GrayButton on:click={submit} disabled={mod_rating === undefined}>Submit</GrayButton>
<GrayButton onclick={submit} disabled={mod_rating === undefined}>Submit</GrayButton>
</div>
+14
View File
@@ -134,3 +134,17 @@ data: `{PLAYER_NAME} = {CUSTOM_FIELD_VALUE}`
{cqc_code} is the code used to join with a **C**lass**Q**uiz**C**ontroller
Only holds the game-pin
## game_in_lobby:{admin_user_id} [string]
Set if game is waiting for players to join for the admin to enable remote control
model: GameInLobby
example:
```json
{
"game_pin": "1234543",
"quiz_title": "Some random title",
"game_id": "uuid of game"
}
```