minerva-lite / app.py
Yujie Chen
Updates
871160f
import os
import sqlite3
import pathlib
from functools import wraps
from flask import Flask, render_template, request, jsonify, redirect, url_for, session
APP = Flask(__name__)
# Use environment variable for secret key (required for Spaces)
APP.secret_key = os.environ.get("SECRET_KEY", "dev-minerva-lite")
ROOT = pathlib.Path(__file__).parent
# Use /tmp for DB on Hugging Face Spaces
if "SPACE_ID" in os.environ:
DB_PATH = pathlib.Path("/tmp/minerva.sqlite3")
APP.config["SESSION_COOKIE_SAMESITE"] = "None"
APP.config["SESSION_COOKIE_SECURE"] = True
else:
DB_PATH = ROOT / "db" / "minerva.sqlite3"
STEP_DESTINATIONS = {
"home": {"endpoint": "registration_menu", "label": "Registration Menu"},
"search": {"endpoint": "search", "label": "Search Class Schedule"},
"add_drop": {"endpoint": "add_drop", "label": "Add or Drop Course Sections"},
"schedule": {"endpoint": "schedule", "label": "Student Schedule"},
"schedule_week": {"endpoint": "schedule_week", "label": "Weekly Schedule View"},
"accounts": {"endpoint": "accounts", "label": "Tuition Fee and Legal Status"},
}
STEP_KEYS_BY_ENDPOINT = {
"registration_menu": "home",
"search": "search",
"add_drop": "add_drop",
"schedule": "schedule",
"schedule_week": "schedule_week",
"accounts": "accounts",
}
def require_term(step_key):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
if not session.get("term_confirmed"):
return redirect(url_for("select_term", next=step_key))
return func(*args, **kwargs)
return wrapper
return decorator
# Always ensure DB is seeded: if DB file is missing or 'courses' table is missing, run seed
def ensure_db_seeded():
need_seed = False
if not DB_PATH.exists():
need_seed = True
else:
try:
con = sqlite3.connect(DB_PATH)
cur = con.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='courses'")
if not cur.fetchone():
need_seed = True
con.close()
except Exception:
need_seed = True
if need_seed:
import seed # side effect: creates DB
ensure_db_seeded()
def q(sql, *params):
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
cur = con.execute(sql, params)
rows = [dict(r) for r in cur.fetchall()]
con.close()
return rows
def execute(sql, *params):
with sqlite3.connect(DB_PATH) as con:
con.execute(sql, params)
con.commit()
@APP.get("/status")
def status():
return jsonify({"service": "minerva-lite", "ok": True})
@APP.get("/")
def registration_menu():
msg = request.args.get("msg", "")
return render_template("registration_menu.html", msg=msg)
@APP.get("/search")
@require_term("search")
def search():
subject = request.args.get("subject", "").strip().upper()
number = request.args.get("number", "").strip()
title = request.args.get("title", "").strip()
campus = request.args.get("campus", "").strip()
term = session.get("term", "Fall 2025")
sql = "SELECT * FROM courses WHERE 1=1"
args = []
if term:
sql += " AND term = ?"
args.append(term)
if subject:
sql += " AND subject = ?"
args.append(subject)
if number:
sql += " AND number LIKE ?"
args.append(f"%{number}%")
if title:
sql += " AND lower(title) LIKE ?"
args.append(f"%{title.lower()}%")
if campus:
sql += " AND campus = ?"
args.append(campus)
sql += " ORDER BY subject, number"
results = q(sql, *args)
return render_template("search.html", results=results)
@APP.post("/add")
def add_course():
crn = request.form.get("crn", "").strip()
if not crn:
return redirect(url_for("add_drop", msg="Enter a CRN."))
# Check if exists and open
rows = q("SELECT * FROM courses WHERE crn=?", crn)
if not rows:
return redirect(url_for("add_drop", msg="Invalid CRN."))
course = rows[0]
# Ensure CRN belongs to the currently selected term
current_term = session.get("term", "Fall 2025")
if course.get("term") != current_term:
return redirect(url_for("add_drop", msg=f"CRN not in {current_term} term."))
reg = q("SELECT * FROM registrations WHERE student_id=1 AND crn=?", crn)
if reg:
return redirect(url_for("add_drop", msg="Already registered."))
if course["seats_taken"] >= course["seats_total"]:
return redirect(url_for("add_drop", msg="Section full."))
execute("INSERT INTO registrations(student_id,crn) VALUES(1,?)", crn)
execute(
"UPDATE courses SET seats_taken = seats_taken + 1 WHERE crn=?",
crn,
)
return redirect(url_for("schedule", msg="Added CRN %s" % crn))
@APP.post("/drop")
def drop_course():
crn = request.form.get("crn", "").strip()
reg = q("SELECT * FROM registrations WHERE student_id=1 AND crn=?", crn)
if not reg:
return redirect(url_for("schedule", msg="Not registered."))
execute("DELETE FROM registrations WHERE student_id=1 AND crn=?", crn)
execute(
"UPDATE courses SET seats_taken = CASE WHEN seats_taken>0 THEN seats_taken-1 ELSE 0 END WHERE crn=?",
crn,
)
return redirect(url_for("schedule", msg="Dropped CRN %s" % crn))
@APP.get("/add-drop")
@require_term("add_drop")
def add_drop():
msg = request.args.get("msg", "")
current_term = session.get("term", "Fall 2025")
regs = q(
"SELECT c.* FROM registrations r JOIN courses c ON r.crn=c.crn WHERE r.student_id=1 AND c.term=? ORDER BY c.subject, c.number",
current_term,
)
return render_template("add_drop.html", regs=regs, msg=msg)
@APP.get("/schedule")
@require_term("schedule")
def schedule():
msg = request.args.get("msg", "")
current_term = session.get("term", "Fall 2025")
regs = q(
"SELECT c.* FROM registrations r JOIN courses c ON r.crn=c.crn WHERE r.student_id=1 AND c.term=? ORDER BY c.subject, c.number",
current_term,
)
return render_template("schedule.html", regs=regs, msg=msg)
def _parse_time_range(range_str):
try:
s, e = [t.strip() for t in range_str.split("-")]
sh, sm = [int(x) for x in s.split(":")]
eh, em = [int(x) for x in e.split(":")]
return sh * 60 + sm, eh * 60 + em
except Exception:
return None, None
@APP.get("/schedule-week")
@require_term("schedule_week")
def schedule_week():
# Build weekly event blocks for registered sections
current_term = session.get("term", "Fall")
regs = q(
"SELECT c.* FROM registrations r JOIN courses c ON r.crn=c.crn WHERE r.student_id=1 AND c.term=?",
current_term,
)
start_day_min = 8 * 60 # 08:00 grid start
end_day_min = 18 * 60 # 18:00 grid end
span = end_day_min - start_day_min
grid_px = 800 # matches CSS .grid-body height
day_map = {"M": 1, "T": 2, "W": 3, "R": 4, "F": 5}
events = []
for c in regs:
smin, emin = _parse_time_range(c.get("time") or "")
if smin is None:
continue
duration = max(emin - smin, 30)
# pixel-perfect placement using the same 800px grid height
top_px = max(int(round((smin - start_day_min) / span * grid_px)), 0)
height_px = max(int(round(duration / span * grid_px)), 2)
for ch in (c.get("days") or ""): # e.g., "MWF" or "TR"
if ch in day_map:
events.append({
"day_col": day_map[ch],
"title": f"{c['subject']} {c['number']}",
"subtitle": c["title"],
"time": c["time"],
"top_px": top_px,
"height_px": height_px,
"crn": c["crn"],
})
# Time labels every hour for the left rail
time_labels = []
for h in range(8, 18):
time_labels.append(f"{h:02d}:00")
return render_template(
"schedule_week.html",
events=events,
time_labels=time_labels,
)
@APP.get("/accounts")
@require_term("accounts")
def accounts():
return render_template("accounts.html")
@APP.post("/set-term")
def set_term():
term = request.form.get("term", "").strip().title()
if term not in ("Fall 2025", "Winter 2026", "Summer 2026"):
return redirect(url_for("registration_menu", msg="Invalid term."))
session["term"] = term
session["term_confirmed"] = True
next_key = request.form.get("next", "").strip()
if next_key in STEP_DESTINATIONS:
endpoint = STEP_DESTINATIONS[next_key]["endpoint"]
return redirect(url_for(endpoint))
# Redirect back to the referrer if possible
ref = request.headers.get("Referer")
return redirect(ref or url_for("registration_menu", msg=f"Term set to {term}"))
@APP.get("/select-term")
def select_term():
next_key = request.args.get("next", "search").strip()
destination = STEP_DESTINATIONS.get(next_key, STEP_DESTINATIONS["search"])
if request.args.get("reset"):
session["term_confirmed"] = False
return render_template(
"select_term.html",
next_key=next_key,
destination_label=destination["label"],
)
@APP.context_processor
def inject_term():
current_step_key = STEP_KEYS_BY_ENDPOINT.get(request.endpoint, "")
if request.endpoint == "select_term":
current_step_key = request.args.get("next", current_step_key or "search")
return {
"current_term": session.get("term", "Fall 2025"),
"terms": ["Fall 2025", "Winter 2026", "Summer 2026"],
"term_confirmed": session.get("term_confirmed", False),
"current_step_key": current_step_key,
}
@APP.get("/reset")
def reset():
if DB_PATH.exists():
DB_PATH.unlink()
import seed # re-seed
return jsonify({"reset": True})
import os
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8000))
APP.run(host="0.0.0.0", port=port)