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 ###