commit 32b4dc48234d23a3f7b3cfab3fb14d239686ef0c Author: Nicolas Gruber Date: Thu Oct 15 16:27:52 2026 +0200 CIS Enhanced: FastAPI-App für LV-Plan, Studiengangs-Pläne und Namenslisten des FH-Burgenland-CIS - Studentenplan (eigene/fremde Matrikelnummer) mit Wochenansicht, Semesterübersicht, iCal/CSV-Export - Studiengangs-Pläne nach StG/Semester/Verband mit Gruppen-Filter - Namenslisten mit Suche, Matrikel-Decode und CSV-Export - Studiennummer-Decoder auf der Startseite - Docker (Dockerfile, Compose) und Caddy-Beispiel für Deploy diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72f862e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.venv +.git +.env +__pycache__ +*.pyc +*.log +Caddyfile.example +README.md +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..072aea6 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# CIS Zugangsdaten (basic auth) +CIS_USER=dein.username +CIS_PASSWORD=dein.passwort + +# Basis-URL (normalerweise lassen, nur bei Änderungen nötig) +CIS_BASE_URL=https://cis.hochschule-burgenland.at/cis + +# Eigene Matrikelnummer (Default in der App) +OWN_UID=2410640055 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd54f30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +__pycache__/ +*.pyc +.venv/ +venv/ +*.log diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..e994da2 --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,12 @@ +# Caddyfile für den Deploy auf der Oracle VM +# App läuft via uvicorn auf 127.0.0.1:8000, Caddy terminiert TLS. + +deinedomain.at { + reverse_proxy 127.0.0.1:8000 +} + +# Optional mit Force-HTTPS-Redirect (Standard bei Caddy ohnehin): +# deinedomain.at { +# encode gzip +# reverse_proxy 127.0.0.1:8000 +# } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..be9a881 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY app ./app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6be023 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# CIS Enhanced + +Bessere Ansichten für das FH-Burgenland-CIS: + +- **Studentenplan** - eigener oder fremder Plan (Matrikelnummer), Wochenansicht + Semesterübersicht, iCal/CSV-Export +- **Studiengangs-Pläne** - nach Studiengang, Studien-Semester und Verband (Vollzeit/Berufsbegleitend), inkl. Gruppen-Filter +- **Namenslisten** - je Studiengang/Semester/Verband mit Suche und CSV-Export +- **Studiennummer-Decoder** - Matrikelnummer wird in Startsemester, Studiengang und Nummer zerlegt + +## Lokal starten + +```bash +python -m venv .venv +.venv\Scripts\pip install -r requirements.txt # Windows +# venv/bin/pip install -r requirements.txt # Linux/Mac +.venv\Scripts\python run.py +``` + +Öffne http://127.0.0.1:8000 + +## Konfiguration + +Zugangsdaten in `.env` eintragen (Vorlage: `.env.example`): + +``` +CIS_USER= +CIS_PASSWORD= +CIS_BASE_URL=https://cis.hochschule-burgenland.at/cis +OWN_UID= +``` + +`.env` wird nicht in das Docker-Image kopiert (`.dockerignore`), sondern zur Laufzeit übergeben. + +## Docker + +```bash +# Image bauen +docker build -t cis-enhanced . + +# Starten (Zugangsdaten aus .env) +docker run -d --name cis-enhanced --restart unless-stopped \ + -p 8000:8000 --env-file .env cis-enhanced + +# oder mit Compose +docker compose up -d --build +``` + +## Deploy (Caddy) + +App auf Port 8000 starten (z.B. als systemd-Service, oder der Docker-Container +portet nach `127.0.0.1:8000`), Caddy davor: + +```ini +deinedomain.at { + reverse_proxy 127.0.0.1:8000 +} +``` + +systemd-Beispiel: + +```ini +[Unit] +Description=CIS Enhanced +After=network.target + +[Service] +WorkingDirectory=/pfad/zu/cis-enhanced +ExecStart=/pfad/zu/cis-enhanced/.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 +Restart=always +User=www-data + +[Install] +WantedBy=multi-user.target +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/cis.py b/app/cis.py new file mode 100644 index 0000000..583bd43 --- /dev/null +++ b/app/cis.py @@ -0,0 +1,276 @@ +"""CIS API client for FH-Burgenland (basic auth, HTML/iCal scraping).""" +import re +import time +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from functools import lru_cache +from typing import Optional + +import requests +from bs4 import BeautifulSoup + +TZ = timezone(timedelta(hours=1)) # Europe/Vienna winter; events carry their own tz, we just parse naive + +SLOTS = [ + "08:30", "09:30", "10:15", "11:15", "12:00", + "13:00", "14:00", "14:45", "15:45", "16:30", + "17:45", "18:30", "19:30", "20:15", "21:00", +] +SLOT_ENDS = [ + "09:15", "10:15", "11:00", "12:00", "12:45", + "13:45", "14:45", "15:30", "16:30", "17:15", + "18:30", "19:15", "20:15", "21:00", "21:45", +] + + +@dataclass +class Event: + lv: str + lektor: str + gruppe: str + ort: str + start: datetime # naive, Vienna wall time + end: datetime + uid: str = "" + + @property + def date(self): + return self.start.date() + + @property + def weekday(self): + return self.start.weekday() # 0=Mon + + @property + def hours(self): + return round((self.end - self.start).total_seconds() / 3600, 2) + + +@dataclass +class Person: + uid: str + name: str + stg: str + semester: str + verband: str + gruppe: str + + +@dataclass +class Student: + nachname: str + vorname: str + geschlecht: str + mail: str + matrikel: str = "" + + @property + def name(self): + return f"{self.nachname}, {self.vorname}" + + +def parse_ical(text: str) -> list[Event]: + events = [] + for block in re.findall(r"BEGIN:VEVENT(.*?)END:VEVENT", text, re.S): + fields = {} + for line in block.strip().splitlines(): + if ":" in line: + key, _, val = line.partition(":") + fields[key.split(";")[0].upper()] = val + desc = fields.get("DESCRIPTION", "").replace("\\n", "\n").split("\n") + desc = [d.strip() for d in desc if d.strip()] + lv, lektor, gruppe, ort = (desc + ["", "", "", ""])[:4] + if not lv: + m = re.match(r"^\s*(\S+)\s+(.*)$", fields.get("SUMMARY", "")) + lv = m.group(1) if m else fields.get("SUMMARY", "") + ort = m.group(2).strip() if m else "" + if " - " in fields.get("SUMMARY", ""): + gruppe = fields["SUMMARY"].rsplit(" - ", 1)[1].strip() + dtstart = fields.get("DTSTART", "") + dtend = fields.get("DTEND", "") + if not dtstart or not dtend: + continue + start = datetime.strptime(dtstart, "%Y%m%dT%H%M%S") + end = datetime.strptime(dtend, "%Y%m%dT%H%M%S") + if end <= start: + end = start + timedelta(hours=1) + events.append(Event(lv=lv, lektor=lektor, gruppe=gruppe, ort=ort, + start=start, end=end, uid=fields.get("UID", ""))) + events.sort(key=lambda e: e.start) + return events + + +class CISClient: + def __init__(self, user: str, password: str, base_url: str): + self.base = base_url.rstrip("/") + self.s = requests.Session() + self.s.auth = (user, password) + self.s.headers["User-Agent"] = "Mozilla/5.0 (cis-enhanced)" + self._meta_cache: Optional[dict] = None + self._meta_ts = 0.0 + + # ---------- metadata (stg list, semesters, rooms) ---------- + def meta(self, max_age: int = 3600) -> dict: + if self._meta_cache and time.time() - self._meta_ts < max_age: + return self._meta_cache + html = self._get(f"{self.base}/private/lvplan/index.php") + soup = BeautifulSoup(html, "html.parser") + selects: dict[str, list[tuple[str, str]]] = {} + for s_ in soup.find_all("select"): + name = s_.get("name") or "" + opts = [(o.get("value") or "", o.get_text(" ", strip=True)) for o in s_.find_all("option")] + if name not in selects or len(opts) > len(selects[name]): + selects[name] = opts + + stgs = [] + for vz, label in selects.get("stg_kz", []): + if not vz: + continue + m = re.match(r"^(\S+)\s+\((.*)\)$", label) + stgs.append({"kz": vz, "code": m.group(1) if m else vz, + "name": m.group(2) if m else label}) + semesters = [] + for raw, label in selects.get("begin", []): + m = re.match(r"^(\d+)&ende=(\d+)$", raw or "") + if not m: + continue + semesters.append({"label": label, "begin": int(m.group(1)), + "ende": int(m.group(2))}) + semesters.sort(key=lambda s: s["begin"], reverse=True) + rooms = [] + for raw, label in selects.get("select", []): + if "ort_kurzbz=" not in raw: + continue + ort = raw.split("ort_kurzbz=")[1].split("&")[0] + m = re.match(r"^(\S+)\s+\((.*)\)$", label) + rooms.append({"kurzbz": ort, "code": m.group(1) if m else ort, + "name": m.group(2) if m else label}) + self._meta_cache = { + "stgs": stgs, + "semesters": semesters, + "rooms": rooms, + "verbands": ["V", "B", "A", "C", "D", "E", "F"], + "grps": [str(i) for i in range(1, 7)], + } + self._meta_ts = time.time() + return self._meta_cache + + # ---------- low level ---------- + def _get(self, url: str, **kw) -> str: + r = self.s.get(url, timeout=30, **kw) + r.raise_for_status() + return r.text + + def _ical_url(self, params: dict) -> str: + q = "&".join(f"{k}={v}" for k, v in params.items()) + return f"{self.base}/private/lvplan/stpl_kalender.php?{q}" + + def _ical_student_params(self, uid: str, begin: int, ende: int) -> dict: + return {"type": "student", "pers_uid": uid, "ort_kurzbz": "", + "stg_kz": "", "sem": "", "ver": "", "grp": "", + "gruppe_kurzbz": "", "lva": "", "begin": begin, "ende": ende, + "format": "ical", "version": "2", "target": "ical"} + + def _ical_stg_params(self, kz: str, sem: str, ver: str, grp: str, + begin: int, ende: int) -> dict: + return {"type": "verband", "stg_kz": kz, "sem": sem, "ver": ver, + "grp": grp, "begin": begin, "ende": ende, + "format": "ical", "version": "2", "target": "ical"} + + # ---------- events ---------- + def student_events(self, uid: str, begin: int, ende: int) -> list[Event]: + return parse_ical(self._get(self._ical_url(self._ical_student_params(uid, begin, ende)))) + + def stg_events(self, kz: str, sem: str, ver: str, grp: str, + begin: int, ende: int) -> list[Event]: + return parse_ical(self._get(self._ical_url(self._ical_stg_params(kz, sem, ver, grp, begin, ende)))) + + def ical_raw(self, kind: str, **kw) -> str: + if kind == "student": + return self._get(self._ical_url(self._ical_student_params(kw["uid"], kw["begin"], kw["ende"]))) + return self._get(self._ical_url(self._ical_stg_params(kw["kz"], kw["sem"], kw["ver"], kw.get("grp", ""), kw["begin"], kw["ende"]))) + + # ---------- person ---------- + @lru_cache(maxsize=256) + def person(self, uid: str) -> Optional[Person]: + try: + html = self._get(f"{self.base}/private/lvplan/stpl_week.php?pers_uid={uid}") + except requests.HTTPError: + return None + t = re.sub(r"<[^>]+>", " ", html) + t = re.sub(r"\s+", " ", t) + m = re.search(r"Person:\s*(.+?)\s*-\s*(\d{9,10}).*?Studiengang:\s*(.+?)\s+Semester:\s*(\S+?)\s+Verband:\s*(\S+?)\s+Gruppe:", t) + if not m: + return None + name, u, stg, sem, ver = m.groups() + return Person(uid=u, name=name.strip(), stg=stg.strip(), + semester=sem.strip(), verband=ver.strip(), gruppe="") + + # ---------- Namensliste ---------- + def namensliste(self, kz: str, sem: str, ver: str) -> list[Student]: + html = self._get(f"{self.base}/private/stud_in_grp.php?kz={kz}&sem={sem}&verband={ver}") + soup = BeautifulSoup(html, "html.parser") + tbl = soup.find("table", class_="tablesorter") + out = [] + if not tbl: + return out + # columns: [checkbox?, nachname, vorname, geschlecht, mail] + for tr in tbl.find_all("tr")[1:]: + cells = [c.get_text(" ", strip=True) for c in tr.find_all(["td", "th"])] + if not cells or not any(cells): + continue + # drop leading checkbox column if present + if len(cells) == 5 and not cells[0]: + cells = cells[1:] + if len(cells) < 4: + continue + nachname, vorname, geschlecht, mail = cells[0], cells[1], cells[2], cells[3] + if not nachname and not vorname: + continue + out.append(Student(nachname=nachname, vorname=vorname, + geschlecht=geschlecht, mail=mail, + matrikel=mail.split("@")[0] if "@" in mail else "")) + return out + + # ---------- Verbände pro StG/Semester ---------- + def verbands_for(self, kz: str, sem: str) -> list[str]: + fallback = ["V", "B", "G", "A", "C", "D", "E", "F"] + try: + r = self.s.post(f"{self.base}/private/lvplan/lvplan_autocomplete.php", + data={"autocomplete": "getVerband", "stg_kz": kz, "sem": sem}, + timeout=30) + data = r.json() + if isinstance(data, dict): + vals = list(data.values()) + else: + vals = data + vals = [str(v).strip() for v in vals if str(v).strip()] + return vals or fallback + except Exception: + return fallback + + # ---------- detail (LV details for one slot) ---------- + def detail(self, kind: str, datum: str, stunde: int, uid: str = "", + kz: str = "", sem: str = "", ver: str = "", grp: str = "", + ort: str = "") -> dict: + if kind == "student": + url = (f"{self.base}/private/lvplan/stpl_detail.php?type=student&datum={datum}" + f"&stunde={stunde}&pers_uid={uid}&stg_kz={kz}&sem={sem}&ver={ver}" + f"&grp= {grp}&ort_kurzbz={ort}") + else: + url = (f"{self.base}/private/lvplan/stpl_detail.php?type=verband&datum={datum}" + f"&stunde={stunde}&stg_kz={kz}&sem={sem}&ver={ver}" + f"&grp={grp}&ort_kurzbz={ort}") + html = self._get(url) + soup = BeautifulSoup(html, "html.parser") + headers, data_row = [], None + for tr in soup.find_all("tr"): + ths = tr.find_all("th") + tds = tr.find_all("td") + if ths and not tds: + headers = [th.get_text(strip=True) for th in ths] + elif tds and not ths: + data_row = [td.get_text(" ", strip=True) for td in tds] + if not headers or not data_row: + return {} + return {h: data_row[i] for i, h in enumerate(headers) if i < len(data_row)} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..1e52ea2 --- /dev/null +++ b/app/main.py @@ -0,0 +1,226 @@ +"""CIS Enhanced - FastAPI app.""" +import os +from datetime import datetime, timedelta +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import FastAPI, Query, Request +from fastapi.responses import HTMLResponse, JSONResponse, Response +from fastapi.templating import Jinja2Templates + +load_dotenv(Path(__file__).resolve().parent.parent / ".env") +for _var in ("CIS_USER", "CIS_PASSWORD"): + if not os.environ.get(_var) or "HIER_" in os.environ[_var]: + raise RuntimeError( + f"Umweltvariable {_var} fehlt. .env ausfüllen (siehe .env.example) " + f"bzw. beim Docker-Start über --env-file / env_file übergeben.") + +from fastapi.staticfiles import StaticFiles # noqa: E402 + +from .cis import CISClient, SLOTS # noqa: E402 +from .util import (current_semester, decode_matrikel, events_to_json, fmt_range, # noqa: E402 + lv_color, week_label, week_start) + +BASE = os.environ["CIS_BASE_URL"].rstrip("/") +client = CISClient(os.environ["CIS_USER"], os.environ["CIS_PASSWORD"], BASE) +OWN_UID = os.environ.get("OWN_UID", os.environ["CIS_USER"]) + +app = FastAPI(title="CIS Enhanced") +app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static") +templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) +templates.env.globals["lv_color"] = lv_color +templates.env.globals["decode_matrikel"] = decode_matrikel +templates.env.globals["fmt_range"] = fmt_range +templates.env.globals["week_label"] = week_label +templates.env.globals["slots"] = SLOTS + + +def _meta(): + return client.meta() + + +@app.get("/", response_class=HTMLResponse) +def home(request: Request): + meta = _meta() + return templates.TemplateResponse(request, "home.html", { + "own_uid": OWN_UID, + "stgs": meta["stgs"], + "semesters": meta["semesters"], + "current_sem": current_semester(meta["semesters"]), + }) + + +def _plan_common(request, kind, uid, kz, sem, ver, grp, semester, week="", tab="week"): + meta = _meta() + semester = semester or current_semester(meta["semesters"]) + s = next((x for x in meta["semesters"] if x["label"] == semester), meta["semesters"][0]) + person = None + if kind == "student": + uid = uid or OWN_UID + person = client.person(uid) + events = client.student_events(uid, s["begin"], s["ende"]) + else: + kz = kz or "640" + sem = sem or "1" + ver = ver or "V" + events = client.stg_events(kz, sem, ver, grp, s["begin"], s["ende"]) + # initial week: explicit param, today if inside semester, else first event week + init_week = None + try: + if week: + init_week = week_start(datetime.strptime(week, "%Y-%m-%d").date()) + except ValueError: + pass + if init_week is None: + now = datetime.now() + if s["begin"] <= now.timestamp() <= s["ende"]: + first_day = now.date() + elif events: + first_day = events[0].start.date() + else: + first_day = datetime.fromtimestamp(s["begin"]).date() + init_week = week_start(first_day) + stg = next((x for x in meta["stgs"] if x["kz"] == kz), None) + verbands = client.verbands_for(kz, sem) if kind == "stg" and kz and sem else meta["verbands"] + return { + "request": request, + "kind": kind, + "uid": uid or "", + "kz": kz or "", + "sem": sem or "", + "ver": ver or "", + "grp": grp or "", + "stg": stg, + "verbands": verbands, + "person": person, + "events": events, + "events_json": events_to_json(events), + "meta": meta, + "semester": semester, + "sem_range": (datetime.fromtimestamp(s["begin"]), datetime.fromtimestamp(s["ende"])), + "init_week": init_week, + "tab": tab if tab in ("week", "sem") else "week", + } + + +@app.get("/plan", response_class=HTMLResponse) +def plan(request: Request, + uid: str = Query("", description="Matrikelnummer"), + semester: str = Query(""), + week: str = Query("", description="Woche als YYYY-MM-DD (Monatag)"), + tab: str = Query("week", description="week|sem")): + return templates.TemplateResponse(request, "plan.html", _plan_common( + request, "student", uid, "", "", "", "", semester, week, tab)) + + +@app.get("/stg", response_class=HTMLResponse) +def stg_page(request: Request, + kz: str = Query("640"), + sem: str = Query("1"), + ver: str = Query("V"), + grp: str = Query(""), + semester: str = Query(""), + week: str = Query(""), + tab: str = Query("week", description="week|sem")): + return templates.TemplateResponse(request, "plan.html", _plan_common( + request, "stg", "", kz, sem, ver, grp, semester, week, tab)) + + +def verband_label(v: str) -> str: + return {"V": " (Vollzeit)", "B": " (berufsbegl.)"}.get(v, "") + + +templates.env.globals["verband_label"] = verband_label + + +@app.get("/liste", response_class=HTMLResponse) +def liste(request: Request, + kz: str = Query("640"), + sem: str = Query("1"), + ver: str = Query("V")): + meta = _meta() + students = client.namensliste(kz, sem, ver) + stg = next((x for x in meta["stgs"] if x["kz"] == kz), None) + verbands = client.verbands_for(kz, sem) + students_json = [{"nachname": s.nachname, "vorname": s.vorname, + "geschlecht": s.geschlecht, "matrikel": s.matrikel, + "mail": s.mail} for s in students] + return templates.TemplateResponse(request, "liste.html", { + "request": request, + "meta": meta, + "kz": kz, + "sem": sem, + "ver": ver, + "verbands": verbands, + "stg": stg, + "students": students, + "students_json": students_json, + }) + + +@app.get("/api/meta") +def api_meta(): + return _meta() + + +@app.get("/api/semesters_for_stg") +def api_semesters_for_stg(kz: str = Query(...)): + """Study semesters that exist for a StG (from CIS autocomplete endpoint).""" + try: + r = client.s.post(f"{BASE}/private/lvplan/lvplan_autocomplete.php", + data={"autocomplete": "getSemester", "stg_kz": kz}, timeout=30) + return JSONResponse(r.json()) + except Exception: + return JSONResponse([]) + + +@app.get("/api/verbands") +def api_verbands(kz: str = Query(...), sem: str = Query("")): + return client.verbands_for(kz, sem) + + +@app.get("/api/detail") +def api_detail(kind: str = "student", datum: str = "", stunde: int = 0, + uid: str = "", kz: str = "", sem: str = "", ver: str = "", + grp: str = "", ort: str = ""): + if kind == "student" and not (kz and sem and ver): + p = client.person(uid) + if p: + if not kz: + code = p.stg.split(" - ")[0].strip() + stg = next((x for x in _meta()["stgs"] if x["code"] == code), None) + kz = stg["kz"] if stg else "" + sem = sem or p.semester + ver = ver or p.verband + grp = grp or p.gruppe + try: + rows = client.detail(kind, datum, stunde, uid, kz, sem, ver, grp, ort) + return {"rows": rows} + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=502) + + +@app.get("/export/ical") +def export_ical(kind: str = "student", + uid: str = Query(""), kz: str = Query(""), + sem: str = Query(""), ver: str = Query(""), grp: str = Query(""), + semester: str = Query("")): + meta = _meta() + s = next((x for x in meta["semesters"] if x["label"] == semester), + next((x for x in meta["semesters"] + if x["begin"] <= datetime.now().timestamp() <= x["ende"]), + meta["semesters"][0])) + if kind == "student": + ical = client.ical_raw("student", uid=uid or OWN_UID, begin=s["begin"], ende=s["ende"]) + fname = f"lvplan_{uid or OWN_UID}_{s['label']}.ics" + else: + ical = client.ical_raw("stg", kz=kz, sem=sem, ver=ver, grp=grp, + begin=s["begin"], ende=s["ende"]) + fname = f"lvplan_{kz}_{sem}_{ver}_{s['label']}.ics" + return Response(ical, media_type="text/calendar", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}) + + +@app.get("/health") +def health(): + return {"ok": True} diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..738a81b --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,409 @@ +/* CIS Enhanced - client logic */ +"use strict"; + +/* ================= helpers ================= */ +const $ = (sel) => document.querySelector(sel); +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", +}[c])); + +function lvColor(lv) { + let h = 0; + for (const c of lv) h = (h * 31 + c.charCodeAt(0)) % 997; + const hue = (h * 1.375) % 360; + return `hsl(${hue}, 52%, 42%)`; +} + +function fmtTime(d) { + return d.toTimeString().slice(0, 5); +} +function fmtDate(d) { + const months = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]; + return `${String(d.getDate()).padStart(2, "0")}.${months[d.getMonth()]}`; +} +function parseISO(s) { + const [date, time] = s.split("T"); + const [y, m, d] = date.split("-").map(Number); + const [hh, mm, ss] = time.split(":").map(Number); + return new Date(y, m - 1, d, hh, mm, ss || 0); +} +function addDays(d, n) { + const r = new Date(d); + r.setDate(r.getDate() + n); + return r; +} +function sameDay(a, b) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +/* ================= plan view ================= */ +const PX_PER_HOUR = 48; +const DAY_START_H = 8; +let plan = null; +let weekStart = null; +let gruppeFilter = ""; + +function visibleEvents() { + if (!gruppeFilter) return plan.events; + return plan.events.filter((e) => e.gruppe === gruppeFilter); +} + +function setGruppeFilter(v) { + gruppeFilter = v; + renderWeek(); + renderSemester(); +} + +function initPlan() { + plan = window.__PLAN_DATA__; + plan.events = plan.events.map((e) => ({ ...e, s: parseISO(e.start), e2: parseISO(e.end) })); + weekStart = parseISO(plan.initWeek + "T00:00:00"); + if (plan.kind === "stg") { + const sel = $("#gruppe-filter"); + const groups = [...new Set(plan.events.map((e) => e.gruppe).filter(Boolean))].sort(); + if (groups.length > 1) { + for (const g of groups) { + sel.insertAdjacentHTML("beforeend", ``); + } + sel.style.display = ""; + } + } + renderWeek(); + renderSemester(); + if (plan.tab === "sem") switchTab("sem"); +} + +function stundeOf(ev) { + const mins = ev.s.getHours() * 60 + ev.s.getMinutes(); + let best = 1; + plan.slots.forEach((slot, i) => { + const [hh, mm] = slot.split(":").map(Number); + if (hh * 60 + mm <= mins) best = i + 1; + }); + return best; +} + +function weekEnd() { + let endH = 18; + for (const e of visibleEvents()) { + const h = e.e2.getHours() + (e.e2.getMinutes() / 60); + if (h > endH) endH = Math.ceil(h + 0.25); + } + return endH; +} + +function layoutDay(evs) { + evs.sort((a, b) => (a.s - b.s) || (b.e2 - a.e2)); + const clusters = []; + let cur = null, curEnd = null; + for (const e of evs) { + if (!cur || e.s >= curEnd) { + cur = []; + clusters.push(cur); + curEnd = e.e2; + } else { + curEnd = Math.max(curEnd, e.e2); + } + cur.push(e); + } + for (const cluster of clusters) { + const cols = []; + for (const e of cluster) { + let placed = false; + for (let i = 0; i < cols.length; i++) { + if (cols[i][cols[i].length - 1].e2 <= e.s) { + cols[i].push(e); + e._col = i; + placed = true; + break; + } + } + if (!placed) { + e._col = cols.length; + cols.push([e]); + } + } + for (const e of cluster) e._cols = cols.length; + } +} + +function renderWeek() { + const grid = $("#week-grid"); + if (!grid) return; + const endH = weekEnd(); + const height = (endH - DAY_START_H) * PX_PER_HOUR; + const today = new Date(); + const monday = weekStart; + const sunday = addDays(monday, 6); + + const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]; + const names = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; + let html = `
`; + for (let i = 0; i < 7; i++) { + const d = addDays(monday, i); + const isToday = sameDay(d, today) ? "today" : ""; + const we = i >= 5 ? "weekend" : ""; + const monthLabel = (i === 0 || d.getDate() <= 7) ? ` ${MONTHS[d.getMonth()]}` : ""; + html += `
${names[i]}${d.getDate()}${monthLabel}
`; + } + html += `
`; + for (let h = DAY_START_H; h <= endH; h++) { + const top = (h - DAY_START_H) * PX_PER_HOUR; + html += `${String(h).padStart(2, "0")}:00`; + } + html += `
`; + for (let i = 0; i < 7; i++) { + const d = addDays(monday, i); + const we = i >= 5 ? "weekend" : ""; + html += `
`; + for (let m = 0; m < (endH - DAY_START_H) * 60; m += 30) { + const top = (m / 60) * PX_PER_HOUR; + if (m % 60 === 0 && m > 0) continue; + html += `
`; + } + const dayEvents = visibleEvents().filter((ev) => ev.s >= d && ev.s < addDays(d, 1) && ev.s.getHours() >= DAY_START_H); + layoutDay(dayEvents); + for (const ev of dayEvents) { + const top = ((ev.s.getHours() + ev.s.getMinutes() / 60) - DAY_START_H) * PX_PER_HOUR; + const dur = (ev.e2 - ev.s) / 3600000; + const hgt = Math.max(dur * PX_PER_HOUR, 14); + const w = 100 / (ev._cols || 1); + const l = (ev._col || 0) * w; + const pay = { lv: ev.lv, lektor: ev.lektor, gruppe: ev.gruppe, ort: ev.ort, + start: ev.start, end: ev.end, datum: ev.start.slice(0, 10) }; + html += ``; + } + html += `
`; + } + grid.style.gridTemplateRows = `auto ${height}px`; + grid.innerHTML = html; + + $("#week-label").textContent = `KW ${isoWeek(monday)}`; + $("#week-range").textContent = `${fmtDate(monday)} - ${fmtDate(sunday)} ${sunday.getFullYear()}`; + renderLegend(); +} + +function isoWeek(d) { + const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + const dayNum = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + return Math.ceil((((date - yearStart) / 86400000) + 1) / 7); +} + +function renderLegend() { + const lvs = [...new Set(visibleEvents().map((e) => e.lv))]; + $("#legend").innerHTML = lvs.map((lv) => + `${esc(lv)}`).join(""); +} + +function renderSemester() { + const byLv = {}; + for (const e of visibleEvents()) { + if (!byLv[e.lv]) byLv[e.lv] = { lv: e.lv, lektors: new Set(), gruppens: new Set(), orte: new Set(), n: 0, hours: 0, first: e.s, last: e.s }; + const g = byLv[e.lv]; + g.lektors.add(e.lektor); + if (e.gruppe) g.gruppens.add(e.gruppe); + if (e.ort) g.orte.add(e.ort); + g.n++; + g.hours += (e.e2 - e.s) / 3600000; + if (e.s < g.first) g.first = e.s; + if (e.s > g.last) g.last = e.s; + } + const rows = Object.values(byLv).sort((a, b) => a.lv.localeCompare(b.lv)); + $("#sem-lv").innerHTML = ` +

