mathly / app /science.py
3v324v23's picture
feat(science): implement interactive riddle voting system with CSS animations
a4bc651
Raw History Blame Contribute Delete
8.93 kB
"""The science path (`science.json`) and its class votes.
A riddle skill is solved by the class together, Kahoot style: someone (usually the teacher, on the board) opens a
vote and gets a 4-digit code, the students join with it and each picks an answer. Votes stay hidden until the host
presses check; then the answer most students chose is the one that is tried, and the rocket flies, falls back
(not enough fuel) or stays on the pad (too heavy). A tie is not checked: the tied answers are shown and the class
may change votes. The host can then move the room to the next riddle.
Rooms live in this process's memory: the app runs as one uvicorn process (see the Dockerfile), and a vote is
a few minutes of a lesson, so a restart only ends the votes in progress. A room is forgotten after ROOM_TTL
seconds without activity.
"""
from __future__ import annotations
import json
import secrets
import time
from functools import cache
from pathlib import Path
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
GRAPH = Path(__file__).with_name("science.json")
OUTCOMES = ("flies", "falls", "stuck")
ROOM_TTL = 3 * 60 * 60
MAX_ROOMS = 500
MAX_VOTERS = 200
def problems(graph: dict) -> list[str]:
"""What is wrong with the science path: an unknown unit, or a riddle without exactly one answer that flies."""
out = [] if str(graph.get("subject") or "").strip() else ["subject is required"]
units = {u["id"] for u in graph.get("units", [])}
for skill in graph.get("skills", []):
sid = skill.get("id", "?")
if skill.get("unit") not in units:
out.append(f"{sid}: unknown unit {skill.get('unit')}")
riddles = skill.get("riddles")
if not isinstance(riddles, list) or not riddles:
out.append(f"{sid}: needs riddles")
continue
for n, riddle in enumerate(riddles, 1):
where = f"{sid} riddle {n}"
for field in ("title", "story", "question"):
if not str(riddle.get(field) or "").strip():
out.append(f"{where}: {field} is required")
choices = riddle.get("choices") or []
if len(choices) < 2:
out.append(f"{where}: needs at least two choices")
if len({c.get("text") for c in choices}) != len(choices):
out.append(f"{where}: two choices read the same")
if sum(c.get("outcome") == "flies" for c in choices) != 1:
out.append(f"{where}: exactly one choice must fly")
for c in choices:
if c.get("outcome") not in OUTCOMES:
out.append(f"{where}: unknown outcome {c.get('outcome')}")
if not str(c.get("why") or "").strip():
out.append(f"{where}: choice {c.get('text')} needs a why")
return out
@cache
def science() -> dict:
graph = json.loads(GRAPH.read_text(encoding="utf-8"))
if found := problems(graph):
raise ValueError(f"{GRAPH.name}: " + "; ".join(found))
return graph
def public_path() -> dict:
"""The path for the screen: riddles are only counted here, their answers come with a room."""
graph = science()
return {"subject": graph["subject"], "units": graph["units"],
"skills": [{k: v for k, v in s.items() if k != "riddles"} | {"riddles": len(s["riddles"])}
for s in graph["skills"]]}
def skill_of(skill_id: str) -> dict:
found = next((s for s in science()["skills"] if s["id"] == skill_id), None)
if found is None:
raise HTTPException(404, "unknown riddle")
return found
class Room:
def __init__(self, code: str, skill: dict):
self.code, self.skill, self.host = code, skill, secrets.token_urlsafe(16)
self.index, self.votes, self.checked, self.tie = 0, {}, None, []
self.touched = time.time()
@property
def riddle(self) -> dict:
return self.skill["riddles"][self.index]
def counts(self) -> list[int]:
out = [0] * len(self.riddle["choices"])
for choice in self.votes.values():
out[choice] += 1
return out
def check(self) -> None:
"""Try the answer most voters chose; a tie leaves the vote open with the tied answers named."""
counts = self.counts()
top = max(counts)
if top == 0:
raise HTTPException(400, "no votes yet")
leaders = [i for i, n in enumerate(counts) if n == top]
if len(leaders) > 1:
self.tie = leaders
return
self.tie, self.checked = [], leaders[0]
def state(self, voter: str, host: str) -> dict:
riddle, counts = self.riddle, self.counts()
out = {"code": self.code, "skill": self.skill["id"], "name": self.skill["name"],
"host": bool(host) and secrets.compare_digest(host, self.host),
"n": self.index + 1, "of": len(self.skill["riddles"]),
"title": riddle["title"], "story": riddle["story"], "question": riddle["question"],
"choices": [c["text"] for c in riddle["choices"]],
"votes": len(self.votes), "mine": self.votes.get(voter), "tie": self.tie,
"phase": "checked" if self.checked is not None else "voting"}
if self.checked is not None:
picked = riddle["choices"][self.checked]
out |= {"counts": counts, "picked": self.checked, "outcome": picked["outcome"], "why": picked["why"],
"right": next(i for i, c in enumerate(riddle["choices"]) if c["outcome"] == "flies")}
return out
class StartBody(BaseModel):
skill: str = Field(max_length=64)
class VoteBody(BaseModel):
voter: str = Field(min_length=8, max_length=64)
choice: int
n: int # the riddle the vote is for, so a late vote does not land on the next one
def install_science_routes(app: FastAPI) -> None:
rooms: dict[str, Room] = {}
app.state.science_rooms = rooms
def prune() -> None:
now = time.time()
for code in [c for c, r in rooms.items() if now - r.touched > ROOM_TTL]:
del rooms[code]
def room_of(code: str) -> Room:
prune()
room = rooms.get(code)
if room is None:
raise HTTPException(404, "no vote with this code")
room.touched = time.time()
return room
def hosted(code: str, host: str) -> Room:
room = room_of(code)
if not host or not secrets.compare_digest(host, room.host):
raise HTTPException(403, "only the one who opened the vote can do this")
return room
@app.get("/api/science")
async def science_path():
return JSONResponse(public_path(), headers={"Cache-Control": "no-store"})
@app.post("/api/science/rooms")
async def open_room(body: StartBody):
skill = skill_of(body.skill)
prune()
if len(rooms) >= MAX_ROOMS:
raise HTTPException(503, "too many votes are open right now")
code = f"{secrets.randbelow(9000) + 1000}"
while code in rooms:
code = f"{secrets.randbelow(9000) + 1000}"
room = rooms[code] = Room(code, skill)
return {**room.state("", room.host), "host_key": room.host}
@app.get("/api/science/rooms/{code}")
async def room_state(code: str, voter: str = "", x_host: str = Header("")):
return JSONResponse(room_of(code).state(voter, x_host), headers={"Cache-Control": "no-store"})
@app.post("/api/science/rooms/{code}/vote")
async def vote(code: str, body: VoteBody, x_host: str = Header("")):
room = room_of(code)
if body.n != room.index + 1 or room.checked is not None:
raise HTTPException(409, "this riddle is no longer open for votes")
if not 0 <= body.choice < len(room.riddle["choices"]):
raise HTTPException(400, "unknown choice")
if body.voter not in room.votes and len(room.votes) >= MAX_VOTERS:
raise HTTPException(409, "this vote is full")
room.votes[body.voter] = body.choice
return room.state(body.voter, x_host)
@app.post("/api/science/rooms/{code}/check")
async def check(code: str, voter: str = "", x_host: str = Header("")):
room = hosted(code, x_host)
if room.checked is None:
room.check()
return room.state(voter, x_host)
@app.post("/api/science/rooms/{code}/next")
async def next_riddle(code: str, voter: str = "", x_host: str = Header("")):
room = hosted(code, x_host)
if room.checked is None:
raise HTTPException(409, "check this riddle first")
if room.index + 1 >= len(room.skill["riddles"]):
raise HTTPException(409, "this was the last riddle")
room.index, room.votes, room.checked, room.tie = room.index + 1, {}, None, []
return room.state(voter, x_host)