A self-hosted tool for tracking study programs, modules, grades and weekly assignment submissions (exam admission requirements) across a double-degree or multi-program setup. Built for a Mathematics/Physics double degree with additional Economics modules — but useful for any program with multiple degree tracks, combined modules and weekly problem sets.
In a double degree (or minor/major combinations), keeping track of grades gets messy fast: modules count differently across programs, some modules get merged into combined modules, and exam admission depends on separately clearing a minimum score in several weekly assignment series (problem sets, programming exercises, …). Grade Tracker models exactly that, instead of rebuilding it in a spreadsheet every semester.
- Multi-user – sign up with username/email/password, log in/out. All data is strictly isolated per user.
- Email verification – a code-based flow verifies the address on sign-up and on every email change; the old address stays active until a changed one is confirmed. Codes are emailed via SMTP if configured, or logged by the backend otherwise (see Setup).
- Account settings – change username, email (re-verified) and password at any time, or delete the account and all its data permanently.
- Password reset – forgot your password? Request a code by email and set a new one, no login required.
- Manage degree programs – add, rename and delete as many programs as you like (e.g. Mathematics, Physics, Economics). Modules without an assignment automatically fall under "Other".
- Modules in multiple programs – a module can be credited in several programs at once, each assignment counted independently (same principle as combined modules, see below).
- Graded or not graded – both modules and combined modules can be marked "not graded": only passed/failed attempts, no numeric grade - their credits still count once passed, they just have no say in the average. A combined module mixing graded and not-graded source modules averages just the graded ones.
- Modules with up to 3 grade attempts – each attempt is either a grade (German scale 1.0–5.0) or "passed"/"failed". The best attempt counts automatically.
- Weekly assignments & exam admission – any number of assignment series per module (e.g. a weekly "problem set" and a bi-weekly "programming exercise"), each with its own admission threshold (default 50%). Give a series a week count up front and it's pre-filled at 0 points so you can see the whole series - and what's left - right away; log real points per week as they come in.
- Combined modules – merge one or more modules into one module with its own credits and averaged grade (e.g. Calculus + Linear Algebra → Math for Physicists, or a single module re-credited under different credits), independent of how the source modules count in their own program.
- Credit-weighted grade average – per program and overall, with special handling for not-graded "passed" modules (count toward credits, not toward the average).
- Dashboard – overall average, credits per program and open exam admissions at a glance.
flowchart LR
subgraph Browser
UI[Next.js Frontend<br/>React + TypeScript + Tailwind]
end
subgraph Docker Compose
UI -->|REST/JSON| API[Flask API]
API --> DB[(PostgreSQL<br/>Docker Volume)]
end
The business logic (grade average calculation, exam admission logic,
combined-module averaging) deliberately lives separate from the web layer
in backend/app/grading.py — pure, independently
testable functions with no Flask or database dependency
(see backend/tests/test_grading.py).
erDiagram
USER ||--o{ PROGRAM : owns
USER ||--o{ MODULE : owns
USER ||--o{ COMBINED_MODULE : owns
PROGRAM }o--o{ MODULE : "assigns (>=0)"
PROGRAM ||--o{ COMBINED_MODULE : contains
MODULE ||--o{ GRADE_ATTEMPT : "up to 3"
MODULE ||--o{ ASSIGNMENT_SERIES : has
ASSIGNMENT_SERIES ||--o{ SUBMISSION : has
COMBINED_MODULE }o--o{ MODULE : "combines (>=1)"
USER {
int id
string username "unique"
string email "unique"
bool email_verified
string pending_email "awaiting verification"
string password_hash
}
PROGRAM {
int id
int user_id
string name
}
MODULE {
int id
int user_id
string name
float credits
bool graded
}
GRADE_ATTEMPT {
int id
int slot "1-3"
string kind "numeric | pass | fail"
float value
}
ASSIGNMENT_SERIES {
int id
string name
float threshold_percent "default 50"
int total_weeks
}
SUBMISSION {
int id
int week_number
float points_achieved
float points_max
}
COMBINED_MODULE {
int id
int user_id
string name
float credits
int program_id
bool graded
}
A module with no program assignment is "Other"; with several assignments it counts independently in each one (many-to-many via an association table, the same principle used for a combined module's source modules).
| Area | Technology |
|---|---|
| Backend | Flask 3, Flask-SQLAlchemy, Flask-Migrate (Alembic), PostgreSQL, pytest, gunicorn |
| Frontend | Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS 4 |
| Infra | Docker, Docker Compose |
Requires Docker & Docker Compose.
git clone <this-repo>
cd grade_tracker
docker compose up --build- Frontend: http://localhost:3001
- Backend API: http://localhost:5001/api
Ports can be changed via
NEXT_PUBLIC_API_URL(see.env.example) or theports:mapping indocker-compose.ymlif they're already taken locally. For anything beyond local use, also setSECRET_KEY(signs login tokens) to your own random value in a.envfile — see.env.example.
Sign up via the web UI first (/signup), then log in. Without SMTP
configured, verification/reset codes aren't actually emailed - they're
logged by the backend instead (docker compose logs backend), which is
fine for local use. Set SMTP_HOST and friends in .env (see
.env.example) to send real emails.
Optionally, seed some example data (Calculus, Linear Algebra, the combined module "Math for Physicists", …) under a demo login:
docker compose exec backend python seed.py
# Demo login: demo@example.com / demo12345Needs a running Postgres reachable via DATABASE_URL (e.g. docker compose up db
to just run the database, or a local install).
# Backend
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export DATABASE_URL=postgresql+psycopg://grade_tracker:grade_tracker@localhost:5432/grade_tracker
export FLASK_APP=wsgi.py
flask db upgrade # creates/updates the schema
flask run --port 5001
# Frontend (separate terminal)
cd frontend
npm install
NEXT_PUBLIC_API_URL=http://localhost:5001 npm run devThe schema is managed with Flask-Migrate/Alembic
(backend/migrations/) instead of db.create_all(). After changing a model
in backend/app/models.py, generate and review a migration, then apply it:
cd backend
flask db migrate -m "describe the change"
flask db upgradeMigrations run automatically on container start (see
backend/entrypoint.sh).
cd backend
source .venv/bin/activate
pytestThe grading logic (grading.py) is fully unit-tested; the API routes are
covered by Flask test-client tests.
cd frontend
npx playwright install --with-deps chromium # once
npm run test:e2eRuns a full account lifecycle (signup → verify email → change
username/email → confirm the email change → log out/in → forgot/reset
password → log in with the new password → delete the account) against the
real Docker Compose stack in a real browser - see
frontend/e2e/ and
frontend/playwright.config.ts. If
docker compose is already running locally, tests reuse it; otherwise the
config starts it for you.
CI (.github/workflows/ci.yml) runs the backend
test suite, the frontend lint/build, and the Playwright end-to-end suite
(against a real docker compose stack) on every push. Everything below is
already in place; what's left needs a real server:
- CORS – restrict via
ALLOWED_ORIGINS(comma-separated), otherwise wide open. Applies to the API only. - Rate limiting – login, register, verification/reset codes are all
IP-limited (
app/routes/auth.py) on top of the existing per-account code cooldowns. Storage is in-memory (app/limiter.py), which is fine for a single instance; scaling to multiple backend instances would need switching that to a shared backend (e.g. Redis). - Fail-fast in production – set
APP_ENV=productionand the backend refuses to start with the defaultSECRET_KEY, and logs a warning ifALLOWED_ORIGINSis still wide open. - Docker images – both containers run as a non-root user; base images
are pinned by digest (see the comments above each
FROMline for how to bump them). - Backups – see
docs/backup.mdfor the Postgres dump/restore commands and a cron snippet. - Optional error tracking – set
SENTRY_DSNto enable Sentry on the backend; unset, it's a no-op. - Legal pages –
/impressumand/datenschutzare filled in with the operator's real details (German Impressumspflicht/DSGVO). Update them if the hosting provider or mail provider changes. - Security review – a focused review of the auth/verification flows and the CORS/rate-limiting hardening found no confirmed issues.
What's left needs a real server: DNS, TLS termination, and the one-time
host setup (Docker, reverse proxy, .env). That part is necessarily
server-specific (domain, paths, SSH access) and isn't tracked in this
repo.
All endpoints under /api, JSON-based. Except for /api/health and
/api/auth/register//api/auth/login, every endpoint requires an
Authorization: Bearer <token> header and only returns the signed-in
user's data.
| Method & Path | Purpose |
|---|---|
POST /auth/register |
Sign up (username, email, password) → token; emails a verification code |
POST /auth/login |
Log in (email, password) → token; works whether or not the email is verified yet |
GET /auth/me |
Get the current user |
PATCH /auth/me |
Change username / email / password (current_password required). A new email is held as pending_email and re-verified before it takes effect |
POST /auth/verify-email |
Confirm the initial signup email with its code |
POST /auth/verify-email-change |
Confirm a pending email change with its code |
POST /auth/resend-code |
Re-send whichever verification code is currently pending (rate-limited) |
POST /auth/forgot-password |
Request a password-reset code by email |
POST /auth/reset-password |
Set a new password using a reset code (email, code, new_password) |
DELETE /auth/me |
Permanently delete the account and all owned data (current_password required) |
GET/POST /studiengaenge |
List / create degree programs |
PATCH/DELETE /studiengaenge/<id> |
Rename / delete |
GET/POST /module |
List modules (optional ?studiengang_id=) / create (studiengang_ids: [], graded default true) |
GET/PATCH/DELETE /module/<id> |
Read / edit (incl. graded) / delete a module |
POST /module/<id>/grades |
Set a grade attempt (slot 1–3); numeric is rejected if the module isn't graded |
DELETE /grades/<id> |
Delete a grade attempt |
POST /module/<id>/series |
Create an assignment series; with total_weeks, points_per_week is required and pre-fills that many weeks at 0 points |
PATCH/DELETE /series/<id> |
Edit / delete an assignment series |
POST /series/<id>/submissions |
Log a weekly submission |
PATCH/DELETE /submissions/<id> |
Edit / delete a submission |
GET/POST /kombimodule |
List / create combined modules (source_module_ids: one or more, graded default true) |
PATCH/DELETE /kombimodule/<id> |
Edit (incl. graded, source_module_ids) / delete a combined module |
GET /stats/overview |
Grade average & exam-admission status per program + overall |
grade_tracker/
├── .github/workflows/ # CI (backend tests, frontend lint/build, Playwright E2E)
├── docs/backup.md # Postgres backup/restore
├── backend/
│ ├── app/
│ │ ├── grading.py # pure calculation logic (average, admission, combined-module averaging)
│ │ ├── auth.py # bearer token issuing/verification, login_required
│ │ ├── codes.py # email verification / password-reset code generation & checks
│ │ ├── email.py # outbound email (SMTP, or logged when unconfigured)
│ │ ├── limiter.py # shared Flask-Limiter instance
│ │ ├── models.py # SQLAlchemy models
│ │ └── routes/ # Flask blueprints (REST endpoints, incl. auth.py)
│ ├── migrations/ # Alembic schema migrations (Flask-Migrate)
│ ├── scripts/ # one-off maintenance scripts
│ ├── tests/ # pytest
│ └── seed.py # example data incl. demo user
└── frontend/
├── e2e/ # Playwright end-to-end tests (run against docker compose)
├── playwright.config.ts
└── src/
├── app/ # Next.js App Router pages (incl. login/signup/account/verify-email/forgot-password/reset-password/impressum/datenschutz)
├── components/ # UI building blocks (ProgressBar, GradeBadge, ConfirmDialog, Footer, …)
└── lib/ # API client, types, AuthContext