diff --git a/classquiz/__init__.py b/classquiz/__init__.py index 45d9c15..3525a91 100644 --- a/classquiz/__init__.py +++ b/classquiz/__init__.py @@ -30,6 +30,7 @@ from classquiz.routers import ( remote, community, avatar, + results, ) from classquiz.socket_server import sio from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks @@ -83,6 +84,7 @@ async def auth_middleware_wrapper(request: Request, call_next): return await rememberme_middleware(request, call_next) +app.include_router(results.router, tags=["results"], prefix="/api/v1/results", include_in_schema=True) app.include_router(remote.router, tags=["remote"], prefix="/api/v1/remote", include_in_schema=True) app.include_router(login.router, tags=["auth"], prefix="/api/v1/login", include_in_schema=True) diff --git a/classquiz/db/models.py b/classquiz/db/models.py index b6cbef1..83b3e9f 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -270,6 +270,19 @@ class UpdatePassword(BaseModel): new_password: str +class AnswerData(BaseModel): + username: str + answer: str + right: bool + time_taken: float # In milliseconds + score: int + + +class AnswerDataList(BaseModel): + # Just a method to make a top-level list + __root__: list[AnswerData] + + class GameInLobby(BaseModel): game_pin: str quiz_title: str @@ -283,3 +296,23 @@ class GameInLobby(BaseModel): # github_username: str | None = ormar.Text(nullable=True) # reddit_username: str | None = ormar.Text(nullable=True) # kahoot_user_id: str | None = ormar.Text(nullable=True) + + +class GameResults(ormar.Model): + id: uuid.UUID = ormar.UUID(primary_key=True) + quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz) + user: uuid.UUID | User = ormar.ForeignKey(User) + timestamp: datetime = ormar.DateTime(default=datetime.now(), nullable=False) + player_count: int = ormar.Integer(nullable=False, default=0) + note: str | None = ormar.Text(nullable=True) + answers: Json[list[AnswerData]] = ormar.JSON(True) + player_scores: Json[dict[str, str]] = ormar.JSON(nullable=True) + custom_field_data: Json[dict[str, str]] | None = ormar.JSON(nullable=True) + title: str = ormar.Text(nullable=False) + description: str = ormar.Text(nullable=False) + questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False) + + class Meta: + tablename = "game_results" + metadata = metadata + database = database diff --git a/classquiz/helpers/__init__.py b/classquiz/helpers/__init__.py index 6ba3543..90dcb66 100644 --- a/classquiz/helpers/__init__.py +++ b/classquiz/helpers/__init__.py @@ -55,7 +55,7 @@ async def generate_spreadsheet(quiz_results: dict, quiz: Quiz, player_fields: di worksheet.write(0, 5, "Wrong answers") for i, _ in enumerate(quiz_results): question = quiz.questions[i] - print(quiz_results) + # print(quiz_results) try: answer_data = quiz_results[str(i)] except KeyError: diff --git a/classquiz/routers/live.py b/classquiz/routers/live.py index 1b43062..6e9d1b5 100644 --- a/classquiz/routers/live.py +++ b/classquiz/routers/live.py @@ -20,9 +20,10 @@ from classquiz.db.models import ( ABCDQuizAnswer, QuizQuestionType, VotingQuizAnswer, + AnswerDataList, ) from classquiz.auth import check_api_key -from classquiz.socket_server import ReturnQuestion, sio, _AnswerDataList +from classquiz.socket_server import ReturnQuestion, sio settings = settings() @@ -276,7 +277,7 @@ async def voting_results(game_pin: str, api_key: str, as_array: bool = False): answer_data = await redis.get(f"game_session:{game_pin}:{game.current_question}") if answer_data is None: return - answer_list = _AnswerDataList.parse_raw(answer_data) + answer_list = AnswerDataList.parse_raw(answer_data) answer_dict = {} for answer in game.questions[game.current_question].answers: answer_dict[answer.answer] = 0 diff --git a/classquiz/routers/results.py b/classquiz/routers/results.py new file mode 100644 index 0000000..dbc8810 --- /dev/null +++ b/classquiz/routers/results.py @@ -0,0 +1,75 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from classquiz.auth import get_current_user +from classquiz.db.models import User, GameResults + +router = APIRouter() + + +@router.get("/list", response_model=list[GameResults]) +async def list_game_results(user: User = Depends(get_current_user)): + results = await GameResults.objects.all(user=user.id) + return results + + +@router.get("/list/{quiz_id}", response_model=list[GameResults]) +async def get_results_by_quiz(quiz_id: UUID, user: User = Depends(get_current_user)): + res = await GameResults.objects.all(user=user.id, quiz=quiz_id) + if res is None: + raise HTTPException(status_code=404, detail="Game Result not found") + else: + return res + + +@router.get("/{game_id}", response_model=GameResults) +async def get_game_result(game_id: UUID, user: User = Depends(get_current_user)): + res = await GameResults.objects.get_or_none(user=user.id, id=game_id) + if res is None: + raise HTTPException(status_code=404, detail="Game Result not found") + else: + return res + + +class _SetNoteInput(BaseModel): + note: str + + +@router.post("/set_note", response_model=GameResults) +async def set_note(id: UUID, data: _SetNoteInput, user: User = Depends(get_current_user)): + res = await GameResults.objects.get_or_none(user=user.id, id=id) + if res is None: + raise HTTPException(status_code=404, detail="Game Result not found") + res.note = data.note + return await res.update() + + +""" +@router.get("/export/{result_id}", response_class=StreamingResponse) +async def export_result(result_id: UUID, user: User = Depends(get_current_user)): + res = await GameResults.objects.get_or_none(user=user.id, id=result_id) + if res is None: + raise HTTPException(status_code=404, detail="Game Result not found") + quiz = Quiz(title=res.title, questions=res.questions) + spreadsheet = await generate_spreadsheet( + quiz=quiz, quiz_results=data, player_fields=player_fields, player_scores=score_data + ) + + def iter_file(): + yield from spreadsheet + + return StreamingResponse( + iter_file(), + media_type="application/vnd.ms-excel", + headers={ + "Content-Disposition": f"attachment;filename=ClassQuiz-{urllib.parse.quote(quiz.title)}-{datetime.strftime('%m-%d-%Y')}.xlsx" + # noqa: E501 + }, + ) + +""" diff --git a/classquiz/socket_server/__init__.py b/classquiz/socket_server/__init__.py index 313259c..967b136 100644 --- a/classquiz/socket_server/__init__.py +++ b/classquiz/socket_server/__init__.py @@ -12,10 +12,21 @@ import socketio from cryptography.fernet import Fernet from classquiz.config import redis, settings -from classquiz.db.models import PlayGame, QuizQuestionType, GameSession, GamePlayer, QuizQuestion, VotingQuizAnswer +from classquiz.db.models import ( + PlayGame, + QuizQuestionType, + GameSession, + GamePlayer, + QuizQuestion, + VotingQuizAnswer, + AnswerDataList, + AnswerData, +) from pydantic import BaseModel, ValidationError, validator from datetime import datetime +from classquiz.socket_server.export_helpers import save_quiz_to_storage + sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins=[]) settings = settings() @@ -272,19 +283,6 @@ class _SubmitAnswerData(BaseModel): complex_answer: list[_SubmitAnswerDataOrderType] | None -class _AnswerData(BaseModel): - username: str - answer: str - right: bool - time_taken: float # In milliseconds - score: int - - -class _AnswerDataList(BaseModel): - # Just a method to make a top-level list - __root__: list[_AnswerData] - - @sio.event async def submit_answer(sid: str, data: dict): now = datetime.now() @@ -360,9 +358,9 @@ async def submit_answer(sid: str, data: dict): if answers is None: await redis.set( f"game_session:{session['game_pin']}:{data.question_index}", - _AnswerDataList( + AnswerDataList( __root__=[ - _AnswerData( + AnswerData( username=session["username"], answer=data.answer, right=answer_right, @@ -374,9 +372,9 @@ async def submit_answer(sid: str, data: dict): ex=7200, ) else: - answers = _AnswerDataList.parse_raw(answers) + answers = AnswerDataList.parse_raw(answers) answers.__root__.append( - _AnswerData( + AnswerData( username=session["username"], answer=data.answer, right=answer_right, @@ -389,7 +387,7 @@ async def submit_answer(sid: str, data: dict): answers.json(), ex=7200, ) - answers = _AnswerDataList.parse_raw(await redis.get(f"game_session:{session['game_pin']}:{data.question_index}")) + answers = AnswerDataList.parse_raw(await redis.get(f"game_session:{session['game_pin']}:{data.question_index}")) player_count = await redis.scard(f"game_session:{session['game_pin']}:players") if len(answers.__root__) == player_count: # await sio.emit( @@ -510,3 +508,12 @@ async def set_control_visibility(sid: str, data: dict): return session: dict = await sio.get_session(sid) await sio.emit("control_visibility", {"visible": data.visible}, room=f"admin:{session['game_pin']}") + + +@sio.event +async def save_quiz(sid: str): + session: dict = await sio.get_session(sid) + if not session["admin"]: + return + await save_quiz_to_storage(session["game_pin"]) + await sio.emit("results_saved_successfully") diff --git a/classquiz/socket_server/export_helpers.py b/classquiz/socket_server/export_helpers.py new file mode 100644 index 0000000..75c4743 --- /dev/null +++ b/classquiz/socket_server/export_helpers.py @@ -0,0 +1,41 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +import json +from datetime import datetime + +from pydantic import ValidationError + +from classquiz.config import redis +from classquiz.db.models import PlayGame, GameResults + + +async def save_quiz_to_storage(game_pin: str): + game = PlayGame.parse_raw(await redis.get(f"game:{game_pin}")) + player_count = await redis.scard(f"game_session:{game_pin}:players") + answers = [] + for i in range(len(game.questions)): + redis_res = await redis.get(f"game_session:{game_pin}:{i}") + try: + answers.append(json.loads(redis_res)) + except ValidationError: + answers.append([]) + player_scores = await redis.hgetall(f"game_session:{game_pin}:player_scores") + custom_field_data = await redis.hgetall(f"game:{game_pin}:players:custom_fields") + q_return = [] + for q in game.questions: + q_return.append(q.dict()) + data = GameResults( + id=game.game_id, + quiz=game.quiz_id, + user=game.user_id, + timestamp=datetime.now(), + player_count=player_count, + answers=json.dumps(answers), + player_scores=json.dumps(player_scores), + custom_field_data=json.dumps(custom_field_data), + title=game.title, + description=game.description, + questions=json.dumps(q_return), + ) + await data.save() diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 60b71cc..c6ee4d3 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -157,7 +157,11 @@ "backup_code": "Backup-code", "totp": "Totp", "text": "Text", - "order": "order" + "order": "order", + "results": "Results", + "note": "Note", + "player_plural": "Players", + "score": "Score" }, "editor": { "time_in_seconds": "Time in seconds", @@ -189,7 +193,8 @@ "show_next_question": "Show next question", "start_by_showing_first_question": "Start by showing the first question.", "no_answers": "No answers!", - "stop_time": "Stop time" + "stop_time": "Stop time", + "save_results": "Save results" }, "password_reset_page": { "reset_password": "Reset password" @@ -253,5 +258,19 @@ "clothe_graphic_type": "Graphic", "thats_you": "That's You!", "start_over": "Start over" + }, + "results_page": { + "no_results_so_far": "No results saved so far...", + "quiz_title": "Quiz Title", + "date_played": "Date Played", + "player_count": "Player count" + }, + "result_page": { + "player_name": "Player name", + "custom_field": "Custom field", + "average_score": "Average score: {{average_score}}", + "correct_answer": "{{count}} correct answer", + "correct_answer_plural": "{{count}} correct answers", + "time_taken": "Time taken" } } diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 96452d9..0ce5c44 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -15,6 +15,7 @@ import { browser } from '$app/environment'; import { onMount } from 'svelte'; import FinalResults from '$lib/play/admin/final_results.svelte'; + import GrayButton from '$lib/components/buttons/gray.svelte'; navbarVisible.set(false); @@ -98,6 +99,10 @@ }, 200); }); + socket.on('results_saved_successfully', (_) => { + results_saved = true; + }); + const confirmUnload = () => { if (warnToLeave) { event.preventDefault(); @@ -110,6 +115,9 @@ const request_answer_export = async () => { await socket.emit('get_export_token'); }; + const save_quiz = async () => { + await socket.emit('save_quiz'); + }; let darkMode = false; if (browser) { @@ -132,6 +140,7 @@ }; let bg_color; let bg_image; + let results_saved = false; $: bg_color = quiz_data ? quiz_data.background_color : undefined; $: bg_image = quiz_data ? quiz_data.background_image : undefined; let show_final_results = false; @@ -152,9 +161,34 @@ {#if JSON.stringify(final_results) !== JSON.stringify([null])} {#if control_visible}
{$t('results_page.no_results_so_far')}
+ {:else} +| {$t('results_page.quiz_title')} + | +{$t('results_page.date_played')} + | +{$t('results_page.player_count')} + | +{$t('words.note')} | +
|---|---|---|---|
| {result.title} | +{new Date(result.timestamp).toLocaleString()} | +{Object.keys(result.player_scores).length} | ++ {#if result.note} + {result.note} + {/if} + | +
+ The quiz with the title '{title}' was played on + {new Date(timestamp).toLocaleString()} + with {usernames.length} players. The players achieved an average score + of {get_average_final_score()}. +
+| {$t('result_page.player_name')} + | +Player Score | + {#if Object.keys(custom_field).length !== 0} +{$t('result_page.custom_field')} + | + {/if} +
|---|---|---|
| {uname} | +{scores[uname]} | + {#if custom_field[uname]} +{custom_field[uname]} | + {/if} +
+ {$t('result_page.average_score', { + average_score: get_average_score(i) + })} +
++ {$t('result_page.correct_answer', { count: correct_answers })} + +
+ {/if} +{answer.answer}
+{get_answer_count_for_answer(answer.answer)}
+ {#if question.type !== QuizQuestionType.VOTING && question.type !== QuizQuestionType.TEXT} ++ {#if answer.right}✅{:else}❌{/if} +
+ {/if} +| {$t('result_page.player_name')} + | + {#if question.type !== QuizQuestionType.VOTING} +{$t('words.score')} | + {/if} +{$t('result_page.time_taken')} + | +{$t('words.answer')} | + {#if question.type !== QuizQuestionType.VOTING} +{$t('words.correct')}? | + {/if} +
|---|---|---|---|---|
| {answer.username} | + {#if question.type !== QuizQuestionType.VOTING} +{answer.score} | + {/if} +{(answer.time_taken / 1000).toFixed(3)}s + | +{answer.answer} | + {#if question.type !== QuizQuestionType.VOTING} ++ {#if answer.right}✅{:else}❌{/if} + | + {/if} +