overhaul: single-session deployment + redesigned frontend

Backend simplification:
- The server now loads ONE pool JSON from $QUIZ_POOL_PATH at startup and
  upserts a single canonical session. The session id comes from the pool
  JSON's optional "session_id" field, falling back to $QUIZ_SESSION_ID.
- The multi-quiz / multi-session CRUD API is gone:
    DELETED  GET/POST /admin/api/quizzes
    DELETED  POST    /admin/api/quizzes/upload
    DELETED  GET/POST /admin/api/sessions
    DELETED  GET     /admin/login (HTML stub)
    DELETED  GET     /admin/api/sessions/{sid}/csv (replaced by /admin/api/csv)
  Replaced with a single-session control surface:
    GET  /admin/                — serves admin.html unconditionally
    GET  /admin/api/state       — admin-gated; pool meta + state + QR + join URL
    POST /admin/api/reset       — admin-gated; wipe submissions + back to lobby
    POST /admin/logout          — clear admin cookie
    GET  /admin/api/csv         — single-session results
    WS   /ws/instructor/{sid}   — kept; new commands "next" + "reset"
- Instructor "Next" button is now a single state-driving command
  (RoomManager.advance_to_next): from lobby it opens Q0; from question_open
  it closes the current Q and opens the next; from question_closed it
  opens the next; if past the last question it ends the session.
- New RoomManager.reset wipes submissions, participants, and per-question
  state, then broadcasts a clean lobby.
- Student GET / now redirects to /?sid=<canonical> when no sid is given,
  so the QR / share URL is fully deterministic.

Frontend rewrite (functional baseline; visual polish to follow):
- /admin/ is now a single SPA: GET /admin/api/state decides login form
  vs dashboard. No separate /admin/login URL bar.
- Admin dashboard is state-driven with one primary action per state.
  QR code, join URL, and live participant list are always visible on the
  left so the operator can leave the page on a projector.
- Student answer buttons are big and tappable; reveal screen highlights
  correct/wrong choice + shows score, total, and rank.
- Static admin/student SPAs share a CSS palette with light/dark support.

Tests rewritten around the single canonical session id.
The auto-bootstrapped session lets each test fixture skip the old
quiz/session creation dance. 39/39 tests pass.

Cleanup:
- Deleted CODEX_PROMPT.md, IMPLEMENTATION_REPORT.md, NOTES.md, SPEC.md,
  static/observer.html (obsolete codex-build artifacts and the unused
  observer view).
- .gitignore now blocks /pool.json (the runtime file the operator drops
  on the server) and the leftover .codex_done / codex_run.log / etc.
- bootstrap.sh seeds /opt/quiz/pool.json from examples/pool_example.json
  on first deploy so a fresh box reaches a usable state without manual
  intervention; .env now includes QUIZ_POOL_PATH.
This commit is contained in:
ameer
2026-05-02 21:13:54 +08:00
parent 32c531247d
commit e7a2f0387b
29 changed files with 1696 additions and 1533 deletions

View File

@@ -1,6 +1,15 @@
# SQLite database path
QUIZ_DB_PATH=./quiz.db
# Path to the single quiz pool JSON the server loads at startup.
# Replace ./pool.json with your week's actual pool. The server creates
# (or upserts) one canonical session per restart from this file.
QUIZ_POOL_PATH=./pool.json
# Canonical session id used in URLs and the join QR. The pool JSON's
# optional top-level "session_id" field overrides this.
QUIZ_SESSION_ID=main
# Required cookie signing secret
QUIZ_SECRET_KEY=change-me-to-a-random-secret

3
.gitignore vendored
View File

