✨ Game results can now be saved in db
This commit is contained in:
@@ -270,6 +270,19 @@ class UpdatePassword(BaseModel):
|
|||||||
new_password: str
|
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):
|
class GameInLobby(BaseModel):
|
||||||
game_pin: str
|
game_pin: str
|
||||||
quiz_title: str
|
quiz_title: str
|
||||||
@@ -283,3 +296,20 @@ class GameInLobby(BaseModel):
|
|||||||
# github_username: str | None = ormar.Text(nullable=True)
|
# github_username: str | None = ormar.Text(nullable=True)
|
||||||
# reddit_username: str | None = ormar.Text(nullable=True)
|
# reddit_username: str | None = ormar.Text(nullable=True)
|
||||||
# kahoot_user_id: 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
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ from classquiz.db.models import (
|
|||||||
ABCDQuizAnswer,
|
ABCDQuizAnswer,
|
||||||
QuizQuestionType,
|
QuizQuestionType,
|
||||||
VotingQuizAnswer,
|
VotingQuizAnswer,
|
||||||
|
AnswerDataList,
|
||||||
)
|
)
|
||||||
from classquiz.auth import check_api_key
|
from classquiz.auth import check_api_key
|
||||||
from classquiz.socket_server import ReturnQuestion, sio, _AnswerDataList
|
from classquiz.socket_server import ReturnQuestion, sio
|
||||||
|
|
||||||
settings = settings()
|
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}")
|
answer_data = await redis.get(f"game_session:{game_pin}:{game.current_question}")
|
||||||
if answer_data is None:
|
if answer_data is None:
|
||||||
return
|
return
|
||||||
answer_list = _AnswerDataList.parse_raw(answer_data)
|
answer_list = AnswerDataList.parse_raw(answer_data)
|
||||||
answer_dict = {}
|
answer_dict = {}
|
||||||
for answer in game.questions[game.current_question].answers:
|
for answer in game.questions[game.current_question].answers:
|
||||||
answer_dict[answer.answer] = 0
|
answer_dict[answer.answer] = 0
|
||||||
|
|||||||
@@ -12,7 +12,17 @@ import socketio
|
|||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
from classquiz.config import redis, settings
|
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 pydantic import BaseModel, ValidationError, validator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -272,19 +282,6 @@ class _SubmitAnswerData(BaseModel):
|
|||||||
complex_answer: list[_SubmitAnswerDataOrderType] | None
|
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
|
@sio.event
|
||||||
async def submit_answer(sid: str, data: dict):
|
async def submit_answer(sid: str, data: dict):
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
@@ -360,9 +357,9 @@ async def submit_answer(sid: str, data: dict):
|
|||||||
if answers is None:
|
if answers is None:
|
||||||
await redis.set(
|
await redis.set(
|
||||||
f"game_session:{session['game_pin']}:{data.question_index}",
|
f"game_session:{session['game_pin']}:{data.question_index}",
|
||||||
_AnswerDataList(
|
AnswerDataList(
|
||||||
__root__=[
|
__root__=[
|
||||||
_AnswerData(
|
AnswerData(
|
||||||
username=session["username"],
|
username=session["username"],
|
||||||
answer=data.answer,
|
answer=data.answer,
|
||||||
right=answer_right,
|
right=answer_right,
|
||||||
@@ -374,9 +371,9 @@ async def submit_answer(sid: str, data: dict):
|
|||||||
ex=7200,
|
ex=7200,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
answers = _AnswerDataList.parse_raw(answers)
|
answers = AnswerDataList.parse_raw(answers)
|
||||||
answers.__root__.append(
|
answers.__root__.append(
|
||||||
_AnswerData(
|
AnswerData(
|
||||||
username=session["username"],
|
username=session["username"],
|
||||||
answer=data.answer,
|
answer=data.answer,
|
||||||
right=answer_right,
|
right=answer_right,
|
||||||
@@ -389,7 +386,7 @@ async def submit_answer(sid: str, data: dict):
|
|||||||
answers.json(),
|
answers.json(),
|
||||||
ex=7200,
|
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")
|
player_count = await redis.scard(f"game_session:{session['game_pin']}:players")
|
||||||
if len(answers.__root__) == player_count:
|
if len(answers.__root__) == player_count:
|
||||||
# await sio.emit(
|
# await sio.emit(
|
||||||
@@ -510,3 +507,35 @@ async def set_control_visibility(sid: str, data: dict):
|
|||||||
return
|
return
|
||||||
session: dict = await sio.get_session(sid)
|
session: dict = await sio.get_session(sid)
|
||||||
await sio.emit("control_visibility", {"visible": data.visible}, room=f"admin:{session['game_pin']}")
|
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()
|
||||||
|
|||||||
@@ -110,6 +110,9 @@
|
|||||||
const request_answer_export = async () => {
|
const request_answer_export = async () => {
|
||||||
await socket.emit('get_export_token');
|
await socket.emit('get_export_token');
|
||||||
};
|
};
|
||||||
|
const save_quiz = async () => {
|
||||||
|
await socket.emit('save_quiz');
|
||||||
|
};
|
||||||
|
|
||||||
let darkMode = false;
|
let darkMode = false;
|
||||||
if (browser) {
|
if (browser) {
|
||||||
@@ -156,6 +159,11 @@
|
|||||||
>{$t('admin_page.export_results')}</button
|
>{$t('admin_page.export_results')}</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-screen flex justify-center">
|
||||||
|
<button on:click={save_quiz} class="admin-button"
|
||||||
|
>{$t('admin_page.save_results')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<FinalResults bind:data={player_scores} bind:show_final_results />
|
<FinalResults bind:data={player_scores} bind:show_final_results />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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}`
|
||||||
@@ -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 ###
|
||||||
Reference in New Issue
Block a user