Spaces:
Running
Running
Yujie Chen commited on
Commit ·
45f12d7
0
Parent(s):
Initial commit for Hugging Face Space
Browse files- Dockerfile +10 -0
- app.py +280 -0
- data/courses_seed.json +23 -0
- requirements.txt +7 -0
- schema.sql +30 -0
- seed.py +89 -0
- selectors.json +29 -0
- static/styles.css +382 -0
- templates/accounts.html +18 -0
- templates/add_drop.html +45 -0
- templates/base.html +82 -0
- templates/registration_menu.html +81 -0
- templates/schedule.html +37 -0
- templates/schedule_week.html +43 -0
- templates/search.html +65 -0
- templates/select_term.html +21 -0
Dockerfile
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
| 4 |
+
RUN pip install --no-cache-dir flask==3.0.0 jinja2==3.1.3 itsdangerous==2.2.0 click==8.1.7
|
| 5 |
+
# copy common utilities from project root (build context is project root)
|
| 6 |
+
COPY ./common /app/common
|
| 7 |
+
# copy only the minerva-lite service files into /app
|
| 8 |
+
COPY ./minerva-lite /app
|
| 9 |
+
EXPOSE 8000
|
| 10 |
+
CMD ["python", "app.py"]
|
app.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template, request, jsonify, redirect, url_for, session
|
| 2 |
+
import sqlite3, pathlib
|
| 3 |
+
from functools import wraps
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
APP = Flask(__name__)
|
| 7 |
+
APP.secret_key = "dev-minerva-lite" # session for term selection
|
| 8 |
+
ROOT = pathlib.Path(__file__).parent
|
| 9 |
+
DB_PATH = ROOT / "db" / "minerva.sqlite3"
|
| 10 |
+
|
| 11 |
+
STEP_DESTINATIONS = {
|
| 12 |
+
"home": {"endpoint": "registration_menu", "label": "Registration Menu"},
|
| 13 |
+
"search": {"endpoint": "search", "label": "Search Class Schedule"},
|
| 14 |
+
"add_drop": {"endpoint": "add_drop", "label": "Add or Drop Course Sections"},
|
| 15 |
+
"schedule": {"endpoint": "schedule", "label": "Student Schedule"},
|
| 16 |
+
"schedule_week": {"endpoint": "schedule_week", "label": "Weekly Schedule View"},
|
| 17 |
+
"accounts": {"endpoint": "accounts", "label": "Tuition Fee and Legal Status"},
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
STEP_KEYS_BY_ENDPOINT = {
|
| 21 |
+
"registration_menu": "home",
|
| 22 |
+
"search": "search",
|
| 23 |
+
"add_drop": "add_drop",
|
| 24 |
+
"schedule": "schedule",
|
| 25 |
+
"schedule_week": "schedule_week",
|
| 26 |
+
"accounts": "accounts",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def require_term(step_key):
|
| 31 |
+
def decorator(func):
|
| 32 |
+
@wraps(func)
|
| 33 |
+
def wrapper(*args, **kwargs):
|
| 34 |
+
if not session.get("term_confirmed"):
|
| 35 |
+
return redirect(url_for("select_term", next=step_key))
|
| 36 |
+
return func(*args, **kwargs)
|
| 37 |
+
return wrapper
|
| 38 |
+
return decorator
|
| 39 |
+
|
| 40 |
+
if not DB_PATH.exists():
|
| 41 |
+
import seed # side effect: creates DB
|
| 42 |
+
|
| 43 |
+
def q(sql, *params):
|
| 44 |
+
con = sqlite3.connect(DB_PATH)
|
| 45 |
+
con.row_factory = sqlite3.Row
|
| 46 |
+
cur = con.execute(sql, params)
|
| 47 |
+
rows = [dict(r) for r in cur.fetchall()]
|
| 48 |
+
con.close()
|
| 49 |
+
return rows
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def execute(sql, *params):
|
| 53 |
+
with sqlite3.connect(DB_PATH) as con:
|
| 54 |
+
con.execute(sql, params)
|
| 55 |
+
con.commit()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@APP.get("/status")
|
| 59 |
+
def status():
|
| 60 |
+
return jsonify({"service": "minerva-lite", "ok": True})
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@APP.get("/")
|
| 64 |
+
def registration_menu():
|
| 65 |
+
msg = request.args.get("msg", "")
|
| 66 |
+
return render_template("registration_menu.html", msg=msg)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@APP.get("/search")
|
| 70 |
+
@require_term("search")
|
| 71 |
+
def search():
|
| 72 |
+
subject = request.args.get("subject", "").strip().upper()
|
| 73 |
+
number = request.args.get("number", "").strip()
|
| 74 |
+
title = request.args.get("title", "").strip()
|
| 75 |
+
campus = request.args.get("campus", "").strip()
|
| 76 |
+
term = session.get("term", "Fall 2025")
|
| 77 |
+
|
| 78 |
+
sql = "SELECT * FROM courses WHERE 1=1"
|
| 79 |
+
args = []
|
| 80 |
+
if term:
|
| 81 |
+
sql += " AND term = ?"
|
| 82 |
+
args.append(term)
|
| 83 |
+
if subject:
|
| 84 |
+
sql += " AND subject = ?"
|
| 85 |
+
args.append(subject)
|
| 86 |
+
if number:
|
| 87 |
+
sql += " AND number LIKE ?"
|
| 88 |
+
args.append(f"%{number}%")
|
| 89 |
+
if title:
|
| 90 |
+
sql += " AND lower(title) LIKE ?"
|
| 91 |
+
args.append(f"%{title.lower()}%")
|
| 92 |
+
if campus:
|
| 93 |
+
sql += " AND campus = ?"
|
| 94 |
+
args.append(campus)
|
| 95 |
+
sql += " ORDER BY subject, number"
|
| 96 |
+
results = q(sql, *args)
|
| 97 |
+
return render_template("search.html", results=results)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@APP.post("/add")
|
| 101 |
+
def add_course():
|
| 102 |
+
crn = request.form.get("crn", "").strip()
|
| 103 |
+
if not crn:
|
| 104 |
+
return redirect(url_for("add_drop", msg="Enter a CRN."))
|
| 105 |
+
# Check if exists and open
|
| 106 |
+
rows = q("SELECT * FROM courses WHERE crn=?", crn)
|
| 107 |
+
if not rows:
|
| 108 |
+
return redirect(url_for("add_drop", msg="Invalid CRN."))
|
| 109 |
+
course = rows[0]
|
| 110 |
+
# Ensure CRN belongs to the currently selected term
|
| 111 |
+
current_term = session.get("term", "Fall 2025")
|
| 112 |
+
if course.get("term") != current_term:
|
| 113 |
+
return redirect(url_for("add_drop", msg=f"CRN not in {current_term} term."))
|
| 114 |
+
reg = q("SELECT * FROM registrations WHERE student_id=1 AND crn=?", crn)
|
| 115 |
+
if reg:
|
| 116 |
+
return redirect(url_for("add_drop", msg="Already registered."))
|
| 117 |
+
if course["seats_taken"] >= course["seats_total"]:
|
| 118 |
+
return redirect(url_for("add_drop", msg="Section full."))
|
| 119 |
+
execute("INSERT INTO registrations(student_id,crn) VALUES(1,?)", crn)
|
| 120 |
+
execute(
|
| 121 |
+
"UPDATE courses SET seats_taken = seats_taken + 1 WHERE crn=?",
|
| 122 |
+
crn,
|
| 123 |
+
)
|
| 124 |
+
return redirect(url_for("schedule", msg="Added CRN %s" % crn))
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@APP.post("/drop")
|
| 128 |
+
def drop_course():
|
| 129 |
+
crn = request.form.get("crn", "").strip()
|
| 130 |
+
reg = q("SELECT * FROM registrations WHERE student_id=1 AND crn=?", crn)
|
| 131 |
+
if not reg:
|
| 132 |
+
return redirect(url_for("schedule", msg="Not registered."))
|
| 133 |
+
execute("DELETE FROM registrations WHERE student_id=1 AND crn=?", crn)
|
| 134 |
+
execute(
|
| 135 |
+
"UPDATE courses SET seats_taken = CASE WHEN seats_taken>0 THEN seats_taken-1 ELSE 0 END WHERE crn=?",
|
| 136 |
+
crn,
|
| 137 |
+
)
|
| 138 |
+
return redirect(url_for("schedule", msg="Dropped CRN %s" % crn))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@APP.get("/add-drop")
|
| 142 |
+
@require_term("add_drop")
|
| 143 |
+
def add_drop():
|
| 144 |
+
msg = request.args.get("msg", "")
|
| 145 |
+
current_term = session.get("term", "Fall 2025")
|
| 146 |
+
regs = q(
|
| 147 |
+
"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",
|
| 148 |
+
current_term,
|
| 149 |
+
)
|
| 150 |
+
return render_template("add_drop.html", regs=regs, msg=msg)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@APP.get("/schedule")
|
| 154 |
+
@require_term("schedule")
|
| 155 |
+
def schedule():
|
| 156 |
+
msg = request.args.get("msg", "")
|
| 157 |
+
current_term = session.get("term", "Fall 2025")
|
| 158 |
+
regs = q(
|
| 159 |
+
"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",
|
| 160 |
+
current_term,
|
| 161 |
+
)
|
| 162 |
+
return render_template("schedule.html", regs=regs, msg=msg)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _parse_time_range(range_str):
|
| 166 |
+
try:
|
| 167 |
+
s, e = [t.strip() for t in range_str.split("-")]
|
| 168 |
+
sh, sm = [int(x) for x in s.split(":")]
|
| 169 |
+
eh, em = [int(x) for x in e.split(":")]
|
| 170 |
+
return sh * 60 + sm, eh * 60 + em
|
| 171 |
+
except Exception:
|
| 172 |
+
return None, None
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@APP.get("/schedule-week")
|
| 176 |
+
@require_term("schedule_week")
|
| 177 |
+
def schedule_week():
|
| 178 |
+
# Build weekly event blocks for registered sections
|
| 179 |
+
current_term = session.get("term", "Fall")
|
| 180 |
+
regs = q(
|
| 181 |
+
"SELECT c.* FROM registrations r JOIN courses c ON r.crn=c.crn WHERE r.student_id=1 AND c.term=?",
|
| 182 |
+
current_term,
|
| 183 |
+
)
|
| 184 |
+
start_day_min = 8 * 60 # 08:00 grid start
|
| 185 |
+
end_day_min = 18 * 60 # 18:00 grid end
|
| 186 |
+
span = end_day_min - start_day_min
|
| 187 |
+
grid_px = 800 # matches CSS .grid-body height
|
| 188 |
+
day_map = {"M": 1, "T": 2, "W": 3, "R": 4, "F": 5}
|
| 189 |
+
|
| 190 |
+
events = []
|
| 191 |
+
for c in regs:
|
| 192 |
+
smin, emin = _parse_time_range(c.get("time") or "")
|
| 193 |
+
if smin is None:
|
| 194 |
+
continue
|
| 195 |
+
duration = max(emin - smin, 30)
|
| 196 |
+
# pixel-perfect placement using the same 800px grid height
|
| 197 |
+
top_px = max(int(round((smin - start_day_min) / span * grid_px)), 0)
|
| 198 |
+
height_px = max(int(round(duration / span * grid_px)), 2)
|
| 199 |
+
for ch in (c.get("days") or ""): # e.g., "MWF" or "TR"
|
| 200 |
+
if ch in day_map:
|
| 201 |
+
events.append({
|
| 202 |
+
"day_col": day_map[ch],
|
| 203 |
+
"title": f"{c['subject']} {c['number']}",
|
| 204 |
+
"subtitle": c["title"],
|
| 205 |
+
"time": c["time"],
|
| 206 |
+
"top_px": top_px,
|
| 207 |
+
"height_px": height_px,
|
| 208 |
+
"crn": c["crn"],
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
# Time labels every hour for the left rail
|
| 212 |
+
time_labels = []
|
| 213 |
+
for h in range(8, 18):
|
| 214 |
+
time_labels.append(f"{h:02d}:00")
|
| 215 |
+
|
| 216 |
+
return render_template(
|
| 217 |
+
"schedule_week.html",
|
| 218 |
+
events=events,
|
| 219 |
+
time_labels=time_labels,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@APP.get("/accounts")
|
| 224 |
+
@require_term("accounts")
|
| 225 |
+
def accounts():
|
| 226 |
+
return render_template("accounts.html")
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@APP.post("/set-term")
|
| 230 |
+
def set_term():
|
| 231 |
+
term = request.form.get("term", "").strip().title()
|
| 232 |
+
if term not in ("Fall 2025", "Winter 2026", "Summer 2026"):
|
| 233 |
+
return redirect(url_for("registration_menu", msg="Invalid term."))
|
| 234 |
+
session["term"] = term
|
| 235 |
+
session["term_confirmed"] = True
|
| 236 |
+
next_key = request.form.get("next", "").strip()
|
| 237 |
+
if next_key in STEP_DESTINATIONS:
|
| 238 |
+
endpoint = STEP_DESTINATIONS[next_key]["endpoint"]
|
| 239 |
+
return redirect(url_for(endpoint))
|
| 240 |
+
# Redirect back to the referrer if possible
|
| 241 |
+
ref = request.headers.get("Referer")
|
| 242 |
+
return redirect(ref or url_for("registration_menu", msg=f"Term set to {term}"))
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@APP.get("/select-term")
|
| 246 |
+
def select_term():
|
| 247 |
+
next_key = request.args.get("next", "search").strip()
|
| 248 |
+
destination = STEP_DESTINATIONS.get(next_key, STEP_DESTINATIONS["search"])
|
| 249 |
+
if request.args.get("reset"):
|
| 250 |
+
session["term_confirmed"] = False
|
| 251 |
+
return render_template(
|
| 252 |
+
"select_term.html",
|
| 253 |
+
next_key=next_key,
|
| 254 |
+
destination_label=destination["label"],
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@APP.context_processor
|
| 259 |
+
def inject_term():
|
| 260 |
+
current_step_key = STEP_KEYS_BY_ENDPOINT.get(request.endpoint, "")
|
| 261 |
+
if request.endpoint == "select_term":
|
| 262 |
+
current_step_key = request.args.get("next", current_step_key or "search")
|
| 263 |
+
return {
|
| 264 |
+
"current_term": session.get("term", "Fall 2025"),
|
| 265 |
+
"terms": ["Fall 2025", "Winter 2026", "Summer 2026"],
|
| 266 |
+
"term_confirmed": session.get("term_confirmed", False),
|
| 267 |
+
"current_step_key": current_step_key,
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
@APP.get("/reset")
|
| 272 |
+
def reset():
|
| 273 |
+
if DB_PATH.exists():
|
| 274 |
+
DB_PATH.unlink()
|
| 275 |
+
import seed # re-seed
|
| 276 |
+
return jsonify({"reset": True})
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
if __name__ == "__main__":
|
| 280 |
+
APP.run(host="0.0.0.0", port=8000)
|
data/courses_seed.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"courses": [
|
| 3 |
+
{"crn": "10001", "subject": "COMP", "number": "202", "title": "Foundations of Programming", "credits": 3, "days": "MWF", "time": "10:00-10:50", "campus": "Downtown", "seats_total": 200, "seats_taken": 160},
|
| 4 |
+
{"crn": "10002", "subject": "COMP", "number": "250", "title": "Introduction to Computer Science", "credits": 3, "days": "TR", "time": "11:35-12:55", "campus": "Downtown", "seats_total": 180, "seats_taken": 175},
|
| 5 |
+
{"crn": "10003", "subject": "COMP", "number": "364", "title": "Bioinformatics", "credits": 3, "days": "TR", "time": "13:05-14:25", "campus": "Downtown", "seats_total": 90, "seats_taken": 55},
|
| 6 |
+
{"crn": "10004", "subject": "COMP", "number": "421", "title": "Database Systems", "credits": 3, "days": "MW", "time": "14:35-15:55", "campus": "Downtown", "seats_total": 120, "seats_taken": 120},
|
| 7 |
+
{"crn": "10005", "subject": "MATH", "number": "222", "title": "Calculus 3", "credits": 3, "days": "MWF", "time": "12:35-13:25", "campus": "Downtown", "seats_total": 250, "seats_taken": 230},
|
| 8 |
+
{"crn": "10006", "subject": "PHYS", "number": "101", "title": "Introductory Physics", "credits": 3, "days": "TR", "time": "08:35-09:55", "campus": "Downtown", "seats_total": 140, "seats_taken": 100},
|
| 9 |
+
{"crn": "10007", "subject": "ECON", "number": "230", "title": "Microeconomic Analysis", "credits": 3, "days": "MWF", "time": "09:35-10:25", "campus": "Downtown", "seats_total": 160, "seats_taken": 155},
|
| 10 |
+
{"crn": "10008", "subject": "BIOL", "number": "111", "title": "Principles of Biology", "credits": 3, "days": "TR", "time": "10:05-11:25", "campus": "Downtown", "seats_total": 180, "seats_taken": 90},
|
| 11 |
+
{"crn": "10009", "subject": "CHEM", "number": "120", "title": "General Chemistry", "credits": 3, "days": "MWF", "time": "11:35-12:25", "campus": "Downtown", "seats_total": 220, "seats_taken": 210},
|
| 12 |
+
{"crn": "10010", "subject": "ENGL", "number": "199", "title": "Introduction to Literature", "credits": 3, "days": "TR", "time": "14:35-15:55", "campus": "Downtown", "seats_total": 80, "seats_taken": 60},
|
| 13 |
+
{"crn": "10011", "subject": "PSYC", "number": "100", "title": "Introduction to Psychology", "credits": 3, "days": "MW", "time": "16:05-17:25", "campus": "Downtown", "seats_total": 240, "seats_taken": 220},
|
| 14 |
+
{"crn": "10012", "subject": "COMP", "number": "424", "title": "Artificial Intelligence", "credits": 3, "days": "TR", "time": "09:05-10:25", "campus": "Downtown", "seats_total": 120, "seats_taken": 80},
|
| 15 |
+
{"crn": "10013", "subject": "COMP", "number": "551", "title": "Applied Machine Learning", "credits": 3, "days": "TR", "time": "13:35-14:55", "campus": "Downtown", "seats_total": 90, "seats_taken": 70},
|
| 16 |
+
{"crn": "10014", "subject": "MATH", "number": "223", "title": "Linear Algebra", "credits": 3, "days": "MWF", "time": "11:35-12:25", "campus": "Downtown", "seats_total": 200, "seats_taken": 180},
|
| 17 |
+
{"crn": "10015", "subject": "STAT", "number": "260", "title": "Introduction to Statistics", "credits": 3, "days": "TR", "time": "12:35-13:55", "campus": "Downtown", "seats_total": 160, "seats_taken": 130},
|
| 18 |
+
{"crn": "10016", "subject": "ECON", "number": "340", "title": "Macroeconomic Theory", "credits": 3, "days": "MW", "time": "15:05-16:25", "campus": "Downtown", "seats_total": 100, "seats_taken": 85},
|
| 19 |
+
{"crn": "10017", "subject": "HIST", "number": "210", "title": "Modern World History", "credits": 3, "days": "MWF", "time": "13:35-14:25", "campus": "Downtown", "seats_total": 140, "seats_taken": 95},
|
| 20 |
+
{"crn": "10018", "subject": "PHYS", "number": "230", "title": "Electricity and Magnetism", "credits": 3, "days": "TR", "time": "16:05-17:25", "campus": "Downtown", "seats_total": 110, "seats_taken": 60},
|
| 21 |
+
{"crn": "10019", "subject": "ENGL", "number": "315", "title": "Creative Writing Workshop", "credits": 3, "days": "MW", "time": "09:05-10:25", "campus": "Downtown", "seats_total": 60, "seats_taken": 45}
|
| 22 |
+
]
|
| 23 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask==3.0.0
|
| 2 |
+
Flask-Session
|
| 3 |
+
jinja2==3.1.3
|
| 4 |
+
itsdangerous==2.2.0
|
| 5 |
+
click==8.1.7
|
| 6 |
+
werkzeug==3.0.1
|
| 7 |
+
markupsafe==2.1.3
|
schema.sql
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
DROP TABLE IF EXISTS registrations;
|
| 2 |
+
DROP TABLE IF EXISTS students;
|
| 3 |
+
DROP TABLE IF EXISTS courses;
|
| 4 |
+
|
| 5 |
+
CREATE TABLE courses (
|
| 6 |
+
crn TEXT PRIMARY KEY,
|
| 7 |
+
subject TEXT NOT NULL,
|
| 8 |
+
number TEXT NOT NULL,
|
| 9 |
+
title TEXT NOT NULL,
|
| 10 |
+
credits REAL NOT NULL,
|
| 11 |
+
days TEXT,
|
| 12 |
+
time TEXT,
|
| 13 |
+
campus TEXT NOT NULL,
|
| 14 |
+
term TEXT NOT NULL,
|
| 15 |
+
seats_total INTEGER NOT NULL,
|
| 16 |
+
seats_taken INTEGER NOT NULL DEFAULT 0
|
| 17 |
+
);
|
| 18 |
+
|
| 19 |
+
CREATE TABLE students (
|
| 20 |
+
id INTEGER PRIMARY KEY,
|
| 21 |
+
name TEXT
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
CREATE TABLE registrations (
|
| 25 |
+
student_id INTEGER NOT NULL,
|
| 26 |
+
crn TEXT NOT NULL,
|
| 27 |
+
PRIMARY KEY (student_id, crn),
|
| 28 |
+
FOREIGN KEY(student_id) REFERENCES students(id),
|
| 29 |
+
FOREIGN KEY(crn) REFERENCES courses(crn)
|
| 30 |
+
);
|
seed.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, sqlite3, pathlib, random
|
| 2 |
+
from common.seed_utils import init_db, load_json
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
ROOT = pathlib.Path(__file__).parent
|
| 6 |
+
DB_PATH = ROOT / "db" / "minerva.sqlite3"
|
| 7 |
+
SCHEMA = ROOT / "schema.sql"
|
| 8 |
+
SEED_JSON = ROOT / "data" / "courses_seed.json"
|
| 9 |
+
SEED = int(os.environ.get("APP_SEED", 42))
|
| 10 |
+
random.seed(SEED)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
init_db(DB_PATH, SCHEMA)
|
| 14 |
+
seed = load_json(SEED_JSON)
|
| 15 |
+
|
| 16 |
+
TERMS = ["Fall 2025", "Winter 2026", "Summer 2026"]
|
| 17 |
+
TERM_PREFIX = {"Fall 2025": "1", "Winter 2026": "2", "Summer 2026": "3"}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _normalize_terms(terms_list):
|
| 21 |
+
# Accept plain season names or fully labeled ones and normalize
|
| 22 |
+
norm = []
|
| 23 |
+
for t in terms_list:
|
| 24 |
+
tl = str(t).strip()
|
| 25 |
+
low = tl.lower()
|
| 26 |
+
if tl in TERMS:
|
| 27 |
+
norm.append(tl)
|
| 28 |
+
elif "fall" in low:
|
| 29 |
+
norm.append("Fall 2025")
|
| 30 |
+
elif "winter" in low:
|
| 31 |
+
norm.append("Winter 2026")
|
| 32 |
+
elif "summer" in low:
|
| 33 |
+
norm.append("Summer 2026")
|
| 34 |
+
# Deduplicate while preserving order
|
| 35 |
+
out = []
|
| 36 |
+
for t in norm:
|
| 37 |
+
if t not in out:
|
| 38 |
+
out.append(t)
|
| 39 |
+
return out
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _pick_terms_for_course(crn_str):
|
| 43 |
+
# Deterministic selection: some courses in 1 term, some in 2, some in all 3
|
| 44 |
+
try:
|
| 45 |
+
base_seed = SEED + int("".join(ch for ch in crn_str if ch.isdigit()))
|
| 46 |
+
except Exception:
|
| 47 |
+
base_seed = SEED
|
| 48 |
+
rnd = random.Random(base_seed)
|
| 49 |
+
p = rnd.random()
|
| 50 |
+
if p < 0.4:
|
| 51 |
+
k = 1
|
| 52 |
+
elif p < 0.8:
|
| 53 |
+
k = 2
|
| 54 |
+
else:
|
| 55 |
+
k = 3
|
| 56 |
+
return rnd.sample(TERMS, k)
|
| 57 |
+
|
| 58 |
+
with sqlite3.connect(DB_PATH) as con:
|
| 59 |
+
# Seed one demo student
|
| 60 |
+
con.execute("INSERT INTO students(id, name) VALUES(1, ?)", ("Demo Student",))
|
| 61 |
+
# Seed courses with variable term availability
|
| 62 |
+
for c in seed["courses"]:
|
| 63 |
+
explicit_terms = _normalize_terms(c.get("terms", [])) if isinstance(c, dict) else []
|
| 64 |
+
terms_for_course = explicit_terms or _pick_terms_for_course(str(c.get("crn", "")))
|
| 65 |
+
for term in terms_for_course:
|
| 66 |
+
base_crn = str(c["crn"]) # from JSON
|
| 67 |
+
# Prefix CRN by term to ensure uniqueness across terms
|
| 68 |
+
crn = f"{TERM_PREFIX[term]}{base_crn}"
|
| 69 |
+
con.execute(
|
| 70 |
+
"""
|
| 71 |
+
INSERT INTO courses(crn,subject,number,title,credits,days,time,campus,term,seats_total,seats_taken)
|
| 72 |
+
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
| 73 |
+
""",
|
| 74 |
+
(
|
| 75 |
+
crn,
|
| 76 |
+
c["subject"],
|
| 77 |
+
c["number"],
|
| 78 |
+
c["title"],
|
| 79 |
+
float(c["credits"]),
|
| 80 |
+
c.get("days", ""),
|
| 81 |
+
c.get("time", ""),
|
| 82 |
+
c.get("campus", "Downtown"),
|
| 83 |
+
term,
|
| 84 |
+
int(c.get("seats_total", 100)),
|
| 85 |
+
int(c.get("seats_taken", 0)),
|
| 86 |
+
),
|
| 87 |
+
)
|
| 88 |
+
con.commit()
|
| 89 |
+
print("Minerva-Lite seeded.")
|
selectors.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"menu_home": "[data-test=menu-home]",
|
| 3 |
+
"menu_search": "[data-test=menu-search]",
|
| 4 |
+
"menu_adddrop": "[data-test=menu-adddrop]",
|
| 5 |
+
"menu_schedule": "[data-test=menu-schedule]",
|
| 6 |
+
"menu_schedule_week": "[data-test=menu-schedule-week]",
|
| 7 |
+
"change_term_link": "[data-test=change-term-link]",
|
| 8 |
+
|
| 9 |
+
"search_subject": "[data-test=search-subject]",
|
| 10 |
+
"search_number": "[data-test=search-number]",
|
| 11 |
+
"search_title": "[data-test=search-title]",
|
| 12 |
+
"search_campus": "[data-test=search-campus]",
|
| 13 |
+
"search_openonly": "[data-test=search-openonly]",
|
| 14 |
+
"search_button": "[data-test=search-button]",
|
| 15 |
+
|
| 16 |
+
"course_row": "[data-test=course-row]",
|
| 17 |
+
"course_crn": "[data-test=course-crn]",
|
| 18 |
+
"add_button": "[data-test=add-button]",
|
| 19 |
+
|
| 20 |
+
"add_crn_input": "[data-test=add-crn-input]",
|
| 21 |
+
"add_submit": "[data-test=add-submit]",
|
| 22 |
+
"schedule_row": "[data-test=schedule-row]",
|
| 23 |
+
"drop_button": "[data-test=drop-button]",
|
| 24 |
+
|
| 25 |
+
"notice": "[data-test=notice]",
|
| 26 |
+
"week_event": "[data-test=week-event]",
|
| 27 |
+
"term_select": "[data-test=term-select]",
|
| 28 |
+
"term_submit": "[data-test=term-submit]"
|
| 29 |
+
}
|
static/styles.css
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root{
|
| 2 |
+
--brand:#b70000;
|
| 3 |
+
--brand-dark:#7a0000;
|
| 4 |
+
--bg:#f1f3f8;
|
| 5 |
+
--text:#1d1d1f;
|
| 6 |
+
--muted:#4e5a6b;
|
| 7 |
+
--border:#d4d8e0;
|
| 8 |
+
--surface:#fff;
|
| 9 |
+
--accent:#0d4f9a;
|
| 10 |
+
--notice-bg:#fff7c2;
|
| 11 |
+
--nav:#0c2340;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
*{box-sizing:border-box}
|
| 15 |
+
body{
|
| 16 |
+
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
| 17 |
+
margin:0;
|
| 18 |
+
color:var(--text);
|
| 19 |
+
background:var(--bg);
|
| 20 |
+
}
|
| 21 |
+
.minerva-skin{
|
| 22 |
+
min-height:100vh;
|
| 23 |
+
display:flex;
|
| 24 |
+
flex-direction:column;
|
| 25 |
+
background:linear-gradient(180deg,#e7ebf5 0%,#f7f8fb 160%);
|
| 26 |
+
}
|
| 27 |
+
.container{
|
| 28 |
+
max-width:1280px;
|
| 29 |
+
width:92vw;
|
| 30 |
+
margin:0 auto;
|
| 31 |
+
padding:0 18px;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.sr-only{
|
| 35 |
+
position:absolute;
|
| 36 |
+
width:1px;
|
| 37 |
+
height:1px;
|
| 38 |
+
padding:0;
|
| 39 |
+
margin:-1px;
|
| 40 |
+
overflow:hidden;
|
| 41 |
+
clip:rect(0,0,0,0);
|
| 42 |
+
border:0;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.minerva-header{
|
| 46 |
+
box-shadow:0 2px 6px rgba(12,19,31,.2);
|
| 47 |
+
background:#dcdfe6;
|
| 48 |
+
}
|
| 49 |
+
.hero-strip{
|
| 50 |
+
background:linear-gradient(135deg,#cfd4de 10%,#edf0f6 60%);
|
| 51 |
+
border-bottom:2px solid #b8bec8;
|
| 52 |
+
}
|
| 53 |
+
.hero-inner{
|
| 54 |
+
display:flex;
|
| 55 |
+
align-items:center;
|
| 56 |
+
justify-content:space-between;
|
| 57 |
+
min-height:90px;
|
| 58 |
+
padding:.85rem 16px;
|
| 59 |
+
gap:1.5rem;
|
| 60 |
+
}
|
| 61 |
+
.hero-brand{display:flex;align-items:center;gap:1rem}
|
| 62 |
+
.hero-logo{
|
| 63 |
+
background:#c02827;
|
| 64 |
+
color:#fff;
|
| 65 |
+
font-weight:600;
|
| 66 |
+
font-size:1.1rem;
|
| 67 |
+
padding:.5rem 1.1rem;
|
| 68 |
+
border-radius:6px;
|
| 69 |
+
text-transform:uppercase;
|
| 70 |
+
letter-spacing:.05em;
|
| 71 |
+
box-shadow:0 2px 4px rgba(0,0,0,.2);
|
| 72 |
+
}
|
| 73 |
+
.hero-wordmark-main{
|
| 74 |
+
font-size:1.8rem;
|
| 75 |
+
color:#1e1e1e;
|
| 76 |
+
font-weight:600;
|
| 77 |
+
}
|
| 78 |
+
.hero-wordmark-sub{
|
| 79 |
+
font-size:.8rem;
|
| 80 |
+
text-transform:uppercase;
|
| 81 |
+
letter-spacing:.08em;
|
| 82 |
+
color:#4b4f5a;
|
| 83 |
+
}
|
| 84 |
+
.hero-figure{
|
| 85 |
+
flex:0 0 180px;
|
| 86 |
+
height:90px;
|
| 87 |
+
background:linear-gradient(120deg,#920707,#f8c4c4);
|
| 88 |
+
border-radius:999px 0 0 999px;
|
| 89 |
+
box-shadow:inset 0 0 25px rgba(0,0,0,.2);
|
| 90 |
+
}
|
| 91 |
+
.tab-row{
|
| 92 |
+
background:linear-gradient(180deg,#f5f6fb 0%,#e1e6f0 100%);
|
| 93 |
+
border-bottom:1px solid #c2c7d3;
|
| 94 |
+
box-shadow:inset 0 1px 0 rgba(255,255,255,.7);
|
| 95 |
+
padding-bottom:.5rem;
|
| 96 |
+
margin-bottom:1.5rem;
|
| 97 |
+
}
|
| 98 |
+
.tab-nav{
|
| 99 |
+
display:flex;
|
| 100 |
+
align-items:center;
|
| 101 |
+
justify-content:center;
|
| 102 |
+
padding:0 16px;
|
| 103 |
+
}
|
| 104 |
+
.tab-links{
|
| 105 |
+
display:flex;
|
| 106 |
+
gap:.25rem;
|
| 107 |
+
flex-wrap:wrap;
|
| 108 |
+
justify-content:center;
|
| 109 |
+
}
|
| 110 |
+
.tab-links a{
|
| 111 |
+
min-width:150px;
|
| 112 |
+
text-align:center;
|
| 113 |
+
padding:.6rem 0;
|
| 114 |
+
text-transform:uppercase;
|
| 115 |
+
font-weight:600;
|
| 116 |
+
font-size:.85rem;
|
| 117 |
+
letter-spacing:.05em;
|
| 118 |
+
color:#1f2a44;
|
| 119 |
+
text-decoration:none;
|
| 120 |
+
border:1px solid #cfd5e2;
|
| 121 |
+
border-bottom:3px solid transparent;
|
| 122 |
+
border-top-left-radius:6px;
|
| 123 |
+
border-top-right-radius:6px;
|
| 124 |
+
background:linear-gradient(180deg,#fcfcff 0%,#eef1f6 100%);
|
| 125 |
+
box-shadow:0 1px 1px rgba(14,23,39,.08);
|
| 126 |
+
transition:all .2s ease;
|
| 127 |
+
}
|
| 128 |
+
.tab-links a:hover{
|
| 129 |
+
color:#b00000;
|
| 130 |
+
border-bottom-color:#b00000;
|
| 131 |
+
}
|
| 132 |
+
.tab-links a.is-active{
|
| 133 |
+
color:#960000;
|
| 134 |
+
background:#fff;
|
| 135 |
+
border-color:#b00000;
|
| 136 |
+
border-bottom-color:#fff;
|
| 137 |
+
box-shadow:0 4px 10px rgba(13,25,44,.12);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
main{
|
| 141 |
+
padding:2rem 0;
|
| 142 |
+
flex:1;
|
| 143 |
+
}
|
| 144 |
+
.page{
|
| 145 |
+
background:var(--surface);
|
| 146 |
+
border:1px solid var(--border);
|
| 147 |
+
border-top:4px solid #f4c542;
|
| 148 |
+
box-shadow:0 8px 20px rgba(12,19,31,.12);
|
| 149 |
+
margin-top:-20px;
|
| 150 |
+
padding:2.25rem 1.5rem;
|
| 151 |
+
}
|
| 152 |
+
.page-heading{
|
| 153 |
+
display:flex;
|
| 154 |
+
align-items:center;
|
| 155 |
+
justify-content:space-between;
|
| 156 |
+
gap:1rem;
|
| 157 |
+
margin-bottom:1.5rem;
|
| 158 |
+
flex-wrap:wrap;
|
| 159 |
+
}
|
| 160 |
+
.page-heading h1{margin:0;color:#a10f0f}
|
| 161 |
+
.term-chip{
|
| 162 |
+
border:1px solid #d2d7e5;
|
| 163 |
+
background:#f4f5fb;
|
| 164 |
+
padding:.35rem .85rem;
|
| 165 |
+
border-radius:999px;
|
| 166 |
+
font-size:.85rem;
|
| 167 |
+
font-weight:600;
|
| 168 |
+
color:#1c2750;
|
| 169 |
+
text-transform:uppercase;
|
| 170 |
+
letter-spacing:.04em;
|
| 171 |
+
}
|
| 172 |
+
.term-footer{
|
| 173 |
+
background:#e0e4f0;
|
| 174 |
+
border-top:1px solid #c3c8d5;
|
| 175 |
+
margin-top:auto;
|
| 176 |
+
}
|
| 177 |
+
.term-footer-inner{
|
| 178 |
+
display:flex;
|
| 179 |
+
justify-content:center;
|
| 180 |
+
gap:.6rem;
|
| 181 |
+
font-size:.85rem;
|
| 182 |
+
padding:.65rem 16px;
|
| 183 |
+
color:#1f2a44;
|
| 184 |
+
flex-wrap:wrap;
|
| 185 |
+
}
|
| 186 |
+
.term-footer-inner a{
|
| 187 |
+
color:#940b0b;
|
| 188 |
+
font-weight:600;
|
| 189 |
+
text-decoration:none;
|
| 190 |
+
}
|
| 191 |
+
.term-footer-inner a:hover{text-decoration:underline}
|
| 192 |
+
.page-lead{display:flex;flex-direction:column;gap:1rem;margin-bottom:2rem}
|
| 193 |
+
.page-title{margin:0;color:#a10f0f;font-size:1.8rem;font-weight:700}
|
| 194 |
+
|
| 195 |
+
.term-gate{
|
| 196 |
+
border:1px solid var(--border);
|
| 197 |
+
background:#fff;
|
| 198 |
+
padding:2rem;
|
| 199 |
+
box-shadow:0 8px 20px rgba(15,23,38,.08);
|
| 200 |
+
}
|
| 201 |
+
.term-gate h1{margin-top:0;color:#a10f0f}
|
| 202 |
+
.term-gate-form{
|
| 203 |
+
display:flex;
|
| 204 |
+
flex-direction:column;
|
| 205 |
+
gap:1rem;
|
| 206 |
+
max-width:320px;
|
| 207 |
+
margin-top:1rem;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.highlight-panel{
|
| 211 |
+
border:1px solid var(--border);
|
| 212 |
+
padding:1rem;
|
| 213 |
+
border-radius:4px;
|
| 214 |
+
display:flex;
|
| 215 |
+
gap:1rem;
|
| 216 |
+
align-items:center;
|
| 217 |
+
font-size:.95rem;
|
| 218 |
+
line-height:1.5;
|
| 219 |
+
}
|
| 220 |
+
.highlight-blue{background:#e4f1ff;border-color:#b4d4fa}
|
| 221 |
+
.highlight-yellow{background:#fff8d9;border-color:#f3d98c}
|
| 222 |
+
.highlight-link{
|
| 223 |
+
color:#0c3f91;
|
| 224 |
+
font-weight:700;
|
| 225 |
+
text-decoration:none;
|
| 226 |
+
}
|
| 227 |
+
.highlight-link:hover{text-decoration:underline}
|
| 228 |
+
|
| 229 |
+
.content-panel{
|
| 230 |
+
border:1px solid var(--border);
|
| 231 |
+
padding:1.5rem;
|
| 232 |
+
margin-bottom:2rem;
|
| 233 |
+
background:#fff;
|
| 234 |
+
}
|
| 235 |
+
.guidance p{margin-top:0;margin-bottom:1rem;line-height:1.6}
|
| 236 |
+
.guidance a{color:#0d4f9a}
|
| 237 |
+
.inline-callout{
|
| 238 |
+
background:#fff3a6;
|
| 239 |
+
border-left:4px solid #f5c400;
|
| 240 |
+
padding:.75rem 1rem;
|
| 241 |
+
font-weight:600;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.step-grid{
|
| 245 |
+
display:flex;
|
| 246 |
+
flex-direction:column;
|
| 247 |
+
gap:1.2rem;
|
| 248 |
+
margin-bottom:2rem;
|
| 249 |
+
}
|
| 250 |
+
.step-card{
|
| 251 |
+
border:1px solid #cfd6e4;
|
| 252 |
+
background:#fdfdff;
|
| 253 |
+
padding:1.25rem;
|
| 254 |
+
box-shadow:0 2px 6px rgba(15,33,55,.08);
|
| 255 |
+
display:flex;
|
| 256 |
+
flex-direction:column;
|
| 257 |
+
gap:.75rem;
|
| 258 |
+
}
|
| 259 |
+
.step-card header{display:flex;gap:.8rem;align-items:center}
|
| 260 |
+
.step-number{
|
| 261 |
+
background:#f4c542;
|
| 262 |
+
color:#381d00;
|
| 263 |
+
font-weight:700;
|
| 264 |
+
min-width:54px;
|
| 265 |
+
text-align:center;
|
| 266 |
+
border-radius:999px;
|
| 267 |
+
padding:.3rem 0;
|
| 268 |
+
}
|
| 269 |
+
.step-card h2{
|
| 270 |
+
margin:0;
|
| 271 |
+
font-size:1.05rem;
|
| 272 |
+
color:#092352;
|
| 273 |
+
}
|
| 274 |
+
.step-card p{margin:0;line-height:1.45;font-size:.92rem;color:#2f3847}
|
| 275 |
+
.step-link{
|
| 276 |
+
margin-top:auto;
|
| 277 |
+
font-weight:600;
|
| 278 |
+
color:var(--accent);
|
| 279 |
+
text-decoration:none;
|
| 280 |
+
}
|
| 281 |
+
.step-link:hover{text-decoration:underline}
|
| 282 |
+
|
| 283 |
+
.support h3{margin-top:0;color:#0c2340}
|
| 284 |
+
|
| 285 |
+
.notice{background:var(--notice-bg);border:1px solid var(--border);padding:.6rem .8rem;border-radius:4px}
|
| 286 |
+
.muted{color:var(--muted)}
|
| 287 |
+
|
| 288 |
+
/* Forms */
|
| 289 |
+
label{font-size:.9rem;color:var(--muted);display:flex;flex-direction:column;gap:.25rem}
|
| 290 |
+
input[type=text], select{
|
| 291 |
+
border:1px solid var(--border);
|
| 292 |
+
padding:.45rem .5rem;border-radius:6px;background:var(--surface);
|
| 293 |
+
}
|
| 294 |
+
.form-grid{display:flex;gap:.6rem;flex-wrap:wrap;align-items:end;margin:.75rem 0 1rem}
|
| 295 |
+
|
| 296 |
+
.btn{border:1px solid var(--border);border-radius:6px;padding:.45rem .7rem;cursor:pointer;background:#fff}
|
| 297 |
+
.btn:hover{filter:brightness(0.98)}
|
| 298 |
+
.btn:disabled{opacity:.5;cursor:not-allowed}
|
| 299 |
+
.btn-primary{background:var(--brand);border-color:var(--brand);color:#fff}
|
| 300 |
+
.btn-danger{background:#a61b1b;border-color:#a61b1b;color:#fff}
|
| 301 |
+
|
| 302 |
+
/* Tables */
|
| 303 |
+
table{border-collapse:collapse;width:100%;background:var(--surface);border:1px solid var(--border)}
|
| 304 |
+
thead th{background:#f1f3f5;color:#111;text-align:left}
|
| 305 |
+
th,td{padding:.55rem .6rem;border-bottom:1px solid var(--border)}
|
| 306 |
+
tbody tr:nth-child(even){background:#fafbfc}
|
| 307 |
+
tbody tr:hover{background:#f5f7fb}
|
| 308 |
+
|
| 309 |
+
.seat-pill{display:inline-block;padding:.15rem .5rem;border-radius:999px;font-size:.78rem;border:1px solid var(--border)}
|
| 310 |
+
.seat-pill.open{background:#e8fff1;border-color:#a7e4bf;color:#186a3b}
|
| 311 |
+
.seat-pill.full{background:#ffe8e8;border-color:#f1b3b3;color:#7d1b1b}
|
| 312 |
+
|
| 313 |
+
/* Weekly schedule */
|
| 314 |
+
.week-grid{
|
| 315 |
+
display:grid;
|
| 316 |
+
grid-template-columns:80px 1fr;
|
| 317 |
+
gap:12px;
|
| 318 |
+
width:100%;
|
| 319 |
+
}
|
| 320 |
+
/* Left time rail: 10 rows (08:00–18:00) each 80px tall */
|
| 321 |
+
.times{display:grid;grid-template-rows:repeat(10,80px);padding-top:48px}
|
| 322 |
+
.time{font-size:.85rem;color:var(--muted);display:flex;align-items:center;justify-content:center}
|
| 323 |
+
.days{display:grid;grid-template-rows:48px 800px}
|
| 324 |
+
.days>.hdr{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;align-items:center}
|
| 325 |
+
.days>.hdr>div{font-weight:600;color:#111;display:flex;align-items:center;justify-content:center;height:48px}
|
| 326 |
+
.grid-body{position:relative;border:1px solid var(--border);background:var(--surface);border-radius:8px;overflow:hidden}
|
| 327 |
+
.grid-body{display:grid;grid-template-columns:repeat(5,1fr)}
|
| 328 |
+
.grid-body>.col{position:relative}
|
| 329 |
+
.grid-body>.col:not(:last-child){border-right:1px solid #e7edf5}
|
| 330 |
+
.grid-body::before{content:"";position:absolute;left:0;right:0;top:0;bottom:0;background:linear-gradient(to bottom, transparent 95%, #eef2f7 96%);background-size:100% 80px;pointer-events:none}
|
| 331 |
+
.grid-body{height:800px}
|
| 332 |
+
.event{position:absolute;left:0;right:0;margin:0 8px;border-radius:8px;padding:.3rem .4rem;background:#eef5ff;border:1px solid #cfe0ff;box-shadow:0 1px 0 rgba(0,0,0,.04)}
|
| 333 |
+
.event-title{font-size:.85rem;font-weight:600;line-height:1.1}
|
| 334 |
+
.event-sub{font-size:.78rem;color:var(--muted);line-height:1.1}
|
| 335 |
+
.event-time{font-size:.78rem;line-height:1.1}
|
| 336 |
+
.event-title,.event-sub,.event-time{overflow-wrap:anywhere}
|
| 337 |
+
|
| 338 |
+
/* Responsive adjustments */
|
| 339 |
+
@media (max-width: 1100px){
|
| 340 |
+
.container{
|
| 341 |
+
width:100%;
|
| 342 |
+
max-width:1100px;
|
| 343 |
+
padding:0 14px;
|
| 344 |
+
}
|
| 345 |
+
.hero-figure{flex:0 0 120px;height:70px}
|
| 346 |
+
}
|
| 347 |
+
@media (max-width: 900px){
|
| 348 |
+
.hero-inner{flex-direction:column;align-items:flex-start;text-align:left}
|
| 349 |
+
.hero-brand{flex-wrap:wrap}
|
| 350 |
+
.hero-figure{display:none}
|
| 351 |
+
.tab-nav{justify-content:flex-start}
|
| 352 |
+
.tab-links{
|
| 353 |
+
flex-wrap:nowrap;
|
| 354 |
+
overflow-x:auto;
|
| 355 |
+
justify-content:flex-start;
|
| 356 |
+
padding-bottom:.5rem;
|
| 357 |
+
}
|
| 358 |
+
.tab-links a{min-width:140px;font-size:.78rem}
|
| 359 |
+
.side-panel{flex-direction:row;flex-wrap:wrap;max-width:none}
|
| 360 |
+
.callout{flex:1 1 240px}
|
| 361 |
+
.highlight-panel{flex:1 1 260px}
|
| 362 |
+
}
|
| 363 |
+
@media (max-width: 700px){
|
| 364 |
+
.container{padding:0 12px}
|
| 365 |
+
.hero-wordmark-main{font-size:1.4rem}
|
| 366 |
+
.hero-wordmark-sub{font-size:.7rem}
|
| 367 |
+
.hero-logo{font-size:1rem}
|
| 368 |
+
.form-grid{flex-direction:column;align-items:stretch}
|
| 369 |
+
table{display:block;overflow-x:auto}
|
| 370 |
+
thead,tbody,tr,th,td{white-space:nowrap}
|
| 371 |
+
.step-card{padding:1rem}
|
| 372 |
+
.callout{flex-direction:column;align-items:flex-start}
|
| 373 |
+
.term-footer-inner{flex-direction:column;gap:.4rem;text-align:center}
|
| 374 |
+
}
|
| 375 |
+
@media (max-width: 520px){
|
| 376 |
+
.hero-brand{align-items:flex-start}
|
| 377 |
+
.tab-links a{min-width:120px;padding:.5rem .3rem}
|
| 378 |
+
.week-grid{grid-template-columns:1fr}
|
| 379 |
+
.times{display:none}
|
| 380 |
+
.grid-body{grid-template-columns:repeat(5, minmax(120px,1fr));overflow-x:auto}
|
| 381 |
+
.grid-body>.col:not(:last-child){border-right:1px solid #e7edf5}
|
| 382 |
+
}
|
templates/accounts.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="page-heading">
|
| 4 |
+
<h1>Tuition Fee and Legal Status</h1>
|
| 5 |
+
{% if term_confirmed %}
|
| 6 |
+
<span class="term-chip">Term: {{ current_term }}</span>
|
| 7 |
+
{% endif %}
|
| 8 |
+
</div>
|
| 9 |
+
<section class="content-panel">
|
| 10 |
+
<p>
|
| 11 |
+
This simplified Minerva environment does not include full billing services, but you can use this page to confirm
|
| 12 |
+
that the correct term is active before reviewing statements in other systems.
|
| 13 |
+
</p>
|
| 14 |
+
<p class="muted">
|
| 15 |
+
Select Step 6 from the registration menu to return here after adjusting your term.
|
| 16 |
+
</p>
|
| 17 |
+
</section>
|
| 18 |
+
{% endblock %}
|
templates/add_drop.html
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="page-heading">
|
| 4 |
+
<h1>Quick Add or Drop Course Sections</h1>
|
| 5 |
+
{% if term_confirmed %}
|
| 6 |
+
<span class="term-chip">Term: {{ current_term }}</span>
|
| 7 |
+
{% endif %}
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<form method="POST" action="/add" class="form-grid" style="margin-bottom:1rem">
|
| 11 |
+
<label>CRN
|
| 12 |
+
<input data-test="add-crn-input" type="text" name="crn" placeholder="e.g., 12345" />
|
| 13 |
+
</label>
|
| 14 |
+
<button class="btn btn-primary" data-test="add-submit" type="submit">Add</button>
|
| 15 |
+
</form>
|
| 16 |
+
|
| 17 |
+
<h2>Registered Sections</h2>
|
| 18 |
+
<table>
|
| 19 |
+
<thead>
|
| 20 |
+
<tr>
|
| 21 |
+
<th>CRN</th><th>Subject</th><th>Number</th><th>Title</th><th>Schedule</th><th>Campus</th><th></th>
|
| 22 |
+
</tr>
|
| 23 |
+
</thead>
|
| 24 |
+
<tbody>
|
| 25 |
+
{% for c in regs %}
|
| 26 |
+
<tr data-test="schedule-row">
|
| 27 |
+
<td>{{ c['crn'] }}</td>
|
| 28 |
+
<td>{{ c['subject'] }}</td>
|
| 29 |
+
<td>{{ c['number'] }}</td>
|
| 30 |
+
<td>{{ c['title'] }}</td>
|
| 31 |
+
<td>{{ c['days'] }} {{ c['time'] }}</td>
|
| 32 |
+
<td>{{ c['campus'] }}</td>
|
| 33 |
+
<td>
|
| 34 |
+
<form method="POST" action="/drop">
|
| 35 |
+
<input type="hidden" name="crn" value="{{ c['crn'] }}" />
|
| 36 |
+
<button class="btn btn-danger" data-test="drop-button" type="submit">Drop</button>
|
| 37 |
+
</form>
|
| 38 |
+
</td>
|
| 39 |
+
</tr>
|
| 40 |
+
{% else %}
|
| 41 |
+
<tr><td colspan="7">No registered sections.</td></tr>
|
| 42 |
+
{% endfor %}
|
| 43 |
+
</tbody>
|
| 44 |
+
</table>
|
| 45 |
+
{% endblock %}
|
templates/base.html
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>{{ title or 'Minerva - Registration' }}</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/styles.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body class="minerva-skin">
|
| 10 |
+
<header class="minerva-header">
|
| 11 |
+
<div class="hero-strip">
|
| 12 |
+
<div class="container hero-inner">
|
| 13 |
+
<div class="hero-brand" data-test="logo">
|
| 14 |
+
<div class="hero-wordmark">
|
| 15 |
+
<div class="hero-wordmark-main">Minerva</div>
|
| 16 |
+
<div class="hero-wordmark-sub">
|
| 17 |
+
Registration & Student Records
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="hero-figure" aria-hidden="true"></div>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
{% set current_path = request.path %} {% set pending_tab =
|
| 25 |
+
request.args.get('next') if request.endpoint == 'select_term' else '' %}
|
| 26 |
+
{% set change_term_next = pending_tab or current_step_key or 'search' %}
|
| 27 |
+
<div class="tab-row">
|
| 28 |
+
<div class="container tab-nav">
|
| 29 |
+
<nav class="tab-links">
|
| 30 |
+
<a
|
| 31 |
+
href="/"
|
| 32 |
+
data-test="menu-home"
|
| 33 |
+
class="{% if current_path == '/' %}is-active{% endif %}"
|
| 34 |
+
>Registration Menu</a
|
| 35 |
+
>
|
| 36 |
+
<a
|
| 37 |
+
href="{% if term_confirmed %}/search{% else %}/select-term?next=search{% endif %}"
|
| 38 |
+
data-test="menu-search"
|
| 39 |
+
class="{% if current_path.startswith('/search') or pending_tab=='search' %}is-active{% endif %}"
|
| 40 |
+
>Search Class</a
|
| 41 |
+
>
|
| 42 |
+
<a
|
| 43 |
+
href="{% if term_confirmed %}/add-drop{% else %}/select-term?next=add_drop{% endif %}"
|
| 44 |
+
data-test="menu-adddrop"
|
| 45 |
+
class="{% if current_path.startswith('/add-drop') or pending_tab=='add_drop' %}is-active{% endif %}"
|
| 46 |
+
>Add/Drop</a
|
| 47 |
+
>
|
| 48 |
+
<a
|
| 49 |
+
href="{% if term_confirmed %}/schedule{% else %}/select-term?next=schedule{% endif %}"
|
| 50 |
+
data-test="menu-schedule"
|
| 51 |
+
class="{% if (current_path.startswith('/schedule') and not current_path.startswith('/schedule-week')) or pending_tab=='schedule' %}is-active{% endif %}"
|
| 52 |
+
>Student Schedule</a
|
| 53 |
+
>
|
| 54 |
+
<a
|
| 55 |
+
href="{% if term_confirmed %}/schedule-week{% else %}/select-term?next=schedule_week{% endif %}"
|
| 56 |
+
data-test="menu-schedule-week"
|
| 57 |
+
class="{% if current_path.startswith('/schedule-week') or pending_tab=='schedule_week' %}is-active{% endif %}"
|
| 58 |
+
>Weekly View</a
|
| 59 |
+
>
|
| 60 |
+
</nav>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
</header>
|
| 64 |
+
<main class="page container">
|
| 65 |
+
{% if msg %}
|
| 66 |
+
<p class="notice" data-test="notice">{{ msg }}</p>
|
| 67 |
+
{% endif %} {% block content %}{% endblock %}
|
| 68 |
+
</main>
|
| 69 |
+
<footer class="term-footer">
|
| 70 |
+
<div class="container term-footer-inner">
|
| 71 |
+
<span>Need to work in another term?</span>
|
| 72 |
+
<a
|
| 73 |
+
href="/select-term?reset=1&next={{ change_term_next }}"
|
| 74 |
+
data-test="change-term-link"
|
| 75 |
+
>Change Term</a
|
| 76 |
+
>
|
| 77 |
+
|
| 78 |
+
</div>
|
| 79 |
+
</footer>
|
| 80 |
+
<!-- status link removed from UI; endpoint remains for health checks -->
|
| 81 |
+
</body>
|
| 82 |
+
</html>
|
templates/registration_menu.html
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<section class="page-lead">
|
| 4 |
+
<h1 class="page-title">Registration Menu</h1>
|
| 5 |
+
</section>
|
| 6 |
+
|
| 7 |
+
<section class="step-grid">
|
| 8 |
+
<article class="step-card">
|
| 9 |
+
<header>
|
| 10 |
+
<div class="step-number">Step 1</div>
|
| 11 |
+
<h2>Check Your Registration Eligibility and Verify Your Curriculum</h2>
|
| 12 |
+
</header>
|
| 13 |
+
<p>
|
| 14 |
+
Confirm that you have resolved all holds, outstanding transcripts, or advising requirements that could prevent
|
| 15 |
+
registration. Review your primary faculty, program, and concentration selections before continuing.
|
| 16 |
+
</p>
|
| 17 |
+
<a class="step-link" href="#" aria-label="Student Records Menu">Go to Student Records Menu</a>
|
| 18 |
+
</article>
|
| 19 |
+
|
| 20 |
+
<article class="step-card">
|
| 21 |
+
<header>
|
| 22 |
+
<div class="step-number">Step 2</div>
|
| 23 |
+
<h2>Search Class and Add Course Sections</h2>
|
| 24 |
+
</header>
|
| 25 |
+
<p>
|
| 26 |
+
Choose the schedule term, search by subject or CRN, and add courses to your worksheet. Visualize potential
|
| 27 |
+
conflicts and note seats that may be available on the waitlist (space permitting).
|
| 28 |
+
</p>
|
| 29 |
+
<a class="step-link" href="{% if term_confirmed %}/search{% else %}/select-term?next=search{% endif %}" data-test="menu-step-search">Launch Search Class</a>
|
| 30 |
+
</article>
|
| 31 |
+
|
| 32 |
+
<article class="step-card">
|
| 33 |
+
<header>
|
| 34 |
+
<div class="step-number">Step 3</div>
|
| 35 |
+
<h2>Quick Add or Drop Course Sections</h2>
|
| 36 |
+
</header>
|
| 37 |
+
<p>
|
| 38 |
+
Have a CRN ready? Use Quick Add to enter it directly, or drop from the list of registered sections. Select the
|
| 39 |
+
satisfactory/unsatisfactory grade mode when eligible.
|
| 40 |
+
</p>
|
| 41 |
+
<a class="step-link" href="{% if term_confirmed %}/add-drop{% else %}/select-term?next=add_drop{% endif %}" data-test="menu-step-adddrop">Open Add/Drop by CRN</a>
|
| 42 |
+
</article>
|
| 43 |
+
|
| 44 |
+
<article class="step-card">
|
| 45 |
+
<header>
|
| 46 |
+
<div class="step-number">Step 4</div>
|
| 47 |
+
<h2>View Student Schedule by Course Section</h2>
|
| 48 |
+
</header>
|
| 49 |
+
<p>
|
| 50 |
+
Display locations, meeting dates, instructors, and any notes attached to your registered sections as well as
|
| 51 |
+
waitlisted courses in one consolidated view.
|
| 52 |
+
</p>
|
| 53 |
+
<a class="step-link" href="{% if term_confirmed %}/schedule{% else %}/select-term?next=schedule{% endif %}" data-test="menu-step-schedule">View Student Schedule</a>
|
| 54 |
+
</article>
|
| 55 |
+
|
| 56 |
+
<article class="step-card">
|
| 57 |
+
<header>
|
| 58 |
+
<div class="step-number">Step 5</div>
|
| 59 |
+
<h2>View Personal Weekly Class Schedule</h2>
|
| 60 |
+
</header>
|
| 61 |
+
<p>
|
| 62 |
+
Review your classes in a weekly calendar grid. Print the schedule or save as PDF to confirm room locations on the
|
| 63 |
+
first day of class.
|
| 64 |
+
</p>
|
| 65 |
+
<a class="step-link" href="{% if term_confirmed %}/schedule-week{% else %}/select-term?next=schedule_week{% endif %}" data-test="menu-step-weekly">Weekly Schedule View</a>
|
| 66 |
+
</article>
|
| 67 |
+
|
| 68 |
+
<article class="step-card">
|
| 69 |
+
<header>
|
| 70 |
+
<div class="step-number">Step 6</div>
|
| 71 |
+
<h2>View Tuition Fee and Legal Status</h2>
|
| 72 |
+
</header>
|
| 73 |
+
<p>
|
| 74 |
+
Verify your status, outstanding balances, and legal statements for the term to avoid late fees or enrollment
|
| 75 |
+
interruptions.
|
| 76 |
+
</p>
|
| 77 |
+
<a class="step-link" href="{% if term_confirmed %}/accounts{% else %}/select-term?next=accounts{% endif %}">Go to Student Accounts</a>
|
| 78 |
+
</article>
|
| 79 |
+
</section>
|
| 80 |
+
|
| 81 |
+
{% endblock %}
|
templates/schedule.html
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="page-heading">
|
| 4 |
+
<h1>Student Schedule</h1>
|
| 5 |
+
{% if term_confirmed %}
|
| 6 |
+
<span class="term-chip">Term: {{ current_term }}</span>
|
| 7 |
+
{% endif %}
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<table>
|
| 11 |
+
<thead>
|
| 12 |
+
<tr>
|
| 13 |
+
<th>CRN</th><th>Subject</th><th>Number</th><th>Title</th><th>Schedule</th><th>Campus</th><th></th>
|
| 14 |
+
</tr>
|
| 15 |
+
</thead>
|
| 16 |
+
<tbody>
|
| 17 |
+
{% for c in regs %}
|
| 18 |
+
<tr data-test="schedule-row">
|
| 19 |
+
<td>{{ c['crn'] }}</td>
|
| 20 |
+
<td>{{ c['subject'] }}</td>
|
| 21 |
+
<td>{{ c['number'] }}</td>
|
| 22 |
+
<td>{{ c['title'] }}</td>
|
| 23 |
+
<td>{{ c['days'] }} {{ c['time'] }}</td>
|
| 24 |
+
<td>{{ c['campus'] }}</td>
|
| 25 |
+
<td>
|
| 26 |
+
<form method="POST" action="/drop">
|
| 27 |
+
<input type="hidden" name="crn" value="{{ c['crn'] }}" />
|
| 28 |
+
<button class="btn btn-danger" data-test="drop-button" type="submit">Drop</button>
|
| 29 |
+
</form>
|
| 30 |
+
</td>
|
| 31 |
+
</tr>
|
| 32 |
+
{% else %}
|
| 33 |
+
<tr><td colspan="7">No registered sections.</td></tr>
|
| 34 |
+
{% endfor %}
|
| 35 |
+
</tbody>
|
| 36 |
+
</table>
|
| 37 |
+
{% endblock %}
|
templates/schedule_week.html
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="page-heading">
|
| 4 |
+
<h1>Weekly Schedule</h1>
|
| 5 |
+
{% if term_confirmed %}
|
| 6 |
+
<span class="term-chip">Term: {{ current_term }}</span>
|
| 7 |
+
{% endif %}
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<div class="week-grid">
|
| 11 |
+
<div class="times">
|
| 12 |
+
{% for t in time_labels %}
|
| 13 |
+
<div class="time">{{ t }}</div>
|
| 14 |
+
{% endfor %}
|
| 15 |
+
</div>
|
| 16 |
+
<div class="days">
|
| 17 |
+
<div class="hdr">
|
| 18 |
+
<div>Mon</div>
|
| 19 |
+
<div>Tue</div>
|
| 20 |
+
<div>Wed</div>
|
| 21 |
+
<div>Thu</div>
|
| 22 |
+
<div>Fri</div>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="grid-body">
|
| 25 |
+
{% for col in range(1,6) %}
|
| 26 |
+
<div class="col">
|
| 27 |
+
{% for e in events %}
|
| 28 |
+
{% if e.day_col == col %}
|
| 29 |
+
<div class="event" style="top: {{ e.top_px }}px; height: {{ e.height_px }}px" data-test="week-event">
|
| 30 |
+
<div class="event-title">{{ e.title }}</div>
|
| 31 |
+
<div class="event-sub">{{ e.subtitle }}</div>
|
| 32 |
+
<div class="event-time">{{ e.time }}</div>
|
| 33 |
+
</div>
|
| 34 |
+
{% endif %}
|
| 35 |
+
{% endfor %}
|
| 36 |
+
</div>
|
| 37 |
+
{% endfor %}
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<p class="muted" style="margin-top:.5rem">Grid spans 08:00–18:00. Events are approximate visualizations.</p>
|
| 43 |
+
{% endblock %}
|
templates/search.html
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<div class="page-heading">
|
| 4 |
+
<h1>Search Class</h1>
|
| 5 |
+
{% if term_confirmed %}
|
| 6 |
+
<span class="term-chip">Term: {{ current_term }}</span>
|
| 7 |
+
{% endif %}
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<form method="GET" action="/search" class="form-grid">
|
| 11 |
+
<label>Subject
|
| 12 |
+
<input data-test="search-subject" type="text" name="subject" value="{{ request.args.get('subject','') }}" placeholder="e.g., COMP" />
|
| 13 |
+
</label>
|
| 14 |
+
<label>Number
|
| 15 |
+
<input data-test="search-number" type="text" name="number" value="{{ request.args.get('number','') }}" placeholder="e.g., 250" />
|
| 16 |
+
</label>
|
| 17 |
+
<label>Title contains
|
| 18 |
+
<input data-test="search-title" type="text" name="title" value="{{ request.args.get('title','') }}" placeholder="e.g., data" />
|
| 19 |
+
</label>
|
| 20 |
+
<label>Campus
|
| 21 |
+
<input data-test="search-campus" type="text" name="campus" value="{{ request.args.get('campus','') }}" placeholder="e.g., Downtown" />
|
| 22 |
+
</label>
|
| 23 |
+
<button class="btn btn-primary" data-test="search-button" type="submit">Search</button>
|
| 24 |
+
</form>
|
| 25 |
+
|
| 26 |
+
<table>
|
| 27 |
+
<thead>
|
| 28 |
+
<tr>
|
| 29 |
+
<th>CRN</th>
|
| 30 |
+
<th>Subject</th>
|
| 31 |
+
<th>Number</th>
|
| 32 |
+
<th>Title</th>
|
| 33 |
+
<th>Days/Time</th>
|
| 34 |
+
<th>Campus</th>
|
| 35 |
+
<th>Seats</th>
|
| 36 |
+
<th></th>
|
| 37 |
+
</tr>
|
| 38 |
+
</thead>
|
| 39 |
+
<tbody>
|
| 40 |
+
{% for c in results %}
|
| 41 |
+
<tr data-test="course-row">
|
| 42 |
+
<td data-test="course-crn">{{ c['crn'] }}</td>
|
| 43 |
+
<td>{{ c['subject'] }}</td>
|
| 44 |
+
<td>{{ c['number'] }}</td>
|
| 45 |
+
<td>{{ c['title'] }}</td>
|
| 46 |
+
<td>{{ c['days'] }} {{ c['time'] }}</td>
|
| 47 |
+
<td>{{ c['campus'] }}</td>
|
| 48 |
+
<td>
|
| 49 |
+
<span class="seat-pill {{ 'full' if c['seats_taken']>=c['seats_total'] else 'open' }}">
|
| 50 |
+
{{ c['seats_taken'] }}/{{ c['seats_total'] }} {{ 'Full' if c['seats_taken']>=c['seats_total'] else 'Open' }}
|
| 51 |
+
</span>
|
| 52 |
+
</td>
|
| 53 |
+
<td>
|
| 54 |
+
<form method="POST" action="/add">
|
| 55 |
+
<input type="hidden" name="crn" value="{{ c['crn'] }}" />
|
| 56 |
+
<button class="btn btn-primary" data-test="add-button" type="submit" {% if c['seats_taken']>=c['seats_total'] %}disabled{% endif %}>Add</button>
|
| 57 |
+
</form>
|
| 58 |
+
</td>
|
| 59 |
+
</tr>
|
| 60 |
+
{% else %}
|
| 61 |
+
<tr><td colspan="8">No sections match your search.</td></tr>
|
| 62 |
+
{% endfor %}
|
| 63 |
+
</tbody>
|
| 64 |
+
</table>
|
| 65 |
+
{% endblock %}
|
templates/select_term.html
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
<section class="term-gate">
|
| 4 |
+
<h1>Select Term</h1>
|
| 5 |
+
<p>
|
| 6 |
+
Before continuing to <strong>{{ destination_label }}</strong>, confirm the McGill term you wish to work with.
|
| 7 |
+
</p>
|
| 8 |
+
<form method="POST" action="/set-term" class="term-gate-form">
|
| 9 |
+
<input type="hidden" name="next" value="{{ next_key }}" />
|
| 10 |
+
<label>
|
| 11 |
+
Select a Term:
|
| 12 |
+
<select name="term" required data-test="term-select">
|
| 13 |
+
{% for t in terms %}
|
| 14 |
+
<option value="{{ t }}" {% if t==current_term %}selected{% endif %}>{{ t }}</option>
|
| 15 |
+
{% endfor %}
|
| 16 |
+
</select>
|
| 17 |
+
</label>
|
| 18 |
+
<button class="btn btn-primary" type="submit" data-test="term-submit">Continue</button>
|
| 19 |
+
</form>
|
| 20 |
+
</section>
|
| 21 |
+
{% endblock %}
|