Lehrveranstaltungen

+ ${rows.length ? ` + + + + ${rows.map((g) => ` + + + + + + + + `).join("")} +
LVLektorInGruppeOrteTermineStd.Zeitraum
${esc(g.lv)}${esc([...g.lektors].join(", "))}${esc([...g.gruppens].join(", ")) || "-"}${esc([...g.orte].join(", ")) || "-"}${g.n}${g.hours.toFixed(1)}${fmtDate(g.first)} - ${fmtDate(g.last)} ${g.last.getFullYear()}
` : `
Keine Termine in diesem Semester.
`}`; + + const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]; + $("#sem-events").innerHTML = ` + + + + ${visibleEvents().map((e) => ` + + + + + + + + `).join("")} +
DatumKWZeitLVLektorInGruppeOrt
${fmtDate(e.s)} ${e.s.getFullYear()}${names[e.s.getDay()]} / ${isoWeek(e.s)}${fmtTime(e.s)} - ${fmtTime(e.e2)}${esc(e.lv)}${esc(e.lektor)}${esc(e.gruppe) || "-"}${esc(e.ort)}
`; +} + +function shiftWeek(n) { + weekStart = addDays(weekStart, n * 7); + renderWeek(); +} +function goToday() { + const t = new Date(); + weekStart = addDays(t, -((t.getDay() + 6) % 7)); + renderWeek(); +} +function switchTab(which) { + $("#tab-week").classList.toggle("active", which === "week"); + $("#tab-sem").classList.toggle("active", which === "sem"); + $("#pane-week").classList.toggle("active", which === "week"); + $("#pane-sem").classList.toggle("active", which === "sem"); +} + +/* ---------- detail modal ---------- */ +async function openEvent(ev) { + const modal = $("#detail-modal"); + modal.classList.add("open"); + const body = $("#detail-body"); + body.innerHTML = `

