A full-stack academic management platform for educational institutions. Lyceum provides role-based portals for students, faculty, and administrators to manage academic records, course enrollment, grading, student welfare requests, and institutional analytics.
Built with Flask, MySQL, and Jinja2, with Chart.js-powered dashboards and OTP-secured email authentication.
Most college workflows — checking grades, submitting a hostel request, updating a roster, promoting a cohort at the end of the semester — still happen through a patchwork of email threads, spreadsheets, and paper forms. This project consolidates those flows into a single, self-hostable web app that a small department can stand up on a laptop or a modest VPS.
What it does
- Gives students a live view of their CGPA, semester GPA, and grade history, plus a what-if simulator for planning.
- Lets faculty own their courses end-to-end: create a course, enroll students, record component-level marks (attendance, assignments, internals, externals), and process student-welfare requests routed to them.
- Gives administrators the levers to run the institution: create users, manage departments, promote cohorts in bulk, audit actions, and export request data as CSV — all filterable by date range.
- Secures access with email OTP login, bcrypt password hashing, forced reset on first login, CSRF-protected forms, rate-limited auth endpoints, and hardened session cookies.
Who it's for
- Small colleges and departments looking for a lightweight alternative to heavy commercial ERPs.
- Students of software engineering and databases who want a realistic Flask + MySQL codebase to read, extend, or fork.
- Developers evaluating role-based-access patterns, OTP flows, or Jinja dashboards as a reference.
Design principles
- Boring stack, readable code. One
app.pyholds every route so flow-through is easy to trace. Templates are plain Jinja — no build step. - Safe defaults. Secrets live in env vars, sessions are HTTP-only, CSRF is global, auth endpoints are rate-limited, and DB connections are cleaned up in a teardown hook.
- Zero vendor lock-in. MySQL + SMTP + a WSGI server is all you need. Run it on bare metal, a VPS, or behind any reverse proxy.
- Features
- Architecture
- Tech Stack
- Project Structure
- Prerequisites
- Quick Start
- Configuration
- Database Setup
- Running the Application
- Docker
- Tests & CI
- Operations
- Default Roles & Workflows
- Security Notes
- Contributing
- License
- Personal dashboard with CGPA and semester-wise GPA analytics
- View enrolled courses, credits, and grade breakdowns
- Grade simulator — run what-if scenarios against current grades
- Submit Student Welfare Division (SWD) requests (leave, bonafide, grievances, etc.)
- Track request status and faculty assignment
- Secure profile management and password reset
- Course management (create, edit, assign credits and semesters)
- Student enrollment and roster management
- Component-based grading — attendance, assignments, internals, externals
- Review and process SWD requests forwarded by administration
- Per-course analytics and student progress insights
- User provisioning for students, faculty, and administrators
- Bulk semester promotion for cohorts
- Department CRUD and faculty/department assignments
- Institutional analytics dashboard:
- Total students, faculty, and pending/resolved requests
- Request distribution by category, status, and department
- Date-range filtering
- Audit log for action tracking
- CSV export of SWD requests
- Request routing and faculty assignment
- OTP-based login (6-digit, 5-minute expiry) via email
- Password reset via time-boxed email links (15-minute expiry)
- Bcrypt password hashing
- Server-side session management
- Forced password reset on first login
┌────────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐
│ Browser (Jinja2 UI) │ <────> │ Flask App (app.py) │ <────> │ MySQL Database │
└────────────────────────┘ └──────────┬───────────┘ └──────────────────┘
│
▼
┌─────────────────────┐
│ SMTP (Gmail) │
│ OTP & reset mail │
└─────────────────────┘
A high-level ERD diagram is available in other/erd.svg.
| Layer | Technology |
|---|---|
| Backend | Python 3.9+, Flask, Flask-Bcrypt |
| Database | MySQL 8.0+ (tested on MySQL 9.4) |
| Templating | Jinja2 |
| Frontend | HTML5, CSS3, Vanilla JS, Chart.js (CDN) |
| SMTP (Gmail App Passwords) | |
| Auth | Bcrypt + OTP + session cookies |
.
├── app.py # Flask application entry point and routes
├── db.py # MySQL connection factory
├── hash_passwords.py # Utility: bulk-hash seed passwords
├── migrate_semester.py # Utility: semester migration script
├── requirements.txt # Python dependencies
├── .env.example # Environment variable template
├── frontend/ # Jinja2 templates
│ ├── base.html
│ ├── home.html
│ ├── student/ # Student portal templates
│ ├── faculty/ # Faculty portal templates
│ ├── admin/ # Admin portal templates
│ └── partials/ # Shared partials (navbar, etc.)
├── static/
│ ├── css/style.css
│ ├── js/script.js
│ └── icons/
├── sql/
│ ├── schema.sql # Database schema (tables, FKs, indexes)
│ ├── seed.sql # Sample data
│ └── migrations/ # Idempotent schema migrations
├── utils/
│ ├── auth.py # Session & role guards
│ └── email_utils.py # SMTP helpers (OTP, password reset, async)
├── tests/ # pytest smoke tests
├── test/ # Analytics/plot sandbox
├── Dockerfile # Production image (gunicorn + healthcheck)
├── docker-compose.yml # Local stack: app + MySQL + Redis
├── .github/workflows/ci.yml # GitHub Actions CI
└── other/
└── erd.svg # Architecture/ERD diagram
- Python 3.11+ (3.12 recommended)
- MySQL 8.0+ running locally or reachable over the network
- A Gmail account with an App Password (for OTP + password reset emails)
- See Google's App Passwords docs
- Optional but recommended: Redis for production rate-limiting (any build ≥ 6.0). Skip if you'll run a single worker locally.
macOS users: AirPlay Receiver binds port 5000 by default, which collides with Flask's default. Either set
FLASK_PORT=5001in.env(recommended) or disable AirPlay Receiver under System Settings → General → AirDrop & Handoff.
Pick one path. Path A runs everything natively — fastest if Python and MySQL are already on your machine. Path B runs the whole stack (app + MySQL + Redis) under Docker — fewer moving parts, slower first boot.
# 1. Clone
git clone https://github.com/aakri0/lyceum.git
cd lyceum
# 2. Virtual environment
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 3. Install Python deps
pip install -r requirements.txt
# 4. Configure environment
cp .env.example .env
# Open .env and set: FLASK_SECRET_KEY (run `openssl rand -hex 32`),
# DB_PASSWORD, EMAIL_ADDRESS, EMAIL_PASSWORD. macOS users: FLASK_PORT=5001.
# 5. Initialize the database — schema, optional seed, then migrations
mysql -u root -p < sql/schema.sql
mysql -u root -p ERP < sql/seed.sql # optional
for f in sql/migrations/*.sql; do mysql -u root -p ERP < "$f"; done
# 6. Hash any plaintext seed passwords (only if you loaded seed.sql)
python hash_passwords.py
# 7. Run
python app.pyThe app will be available at http://localhost:5001 (or whatever
FLASK_PORT you set).
The repo ships a Dockerfile and docker-compose.yml that bring up
the app under Gunicorn (4 workers), MySQL 8.4, and Redis. Schema and
migrations are auto-loaded into MySQL on first boot via the
docker-entrypoint-initdb.d/ mount.
git clone https://github.com/aakri0/lyceum.git
cd lyceum
cp .env.example .env
# Set FLASK_SECRET_KEY, DB_PASSWORD, EMAIL_ADDRESS, EMAIL_PASSWORD.
docker compose up --buildThe app is on http://localhost:8000 (Gunicorn binds 8000 inside the
container; compose maps it to host 8000). MySQL data persists in the
named volume db_data.
Colima is the lightweight macOS
alternative to Docker Desktop. It provides the same docker and
docker compose CLIs, so the commands above work unchanged — you just
need a running Colima VM.
brew install colima docker docker-compose
colima start --cpu 2 --memory 4 --disk 20 # one-time; ~30s
# verify the docker context points at colima
docker context use colima
docker info | head -5
# now the standard flow works
docker compose up --buildIf the first docker compose up fails with "Cannot connect to the
Docker daemon", your CLI is talking to a stopped Docker Desktop —
switch contexts:
docker context use colimaTo stop the VM later: colima stop. To wipe and start over:
colima delete && colima start.
All configuration is driven by environment variables loaded from a .env file in the project root. Copy .env.example to .env and fill in values.
| Variable | Description | Example |
|---|---|---|
FLASK_SECRET_KEY |
Flask session signing key (long random string, required) | openssl rand -hex 32 output |
FLASK_DEBUG |
Enable debug mode (0 in production) |
0 |
FLASK_HOST |
Bind host | 127.0.0.1 |
FLASK_PORT |
Bind port | 5000 |
SESSION_COOKIE_SECURE |
1 in production (HTTPS); 0 for local HTTP dev |
0 |
LOG_LEVEL |
DEBUG, INFO, WARNING, or ERROR |
INFO |
DB_HOST |
MySQL host | localhost |
DB_PORT |
MySQL port | 3306 |
DB_USER |
MySQL user | erp_user |
DB_PASSWORD |
MySQL password | •••••••• |
DB_NAME |
MySQL database name | ERP |
SMTP_SERVER |
SMTP host | smtp.gmail.com |
SMTP_PORT |
SMTP port (STARTTLS) | 587 |
EMAIL_ADDRESS |
Sender email | your.erp@gmail.com |
EMAIL_PASSWORD |
Gmail App Password (not your account password) | abcd efgh ijkl mnop |
EMAIL_SEND_SYNC |
1 to block on send; 0 (default) dispatches on a thread |
0 |
SMTP_TIMEOUT |
SMTP socket timeout (seconds) | 15 |
BCRYPT_LOG_ROUNDS |
bcrypt cost factor (12 recommended for 2026 hardware) | 12 |
RATELIMIT_STORAGE_URI |
Backend for Flask-Limiter (memory:// or redis://host:6379) — must be Redis/Memcached when running >1 worker |
redis://localhost:6379 |
Never commit your
.envfile. It is included in.gitignore.
The schema defines the following core entities:
users— identity table (email, hashed password, role)students,faculty— role-specific profile extensionsdepartments— organizational unitscourses,enrollments,grade_components— academic recordsswd_requests— student welfare requestsdocuments— uploaded artefactsotp_verification,password_resets— auth stateaudit_logs— administrator action trail
Initialize with:
mysql -u root -p < sql/schema.sql
mysql -u root -p ERP < sql/seed.sql # optional sample data
python hash_passwords.py # converts plaintext seed passwords to bcryptDevelopment:
python app.pyProduction (example with Gunicorn):
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:8000 app:appFor production deployments you should also:
- Set
FLASK_DEBUG=0andSESSION_COOKIE_SECURE=1 - Put the app behind a reverse proxy (Nginx, Caddy) terminating TLS
- Point
RATELIMIT_STORAGE_URIat Redis/Memcached (the in-memory default silently breaks under multiple workers) - Use a managed MySQL instance with automated backups
- Rotate
FLASK_SECRET_KEYand credentials regularly - Probe
GET /healthzfrom your load balancer or uptime monitor — it returns200 {"status":"ok","db":"ok"}when the app and DB are healthy and503otherwise
A Dockerfile and docker-compose.yml are provided. The compose stack
brings up MySQL 8.4, Redis (for rate-limiting), and the Flask app under
Gunicorn with a health check.
cp .env.example .env # fill in FLASK_SECRET_KEY, DB_PASSWORD, EMAIL_*
docker compose up --buildThe app will be on http://localhost:8000. The MySQL volume db_data
persists across restarts.
The image runs as a non-root appuser (uid 1000) and uses a multi-stage,
slim Python base. CI builds the image on every push to verify it stays
buildable.
A small smoke-test suite lives in tests/. It covers the contract that
doesn't require a live database — security headers, CSRF rejection,
/healthz behaviour, basic routing.
pytest -vGitHub Actions runs the suite on Python 3.11 and 3.12 on every push and
pull request, plus a Docker build smoke test (see
.github/workflows/ci.yml).
Integration tests live in tests/test_integration.py and use the
tests/conftest_integration.py fixture, which creates a disposable
ERP_TEST_<pid> database, loads sql/schema.sql plus every file in
sql/migrations/, runs the test, and drops the database. They're skipped
automatically when MySQL isn't reachable, so the suite still passes on a
laptop with no server.
To run them, point at a MySQL instance you don't mind getting a temporary DB created on:
DB_HOST=127.0.0.1 DB_USER=root DB_PASSWORD=secret pytest tests/test_integration.pyscripts/backup_db.sh produces a compressed mysqldump of DB_NAME,
rotates older files, and (optionally) uploads to S3. It reads its
credentials from the same env vars the app uses.
# Local-only: writes ./backups/ERP-<timestamp>.sql.gz, keeps last 14
./scripts/backup_db.sh
# Also upload to S3
./scripts/backup_db.sh s3://my-bucket/erp-backupsA typical cron entry on the app host:
30 2 * * * cd /opt/erp && ./scripts/backup_db.sh s3://my-bucket/erp-backups \
>> /var/log/erp-backup.log 2>&1Test restores periodically — backups you haven't restored from are not backups.
Two endpoints are exposed for orchestration / uptime monitoring:
| Path | Purpose | Failure modes |
|---|---|---|
/healthz |
Liveness + DB ping. Cheap; safe to call frequently | DB down → 503 |
/readyz |
Liveness + DB + Redis (if configured) + SMTP | Any configured dep unreachable → 503 |
Point your load balancer (or docker-compose healthcheck — already wired)
at /healthz. Point an external uptime monitor (UptimeRobot, BetterStack,
Healthchecks.io) at /readyz so degradation in Redis or SMTP also pages.
Logs go to stdout in the format
%(asctime)s %(levelname)s %(name)s: %(message)s. The Docker image runs
Gunicorn with --access-logfile - so request logs land on stdout too.
For aggregation, the standard pattern is:
- Self-hosted VPS: ship stdout to journald (already automatic under
systemd) and forward with
vector/promtailto Loki or Elastic. - Docker / k8s: the runtime captures stdout; ship via the cluster's
log router (
fluent-bit,vector). - Cheapest hosted option: Better Stack Logs or Grafana Cloud — both have free tiers that handle this volume.
Set LOG_LEVEL=DEBUG in .env to surface the SQL chatter and rate-limit
decisions while debugging; default to INFO everywhere else.
| Role | Login route | Primary capabilities |
|---|---|---|
| Student | /student_login |
View grades, simulate CGPA, submit SWD requests |
| Faculty | /faculty_login |
Manage courses, grade students, process requests |
| Admin | /admin_login |
Manage users, departments, analytics, audit log |
Login flow: email + password → 6-digit OTP sent via email → dashboard.
- Passwords are stored as bcrypt hashes (
flask-bcrypt) with a 12-round cost factor (configurable viaBCRYPT_LOG_ROUNDS); minimum length 8 on reset. - OTPs are bcrypt-hashed at rest and expire after 5 minutes; password reset tokens are UUID v4, single-use, and expire after 15 minutes.
- Admin-created accounts receive a one-time random password via
secrets.token_urlsafeand are forced to reset on first login. - Session cookies are
HttpOnly+SameSite=Laxby default; setSESSION_COOKIE_SECURE=1in production to require HTTPS. - Role-based guards (
session['role']) are checked on every student/faculty/admin route; cross-role access redirects to the appropriate login. - Secrets are loaded from environment variables; nothing sensitive should be committed.
FLASK_SECRET_KEYmust be long and random in production — regenerate withopenssl rand -hex 32.- If you are forking this repository, rotate any credentials that may have appeared in earlier commits and purge history with
git filter-repoor BFG. - Use Gmail App Passwords, never your primary Google password.
- Every state-changing form carries a
{{ csrf_token() }}input, validated globally by Flask-WTF'sCSRFProtect. - Login, OTP, password-reset, and resend endpoints are rate-limited with Flask-Limiter (in-memory by default; point
RATELIMIT_STORAGE_URIat Redis/Memcached for multi-process deployments). - Database connections opened via
get_connection()inside a request are tracked onflask.gand closed inteardown_appcontext, so a raised exception mid-route never leaks a connection. - Security headers (CSP, HSTS, X-Frame-Options=DENY, X-Content-Type-Options, Referrer-Policy) are set globally by Flask-Talisman; HSTS and force-https activate when
SESSION_COOKIE_SECURE=1. - OTP and password-reset emails are dispatched on a background daemon thread, so a slow Gmail upstream cannot stall a request worker. Failures are logged via
logger.exception(setEMAIL_SEND_SYNC=1in tests to surface errors). audit_logscaptures the originating IP (X-Forwarded-Foraware) and User-Agent for every state-changing admin/faculty action.
To report a suspected vulnerability privately, follow the disclosure process in SECURITY.md.
Contributions are welcome. Please see CONTRIBUTING.md for guidelines on branching, commit style, and opening pull requests.
Released under the MIT License. See LICENSE for the full text.