diff --git a/Caddyfile b/Caddyfile index 2e6747c..8f9581a 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,4 +1,6 @@ +#* { :8080 { +# tls /home/mawoka/certs/cert.pem /home/mawoka/certs/key.pem reverse_proxy /* localhost:3000 reverse_proxy /api* localhost:8000 reverse_proxy /rapidoc* localhost:8000 diff --git a/classquiz/db/models.py b/classquiz/db/models.py index 4fa793d..18e6b30 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -453,3 +453,20 @@ class PrivateStorageItem(PublicStorageItem): class UpdateStorageItem(BaseModel): filename: str | None alt_text: str | None + + +class Controller(ormar.Model): + id: uuid.UUID = ormar.UUID(primary_key=True) + user: uuid.UUID | User = ormar.ForeignKey(User) + secret_key: str = ormar.String(nullable=False, max_length=24, min_length=24) + player_name: str = ormar.Text(nullable=False) + last_seen: datetime | None = ormar.DateTime(nullable=True) + first_seen: datetime | None = ormar.DateTime(nullable=True) + name: str = ormar.Text(nullable=False) + os_version: str | None = ormar.Text(nullable=True) + wanted_os_version: str = ormar.Text(nullable=True, default=None) + + class Meta: + tablename = "controller" + metadata = metadata + database = database diff --git a/classquiz/helpers/box_controller.py b/classquiz/helpers/box_controller.py new file mode 100644 index 0000000..101023b --- /dev/null +++ b/classquiz/helpers/box_controller.py @@ -0,0 +1,21 @@ +# 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 random + + +def generate_code(specified_length: int) -> str: + buttons = [ + "B", + "b", + "G", + "g", + "Y", + "y", + "R", + "r", + ] # Capital stands for long press, lowercase letter for short press + resulting_code = "" + for _ in range(specified_length): + resulting_code += random.choice(buttons) + return resulting_code diff --git a/classquiz/routers/box_controller/embedded/__init__.py b/classquiz/routers/box_controller/embedded/__init__.py new file mode 100644 index 0000000..77a8f97 --- /dev/null +++ b/classquiz/routers/box_controller/embedded/__init__.py @@ -0,0 +1,93 @@ +# 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/. + +# 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 os +import uuid +from datetime import datetime + +from fastapi import APIRouter, HTTPException +from fastapi.responses import PlainTextResponse +from pydantic import BaseModel +from classquiz.config import redis +from classquiz.db.models import PlayGame, Controller +from classquiz.routers.box_controller.embedded.socket import router as socket_router + +router = APIRouter() +router.include_router(socket_router, prefix="/socket") + + +class JoinGameInput(BaseModel): + id: uuid.UUID + secret_key: str + code: str + + +class JoinGameResponse(BaseModel): + id: str + + +@router.post("/join") +async def join_game(data: JoinGameInput) -> JoinGameResponse: + controller = await Controller.objects.get_or_none(id=data.id, secret_key=data.secret_key) + game_pin = await redis.get(f"game:cqc:code:{data.code}") + if game_pin is None: + raise HTTPException(status_code=404, detail="Game not found") + game = await redis.get(f"game:{game_pin}") + game = PlayGame.parse_raw(game) + # Check if game is already running + if game.started: + raise HTTPException(status_code=400, detail="Game started already") + # check if username already exists + if await redis.get(f"game_session:{game_pin}:players:{controller.player_name}") is not None: + raise HTTPException(status_code=409, detail="Username already exists") + + player_id = os.urandom(5).hex() + await redis.set(f"game:cqc:player:{player_id}", controller.player_name) + return JoinGameResponse(id=f"{player_id}:{game_pin}") + + +class RegisterWithCodeInput(BaseModel): + code: str + + +class RegisterWithCodeResponse(BaseModel): + id: uuid.UUID + secret_key: str + + +@router.post("/register") +async def register_with_code(data: RegisterWithCodeInput) -> RegisterWithCodeResponse: + c_id = await redis.get(f"controller_setup:{data.code}") + if c_id is None: + raise HTTPException(status_code=404, detail="Code not found") + await redis.delete(f"controller_setup:{data.code}") + c_id = uuid.UUID(c_id) + controller = await Controller.objects.get(id=c_id) + controller.first_seen = datetime.now() + controller.last_seen = datetime.now() + await controller.update() + return RegisterWithCodeResponse(id=controller.id, secret_key=controller.secret_key) + + +@router.get("/ping") +async def ping_server(id: uuid.UUID, secret_key: str, version: str): + controller = await Controller.objects.get_or_none(id=id, secret_key=secret_key) + if controller is None: + raise HTTPException(status_code=404, detail="Key and/or id invalid") + controller.last_seen = datetime.now() + controller.os_version = version + await controller.update() + + +@router.get("/update") +async def get_firmware_version(id: uuid.UUID, secret_key: str) -> PlainTextResponse: + controller = await Controller.objects.get_or_none(id=id, secret_key=secret_key) + if controller is None: + return PlainTextResponse(status_code=404, content="Key and/or id invalid") + if controller.wanted_os_version is None: + return PlainTextResponse(status_code=400, content="No update needed") + return PlainTextResponse(status_code=200, content=controller.wanted_os_version) diff --git a/classquiz/routers/box_controller/embedded.py b/classquiz/routers/box_controller/embedded/socket.py similarity index 74% rename from classquiz/routers/box_controller/embedded.py rename to classquiz/routers/box_controller/embedded/socket.py index e9df0fc..9d52b45 100644 --- a/classquiz/routers/box_controller/embedded.py +++ b/classquiz/routers/box_controller/embedded/socket.py @@ -2,7 +2,6 @@ # 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 enum -import os import typing from datetime import datetime @@ -15,34 +14,6 @@ from classquiz.socket_server import sio, calculate_score, set_answer router = APIRouter() -class JoinGameInput(BaseModel): - code: str - name: str - - -class JoinGameResponse(BaseModel): - id: str - - -@router.post("/join") -async def join_game(data: JoinGameInput): - game_pin = await redis.get(f"game:cqc:code:{data.code}") - if game_pin is None: - raise HTTPException(status_code=404, detail="Game not found") - game = await redis.get(f"game:{game_pin}") - game = PlayGame.parse_raw(game) - # Check if game is already running - if game.started: - raise HTTPException(status_code=400, detail="Game started already") - # check if username already exists - if await redis.get(f"game_session:{game_pin}:players:{data.name}") is not None: - raise HTTPException(status_code=409, detail="Username already exists") - - player_id = os.urandom(5).hex() - await redis.set(f"game:cqc:player:{player_id}", data.name) - return JoinGameResponse(id=f"{player_id}:{game_pin}") - - class SubmitAnswerInput(BaseModel): answer: int @@ -86,11 +57,14 @@ async def submit_answer_fn(data_answer: int, game_pin: str, player_id: str, now: answers = await redis.get(f"game_session:{game_pin}:{game.current_question}") answers = await set_answer(answers, game_pin=game_pin, data=answer_data, q_index=game.current_question) player_count = await redis.scard(f"game_session:{game_pin}:players") - print(player_count, answers) + await sio.emit("player_answer", {}) if answers is not None and len(answers.__root__) == player_count: await sio.emit("everyone_answered", {}) +button_to_index_map = {"y": 0, "r": 3, "g": 1, "b": 2} + + class WebSocketTypes(enum.Enum): ButtonPress = "bp" Error = "e" @@ -103,19 +77,16 @@ class WebSocketRequest(BaseModel): wss_clients = {} -button_to_index_map = {"b": 0, "g": 1, "y": 2, "r": 3} - -@router.websocket("/socket/{id}") -async def websocket_endpoint(ws: WebSocket, game_id: str, id: str): +@router.websocket("/{game_id}") +async def websocket_endpoint(ws: WebSocket, game_id: str): try: - if id in wss_clients: + if game_id in wss_clients: await ws.close(code=status.WS_1001_GOING_AWAY) - print(f"Client {id} already exists.") + print(f"Client {game_id} already exists.") return - await ws.accept() - wss_clients[id] = ws + wss_clients[game_id] = ws player_id, game_pin = game_id.split(":") if player_id is None or game_pin is None: @@ -133,7 +104,10 @@ async def websocket_endpoint(ws: WebSocket, game_id: str, id: str): raw_data = await ws.receive_text() try: data = WebSocketRequest.parse_raw(raw_data) - except ValidationError: + except ValidationError as e: + print("ValError") + print(e) + print(raw_data) await ws.send_text(WebSocketRequest(type=WebSocketTypes.Error, data="ValidationError").json()) continue @@ -144,10 +118,9 @@ async def websocket_endpoint(ws: WebSocket, game_id: str, id: str): except (KeyError, AttributeError): await ws.send_text(WebSocketRequest(type=WebSocketTypes.Error, data="InvalidButton").json()) continue - await submit_answer_fn(answer_index, game_pin, player_id, now) - print(f"Data from client {id}: {data}") + print(f"Data from client {game_id}: {data}") except WebSocketDisconnect as ex: - print(f"Client {id} is disconnected: {ex}") - wss_clients.pop(id, None) + print(f"Client {game_id} is disconnected: {ex}") + wss_clients.pop(game_id, None) diff --git a/classquiz/routers/box_controller/web.py b/classquiz/routers/box_controller/web.py index ee6abb2..92a0f8a 100644 --- a/classquiz/routers/box_controller/web.py +++ b/classquiz/routers/box_controller/web.py @@ -1,52 +1,102 @@ # 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 random +import os +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, PlayGame +from classquiz.db.models import User, Controller from classquiz.config import redis +from classquiz.helpers.box_controller import generate_code router = APIRouter() -def generate_code() -> str: - specified_length = 6 - buttons = [ - "B", - "b", - "G", - "g", - "Y", - "y", - "R", - "r", - ] # Capital stands for long press, lowercase letter for short press - resulting_code = "" - for _ in range(specified_length): - resulting_code += random.choice(buttons) - return resulting_code +class SetControllerUpInput(BaseModel): + player_name: str | None + name: str -class ActivateCqbForCurrentQuizInput(BaseModel): - game_pin: int - - -class ActivateCqbForCurrentQuizResponse(BaseModel): +class SetControllerUpResponse(BaseModel): code: str + id: uuid.UUID -@router.post("/activate-for-quiz", response_model=ActivateCqbForCurrentQuizResponse) -async def activate_cqc_for_current_quiz(data: ActivateCqbForCurrentQuizInput, user: User = Depends(get_current_user)): - redis_res = await redis.get(f"game:{data.game_pin}") - if redis_res is None: - raise HTTPException(status_code=404, detail="Game not found") - game_data = PlayGame.parse_raw(redis_res) - if game_data.user_id != user.id: - raise HTTPException(status_code=401, detail="The quiz wasn't started by you") - code = generate_code() - await redis.set(f"game:cqc:code:{code}", game_data.game_pin, ex=3600) - return ActivateCqbForCurrentQuizResponse(code=code) +@router.post("/setup") +async def set_controller_up( + input_data: SetControllerUpInput, user: User = Depends(get_current_user) +) -> SetControllerUpResponse: + code = generate_code(10) + if input_data.player_name is None: + input_data.player_name = user.username + data = Controller( + id=uuid.uuid4(), + user=user, + secret_key=os.urandom(12).hex(), + player_name=input_data.player_name, + last_seen=None, + first_seen=None, + name=input_data.name, + os_version=None, + ) + await data.save() + await redis.set(f"controller_setup:{code}", data.id.hex, ex=900) + return SetControllerUpResponse(code=code, id=data.id) + + +GetControllerResponse = Controller.get_pydantic(exclude={"secret_key", "user"}) + + +@router.get("/controller") +async def get_controller(id: uuid.UUID, user: User = Depends(get_current_user)) -> GetControllerResponse: + controller = await Controller.objects.get_or_none(id=id, user=user.id) + if controller is None: + raise HTTPException(status_code=404, detail="Controller not found") + return GetControllerResponse(**controller.dict()) + + +class ModifyControllerInput(BaseModel): + id: uuid.UUID + player_name: str + name: str + + +@router.post("/modify") +async def modify_controller( + data: ModifyControllerInput, user: User = Depends(get_current_user) +) -> GetControllerResponse: + controller = await Controller.objects.get_or_none(id=data.id, user=user.id) + if controller is None: + raise HTTPException(status_code=404, detail="Controller not found") + controller.player_name = data.player_name + controller.name = data.name + await controller.update() + return GetControllerResponse(**controller.dict()) + + +@router.get("/list") +async def get_all_controllers(user: User = Depends(get_current_user)) -> list[GetControllerResponse]: + controllers = await Controller.objects.all(user=user.id) + if len(controllers) == 0: + return [] + return_list = [] + for controller in controllers: + return_list.append(GetControllerResponse(**controller.dict())) + return return_list + + +class SetVersionToBeFlashedInput(BaseModel): + id: uuid.UUID + version: str + + +@router.post("/set_update") +async def set_version_to_be_flashed(data: SetVersionToBeFlashedInput, user: User = Depends(get_current_user)): + controller = await Controller.objects.get_or_none(id=data.id, user=user.id) + if controller is None: + raise HTTPException(status_code=404, detail="Controller not found") + controller.wanted_os_version = data.version + await controller.update() diff --git a/classquiz/routers/quiz.py b/classquiz/routers/quiz.py index 6df16cd..9de523b 100644 --- a/classquiz/routers/quiz.py +++ b/classquiz/routers/quiz.py @@ -3,7 +3,6 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. import json -import random import re import uuid from datetime import datetime @@ -19,6 +18,7 @@ from pydantic import ValidationError, BaseModel from classquiz.auth import get_current_user from classquiz.config import redis, settings, storage, meilisearch from classquiz.db.models import Quiz, User, PlayGame, GameInLobby, QuizQuestion +from classquiz.helpers.box_controller import generate_code from classquiz.kahoot_importer.import_quiz import import_quiz import urllib.parse @@ -70,24 +70,6 @@ async def get_public_quiz(quiz_id: str): return PublicQuizResponse(**quiz.dict()) -def generate_code() -> str: - specified_length = 6 - buttons = [ - "B", - "b", - "G", - "g", - "Y", - "y", - "R", - "r", - ] # Capital stands for long press, lowercase letter for short press - resulting_code = "" - for _ in range(specified_length): - resulting_code += random.choice(buttons) - return resulting_code - - @router.post("/start/{quiz_id}") async def start_quiz( quiz_id: str, @@ -131,7 +113,7 @@ async def start_quiz( ) code = None if cqcs_enabled: - code = generate_code() + code = generate_code(6) await redis.set(f"game:cqc:code:{code}", game_pin, ex=3600) 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) diff --git a/frontend/src/lib/components/controller/code.svelte b/frontend/src/lib/components/controller/code.svelte new file mode 100644 index 0000000..581c5b0 --- /dev/null +++ b/frontend/src/lib/components/controller/code.svelte @@ -0,0 +1,22 @@ + + +
+ {#each code as c} +
+

{c}

+ +
+ {/each} +
diff --git a/frontend/src/lib/dashboard/start_game.svelte b/frontend/src/lib/dashboard/start_game.svelte index f148a6a..cda18af 100644 --- a/frontend/src/lib/dashboard/start_game.svelte +++ b/frontend/src/lib/dashboard/start_game.svelte @@ -148,12 +148,7 @@ />
-
diff --git a/migrations/versions/32649a1ffcf2_added_controllers_table.py b/migrations/versions/32649a1ffcf2_added_controllers_table.py new file mode 100644 index 0000000..4aeaefc --- /dev/null +++ b/migrations/versions/32649a1ffcf2_added_controllers_table.py @@ -0,0 +1,37 @@ +"""Added Controllers Table + +Revision ID: 32649a1ffcf2 +Revises: 89c4b5d547aa +Create Date: 2023-06-30 01:24:43.124764 + +""" +from alembic import op +import sqlalchemy as sa +import ormar + +# revision identifiers, used by Alembic. +revision = "32649a1ffcf2" +down_revision = "89c4b5d547aa" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "controller", + sa.Column("id", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=False), + sa.Column("user", ormar.fields.sqlalchemy_uuid.CHAR(32), nullable=True), + sa.Column("secret_key", sa.String(length=24), nullable=False), + sa.Column("player_name", sa.Text(), nullable=False), + sa.Column("last_seen", sa.DateTime(), nullable=True), + sa.Column("first_seen", sa.DateTime(), nullable=True), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("os_version", sa.Text(), nullable=True), + sa.Column("wanted_os_version", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["user"], ["users.id"], name="fk_controller_users_id_user"), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("controller")