@@ -15,6 +15,9 @@ quiz.db
# Only generic demo pools tracked under examples/pool_example.json.
examples/*_pool.json
!examples/pool_example.json
# The runtime pool the server reads from disk lives at the repo root.
# Operators populate it; it stays out of version control.
/pool.json
# Codex build leftovers
.codex_done

View File

@@ -1,55 +0,0 @@
# Codex implementation brief — Live In-Lecture Quiz Portal
You are implementing a complete, production-quality web application from a detailed specification. The user (Prof. Ameer H. Khan) wants to use this for an in-lecture engagement quiz with his students next week.
## Required reading
**Read `SPEC.md` in full before writing any code.** It is the authoritative specification. Sections 1-18 cover everything: routes, data model, state machine, WebSocket protocol, frontend pages, security, project layout, and acceptance criteria. Implement exactly what the spec says.
## Working directory
You are at `/home/ameer/RD/Projects/Apps/quiz/`. This is your workspace root. The spec, a baseline git commit, and `.git/` are already in place.
## Your responsibilities, end-to-end
1. **Implement** the full application per `SPEC.md` — backend (FastAPI + websockets + aiosqlite), frontend (vanilla HTML/CSS/JS), data model, scoring, state machine, all routes.
2. **Set up** the project structure exactly as specified in §12 (`pyproject.toml`, `.env.example`, `.gitignore`, `app/`, `static/`, `tests/`, `examples/`).
3. **Author** an example question pool at `examples/week9_pool.json` with 10 plausible MCQs about Computer Organization Week 9 recap topics (CPU structure, multi-cycle datapath, hardwired control unit, FSM, microprogrammed control, hardwired vs microprogrammed tradeoffs). It is fine to generate plausible technical content; the user will replace with his real questions later.
4. **Write tests** as listed in §14 — unit, API, WebSocket, edge-case, and the load-simulation test.
5. **Run tests iteratively** until all pass: `pytest -q` green, `pytest --cov=app` at least 80% line coverage on `app/`. Fix bugs as they surface. Iterate until clean.
6. **Smoke test** by starting `uvicorn app.main:app` with a test `.env`, verify it boots without errors and `GET /healthz` responds.
7. **Document** in `README.md` (install + run + test + manual smoke test) and `NOTES.md` (any non-obvious choices or deviations from the spec, per §17).
8. **Commit incrementally** to git as you go — separate commits for "scaffold project", "data model + db", "scoring + pool validation", "auth + cookies", "student API + WS", "admin API + WS", "frontend student", "frontend admin", "tests", "fixes from test runs". This makes the human review tractable.
## Operational rules
- **Use `python3.11` or newer** (system has `python3` available; check version; if <3.11, document fallback in NOTES).
- **Create a `.venv`** inside the project dir, install deps via `pip install -e '.[dev]'`. Do NOT use conda or shared envs.
- **Pin dependencies** with compatible-release operators (e.g. `fastapi~=0.115`).
- **Do not** invent new features beyond the spec. Where the spec is silent, pick the simplest reasonable option and document in `NOTES.md`.
- **Do not** add Docker, systemd, Caddy, DNS, or any deployment config. §15 explicitly defers these to the human.
- **Do not** commit `.env`, `quiz.db`, `__pycache__`, or `.venv` (`.gitignore` handles this).
- **Do not** add em-dashes (`—` or `---`) in user-visible text (frontend strings, README, error messages). Use commas, semicolons, periods, parens, or colons. (User preference; internal scratch is unconstrained.)
## When you are done
Write a final summary to `IMPLEMENTATION_REPORT.md` in the project root covering:
- What was built (file inventory + LoC counts).
- Test results: full `pytest -q` output, coverage percentage.
- Any deviations from the spec (with rationale).
- How to run locally (3-5 lines of shell).
- Any open issues, known bugs, or things you noticed but couldn't fix.
- Anything you'd recommend the human review carefully.
Then stop. Do not deploy, do not push to remote, do not modify anything outside `/home/ameer/RD/Projects/Apps/quiz/`.
## Acceptance bar
The implementation is acceptable when:
- All test categories from SPEC §14 are present.
- `pytest -q` is fully green.
- Line coverage on `app/` is ≥ 80%.
- The load simulation test passes with 50 simulated students.
- `uvicorn app.main:app` boots without errors.
- `README.md` and `NOTES.md` are present and informative.
- Git history shows incremental commits.
If you hit a blocker you genuinely cannot resolve, document it clearly in `IMPLEMENTATION_REPORT.md` under "Open issues" and continue with the rest of the work; do not halt the whole job for a single recoverable issue.

View File

@@ -1,67 +0,0 @@
# Implementation Report
## Built
Implemented the live in-lecture quiz portal per `SPEC.md`: FastAPI backend, SQLite schema with WAL, signed student and admin cookies, quiz pool validation, modular scoring, student/admin APIs, WebSocket state machine, auto-close, CSV export, vanilla student/admin frontends, example Week 9 question pool, docs, and tests.
File inventory and line counts:
```text
app/: 1400 lines
static/: 775 lines
tests/: 504 lines
examples/week9_pool.json: 127 lines
total across app, static, tests, examples: 2806 lines
```
## Test Results
`pytest -q`:
```text
31 passed, 33 warnings in 5.43s
```
`pytest --cov=app`:
```text
31 passed, 33 warnings in 5.48s
TOTAL 854 statements, 67 missed, 92.15% coverage
Required test coverage of 80.0% reached.
```
Smoke test:
```text
uvicorn app.main:app started successfully on 127.0.0.1:8001
GET /healthz returned {"ok":true,"version":"0.1.0","sessions_active":0,"ws_clients":0}
```
## Run Locally
```bash
python3 -m venv .venv
. .venv/bin/activate
pip install -e '.[dev]'
cp .env.example .env
uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload
```
## Deviations and Notes
- Server-side QR SVG data URLs are used instead of a client-side QR library.
- `live_histogram` is pushed on every accepted submission, not throttled.
- Broadcast sends are queued so slow WebSocket clients do not block state changes.
- `static/observer.html` is a placeholder because the observer page is optional.
- FastAPI emits a non-fatal `on_event` deprecation warning with the installed package version.
## Open Issues
No known functional blockers. The admin UI is intentionally plain and should be reviewed with the instructor workflow in mind before classroom use.
## Review Carefully
- The late-join behavior and missed-submission rows.
- The session control flow from `lobby` through `finished`.
- CSV shape against the exact spreadsheet format wanted for class records.
- The generated example questions, because they are plausible placeholders.

View File

@@ -1,15 +0,0 @@
# Notes
## Implementation Choices
- QR codes are generated server-side with the Python `qrcode` package and returned as an SVG data URL from session creation.
- `live_histogram` is pushed on every accepted submission. There is no throttling in v1 because the acceptance load is small and this keeps behavior simple.
- WebSocket broadcast sends are queued as background tasks. This prevents one slow classroom device from blocking state transitions or auto-close.
- The optional observer page exists as a placeholder only. The spec explicitly marks it optional.
- Tests use one FastAPI `TestClient` portal for multi-student WebSocket simulation. Each socket is opened after joining as that student, and the stored socket identity remains stable after the cookie is overwritten for the next simulated student.
- Python 3.14.4 was used locally. The project requires Python 3.11 or newer.
## Deviations
- The admin frontend includes a compact one-question sample in the Add Pool modal for convenience. The full 10-question Week 9 pool is in `examples/week9_pool.json`.
- FastAPI emits a deprecation warning for `on_event` under the installed version. Startup works correctly, and the warning is not user-facing.

533
SPEC.md
View File

@@ -1,533 +0,0 @@
# Live In-Lecture Quiz Portal — Implementation Spec
**Project:** `quiz` (live-paced classroom engagement quiz, Kahoot-style)
**Build location:** `/home/ameer/RD/Projects/Apps/quiz/`
**Public domain (planned):** `quiz.ahkhan.me`
**Owner:** Prof. Ameer H. Khan, Taizhou University
**Spec version:** 1.0 (2026-05-01)
**Stack (locked):** Python 3.11+, FastAPI, `websockets` (via FastAPI's WebSocket support), `aiosqlite`, vanilla HTML+CSS+JS frontend (no framework, no build step).
This is a complete, build-ready specification. Implement exactly what is here. Do not invent new features. Where the spec is silent, choose the simplest reasonable option and document it briefly in code comments or in `NOTES.md`.
---
## 1. High-level flow
A teacher starts a quiz session. Students scan a QR code or open a URL like `https://quiz.ahkhan.me/?sid=ABC123`. They identify themselves once with student ID + name (saved as a signed cookie). They wait in a lobby. The teacher opens questions one at a time from an admin dashboard. Students answer within a configurable time window (default 60s); earlier correct answers score more points (linear time decay). After each question, the teacher reveals the answer and a top-5 leaderboard. At the end, the teacher downloads a CSV of all results.
**This is a participation/engagement tool, not a summative assessment.** Anti-cheating is intentionally minimal (a single signed cookie discourages but does not prevent proxy answering); the in-lecture paper attendance sheet remains the load-bearing presence check.
---
## 2. Personas
- **Instructor** (1 per session): authenticates with a shared admin password (env-configured). Creates sessions, opens/closes questions, sees all stats, downloads CSV.
- **Student** (N per session): identifies with student ID + name, persists via cookie, answers questions when revealed, sees own rank + top 5.
---
## 3. URL routes
### Student-facing
| Path | Method | Purpose |
|---|---|---|
| `/?sid=<code>` | GET | Student entry. If `sid` missing/invalid → "Ask your instructor for the link" page. Otherwise: serve student SPA. |
| `/api/session/<sid>` | GET | Public session metadata: `{title, state, current_question_idx, time_limit_default}` (no quiz content). |
| `/api/session/<sid>/join` | POST | Body: `{student_id, name}`. Sets signed cookie, creates `participants` row (or updates name if `student_id` already present). Returns `{ok, cookie_id}`. |
| `/api/session/<sid>/me` | GET | Cookie-authenticated. Returns `{student_id, name, total_score, submissions: [...]}` for the current student. |
| `/api/session/<sid>/stats` | GET | Public stats for end-of-question display: `{question_idx, response_time_avg_ms, response_time_distribution, average_score, top5: [{rank, name, score}], your_rank?}`. Cookie-aware for `your_rank`. |
| `/ws/student/<sid>` | WebSocket | Cookie-authenticated. Per-client connection for state updates and submissions. |
### Instructor-facing
| Path | Method | Purpose |
|---|---|---|
| `/admin/login` | GET / POST | Login form. POST: body `{password}`. On success sets `admin` signed cookie. |
| `/admin/` | GET | Admin dashboard SPA (requires admin cookie). |
| `/admin/api/quizzes` | GET / POST | List quiz pools / create new (POST: body `{title, pool_json, time_limit_default}`). |
| `/admin/api/quizzes/upload` | POST | Multipart upload of a pool JSON file (alternative to direct POST). |
| `/admin/api/sessions` | GET / POST | List sessions / create new (POST: body `{quiz_id}` returns `{sid, qr_url, join_url}`). |
| `/admin/api/sessions/<sid>/csv` | GET | Download final results CSV. |
| `/ws/instructor/<sid>` | WebSocket | Admin-cookie-authenticated. Sends control commands, receives all real-time events. |
### Other
| Path | Method | Purpose |
|---|---|---|
| `/healthz` | GET | Returns `{ok: true, version, sessions_active, ws_clients}`. |
| `/static/*` | GET | Static frontend assets (`student.html`, `admin.html`, `quiz.js`, `admin.js`, `style.css`, etc.). |
---
## 4. Data model (SQLite, via `aiosqlite`)
Tables:
```sql
CREATE TABLE quizzes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
pool_json TEXT NOT NULL, -- the question pool, see §6
time_limit_default INTEGER NOT NULL DEFAULT 60, -- seconds
score_fn_name TEXT NOT NULL DEFAULT 'linear_decay',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE quiz_sessions (
sid TEXT PRIMARY KEY, -- 6-char Crockford base32, see §10
quiz_id INTEGER NOT NULL REFERENCES quizzes(id),
title TEXT NOT NULL, -- snapshot of quiz title at session-create
state TEXT NOT NULL DEFAULT 'lobby', -- 'lobby'|'question_open'|'question_closed'|'between_questions'|'finished'
current_question_idx INTEGER, -- NULL when state='lobby' or 'finished'
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP
);
CREATE TABLE participants (
sid TEXT NOT NULL REFERENCES quiz_sessions(sid),
student_id TEXT NOT NULL,
name TEXT NOT NULL,
cookie_id TEXT NOT NULL, -- random uuid stored in cookie too
joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (sid, student_id)
);
CREATE TABLE question_events (
sid TEXT NOT NULL REFERENCES quiz_sessions(sid),
question_idx INTEGER NOT NULL,
opened_at TIMESTAMP NOT NULL,
closed_at TIMESTAMP,
time_limit INTEGER NOT NULL, -- seconds, snapshot at open
PRIMARY KEY (sid, question_idx)
);
CREATE TABLE submissions (
sid TEXT NOT NULL REFERENCES quiz_sessions(sid),
student_id TEXT NOT NULL,
question_idx INTEGER NOT NULL,
answer TEXT, -- option key 'A'|'B'|'C'|'D' or NULL if missed
submitted_at TIMESTAMP, -- NULL if missed
elapsed_ms INTEGER, -- NULL if missed; else server-side measured from opened_at
score INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'submitted', -- 'submitted'|'missed'|'late_join'
PRIMARY KEY (sid, student_id, question_idx)
);
CREATE INDEX idx_submissions_sid_qidx ON submissions(sid, question_idx);
CREATE INDEX idx_participants_sid ON participants(sid);
```
Use WAL mode (`PRAGMA journal_mode=WAL`) for concurrent read-during-write.
---
## 5. State machine
A `quiz_sessions` row moves through these states:
```
lobby ──open_question──> question_open ──close_question──> question_closed
┌────────next (if more Qs)──────┘
between_questions ──open_question──> question_open
┌─end_session─────┘
finished
```
- `lobby`: students can join, no question visible. Can transition to `question_open` (open Q0).
- `question_open`: question visible, accepting submissions. Server auto-transitions to `question_closed` when `time_limit` expires; instructor can also force-close early via `close_question`.
- `question_closed`: correct answer + histogram + leaderboard sent to all clients. Instructor advances via `next``between_questions`.
- `between_questions`: brief intermission, dashboard shows leaderboard. Instructor opens next Q via `open_question`.
- `finished`: session over. Final leaderboard pushed. CSV downloadable from admin.
**Auto-close behavior:** server schedules an asyncio task at `open_question` time that fires after `time_limit` seconds and triggers `close_question` if state is still `question_open` for that question.
**Idempotency:** `open_question(idx)` while already in `question_open` for the same `idx` is a no-op. Opening a different `idx` while `question_open` first auto-closes the current one.
---
## 6. Question pool JSON format
A quiz pool is a JSON document like:
```json
{
"title": "Week 9 Recap — Computer Organization",
"score_fn": "linear_decay",
"time_limit_default": 60,
"questions": [
{
"id": "q1",
"text": "Which signal triggers the writeback to the register file?",
"options": {
"A": "MARWrite",
"B": "RegWrite",
"C": "MemRead",
"D": "PCsrc"
},
"correct": "B",
"time_limit": 60,
"explanation": "RegWrite gates the W-phase writeback into the RF."
},
{
"id": "q2",
"text": "...",
"options": { "A": "...", "B": "...", "C": "...", "D": "..." },
"correct": "C"
}
]
}
```
**Field rules:**
- `score_fn` (optional, default `linear_decay`): name of the scoring function in `app/scoring.py` registry.
- `time_limit_default` (optional, default 60): per-quiz default. Per-question `time_limit` overrides.
- `questions[].time_limit` (optional): per-question override.
- `questions[].explanation` (optional): shown to students after the reveal.
- Options must always be exactly the 4 keys `A`, `B`, `C`, `D`. Correct must be one of those.
**Validation:** validate at quiz-create time; reject pool JSON that violates these rules with a clear error.
---
## 7. Scoring (modular)
Implement `app/scoring.py` with a registry pattern:
```python
SCORE_FNS: dict[str, Callable[[bool, int, int], int]] = {}
def register(name): ...
@register("linear_decay")
def linear_decay(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int:
if not correct:
return 0
elapsed_ms = max(0, min(elapsed_ms, time_limit_ms))
return round(1000 * (1 - 0.5 * elapsed_ms / time_limit_ms))
@register("flat")
def flat(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int:
return 1000 if correct else 0
@register("exponential_decay")
def exponential_decay(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int:
if not correct:
return 0
import math
decay = math.exp(-2 * elapsed_ms / time_limit_ms)
return round(1000 * (0.5 + 0.5 * decay))
```
Scoring is invoked server-side at submission time using the server-measured `elapsed_ms = (now - question_events.opened_at)`. For a late joiner who joins mid-question, `elapsed_ms` is still measured from the question's `opened_at` (NOT join time) so they are scored fairly within the same time window — this matches the user's requirement that late joiners get 0 unless they answer within the original window.
**Important:** The previous discussion considered "decay starts from join time" for late joiners; the user's final clarification is **late/missed = 0**, so we treat late joiners exactly the same as anyone else: if they submit before the window closes they're scored normally by elapsed-since-opened, otherwise 0.
If they connect after the question already auto-closed, their `submissions` row is created with `status='missed', score=0` for that question.
---
## 8. WebSocket protocol
All messages are JSON objects with a `type` field. Use camelCase for field names in JSON only if it conflicts with Python conventions; prefer snake_case throughout for consistency.
### Client → Server (student)
```json
{ "type": "submit", "question_idx": 3, "answer": "B" }
{ "type": "ping" }
```
### Client → Server (instructor)
```json
{ "type": "open_question", "question_idx": 0, "time_limit": 60 }
{ "type": "close_question" }
{ "type": "next" }
{ "type": "end_session" }
{ "type": "ping" }
```
### Server → Student
```json
{ "type": "state", "state": "lobby", "current_question_idx": null, "title": "..." }
{ "type": "question_open",
"question_idx": 3,
"text": "...",
"options": {"A": "...", "B": "...", "C": "...", "D": "..."},
"time_limit": 60,
"opened_at_server_ts": 1714589123456,
"remaining_ms": 58200 }
{ "type": "submit_ack",
"question_idx": 3,
"score": 830,
"elapsed_ms": 4200 }
{ "type": "question_closed",
"question_idx": 3,
"correct": "B",
"explanation": "...",
"your_answer": "B",
"your_score": 830,
"histogram": {"A": 5, "B": 18, "C": 3, "D": 1, "missed": 2},
"top5": [{"rank":1,"name":"...","score":4520}, ...],
"your_rank": 7,
"your_total": 2940 }
{ "type": "between_questions",
"next_idx": 4,
"top5": [...],
"your_rank": 7,
"your_total": 2940 }
{ "type": "session_ended",
"final_top5": [...],
"your_rank": 7,
"your_total": 2940,
"questions_answered": 8,
"questions_correct": 6 }
{ "type": "error", "code": "...", "message": "..." }
{ "type": "pong" }
```
### Server → Instructor
Everything the student gets, plus:
```json
{ "type": "lobby_update",
"participants": [{"student_id":"...","name":"...","joined_at":"..."}],
"count": 24 }
{ "type": "live_histogram", // pushed periodically while question_open
"question_idx": 3,
"histogram": {"A":5,"B":12,"C":2,"D":0,"missed":0,"pending":5},
"submitted_count": 19,
"total_count": 24 }
{ "type": "full_leaderboard", // sent on between_questions and session_ended
"leaderboard": [{"rank":1,"student_id":"...","name":"...","score":...}, ...] }
```
### Reconnection
On WS connect, server immediately sends a `state` message with the current session state. If `state == question_open`, it follows with a `question_open` message including correct `remaining_ms` so a reconnecting client sees the right countdown.
If a student reconnects and they have already submitted for the active question, the `state` follow-up includes a `submit_ack` echoing their stored answer + score.
---
## 9. Frontend pages
Three SPAs (each = single HTML + small JS module). No framework. No build step. Use ES modules served as-is.
### `/static/student.html` (served at `/`)
- Reads `?sid=` from URL.
- If no `sid` or invalid: shows "Ask your instructor for the link" page with a friendly message.
- Calls `GET /api/session/<sid>` to fetch metadata.
- Checks for cookie. If absent or `sid` mismatch: shows ID+name form. On submit: `POST /api/session/<sid>/join`, sets cookie via response.
- After cookie present: opens `/ws/student/<sid>`, renders state-dependent view:
- **Lobby:** "You're in. Waiting for instructor to start." + your name + spinner.
- **Question:** question text, 4 buttons (large, mobile-friendly), countdown bar driven by `remaining_ms` and local clock (resync on every server message). Disable buttons after submit.
- **Submitted (still open):** "✓ Submitted in 4.2s — +830 pts. Wait for the reveal."
- **Question closed:** show correct answer (highlighted green), your answer (highlighted red if wrong), explanation if present, top-5 leaderboard, your rank.
- **Between questions:** "Next question coming up. Your rank: 7/24."
- **Finished:** confetti or similar celebratory framing, final top-5, your rank, summary stats.
- Mobile-first responsive layout. Big tap targets. Dark mode okay.
### `/static/admin.html` (served at `/admin/`)
- Login gate (POST `/admin/login`).
- Sidebar: list of quizzes, list of recent sessions.
- "Create session" button → modal: pick a quiz → returns `sid`, displays large QR code + join URL on screen.
- Active session view (one open at a time):
- Top: session title, state, connected count.
- Lobby tab: live roster.
- Per-question controls: prev | current Q (text + options) | next. Big `[Open]` / `[Close & Reveal]` / `[Next]` buttons.
- Live histogram chart (bar chart of A/B/C/D/missed counts) updates in real time during `question_open`.
- After close: show full leaderboard, correct-answer indicator, "Next" to advance.
- End-session button.
- "Download CSV" button always visible.
- Generate QR codes client-side (use a tiny vendored QR library, e.g. `qrcode-svg` ~3KB or a hand-rolled QR encoder). Or generate server-side as SVG via Python's `qrcode[svg]`. Pick whichever is simpler; document choice.
### `/static/observer.html` (optional, served at `/observe/?sid=...`)
**Skip if time-pressed.** Minimal cookieless view useful to project on a classroom screen: shows current Q + live histogram + leaderboard. Read-only, no submit. Lower priority than the two above.
---
## 10. Identifiers, secrets, cookies
**Session ID (`sid`):** 6-char Crockford base32 (alphabet `0123456789ABCDEFGHJKMNPQRSTVWXYZ`, no I/L/O/U), generated cryptographically random. Display uppercase. ~10⁹ space, ample for collision avoidance at our scale; on collision, regenerate (max 5 retries).
**Student cookie:** name `qz_student`. Value = signed JSON `{sid, student_id, name, cookie_id}`. Use `itsdangerous.URLSafeSerializer` with `SECRET_KEY` env var. Cookie attributes: `HttpOnly`, `SameSite=Lax`, `Secure` (when behind HTTPS), `Path=/`, `Max-Age=31536000` (1 year).
**Admin cookie:** name `qz_admin`. Value = signed `{is_admin: true, ts: ...}`. Same security attributes. `Max-Age=86400` (1 day).
**Admin password:** env var `QUIZ_ADMIN_PASSWORD`. Reject login if env var unset (don't allow unauthenticated admin).
**Single-cookie design (per spec lock):** the cookie holds everything for one identity. Clearing the cookie loses all in-progress state on the client; server still has the participant row. If a cleared-cookie student rejoins with a new `student_id`, they get a fresh participant row — duplicate participation, but server has both records. We do NOT actively block this (warn-only at most). The friction of clearing the cookie + re-entering data is the soft anti-proxy deterrent.
---
## 11. Configuration (env vars)
| Var | Default | Purpose |
|---|---|---|
| `QUIZ_DB_PATH` | `./quiz.db` | SQLite file path |
| `QUIZ_SECRET_KEY` | (required) | Cookie signing key |
| `QUIZ_ADMIN_PASSWORD` | (required) | Admin login password |
| `QUIZ_HOST` | `127.0.0.1` | uvicorn bind host |
| `QUIZ_PORT` | `8001` | uvicorn bind port |
| `QUIZ_PUBLIC_URL` | `https://quiz.ahkhan.me` | Used to construct join URLs and QR codes |
| `QUIZ_LOG_LEVEL` | `INFO` | Logging level |
Provide a `.env.example` file listing all of these with comments. Never commit real values.
---
## 12. Project layout
```
quiz/
├── README.md # how to run, dev workflow
├── SPEC.md # this file (already exists)
├── NOTES.md # implementation choices, edge cases, deviations
├── pyproject.toml # FastAPI, uvicorn, websockets, aiosqlite, itsdangerous, python-multipart, qrcode, pytest, pytest-asyncio, httpx, etc.
├── .env.example
├── .gitignore # quiz.db, .venv, __pycache__, .env
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app factory, route registration
│ ├── config.py # env var loading
│ ├── db.py # aiosqlite connection, schema migrations
│ ├── auth.py # cookie signing, admin login
│ ├── models.py # pydantic models for API requests/responses
│ ├── pool.py # quiz pool JSON validation
│ ├── scoring.py # score function registry
│ ├── room.py # room manager (in-process state, WS broadcast, autoclose tasks)
│ ├── routes_student.py # student API + WS
│ ├── routes_admin.py # admin API + WS
│ └── csv_export.py # CSV download formatter
├── static/
│ ├── student.html
│ ├── admin.html
│ ├── observer.html # optional
│ ├── quiz.js
│ ├── admin.js
│ ├── style.css
│ └── vendor/ # any tiny vendored libs (e.g., QR encoder)
├── tests/
│ ├── conftest.py
│ ├── test_pool.py
│ ├── test_scoring.py
│ ├── test_auth.py
│ ├── test_api_student.py
│ ├── test_api_admin.py
│ ├── test_ws_student.py
│ ├── test_ws_admin.py
│ ├── test_state_machine.py
│ ├── test_late_join.py
│ ├── test_reconnect.py
│ ├── test_csv_export.py
│ └── test_load_simulation.py # see §14
└── examples/
└── week9_pool.json # sample pool covering W9 recap topics
```
---
## 13. Dev workflow
```bash
cd /home/ameer/RD/Projects/Apps/quiz
python3.11 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
cp .env.example .env
# edit .env with QUIZ_SECRET_KEY (random) + QUIZ_ADMIN_PASSWORD
pytest # all tests must pass
uvicorn app.main:app --reload # local dev server on :8001
```
Open `http://127.0.0.1:8001/admin/`, log in with admin password, create a session from the example pool, get the `sid`. Open `http://127.0.0.1:8001/?sid=<that>` in another browser/incognito to test as student.
---
## 14. Required tests
All tests use `pytest` + `pytest-asyncio` + FastAPI's `TestClient` (sync) and `httpx.AsyncClient` (async, for WS). Use a fresh in-memory or temp-file SQLite per test.
### Unit tests
- `test_pool.py`: pool JSON validation accepts well-formed pools, rejects: missing fields, wrong option keys, invalid `correct`, empty questions list. Hits all `pool.py` validation paths.
- `test_scoring.py`: each registered score_fn produces correct values for: correct+early, correct+late, correct+after-window, wrong, edge cases (elapsed_ms=0, elapsed_ms=time_limit_ms, elapsed_ms>time_limit_ms).
- `test_auth.py`: cookie signing roundtrip, tampered cookie rejected, admin login success/failure paths.
### API tests
- `test_api_student.py`: `/api/session/<sid>` returns expected metadata; join endpoint creates participant + sets cookie; idempotent re-join with same student_id updates name; me/stats endpoints return expected shape.
- `test_api_admin.py`: login required for all `/admin/*`; quiz CRUD; session create returns sid+QR; CSV export shape correct.
### WebSocket tests
- `test_ws_student.py`: connect with valid cookie succeeds; without cookie rejected (4001 close); receives initial state message; `submit` for open question returns `submit_ack` with correct score; `submit` for closed question rejected.
- `test_ws_admin.py`: requires admin cookie; control commands transition state correctly; broadcasts reach connected students.
- `test_state_machine.py`: full lifecycle test — create session, join 3 students, open Q0, all submit, close, advance, ..., end. Assert state transitions and DB rows.
### Edge-case tests
- `test_late_join.py`: student joins after Q0 already opened — gets `state` + `question_open` with reduced `remaining_ms`. Submits in time → scored correctly. Joins after Q0 closed → no submission for Q0, status='missed' on later finalization.
- `test_reconnect.py`: student submits, disconnects, reconnects — server resends current state including their existing `submit_ack` for the active Q.
### Load/simulation test (`test_load_simulation.py`)
- Spawn 50 simulated student WS clients in asyncio tasks (acceptance threshold; 100 if it runs in <30s).
- Run a full quiz with 5 questions; each simulated student answers within a randomized 1-50s window.
- Assert: no dropped messages, all submissions persisted with correct scores, final leaderboard CSV row count matches participant count, autoclose fires within 1s of `time_limit`.
- This is the single most important test — it validates real-world behavior.
### Acceptance criteria
- All tests pass with `pytest -q`.
- `pytest --cov=app` ≥ 80% line coverage on `app/`.
- Manual smoke test (documented in `README.md`): create example session, join as student in incognito browser, submit answer, see reveal — works end-to-end without errors in browser console or server log.
---
## 15. Operational notes (for later, not for codex implementation)
These are documented for human deployment and should NOT be implemented by codex (no systemd, no Caddyfile, no DNS work needed in this scope).
- Hosting target: mainland-China VPS (Alibaba Cloud Zhejiang) — to be provisioned by user separately.
- Reverse proxy: Caddy with auto-TLS via Let's Encrypt HTTP-01 on the host.
- Service supervision: systemd unit (template can be added later).
- Backup: nightly `sqlite3 .backup` to a second file; this is out of scope for v1.
---
## 16. What NOT to build
- No user registration / OAuth. Identity is just student_id + name + cookie.
- No live chat or Q&A.
- No file uploads from students.
- No multi-language UI (English only for v1; Chinese can be added later).
- No analytics dashboards beyond per-question stats.
- No mobile native app.
- No PDF report generation.
- No email notifications.
- No persistent leaderboard across sessions.
---
## 17. Things explicitly OK to defer to `NOTES.md`
If during implementation you discover a genuinely necessary deviation, document it in `NOTES.md` with: (a) what the spec said, (b) what you did instead, (c) why. Do not silently invent. Examples of acceptable deferrals:
- The exact QR-encoder library (server-side qrcode lib vs client-side JS).
- Whether `live_histogram` is pushed on every submit or throttled to once per 500ms (latter is fine, document it).
- Choice of pinning vs latest for dependencies (prefer compatible-release pins).
---
## 18. Definition of done
The project is "done" when:
1. All files in §12 exist (except observer.html, which is optional).
2. `pytest` passes with all tests green.
3. `pytest --cov=app` ≥ 80% line coverage.
4. `uvicorn app.main:app` starts without errors with a valid `.env`.
5. `examples/week9_pool.json` is a real, valid 10-question pool covering the W9 recap topics (CPU structure, datapath, control unit, FSM, hardwired vs microprogrammed). If you don't have domain knowledge to author content, generate plausible placeholder questions in the right format.
6. `README.md` documents: install, env setup, dev run, test run, manual smoke-test steps.
7. `NOTES.md` documents any deviations or non-obvious choices.
8. The load simulation test (`test_load_simulation.py`) passes with 50 simulated students.

View File

@@ -28,6 +28,8 @@ class Settings:
port: int = 8001
public_url: str = "https://quiz.ahkhan.me"
log_level: str = "INFO"
pool_path: str = "./pool.json"
default_session_id: str = "main"
@classmethod
def from_env(cls) -> "Settings":
@@ -40,6 +42,8 @@ class Settings:
port=int(os.getenv("QUIZ_PORT", "8001")),
public_url=os.getenv("QUIZ_PUBLIC_URL", "https://quiz.ahkhan.me").rstrip("/"),
log_level=os.getenv("QUIZ_LOG_LEVEL", "INFO"),
pool_path=os.getenv("QUIZ_POOL_PATH", "./pool.json"),
default_session_id=os.getenv("QUIZ_SESSION_ID", "main"),
)
@property

View File

@@ -1,27 +1,47 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app import __version__
from app.config import Settings
from app.db import init_db
from app.pool import PoolValidationError, load_pool_from_file
from app.room import RoomManager
from app.routes_admin import router as admin_router
from app.routes_student import router as student_router
log = logging.getLogger("quiz")
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or Settings.from_env()
rooms = RoomManager(settings)
app = FastAPI(title="Live In-Lecture Quiz Portal")
@asynccontextmanager
async def lifespan(_app: FastAPI):
await init_db(settings.db_path)
try:
pool = load_pool_from_file(settings.pool_path)
except PoolValidationError as exc:
log.error("Pool load failed at %s: %s", settings.pool_path, exc)
log.error("Server is starting without an active session.")
log.error("Drop a valid pool JSON at %s and restart.", settings.pool_path)
else:
sid = pool.get("session_id", settings.default_session_id)
await rooms.ensure_single_session(sid, pool)
rooms.canonical_sid = sid
log.info("Session ready: sid=%s title=%r questions=%d",
sid, pool["title"], len(pool["questions"]))
yield
app = FastAPI(title="Live In-Lecture Quiz Portal", lifespan=lifespan)
app.state.settings = settings
app.state.rooms = rooms
@app.on_event("startup")
async def startup() -> None:
await init_db(settings.db_path)
@app.get("/healthz")
async def healthz():
return {

View File

@@ -1,9 +1,7 @@
"""Pydantic request and response models."""
"""Pydantic request models."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
@@ -14,19 +12,3 @@ class JoinRequest(BaseModel):
class AdminLoginRequest(BaseModel):
password: str
class QuizCreateRequest(BaseModel):
title: str | None = None
pool_json: dict[str, Any] | str
time_limit_default: int | None = None
class SessionCreateRequest(BaseModel):
quiz_id: int
class SubmitMessage(BaseModel):
type: str
question_idx: int
answer: str

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from app.scoring import SCORE_FNS
@@ -25,6 +26,17 @@ def parse_pool_json(pool_json: str | dict[str, Any]) -> dict[str, Any]:
return validate_pool(data)
def load_pool_from_file(path: str | Path) -> dict[str, Any]:
p = Path(path)
if not p.exists():
raise PoolValidationError(f"Pool file not found: {p}")
try:
raw = p.read_text(encoding="utf-8")
except OSError as exc:
raise PoolValidationError(f"Could not read pool file {p}: {exc}") from exc
return parse_pool_json(raw)
def validate_pool(data: dict[str, Any]) -> dict[str, Any]:
if not isinstance(data, dict):
raise PoolValidationError("Pool must be a JSON object")
@@ -45,12 +57,18 @@ def validate_pool(data: dict[str, Any]) -> dict[str, Any]:
raise PoolValidationError(f"Question {index} must be an object")
normalized_questions.append(_validate_question(question, index, time_limit_default))
return {
out: dict[str, Any] = {
"title": title.strip(),
"score_fn": score_fn,
"time_limit_default": time_limit_default,
"questions": normalized_questions,
}
session_id = data.get("session_id")
if session_id is not None:
if not isinstance(session_id, str) or not session_id.strip():
raise PoolValidationError("session_id, if present, must be a non-empty string")
out["session_id"] = session_id.strip()
return out
def question_count(pool: dict[str, Any]) -> int:

View File

@@ -44,6 +44,100 @@ class RoomManager:
self.instructor_clients: dict[str, set[WebSocket]] = defaultdict(set)
self.autoclose_tasks: dict[tuple[str, int], asyncio.Task] = {}
self.locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
# The single canonical session id, set during startup once the pool
# has been loaded. Routes use this rather than settings.default_session_id
# so that a session_id field in the pool JSON can override the env default.
self.canonical_sid: str | None = None
async def ensure_single_session(self, sid: str, pool: dict[str, Any]) -> None:
"""Idempotently upsert the canonical single-session row + its quiz row.
Called on startup with the operator-supplied pool JSON. Creates the
quiz + session if they don't exist, otherwise updates the pool blob
on the existing quiz so a fresh restart picks up edits to the pool
file without losing prior submissions for the same session.
"""
title = pool["title"]
pool_blob = json.dumps(pool)
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
"SELECT quiz_id FROM quiz_sessions WHERE sid = ?",
(sid,),
)
row = await cursor.fetchone()
if row is None:
cursor = await db.execute(
"INSERT INTO quizzes (title, pool_json, time_limit_default, score_fn_name) VALUES (?, ?, ?, ?)",
(title, pool_blob, pool["time_limit_default"], pool["score_fn"]),
)
quiz_id = cursor.lastrowid
await db.execute(
"INSERT INTO quiz_sessions (sid, quiz_id, title) VALUES (?, ?, ?)",
(sid, quiz_id, title),
)
else:
quiz_id = row["quiz_id"]
await db.execute(
"UPDATE quizzes SET title = ?, pool_json = ?, time_limit_default = ?, score_fn_name = ? WHERE id = ?",
(title, pool_blob, pool["time_limit_default"], pool["score_fn"], quiz_id),
)
await db.execute(
"UPDATE quiz_sessions SET title = ? WHERE sid = ?",
(title, sid),
)
await db.commit()
async def advance_to_next(self, sid: str) -> None:
"""Instructor 'Next' button: a single button that drives the whole
lifecycle. From lobby it opens Q0; from a question_open state it
closes the current Q and opens the next; from question_closed it
opens the next Q. If there is no next question, the session ends.
"""
async with self.locks[sid]:
session = await self.get_session(sid)
if session["state"] == "finished":
return
current_idx = session["current_question_idx"]
close_current = session["state"] == "question_open"
if close_current:
await self._close_question_locked(sid, int(current_idx))
if close_current:
await self.broadcast_question_closed(sid, int(current_idx))
pool = await self.get_pool_for_session(sid)
total = question_count(pool)
next_idx = 0 if current_idx is None else int(current_idx) + 1
if next_idx >= total:
await self.end_session(sid)
return
await self.open_question(sid, next_idx)
async def reset(self, sid: str) -> None:
"""Wipe submissions, participants, and per-question state, then return
the session to lobby. Useful for re-running the same quiz across
classes without redeploying."""
async with self.locks[sid]:
task_keys = [key for key in self.autoclose_tasks if key[0] == sid]
for key in task_keys:
task = self.autoclose_tasks.pop(key, None)
if task:
task.cancel()
async with connect(self.settings.db_path) as db:
await db.execute("DELETE FROM submissions WHERE sid = ?", (sid,))
await db.execute("DELETE FROM question_events WHERE sid = ?", (sid,))
await db.execute("DELETE FROM participants WHERE sid = ?", (sid,))
await db.execute(
"UPDATE quiz_sessions SET state = 'lobby', current_question_idx = NULL, finished_at = NULL WHERE sid = ?",
(sid,),
)
await db.commit()
for ws in list(self.student_clients.get(sid, {}).keys()):
try:
await ws.close(code=4002)
except Exception:
pass
self.student_clients.pop(sid, None)
await self.broadcast_instructors(sid, {"type": "state", "state": "lobby", "current_question_idx": None, "title": (await self.get_session(sid))["title"]})
await self.broadcast_lobby(sid)
async def sessions_active(self) -> int:
async with connect(self.settings.db_path) as db:
@@ -139,9 +233,11 @@ class RoomManager:
elif msg_type == "close_question":
await self.close_question(sid)
elif msg_type == "next":
await self.next_question(sid)
await self.advance_to_next(sid)
elif msg_type == "end_session":
await self.end_session(sid)
elif msg_type == "reset":
await self.reset(sid)
else:
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Unknown message type"})
except (WebSocketDisconnect, RuntimeError):

View File

@@ -1,28 +1,29 @@
"""Instructor routes."""
"""Instructor routes (single-session deployment).
The deployment runs exactly one quiz session at a time, loaded from
`QUIZ_POOL_PATH` at startup. There is no per-quiz CRUD; the operator
edits the pool JSON on disk and restarts the service when they want
a new pool. The admin UI is therefore a thin control panel for the
single canonical session whose id is `Settings.default_session_id`.
"""
from __future__ import annotations
import base64
import json
import secrets
from io import BytesIO
from typing import Any
from pathlib import Path
import qrcode
import qrcode.image.svg
from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFile, WebSocket
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
from fastapi.responses import FileResponse, PlainTextResponse
from app import auth
from app.config import Settings
from app.csv_export import export_session_csv
from app.db import connect
from app.models import QuizCreateRequest, SessionCreateRequest
from app.pool import PoolValidationError, parse_pool_json
from app.models import AdminLoginRequest
from app.room import RoomManager
CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
def router(settings: Settings, rooms: RoomManager) -> APIRouter:
api = APIRouter()
@@ -30,119 +31,62 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
def require_admin(request: Request) -> None:
auth.require_admin_request(settings, request)
@api.get("/admin/login")
async def login_form():
return HTMLResponse(
"<!doctype html><title>Admin Login</title><form method='post'>"
"<label>Password <input name='password' type='password'></label>"
"<button type='submit'>Log in</button></form>"
)
@api.post("/admin/login")
async def login(request: Request, response: Response):
password = ""
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
data = await request.json()
password = str(data.get("password", ""))
else:
form = await request.form()
password = str(form.get("password", ""))
if not auth.verify_admin_password(settings, password):
async def login(body: AdminLoginRequest, response: Response):
if not auth.verify_admin_password(settings, body.password):
raise HTTPException(status_code=401, detail="Invalid admin password")
auth.set_admin_cookie(settings, response, auth.sign_admin(settings))
return {"ok": True}
@api.post("/admin/logout")
async def logout(response: Response):
response.delete_cookie(auth.ADMIN_COOKIE, path="/")
return {"ok": True}
@api.get("/admin/")
async def admin_page(request: Request):
require_admin(request)
return FileResponse("static/admin.html")
async def admin_page():
# No auth gate; the SPA fetches /admin/api/state and renders
# the login form on 401 or the dashboard on 200.
return FileResponse(Path("static/admin.html"))
@api.get("/admin/api/quizzes")
async def list_quizzes(request: Request):
require_admin(request)
async with connect(settings.db_path) as db:
cursor = await db.execute(
"SELECT id, title, time_limit_default, score_fn_name, created_at FROM quizzes ORDER BY created_at DESC, id DESC"
)
rows = await cursor.fetchall()
return {"quizzes": [dict(row) for row in rows]}
@api.post("/admin/api/quizzes")
async def create_quiz(request: Request, body: QuizCreateRequest):
require_admin(request)
try:
pool = parse_pool_json(body.pool_json)
except PoolValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if body.time_limit_default is not None:
pool["time_limit_default"] = body.time_limit_default
title = (body.title or pool["title"]).strip()
async with connect(settings.db_path) as db:
cursor = await db.execute(
"INSERT INTO quizzes (title, pool_json, time_limit_default, score_fn_name) VALUES (?, ?, ?, ?)",
(title, json.dumps(pool), pool["time_limit_default"], pool["score_fn"]),
)
await db.commit()
quiz_id = cursor.lastrowid
return {"ok": True, "quiz_id": quiz_id}
@api.post("/admin/api/quizzes/upload")
async def upload_quiz(request: Request, file: UploadFile = File(...)):
require_admin(request)
raw = (await file.read()).decode("utf-8")
try:
pool = parse_pool_json(raw)
except PoolValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
async with connect(settings.db_path) as db:
cursor = await db.execute(
"INSERT INTO quizzes (title, pool_json, time_limit_default, score_fn_name) VALUES (?, ?, ?, ?)",
(pool["title"], json.dumps(pool), pool["time_limit_default"], pool["score_fn"]),
)
await db.commit()
quiz_id = cursor.lastrowid
return {"ok": True, "quiz_id": quiz_id}
@api.get("/admin/api/sessions")
async def list_sessions(request: Request):
require_admin(request)
async with connect(settings.db_path) as db:
cursor = await db.execute(
"""
SELECT s.sid, s.quiz_id, s.title, s.state, s.current_question_idx, s.started_at, s.finished_at,
COUNT(p.student_id) AS participant_count
FROM quiz_sessions s
LEFT JOIN participants p ON p.sid = s.sid
GROUP BY s.sid
ORDER BY s.started_at DESC
"""
)
rows = await cursor.fetchall()
return {"sessions": [dict(row) for row in rows]}
@api.post("/admin/api/sessions")
async def create_session(request: Request, body: SessionCreateRequest):
require_admin(request)
async with connect(settings.db_path) as db:
quiz_cursor = await db.execute("SELECT id, title FROM quizzes WHERE id = ?", (body.quiz_id,))
quiz = await quiz_cursor.fetchone()
if quiz is None:
raise HTTPException(status_code=404, detail="Quiz not found")
sid = await _generate_sid(db)
await db.execute(
"INSERT INTO quiz_sessions (sid, quiz_id, title) VALUES (?, ?, ?)",
(sid, body.quiz_id, quiz["title"]),
)
await db.commit()
join_url = f"{settings.public_url}/?sid={sid}"
return {"sid": sid, "join_url": join_url, "qr_url": _qr_data_url(join_url)}
@api.get("/admin/api/sessions/{sid}/csv")
async def csv_download(sid: str, request: Request):
@api.get("/admin/api/state")
async def admin_state(request: Request):
require_admin(request)
sid = rooms.canonical_sid or settings.default_session_id
if not await rooms.session_exists(sid):
raise HTTPException(status_code=404, detail="Session not found")
raise HTTPException(status_code=503, detail="Session is not initialised")
session = await rooms.get_session(sid)
pool = await rooms.get_pool_for_session(sid)
join_url = f"{settings.public_url}/?sid={sid}"
return {
"sid": sid,
"title": session["title"],
"state": session["state"],
"current_question_idx": session["current_question_idx"],
"join_url": join_url,
"qr_url": _qr_data_url(join_url),
"pool_meta": {
"score_fn": pool["score_fn"],
"time_limit_default": pool["time_limit_default"],
"question_count": len(pool["questions"]),
},
}
@api.post("/admin/api/reset")
async def admin_reset(request: Request):
require_admin(request)
sid = rooms.canonical_sid or settings.default_session_id
if not await rooms.session_exists(sid):
raise HTTPException(status_code=503, detail="Session is not initialised")
await rooms.reset(sid)
return {"ok": True}
@api.get("/admin/api/csv")
async def csv_download(request: Request):
require_admin(request)
sid = rooms.canonical_sid or settings.default_session_id
if not await rooms.session_exists(sid):
raise HTTPException(status_code=503, detail="Session is not initialised")
csv_text = await export_session_csv(settings.db_path, sid)
return PlainTextResponse(
csv_text,
@@ -160,15 +104,6 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
return api
async def _generate_sid(db: Any) -> str:
for _ in range(5):
sid = "".join(secrets.choice(CROCKFORD) for _ in range(6))
cursor = await db.execute("SELECT 1 FROM quiz_sessions WHERE sid = ?", (sid,))
if await cursor.fetchone() is None:
return sid
raise HTTPException(status_code=500, detail="Could not allocate session ID")
def _qr_data_url(value: str) -> str:
image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage)
buf = BytesIO()

View File

@@ -1,4 +1,4 @@
"""Student routes."""
"""Student routes (single-session deployment)."""
from __future__ import annotations
@@ -6,7 +6,7 @@ from pathlib import Path
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from app import auth
from app.config import Settings
@@ -17,13 +17,27 @@ from app.room import RoomManager
def router(settings: Settings, rooms: RoomManager) -> APIRouter:
api = APIRouter()
def resolve_sid(sid: str | None) -> str:
return sid if sid else (rooms.canonical_sid or settings.default_session_id)
@api.get("/")
async def student_entry(sid: str | None = None):
if not sid or not await rooms.session_exists(sid):
target_sid = resolve_sid(sid)
if not await rooms.session_exists(target_sid):
return HTMLResponse(
"<!doctype html><title>Quiz</title><main><h1>Ask your instructor for the link</h1>"
"<p>This quiz link is missing or no longer valid.</p></main>"
"<!doctype html><meta charset='utf-8'>"
"<link rel='stylesheet' href='/static/style.css'>"
"<title>Quiz unavailable</title>"
"<main class='centered-shell'><div class='card narrow'>"
"<h1>Ask your instructor for the link</h1>"
"<p class='muted'>This quiz link is missing or no longer valid.</p>"
"</div></main>",
status_code=404,
)
if not sid:
# Canonicalise the URL so QR codes, share links, and bookmarks
# all converge on the same sid-bearing form.
return RedirectResponse(url=f"/?sid={target_sid}", status_code=302)
return FileResponse(Path("static/student.html"))
@api.get("/api/session/{sid}")
@@ -32,6 +46,7 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
raise HTTPException(status_code=404, detail="Session not found")
session = await rooms.get_session(sid)
return {
"sid": sid,
"title": session["title"],
"state": session["state"],
"current_question_idx": session["current_question_idx"],

View File

@@ -25,14 +25,14 @@ fi
stage() { printf '\n==> Stage %s\n' "$*"; }
stage "1/8: apt update + base packages"
stage "1/9: apt update + base packages"
apt-get update -q
DEBIAN_FRONTEND=noninteractive apt-get install -y -q \
git curl ca-certificates gnupg \
python3 python3-venv python3-pip \
debian-keyring debian-archive-keyring apt-transport-https
stage "2/8: install Caddy (skip if present)"
stage "2/9: install Caddy (skip if present)"
if ! command -v caddy >/dev/null 2>&1; then
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
@@ -42,12 +42,12 @@ if ! command -v caddy >/dev/null 2>&1; then
apt-get install -y -q caddy
fi
stage "3/8: create $APP_USER system user (skip if present)"
stage "3/9: create $APP_USER system user (skip if present)"
if ! id "$APP_USER" >/dev/null 2>&1; then
useradd --system --shell /usr/sbin/nologin --home-dir "$APP_DIR" "$APP_USER"
fi
stage "4/8: clone or update repo into $APP_DIR"
stage "4/9: clone or update repo into $APP_DIR"
if [ -d "$APP_DIR/.git" ]; then
git -C "$APP_DIR" fetch origin
git -C "$APP_DIR" reset --hard "origin/$BRANCH"
@@ -57,12 +57,12 @@ else
fi
chown -R "$APP_USER":"$APP_USER" "$APP_DIR"
stage "5/8: build venv + install dependencies"
stage "5/9: build venv + install dependencies"
sudo -u "$APP_USER" -H python3 -m venv "$APP_DIR/.venv"
sudo -u "$APP_USER" -H "$APP_DIR/.venv/bin/pip" install --quiet --upgrade pip
sudo -u "$APP_USER" -H "$APP_DIR/.venv/bin/pip" install --quiet -e "$APP_DIR"
stage "6/8: configure environment (.env)"
stage "6/9: configure environment (.env)"
ENV_FILE="$APP_DIR/.env"
if [ ! -f "$ENV_FILE" ]; then
if [ -f /root/.quiz.env ]; then
@@ -85,6 +85,7 @@ if [ ! -f "$ENV_FILE" ]; then
echo
cat > "$ENV_FILE" <<EOF
QUIZ_DB_PATH=$APP_DIR/quiz.db
QUIZ_POOL_PATH=$APP_DIR/pool.json
QUIZ_SECRET_KEY=$QUIZ_SECRET_KEY
QUIZ_ADMIN_PASSWORD=$QUIZ_ADMIN_PASSWORD
QUIZ_HOST=127.0.0.1
@@ -97,12 +98,21 @@ EOF
chmod 600 "$ENV_FILE"
fi
stage "7/8: install systemd unit"
stage "7/9: seed pool.json (if not already present)"
POOL_FILE="$APP_DIR/pool.json"
if [ ! -f "$POOL_FILE" ]; then
cp "$APP_DIR/examples/pool_example.json" "$POOL_FILE"
chown "$APP_USER":"$APP_USER" "$POOL_FILE"
echo "Seeded $POOL_FILE from examples/pool_example.json — replace with your real pool when ready."
fi
stage "8/9: install systemd unit"
install -m 644 "$APP_DIR/deploy/quiz.service" /etc/systemd/system/quiz.service
systemctl daemon-reload
systemctl enable --now quiz.service
systemctl enable quiz.service
systemctl restart quiz.service
stage "8/8: configure Caddy"
stage "9/9: configure Caddy"
sed "s/__DOMAIN__/$DOMAIN/g" "$APP_DIR/deploy/Caddyfile.tpl" > /etc/caddy/Caddyfile
systemctl reload caddy

View File

@@ -6,8 +6,10 @@
<title>Quiz Admin</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main id="admin-app"></main>
<body class="admin-body">
<main id="admin-app">
<div class="bootstrap-loading">Loading…</div>
</main>
<script type="module" src="/static/admin.js"></script>
</body>
</html>

View File

@@ -1,212 +1,553 @@
/* Quiz admin SPA.
*
* Single page, no router. boot() decides between login form and dashboard
* based on whether GET /admin/api/state returns 200 (authed) or 401.
*
* The dashboard is state-driven: a single primary action button per
* session state (Start / Stop early / Next / Finish / Reset). The QR
* code, join URL, and participant list are always visible on the left
* so the operator can leave the page on a projector.
*/
const app = document.querySelector("#admin-app");
let quizzes = [];
let sessions = [];
let activeSid = null;
let ws = null;
let leaderboard = [];
let roster = [];
let liveHistogram = null;
let currentState = null;
const samplePool = {
title: "Week 9 Recap: Computer Organization",
score_fn: "linear_decay",
time_limit_default: 60,
questions: [
{id: "q1", text: "Which unit sequences control signals in a multi-cycle datapath?", options: {A: "ALU", B: "Control unit", C: "Register file", D: "Instruction memory"}, correct: "B"}
]
const store = {
session: null, // /admin/api/state response
ws: null,
roster: [],
currentQuestion: null,
histogram: null,
totalCount: 0,
submittedCount: 0,
closedPayload: null, // last question_closed message
leaderboard: [],
endedPayload: null,
notice: null,
questionDeadlineMs: null,
};
let countdownTimer = null;
function escapeText(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[char]);
})[c]);
}
async function api(path, options = {}) {
const headers = options.body ? { "Content-Type": "application/json", ...(options.headers || {}) } : options.headers;
const response = await fetch(path, {
credentials: "same-origin",
headers: {"Content-Type": "application/json", ...(options.headers || {})},
...options,
headers,
});
if (!response.ok) throw new Error(await response.text());
if (response.status === 401) {
const error = new Error("unauthorized");
error.status = 401;
throw error;
}
if (!response.ok) {
const error = new Error(await response.text());
error.status = response.status;
throw error;
}
const contentType = response.headers.get("content-type") || "";
return contentType.includes("json") ? response.json() : response.text();
}
async function boot() {
try {
await refresh();
render();
} catch {
renderLogin();
store.session = await api("/admin/api/state");
store.notice = null;
renderDashboard();
connectWS();
} catch (err) {
if (err.status === 401) {
renderLogin();
} else if (err.status === 503) {
renderUnavailable(err.message || "Session not initialised on the server.");
} else {
renderUnavailable(err.message || "Could not load admin state.");
}
}
}
function renderLogin(error = "") {
app.innerHTML = `<section class="shell"><div class="panel narrow">
<h1>Admin Login</h1>
<form id="login-form" class="stack">
<label>Password <input name="password" type="password" required></label>
${error ? `<p class="error">${escapeText(error)}</p>` : ""}
<button class="primary">Log in</button>
</form>
</div></section>`;
function renderUnavailable(detail) {
app.innerHTML = `
<section class="centered-shell">
<div class="card narrow">
<h1>Quiz unavailable</h1>
<p>${escapeText(detail)}</p>
<p class="muted">Verify <code>QUIZ_POOL_PATH</code> on the server points at a valid pool JSON, then restart <code>quiz.service</code>.</p>
</div>
</section>
`;
}
function renderLogin(error = null) {
app.innerHTML = `
<section class="centered-shell">
<form id="login-form" class="card narrow stack">
<header class="card-header">
<h1>Quiz admin</h1>
<p class="muted">Sign in to control the live session.</p>
</header>
<label class="field">
<span>Password</span>
<input name="password" type="password" autocomplete="current-password" required autofocus>
</label>
${error ? `<p class="alert error">${escapeText(error)}</p>` : ""}
<button class="btn primary block" type="submit">Sign in</button>
</form>
</section>
`;
document.querySelector("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const submit = event.submitter || event.currentTarget.querySelector("button");
submit.disabled = true;
const password = new FormData(event.currentTarget).get("password");
try {
await api("/admin/login", {method: "POST", body: JSON.stringify({password: form.get("password")})});
await refresh();
render();
} catch {
renderLogin("Login failed.");
await api("/admin/login", { method: "POST", body: JSON.stringify({ password }) });
await boot();
} catch (err) {
submit.disabled = false;
renderLogin(err.status === 401 ? "Wrong password." : "Could not sign in.");
}
});
}
async function refresh() {
quizzes = (await api("/admin/api/quizzes")).quizzes;
sessions = (await api("/admin/api/sessions")).sessions;
function renderDashboard() {
const session = store.session;
if (!session) return;
const state = store.endedPayload ? "finished" : (store.closedPayload?.state || session.state);
app.innerHTML = `
<header class="topbar">
<div class="topbar-title">
<h1>${escapeText(session.title)}</h1>
<p class="muted">${escapeText(session.pool_meta.question_count)} questions · ${escapeText(session.pool_meta.score_fn.replace(/_/g, " "))} · ${escapeText(session.pool_meta.time_limit_default)} s default</p>
</div>
<div class="topbar-actions">
<span class="state-badge state-${escapeText(state)}">${escapeText(stateLabel(state))}</span>
<button id="logout-btn" class="btn ghost">Sign out</button>
</div>
</header>
${store.notice ? `<div class="alert info">${escapeText(store.notice)}</div>` : ""}
<section class="dashboard">
<aside class="dashboard-side">
${renderJoinPanel()}
${renderRosterPanel()}
</aside>
<main class="dashboard-main">
${renderStatePanel(state)}
</main>
</section>
`;
document.querySelector("#logout-btn").addEventListener("click", logout);
bindStateActions();
if (state === "question_open") startCountdown();
}
function render() {
app.innerHTML = `<section class="admin-layout">
<aside class="sidebar">
<h1>Quiz Admin</h1>
<button id="new-quiz" class="secondary">Add Pool</button>
<button id="new-session" class="primary" ${quizzes.length ? "" : "disabled"}>Create Session</button>
<h2>Quizzes</h2>
<div class="list">${quizzes.map((quiz) => `<button data-quiz="${quiz.id}">${escapeText(quiz.title)}</button>`).join("") || "<p>No quizzes yet.</p>"}</div>
<h2>Sessions</h2>
<div class="list">${sessions.map((session) => `<button data-session="${session.sid}">${session.sid} ${escapeText(session.state)}</button>`).join("") || "<p>No sessions yet.</p>"}</div>
</aside>
<main class="workspace">${renderSession()}</main>
</section>`;
document.querySelector("#new-quiz").addEventListener("click", renderQuizModal);
document.querySelector("#new-session").addEventListener("click", renderSessionModal);
document.querySelectorAll("[data-session]").forEach((button) => {
button.addEventListener("click", () => connectSession(button.dataset.session));
});
bindControls();
function stateLabel(state) {
return ({
lobby: "Lobby",
question_open: "Question live",
question_closed: "Reveal",
between_questions: "Between",
finished: "Finished",
})[state] || state || "—";
}
function renderSession() {
if (!activeSid) return `<div class="panel"><h1>No active session</h1><p>Create or select a session.</p></div>`;
const session = sessions.find((item) => item.sid === activeSid);
return `<div class="panel">
<div class="topline"><h1>${escapeText(session?.title || activeSid)}</h1><span>${escapeText(currentState?.state || session?.state || "")}</span></div>
<p>Session ID: <strong>${activeSid}</strong></p>
<div class="toolbar">
<button data-command="open_question" class="primary">Open</button>
<button data-command="close_question">Close &amp; Reveal</button>
<button data-command="next">Next</button>
<button data-command="end_session" class="danger">End</button>
<a class="button" href="/admin/api/sessions/${activeSid}/csv">Download CSV</a>
function renderJoinPanel() {
const session = store.session;
return `
<div class="card panel join-panel">
<h2>Join</h2>
<div class="qr-wrap">${session.qr_url ? `<img class="qr" src="${session.qr_url}" alt="Join QR">` : "<div class='qr-fallback'>QR unavailable</div>"}</div>
<div class="join-url-row">
<code class="join-url">${escapeText(session.join_url)}</code>
<button id="copy-url" class="btn ghost small" type="button">Copy</button>
</div>
<p class="muted small">Session id: <code>${escapeText(session.sid)}</code></p>
</div>
<label>Question index <input id="question-idx" type="number" min="0" value="${currentState?.current_question_idx ?? 0}"></label>
<label>Time limit <input id="time-limit" type="number" min="1" value="60"></label>
<h2>Roster (${roster.length})</h2>
<div class="roster">${roster.map((p) => `<span>${escapeText(p.student_id)} ${escapeText(p.name)}</span>`).join("") || "No students yet."}</div>
<h2>Live Histogram</h2>
${renderHistogram(liveHistogram?.histogram)}
<h2>Leaderboard</h2>
${renderLeaderboard(leaderboard)}
</div>`;
`;
}
function renderHistogram(histogram) {
const data = histogram || {A: 0, B: 0, C: 0, D: 0, missed: 0, pending: 0};
return `<div class="histogram">${Object.entries(data).map(([key, value]) => (
`<div class="hist-row"><span>${key}</span><meter min="0" max="50" value="${value}"></meter><b>${value}</b></div>`
)).join("")}</div>`;
function renderRosterPanel() {
const r = store.roster || [];
return `
<div class="card panel">
<h2>Joined <span class="count">${r.length}</span></h2>
${r.length
? `<ul class="roster">${r.map((p) =>
`<li><span class="dot"></span><span class="who"><b>${escapeText(p.name)}</b><small>${escapeText(p.student_id)}</small></span></li>`
).join("")}</ul>`
: `<p class="muted">No students have joined yet. Share the QR or URL.</p>`}
</div>
`;
}
function renderLeaderboard(rows) {
return `<ol class="leaderboard">${(rows || []).map((row) => (
`<li><span>${row.rank}. ${escapeText(row.name)} ${row.student_id ? `(${escapeText(row.student_id)})` : ""}</span><strong>${row.score}</strong></li>`
)).join("") || "<li>No scores yet.</li>"}</ol>`;
function renderStatePanel(state) {
if (state === "lobby") return renderLobby();
if (state === "question_open") return renderQuestionOpen();
if (state === "question_closed" || state === "between_questions") return renderQuestionClosed();
if (state === "finished") return renderFinished();
return `<div class="card panel"><p class="muted">Unknown state: ${escapeText(state)}</p></div>`;
}
function bindControls() {
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const command = button.dataset.command;
if (command === "open_question") {
ws.send(JSON.stringify({
type: command,
question_idx: Number(document.querySelector("#question-idx").value || 0),
time_limit: Number(document.querySelector("#time-limit").value || 60),
}));
} else {
ws.send(JSON.stringify({type: command}));
}
});
function renderLobby() {
const total = store.session.pool_meta.question_count;
return `
<div class="card panel">
<div class="state-cta">
<h2>Ready to start</h2>
<p>When you start, question 1 of ${total} opens for everyone in the room. Late joiners can still join after a question opens; they get whatever time remains.</p>
<button class="btn primary big" data-action="next">Start quiz →</button>
</div>
</div>
`;
}
function renderQuestionOpen() {
const q = store.currentQuestion;
if (!q) {
return `<div class="card panel"><p class="muted">Waiting for question to broadcast…</p></div>`;
}
const total = store.session.pool_meta.question_count;
const idx = q.question_idx;
return `
<div class="card panel question-card">
<div class="question-head">
<span class="qnum">Q${idx + 1} / ${total}</span>
<span id="countdown" class="countdown" data-deadline="${store.questionDeadlineMs ?? 0}">—</span>
</div>
<div class="qbar"><span id="qbar-fill"></span></div>
<h2 class="question-text">${escapeText(q.text)}</h2>
<ol class="options">
${["A","B","C","D"].map((k) =>
`<li><span class="key">${k}</span><span class="opt-text">${escapeText(q.options[k] || "")}</span></li>`
).join("")}
</ol>
${renderLiveHistogram()}
<div class="action-row">
<button class="btn warn" data-action="close">Stop early</button>
</div>
</div>
`;
}
function renderLiveHistogram() {
if (!store.histogram) return `<p class="muted small">Waiting for first submission…</p>`;
const h = store.histogram;
const total = Math.max(1, store.totalCount || 0);
return `
<div class="hist live">
<div class="hist-summary">
<span><b>${store.submittedCount}</b> submitted</span>
${store.totalCount ? `<span class="muted">of ${store.totalCount} joined</span>` : ""}
${h.pending != null ? `<span class="muted">· ${h.pending} pending</span>` : ""}
</div>
<div class="hist-rows">
${["A","B","C","D"].map((k) => {
const v = h[k] || 0;
const pct = Math.round(100 * v / total);
return `
<div class="hist-row">
<span class="key">${k}</span>
<div class="bar"><span class="fill" style="width:${pct}%"></span></div>
<span class="num">${v}</span>
</div>
`;
}).join("")}
</div>
</div>
`;
}
function renderQuestionClosed() {
const c = store.closedPayload;
const q = store.currentQuestion;
if (!c || !q) {
return `<div class="card panel"><p class="muted">Reveal pending…</p></div>`;
}
const total = store.session.pool_meta.question_count;
const idx = q.question_idx;
const isLast = idx >= total - 1;
const totalSubmitters = ["A","B","C","D"].reduce((a, k) => a + (c.histogram[k] || 0), 0) + (c.histogram.missed || 0);
const denom = Math.max(1, totalSubmitters);
return `
<div class="card panel reveal-card">
<div class="question-head">
<span class="qnum">Q${idx + 1} / ${total}</span>
<span class="state-badge state-question_closed">Closed</span>
</div>
<h2 class="question-text">${escapeText(q.text)}</h2>
<ol class="options reveal">
${["A","B","C","D"].map((k) => {
const correct = k === c.correct;
return `
<li class="${correct ? "correct" : ""}">
<span class="key">${k}${correct ? " ✓" : ""}</span>
<span class="opt-text">${escapeText(q.options[k] || "")}</span>
<span class="opt-count muted">${c.histogram[k] || 0}</span>
</li>
`;
}).join("")}
</ol>
${c.explanation ? `<p class="explanation">${escapeText(c.explanation)}</p>` : ""}
<div class="hist final">
<div class="hist-rows">
${["A","B","C","D"].map((k) => {
const v = c.histogram[k] || 0;
const pct = Math.round(100 * v / denom);
const correct = k === c.correct;
return `
<div class="hist-row ${correct ? "is-correct" : ""}">
<span class="key">${k}</span>
<div class="bar"><span class="fill" style="width:${pct}%"></span></div>
<span class="num">${v} (${pct}%)</span>
</div>
`;
}).join("")}
${c.histogram.missed ? `<div class="hist-row missed"><span class="key">—</span><div class="bar"></div><span class="num">${c.histogram.missed} missed</span></div>` : ""}
</div>
</div>
<h3>Top so far</h3>
${renderLeaderboardList(store.leaderboard.slice(0, 10))}
<div class="action-row">
<button class="btn primary big" data-action="next">${isLast ? "Finish quiz →" : "Next question →"}</button>
<button class="btn ghost" data-action="end">Finish now</button>
</div>
</div>
`;
}
function renderFinished() {
return `
<div class="card panel">
<h2>Quiz finished</h2>
<p class="muted">${store.session.pool_meta.question_count} questions complete. Final leaderboard below.</p>
<h3>Final leaderboard</h3>
${renderLeaderboardList(store.leaderboard)}
<div class="action-row">
<a class="btn ghost" href="/admin/api/csv" target="_blank" rel="noopener">Download CSV</a>
<button class="btn warn" data-action="reset">Reset session</button>
</div>
<p class="muted small">Reset clears all submissions and the participant list, then returns to the lobby. The QR / join URL stays the same.</p>
</div>
`;
}
function renderLeaderboardList(rows) {
if (!rows || !rows.length) return `<p class="muted">No scores yet.</p>`;
return `
<ol class="leaderboard">
${rows.map((r) => `
<li>
<span class="rank">${r.rank}</span>
<span class="who"><b>${escapeText(r.name)}</b>${r.student_id ? `<small>${escapeText(r.student_id)}</small>` : ""}</span>
<span class="score">${r.score}</span>
</li>
`).join("")}
</ol>
`;
}
function bindStateActions() {
document.querySelectorAll("[data-action]").forEach((btn) => {
btn.addEventListener("click", () => onAction(btn.dataset.action, btn));
});
const copy = document.querySelector("#copy-url");
if (copy) copy.addEventListener("click", copyJoinUrl);
}
async function onAction(action, btn) {
if (action === "reset") {
if (!confirm("Reset clears all participants and submissions. Continue?")) return;
btn.disabled = true;
try {
await api("/admin/api/reset", { method: "POST" });
// Server pushes a state=lobby broadcast over WS; rerender once the
// message lands, plus optimistically clear local accumulators.
store.roster = [];
store.histogram = null;
store.currentQuestion = null;
store.closedPayload = null;
store.endedPayload = null;
store.leaderboard = [];
store.session.state = "lobby";
store.session.current_question_idx = null;
renderDashboard();
} catch (err) {
alert(err.message || "Reset failed.");
btn.disabled = false;
}
return;
}
if (!store.ws || store.ws.readyState !== WebSocket.OPEN) {
store.notice = "Reconnecting to live channel…";
renderDashboard();
connectWS();
return;
}
const msg = ({
next: { type: "next" },
close: { type: "close_question" },
end: { type: "end_session" },
})[action];
if (msg) {
btn.disabled = true;
store.ws.send(JSON.stringify(msg));
}
}
async function logout() {
try {
await api("/admin/logout", { method: "POST" });
} catch {}
if (store.ws) store.ws.close();
store.ws = null;
store.session = null;
renderLogin();
}
function copyJoinUrl() {
const url = store.session?.join_url;
if (!url) return;
navigator.clipboard.writeText(url).then(() => {
const btn = document.querySelector("#copy-url");
if (!btn) return;
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 1500);
});
}
function renderQuizModal() {
app.innerHTML = `<section class="shell"><div class="panel">
<h1>Add Pool</h1>
<form id="quiz-form" class="stack">
<label>Pool JSON <textarea name="pool" rows="18">${escapeText(JSON.stringify(samplePool, null, 2))}</textarea></label>
<button class="primary">Create Quiz</button>
<button type="button" id="cancel">Cancel</button>
</form>
</div></section>`;
document.querySelector("#cancel").addEventListener("click", render);
document.querySelector("#quiz-form").addEventListener("submit", async (event) => {
event.preventDefault();
const pool = JSON.parse(new FormData(event.currentTarget).get("pool"));
await api("/admin/api/quizzes", {method: "POST", body: JSON.stringify({pool_json: pool})});
await refresh();
render();
});
}
async function renderSessionModal() {
const options = quizzes.map((quiz) => `<option value="${quiz.id}">${escapeText(quiz.title)}</option>`).join("");
app.innerHTML = `<section class="shell"><div class="panel narrow">
<h1>Create Session</h1>
<form id="session-form" class="stack">
<label>Quiz <select name="quiz_id">${options}</select></label>
<button class="primary">Create</button>
<button type="button" id="cancel">Cancel</button>
</form>
<div id="session-result"></div>
</div></section>`;
document.querySelector("#cancel").addEventListener("click", render);
document.querySelector("#session-form").addEventListener("submit", async (event) => {
event.preventDefault();
const quizId = Number(new FormData(event.currentTarget).get("quiz_id"));
const result = await api("/admin/api/sessions", {method: "POST", body: JSON.stringify({quiz_id: quizId})});
document.querySelector("#session-result").innerHTML = `<h2>${result.sid}</h2><p><a href="${result.join_url}">${result.join_url}</a></p><img class="qr" src="${result.qr_url}" alt="QR code">`;
await refresh();
connectSession(result.sid);
});
}
function connectSession(sid) {
activeSid = sid;
if (ws) ws.close();
function connectWS() {
if (store.ws) {
try { store.ws.close(); } catch {}
}
const sid = store.session.sid;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${protocol}://${window.location.host}/ws/instructor/${sid}`);
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/instructor/${sid}`);
store.ws = ws;
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "state") currentState = message;
if (message.type === "lobby_update") roster = message.participants;
if (message.type === "live_histogram") liveHistogram = message;
if (message.type === "full_leaderboard") leaderboard = message.leaderboard;
if (message.type === "question_closed") liveHistogram = {histogram: message.histogram};
render();
try { handleWSMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); }
});
render();
ws.addEventListener("close", () => {
store.notice = "Live connection dropped. Trying to reconnect…";
renderDashboard();
setTimeout(() => { if (store.session) connectWS(); }, 2000);
});
ws.addEventListener("open", () => {
if (store.notice && store.notice.startsWith("Live connection")) {
store.notice = null;
renderDashboard();
}
});
}
function handleWSMessage(message) {
switch (message.type) {
case "state":
store.session.state = message.state;
store.session.current_question_idx = message.current_question_idx;
if (message.state === "lobby") {
store.currentQuestion = null;
store.closedPayload = null;
store.endedPayload = null;
store.histogram = null;
}
renderDashboard();
break;
case "lobby_update":
store.roster = message.participants || [];
renderDashboard();
break;
case "question_open":
store.session.state = "question_open";
store.session.current_question_idx = message.question_idx;
store.currentQuestion = message;
store.closedPayload = null;
store.histogram = null;
store.submittedCount = 0;
store.totalCount = 0;
store.questionDeadlineMs = Date.now() + (message.remaining_ms ?? message.time_limit * 1000);
renderDashboard();
break;
case "live_histogram":
store.histogram = message.histogram;
store.submittedCount = message.submitted_count;
store.totalCount = message.total_count;
patchHistogramOnly();
break;
case "question_closed":
store.session.state = "question_closed";
store.closedPayload = message;
store.histogram = message.histogram;
stopCountdown();
renderDashboard();
break;
case "between_questions":
// Not currently emitted by the new advance_to_next; safe to ignore.
break;
case "full_leaderboard":
store.leaderboard = message.leaderboard || [];
renderDashboard();
break;
case "session_ended":
store.session.state = "finished";
store.endedPayload = message;
stopCountdown();
renderDashboard();
break;
case "error":
store.notice = `Server error: ${message.message || message.code || "unknown"}`;
renderDashboard();
break;
}
}
function patchHistogramOnly() {
// Update histogram without re-rendering the entire dashboard, so the
// countdown bar doesn't flicker.
const target = document.querySelector(".question-card");
if (!target) { renderDashboard(); return; }
const live = target.querySelector(".hist.live");
const replacement = renderLiveHistogram();
if (live) {
const wrap = document.createElement("div");
wrap.innerHTML = replacement;
live.replaceWith(wrap.firstElementChild);
} else {
// No histogram yet; do a full render.
renderDashboard();
}
}
function startCountdown() {
stopCountdown();
countdownTimer = setInterval(tickCountdown, 250);
tickCountdown();
}
function stopCountdown() {
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = null;
}
function tickCountdown() {
const el = document.querySelector("#countdown");
const fill = document.querySelector("#qbar-fill");
if (!el || !fill || !store.questionDeadlineMs) return;
const remaining = Math.max(0, store.questionDeadlineMs - Date.now());
const limit = (store.currentQuestion?.time_limit ?? 60) * 1000;
el.textContent = `${Math.ceil(remaining / 1000)}s`;
fill.style.width = `${Math.max(0, Math.min(100, remaining / limit * 100))}%`;
if (remaining <= 0) stopCountdown();
}
boot();

View File

@@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quiz Observer</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="shell">
<h1>Quiz Observer</h1>
<p>This read-only view is reserved for a future classroom display.</p>
</main>
</body>
</html>

View File

@@ -1,223 +1,361 @@
/* Student quiz SPA.
*
* Visit /?sid=<id>. If no cookie, render the join form. If cookie, open
* the student WS and follow server messages through the lifecycle:
* lobby → question_open → submitted → question_closed → … → session_ended
*
* The server is authoritative for state transitions and scoring. The
* client only animates the UI for whatever message the server sent.
*/
const app = document.querySelector("#app");
const params = new URLSearchParams(window.location.search);
const sid = params.get("sid");
let ws = null;
let me = null;
let activeQuestion = null;
let submitted = null;
const store = {
me: null,
ws: null,
currentQuestion: null,
submitted: null,
pickedAnswer: null,
deadlineMs: null,
};
let countdownTimer = null;
function html(strings, ...values) {
return strings.map((part, index) => part + (values[index] ?? "")).join("");
}
function escapeText(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[char]);
})[c]);
}
function setView(markup) {
app.innerHTML = `<section class="shell">${markup}</section>`;
}
function askForLink() {
setView(html`<div class="panel center">
<h1>Ask your instructor for the link</h1>
<p>This quiz link is missing or no longer valid.</p>
</div>`);
app.innerHTML = `<section class="centered-shell">${markup}</section>`;
}
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: {"Content-Type": "application/json", ...(options.headers || {})},
...options,
});
if (!response.ok) throw new Error(await response.text());
const headers = options.body ? { "Content-Type": "application/json", ...(options.headers || {}) } : options.headers;
const response = await fetch(path, { credentials: "same-origin", ...options, headers });
if (!response.ok) {
const error = new Error(await response.text());
error.status = response.status;
throw error;
}
return response.json();
}
function showAskInstructor() {
setView(`
<div class="card narrow">
<h1>Ask your instructor for the link</h1>
<p class="muted">This quiz link is missing or no longer valid.</p>
</div>
`);
}
async function boot() {
if (!sid) {
askForLink();
showAskInstructor();
return;
}
try {
await api(`/api/session/${sid}`);
} catch {
askForLink();
showAskInstructor();
return;
}
try {
me = await api(`/api/session/${sid}/me`);
connect();
} catch {
renderJoin();
store.me = await api(`/api/session/${sid}/me`);
} catch (err) {
if (err.status === 401) {
renderJoin();
return;
}
showAskInstructor();
return;
}
connect();
}
function renderJoin(error = "") {
setView(html`<div class="panel narrow">
<h1>Join Quiz</h1>
<form id="join-form" class="stack">
<label>Student ID <input name="student_id" autocomplete="username" required></label>
<label>Name <input name="name" autocomplete="name" required></label>
${error ? `<p class="error">${escapeText(error)}</p>` : ""}
<button class="primary" type="submit">Join</button>
function renderJoin(error = null) {
setView(`
<form id="join-form" class="card narrow stack">
<header class="card-header">
<h1>Join the quiz</h1>
<p class="muted">Enter your student ID and name. The cookie is per-device; clear it to switch.</p>
</header>
<label class="field">
<span>Student ID</span>
<input name="student_id" autocomplete="username" required autofocus>
</label>
<label class="field">
<span>Name</span>
<input name="name" autocomplete="name" required>
</label>
${error ? `<p class="alert error">${escapeText(error)}</p>` : ""}
<button type="submit" class="btn primary block">Join</button>
</form>
</div>`);
`);
document.querySelector("#join-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const submit = event.submitter || event.currentTarget.querySelector("button");
submit.disabled = true;
const data = new FormData(event.currentTarget);
try {
await api(`/api/session/${sid}/join`, {
method: "POST",
body: JSON.stringify({
student_id: form.get("student_id"),
name: form.get("name"),
student_id: data.get("student_id"),
name: data.get("name"),
}),
});
me = await api(`/api/session/${sid}/me`);
store.me = await api(`/api/session/${sid}/me`);
connect();
} catch {
renderJoin("Could not join this session.");
} catch (err) {
submit.disabled = false;
renderJoin(err.message || "Could not join.");
}
});
}
function connect() {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`);
ws.addEventListener("message", (event) => handleMessage(JSON.parse(event.data)));
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`);
store.ws = ws;
ws.addEventListener("message", (event) => {
try { handleMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); }
});
ws.addEventListener("close", () => {
clearInterval(countdownTimer);
setView(html`<div class="panel center"><h1>Connection closed</h1><p>Refresh the page to reconnect.</p></div>`);
stopCountdown();
setView(`
<div class="card narrow center">
<h1>Disconnected</h1>
<p class="muted">Your connection dropped.</p>
<button class="btn primary block" onclick="window.location.reload()">Reconnect</button>
</div>
`);
});
}
function handleMessage(message) {
if (message.type === "state") renderState(message);
if (message.type === "question_open") renderQuestion(message);
if (message.type === "submit_ack") renderSubmitted(message);
if (message.type === "question_closed") renderReveal(message);
if (message.type === "between_questions") renderBetween(message);
if (message.type === "session_ended") renderFinished(message);
if (message.type === "error") renderError(message.message);
switch (message.type) {
case "state": return renderState(message);
case "question_open": return renderQuestion(message);
case "submit_ack": return renderSubmitted(message);
case "question_closed": return renderReveal(message);
case "between_questions": return renderBetween(message);
case "session_ended": return renderFinished(message);
case "error": return renderError(message);
}
}
function renderState(message) {
activeQuestion = null;
submitted = null;
clearInterval(countdownTimer);
store.currentQuestion = null;
store.submitted = null;
store.pickedAnswer = null;
stopCountdown();
if (message.state === "lobby") {
setView(html`<div class="panel center">
<h1>${escapeText(message.title)}</h1>
<p class="status">You are in. Waiting for instructor to start.</p>
<p>${escapeText(me?.name || "")}</p>
<div class="spinner"></div>
</div>`);
setView(`
<div class="card narrow center">
<p class="eyebrow">${escapeText(message.title || "Live quiz")}</p>
<h1>You're in.</h1>
<p class="muted">Hi <b>${escapeText(store.me?.name || "")}</b>. Waiting for your instructor to start.</p>
<div class="spinner" aria-hidden="true"></div>
</div>
`);
} else if (message.state === "finished") {
// Edge case: rejoin after the quiz already ended. Render a friendly
// placeholder and wait for a session_ended payload.
setView(`
<div class="card narrow center">
<h1>Quiz finished</h1>
<p class="muted">Final results coming through…</p>
</div>
`);
}
}
function renderQuestion(message) {
activeQuestion = message;
submitted = null;
const buttons = Object.entries(message.options).map(([key, value]) => (
`<button class="answer" data-answer="${key}"><strong>${key}</strong><span>${escapeText(value)}</span></button>`
)).join("");
setView(html`<article class="panel quiz-panel">
<div class="topline"><span>Question ${message.question_idx + 1}</span><span id="timer"></span></div>
<div class="bar"><span id="bar-fill"></span></div>
<h1>${escapeText(message.text)}</h1>
<div class="answers">${buttons}</div>
</article>`);
document.querySelectorAll("[data-answer]").forEach((button) => {
button.addEventListener("click", () => submitAnswer(button.dataset.answer));
store.currentQuestion = message;
store.submitted = null;
store.pickedAnswer = null;
store.deadlineMs = Date.now() + (message.remaining_ms ?? message.time_limit * 1000);
setView(`
<article class="card quiz-card">
<div class="question-head">
<span class="qnum">Question ${message.question_idx + 1}</span>
<span id="countdown" class="countdown">—</span>
</div>
<div class="qbar"><span id="qbar-fill"></span></div>
<h1 class="question-text">${escapeText(message.text)}</h1>
<div class="answer-grid">
${["A","B","C","D"].map((k) => `
<button class="answer-btn" data-answer="${k}">
<span class="answer-key">${k}</span>
<span class="answer-text">${escapeText(message.options[k] || "")}</span>
</button>
`).join("")}
</div>
</article>
`);
document.querySelectorAll("[data-answer]").forEach((btn) => {
btn.addEventListener("click", () => submitAnswer(btn.dataset.answer));
});
startCountdown(message);
}
function startCountdown(message) {
clearInterval(countdownTimer);
const endAt = Date.now() + message.remaining_ms;
const total = message.time_limit * 1000;
const tick = () => {
const remaining = Math.max(0, endAt - Date.now());
const timer = document.querySelector("#timer");
const fill = document.querySelector("#bar-fill");
if (timer) timer.textContent = `${Math.ceil(remaining / 1000)}s`;
if (fill) fill.style.width = `${Math.max(0, Math.min(100, remaining / total * 100))}%`;
};
tick();
countdownTimer = setInterval(tick, 250);
startCountdown();
}
function submitAnswer(answer) {
if (!ws || !activeQuestion || submitted) return;
ws.send(JSON.stringify({type: "submit", question_idx: activeQuestion.question_idx, answer}));
document.querySelectorAll("[data-answer]").forEach((button) => button.disabled = true);
if (!store.ws || !store.currentQuestion || store.submitted || store.pickedAnswer) return;
store.pickedAnswer = answer;
document.querySelectorAll("[data-answer]").forEach((btn) => {
btn.disabled = true;
if (btn.dataset.answer === answer) btn.classList.add("picked");
});
store.ws.send(JSON.stringify({
type: "submit",
question_idx: store.currentQuestion.question_idx,
answer,
}));
}
function renderSubmitted(message) {
submitted = message;
store.submitted = message;
const seconds = (message.elapsed_ms / 1000).toFixed(1);
setView(html`<div class="panel center">
<h1>Submitted</h1>
<p class="score">Submitted in ${seconds}s, +${message.score} pts.</p>
<p>Wait for the reveal.</p>
</div>`);
setView(`
<div class="card narrow center">
<p class="eyebrow">Question ${message.question_idx + 1}</p>
<h1 class="big-score">+${message.score}</h1>
<p class="muted">submitted in ${seconds}s</p>
<p class="muted small">Waiting for the reveal…</p>
<div class="spinner" aria-hidden="true"></div>
</div>
`);
}
function renderReveal(message) {
clearInterval(countdownTimer);
const rows = Object.entries(message.histogram).map(([key, value]) => (
`<div class="hist-row"><span>${key}</span><meter min="0" max="50" value="${value}"></meter><b>${value}</b></div>`
)).join("");
const board = renderBoard(message.top5);
setView(html`<article class="panel">
<p class="status">Correct answer: ${escapeText(message.correct)}</p>
<h1>Reveal</h1>
<p>Your answer: ${escapeText(message.your_answer || "missed")}. Score: ${message.your_score || 0}.</p>
${message.explanation ? `<p>${escapeText(message.explanation)}</p>` : ""}
<div class="histogram">${rows}</div>
${board}
<p>Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.</p>
</article>`);
stopCountdown();
const q = store.currentQuestion;
const yourAnswer = message.your_answer ?? null;
const correct = message.correct;
const won = yourAnswer === correct;
const totalSubmitters = ["A","B","C","D"].reduce((a, k) => a + (message.histogram[k] || 0), 0) + (message.histogram.missed || 0);
const denom = Math.max(1, totalSubmitters);
setView(`
<article class="card reveal-card">
<div class="question-head">
<span class="qnum">Q${message.question_idx + 1}</span>
<span class="state-badge ${won ? "state-correct" : "state-wrong"}">${won ? "Correct" : (yourAnswer ? "Wrong" : "Missed")}</span>
</div>
${q?.text ? `<h2 class="question-text small">${escapeText(q.text)}</h2>` : ""}
<ol class="options reveal student-reveal">
${["A","B","C","D"].map((k) => {
const isCorrect = k === correct;
const isYours = k === yourAnswer;
let cls = "";
if (isCorrect) cls += " correct";
if (isYours && !isCorrect) cls += " wrong-pick";
if (isYours) cls += " yours";
return `
<li class="${cls}">
<span class="key">${k}${isCorrect ? " ✓" : ""}${isYours && !isCorrect ? " ✗" : ""}</span>
<span class="opt-text">${escapeText(q?.options?.[k] || "")}</span>
<span class="opt-count muted">${message.histogram[k] || 0} (${Math.round(100 * (message.histogram[k] || 0) / denom)}%)</span>
</li>
`;
}).join("")}
</ol>
${message.explanation ? `<p class="explanation">${escapeText(message.explanation)}</p>` : ""}
<div class="reveal-stats">
<div class="stat"><span class="muted">Your score</span><b>+${message.your_score || 0}</b></div>
<div class="stat"><span class="muted">Total</span><b>${message.your_total ?? 0}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
</div>
<h3>Top 5</h3>
${renderBoard(message.top5)}
</article>
`);
}
function renderBetween(message) {
setView(html`<div class="panel center">
<h1>Next question coming up</h1>
<p>Your rank: ${message.your_rank ?? "pending"}</p>
<p>Total: ${message.your_total ?? 0}</p>
${renderBoard(message.top5)}
</div>`);
setView(`
<div class="card narrow center">
<p class="eyebrow">Up next</p>
<h1>Question ${(message.next_idx ?? 0) + 1}</h1>
<div class="reveal-stats">
<div class="stat"><span class="muted">Total</span><b>${message.your_total ?? 0}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
</div>
${renderBoard(message.top5)}
<div class="spinner" aria-hidden="true"></div>
</div>
`);
}
function renderFinished(message) {
setView(html`<div class="panel center celebration">
<h1>Quiz finished</h1>
<p>Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.</p>
<p>Answered ${message.questions_answered ?? 0}, correct ${message.questions_correct ?? 0}.</p>
${renderBoard(message.final_top5)}
</div>`);
stopCountdown();
setView(`
<article class="card celebration-card">
<div class="celebration-banner">Quiz complete</div>
<div class="reveal-stats">
<div class="stat big"><span class="muted">Your total</span><b>${message.your_total ?? 0}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
<div class="stat"><span class="muted">Correct</span><b>${message.questions_correct ?? 0} / ${message.questions_answered ?? 0}</b></div>
</div>
<h3>Final top 5</h3>
${renderBoard(message.final_top5)}
<p class="muted small">Thanks for playing.</p>
</article>
`);
}
function renderBoard(rows = []) {
if (!rows.length) return "<p>No scores yet.</p>";
return `<ol class="leaderboard">${rows.map((row) => (
`<li><span>${row.rank}. ${escapeText(row.name)}</span><strong>${row.score}</strong></li>`
)).join("")}</ol>`;
if (!rows || !rows.length) return `<p class="muted small">No scores yet.</p>`;
return `
<ol class="leaderboard">
${rows.map((r) => `
<li>
<span class="rank">${r.rank}</span>
<span class="who"><b>${escapeText(r.name)}</b></span>
<span class="score">${r.score}</span>
</li>
`).join("")}
</ol>
`;
}
function renderError(message) {
setView(html`<div class="panel center"><h1>Quiz message</h1><p>${escapeText(message)}</p></div>`);
setView(`
<div class="card narrow center">
<h1>Server message</h1>
<p class="muted">${escapeText(message.message || message.code || "Something went wrong.")}</p>
</div>
`);
}
function startCountdown() {
stopCountdown();
countdownTimer = setInterval(tickCountdown, 250);
tickCountdown();
}
function stopCountdown() {
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = null;
}
function tickCountdown() {
const el = document.querySelector("#countdown");
const fill = document.querySelector("#qbar-fill");
if (!el || !fill || !store.deadlineMs) return;
const remaining = Math.max(0, store.deadlineMs - Date.now());
const limit = (store.currentQuestion?.time_limit ?? 60) * 1000;
el.textContent = `${Math.ceil(remaining / 1000)}s`;
fill.style.width = `${Math.max(0, Math.min(100, remaining / limit * 100))}%`;
if (remaining <= 0) stopCountdown();
}
boot();

View File

@@ -1,299 +1,490 @@
/* ============================================================
* Quiz portal — functional baseline stylesheet.
* Visual polish (typography, palette, micro-interactions) is layered
* on top of this by the frontend-design pass; structural rules and
* accessibility-relevant defaults live here.
* ============================================================ */
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--surface: #ffffff;
--border: #d9dee7;
--text: #18212f;
--muted: #5b6573;
--primary: #0d6b57;
--primary-text: #ffffff;
--warn: #b67700;
--warn-text: #ffffff;
--danger: #a43831;
--danger-text: #ffffff;
--info: #254f7a;
--accent: #1c8a72;
--correct-bg: #e6f4ed;
--correct-border: #199870;
--wrong-border: #d04040;
--shadow: 0 8px 28px rgba(15, 25, 42, 0.06);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f6f7f9;
color: #18212f;
background: var(--bg);
color: var(--text);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: #f6f7f9;
color: #18212f;
min-height: 100dvh;
background: var(--bg);
color: var(--text);
font-size: 16px;
line-height: 1.45;
}
.shell {
max-width: 960px;
margin: 0 auto;
padding: 24px;
}
h1, h2, h3 { margin: 0 0 0.4rem; line-height: 1.2; }
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.01em; }
h2 { font-size: 1.05rem; font-weight: 700; }
h3 { font-size: 0.95rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
p { margin: 0 0 0.6rem; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85em; }
.panel {
background: #ffffff;
border: 1px solid #d9dee7;
border-radius: 8px;
padding: 24px;
box-shadow: 0 10px 30px rgba(19, 33, 54, 0.08);
}
.muted { color: var(--muted); }
.small { font-size: 0.85rem; }
.eyebrow { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; margin: 0 0 0.4rem; }
.narrow {
max-width: 440px;
margin: 8vh auto;
}
/* ---------- Layout containers ---------- */
.center {
text-align: center;
}
.stack {
display: grid;
gap: 16px;
}
h1, h2 {
margin-top: 0;
}
label {
display: grid;
gap: 6px;
font-weight: 700;
}
input, textarea, select, button, .button {
font: inherit;
}
input, textarea, select {
border: 1px solid #b8c0cc;
border-radius: 8px;
padding: 10px 12px;
background: #fff;
color: #18212f;
}
button, .button {
border: 1px solid #9aa7b8;
border-radius: 8px;
padding: 10px 14px;
background: #eef1f5;
color: #18212f;
cursor: pointer;
text-decoration: none;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.primary {
background: #0d6b57;
border-color: #0d6b57;
color: #fff;
}
.secondary {
background: #254f7a;
border-color: #254f7a;
color: #fff;
}
.danger {
background: #a43831;
border-color: #a43831;
color: #fff;
}
.error {
color: #a43831;
font-weight: 700;
}
.status, .score {
font-size: 1.2rem;
font-weight: 800;
}
.spinner {
width: 36px;
height: 36px;
margin: 20px auto 0;
border: 4px solid #cbd3df;
border-top-color: #0d6b57;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.quiz-panel h1 {
font-size: clamp(1.5rem, 4vw, 2.5rem);
}
.topline {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
font-weight: 800;
}
.bar {
height: 12px;
background: #e0e6ef;
border-radius: 999px;
overflow: hidden;
margin: 12px 0 24px;
}
.bar span {
display: block;
height: 100%;
width: 100%;
background: #0d6b57;
transition: width 0.2s linear;
}
.answers {
display: grid;
gap: 12px;
}
.answer {
display: grid;
grid-template-columns: 42px 1fr;
gap: 12px;
min-height: 68px;
align-items: center;
text-align: left;
background: #fff;
}
.answer strong {
.bootstrap-loading {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: #254f7a;
color: #fff;
min-height: 100dvh;
color: var(--muted);
font-size: 0.95rem;
}
.histogram {
.centered-shell {
display: grid;
gap: 10px;
margin: 16px 0;
}
.hist-row {
display: grid;
grid-template-columns: 72px 1fr 48px;
gap: 10px;
align-items: center;
}
meter {
width: 100%;
}
.leaderboard {
display: grid;
gap: 8px;
padding-left: 0;
list-style: none;
}
.leaderboard li {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 10px 0;
border-bottom: 1px solid #e2e7ee;
}
.celebration {
outline: 6px solid #f2c94c;
}
.admin-layout {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
padding: 20px;
background: #223044;
color: #fff;
}
.sidebar .list {
display: grid;
gap: 8px;
margin-bottom: 24px;
}
.sidebar button {
width: 100%;
text-align: left;
}
.workspace {
place-items: center;
min-height: 100dvh;
padding: 24px;
}
.toolbar {
/* ---------- Cards & panels ---------- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow);
padding: 24px;
}
.card.narrow { width: min(440px, 100%); }
.card-header { margin-bottom: 16px; }
.card.center { text-align: center; }
.panel { padding: 20px; }
.panel + .panel { margin-top: 16px; }
.panel h2 { margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.panel h2 .count {
background: var(--info);
color: #fff;
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
}
.stack { display: grid; gap: 14px; }
/* ---------- Forms ---------- */
.field { display: grid; gap: 6px; }
.field > span { font-weight: 600; font-size: 0.9rem; color: var(--muted); }
input, textarea, select {
font: inherit;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
background: var(--surface);
color: var(--text);
outline: none;
}
input:focus, textarea:focus, select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
}
/* ---------- Buttons ---------- */
.btn, button {
font: inherit;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 16px;
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: transform 0.05s ease, background 0.15s ease, border-color 0.15s ease;
}
.btn:active, button:active { transform: translateY(1px); }
.btn:disabled, button:disabled { cursor: not-allowed; opacity: 0.55; }
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-text); }
.btn.warn { background: var(--warn); border-color: var(--warn); color: var(--warn-text); }
.btn.danger { background: var(--danger); border-color: var(--danger); color: var(--danger-text); }
.btn.ghost { background: transparent; }
.btn.block { width: 100%; }
.btn.big { padding: 14px 20px; font-size: 1.05rem; font-weight: 600; }
.btn.small { padding: 6px 10px; font-size: 0.85rem; }
/* ---------- Alerts ---------- */
.alert { padding: 10px 14px; border-radius: 8px; font-size: 0.9rem; }
.alert.error { background: color-mix(in srgb, var(--danger) 15%, transparent); border: 1px solid var(--danger); color: var(--danger); }
.alert.info { background: color-mix(in srgb, var(--info) 12%, transparent); border: 1px solid var(--info); color: var(--info); }
/* ---------- Admin topbar ---------- */
.admin-body { padding-bottom: 32px; }
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
gap: 10px;
margin: 16px 0;
}
.topbar-title h1 { font-size: 1.25rem; }
.topbar-title p { margin: 0; }
.topbar-actions { display: flex; gap: 10px; align-items: center; }
.state-badge {
border-radius: 999px;
padding: 4px 12px;
font-size: 0.8rem;
font-weight: 600;
border: 1px solid var(--border);
}
.state-badge.state-lobby { background: color-mix(in srgb, var(--info) 18%, transparent); color: var(--info); border-color: var(--info); }
.state-badge.state-question_open { background: color-mix(in srgb, var(--primary) 18%, transparent); color: var(--primary); border-color: var(--primary); }
.state-badge.state-question_closed { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); border-color: var(--warn); }
.state-badge.state-finished { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); border-color: var(--accent); }
.state-badge.state-correct { background: var(--correct-bg); color: var(--accent); border-color: var(--correct-border); }
.state-badge.state-wrong { background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); border-color: var(--wrong-border); }
/* ---------- Admin dashboard layout ---------- */
.dashboard {
display: grid;
gap: 16px;
grid-template-columns: minmax(280px, 360px) 1fr;
padding: 20px 24px;
align-items: start;
}
@media (max-width: 800px) {
.dashboard { grid-template-columns: 1fr; }
}
.dashboard-side { display: grid; gap: 16px; }
.dashboard-main { display: grid; gap: 16px; }
/* ---------- Join panel (QR + URL + roster) ---------- */
.qr-wrap {
background: #fff;
padding: 14px;
border-radius: 10px;
border: 1px solid var(--border);
display: grid;
place-items: center;
margin-bottom: 12px;
}
.qr-wrap img { width: 100%; height: auto; max-width: 280px; display: block; }
.qr-fallback { padding: 40px; color: var(--muted); }
.join-url-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.join-url {
flex: 1 1 200px;
background: color-mix(in srgb, var(--info) 8%, transparent);
padding: 6px 10px;
border-radius: 6px;
font-size: 0.85rem;
word-break: break-all;
}
.roster {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 6px;
max-height: 360px;
overflow-y: auto;
}
.roster li { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.roster li:last-child { border-bottom: none; }
.roster .dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); flex-shrink: 0; }
.roster .who { display: grid; line-height: 1.2; }
.roster .who small { color: var(--muted); font-size: 0.8rem; }
/* ---------- State CTA panel (lobby / finished) ---------- */
.state-cta { display: grid; gap: 10px; }
.state-cta .btn.big { justify-self: start; }
.action-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
/* ---------- Question card (admin + student share) ---------- */
.question-head {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 10px;
}
.qnum { font-weight: 700; color: var(--muted); font-size: 0.9rem; }
.countdown {
font-weight: 700;
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
color: var(--primary);
}
.roster span {
border: 1px solid #ccd4df;
.qbar {
height: 8px;
background: color-mix(in srgb, var(--primary) 14%, transparent);
border-radius: 999px;
padding: 6px 10px;
overflow: hidden;
margin-bottom: 16px;
}
.qbar span {
display: block;
height: 100%;
width: 100%;
background: var(--primary);
transition: width 0.2s linear;
}
.qr {
width: min(280px, 100%);
height: auto;
background: #fff;
padding: 12px;
.question-text { font-size: 1.2rem; font-weight: 600; margin: 8px 0 16px; }
.question-text.small { font-size: 1rem; }
.options {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.options li {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: 12px;
align-items: center;
padding: 10px 14px;
background: color-mix(in srgb, var(--info) 4%, transparent);
border: 1px solid var(--border);
border-radius: 10px;
}
.options .key {
background: var(--info);
color: #fff;
font-weight: 700;
border-radius: 6px;
padding: 4px 0;
text-align: center;
width: 36px;
}
.options .opt-text { color: var(--text); }
.options .opt-count { color: var(--muted); font-size: 0.85rem; }
.options.reveal li.correct {
background: var(--correct-bg);
border-color: var(--correct-border);
}
.options.reveal li.correct .key { background: var(--correct-border); }
.options.reveal li.wrong-pick { border-color: var(--wrong-border); }
.options.reveal li.wrong-pick .key { background: var(--danger); }
.explanation {
background: color-mix(in srgb, var(--accent) 8%, transparent);
padding: 12px 14px;
border-radius: 10px;
border-left: 3px solid var(--accent);
margin-top: 14px;
font-size: 0.95rem;
}
@media (max-width: 720px) {
.shell, .workspace {
padding: 14px;
}
/* ---------- Histogram ---------- */
.admin-layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
}
.panel {
padding: 18px;
}
.hist { margin-top: 16px; display: grid; gap: 8px; }
.hist-summary { display: flex; gap: 12px; flex-wrap: wrap; font-size: 0.9rem; }
.hist-rows { display: grid; gap: 6px; }
.hist-row {
display: grid;
grid-template-columns: 36px 1fr 80px;
gap: 10px;
align-items: center;
}
.hist-row .bar {
height: 8px;
background: color-mix(in srgb, var(--info) 12%, transparent);
border-radius: 999px;
overflow: hidden;
}
.hist-row .bar .fill {
display: block;
height: 100%;
background: var(--info);
transition: width 0.3s ease;
}
.hist-row.is-correct .bar .fill { background: var(--correct-border); }
.hist-row.missed .bar { background: transparent; }
.hist-row .num { font-size: 0.85rem; text-align: right; color: var(--muted); }
/* ---------- Leaderboard ---------- */
.leaderboard {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.leaderboard li {
display: grid;
grid-template-columns: 32px 1fr auto;
gap: 12px;
align-items: center;
padding: 8px 12px;
border-radius: 8px;
}
.leaderboard li:nth-child(odd) { background: color-mix(in srgb, var(--info) 5%, transparent); }
.leaderboard .rank { font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; }
.leaderboard .who { display: grid; line-height: 1.2; }
.leaderboard .who small { color: var(--muted); font-size: 0.78rem; }
.leaderboard .score { font-weight: 700; font-variant-numeric: tabular-nums; color: var(--primary); }
/* ---------- Student-side answer buttons (big, tappable) ---------- */
.quiz-card { width: min(640px, 100%); }
.answer-grid {
display: grid;
gap: 12px;
margin: 18px 0 0;
}
.answer-btn {
display: grid;
grid-template-columns: 48px 1fr;
gap: 14px;
align-items: center;
text-align: left;
background: var(--surface);
border: 2px solid var(--border);
border-radius: 12px;
padding: 18px 18px;
font-size: 1rem;
min-height: 64px;
}
.answer-btn:hover:not(:disabled) {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 6%, transparent);
}
.answer-btn.picked {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
.answer-btn .answer-key {
background: var(--info);
color: #fff;
font-weight: 700;
border-radius: 8px;
padding: 6px 0;
text-align: center;
width: 48px;
font-size: 1.05rem;
}
.answer-btn .answer-text { font-weight: 500; }
.big-score {
font-size: 3rem;
font-weight: 800;
color: var(--primary);
margin: 8px 0 4px;
font-variant-numeric: tabular-nums;
}
.spinner {
width: 28px;
height: 28px;
margin: 12px auto 0;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------- Reveal stats (student) ---------- */
.reveal-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 16px 0;
}
.reveal-stats .stat {
text-align: center;
padding: 12px 8px;
background: color-mix(in srgb, var(--info) 6%, transparent);
border-radius: 10px;
}
.reveal-stats .stat span { display: block; font-size: 0.8rem; }
.reveal-stats .stat b { display: block; font-size: 1.4rem; margin-top: 4px; }
.reveal-stats .stat.big b { font-size: 2.2rem; color: var(--primary); }
.celebration-card {
text-align: center;
width: min(640px, 100%);
position: relative;
}
.celebration-banner {
background: var(--primary);
color: #fff;
padding: 14px 20px;
border-radius: 10px;
font-weight: 700;
font-size: 1.1rem;
margin: -8px 0 20px;
}
/* ---------- Dark mode ---------- */
@media (prefers-color-scheme: dark) {
:root, body {
background: #10141b;
color: #edf1f7;
}
.panel, input, textarea, select, .answer {
background: #171d27;
color: #edf1f7;
border-color: #344052;
}
button, .button {
background: #263246;
color: #edf1f7;
border-color: #4a586d;
:root {
--bg: #0f1117;
--surface: #181c25;
--border: #2c3340;
--text: #edf1f7;
--muted: #98a3b3;
--primary: #4ec9aa;
--primary-text: #061612;
--warn: #f0b441;
--warn-text: #1a1102;
--danger: #f06a6a;
--info: #5489c6;
--accent: #6dd2b6;
--correct-bg: rgba(78, 201, 170, 0.18);
--correct-border: #4ec9aa;
--shadow: 0 8px 28px rgba(0, 0, 0, 0.4);
}
.qr-wrap { background: #fff; }
.join-url { background: rgba(84, 137, 198, 0.12); }
}

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
@@ -9,12 +11,16 @@ from app.config import Settings
from app.main import create_app
CANONICAL_SID = "main"
@pytest.fixture
def sample_pool():
return {
"title": "Sample Quiz",
"score_fn": "linear_decay",
"time_limit_default": 2,
"session_id": CANONICAL_SID,
"questions": [
{
"id": "q1",
@@ -57,35 +63,30 @@ def sample_pool():
@pytest.fixture
def client(tmp_path):
def client(tmp_path, sample_pool):
pool_path = tmp_path / "pool.json"
pool_path.write_text(json.dumps(sample_pool))
settings = Settings(
db_path=str(tmp_path / "quiz.db"),
secret_key="test-secret",
admin_password="admin-pass",
public_url="http://testserver",
pool_path=str(pool_path),
default_session_id=CANONICAL_SID,
)
app = create_app(settings)
with TestClient(app) as test_client:
yield test_client
@pytest.fixture
def sid() -> str:
return CANONICAL_SID
def admin_login(client: TestClient) -> None:
response = client.post("/admin/login", json={"password": "admin-pass"})
assert response.status_code == 200
def create_quiz(client: TestClient, pool: dict) -> int:
admin_login(client)
response = client.post("/admin/api/quizzes", json={"pool_json": pool})
assert response.status_code == 200, response.text
return response.json()["quiz_id"]
def create_session(client: TestClient, pool: dict) -> str:
quiz_id = create_quiz(client, pool)
response = client.post("/admin/api/sessions", json={"quiz_id": quiz_id})
assert response.status_code == 200, response.text
return response.json()["sid"]
def join_student(client: TestClient, sid: str, student_id: str = "s1", name: str = "Student One") -> dict:

View File

@@ -1,42 +1,56 @@
from conftest import admin_login, create_quiz, create_session, join_student
from conftest import admin_login, join_student
def test_admin_login_required_and_quiz_session_crud(client, sample_pool):
assert client.get("/admin/").status_code == 401
def test_admin_state_requires_login(client):
# /admin/api/state is the canonical "am I logged in" probe used by the SPA.
assert client.get("/admin/api/state").status_code == 401
assert client.post("/admin/login", json={"password": "wrong"}).status_code == 401
admin_login(client)
assert client.get("/admin/").status_code == 200
quiz_id = create_quiz(client, sample_pool)
quizzes = client.get("/admin/api/quizzes").json()["quizzes"]
assert any(item["id"] == quiz_id for item in quizzes)
response = client.post("/admin/api/sessions", json={"quiz_id": quiz_id})
def test_admin_state_after_login_includes_pool_meta_and_qr(client, sid):
admin_login(client)
response = client.get("/admin/api/state")
assert response.status_code == 200
payload = response.json()
assert len(payload["sid"]) == 6
assert payload["join_url"].endswith(f"?sid={payload['sid']}")
assert payload["sid"] == sid
assert payload["state"] == "lobby"
assert payload["join_url"].endswith(f"?sid={sid}")
assert payload["qr_url"].startswith("data:image/svg+xml;base64,")
sessions = client.get("/admin/api/sessions").json()["sessions"]
assert sessions[0]["sid"] == payload["sid"]
assert payload["pool_meta"]["question_count"] == 5
assert payload["pool_meta"]["score_fn"] == "linear_decay"
def test_quiz_upload_and_csv_export(client, sample_pool):
sid = create_session(client, sample_pool)
join_student(client, sid, "s1", "Student One")
csv_response = client.get(f"/admin/api/sessions/{sid}/csv")
assert csv_response.status_code == 200
assert "student_id,name,question_idx" in csv_response.text
upload = client.post(
"/admin/api/quizzes/upload",
files={"file": ("pool.json", __import__("json").dumps(sample_pool), "application/json")},
)
assert upload.status_code == 200
def test_admin_html_served_without_auth_gate(client):
# The HTML shell is unauthed; the SPA decides login vs dashboard from
# the /admin/api/state response. Anything else would force a separate
# /admin/login page back into the URL bar.
response = client.get("/admin/")
assert response.status_code == 200
assert "<title>Quiz Admin</title>" in response.text
def test_invalid_quiz_and_session_errors(client):
def test_csv_endpoint_is_admin_only_and_serves_results(client, sid):
assert client.get("/admin/api/csv").status_code == 401
admin_login(client)
assert client.post("/admin/api/quizzes", json={"pool_json": {"title": "bad", "questions": []}}).status_code == 400
assert client.post("/admin/api/sessions", json={"quiz_id": 999}).status_code == 404
join_student(client, sid)
response = client.get("/admin/api/csv")
assert response.status_code == 200
assert "student_id,name,question_idx" in response.text
def test_admin_logout_clears_cookie(client):
admin_login(client)
assert client.get("/admin/api/state").status_code == 200
client.post("/admin/logout")
assert client.get("/admin/api/state").status_code == 401
def test_admin_reset_clears_participants_and_state(client, sid):
admin_login(client)
join_student(client, sid, "s1", "First")
join_student(client, sid, "s2", "Second")
response = client.post("/admin/api/reset")
assert response.status_code == 200
state = client.get("/admin/api/state").json()
assert state["state"] == "lobby"
assert state["current_question_idx"] is None

View File

@@ -1,8 +1,7 @@
from conftest import create_session, join_student
from conftest import join_student
def test_session_metadata_join_me_and_stats(client, sample_pool):
sid = create_session(client, sample_pool)
def test_session_metadata_join_me_and_stats(client, sid):
metadata = client.get(f"/api/session/{sid}").json()
assert metadata["title"] == "Sample Quiz"
assert metadata["state"] == "lobby"
@@ -22,8 +21,15 @@ def test_session_metadata_join_me_and_stats(client, sample_pool):
assert stats["top5"][0]["name"] == "Updated Name"
def test_root_without_sid_redirects_to_canonical(client, sid):
response = client.get("/", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == f"/?sid={sid}"
def test_invalid_session_and_missing_cookie_paths(client):
assert client.get("/?sid=BAD").status_code == 200
assert "Ask your instructor" in client.get("/?sid=BAD").text
response = client.get("/?sid=BAD")
assert response.status_code == 404
assert "Ask your instructor" in response.text
assert client.get("/api/session/BAD").status_code == 404
assert client.get("/api/session/BAD/me").status_code == 401

View File

@@ -1,8 +1,8 @@
from conftest import create_session, join_student
from conftest import admin_login, join_student
def test_csv_export_contains_one_row_per_submission(client, sample_pool):
sid = create_session(client, sample_pool)
def test_csv_export_contains_one_row_per_submission(client, sid):
admin_login(client)
join_student(client, sid, "s1", "Student One")
rooms = client.app.state.rooms
client.portal.call(rooms.open_question, sid, 0, 2)
@@ -10,7 +10,7 @@ def test_csv_export_contains_one_row_per_submission(client, sample_pool):
assert ack["type"] == "submit_ack"
client.portal.call(rooms.close_question, sid)
response = client.get(f"/admin/api/sessions/{sid}/csv")
response = client.get("/admin/api/csv")
lines = response.text.strip().splitlines()
assert lines[0] == "sid,student_id,name,question_idx,answer,elapsed_ms,score,status"
assert len(lines) == 2

View File

@@ -1,10 +1,9 @@
from fastapi.testclient import TestClient
from conftest import create_session, join_student
from conftest import join_student
def test_late_join_during_open_gets_reduced_remaining_and_can_score(client, sample_pool):
sid = create_session(client, sample_pool)
def test_late_join_during_open_gets_reduced_remaining_and_can_score(client, sid):
rooms = client.app.state.rooms
client.portal.call(rooms.open_question, sid, 0, 2)
@@ -20,8 +19,7 @@ def test_late_join_during_open_gets_reduced_remaining_and_can_score(client, samp
assert ws.receive_json()["score"] > 0
def test_join_after_closed_gets_missed_row(client, sample_pool):
sid = create_session(client, sample_pool)
def test_join_after_closed_gets_missed_row(client, sid):
rooms = client.app.state.rooms
client.portal.call(rooms.open_question, sid, 0, 1)
client.portal.call(rooms.close_question, sid)

View File

@@ -1,10 +1,9 @@
import time
from conftest import create_session, join_student
from conftest import admin_login, join_student
def test_load_simulation_50_students_full_quiz_and_autoclose(client, sample_pool):
sid = create_session(client, sample_pool)
def test_load_simulation_50_students_full_quiz(client, sid, sample_pool):
"""50 students answer 5 questions; instructor drives transitions via the
single 'advance_to_next' WS command."""
rooms = client.app.state.rooms
sockets = []
try:
@@ -14,33 +13,27 @@ def test_load_simulation_50_students_full_quiz_and_autoclose(client, sample_pool
sockets.append(ws)
assert ws.receive_json()["type"] == "state"
# Start: opens Q0 from lobby.
client.portal.call(rooms.advance_to_next, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "question_open"
for question_idx in range(5):
client.portal.call(rooms.open_question, sid, question_idx, 1)
for ws in sockets:
assert ws.receive_json()["type"] == "question_open"
for idx, ws in enumerate(sockets):
answer = sample_pool["questions"][question_idx]["correct"] if idx % 3 else "A"
ws.send_json({"type": "submit", "question_idx": question_idx, "answer": answer})
assert ws.receive_json()["type"] == "submit_ack"
if question_idx == 4:
started = time.monotonic()
for ws in sockets:
message = ws.receive_json()
assert message["type"] == "question_closed"
assert time.monotonic() - started < 2
else:
client.portal.call(rooms.close_question, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "question_closed"
client.portal.call(rooms.next_question, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "between_questions"
client.portal.call(rooms.end_session, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "session_ended"
client.portal.call(rooms.advance_to_next, sid)
for ws in sockets:
first = ws.receive_json()
assert first["type"] == "question_closed"
second = ws.receive_json()
expected_next = "question_open" if question_idx < 4 else "session_ended"
assert second["type"] == expected_next
csv_lines = client.get(f"/admin/api/sessions/{sid}/csv").text.strip().splitlines()
admin_login(client)
csv_lines = client.get("/admin/api/csv").text.strip().splitlines()
assert len(csv_lines) == 1 + 50 * 5
stats = client.get(f"/api/session/{sid}/stats?question_idx=4").json()
assert stats["top5"]

View File

@@ -1,8 +1,7 @@
from conftest import create_session, join_student
from conftest import join_student
def test_reconnect_replays_existing_submit_ack(client, sample_pool):
sid = create_session(client, sample_pool)
def test_reconnect_replays_existing_submit_ack(client, sid):
join_student(client, sid, "s1", "Student One")
rooms = client.app.state.rooms
client.portal.call(rooms.open_question, sid, 0, 2)

View File

@@ -1,45 +1,81 @@
from conftest import create_session, join_student
from conftest import join_student
def test_full_lifecycle_with_three_students(client, sample_pool):
sid = create_session(client, sample_pool)
def test_full_lifecycle_via_advance_and_close(client, sid):
"""End-to-end: 3 students, instructor drives via advance_to_next which
closes the open question and opens the next in a single step."""
rooms = client.app.state.rooms
sockets = []
for idx in range(3):
join_student(client, sid, f"s{idx}", f"Student {idx}")
ws = client.websocket_connect(f"/ws/student/{sid}").__enter__()
sockets.append(ws)
assert ws.receive_json()["state"] == "lobby"
try:
client.portal.call(rooms.open_question, sid, 0, 2)
for idx in range(3):
join_student(client, sid, f"s{idx}", f"Student {idx}")
ws = client.websocket_connect(f"/ws/student/{sid}").__enter__()
sockets.append(ws)
assert ws.receive_json()["state"] == "lobby"
# Start: opens Q0.
client.portal.call(rooms.advance_to_next, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "question_open"
for idx, ws in enumerate(sockets):
ws.send_json({"type": "submit", "question_idx": 0, "answer": "B" if idx < 2 else "A"})
assert ws.receive_json()["type"] == "submit_ack"
client.portal.call(rooms.close_question, sid)
# Advance: closes Q0 and opens Q1 in one step.
client.portal.call(rooms.advance_to_next, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "question_closed"
session = client.portal.call(rooms.get_session, sid)
assert session["state"] == "question_closed"
client.portal.call(rooms.next_question, sid)
for ws in sockets:
assert ws.receive_json()["type"] == "between_questions"
assert client.portal.call(rooms.get_session, sid)["state"] == "between_questions"
client.portal.call(rooms.open_question, sid, 1, 2)
for ws in sockets:
assert ws.receive_json()["type"] == "question_open"
assert client.portal.call(rooms.get_session, sid)["state"] == "question_open"
assert client.portal.call(rooms.get_session, sid)["current_question_idx"] == 1
# End the session early.
client.portal.call(rooms.end_session, sid)
for ws in sockets:
message = ws.receive_json()
assert message["type"] in {"question_closed", "session_ended"}
if message["type"] == "question_closed":
first = ws.receive_json()
# end_session closes the open question, then sends session_ended.
if first["type"] == "question_closed":
assert ws.receive_json()["type"] == "session_ended"
else:
assert first["type"] == "session_ended"
assert client.portal.call(rooms.get_session, sid)["state"] == "finished"
finally:
for ws in locals().get("sockets", []):
for ws in sockets:
ws.__exit__(None, None, None)
def test_explicit_close_then_advance_skips_redundant_close(client, sid):
"""If the instructor closes manually first, the next advance just opens
the following question (no double-close broadcast)."""
rooms = client.app.state.rooms
join_student(client, sid, "s1", "Solo")
with client.websocket_connect(f"/ws/student/{sid}") as ws:
assert ws.receive_json()["state"] == "lobby"
client.portal.call(rooms.open_question, sid, 0, 2)
assert ws.receive_json()["type"] == "question_open"
client.portal.call(rooms.close_question, sid)
assert ws.receive_json()["type"] == "question_closed"
client.portal.call(rooms.advance_to_next, sid)
assert ws.receive_json()["type"] == "question_open"
assert client.portal.call(rooms.get_session, sid)["current_question_idx"] == 1
def test_reset_clears_participants_and_returns_to_lobby(client, sid):
rooms = client.app.state.rooms
join_student(client, sid, "s1", "First")
join_student(client, sid, "s2", "Second")
client.portal.call(rooms.open_question, sid, 0, 2)
client.portal.call(rooms.submit_answer, sid, "s1", 0, "B")
client.portal.call(rooms.close_question, sid)
client.portal.call(rooms.reset, sid)
session = client.portal.call(rooms.get_session, sid)
assert session["state"] == "lobby"
assert session["current_question_idx"] is None
# Participants and submissions are wiped.
board = client.portal.call(rooms.leaderboard, sid)
assert board == []

View File

@@ -1,20 +1,42 @@
import pytest
from starlette.websockets import WebSocketDisconnect
from conftest import admin_login, create_session, join_student
from conftest import admin_login, join_student
def test_instructor_ws_requires_admin_cookie(client, sample_pool):
sid = create_session(client, sample_pool)
client.cookies.clear()
def test_instructor_ws_requires_admin_cookie(client, sid):
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(f"/ws/instructor/{sid}"):
pass
assert exc.value.code == 4001
def test_instructor_controls_transition_and_broadcast(client, sample_pool):
sid = create_session(client, sample_pool)
def test_instructor_next_command_drives_full_loop(client, sid):
"""The 'next' WS message drives the entire lifecycle:
lobby → opens Q0 → closes Q0 + opens Q1 → ... → closes last + ends."""
join_student(client, sid, "s1", "Student One")
admin_login(client)
with client.websocket_connect(f"/ws/student/{sid}") as student_ws:
assert student_ws.receive_json()["type"] == "state"
with client.websocket_connect(f"/ws/instructor/{sid}") as admin_ws:
# Drain lobby snapshot.
assert admin_ws.receive_json()["type"] == "state"
assert admin_ws.receive_json()["type"] == "lobby_update"
# First "next" opens Q0 from lobby.
admin_ws.send_json({"type": "next"})
assert student_ws.receive_json()["type"] == "question_open"
admin_open = admin_ws.receive_json()
assert admin_open["type"] == "question_open"
assert admin_ws.receive_json()["type"] == "live_histogram"
# Second "next" closes Q0 and opens Q1.
admin_ws.send_json({"type": "next"})
student_msgs = [student_ws.receive_json() for _ in range(2)]
assert {m["type"] for m in student_msgs} == {"question_closed", "question_open"}
def test_instructor_close_then_next_emits_clean_open(client, sid):
join_student(client, sid, "s1", "Student One")
admin_login(client)
with client.websocket_connect(f"/ws/student/{sid}") as student_ws:
@@ -23,16 +45,33 @@ def test_instructor_controls_transition_and_broadcast(client, sample_pool):
assert admin_ws.receive_json()["type"] == "state"
assert admin_ws.receive_json()["type"] == "lobby_update"
admin_ws.send_json({"type": "open_question", "question_idx": 0, "time_limit": 2})
student_open = student_ws.receive_json()
assert student_open["type"] == "question_open"
admin_open = admin_ws.receive_json()
assert admin_open["type"] == "question_open"
assert student_ws.receive_json()["type"] == "question_open"
assert admin_ws.receive_json()["type"] == "question_open"
assert admin_ws.receive_json()["type"] == "live_histogram"
admin_ws.send_json({"type": "close_question"})
assert student_ws.receive_json()["type"] == "question_closed"
messages = [admin_ws.receive_json(), admin_ws.receive_json()]
assert {msg["type"] for msg in messages} == {"question_closed", "full_leaderboard"}
admin_msgs = [admin_ws.receive_json(), admin_ws.receive_json()]
assert {m["type"] for m in admin_msgs} == {"question_closed", "full_leaderboard"}
admin_ws.send_json({"type": "next"})
assert student_ws.receive_json()["type"] == "between_questions"
assert student_ws.receive_json()["type"] == "question_open"
def test_reset_command_returns_session_to_lobby(client, sid):
join_student(client, sid, "s1", "Student One")
admin_login(client)
with client.websocket_connect(f"/ws/instructor/{sid}") as admin_ws:
assert admin_ws.receive_json()["type"] == "state"
assert admin_ws.receive_json()["type"] == "lobby_update"
admin_ws.send_json({"type": "open_question", "question_idx": 0, "time_limit": 2})
assert admin_ws.receive_json()["type"] == "question_open"
assert admin_ws.receive_json()["type"] == "live_histogram"
admin_ws.send_json({"type": "reset"})
# After reset, the instructor receives a state=lobby snapshot + lobby_update.
msgs = []
while len(msgs) < 2:
msgs.append(admin_ws.receive_json())
types = [m["type"] for m in msgs]
assert "state" in types

View File

@@ -1,19 +1,17 @@
import pytest
from starlette.websockets import WebSocketDisconnect
from conftest import create_session, join_student
from conftest import join_student
def test_student_ws_requires_cookie(client, sample_pool):
sid = create_session(client, sample_pool)
def test_student_ws_requires_cookie(client, sid):
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(f"/ws/student/{sid}"):
pass
assert exc.value.code == 4001
def test_student_ws_initial_state_submit_and_closed_reject(client, sample_pool):
sid = create_session(client, sample_pool)
def test_student_ws_initial_state_submit_and_closed_reject(client, sid):
join_student(client, sid, "s1", "Student One")
with client.websocket_connect(f"/ws/student/{sid}") as ws:
state = ws.receive_json()