A live, collaborative discussion-section tool. Navigation is Class → Assignment → Group. A TA's class holds many assignments over time. Students join a TA-preassigned group (or work individually — both options are always available, no join codes), and each group's progress on each assignment is tracked independently.
Each assignment has a shared typist-only code editor plus a private per-student scratch editor, a randomly-chosen prediction quiz with real right/wrong feedback, a sandboxed autograder that runs submitted code against real test cases, per-student confidence ratings that gate advancing to the next question, and a live TA dashboard. TAs author new assignments/questions through a guided form: problem description, code, test cases, and a reference solution that gets sandbox-validated before saving.
Stack: React (Vite) frontend, Flask/SQLAlchemy backend (SQLite locally, Postgres in production via DATABASE_URL), synced with short-interval polling — no WebSockets. Auth is Google OAuth restricted to a configurable email domain (berkeley.edu by default) in production. Locally, when no Google credentials are configured, a passwordless stub takes over instead (just enter a display name and role). Both paths go through one auth boundary in server/auth.py.
- Python 3.11+
- Node 20+
- Docker (for the autograder —
docker build -t discussion-grader:latest ./grader, see below) - Redis (the grading queue runs through it —
brew install redis && brew services start redis, ordocker run -d -p 6379:6379 redis:7-alpine)
# backend
cd server
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cd ..
# frontend
cd client
npm install
cd ..
# root dev convenience script (optional, for `npm run dev`)
npm install
# autograder image (required before "Run tests" will work)
docker build -t discussion-grader:latest ./graderSeed the demo class and assignments (creates server/instance/app.db):
source server/.venv/bin/activate
FLASK_APP=server.app flask seed-dbThis creates one demo class ("CS 61A" / "Disc 12") with two assignments — a hand-authored tree-tracing worksheet and a markdown-authored Hailstone practice assignment (see "Authoring questions" below) — plus four pre-created groups. Sign in as a student and click the class card; no join code needed.
TAs only see classes they've been assigned to (see "Roles" below). The seeded demo class starts unassigned, so a fresh TA login sees no classes until that's set up.
Three terminals, all from the repo root:
# terminal 1 — backend
source server/.venv/bin/activate
FLASK_APP=server.app FLASK_DEBUG=1 flask run -p 5050
# terminal 2 — grading worker (runs the actual Docker container per "Run tests" click)
source server/.venv/bin/activate
FLASK_APP=server.app flask grading-worker
# terminal 3 — frontend
cd client && npm run devOr, with the backend venv already activated, run both from the repo root at once: npm run dev.
Open the Vite dev URL (usually http://localhost:5173). API calls are proxied to the Flask server on port 5050 — not 5000, since macOS's AirPlay Receiver squats on that port by default.
Without terminal 2 running (and Redis up), "Run tests" still accepts the submission, but grading never finishes. See "Grading concurrency" below for why grading runs as a separate process instead of Flask calling Docker inline.
Three roles: student, ta, admin (server/models/user.py). Student and TA both use the passwordless stub — pick a name and role on the login screen, no verification. admin isn't offered on that form, since it's meant to mimic how a real Canvas/bCourses admin/head-TA designation comes from the roster rather than something a user grants themselves:
FLASK_APP=server.app flask create-admin "Your Name"This prints the new admin's numeric id. Use "Sign in as admin" on the login page with that id (POST /api/auth/admin-login). This is a dev-only convenience, disabled in production for the same reason the passwordless stub is (ALLOW_PASSWORDLESS_LOGIN) — a bare numeric id with no password is otherwise brute-forceable. In production, pass --email you@berkeley.edu to create-admin and sign in with Google instead; find_user_by_email resolves the pre-created admin row on first real sign-in.
TA scoping: each class (Section) has at most one assigned TA (Section.ta_user_id). A TA only sees and manages the one class they're assigned to — its groups, roster, assignments, live dashboard. Every other class is invisible to them, same as a student's view. An admin sees and manages every class, and is the only role that can (re)assign a class's TA, from the "Admin" nav link. Since every login is a fresh, non-persistent user (see "Swapping in real auth later" below), a TA needs to sign in at least once before an admin can find and assign them. If that TA signs out and back in, they're a new user id and need reassigning.
Discussion history: both a group's students and its TA/admin can see every released assignment that group has done in its class — status, progress, and points — via the "History" link.
- Sign in as a student, click the demo class, click an assignment, then click a group card to join it (or "Work individually").
- Claim the pen, edit the code, type a prediction for the randomly-shown call, hit "Run tests" — you'll see prediction-quiz feedback alongside real per-test-case pass/fail from the sandboxed autograder.
- Not the typist? Use your own private scratch editor below the shared one. It has an independent "Run tests" button, unaffected by the group cooldown.
- Rate your confidence. Open a second browser (or an incognito window), sign in as a different student, join the same group, and rate too — "Next question" only unlocks once everyone in the group has rated.
- Sign in as a TA once (so that account exists), then bootstrap and sign in as an admin (see "Roles" above) and assign that TA to the demo class from the "Admin" page.
- Sign back in as that TA: click the class, then "Manage groups" to bulk-create/rename/delete groups, or open an assignment's "Live dashboard" to watch groups live (click into a group for its full detail view, or release a stuck typist's pen), or "+ Add question" to author a new question via the guided form. Try a deliberately wrong "passing solution" first to see it get rejected with the specific failing test case.
- A group's progress on two different assignments in the same class is tracked completely independently — separate typist, cooldown, current question.
source server/.venv/bin/activate
pip install -r server/requirements-dev.txt
pytest server/testsCovers the concurrency-sensitive parts of the app — typist-claim race, run cooldown race, advance/double-advance race, all enforced with guarded UPDATE ... WHERE statements at the database level rather than trusted client state — plus the autograder (server/tests/test_grading.py, run against the real Docker image, not mocked: correct/wrong/malicious/infinite-loop submissions, for both grading modes).
Modeled on PrairieLearn's grader-python external grader: one ephemeral, network-isolated, resource-limited Docker container per submission (server/services/grading.py), using the same root → secret-result-filename → drop-to-unprivileged-user pattern PrairieLearn's own run.sh uses internally.
Two grading modes, selected per-question (Question.grading_mode):
pltest— aclass Test(PLTestCase)inQuestion.test_code, using@points/@namedecorators andFeedback.check_scalar/check_list, matching PrairieLearn's real test-authoring API (seegrader/harness/).doctest— runs the>>>examples already present in the student's own function docstrings (the real CS61A/OkPy style) via Python'sdoctestmodule. No separate test file needed.
This is the right foundation for scale, but not "thousands of concurrent students" on its own — that needs a job queue and a worker fleet in front of the same container-invocation logic, which is what "Grading concurrency" below adds.
Docker-per-submission is right for isolation, but a docker run blocking a Flask/gunicorn worker for several seconds becomes a real problem once more than a handful of students click "Run tests" close together — a burst at the start of a live section, say. Every blocked worker is one fewer worker available to serve any request, so the whole site stalls, not just grading.
So POST /groups/:id/run-tests (server/blueprints/groups.py) no longer runs Docker itself. It validates the submission (membership, cooldown, etc.), creates a TestRun row with status="pending", enqueues a job onto a Redis-backed queue (server/services/grading_queue.py), and returns immediately (202). The actual container invocation happens in a separate flask grading-worker process (server/services/grading_jobs.py) that pulls one job at a time off the queue, runs it through the same grading.run_grader() as before, and writes the result back onto the TestRun row. The frontend (client/src/hooks/useTestRunner.js) polls GET /groups/:id/run-tests/:test_run_id until status: "done", then renders the result the same way it always did — only the timing changed, not the shape.
Sizing the worker pool: each flask grading-worker process handles one job — one Docker container — at a time, so the number of worker processes you run is your concurrent-grading cap. Each container is capped at --cpus=0.5 --memory=128m (server/services/grading.py), so N workers need roughly 0.5×N CPU cores and 128MB×N RAM on top of whatever the web app needs. Run as many as your hardware supports:
for i in $(seq 1 10); do FLASK_APP=server.app flask grading-worker & done # 10 concurrent containersIn production, run these under a real process supervisor (systemd, supervisord, or your platform's replica count) instead of loose background processes.
A burst larger than your worker count doesn't fail, it just queues. A spike of 300 students clicking "Run tests" at once degrades to "some students wait longer," not "the site goes down." The frontend's poll loop already tolerates several seconds of queueing.
Two ways to author content, both landing on the same Question model.
In the app (TAs): open an assignment → "+ Add question" → fill in title/difficulty/problem description/problem code, add one or more {call, expected} test cases, and paste a reference "passing solution". The reference solution runs through the real sandboxed grader against the test cases before saving (POST /api/worksheets/:id/questions in server/blueprints/admin.py) — a wrong reference solution gets rejected with the specific failing case, catching authoring typos before students ever see them. This is grading_mode="simple": server/services/test_case_grading.py auto-generates the PLTestCase test code from the structured test cases, reusing the grader's existing pltest path instead of adding a new container-side mode.
As markdown files (content/): worksheets can also be authored as git-committed markdown instead of the form. Layout:
content/worksheets/<worksheet-slug>/
├── manifest.json # {slug, title, class_course_name, class_name, question_ids: [...], ...}
└── questions/<question-id>/
├── question.md # frontmatter + body (see content/worksheets/cs61a-practice/ for a real example)
└── <code file> # referenced by `code:` in the frontmatter / `@code <file>` in the body
question.md frontmatter (id, title, difficulty, code) is followed by a --- and then markdown prose. Two directives are recognized and stripped from the rendered prompt: @code <file> pulls in the sibling starter-code file, and @pytest <name> marks the question as doctest-graded (grading_mode="doctest", no separate test-case authoring needed). A :::solution ... ::: block is extracted separately and only shown to students on demand. manifest.json's class_course_name/class_name declare which class the assignment belongs to — server/seed.py upserts the class first, so multiple assignments (git-authored or form-authored) can share one class, then creates the worksheet under it.
server/content/loader.py is today's content source for the markdown path, reading these directories at seed time via server/seed.py. Pulling this content from an external repo automatically is a natural next step — the loader is the abstraction boundary for that, the same way server/auth.py is the boundary for real OAuth. A future load_worksheet_from_repo(url) would slot in without touching anything downstream.
The app is Postgres-ready: SQLALCHEMY_DATABASE_URI reads from the DATABASE_URL env var (falling back to local SQLite only when unset), and schema changes go through Alembic (server/migrations/) via Flask-Migrate instead of db.create_all().
Required env vars in production — see .env.example for a copyable template. ProdConfig fails fast at startup if any of these are missing or wrong:
SECRET_KEY— any long random string; must not be the dev default.DATABASE_URL— a Postgres URL, e.g.postgresql://user:pass@host:5432/dbname. SQLite is rejected outright.GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET— from a Google OAuth client (see "Setting up Google sign-in" below). The passwordless dev stub is hard-disabled inProdConfig, so these are required.REDIS_URL— e.g.redis://host:6379/0, backs the grading job queue (see "Grading concurrency" above).ProdConfigpings it at startup and refuses to boot if it's unreachable — silently-broken grading is worse than a crash-on-deploy.FLASK_ENV=production— selectsProdConfig(server/app.py), which also turns onSESSION_COOKIE_SECUREand forces thehttpsURL scheme. Must run behind HTTPS.ALLOWED_EMAIL_DOMAIN— defaults toberkeley.edu; only needed for a different school/domain.GRADER_IMAGE— defaults todiscussion-grader:latest; only needed if you tag it differently.
Also run one or more flask grading-worker processes — the web process alone accepts submissions but never grades them.
GET /api/health checks real DB connectivity (not just "the process is up") and returns 200/503. Point a load balancer or uptime monitor at it.
Only /api/auth/login and /api/auth/admin-login are rate-limited (server/extensions.py, server/blueprints/auth.py) — not the whole app. This app polls constantly (group state every ~2.5s, run-tests every 1s while grading) and students are often behind one shared IP (campus WiFi/NAT), so a blanket per-IP limit would throttle a whole classroom's legitimate traffic instead of catching abuse. The two login endpoints are rare, one-shot, and the only ones with brute-forceable auth (admin-login takes a bare numeric id — see "Roles" above): admin-login is capped at 5/minute per IP, login at 20/minute. Both already 404 in production anyway (ALLOW_PASSWORDLESS_LOGIN), so this mainly matters for a staging environment that intentionally leaves passwordless login on. It's backed by the same Redis as the grading queue (RATELIMIT_STORAGE_URI, defaults to REDIS_URL), so the limit holds across every gunicorn worker, not per-process.
- In Google Cloud Console, create (or reuse) a project, then go to APIs & Services → OAuth consent screen. Choose Internal if this is a Google Workspace org you control (restricts to your org automatically) or External otherwise — the app also independently rejects any email not ending in
ALLOWED_EMAIL_DOMAINserver-side. - Go to APIs & Services → Credentials → Create Credentials → OAuth client ID, type Web application.
- Add an Authorized redirect URI of
https://<your-domain>/api/auth/google/callback(and, for local testing against real Google,http://localhost:5050/api/auth/google/callback— this must hit Flask's own port directly, not the Vite dev port, since Google can't be proxied). - Copy the generated Client ID and Client Secret into the
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETenv vars.
With those two env vars unset (the local dev default), the login page falls back to the passwordless stub and /api/auth/google/* routes 404. Nothing above is required to keep developing locally.
Bringing up a fresh Postgres database:
DATABASE_URL=postgresql://... FLASK_APP=server.app flask db upgrade
DATABASE_URL=postgresql://... FLASK_APP=server.app flask create-admin "Your Name" --email you@berkeley.eduWhenever a model changes, generate and commit a new migration instead of hand-editing the schema:
FLASK_APP=server.app flask db migrate -m "describe the change"
FLASK_APP=server.app flask db upgradeDockerfile builds the React app and serves it from Flask via gunicorn. Two things to know before a real launch at meaningful scale:
- Docker deployment:
flask grading-workershells out to thedockerCLI directly (server/services/grading.py), so it needs a host with real Docker daemon access. Most fully-managed platforms (Heroku, Cloud Run, Fargate) don't allow this — run it on a real VM, or a dedicated worker VM the web app doesn't share. See "Process supervision" below. - Backups: only needed if you're self-managing Postgres. Most managed hosts (RDS, Supabase, Neon, Fly Postgres) already handle this for you. See "Backups" below if you're not.
Nothing should run as a loose background process in production — the web app, every grading worker, and the container reaper all need to come back on their own after a crash or reboot. deploy/ has systemd units for a single-VM deployment (the web app can just as well run from Dockerfile on a managed platform instead — only the grading workers actually require a host with real Docker access):
cs61a-discussion-web.service— gunicorn serving the app. Sync workers are fine here: "Run tests" no longer blocks on Docker (see "Grading concurrency" above), so nothing about serving pages or the frequent short polls needs the thread/async tuning a synchronous Docker call would have required.cs61a-grading-worker@.service— a template unit; each instance is oneflask grading-workerprocess, and therefore one concurrent Docker container (see "Grading concurrency" for sizing how many to run against real hardware).cs61a-grader-reaper.service+.timer— runsdeploy/scripts/reap_grader_containers.pyevery 5 minutes, removing anygrader-*container still around well past its own timeout. This catches the crash-mid-grading case thatgrading.py's own cleanup can't, since that code only runs if the owning Python process is still alive.
To install on a fresh VM (adjust paths/user to taste):
sudo useradd --system --home /opt/cs61a-discussion cs61a
sudo usermod -aG docker cs61a # lets the grading workers run `docker` without sudo
sudo cp deploy/systemd/*.service deploy/systemd/*.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now cs61a-discussion-web
sudo systemctl enable --now cs61a-grader-reaper.timer
# One instance per desired concurrent Docker container:
sudo systemctl enable --now cs61a-grading-worker@{1..10}Both .service files read /opt/cs61a-discussion/.env for the env vars listed above (SECRET_KEY, DATABASE_URL, GOOGLE_CLIENT_ID/SECRET, REDIS_URL, etc. — see .env.example). Create that file on the host; it isn't, and shouldn't be, committed to the repo.
Pushing to main doesn't deploy anything by itself — it only runs CI. This is a live tool students use during actual discussion sections, so deploys happen on purpose, not automatically on every commit. deploy/scripts/deploy.sh does the actual work — pulls main, reinstalls backend deps, runs migrations, rebuilds the grader image and the frontend, restarts the web app and every grading worker, then checks /api/health:
sudo -u cs61a bash /opt/cs61a-discussion/deploy/scripts/deploy.shNothing terminates HTTPS on its own — gunicorn (cs61a-discussion-web.service) just listens on 127.0.0.1:8080. deploy/Caddyfile puts Caddy in front of it: replace fake-domain.example.edu with your real domain and Caddy provisions and renews a Let's Encrypt certificate automatically, no separate certbot setup. (nginx works too if you already run it elsewhere; Caddy's just less to configure correctly for one domain.)
sudo apt install -y caddy # or your distro's equivalent
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
sudo systemctl restart caddyPoint the domain's DNS A/AAAA record at the VM before starting Caddy, or it can't complete the Let's Encrypt challenge. server/app.py wraps the app in Werkzeug's ProxyFix whenever ProdConfig is active — without it, every request would appear to come from Caddy's own IP once behind the proxy, which would silently break the per-IP rate limits above by bucketing every real user together under one address.
Opt-in via SENTRY_DSN (server/app.py), unset by default everywhere including production — wire it up once you have an account, it's not a required env var. Once set, uncaught exceptions in the web app, the CLI commands, and every flask grading-worker process get reported (they all go through the same create_app()). SENTRY_TRACES_SAMPLE_RATE (default 0) separately controls performance tracing, which costs quota — leave it off until you actually want it. Without a DSN, exceptions just go wherever gunicorn's own logs go.
Only relevant if you're self-managing Postgres rather than using a host that already backs it up (RDS, Supabase, Neon, Fly Postgres all do). deploy/scripts/backup_postgres.sh runs pg_dump --format=custom (compressed, restorable with pg_restore, including selective-table restores) to $BACKUP_DIR (default /var/backups/cs61a-discussion) and prunes anything older than $BACKUP_RETENTION_DAYS (default 14). Set BACKUP_S3_BUCKET (with the aws CLI available) to also copy each dump off-host — a backup that only lives on the same machine as the database doesn't protect you against losing that machine. Requires pg_dump/pg_restore on the host (the postgresql-client package, separate from the app's own Python venv).
Install alongside the other systemd units:
sudo cp deploy/systemd/cs61a-postgres-backup.service deploy/systemd/cs61a-postgres-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now cs61a-postgres-backup.timerTo restore: pg_restore --clean --if-exists --dbname="$DATABASE_URL" /path/to/backup.dump. Use a pg_restore version matching (or newer than) the server's major version — an older server can reject a directive a newer client's dump includes (e.g. transaction_timeout, added in Postgres 17), though as a session-level setting it's safe to ignore if it comes up.
server/auth.py is the auth boundary: get_current_user(), login_required, and role_required are used by every route. Everything downstream only cares that session["user_id"] is set to a real User.id, not how it got there. GET /api/auth/google/login / GET /api/auth/google/callback in server/blueprints/auth.py implement this: they redirect to Google, verify the returned email is email_verified and ends in ALLOWED_EMAIL_DOMAIN, then resolve it through the same find_user_by_email used by roster import. A TA or student who already exists (from flask create-admin, TA-roster import, or enrollment import) keeps their assigned role/section on first real sign-in; anyone else gets a fresh student account. See "Setting up Google sign-in" above for the Cloud Console side. Swapping to a different provider (Canvas/bCourses OAuth, say) just means writing an equivalent pair of routes that end the same way — nothing else changes.