From a3647f10184fa9c69c212eb31aae864aae57360c Mon Sep 17 00:00:00 2001 From: Mawoka Date: Tue, 27 Dec 2022 16:48:43 +0100 Subject: [PATCH] :sparkles: New Remote-Control page --- classquiz/__init__.py | 3 + classquiz/db/models.py | 6 + classquiz/routers/live.py | 14 +- classquiz/routers/quiz.py | 7 +- classquiz/routers/remote.py | 19 + classquiz/socket_server/__init__.py | 71 +++- frontend/src/lib/admin.svelte | 145 ++++---- frontend/src/lib/dashboard/main_slider.svelte | 28 +- frontend/src/routes/admin/+page.svelte | 28 +- frontend/src/routes/remote/+page.svelte | 335 ++++++++++++++++++ 10 files changed, 565 insertions(+), 91 deletions(-) create mode 100644 classquiz/routers/remote.py create mode 100644 frontend/src/routes/remote/+page.svelte diff --git a/classquiz/__init__.py b/classquiz/__init__.py index 92f44dd..a24eeca 100644 --- a/classquiz/__init__.py +++ b/classquiz/__init__.py @@ -27,6 +27,7 @@ from classquiz.routers import ( eximport, login, sitemap, + remote, ) from classquiz.socket_server import sio from classquiz.helpers import meilisearch_init, telemetry_ping, bg_tasks @@ -80,6 +81,7 @@ async def auth_middleware_wrapper(request: Request, call_next): return await rememberme_middleware(request, call_next) +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.add_middleware(SessionMiddleware, secret_key=settings.secret_key) @@ -96,4 +98,5 @@ app.include_router( app.include_router(editor.router, tags=["editor"], prefix="/api/v1/editor", include_in_schema=True) app.include_router(eximport.router, tags=["export", "import"], prefix="/api/v1/eximport", include_in_schema=True) app.include_router(sitemap.router, tags=["sitemap"], prefix="/api/v1/sitemap", include_in_schema=True) + app.mount("/", ASGIApp(sio)) diff --git a/classquiz/db/models.py b/classquiz/db/models.py index 4a30e48..628c5d5 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -240,3 +240,9 @@ class GameSession(BaseModel): class UpdatePassword(BaseModel): old_password: str new_password: str + + +class GameInLobby(BaseModel): + game_pin: str + quiz_title: str + game_id: uuid.UUID diff --git a/classquiz/routers/live.py b/classquiz/routers/live.py index 514a719..1b43062 100644 --- a/classquiz/routers/live.py +++ b/classquiz/routers/live.py @@ -4,6 +4,8 @@ import json +import uuid + from fastapi import APIRouter, HTTPException from pydantic import BaseModel, validator from classquiz.config import settings, redis @@ -155,17 +157,21 @@ async def get_game_user_count(game_pin: str, api_key: str, as_string: bool = Fal @router.get( "/players", ) -async def get_game_session(game_pin: str, api_key: str): +async def get_game_session(game_pin: str, api_key: str | None = None, game_id: uuid.UUID | None = None): + if game_id is None and api_key is None: + raise HTTPException(status_code=401, detail="API-Key and Quiz-ID are missing") user_id = await check_api_key(api_key) redis_res = await redis.get(f"game_session:{game_pin}") if redis_res is None: game_pin = await redis.get(f"game_pin:{user_id}:{game_pin}") redis_res = await redis.get(f"game_session:{game_pin}") - if redis_res is None or user_id is None: + if redis_res is None: raise HTTPException(status_code=404, detail="Game not found or API key not found") data = GameSession.parse_raw(redis_res) + if user_id is None and data.game_id != str(game_id): + raise HTTPException(status_code=401, detail="Game not found or API key not found") game = PlayGame.parse_raw(await redis.get(f"game:{game_pin}")) - if game.user_id != user_id: + if game.user_id != user_id and data.game_id != str(game_id): raise HTTPException(status_code=404, detail="Game not found or API key not found") for i in range(0, len(game.questions)): res = await redis.get(f"game_session:{game_pin}:{i}") @@ -195,7 +201,7 @@ async def set_next_question(game_pin: str, question_number: int, api_key: str): if game_data.user_id != user_id: raise HTTPException(status_code=404, detail="Game not found or API key not found") game_data.current_question = question_number - await redis.set(f"game:{game_pin}", game_data.json()) + await redis.set(f"game:{game_pin}", game_data.json(), ex=18000) await sio.emit( "set_question_number", { diff --git a/classquiz/routers/quiz.py b/classquiz/routers/quiz.py index 04f523f..501e09f 100644 --- a/classquiz/routers/quiz.py +++ b/classquiz/routers/quiz.py @@ -18,7 +18,7 @@ import bleach from classquiz.auth import get_current_user from classquiz.config import redis, settings, storage, meilisearch -from classquiz.db.models import Quiz, QuizInput, User, PlayGame +from classquiz.db.models import Quiz, QuizInput, User, PlayGame, GameInLobby from classquiz.kahoot_importer.import_quiz import import_quiz import html import urllib.parse @@ -129,6 +129,11 @@ async def start_quiz( ) await redis.set(f"game:{str(game.game_pin)}", game.json(), ex=18000) await redis.set(f"game_pin:{user.id}:{quiz_id}", game_pin, ex=18000) + await redis.set( + f"game_in_lobby:{user.id.hex}", + GameInLobby(game_id=game.game_id, game_pin=str(game_pin), quiz_title=quiz.title).json(), + ex=900, + ) return {**quiz.dict(exclude={"id"}), **game.dict(exclude={"questions"})} diff --git a/classquiz/routers/remote.py b/classquiz/routers/remote.py new file mode 100644 index 0000000..8366099 --- /dev/null +++ b/classquiz/routers/remote.py @@ -0,0 +1,19 @@ +# 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 fastapi import APIRouter, Depends, HTTPException + +from classquiz.auth import get_current_user +from classquiz.db.models import User, GameInLobby +from classquiz.config import redis + +router = APIRouter() + + +@router.get("/game_waiting") +async def get_game_in_lobby(user: User = Depends(get_current_user)): + game_in_lobby_raw = await redis.get(f"game_in_lobby:{user.id.hex}") + if game_in_lobby_raw is None: + raise HTTPException(status_code=404, detail="No game waiting") + game_in_lobby = GameInLobby.parse_raw(game_in_lobby_raw) + return game_in_lobby diff --git a/classquiz/socket_server/__init__.py b/classquiz/socket_server/__init__.py index b0128f8..4345b5e 100644 --- a/classquiz/socket_server/__init__.py +++ b/classquiz/socket_server/__init__.py @@ -116,7 +116,7 @@ async def join_game(sid: str, data: dict): ) redis_res = await redis.get(f"game_session:{data.game_pin}") redis_res = GameSession.parse_raw(redis_res) - await redis.set(f"game_session:{data.game_pin}:players:{data.username}", sid, ex=18000) + await redis.set(f"game_session:{data.game_pin}:players:{data.username}", sid, ex=7200) await redis.sadd(f"game_session:{data.game_pin}:players", GamePlayer(username=data.username, sid=sid).json()) if data.custom_field == "": data.custom_field = None @@ -131,7 +131,7 @@ async def join_game(sid: str, data: dict): await sio.emit( "player_joined", {"username": data.username, "sid": sid}, - room=redis_res.admin, + room=f"admin:{data.game_pin}", ) # +++ Time-Sync +++ encrypted_datetime = fernet.encrypt(datetime.now().isoformat().encode("utf-8")).decode("utf-8") @@ -146,7 +146,8 @@ async def start_game(sid: str, _data: dict): if session["admin"]: game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}")) game_data.started = True - await redis.set(f"game:{session['game_pin']}", game_data.json()) + await redis.set(f"game:{session['game_pin']}", game_data.json(), ex=7200) + await redis.delete(f"game_in_lobby:{game_data.user_id.hex}") await sio.emit("start_game", room=session["game_pin"]) @@ -169,7 +170,7 @@ async def register_as_admin(sid: str, data: dict): await redis.set( f"game_session:{game_pin}", GameSession(admin=sid, game_id=game_id, answers=[]).json(), - ex=18000, + ex=7200, ) await sio.emit( @@ -180,7 +181,9 @@ async def register_as_admin(sid: str, data: dict): async with sio.session(sid) as session: session["game_pin"] = game_pin session["admin"] = True + session["remote"] = False sio.enter_room(sid, game_pin) + sio.enter_room(sid, f"admin:{data.game_pin}") else: await sio.emit("already_registered_as_admin", room=sid) @@ -227,8 +230,8 @@ async def set_question_number(sid, data: str): game_pin = session["game_pin"] game_data = PlayGame.parse_raw(await redis.get(f"game:{session['game_pin']}")) game_data.current_question = int(float(data)) - await redis.set(f"game:{session['game_pin']}", game_data.json()) - await redis.set(f"game:{session['game_pin']}:current_time", datetime.now().isoformat()) + await redis.set(f"game:{session['game_pin']}", game_data.json(), ex=7200) + await redis.set(f"game:{session['game_pin']}:current_time", datetime.now().isoformat(), ex=7200) temp_return = game_data.dict(include={"questions"})["questions"][int(float(data))] if game_data.questions[int(float(data))].type == QuizQuestionType.SLIDE: return @@ -322,7 +325,7 @@ async def submit_answer(sid: str, data: dict): ) ] ).json(), - ex=18000, + ex=7200, ) else: answers = _AnswerDataList.parse_raw(answers) @@ -338,7 +341,7 @@ async def submit_answer(sid: str, data: dict): await redis.set( f"game_session:{session['game_pin']}:{data.question_index}", answers.json(), - ex=18000, + ex=7200, ) 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") @@ -349,7 +352,9 @@ async def submit_answer(sid: str, data: dict): # room=session["game_pin"], # ) await sio.emit("everyone_answered", {}) - # await redis.set(f"game_data:{session['game_pin']}", json.dumps(data)) + + +# await redis.set(f"game_data:{session['game_pin']}", json.dumps(data)) @sio.event @@ -363,14 +368,14 @@ async def get_final_results(sid: str, _data: dict): @sio.event -async def get_export_token(sid): +async def get_export_token(sid: str): session = await sio.get_session(sid) if not session["admin"]: return game_data = PlayGame(**json.loads(await redis.get(f"game:{session['game_pin']}"))) results = await generate_final_results(game_data, session["game_pin"]) token = os.urandom(32).hex() - await redis.set(f"export_token:{token}", json.dumps(results)) + await redis.set(f"export_token:{token}", json.dumps(results), ex=7200) await sio.emit("export_token", token, room=sid) @@ -407,7 +412,6 @@ async def kick_player(sid: str, data: dict): return session: dict = await sio.get_session(sid) - print(sid) if not session["admin"]: return @@ -417,3 +421,46 @@ async def kick_player(sid: str, data: dict): ) sio.leave_room(player_sid, session["game_pin"]) await sio.emit("kick", room=player_sid) + + +class _RegisterAsRemoteInput(BaseModel): + game_pin: str + game_id: str + + +@sio.event +async def register_as_remote(sid: str, data: dict): + try: + data = _RegisterAsRemoteInput(**data) + except ValidationError as e: + await sio.emit("error", room=sid) + print(e) + return + await sio.emit( + "registered_as_admin", + {"game_id": data.game_id, "game": await redis.get(f"game:{data.game_pin}")}, + room=sid, + ) + await sio.emit("control_visibility", {"visible": False}, room=f"admin:{data.game_pin}") + async with sio.session(sid) as session: + session["game_pin"] = data.game_pin + session["admin"] = True + session["remote"] = True + sio.enter_room(sid, data.game_pin) + sio.enter_room(sid, f"admin:{data.game_pin}") + + +class _SetControlVisibilityInput(BaseModel): + visible: bool + + +@sio.event +async def set_control_visibility(sid: str, data: dict): + try: + data = _SetControlVisibilityInput(**data) + except ValidationError as e: + await sio.emit("error", room=sid) + print(e) + return + session: dict = await sio.get_session(sid) + await sio.emit("control_visibility", {"visible": data.visible}, room=f"admin:{session['game_pin']}") diff --git a/frontend/src/lib/admin.svelte b/frontend/src/lib/admin.svelte index 7854bb2..0750c04 100644 --- a/frontend/src/lib/admin.svelte +++ b/frontend/src/lib/admin.svelte @@ -28,27 +28,38 @@ let shown_question_now: number; let final_results_clicked = false; let timer_interval; + export let control_visible: boolean; export let player_scores; export const set_question_number = (q_number: number) => { + socket.emit('set_question_number', q_number.toString()); + }; + + socket.on('set_question_number', () => { + console.log('set_question_number'); + }); + socket.on('get_question_results', () => { + console.log('get_question_results'); + }); + socket.on('question_results', (_) => { + timer_res = '0'; + clearInterval(timer_interval); + }); + socket.on('set_question_number', (data) => { timer_res = '0'; clearInterval(timer_interval); - // setTimeout(() => { question_results = null; - socket.emit('set_question_number', q_number.toString()); - shown_question_now = q_number; - timer_res = quiz_data.questions[q_number].time; + shown_question_now = data.question_index; + timer_res = quiz_data.questions[data.question_index].time; selected_question = selected_question + 1; timer(timer_res); - // }, 1000); - }; + }); const get_question_results = () => { socket.emit('get_question_results', { game_id: game_token, question_number: shown_question_now }); - timer_res = '0'; }; const show_solutions = () => { socket.emit('show_solutions', {}); @@ -57,12 +68,17 @@ const get_final_results = () => { socket.emit('get_final_results', {}); - final_results_clicked = true; - timer_res = '0'; }; + socket.on('solutions', (_) => { + timer_res = '0'; + clearInterval(timer_interval); + }); + socket.on('final_results', (data) => { // data = JSON.parse(data); + final_results_clicked = true; + timer_res = '0'; final_results = data; }); @@ -105,33 +121,49 @@ } -
-

- {selected_question === -1 ? '0' : selected_question + 1} - /{quiz_data.questions.length} -

-
- {#if selected_question + 1 === quiz_data.questions.length && ((timer_res === '0' && question_results !== null) || quiz_data?.questions?.[selected_question]?.type === QuizQuestionType.SLIDE)} - {#if JSON.stringify(final_results) === JSON.stringify([null])} - - {/if} - {:else if timer_res === '0' || selected_question === -1} - {#if (selected_question + 1 !== quiz_data.questions.length && question_results !== null) || selected_question === -1} - - {/if} - {#if question_results === null && selected_question !== -1} +{#if control_visible} +
+

+ {selected_question === -1 ? '0' : selected_question + 1} + /{quiz_data.questions.length} +

+
+ {#if selected_question + 1 === quiz_data.questions.length && ((timer_res === '0' && question_results !== null) || quiz_data?.questions?.[selected_question]?.type === QuizQuestionType.SLIDE)} + {#if JSON.stringify(final_results) === JSON.stringify([null])} + + {/if} + {:else if timer_res === '0' || selected_question === -1} + {#if (selected_question + 1 !== quiz_data.questions.length && question_results !== null) || selected_question === -1} + + {/if} + {#if question_results === null && selected_question !== -1} + {#if quiz_data.questions[selected_question].type === QuizQuestionType.SLIDE} + + {:else} + + {/if} + {/if} + {:else if selected_question !== -1} {#if quiz_data.questions[selected_question].type === QuizQuestionType.SLIDE} {:else} - + {/if} - {/if} - {:else if selected_question !== -1} - {#if quiz_data.questions[selected_question].type === QuizQuestionType.SLIDE} - - {:else} - + class='admin-button' + >Next Question ({selected_question + 2} + ) + --> {/if} - {:else} - - {/if} +
-
+{/if} {#if timer_res !== '0' && selected_question >= 0} {/if} -
+
{#if timer_res !== undefined && !final_results_clicked && !question_results} {#if quiz_data.questions[selected_question].type === QuizQuestionType.SLIDE} {#await import('$lib/play/admin/slide.svelte')} diff --git a/frontend/src/lib/dashboard/main_slider.svelte b/frontend/src/lib/dashboard/main_slider.svelte index 04da9d2..fbe0e87 100644 --- a/frontend/src/lib/dashboard/main_slider.svelte +++ b/frontend/src/lib/dashboard/main_slider.svelte @@ -46,20 +46,39 @@ copy_toast_open = false; }, 2000); }; + + let game_in_lobby: { game_pin: string; game_id: string; quiz_title: string } | undefined = + undefined; + + const get_game_in_lobby_fn = async () => { + const res = await fetch('/api/v1/remote/game_waiting'); + console.log('Fetched.'); + if (res.ok) { + game_in_lobby = await res.json(); + } + }; onMount(() => { document.body.addEventListener('keydown', close_start_game_if_esc_is_pressed); }); - $: console.log(quizzes, 'quizzes_here'); - $: console.log(visibleImages, 'visible images'); + + get_game_in_lobby_fn(); -{#if copy_toast_open} +{#if copy_toast_open || game_in_lobby}
+ {#if control_visible} +
+ +
+ {/if} {/if} {#if !success} @@ -181,7 +195,6 @@ id="startGame" on:click={() => { socket.emit('start_game', ''); - game_started = true; }} >{$t('admin_page.start_game')} @@ -197,6 +210,7 @@ bind:game_mode bind:bg_color bind:player_scores + bind:control_visible /> {/if}
diff --git a/frontend/src/routes/remote/+page.svelte b/frontend/src/routes/remote/+page.svelte new file mode 100644 index 0000000..9d81987 --- /dev/null +++ b/frontend/src/routes/remote/+page.svelte @@ -0,0 +1,335 @@ + + + + +{#if game_started} + {#if selected_question + 1 === game_data.questions.length && ((timer_res === '0' && question_results !== null) || game_data?.questions?.[selected_question]?.type === QuizQuestionType.SLIDE)} + {#if JSON.stringify(final_results) === JSON.stringify([null])} + + {:else} +
+ +
+ {/if} + {:else if timer_res === '0' || selected_question === -1} + {#if (selected_question + 1 !== game_data.questions.length && question_results !== null) || selected_question === -1} + + {/if} + {#if question_results === null && selected_question !== -1} + {#if game_data.questions[selected_question].type === QuizQuestionType.SLIDE} + + {:else} + + {/if} + {/if} + {:else if selected_question !== -1} + {#if game_data.questions[selected_question].type === QuizQuestionType.SLIDE} + + {:else} + + {/if} + {/if} + {#if selected_question === -1} +

{game_data.title}

+

{game_data.description}

+ {:else if game_data.questions[selected_question].type === QuizQuestionType.SLIDE} + {#await import('$lib/play/admin/slide.svelte')} + + {:then c} + + {/await} + {:else} +
+

+ {@html game_data.questions[selected_question].question} +

+ +
+ +
+ {#if game_data.questions[selected_question].image !== null} +
+ Content for Question +
+ {/if} + {#if game_data.questions[selected_question].type === QuizQuestionType.ABCD || game_data.questions[selected_question].type === QuizQuestionType.VOTING} +
+ {#each game_data.questions[selected_question].answers as answer, i} +
+ {answer.answer} + +
+ {/each} +
+ {/if} +
+ {/if} + + {#if timer_res === '0' && JSON.stringify(final_results) === JSON.stringify( [null] ) && game_data.questions[selected_question].type !== QuizQuestionType.SLIDE} + {#if game_data.questions[selected_question].type === QuizQuestionType.VOTING && question_results} + {#await import('$lib/play/admin/voting_results.svelte')} + + {:then c} + + {/await} + {/if} + {/if} +{:else} + + +

Players:

+ {#await get_already_joined_players()} + + {:then _} +
+
    + {#each players as player} +
  • {player.username}
  • + {/each} +
+
+ {/await} +{/if} +
+ {#if control_visible} + + {:else} + + {/if} +
+ +