Merge pull request #203 from mawoka-myblock/save-game-results

This commit is contained in:
Mawoka
2023-02-13 17:51:03 +01:00
committed by GitHub
20 changed files with 905 additions and 28 deletions
+2
View File
@@ -30,6 +30,7 @@ from classquiz.routers import (
remote, remote,
community, community,
avatar, avatar,
results,
) )
from classquiz.socket_server import sio from classquiz.socket_server import sio
from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks 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) 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(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) app.include_router(login.router, tags=["auth"], prefix="/api/v1/login", include_in_schema=True)
+33
View File
@@ -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,23 @@ 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: 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
+1 -1
View File
@@ -55,7 +55,7 @@ async def generate_spreadsheet(quiz_results: dict, quiz: Quiz, player_fields: di
worksheet.write(0, 5, "Wrong answers") worksheet.write(0, 5, "Wrong answers")
for i, _ in enumerate(quiz_results): for i, _ in enumerate(quiz_results):
question = quiz.questions[i] question = quiz.questions[i]
print(quiz_results) # print(quiz_results)
try: try:
answer_data = quiz_results[str(i)] answer_data = quiz_results[str(i)]
except KeyError: except KeyError:
+3 -2
View File
@@ -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
+75
View File
@@ -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
},
)
"""
+26 -19
View File
@@ -12,10 +12,21 @@ 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,
)
from pydantic import BaseModel, ValidationError, validator from pydantic import BaseModel, ValidationError, validator
from datetime import datetime from datetime import datetime
from classquiz.socket_server.export_helpers import save_quiz_to_storage
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins=[]) sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins=[])
settings = settings() settings = settings()
@@ -272,19 +283,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 +358,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 +372,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 +387,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 +508,12 @@ 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
await save_quiz_to_storage(session["game_pin"])
await sio.emit("results_saved_successfully")
+41
View File
@@ -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()
+21 -2
View File
@@ -157,7 +157,11 @@
"backup_code": "Backup-code", "backup_code": "Backup-code",
"totp": "Totp", "totp": "Totp",
"text": "Text", "text": "Text",
"order": "order" "order": "order",
"results": "Results",
"note": "Note",
"player_plural": "Players",
"score": "Score"
}, },
"editor": { "editor": {
"time_in_seconds": "Time in seconds", "time_in_seconds": "Time in seconds",
@@ -189,7 +193,8 @@
"show_next_question": "Show next question", "show_next_question": "Show next question",
"start_by_showing_first_question": "Start by showing the first question.", "start_by_showing_first_question": "Start by showing the first question.",
"no_answers": "No answers!", "no_answers": "No answers!",
"stop_time": "Stop time" "stop_time": "Stop time",
"save_results": "Save results"
}, },
"password_reset_page": { "password_reset_page": {
"reset_password": "Reset password" "reset_password": "Reset password"
@@ -253,5 +258,19 @@
"clothe_graphic_type": "Graphic", "clothe_graphic_type": "Graphic",
"thats_you": "That's You!", "thats_you": "That's You!",
"start_over": "Start over" "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"
} }
} }
+36 -2
View File
@@ -15,6 +15,7 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import FinalResults from '$lib/play/admin/final_results.svelte'; import FinalResults from '$lib/play/admin/final_results.svelte';
import GrayButton from '$lib/components/buttons/gray.svelte';
navbarVisible.set(false); navbarVisible.set(false);
@@ -98,6 +99,10 @@
}, 200); }, 200);
}); });
socket.on('results_saved_successfully', (_) => {
results_saved = true;
});
const confirmUnload = () => { const confirmUnload = () => {
if (warnToLeave) { if (warnToLeave) {
event.preventDefault(); event.preventDefault();
@@ -110,6 +115,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) {
@@ -132,6 +140,7 @@
}; };
let bg_color; let bg_color;
let bg_image; let bg_image;
let results_saved = false;
$: bg_color = quiz_data ? quiz_data.background_color : undefined; $: bg_color = quiz_data ? quiz_data.background_color : undefined;
$: bg_image = quiz_data ? quiz_data.background_image : undefined; $: bg_image = quiz_data ? quiz_data.background_image : undefined;
let show_final_results = false; let show_final_results = false;
@@ -152,10 +161,35 @@
{#if JSON.stringify(final_results) !== JSON.stringify([null])} {#if JSON.stringify(final_results) !== JSON.stringify([null])}
{#if control_visible} {#if control_visible}
<div class="w-screen flex justify-center mt-16"> <div class="w-screen flex justify-center mt-16">
<button on:click={request_answer_export} class="admin-button" <div class="w-fit">
>{$t('admin_page.export_results')}</button <GrayButton on:click={request_answer_export}
>{$t('admin_page.export_results')}</GrayButton
> >
</div> </div>
</div>
<div class="w-screen flex justify-center mt-2">
<div class="w-fit">
<GrayButton on:click={save_quiz} flex={true} disabled={results_saved}>
{#if results_saved}
<svg
class="w-4 h-4"
aria-hidden="true"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 13l4 4L19 7"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{:else}{$t('admin_page.save_results')}{/if}
</GrayButton>
</div>
</div>
{/if} {/if}
<FinalResults bind:data={player_scores} bind:show_final_results /> <FinalResults bind:data={player_scores} bind:show_final_results />
{/if} {/if}
+1 -1
View File
@@ -103,7 +103,7 @@
<div class="w-full grid grid-cols-4 gap-2"> <div class="w-full grid grid-cols-4 gap-2">
<BrownButton href="/create">{$t('words.create')}</BrownButton> <BrownButton href="/create">{$t('words.create')}</BrownButton>
<BrownButton href="/import">{$t('words.import')}</BrownButton> <BrownButton href="/import">{$t('words.import')}</BrownButton>
<BrownButton href="/api/v1/users/logout">{$t('words.logout')}</BrownButton> <BrownButton href="/results">{$t('words.results')}</BrownButton>
<BrownButton href="/account/settings"> <BrownButton href="/account/settings">
{$t('words.settings')} {$t('words.settings')}
</BrownButton> </BrownButton>
+58
View File
@@ -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/.
-->
<script lang="ts">
import type { PageData } from './$types';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let data: PageData;
</script>
<div class="w-full">
<div class="flex justify-center w-full">
<div class="w-11/12 m-auto">
{#if data.results.length === 0}
<p class="text-center text-3xl mt-8">{$t('results_page.no_results_so_far')}</p>
{:else}
<table class="w-full">
<tr class="border-b-2 dark:border-gray-500 text-left border-gray-300">
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('results_page.quiz_title')}
</th>
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('results_page.date_played')}
</th>
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('results_page.player_count')}
</th>
<th class="mx-auto p-1">{$t('words.note')}</th>
</tr>
{#each data.results as result}
<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
></td
>
<td class="border-r dark:border-gray-500 p-1 border-gray-300"
>{new Date(result.timestamp).toLocaleString()}</td
>
<td class="border-r dark:border-gray-500 p-1 border-gray-300"
>{Object.keys(result.player_scores).length}</td
>
<td class:p-1={result.note}>
{#if result.note}
{result.note}
{/if}
</td>
</tr>
{/each}
</table>
{/if}
</div>
</div>
</div>
+20
View File
@@ -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;
@@ -0,0 +1,96 @@
<!--
- 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/.
-->
<script lang="ts">
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;
// eslint-disable-next-line no-unused-vars
enum SelectedTab {
// eslint-disable-next-line no-unused-vars
Overview,
// eslint-disable-next-line no-unused-vars
Players,
// eslint-disable-next-line no-unused-vars
Questions
}
let selected_tab: SelectedTab = SelectedTab.Overview;
</script>
<div class="w-full">
<div class="flex flex-row w-full justify-around border-b-2 border-gray-500 mb-4">
<div
class="w-full py-2 flex transition-all hover:opacity-100"
class:text-lg={selected_tab === SelectedTab.Overview}
class:opacity-60={selected_tab !== SelectedTab.Overview}
>
<button
on:click={() => {
selected_tab = SelectedTab.Overview;
}}
class="m-auto w-full h-full"
>{$t('words.overview')}
</button>
</div>
<div
class="w-full py-2 flex border-x-2 border-gray-500 transition-all hover:opacity-100"
class:text-lg={selected_tab === SelectedTab.Players}
class:opacity-60={selected_tab !== SelectedTab.Players}
>
<button
on:click={() => {
selected_tab = SelectedTab.Players;
}}
class="m-auto w-full h-full"
>
{$t('words.player', { count: 2 })}
</button>
</div>
<div
class="w-full py-2 flex transition-all hover:opacity-100"
class:text-lg={selected_tab === SelectedTab.Questions}
class:opacity-60={selected_tab !== SelectedTab.Questions}
>
<button
on:click={() => {
selected_tab = SelectedTab.Questions;
}}
class="m-auto w-full h-full"
>{$t('words.question', { count: 2 })}
</button>
</div>
</div>
{#if selected_tab === SelectedTab.Overview}
<div in:fade={{ duration: 150 }}>
<GeneralOverview
questions={data.results.questions}
answers={data.results.answers}
scores={data.results.player_scores}
title={data.results.title}
timestamp={data.results.timestamp}
/>
</div>
{:else if selected_tab === SelectedTab.Questions}
<div in:fade={{ duration: 150 }}>
<QuestionOverview questions={data.results.questions} answers={data.results.answers} />
</div>
{:else if selected_tab === SelectedTab.Players}
<div in:fade={{ duration: 150 }}>
<PlayerOverview
custom_field={data.results.custom_field_data}
scores={data.results.player_scores}
/>
</div>
{/if}
</div>
@@ -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;
@@ -0,0 +1,56 @@
<!--
- 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/.
-->
<script lang="ts">
import type { Question } from '$lib/quiz_types';
export let questions: Question[];
export let answers: {
username: string;
answer: string;
right: boolean;
tike_taken: number;
score: number;
}[][];
export let scores: {
[key: string]: string;
};
export let title: string;
export let timestamp: string;
const usernames = Object.keys(scores);
const get_average_final_score = () => {
let score_data = 0;
for (const username of usernames) {
score_data += parseInt(scores[username]);
}
return score_data / usernames.length;
};
</script>
<!--
- 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/.
-->
<div class="w-full">
<div class="flex justify-center w-full">
<p class="text-3xl w-5/6 text-center">
The quiz with the title '<strong>{title}</strong>' was played on
<strong>{new Date(timestamp).toLocaleString()}</strong>
with <strong>{usernames.length}</strong> players. The players achieved an average score
of <strong>{get_average_final_score()}</strong>.
</p>
</div>
</div>
<style>
underline {
text-decoration: underline;
}
</style>
@@ -0,0 +1,48 @@
<!--
- 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/.
-->
<script lang="ts">
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let scores: {
[key: string]: string;
};
export let custom_field: {
[key: string]: string;
};
let usernames = Object.keys(scores);
console.log(custom_field);
</script>
<div class="w-full">
<div class="flex justify-center w-full">
<table class="w-11/12 m-auto">
<tr class="border-b-2 dark:border-gray-500 text-left border-gray-300">
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('result_page.player_name')}
</th>
<th class="p-1 mx-auto">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"
>{$t('result_page.custom_field')}
</th>
{/if}
</tr>
{#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="p-1">{scores[uname]}</td>
{#if custom_field[uname]}
<td class="border-l dark:border-gray-500 p-1 border-gray-300"
>{custom_field[uname]}</td
>
{/if}
</tr>
{/each}
</table>
</div>
</div>
@@ -0,0 +1,87 @@
<!--
- 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/.
-->
<script lang="ts">
import type { Question } from '$lib/quiz_types';
import { fly } from 'svelte/transition';
import QuestionTab from './question_tab_thing.svelte';
import { QuizQuestionType } from '$lib/quiz_types';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let questions: Question[];
export let answers: {
username: string;
answer: string;
right: boolean;
tike_taken: number;
score: number;
}[][];
const get_average_score = (q_index: number): number => {
const q_answers = answers[q_index];
let summed_up_scores = 0;
for (const answer of q_answers) {
summed_up_scores = summed_up_scores + answer.score;
}
return summed_up_scores / q_answers.length;
};
const get_number_of_correct_answers = (q_index: number): number => {
const q_answers = answers[q_index];
let correct_answer = 0;
for (const answer of q_answers) {
if (answer.right) {
correct_answer++;
}
}
return correct_answer;
};
const toggle_dropdown = (q_index: number) => {
if (question_open === q_index) {
question_open = false;
} else {
question_open = q_index;
}
};
let question_open: number | boolean = false;
</script>
<div class="w-full flex justify-center">
<div class="w-11/12 flex flex-col w-full gap-4">
{#each questions as question, i}
<div class="transition-all">
<div class="w-full bg-white bg-opacity-60 p-2 rounded grid grid-cols-3 z-40">
<button
class="text-center underline text-xl"
on:click={() => {
toggle_dropdown(i);
}}>{@html question.question}</button
>
{#if question.type !== QuizQuestionType.VOTING}
{@const correct_answers = get_number_of_correct_answers(i)}
<p class="text-center text-sm my-auto">
{$t('result_page.average_score', {
average_score: get_average_score(i)
})}
</p>
<p class="text-center text-sm my-auto">
{$t('result_page.correct_answer', { count: correct_answers })}
<!-- {correct_answers} correct
{#if correct_answers === 1}Answer{:else}Answers{/if}-->
</p>
{/if}
</div>
{#if question_open === i}
<div in:fly|local={{ y: -10 }}>
<QuestionTab {question} answers={answers[i]} />
</div>
{/if}
</div>
{/each}
</div>
</div>
@@ -0,0 +1,110 @@
<!--
- 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/.
-->
<script lang="ts">
import { QuizQuestionType } from '$lib/quiz_types';
import { getLocalization } from '$lib/i18n';
const { t } = getLocalization();
export let question;
interface Answer {
username: string;
answer: string;
right: boolean;
time_taken: number;
score: number;
}
export let answers: Answer[];
// console.log(question);
const get_answer_count_for_answer = (answer: string): number => {
let count = 0;
for (const a of answers) {
if (a.answer === answer) {
count++;
}
}
return count;
};
</script>
<div class="flex justify-center">
<div class="bg-white p-2 -z-10 w-10/12 rounded">
{#if question.type !== QuizQuestionType.ORDER && question.type !== QuizQuestionType.RANGE}
<div class="flex flex-col mb-4">
{#each question.answers as answer}
<div class="grid grid-cols-4">
<p>{answer.answer}</p>
<div
class="col-span-3 flex w-full border-l border-gray-300 px-1 dark:border-gray-500"
>
<div class="my-auto w-full mr-1">
<span
class="h-1 block bg-green-600 my-auto"
style="width: {(get_answer_count_for_answer(answer.answer) /
answers.length) *
100}%"
/>
</div>
<p>{get_answer_count_for_answer(answer.answer)}</p>
{#if question.type !== QuizQuestionType.VOTING && question.type !== QuizQuestionType.TEXT}
<p class="ml-1">
{#if answer.right}{:else}{/if}
</p>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<div>
<table class="w-full text-left">
<tr class="border-b-2 dark:border-gray-500 text-left border-gray-300">
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('result_page.player_name')}
</th>
{#if question.type !== QuizQuestionType.VOTING}
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('words.score')}</th
>
{/if}
<th class="border-r dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('result_page.time_taken')}
</th>
<th class="p-1 mx-auto">{$t('words.answer')} </th>
{#if question.type !== QuizQuestionType.VOTING}
<th class="border-l dark:border-gray-500 p-1 mx-auto border-gray-300"
>{$t('words.correct')}?</th
>
{/if}
</tr>
{#each answers as answer}
<tr>
<td class="border-r dark:border-gray-500 p-1 border-gray-300"
>{answer.username}</td
>
{#if question.type !== QuizQuestionType.VOTING}
<td class="border-r dark:border-gray-500 p-1 border-gray-300"
>{answer.score}</td
>
{/if}
<td class="border-r dark:border-gray-500 p-1 border-gray-300"
>{(answer.time_taken / 1000).toFixed(3)}s
</td>
<td class="p-1">{answer.answer}</td>
{#if question.type !== QuizQuestionType.VOTING}
<td class="p-1 border-l dark:border-gray-500 border-gray-300">
{#if answer.right}{:else}{/if}
</td>
{/if}
</tr>
{/each}
</table>
</div>
</div>
</div>
+124
View File
@@ -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,46 @@
"""Added game_results
Revision ID: 438516c09cf3
Revises: 7ad8502af419
Create Date: 2023-02-13 17:34:05.358665
"""
from alembic import op
import sqlalchemy as sa
import ormar
# revision identifiers, used by Alembic.
revision = "438516c09cf3"
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", 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.Column("title", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("questions", sa.JSON(), nullable=False),
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 ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("game_results")
# ### end Alembic commands ###