A self-hosted, Docker-based platform for running practical programming exams on a local network.
Instructors author exams, monitor every student live, grant extra time, and unlock questions on the fly. Students work in a browser-based JupyterLab environment with a fixed, reproducible library set — no local setup, no dependency drift, no "it worked on my machine".
- Why ExamBox
- Features
- Architecture
- Tech Stack
- Quick Start
- Usage
- Configuration
- Data Model
- API Reference
- Project Structure
- Known Limitations
- Roadmap
- Academic Context
- License
Practical exams in data science courses have a recurring infrastructure problem: every student machine is different. Library versions drift, installs fail mid-exam, and grading a submission means reconstructing the environment it was written in.
ExamBox removes that class of problem by making the environment part of the exam. The runtime is a pinned Docker image, so every student runs the same Python, the same pandas, the same scikit-learn. Submissions arrive as a ZIP archive at a single, predictable location on the instructor's machine, and the whole system runs on a LAN with no cloud dependency, no accounts, and no external services.
It's built to be started with one command and torn down just as easily.
For instructors
- Exam authoring — Create exams with a title, subject, duration, and an arbitrary number of questions.
- Question editor — Write descriptions in Markdown, provide starter code, attach datasets (CSV, JSON, images), and upload a PDF statement per question.
- Notebook generation — Turn a question's metadata into a ready-to-run
.ipynbfrom the UI; title and description become Markdown cells, starter code becomes the first code cell. - Live dashboard — Poll every student's status, current question, unlocked questions, and remaining time.
- Mid-exam controls — Grant extra minutes to an individual, unlock the next question for one student or for everyone at once, or force a submission.
- Centralized submissions — Every workspace is zipped and stored under
data/submissions/<exam>/<student>/<timestamp>.zip, downloadable from the browser.
For students
- Zero setup — A browser is the only requirement. JupyterLab is served by the platform.
- Reproducible environment — A pinned scientific Python stack:
pandas,numpy,matplotlib,seaborn,scipy,scikit-learn. - Progressive disclosure — Questions appear as folders (
Q1/,Q2/, …) only once the instructor unlocks them. - Reference materials — Read-only course notes mounted into the workspace.
- Live timer — A countdown backed by server-side time, so it survives a page refresh.
Two services on a Docker Compose network. The exam manager owns all state and orchestration; JupyterLab is the student-facing execution environment.
flowchart TB
subgraph host["Instructor machine — Docker host"]
subgraph net["exam_network (bridge)"]
manager["<b>exam-manager</b><br/>FastAPI + Jinja2<br/>:8080 → :8000"]
jupyter["<b>jupyter</b><br/>JupyterLab 4<br/>:8888"]
end
db[("SQLite<br/>exambox.db")]
volumes["<b>Shared volumes</b><br/>exams/ · materials/<br/>work/ · submissions/"]
end
prof["Instructor<br/>browser"] -->|"dashboard · authoring"| manager
stud["Students<br/>browsers (LAN)"] -->|"login · timer · submit"| manager
stud -->|"notebooks"| jupyter
manager --- db
manager -->|"unlock: copy Qn → workspace"| volumes
manager -->|"submit: zip workspace"| volumes
volumes --- jupyter
Request flow. A student registers through the exam manager, which creates a StudentSession, provisions their workspace directory, copies in Q1, and stamps a server-side end_time. The student is handed a JupyterLab URL and works there. Unlocking a question is a filesystem operation: the manager copies Q{n} from the exam template into the student's workspace, where JupyterLab's file browser picks it up. Submitting walks the workspace, writes a ZIP into data/submissions/, and records a Submission row.
Design note. Questions are unlocked by copying folders into a workspace rather than by gating an API. This means the mechanism keeps working regardless of what the student does inside the notebook, and it leaves the submission as a plain directory tree — trivially inspectable and gradeable without the platform running.
| Layer | Technology |
|---|---|
| API & web UI | FastAPI 0.104, Jinja2, Uvicorn |
| Persistence | SQLAlchemy 2.0 (async), SQLite via aiosqlite |
| Validation | Pydantic v2 |
| Student runtime | JupyterLab 4.0, pandas, numpy, matplotlib, seaborn, scipy, scikit-learn |
| Packaging | Docker, Docker Compose, Python 3.11 slim images |
| Frontend | Server-rendered Jinja2 templates, vanilla JavaScript |
- Docker Desktop (or Docker Engine) with Compose v2
- 4 GB of available RAM
git clone https://github.com/cofrian/Exam_Box.git
cd Exam_Box
docker compose up --buildFirst build takes a few minutes while the scientific Python stack is installed. Once it's up:
| Interface | URL |
|---|---|
| Instructor panel | http://localhost:8080 |
| Student login | http://localhost:8080/student |
| JupyterLab | http://localhost:8888 |
| API docs (Swagger) | http://localhost:8080/docs |
| Health check | http://localhost:8080/health |
To stop: docker compose down. Exams, materials, and submissions live in data/ on the host and survive teardown.
Start the stack on the instructor's machine, find its IP (ipconfig on Windows, ip addr on Linux/macOS), and point students at it:
- Instructor:
http://<host-ip>:8080 - Students:
http://<host-ip>:8080/student
Port 8888 must also be reachable, since students' browsers connect to JupyterLab directly.
- Create an exam at
/professor/exams— name, subject, duration, question count. Set template folder to a directory underdata/exams/(e.g.demo_python) and materials folder to one underdata/materials/(e.g.apuntes_python). - Author the questions via Edit Questions: Markdown description, starter code, attached datasets, PDF statement, and points. Use Generate Notebooks to produce the
.ipynbfiles. - Activate the exam so it appears on the student login page.
- Monitor from the dashboard: per-student progress, status, and remaining time.
- Intervene as needed —
+5 min, unlock the next question (per student or for all), or force a submission. - Collect from
/professor/submissions, where each student's ZIP is listed and downloadable.
- Open
/student, pick the active exam, enter your ID and full name. - Click Start Exam to open JupyterLab with your workspace.
- Work through the unlocked question folders. Save often.
- Click Submit when done.
A bundled demo exam lives in data/exams/demo_python/ (three questions, with a ventas.csv dataset) alongside course notes in data/materials/apuntes_python/, so you can exercise the full flow immediately. Open two incognito windows as two students to see the dashboard and unlock controls work live.
Copy .env.example to .env to override defaults.
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
sqlite+aiosqlite:///./data/exambox.db |
Async SQLAlchemy connection string |
DATA_PATH |
/app/data |
Root for exams, materials, workspaces, submissions (inside the container) |
JUPYTER_URL |
http://localhost:8888 |
JupyterLab base URL handed to students |
JUPYTER_TOKEN |
exambox123 |
JupyterLab auth token |
HOST_DATA_PATH |
(unset) | Host path to data/, used when spawning per-student containers (see Roadmap) |
Edit student-image/requirements.txt to change the available libraries, then rebuild:
docker compose build jupyterPinning versions here is what makes submissions reproducible — keep them pinned.
An exam template is just a folder of question folders:
data/exams/my_exam/
├── Q1/
│ ├── question1.ipynb
│ └── dataset.csv
├── Q2/
└── Q3/
Reference it by folder name (my_exam) when creating the exam. Materials work the same way under data/materials/.
Four tables, managed with async SQLAlchemy.
| Model | Role |
|---|---|
Exam |
Exam definition — subject, duration, question count, template/materials folders, active flag |
Question |
A single question — title, Markdown description, instructions, starter code, attachments, PDF, points |
StudentSession |
One student's run — status, progress, unlocked count, started_at / end_time, extra minutes |
Submission |
A delivered archive — file path, size, timestamp, and whether it was auto-submitted |
Time is authoritative on the server: end_time is computed at session start and adjusted when extra minutes are granted, so the client-side countdown is only ever a display of server state.
Full interactive documentation is at /docs (Swagger UI) while the stack is running.
Exams
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/exams |
List all exams |
POST |
/api/exams |
Create an exam |
GET |
/api/exams/{id} |
Get an exam |
GET |
/api/exams/{id}/full |
Get an exam with its questions |
POST |
/api/exams/{id}/activate |
Open an exam to students |
POST |
/api/exams/{id}/deactivate |
Close an exam |
Questions
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/exams/{id}/questions |
List an exam's questions |
POST |
/api/questions |
Create a question |
PUT |
/api/questions/{id} |
Update a question |
DELETE |
/api/questions/{id} |
Delete a question and reorder the rest |
POST |
/api/questions/{id}/upload |
Attach a file (dataset, PDF, image) |
POST |
/api/questions/{id}/generate-notebook |
Generate the .ipynb for a question |
Sessions & monitoring
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/students/register |
Register a student for an exam |
POST |
/api/students/{id}/start |
Start a session and provision the workspace |
GET |
/api/students/{id}/status |
Session status and progress |
GET |
/api/students/{id}/time |
Server-authoritative remaining time |
GET |
/api/dashboard/{exam_id} |
Aggregated dashboard payload |
Instructor actions & submissions
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/actions/add-time |
Grant extra minutes |
POST |
/api/actions/unlock-question |
Unlock the next question (one student or all) |
POST |
/api/actions/force-submit/{id} |
Force a submission |
POST |
/api/submit/{session_id} |
Submit an exam |
GET |
/api/submissions/{exam_id} |
List an exam's submissions |
GET |
/api/submissions/download/{id} |
Download a submission archive |
Exam_Box/
├── docker-compose.yml # Main stack: exam-manager + jupyter
├── docker-compose.dev.yml # Variant that mounts the Docker socket
│
├── exam-manager/ # FastAPI backend and web UI
│ ├── app/
│ │ ├── main.py # Routes and API endpoints
│ │ ├── models.py # SQLAlchemy models
│ │ ├── schemas.py # Pydantic schemas
│ │ ├── database.py # Async engine and session factory
│ │ └── docker_manager.py # Per-student container orchestration (not yet wired in)
│ ├── templates/ # Jinja2 templates (professor/, student/)
│ └── static/css/
│
├── student-image/ # JupyterLab image for students
│ ├── Dockerfile
│ ├── requirements.txt # Pinned scientific Python stack
│ ├── jupyter_notebook_config.py
│ └── scripts/ # entrypoint, unlock watcher, submit
│
└── data/ # Host-persisted state
├── exams/ # Exam templates (demo_python ships as an example)
├── materials/ # Read-only reference material
├── work/ # Student workspaces
├── submissions/ # Delivered ZIP archives
└── db/ # SQLite database
Stated plainly, because knowing where a system's edges are is part of having built it.
- One shared JupyterLab instance. The shipped
docker-compose.ymlruns a singlejupyterservice that all students connect to, sharing one token and one mountedwork/tree. Sessions, timers, progress, and submissions are tracked per student, but the execution environment is not isolated between them.exam-manager/app/docker_manager.pyimplements per-student container provisioning — unique token, port from a pool, 1 GB memory cap, 50% CPU quota — butmain.pydoes not currently call it. See Roadmap. - No network isolation.
exam_networkis a standard bridge, so student containers reach the Internet. Making the networkinternal: truerequires the per-student container work above, since the shared instance is reachable from student browsers on the host network. - No authentication. The instructor panel is unauthenticated; students identify themselves by typing an ID. This is a deliberate trade-off for a trusted LAN in a proctored room, and it is the first thing to change for any other setting.
- Environment control is not proctoring. ExamBox controls what's inside the container. It cannot see the rest of the student's machine. High-stakes use needs real proctoring alongside it.
pip_freeze.txtis a stub. The submission archive includes a placeholder rather than the environment's actual package list, because the manager can't currently introspect the student container. Real value depends on the per-student container work.- SQLite and polling. Fine for a classroom (tens of concurrent students); the dashboard polls rather than pushing over WebSockets, and SQLite serializes writes. Neither would hold up at a larger scale.
The natural next steps, in dependency order:
- Wire up per-student containers.
docker_manager.create_student_container()already exists and is ready to be called from the session-start endpoint, replacing the shared instance with one container per student, each with its own token, port, and resource caps. - Isolate the network. With per-student containers in place, mark the student network
internal: trueso the only reachable host is the exam manager. - Real
pip_freeze. Runpip freezeinside the student container at submission time and bundle the true output. - Instructor authentication. Session-based login on
/professor/*. - Push-based dashboard. Replace polling with WebSockets.
- Auto-submission on expiry. Currently a session can run past
end_timeuntil it's force-submitted; a background task should close it out.
Built for IPD — Infraestructuras para el Procesamiento de Datos (Data Processing Infrastructures), part of the Data Science degree at Universitat Politècnica de València (UPV).
Final grade: 10/10.
The brief was to design and deploy a containerized, multi-service system. ExamBox uses the course material — image construction, Compose orchestration, volume and network design, service isolation, and resource limits — to solve an infrastructure problem the course itself has: running a practical exam where the environment is guaranteed identical for everyone and the results come back reproducible.
MIT © Sergio Ortiz