Live poll rooms over FastAPI + JWT + WebSockets.
Open a room, share a short code (or QR), push a question, collect one vote per person, then reveal tallies live. Built for a 3-hour hands-on workshop.
Students work in app/; reference answers for the classic one-vote gap live in solution/ (legacy poll path). The product path is room-scoped.
| Piece | What it is |
|---|---|
| Host account | Register / login → JWT |
| Room | Short join code + participants by display name |
| Poll | One live question per room |
| Vote | One vote per participant per poll (DB unique + API 409) |
| Reveal | Tallies hidden until the host reveals |
| Live updates | WebSocket room stream |
| UI | React app: join-first, QR, projection mode, dark/light |
- Python ≥ 3.10
- Node.js ≥ 18 (for the React UI)
- Terminal (PowerShell, bash, or Git Bash)
Windows (PowerShell)
# if scripts are blocked:
# Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
.\setup.ps1
.\.venv\Scripts\Activate.ps1
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000Linux / macOS / Git Bash
chmod +x setup.sh && ./setup.sh
source .venv/bin/activate
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000- API docs: http://127.0.0.1:8000/docs
- Health: http://127.0.0.1:8000/health
cd frontend
npm install
npm run dev- UI: http://127.0.0.1:5173/
- Vite proxies API/WS to port 8000
- Expand Open a room → Register / Log in → Create room
- Share code or QR
- Phones/laptops: Join with a code
- Host: Push live a question
- Everyone votes once
- Host: Reveal (or open Projection mode on a second screen)
| Time | Focus |
|---|---|
| 0:00–0:15 | Setup, tour repo, run API + UI |
| 0:15–0:45 | Models: User, Room, Participant, Poll, Option, Vote |
| 0:45–1:15 | REST: register, token, create room, join |
| 1:15–1:35 | Break |
| 1:35–2:10 | Polls, vote, one vote per participant, 409 |
| 2:10–2:35 | WebSockets: live room state |
| 2:35–2:50 | Reveal + projection + QR (product polish) |
| 2:50–3:00 | Debug, questions, wrap |
Keep scope tight: rooms + vote integrity + live stream are the core. QR / projection / reveal are short demos, not deep dives.
app/
main.py # FastAPI app, CORS, exception handlers
config.py # env / .env settings
database.py # SQLite + light migrations
models.py # User, Room, Participant, Poll, Option, Vote
schemas.py # Pydantic models
auth.py # password hashing, host + participant JWT
ws_manager.py # WebSocket fan-out by room code
routers/
users.py # POST /users/, POST /token
rooms.py # rooms, join, polls, vote, reveal
polls.py # legacy poll routes (teaching / starter)
ws.py # WS /ws/rooms/{code}
static/ # optional legacy static UI
frontend/ # React (Vite) product UI
solution/ # answer key for legacy one-vote gap
scripts/ # public regression helpers
requirements.txt
setup.sh / setup.ps1
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/users/ |
— | Register host |
POST |
/token |
— | Login → host JWT |
POST |
/rooms/ |
Host JWT | Create room → { code } |
POST |
/rooms/join |
— | { code, display_name } → participant JWT |
GET |
/rooms/{code} |
optional | Room state (counts masked until reveal) |
POST |
/rooms/{code}/polls |
Host | New live question (results hidden) |
POST |
/rooms/{code}/polls/{id}/vote |
Participant | Cast one vote |
POST |
/rooms/{code}/polls/{id}/reveal |
Host | Reveal tallies to the room |
WS |
/ws/rooms/{code} |
— | Live room updates |
Legacy /polls/* remains for the original workshop starter exercises.
Enforced by:
UniqueConstraint("participant_id", "poll_id")onVote- Explicit check in
cast_vote→ 409 IntegrityErrorhandler for concurrent double-submits
If you change models and SQLite acts up: stop the server, delete liveroom.db, restart (create_all does not fully migrate old tables).
Copy .env.example → .env (gitignored):
| Variable | Purpose |
|---|---|
SECRET_KEY |
JWT signing key (change for any shared deploy) |
ACCESS_TOKEN_EXPIRE_MINUTES |
Token lifetime |
DATABASE_URL |
Optional; default is project-root SQLite |
DEBUG |
1 only locally (richer 500 detail) |
Do not commit:
.env,*.db, screenshots (*.png/*.jpg)frontend/node_modules/, local logs- Anything under
scripts/private/(maintainer-only)