From a980c70d85cb39011d204c1724efa6c9a1b23f87 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Sun, 29 Jan 2023 18:16:05 +0100 Subject: [PATCH 1/5] :sparkles: Game results can now be saved in db --- classquiz/db/models.py | 30 +++++ classquiz/routers/live.py | 5 +- classquiz/socket_server/__init__.py | 67 +++++++--- frontend/src/routes/admin/+page.svelte | 8 ++ live_redis_data.md | 124 ++++++++++++++++++ .../ac98b64a5347_added_game_results_column.py | 43 ++++++ 6 files changed, 256 insertions(+), 21 deletions(-) create mode 100644 live_redis_data.md create mode 100644 migrations/versions/ac98b64a5347_added_game_results_column.py diff --git a/classquiz/db/models.py b/classquiz/db/models.py index b6cbef1..10b3193 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,20 @@ 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_id: uuid.UUID = ormar.ForeignKey(Quiz) + user_id: uuid.UUID = 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[AnswerDataList] = 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) + + class Meta: + tablename = "game_results" + metadata = metadata + database = database 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/socket_server/__init__.py b/classquiz/socket_server/__init__.py index 313259c..df1d3f1 100644 --- a/classquiz/socket_server/__init__.py +++ b/classquiz/socket_server/__init__.py @@ -12,7 +12,17 @@ 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, + GameResults, +) from pydantic import BaseModel, ValidationError, validator from datetime import datetime @@ -272,19 +282,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 +357,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 +371,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 +386,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 +507,35 @@ 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 + game_pin = session["game_pin"] + 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)): + print("hiqq", i) + redis_res = await redis.get(f"game_session:{game_pin}:{i}") + try: + answers.append(AnswerDataList.parse_raw(redis_res).dict()) + 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") + print(custom_field_data) + data = GameResults( + id=game.game_id, + quiz_id=game.quiz_id, + user_id=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), + ) + await data.save() diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 96452d9..513eea5 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -110,6 +110,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) { @@ -156,6 +159,11 @@ >{$t('admin_page.export_results')} +
+ +
{/if} {/if} diff --git a/live_redis_data.md b/live_redis_data.md new file mode 100644 index 0000000..1e1e3ad --- /dev/null +++ b/live_redis_data.md @@ -0,0 +1,124 @@ +## game_session:{GAME_PIN}:players:{PLAYER_NAME} [string] + +Contains only the sid (socket.io session-id) of {PLAYER_NAME} + +## game_session:{GAME_PIN}:{QUESTION_INDEX} [string] + +Stores the answer per question which the players submitted + +model: _AnswerDataList + +example-data: + +```json +[ + { + "username": "Mawoka", + "answer": "a", + "right": false, + "time_taken": 4994.246999999999, + "score": 0 + } +] +``` + +## game:{GAME_PIN} [string] + +Stores the data for the game + +model: PlayGame + +example: + +```json +{ + "quiz_id": "be7089c6-ec97-4da9-bb3e-1aa9b67fb939", + "description": "asddsadas", + "user_id": "7cbabbc5-fdbb-4d8b-9a89-7005dfdb6f33", + "title": "Test", + "questions": [ + { + "question": "sdadsadas", + "time": "20", + "type": "ABCD", + "answers": [ + { + "right": false, + "answer": "a", + "color": null + }, + { + "right": true, + "answer": "b", + "color": "null" + } + ], + "image": null + } + ], + "game_id": "7b572f2b-cf7b-47a9-ac0f-446dac22eab0", + "game_pin": "623490", + "started": true, + "captcha_enabled": false, + "cover_image": null, + "game_mode": "kahoot", + "current_question": 0, + "background_color": null, + "background_image": null, + "custom_field": null +} +``` + +## game_session:{GAME_PIN}:player_scores [hash] + +Just stores the score the player has at any point of the game + +data: + +`{PLAYER_NAME} = {SCORE}` + +## game_session:{GAME_PIN} [string] + +Mostly unused, only used to check if an admin is registered. The `answers` never change. + +model: GameSession + +example: + +```json +{ + "admin": "qo1yt-rBG4HyX0YGAAAB", + "game_id": "7b572f2b-cf7b-47a9-ac0f-446dac22eab0", + "answers": [] +} +``` + +## game_session:{GAME_PIN}:players [set] + +A list of all current players, used to get the current number of players to check if everyone has answered + +entry: + +```json +{ + "username": "Mawoka", + "sid": "VSprqk7xGKaH5QbwAAAD" +} +``` + +## game:{GAME_PIN}:current_time [string] + +The time when the question was shown to measure the time the players needed to answer + +## game_pin:{GAME_ID}:{QUIZ_ID} [string] + +**Seems** to be unused + +Returns the Game-pin + +## game:{GAME_ID}:players:custom_fields [hash] [OPTIONAL] + +Holds the custom-field data, but is only set if the custom-field is enabled. + + +data: `{PLAYER_NAME} = {CUSTOM_FIELD_VALUE}` diff --git a/migrations/versions/ac98b64a5347_added_game_results_column.py b/migrations/versions/ac98b64a5347_added_game_results_column.py new file mode 100644 index 0000000..b2d60c9 --- /dev/null +++ b/migrations/versions/ac98b64a5347_added_game_results_column.py @@ -0,0 +1,43 @@ +"""Added game_results column + +Revision ID: ac98b64a5347 +Revises: 7ad8502af419 +Create Date: 2023-01-29 18:00:36.389980 + +""" +from alembic import op +import sqlalchemy as sa +import ormar + + +# revision identifiers, used by Alembic. +revision = "ac98b64a5347" +down_revision = "7ad8502af419" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "game_results", + sa.Column("id", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=False), + sa.Column("quiz_id", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True), + sa.Column("user_id", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column("player_count", sa.Integer(), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("answers", sa.JSON(), nullable=False), + sa.Column("player_scores", sa.JSON(none_as_null=True), nullable=True), + sa.Column("custom_field_data", sa.JSON(none_as_null=True), nullable=True), + sa.ForeignKeyConstraint(["quiz_id"], ["quiz.id"], name="fk_game_results_quiz_id_quiz_id"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name="fk_game_results_users_id_user_id"), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("game_results") + # ### end Alembic commands ### From bce22c43fcb2bffabc11ce4e35681a0618061f15 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Tue, 31 Jan 2023 17:26:19 +0100 Subject: [PATCH 2/5] :sparkles: Started working on results-UI --- classquiz/__init__.py | 2 + classquiz/db/models.py | 6 +- classquiz/routers/results.py | 58 ++++++++++++++ classquiz/socket_server/__init__.py | 8 +- frontend/src/routes/results/+page.svelte | 47 ++++++++++++ frontend/src/routes/results/+page.ts | 20 +++++ .../routes/results/[result_id]/+page.svelte | 76 +++++++++++++++++++ .../src/routes/results/[result_id]/+page.ts | 20 +++++ .../[result_id]/player_overview.svelte | 44 +++++++++++ .../[result_id]/question_overview.svelte | 17 +++++ ...9a28d7a36ad1_added_game_results_column.py} | 14 ++-- 11 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 classquiz/routers/results.py create mode 100644 frontend/src/routes/results/+page.svelte create mode 100644 frontend/src/routes/results/+page.ts create mode 100644 frontend/src/routes/results/[result_id]/+page.svelte create mode 100644 frontend/src/routes/results/[result_id]/+page.ts create mode 100644 frontend/src/routes/results/[result_id]/player_overview.svelte create mode 100644 frontend/src/routes/results/[result_id]/question_overview.svelte rename migrations/versions/{ac98b64a5347_added_game_results_column.py => 9a28d7a36ad1_added_game_results_column.py} (70%) 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 10b3193..6e3ceab 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -300,12 +300,12 @@ class GameInLobby(BaseModel): class GameResults(ormar.Model): id: uuid.UUID = ormar.UUID(primary_key=True) - quiz_id: uuid.UUID = ormar.ForeignKey(Quiz) - user_id: uuid.UUID = ormar.ForeignKey(User) + 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[AnswerDataList] = ormar.JSON(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) diff --git a/classquiz/routers/results.py b/classquiz/routers/results.py new file mode 100644 index 0000000..8b0568e --- /dev/null +++ b/classquiz/routers/results.py @@ -0,0 +1,58 @@ +# 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(include_quiz: bool = False, user: User = Depends(get_current_user)): + if include_quiz is True: + results = await GameResults.objects.select_related("quiz").all(user=user.id) + else: + 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, include_quiz: bool = False, user: User = Depends(get_current_user)): + if include_quiz is True: + res = await GameResults.objects.select_related("quiz").all(user=user.id, quiz=quiz_id) + else: + 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, include_quiz: bool = False, user: User = Depends(get_current_user)): + if include_quiz: + res = await GameResults.objects.select_related("quiz").get_or_none(user=user.id, id=game_id) + else: + 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() diff --git a/classquiz/socket_server/__init__.py b/classquiz/socket_server/__init__.py index df1d3f1..c5c95fb 100644 --- a/classquiz/socket_server/__init__.py +++ b/classquiz/socket_server/__init__.py @@ -519,19 +519,17 @@ async def save_quiz(sid: str): player_count = await redis.scard(f"game_session:{game_pin}:players") answers = [] for i in range(len(game.questions)): - print("hiqq", i) redis_res = await redis.get(f"game_session:{game_pin}:{i}") try: - answers.append(AnswerDataList.parse_raw(redis_res).dict()) + 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") - print(custom_field_data) data = GameResults( id=game.game_id, - quiz_id=game.quiz_id, - user_id=game.user_id, + quiz=game.quiz_id, + user=game.user_id, timestamp=datetime.now(), player_count=player_count, answers=json.dumps(answers), diff --git a/frontend/src/routes/results/+page.svelte b/frontend/src/routes/results/+page.svelte new file mode 100644 index 0000000..fcbac5a --- /dev/null +++ b/frontend/src/routes/results/+page.svelte @@ -0,0 +1,47 @@ + + + +
+
+
+ + + + + + + + {#each data.results as result} + + + + + + + {/each} +
Quiz TitleDate PlayedPlayer countNote
{result.quiz.title}{new Date(result.timestamp).toLocaleString()}{Object.keys(result.player_scores).length}{result.note}
+
+
+
diff --git a/frontend/src/routes/results/+page.ts b/frontend/src/routes/results/+page.ts new file mode 100644 index 0000000..9f3e1de --- /dev/null +++ b/frontend/src/routes/results/+page.ts @@ -0,0 +1,20 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import type { PageLoad } from './$types'; + +export const load = (async ({ fetch }) => { + const res = await fetch('/api/v1/results/list?include_quiz=true'); + let json; + if (res.ok) { + json = await res.json(); + } else { + json = []; + } + return { + results: json + }; +}) satisfies PageLoad; diff --git a/frontend/src/routes/results/[result_id]/+page.svelte b/frontend/src/routes/results/[result_id]/+page.svelte new file mode 100644 index 0000000..ee85583 --- /dev/null +++ b/frontend/src/routes/results/[result_id]/+page.svelte @@ -0,0 +1,76 @@ + + + +
+
+
+ +
+
+ +
+
+ +
+
+ {#if selected_tab === SelectedTab.Overview}{:else if selected_tab === SelectedTab.Questions}{:else if selected_tab === SelectedTab.Players} +
+ +
+ {/if} +
diff --git a/frontend/src/routes/results/[result_id]/+page.ts b/frontend/src/routes/results/[result_id]/+page.ts new file mode 100644 index 0000000..27726f8 --- /dev/null +++ b/frontend/src/routes/results/[result_id]/+page.ts @@ -0,0 +1,20 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import type { PageLoad } from './$types'; + +export const load = (async ({ params, fetch }) => { + const res = await fetch(`/api/v1/results/${params.result_id}?include_quiz=true`); + let json; + if (res.ok) { + json = await res.json(); + } else { + json = undefined; + } + return { + results: json + }; +}) satisfies PageLoad; diff --git a/frontend/src/routes/results/[result_id]/player_overview.svelte b/frontend/src/routes/results/[result_id]/player_overview.svelte new file mode 100644 index 0000000..71aad1a --- /dev/null +++ b/frontend/src/routes/results/[result_id]/player_overview.svelte @@ -0,0 +1,44 @@ + + + +
+
+ + + + + {#if custom_field} + + {/if} + + {#each usernames as uname} + + + + {#if custom_field} + + {/if} + + {/each} +
Player namePlayer ScoreCustom field
{uname}{scores[uname]}{custom_field[uname]}
+
+
diff --git a/frontend/src/routes/results/[result_id]/question_overview.svelte b/frontend/src/routes/results/[result_id]/question_overview.svelte new file mode 100644 index 0000000..e050045 --- /dev/null +++ b/frontend/src/routes/results/[result_id]/question_overview.svelte @@ -0,0 +1,17 @@ + + diff --git a/migrations/versions/ac98b64a5347_added_game_results_column.py b/migrations/versions/9a28d7a36ad1_added_game_results_column.py similarity index 70% rename from migrations/versions/ac98b64a5347_added_game_results_column.py rename to migrations/versions/9a28d7a36ad1_added_game_results_column.py index b2d60c9..cf7f847 100644 --- a/migrations/versions/ac98b64a5347_added_game_results_column.py +++ b/migrations/versions/9a28d7a36ad1_added_game_results_column.py @@ -1,8 +1,8 @@ """Added game_results column -Revision ID: ac98b64a5347 +Revision ID: 9a28d7a36ad1 Revises: 7ad8502af419 -Create Date: 2023-01-29 18:00:36.389980 +Create Date: 2023-01-30 15:42:42.293514 """ from alembic import op @@ -11,7 +11,7 @@ import ormar # revision identifiers, used by Alembic. -revision = "ac98b64a5347" +revision = "9a28d7a36ad1" down_revision = "7ad8502af419" branch_labels = None depends_on = None @@ -22,16 +22,16 @@ def upgrade() -> None: op.create_table( "game_results", sa.Column("id", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=False), - sa.Column("quiz_id", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True), - sa.Column("user_id", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True), + sa.Column("quiz", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True), + sa.Column("user", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True), sa.Column("timestamp", sa.DateTime(), nullable=False), sa.Column("player_count", sa.Integer(), nullable=False), sa.Column("note", sa.Text(), nullable=True), sa.Column("answers", sa.JSON(), nullable=False), sa.Column("player_scores", sa.JSON(none_as_null=True), nullable=True), sa.Column("custom_field_data", sa.JSON(none_as_null=True), nullable=True), - sa.ForeignKeyConstraint(["quiz_id"], ["quiz.id"], name="fk_game_results_quiz_id_quiz_id"), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], name="fk_game_results_users_id_user_id"), + sa.ForeignKeyConstraint(["quiz"], ["quiz.id"], name="fk_game_results_quiz_id_quiz"), + sa.ForeignKeyConstraint(["user"], ["users.id"], name="fk_game_results_users_id_user"), sa.PrimaryKeyConstraint("id"), ) # ### end Alembic commands ### From 36688bb552dddcfec427eca7278ece84564bdbbd Mon Sep 17 00:00:00 2001 From: Mawoka Date: Sun, 5 Feb 2023 21:33:10 +0100 Subject: [PATCH 3/5] :sparkles: More progress on frontend for game results --- frontend/src/routes/results/+page.svelte | 20 ++-- .../routes/results/[result_id]/+page.svelte | 7 +- .../[result_id]/player_overview.svelte | 5 +- .../[result_id]/question_overview.svelte | 59 +++++++++++- .../[result_id]/question_tab_thing.svelte | 94 +++++++++++++++++++ 5 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 frontend/src/routes/results/[result_id]/question_tab_thing.svelte diff --git a/frontend/src/routes/results/+page.svelte b/frontend/src/routes/results/+page.svelte index fcbac5a..0ff7dff 100644 --- a/frontend/src/routes/results/+page.svelte +++ b/frontend/src/routes/results/+page.svelte @@ -15,15 +15,15 @@ + >Quiz Title + + >Date Played + - + >Player count + + {#each data.results as result} @@ -38,7 +38,11 @@ - + {/each}
Quiz Title Date Played Player countNoteNote
{Object.keys(result.player_scores).length}{result.note} + {#if result.note} + {result.note} + {/if} +
diff --git a/frontend/src/routes/results/[result_id]/+page.svelte b/frontend/src/routes/results/[result_id]/+page.svelte index ee85583..914229c 100644 --- a/frontend/src/routes/results/[result_id]/+page.svelte +++ b/frontend/src/routes/results/[result_id]/+page.svelte @@ -6,6 +6,7 @@
@@ -22,7 +23,7 @@ >Player name Player Score - {#if custom_field} + {#if Object.keys(custom_field).length !== 0} Custom field @@ -32,7 +33,7 @@ {uname} {scores[uname]} - {#if custom_field} + {#if custom_field[uname]} {custom_field[uname]} diff --git a/frontend/src/routes/results/[result_id]/question_overview.svelte b/frontend/src/routes/results/[result_id]/question_overview.svelte index e050045..7a67da3 100644 --- a/frontend/src/routes/results/[result_id]/question_overview.svelte +++ b/frontend/src/routes/results/[result_id]/question_overview.svelte @@ -5,6 +5,8 @@ --> + +
+
+ {#each quiz.questions as question, i} +
+
+ +

Average Score: {get_average_score(i)}

+

+ {get_number_of_correct_answers(i)} correct Answer(s) +

+
+ {#if question_open === i} +
+ +
+ {/if} +
+ {/each} +
+
diff --git a/frontend/src/routes/results/[result_id]/question_tab_thing.svelte b/frontend/src/routes/results/[result_id]/question_tab_thing.svelte new file mode 100644 index 0000000..3bad81c --- /dev/null +++ b/frontend/src/routes/results/[result_id]/question_tab_thing.svelte @@ -0,0 +1,94 @@ + + + +
+
+
+ {#each question.answers as answer} +
+

{answer.answer}

+
+
+ +
+

{get_answer_count_for_answer(answer.answer)}

+
+
+ {/each} +
+
+ + + + + + + + + {#each answers as answer} + + + + + + + + {/each} +
Player NameScoreTime TakenAnswerCorrect?
{answer.username}{answer.score}{answer.time_taken}{answer.answer}{answer.right}
+
+
+
From f0c2c4250fe11ce113f61c88ab56727a9483825e Mon Sep 17 00:00:00 2001 From: Mawoka Date: Mon, 6 Feb 2023 21:20:07 +0100 Subject: [PATCH 4/5] :sparkles: Some more progress on save game results --- .../[result_id]/question_overview.svelte | 2 +- .../[result_id]/question_tab_thing.svelte | 24 +++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/frontend/src/routes/results/[result_id]/question_overview.svelte b/frontend/src/routes/results/[result_id]/question_overview.svelte index 7a67da3..3ee1aab 100644 --- a/frontend/src/routes/results/[result_id]/question_overview.svelte +++ b/frontend/src/routes/results/[result_id]/question_overview.svelte @@ -64,7 +64,7 @@

{#if question_open === i} -
+
{/if} diff --git a/frontend/src/routes/results/[result_id]/question_tab_thing.svelte b/frontend/src/routes/results/[result_id]/question_tab_thing.svelte index 3bad81c..0570c38 100644 --- a/frontend/src/routes/results/[result_id]/question_tab_thing.svelte +++ b/frontend/src/routes/results/[result_id]/question_tab_thing.svelte @@ -26,15 +26,6 @@ } return count; }; - const get_answers_for_answer = (answer: string): Array => { - let ret_answers = []; - for (const a of answers) { - if (a.answer === answer) { - ret_answers.push(a); - } - } - return ret_answers; - };
@@ -43,7 +34,9 @@ {#each question.answers as answer}

{answer.answer}

-
+

{get_answer_count_for_answer(answer.answer)}

+

+ {#if answer.right}✅{:else}❌{/if} +

{/each}
-
+
{(answer.time_taken / 1000).toFixed(3)}s - + {/each}
{answer.score} {answer.time_taken} {answer.answer}{answer.right}{#if answer.right}✅{:else}❌{/if}
From 3646fcf90b330d459bb11f3f532e0dcfdd7ea6f1 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Mon, 13 Feb 2023 17:37:05 +0100 Subject: [PATCH 5/5] :sparkles: Finished game-results --- classquiz/db/models.py | 3 + classquiz/helpers/__init__.py | 2 +- classquiz/routers/results.py | 47 +++++--- classquiz/socket_server/__init__.py | 28 +---- classquiz/socket_server/export_helpers.py | 41 +++++++ frontend/src/lib/i18n/locales/en.json | 23 +++- frontend/src/routes/admin/+page.svelte | 40 +++++-- frontend/src/routes/dashboard/+page.svelte | 2 +- frontend/src/routes/results/+page.svelte | 73 +++++++------ .../routes/results/[result_id]/+page.svelte | 29 +++-- .../src/routes/results/[result_id]/+page.ts | 6 +- .../[result_id]/general_overview.svelte | 56 ++++++++++ .../[result_id]/player_overview.svelte | 11 +- .../[result_id]/question_overview.svelte | 35 ++++-- .../[result_id]/question_tab_thing.svelte | 102 ++++++++++-------- ....py => 438516c09cf3_added_game_results.py} | 11 +- 16 files changed, 355 insertions(+), 154 deletions(-) create mode 100644 classquiz/socket_server/export_helpers.py create mode 100644 frontend/src/routes/results/[result_id]/general_overview.svelte rename migrations/versions/{9a28d7a36ad1_added_game_results_column.py => 438516c09cf3_added_game_results.py} (82%) diff --git a/classquiz/db/models.py b/classquiz/db/models.py index 6e3ceab..83b3e9f 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -308,6 +308,9 @@ class GameResults(ormar.Model): 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" 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/results.py b/classquiz/routers/results.py index 8b0568e..dbc8810 100644 --- a/classquiz/routers/results.py +++ b/classquiz/routers/results.py @@ -13,20 +13,14 @@ router = APIRouter() @router.get("/list", response_model=list[GameResults]) -async def list_game_results(include_quiz: bool = False, user: User = Depends(get_current_user)): - if include_quiz is True: - results = await GameResults.objects.select_related("quiz").all(user=user.id) - else: - results = await GameResults.objects.all(user=user.id) +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, include_quiz: bool = False, user: User = Depends(get_current_user)): - if include_quiz is True: - res = await GameResults.objects.select_related("quiz").all(user=user.id, quiz=quiz_id) - else: - res = await GameResults.objects.all(user=user.id, quiz=quiz_id) +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: @@ -34,11 +28,8 @@ async def get_results_by_quiz(quiz_id: UUID, include_quiz: bool = False, user: U @router.get("/{game_id}", response_model=GameResults) -async def get_game_result(game_id: UUID, include_quiz: bool = False, user: User = Depends(get_current_user)): - if include_quiz: - res = await GameResults.objects.select_related("quiz").get_or_none(user=user.id, id=game_id) - else: - res = await GameResults.objects.get_or_none(user=user.id, id=game_id) +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: @@ -56,3 +47,29 @@ async def set_note(id: UUID, data: _SetNoteInput, user: User = Depends(get_curre 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 c5c95fb..967b136 100644 --- a/classquiz/socket_server/__init__.py +++ b/classquiz/socket_server/__init__.py @@ -21,11 +21,12 @@ from classquiz.db.models import ( VotingQuizAnswer, AnswerDataList, AnswerData, - GameResults, ) 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() @@ -514,26 +515,5 @@ async def save_quiz(sid: str): session: dict = await sio.get_session(sid) if not session["admin"]: return - game_pin = session["game_pin"] - 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") - 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), - ) - await data.save() + 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 513eea5..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(); @@ -135,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; @@ -155,14 +161,34 @@ {#if JSON.stringify(final_results) !== JSON.stringify([null])} {#if control_visible}
- +
+ {$t('admin_page.export_results')} +
-
- +
+
+ + {#if results_saved} + + {:else}{$t('admin_page.save_results')}{/if} + +
{/if} diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 0b87796..c1680b0 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -103,7 +103,7 @@
{$t('words.create')} {$t('words.import')} - {$t('words.logout')} + {$t('words.results')} {$t('words.settings')} diff --git a/frontend/src/routes/results/+page.svelte b/frontend/src/routes/results/+page.svelte index 0ff7dff..01e362e 100644 --- a/frontend/src/routes/results/+page.svelte +++ b/frontend/src/routes/results/+page.svelte @@ -5,6 +5,9 @@ --> @@ -12,40 +15,44 @@
- - - - - - - - {#each data.results as result} - - - - - + {#if data.results.length === 0} +

{$t('results_page.no_results_so_far')}

+ {:else} +
Quiz Title - Date Played - Player count - Note
{result.quiz.title}{new Date(result.timestamp).toLocaleString()}{Object.keys(result.player_scores).length} - {#if result.note} - {result.note} - {/if} -
+ + + + + - {/each} -
{$t('results_page.quiz_title')} + {$t('results_page.date_played')} + {$t('results_page.player_count')} + {$t('words.note')}
+ {#each data.results as result} + + {result.title} + {new Date(result.timestamp).toLocaleString()} + {Object.keys(result.player_scores).length} + + {#if result.note} + {result.note} + {/if} + + + {/each} + + {/if}
diff --git a/frontend/src/routes/results/[result_id]/+page.svelte b/frontend/src/routes/results/[result_id]/+page.svelte index 914229c..11126c3 100644 --- a/frontend/src/routes/results/[result_id]/+page.svelte +++ b/frontend/src/routes/results/[result_id]/+page.svelte @@ -7,6 +7,11 @@ import type { PageData } from './$types'; import PlayerOverview from './player_overview.svelte'; import QuestionOverview from './question_overview.svelte'; + import GeneralOverview from './general_overview.svelte'; + import { fade } from 'svelte/transition'; + import { getLocalization } from '$lib/i18n'; + + const { t } = getLocalization(); export let data: PageData; @@ -35,7 +40,7 @@ selected_tab = SelectedTab.Overview; }} class="m-auto w-full h-full" - >Overview + >{$t('words.overview')}
- Players + {$t('words.player', { count: 2 })}
Questions + >{$t('words.question', { count: 2 })}
- {#if selected_tab === SelectedTab.Overview}{:else if selected_tab === SelectedTab.Questions} -
- + {#if selected_tab === SelectedTab.Overview} +
+ +
+ {:else if selected_tab === SelectedTab.Questions} +
+
{:else if selected_tab === SelectedTab.Players} -
+
{ +export const load = async ({ params, fetch }) => { const res = await fetch(`/api/v1/results/${params.result_id}?include_quiz=true`); let json; if (res.ok) { @@ -17,4 +17,4 @@ export const load = (async ({ params, fetch }) => { return { results: json }; -}) satisfies PageLoad; +}; //satisfies PageLoad; diff --git a/frontend/src/routes/results/[result_id]/general_overview.svelte b/frontend/src/routes/results/[result_id]/general_overview.svelte new file mode 100644 index 0000000..82a0bb7 --- /dev/null +++ b/frontend/src/routes/results/[result_id]/general_overview.svelte @@ -0,0 +1,56 @@ + + + + + +
+
+

+ 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()}. +

+
+
+ + diff --git a/frontend/src/routes/results/[result_id]/player_overview.svelte b/frontend/src/routes/results/[result_id]/player_overview.svelte index 565be87..4e92fd1 100644 --- a/frontend/src/routes/results/[result_id]/player_overview.svelte +++ b/frontend/src/routes/results/[result_id]/player_overview.svelte @@ -4,6 +4,9 @@ - file, You can obtain one at https://mozilla.org/MPL/2.0/. -->
-
- {#each quiz.questions as question, i} -
+
+ {#each questions as question, i} +
{@html question.question} -

Average Score: {get_average_score(i)}

-

- {get_number_of_correct_answers(i)} correct Answer(s) -

+ {#if question.type !== QuizQuestionType.VOTING} + {@const correct_answers = get_number_of_correct_answers(i)} +

+ {$t('result_page.average_score', { + average_score: get_average_score(i) + })} +

+

+ {$t('result_page.correct_answer', { count: correct_answers })} + +

+ {/if}
{#if question_open === i} -
+
{/if} diff --git a/frontend/src/routes/results/[result_id]/question_tab_thing.svelte b/frontend/src/routes/results/[result_id]/question_tab_thing.svelte index 0570c38..a44a0bd 100644 --- a/frontend/src/routes/results/[result_id]/question_tab_thing.svelte +++ b/frontend/src/routes/results/[result_id]/question_tab_thing.svelte @@ -4,6 +4,11 @@ - file, You can obtain one at https://mozilla.org/MPL/2.0/. -->