Lade Details vom CIS...

`; + const stunde = stundeOf({ s: parseISO(ev.start) }); + const params = new URLSearchParams({ + kind: plan.kind, datum: ev.datum, stunde, ort: ev.ort, + }); + if (plan.kind === "student") { + params.set("uid", plan.uid); + } else { + params.set("kz", plan.kz); + params.set("sem", plan.sem); + params.set("ver", plan.ver); + params.set("grp", plan.grp); + } + try { + const r = await fetch("/api/detail?" + params.toString()); + const j = await r.json(); + if (j.error) throw new Error(j.error); + const rows = j.rows || {}; + body.innerHTML = ` + + + + + ${rows["LektorIn"] ? `` : ""} + ${rows["Ort"] ? `` : ""} + ${rows["Lehrfach"] ? `` : ""} + ${rows["Bezeichnung"] ? `` : ""} + ${rows["Verband"] ? `` : ""} + ${rows["Info"] ? `` : ""} +
Datum / Stunde${esc(ev.datum)} / Stunde ${esc(stunde)}
LV-Code${esc(ev.lv)}
Zeit${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}
LektorIn${esc(rows["LektorIn"])}
Ort${esc(rows["Ort"])}
Lehrfach${esc(rows["Lehrfach"])}
Bezeichnung${esc(rows["Bezeichnung"])}
Gruppe${esc(rows["Verband"])}
Info${esc(rows["Info"])}
`; + } catch (e) { + body.innerHTML = `

