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)