Details konnten nicht geladen werden: ${esc(e.message)}

+ + + + + + + +
Datum${esc(ev.datum)}
Zeit${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}
LV${esc(ev.lv)}
LektorIn${esc(ev.lektor)}
Gruppe${esc(ev.gruppe)}
Ort${esc(ev.ort)}
`; + } +} +function closeDetail() { + $("#detail-modal").classList.remove("open"); +} + +/* ---------- CSV ---------- */ +function downloadCSV() { + const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]; + let csv; + if (window.__PLAN_DATA__) { + csv = "Datum;Wochentag;Beginn;Ende;LV;Lektor;Gruppe;Ort\n" + plan.events.map((e) => + [e.start.slice(0, 10), names[e.s.getDay()], fmtTime(e.s), fmtTime(e.e2), + e.lv, e.lektor, e.gruppe, e.ort].map((x) => `"${String(x).replace(/"/g, '""')}"`).join(";") + ).join("\n"); + } else if (window.__LIST_DATA__) { + csv = "Nr;Nachname;Vorname;Geschlecht;Matrikelnummer;Mail\n" + window.__LIST_DATA__.map((s, i) => + [i + 1, s.nachname, s.vorname, s.geschlecht, s.matrikel, s.mail].map((x) => `"${String(x).replace(/"/g, '""')}"`).join(";") + ).join("\n"); + } else return; + const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = (window.__PLAN_DATA__ ? `lvplan_${window.__PLAN_DATA__.semester}` : `namensliste`) + ".csv"; + a.click(); + URL.revokeObjectURL(a.href); +} + +/* ================= Namensliste ================= */ +function filterList() { + const q = ($("#list-search").value || "").toLowerCase().trim(); + let n = 0; + for (const tr of $("#list-table").querySelectorAll("tbody tr")) { + const hay = (tr.dataset.search || "").toLowerCase(); + const show = !q || hay.includes(q); + tr.style.display = show ? "" : "none"; + if (show) n++; + } + const c = $("#student-count"); + if (c) c.textContent = q ? `${n}` : window.__LIST_COUNT__ || n; +} + +/* ================= Matrikel decoder (home) ================= */ +function decodeMat() { + const m = $("#decode-input").value.trim(); + const box = $("#decode-result"); + if (!/^\d{9,10}$/.test(m)) { + box.classList.remove("show"); + return; + } + const stgKz = m.slice(-6, -3); + const nr = m.slice(-3); + const year = m.slice(0, 2); + const code = m.slice(2, 4); + let start = `20${year}`; + if (code === "10") start = `WS 20${year}`; + else if (code === "20") start = `SS 20${year}`; + const stg = (window.__STGS__ || []).find((s) => s.kz === stgKz); + box.innerHTML = ` +
+
Startsemester
${esc(start)}
+
Studiengang
+
${stg ? `${esc(stg.code)} - ${esc(stg.name)}` : `KZ ${esc(stgKz)}`}
+
Fortlaufnummer
${esc(nr)}
+
Plan ansehen
+
+
`; + box.classList.add("show"); +} + +/* ================= Verband-Selects dynamisch ================= */ +async function reloadVerbands() { + const sel = $("#ver-select"); + if (!sel) return; + const kz = $("#stg-select").value; + const sem = $("#sem-select").value; + const current = sel.value; + try { + const r = await fetch(`/api/verbands?kz=${encodeURIComponent(kz)}&sem=${encodeURIComponent(sem)}`); + if (!r.ok) return; + const verbs = await r.json(); + if (!Array.isArray(verbs) || !verbs.length) return; + const labels = { V: " (Vollzeit)", B: " (berufsbegl.)" }; + sel.innerHTML = verbs.map((v) => + ``).join(""); + if (current && !verbs.includes(current)) sel.selectedIndex = 0; + } catch (e) { /* Keep static options */ } +} + +/* ================= boot ================= */ +document.addEventListener("DOMContentLoaded", () => { + if (window.__PLAN_DATA__) initPlan(); + if (window.__STGS__) decodeMat(); +}); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeDetail(); +}); diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..84b204f --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,199 @@ +:root { + --blue: #0a5c99; + --blue-dark: #083f6b; + --blue-light: #e8f1f9; + --ink: #1c2733; + --muted: #64748b; + --line: #e2e8f0; + --bg: #f5f7fa; + --card: #ffffff; + --green: #15803d; + --red: #b91c1c; + --radius: 10px; + --shadow: 0 1px 3px rgba(16, 42, 67, .08), 0 4px 14px rgba(16, 42, 67, .06); +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; + background: var(--bg); + color: var(--ink); + font-size: 15px; + line-height: 1.45; +} +a { color: var(--blue); text-decoration: none; } +a:hover { text-decoration: underline; } +.container { max-width: 1200px; margin: 0 auto; padding: 0 16px; } + +/* ---------- top bar ---------- */ +.topbar { background: var(--blue-dark); color: #fff; position: sticky; top: 0; z-index: 50; } +.topbar-inner { display: flex; align-items: center; justify-content: space-between; height: 56px; } +.brand { display: flex; align-items: center; gap: 10px; color: #fff; } +.brand:hover { text-decoration: none; } +.brand-badge { + background: #fff; color: var(--blue-dark); font-weight: 800; + padding: 3px 8px; border-radius: 6px; font-size: 14px; letter-spacing: .5px; +} +.brand-text { font-weight: 700; font-size: 17px; } +.brand-text small { font-weight: 400; opacity: .75; margin-left: 6px; font-size: 12px; } +.topbar nav { display: flex; gap: 4px; } +.topbar nav a { + color: rgba(255,255,255,.85); padding: 7px 12px; border-radius: 8px; font-size: 14px; +} +.topbar nav a:hover { background: rgba(255,255,255,.12); text-decoration: none; } +.topbar nav a.active { background: rgba(255,255,255,.18); color: #fff; font-weight: 600; } + +main.container { padding-top: 24px; padding-bottom: 48px; min-height: calc(100vh - 130px); } + +/* ---------- cards / panels ---------- */ +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; +} +.card + .card { margin-top: 16px; } +.card h2 { margin: 0 0 12px; font-size: 17px; } +.card h3 { margin: 16px 0 8px; font-size: 15px; } +.page-title { font-size: 24px; margin: 4px 0 20px; font-weight: 700; } +.page-sub { color: var(--muted); margin-top: -14px; margin-bottom: 20px; } + +/* ---------- forms ---------- */ +.form-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; } +.field { display: flex; flex-direction: column; gap: 4px; } +.field label { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; } +.field input, .field select { + padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; + font-size: 14px; background: #fff; min-width: 130px; color: var(--ink); +} +.field input:focus, .field select:focus { outline: 2px solid var(--blue-light); border-color: var(--blue); } +.btn { + display: inline-block; padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line); + background: #fff; color: var(--ink); font-size: 14px; font-weight: 600; cursor: pointer; +} +.btn:hover { background: var(--blue-light); text-decoration: none; border-color: var(--blue); } +.btn-primary { background: var(--blue); border-color: var(--blue); color: #fff; } +.btn-primary:hover { background: var(--blue-dark); } +.btn-sm { padding: 5px 10px; font-size: 13px; } + +/* ---------- tables ---------- */ +table.data { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; } +table.data th { + text-align: left; padding: 9px 10px; background: var(--blue-light); + color: var(--blue-dark); font-size: 12px; text-transform: uppercase; letter-spacing: .4px; + border-bottom: 2px solid var(--blue); +} +table.data td { padding: 8px 10px; border-bottom: 1px solid var(--line); } +table.data tr:hover td { background: #f8fafc; } +table.data .num { text-align: right; font-variant-numeric: tabular-nums; } +.badge { + display: inline-block; padding: 2px 9px; border-radius: 99px; font-size: 12px; + font-weight: 600; background: var(--blue-light); color: var(--blue-dark); +} +.badge-muted { background: #eef2f7; color: var(--muted); } +.count-pill { + display: inline-block; background: var(--blue); color: #fff; border-radius: 99px; + padding: 1px 10px; font-size: 13px; font-weight: 700; margin-left: 8px; +} + +/* ---------- week grid ---------- */ +.weekbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; } +.weekbar .week-label { font-weight: 700; font-size: 17px; } +.weekbar .week-range { color: var(--muted); font-size: 14px; } +.weekbar .nav-btns { display: flex; gap: 6px; } +.week-scroll { overflow-x: auto; } +.week-grid { + display: grid; min-width: 860px; + grid-template-columns: 56px repeat(7, 1fr); + border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; + background: #fff; +} +.wg-corner { background: var(--blue-light); } +.wg-dayhead { + padding: 8px 4px; text-align: center; font-size: 12px; font-weight: 700; + background: var(--blue-light); color: var(--blue-dark); border-left: 1px solid var(--line); +} +.wg-dayhead .d { display: block; font-size: 15px; } +.wg-dayhead.today { background: var(--blue); color: #fff; } +.wg-dayhead.weekend { background: #f1f5f9; color: var(--muted); } +.wg-dayhead.today.weekend { background: var(--blue); color: #fff; } +.wg-time { + font-size: 11px; color: var(--muted); text-align: right; padding-right: 6px; + border-top: 1px solid var(--line); height: 48px; position: relative; +} +.wg-time span { position: absolute; top: -7px; background: #fff; padding-right: 4px; } +.wg-col { position: relative; border-left: 1px solid var(--line); border-top: 1px solid var(--line); } +.wg-col.weekend { background: #fafbfc; } +.wg-slotline { position: absolute; left: 0; right: 0; border-top: 1px dashed #eef1f5; } +.ev { + position: absolute; left: 2px; right: 2px; border-radius: 6px; color: #fff; + font-size: 11.5px; padding: 3px 6px; overflow: hidden; cursor: pointer; + border: none; text-align: left; line-height: 1.25; min-height: 14px; +} +.ev:hover { filter: brightness(1.12); z-index: 5; } +.ev .t { font-weight: 700; display: block; } +.ev .sub { opacity: .9; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* ---------- legend ---------- */ +.legend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.legend .item { display: flex; align-items: center; gap: 6px; font-size: 12.5px; } +.legend .dot { width: 12px; height: 12px; border-radius: 3px; display: inline-block; } + +/* ---------- tabs ---------- */ +.tabs { display: flex; gap: 4px; border-bottom: 2px solid var(--line); margin-bottom: 18px; } +.tabs button { + background: none; border: none; padding: 10px 16px; font-size: 14px; font-weight: 600; + color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; +} +.tabs button.active { color: var(--blue); border-bottom-color: var(--blue); } +.tabpane { display: none; } +.tabpane.active { display: block; } + +/* ---------- misc ---------- */ +.empty { text-align: center; color: var(--muted); padding: 40px 0; } +.kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; } +.kv .item { background: var(--blue-light); border-radius: 8px; padding: 10px 14px; } +.kv .item .k { font-size: 11px; text-transform: uppercase; color: var(--blue-dark); font-weight: 700; letter-spacing: .4px; } +.kv .item .v { font-size: 15px; font-weight: 600; } +.mono { font-family: ui-monospace, Consolas, monospace; font-size: 13px; } + +/* ---------- modal ---------- */ +.modal-back { + position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100; + display: none; align-items: center; justify-content: center; padding: 20px; +} +.modal-back.open { display: flex; } +.modal { + background: #fff; border-radius: 12px; max-width: 520px; width: 100%; + padding: 22px; box-shadow: 0 20px 50px rgba(0,0,0,.25); +} +.modal h3 { margin: 0 0 12px; font-size: 18px; } +.modal .close { float: right; border: none; background: none; font-size: 22px; cursor: pointer; color: var(--muted); } +.modal table { width: 100%; font-size: 14px; border-collapse: collapse; } +.modal table td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } +.modal table td:first-child { color: var(--muted); width: 35%; } + +/* ---------- home ---------- */ +.hero { margin-bottom: 24px; } +.hero h1 { font-size: 28px; margin: 0 0 6px; } +.hero p { color: var(--muted); margin: 0; max-width: 720px; } +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; } +.tile { + background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); + box-shadow: var(--shadow); padding: 22px; display: block; color: var(--ink); + transition: transform .08s ease, border-color .08s ease; +} +.tile:hover { text-decoration: none; border-color: var(--blue); transform: translateY(-2px); } +.tile .icon { font-size: 26px; margin-bottom: 10px; } +.tile h3 { margin: 0 0 6px; font-size: 17px; color: var(--blue-dark); } +.tile p { margin: 0; color: var(--muted); font-size: 13.5px; } + +.decoder-result { margin-top: 14px; display: none; } +.decoder-result.show { display: block; } + +.footer { border-top: 1px solid var(--line); padding: 16px 0; color: var(--muted); font-size: 13px; background: #fff; } + +@media (max-width: 720px) { + .topbar-inner { flex-direction: column; height: auto; padding: 8px 0; gap: 6px; } + .topbar nav { flex-wrap: wrap; justify-content: center; } + .form-row { flex-direction: column; align-items: stretch; } + .field input, .field select { min-width: 0; width: 100%; } +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..925cd09 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,36 @@ + + + + + +{% block title %}CIS Enhanced{% endblock %} | FH-Burgenland + + + +{% set path = request.url.path %} +
+ +
+
+{% block content %}{% endblock %} +
+ +{% block scripts %}{% endblock %} + + + diff --git a/app/templates/home.html b/app/templates/home.html new file mode 100644 index 0000000..51da01b --- /dev/null +++ b/app/templates/home.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Start{% endblock %} +{% block content %} +
+

Willkommen beim CIS Enhanced

+

Bessere Ansichten für deinen Lehrveranstaltungsplan, Studiengangs-Pläne und Namenslisten - + basierend auf den Daten des FH-Burgenland-CIS.

+
+ + + +
+

Studiennummer decoder

+

Eine FH-Burgenland-Nummer wie 2410640055 + encodiert Startsemester, Studiengang und Fortlaufnummer:

+
+
+ + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/liste.html b/app/templates/liste.html new file mode 100644 index 0000000..a81fba9 --- /dev/null +++ b/app/templates/liste.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% block title %}Namensliste{% endblock %} +{% block content %} +

Namensliste + {% if stg %}{{ stg.code }} - {{ stg.name }}{% endif %} + {{ ver == 'V' and 'Vollzeit' or ver }} +

+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

+ Studierende, Semester {{ sem }}, Verband {{ ver }} + {{ students | length }} +

+
+
+ + +
+ +
+
+ + + + + + + + + + + + + + {% for st in students %} + {% set dec = decode_matrikel(st.matrikel, meta.stgs) %} + + + + + + + + + + {% else %} + + {% endfor %} + +
#NachnameVornameGeschlechtMatrikelnummerStartMail
{{ loop.index }}{{ st.nachname }}{{ st.vorname }}{{ st.geschlecht }} + {% if dec %} + {{ st.matrikel }} + {% else %} + {{ st.matrikel }} + {% endif %} + {% if dec %}{{ dec.start }}{% else %}-{% endif %}{{ st.mail }}
Keine Studierenden gefunden (Semester/Verband prüfen - nicht jede Kombination existiert).
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/plan.html b/app/templates/plan.html new file mode 100644 index 0000000..90ec9a6 --- /dev/null +++ b/app/templates/plan.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} +{% block title %}{% if kind == 'student' %}Studentenplan{% else %}Studiengang-Plan{% endif %}{% endblock %} +{% block content %} +

+ {% if kind == 'student' %} + Studentenplan + {% if person %}{{ person.name }}{% endif %} + {% else %} + Studiengang-Plan + {% if stg %}{{ stg.code }} - {{ stg.name }}{% endif %} + {% endif %} + {{ semester }} +

+ +
+
+ {% if kind == 'student' %} +
+ + +
+ {% if uid and uid | length >= 9 %} + {% set dec = decode_matrikel(uid, meta.stgs) %} + {% if dec %} +
+ +
+ Start: {{ dec.start }} + {% if dec.stg_code %} · {{ dec.stg_code }} - {{ dec.stg_name }}{% endif %} + · Nr. {{ dec.nr }} +
+
+ {% endif %} + {% endif %} + {% else %} +
+ + +
+
+ + +
+
+ + +
+ {% endif %} +
+ + +
+ + iCal export + +
+
+ +{% if kind == 'student' and person %} +
+
+
Person
{{ person.name }}
+
Matrikelnummer
{{ person.uid }}
+
Studiengang
{{ person.stg }}
+
Semester / Verband
{{ person.semester }} / {{ person.verband }}{% if person.gruppe %} / Grp. {{ person.gruppe }}{% endif %}
+
+
+{% endif %} + +
+
+ + +
+
+
+
+ + +
+ +
+
+
+
+
+
+

Alle Termine

+
+
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/util.py b/app/util.py new file mode 100644 index 0000000..0ac47b8 --- /dev/null +++ b/app/util.py @@ -0,0 +1,76 @@ +"""Shared helpers.""" +import re +from datetime import date, datetime, timedelta + +DAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] +MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"] + + +def week_start(d: date) -> date: + return d - timedelta(days=d.weekday()) + + +def week_label(d: date) -> str: + iso = d.isocalendar() + return f"KW {iso[1]}" + + +def fmt_day(d: date) -> str: + return f"{d.day:02d}.{MONTHS[d.month - 1]}" + + +def fmt_range(d1: date, d2: date) -> str: + return f"{fmt_day(d1)} - {fmt_day(d2)} {d2.year}" + + +def current_semester(semesters: list[dict], now: datetime | None = None) -> str: + now = now or datetime.now() + ts = now.replace(tzinfo=None).timestamp() + for s in semesters: + if s["begin"] <= ts <= s["ende"]: + return s["label"] + # fallback: most recent past semester + past = [s for s in semesters if s["ende"] >= ts] + if past: + return past[0]["label"] + return semesters[0]["label"] if semesters else "" + + +def decode_matrikel(m: str, stgs: list[dict]) -> dict | None: + """Decode FH-Burgenland matrikel: YY + startcode + StG(3) + nr(3).""" + m = m.strip() + if not re.fullmatch(r"\d{9,10}", m): + return None + stg_code = m[-6:-3] + nr = m[-3:] + year = m[:2] + startcode = m[2:4] if len(m) == 10 else "" + start = None + if startcode == "10": + start = f"WS 20{year}" + elif startcode == "20": + start = f"SS 20{year}" + stg = next((s for s in stgs if s["kz"] == stg_code), None) + return { + "matrikel": m, + "start": start or f"20{year}", + "stg_kz": stg_code, + "stg_code": stg["code"] if stg else None, + "stg_name": stg["name"] if stg else None, + "nr": int(nr), + } + + +def lv_color(lv: str) -> str: + """Stable hue for an LV code.""" + h = sum(ord(c) for c in lv) * 47 + hue = h % 360 + return f"hsl({hue}, 55%, 45%)" + + +def events_to_json(events) -> list[dict]: + return [{ + "lv": e.lv, "lektor": e.lektor, "gruppe": e.gruppe, "ort": e.ort, + "start": e.start.isoformat(), "end": e.end.isoformat(), + "uid": e.uid, + } for e in events] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..882a47f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + cis-enhanced: + build: . + container_name: cis-enhanced + env_file: .env + ports: + - "8000:8000" + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1b3856e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn[standard] +requests +beautifulsoup4 +jinja2 +python-dotenv diff --git a/run.py b/run.py new file mode 100644 index 0000000..4753965 --- /dev/null +++ b/run.py @@ -0,0 +1,4 @@ +import uvicorn + +if __name__ == "__main__": + uvicorn.run("app.main:app", host="127.0.0.1", port=8000, reload=True)