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
This commit is contained in:
Nicolas Gruber
2026-10-15 16:27:52 +02:00
commit 32b4dc4823
19 changed files with 1655 additions and 0 deletions
View File
+276
View File
@@ -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)}
+226
View File
@@ -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}
+409
View File
@@ -0,0 +1,409 @@
/* CIS Enhanced - client logic */
"use strict";
/* ================= helpers ================= */
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[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", `<option value="${esc(g)}">${esc(g)}</option>`);
}
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 = `<div class="wg-corner"></div>`;
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 += `<div class="wg-dayhead ${isToday} ${we}">${names[i]}<span class="d">${d.getDate()}</span>${monthLabel}</div>`;
}
html += `<div class="wg-time" style="height:${height}px">`;
for (let h = DAY_START_H; h <= endH; h++) {
const top = (h - DAY_START_H) * PX_PER_HOUR;
html += `<span style="top:${top}px">${String(h).padStart(2, "0")}:00</span>`;
}
html += `</div>`;
for (let i = 0; i < 7; i++) {
const d = addDays(monday, i);
const we = i >= 5 ? "weekend" : "";
html += `<div class="wg-col ${we}" style="height:${height}px" data-day="${i}">`;
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 += `<div class="wg-slotline" style="top:${top}px"></div>`;
}
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 += `<button class="ev" style="top:${top + 1}px;height:${hgt - 2}px;background:${lvColor(ev.lv)};left:calc(${l}% + 1px);width:calc(${w}% - 3px);right:auto"
onclick='openEvent(${JSON.stringify(pay).replace(/'/g, "&#39;")})'>
<span class="t">${esc(ev.lv)}</span>
${hgt > 34 ? `<span class="sub">${esc(ev.ort)}${ev.gruppe ? " · " + esc(ev.gruppe) : ""}</span>` : ""}
</button>`;
}
html += `</div>`;
}
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) =>
`<span class="item"><span class="dot" style="background:${lvColor(lv)}"></span>${esc(lv)}</span>`).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 = `
<h3>Lehrveranstaltungen</h3>
${rows.length ? `<table class="data">
<thead><tr><th>LV</th><th>LektorIn</th><th>Gruppe</th><th>Orte</th>
<th class="num">Termine</th><th class="num">Std.</th><th>Zeitraum</th></tr></thead>
<tbody>
${rows.map((g) => `<tr>
<td><span class="dot" style="display:inline-block;width:10px;height:10px;border-radius:3px;background:${lvColor(g.lv)};margin-right:6px"></span><b>${esc(g.lv)}</b></td>
<td>${esc([...g.lektors].join(", "))}</td>
<td>${esc([...g.gruppens].join(", ")) || "-"}</td>
<td>${esc([...g.orte].join(", ")) || "-"}</td>
<td class="num">${g.n}</td>
<td class="num">${g.hours.toFixed(1)}</td>
<td>${fmtDate(g.first)} - ${fmtDate(g.last)} ${g.last.getFullYear()}</td>
</tr>`).join("")}
</tbody></table>` : `<div class="empty">Keine Termine in diesem Semester.</div>`}`;
const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
$("#sem-events").innerHTML = `
<table class="data">
<thead><tr><th>Datum</th><th>KW</th><th>Zeit</th><th>LV</th><th>LektorIn</th><th>Gruppe</th><th>Ort</th></tr></thead>
<tbody>
${visibleEvents().map((e) => `<tr>
<td>${fmtDate(e.s)} ${e.s.getFullYear()}</td>
<td>${names[e.s.getDay()]} / ${isoWeek(e.s)}</td>
<td>${fmtTime(e.s)} - ${fmtTime(e.e2)}</td>
<td><b>${esc(e.lv)}</b></td>
<td>${esc(e.lektor)}</td>
<td>${esc(e.gruppe) || "-"}</td>
<td>${esc(e.ort)}</td>
</tr>`).join("")}
</tbody></table>`;
}
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 = `<p style="color:var(--muted)">Lade Details vom CIS...</p>`;
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 = `
<table>
<tr><td>Datum / Stunde</td><td>${esc(ev.datum)} / Stunde ${esc(stunde)}</td></tr>
<tr><td>LV-Code</td><td><b>${esc(ev.lv)}</b></td></tr>
<tr><td>Zeit</td><td>${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}</td></tr>
${rows["LektorIn"] ? `<tr><td>LektorIn</td><td>${esc(rows["LektorIn"])}</td></tr>` : ""}
${rows["Ort"] ? `<tr><td>Ort</td><td>${esc(rows["Ort"])}</td></tr>` : ""}
${rows["Lehrfach"] ? `<tr><td>Lehrfach</td><td>${esc(rows["Lehrfach"])}</td></tr>` : ""}
${rows["Bezeichnung"] ? `<tr><td>Bezeichnung</td><td>${esc(rows["Bezeichnung"])}</td></tr>` : ""}
${rows["Verband"] ? `<tr><td>Gruppe</td><td>${esc(rows["Verband"])}</td></tr>` : ""}
${rows["Info"] ? `<tr><td>Info</td><td>${esc(rows["Info"])}</td></tr>` : ""}
</table>`;
} catch (e) {
body.innerHTML = `<p style="color:var(--red)">Details konnten nicht geladen werden: ${esc(e.message)}</p>
<table>
<tr><td>Datum</td><td>${esc(ev.datum)}</td></tr>
<tr><td>Zeit</td><td>${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}</td></tr>
<tr><td>LV</td><td><b>${esc(ev.lv)}</b></td></tr>
<tr><td>LektorIn</td><td>${esc(ev.lektor)}</td></tr>
<tr><td>Gruppe</td><td>${esc(ev.gruppe)}</td></tr>
<tr><td>Ort</td><td>${esc(ev.ort)}</td></tr>
</table>`;
}
}
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 = `
<div class="kv">
<div class="item" style="background:#fff"><div class="k">Startsemester</div><div class="v">${esc(start)}</div></div>
<div class="item" style="background:#fff"><div class="k">Studiengang</div>
<div class="v">${stg ? `${esc(stg.code)} - ${esc(stg.name)}` : `KZ ${esc(stgKz)}`}</div></div>
<div class="item" style="background:#fff"><div class="k">Fortlaufnummer</div><div class="v">${esc(nr)}</div></div>
<div class="item" style="background:#fff"><div class="k">Plan ansehen</div>
<div class="v"><a class="btn btn-sm" href="/plan?uid=${esc(m)}" style="margin-top:2px">Studentenplan &rarr;</a></div></div>
</div>`;
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) =>
`<option value="${esc(v)}"${v === current ? " selected" : ""}>${esc(v)}${labels[v] || ""}</option>`).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();
});
+199
View File
@@ -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%; }
}
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}CIS Enhanced{% endblock %} | FH-Burgenland</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
{% set path = request.url.path %}
<header class="topbar">
<div class="container topbar-inner">
<a class="brand" href="/">
<span class="brand-badge">CIS</span>
<span class="brand-text">Enhanced <small>FH-Burgenland</small></span>
</a>
<nav>
<a href="/plan" class="{{ 'active' if path.startswith('/plan') }}">Studentenplan</a>
<a href="/stg" class="{{ 'active' if path.startswith('/stg') }}">Studiengänge</a>
<a href="/liste" class="{{ 'active' if path.startswith('/liste') }}">Namenslisten</a>
<a href="/" class="{{ 'active' if path == '/' }}">Start</a>
</nav>
</div>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<div class="container">
<span>CIS Enhanced · Datenquelle: <a href="https://cis.hochschule-burgenland.at/cis" target="_blank">cis.hochschule-burgenland.at</a></span>
</div>
</footer>
{% block scripts %}{% endblock %}
<script src="/static/app.js"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Start{% endblock %}
{% block content %}
<div class="hero">
<h1>Willkommen beim CIS Enhanced</h1>
<p>Bessere Ansichten für deinen Lehrveranstaltungsplan, Studiengangs-Pläne und Namenslisten -
basierend auf den Daten des FH-Burgenland-CIS.</p>
</div>
<div class="tiles">
<a class="tile" href="/plan">
<div class="icon">&#128197;</div>
<h3>Studentenplan</h3>
<p>Dein eigener Plan oder der eines anderen Studierenden (Matrikelnummer eingeben),
Woche für Woche übersichtlich - inkl. iCal-Export.</p>
</a>
<a class="tile" href="/stg">
<div class="icon">&#127979;</div>
<h3>Studiengangs-Pläne</h3>
<p>Ganzer Studiengang nach Studien-Semester, Verband (Vollzeit/Berufsbegleitend)
und Gruppe - direkt aus dem CIS, ohne Archiv-Dates.</p>
</a>
<a class="tile" href="/liste">
<div class="icon">&#128101;</div>
<h3>Namenslisten</h3>
<p>Teilnehmerlisten je Studiengang, Semester und Verband - mit Suche und CSV-Export.</p>
</a>
</div>
<div class="card" style="margin-top:16px">
<h2>Studiennummer decoder</h2>
<p style="color:var(--muted); margin-top:0">Eine FH-Burgenland-Nummer wie <span class="mono">2410640055</span>
encodiert Startsemester, Studiengang und Fortlaufnummer:</p>
<form class="form-row" id="decode-form">
<div class="field">
<label>Matrikelnummer</label>
<input type="text" id="decode-input" value="{{ own_uid }}" placeholder="z.B. 2410640055"
oninput="decodeMat()">
</div>
</form>
<div class="decoder-result card" id="decode-result" style="background:var(--blue-light); box-shadow:none"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
window.__STGS__ = {{ stgs | tojson }};
window.__SEMESTERS__ = {{ semesters | tojson }};
</script>
{% endblock %}
+97
View File
@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}Namensliste{% endblock %}
{% block content %}
<h1 class="page-title">Namensliste
{% if stg %}<span class="badge">{{ stg.code }} - {{ stg.name }}</span>{% endif %}
<span class="badge badge-muted">{{ ver == 'V' and 'Vollzeit' or ver }}</span>
</h1>
<div class="card">
<form class="form-row" method="get" action="/liste">
<div class="field">
<label>Studiengang</label>
<select name="kz" id="stg-select" required onchange="reloadVerbands()">
{% for s in meta.stgs %}
<option value="{{ s.kz }}" {{ 'selected' if s.kz == kz }}>{{ s.code }} - {{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Studien-Semester</label>
<select name="sem" id="sem-select" onchange="reloadVerbands()">
{% for n in range(1, 9) %}
<option value="{{ n }}" {{ 'selected' if (n | string) == sem }}>{{ n }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Verband</label>
<select name="ver" id="ver-select">
{% for v in verbands %}
<option value="{{ v }}" {{ 'selected' if v == ver }}>{{ v }}{{ verband_label(v) }}</option>
{% endfor %}
</select>
</div>
<button class="btn btn-primary" type="submit">Laden</button>
</form>
</div>
<div class="card">
<h2>
Studierende, Semester {{ sem }}, Verband {{ ver }}
<span class="count-pill" id="student-count">{{ students | length }}</span>
</h2>
<div class="form-row" style="margin-bottom:12px">
<div class="field" style="flex:1">
<label>&nbsp;</label>
<input type="text" id="list-search" placeholder="Suchen (Name oder Matrikelnummer)..."
oninput="filterList()" style="width:100%">
</div>
<button class="btn" onclick="downloadCSV()" title="Tabelle als CSV herunterladen">CSV export</button>
</div>
<div style="overflow-x:auto">
<table class="data" id="list-table">
<thead>
<tr>
<th>#</th>
<th>Nachname</th>
<th>Vorname</th>
<th>Geschlecht</th>
<th>Matrikelnummer</th>
<th>Start</th>
<th>Mail</th>
</tr>
</thead>
<tbody>
{% for st in students %}
{% set dec = decode_matrikel(st.matrikel, meta.stgs) %}
<tr data-search="{{ (st.nachname ~ ' ' ~ st.vorname ~ ' ' ~ st.matrikel) | lower }}">
<td class="num">{{ loop.index }}</td>
<td>{{ st.nachname }}</td>
<td>{{ st.vorname }}</td>
<td>{{ st.geschlecht }}</td>
<td class="mono">
{% if dec %}
<a href="/plan?uid={{ st.matrikel }}" title="Plan aufrufen">{{ st.matrikel }}</a>
{% else %}
{{ st.matrikel }}
{% endif %}
</td>
<td>{% if dec %}<span class="badge badge-muted">{{ dec.start }}</span>{% else %}-{% endif %}</td>
<td><a href="mailto:{{ st.mail }}">{{ st.mail }}</a></td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">Keine Studierenden gefunden (Semester/Verband prüfen - nicht jede Kombination existiert).</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
window.__LIST_DATA__ = {{ students_json | tojson }};
window.__LIST_COUNT__ = {{ students | length }};
</script>
{% endblock %}
+142
View File
@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block title %}{% if kind == 'student' %}Studentenplan{% else %}Studiengang-Plan{% endif %}{% endblock %}
{% block content %}
<h1 class="page-title">
{% if kind == 'student' %}
Studentenplan
{% if person %}<span class="badge">{{ person.name }}</span>{% endif %}
{% else %}
Studiengang-Plan
{% if stg %}<span class="badge">{{ stg.code }} - {{ stg.name }}</span>{% endif %}
{% endif %}
<span class="badge badge-muted">{{ semester }}</span>
</h1>
<div class="card">
<form class="form-row" method="get" action="/{{ kind if kind != 'student' else 'plan' }}">
{% if kind == 'student' %}
<div class="field">
<label>Matrikelnummer</label>
<input type="text" name="uid" value="{{ uid }}" placeholder="z.B. 2410640055" required>
</div>
{% if uid and uid | length >= 9 %}
{% set dec = decode_matrikel(uid, meta.stgs) %}
{% if dec %}
<div class="field">
<label>&nbsp;</label>
<div class="badge badge-muted" style="margin-bottom:24px">
Start: {{ dec.start }}
{% if dec.stg_code %} · {{ dec.stg_code }} - {{ dec.stg_name }}{% endif %}
· Nr. {{ dec.nr }}
</div>
</div>
{% endif %}
{% endif %}
{% else %}
<div class="field">
<label>Studiengang</label>
<select name="kz" id="stg-select" required onchange="reloadVerbands()">
{% for s in meta.stgs %}
<option value="{{ s.kz }}" {{ 'selected' if s.kz == kz }}>{{ s.code }} - {{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Studien-Sem.</label>
<select name="sem" id="sem-select" onchange="reloadVerbands()">
{% for n in range(1, 9) %}
<option value="{{ n }}" {{ 'selected' if (n | string) == sem }}>{{ n }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Verband</label>
<select name="ver" id="ver-select">
{% for v in verbands %}
<option value="{{ v }}" {{ 'selected' if v == ver }}>{{ v }}{{ verband_label(v) }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="field">
<label>Semester</label>
<select name="semester">
{% for s in meta.semesters %}
<option value="{{ s.label }}" {{ 'selected' if s.label == semester }}>{{ s.label }}</option>
{% endfor %}
</select>
</div>
<button class="btn btn-primary" type="submit">Laden</button>
<a class="btn" href="/export/ical?kind={{ kind }}{% if kind == 'student' %}&uid={{ uid }}{% else %}&kz={{ kz }}&sem={{ sem }}&ver={{ ver }}{% endif %}&semester={{ semester }}"
title="iCal-Download (Google, Outlook, Thunderbird, ...)">iCal export</a>
<button class="btn" onclick="downloadCSV()" title="Termine als CSV herunterladen">CSV export</button>
</form>
</div>
{% if kind == 'student' and person %}
<div class="card">
<div class="kv">
<div class="item"><div class="k">Person</div><div class="v">{{ person.name }}</div></div>
<div class="item"><div class="k">Matrikelnummer</div><div class="v mono">{{ person.uid }}</div></div>
<div class="item"><div class="k">Studiengang</div><div class="v">{{ person.stg }}</div></div>
<div class="item"><div class="k">Semester / Verband</div><div class="v">{{ person.semester }} / {{ person.verband }}{% if person.gruppe %} / Grp. {{ person.gruppe }}{% endif %}</div></div>
</div>
</div>
{% endif %}
<div class="card">
<div class="tabs">
<button id="tab-week" class="active" onclick="switchTab('week')">Wochenansicht</button>
<button id="tab-sem" onclick="switchTab('sem')">Semesterübersicht</button>
</div>
<div id="pane-week" class="tabpane active">
<div class="weekbar">
<div>
<span class="week-label" id="week-label"></span>
<span class="week-range" id="week-range"></span>
</div>
<div class="nav-btns">
<select id="gruppe-filter" onchange="setGruppeFilter(this.value)" style="display:none;margin-right:4px;padding:5px 8px;border:1px solid var(--line);border-radius:8px;font-size:13px">
<option value="">alle Gruppen</option>
</select>
<button class="btn btn-sm" onclick="shiftWeek(-1)">&#9664;</button>
<button class="btn btn-sm" onclick="goToday()">Heute</button>
<button class="btn btn-sm" onclick="shiftWeek(1)">&#9654;</button>
</div>
</div>
<div class="week-scroll"><div class="week-grid" id="week-grid"></div></div>
<div class="legend" id="legend"></div>
</div>
<div id="pane-sem" class="tabpane">
<div id="sem-lv"></div>
<h3>Alle Termine</h3>
<div id="sem-events"></div>
</div>
</div>
<div class="modal-back" id="detail-modal" onclick="if(event.target===this)closeDetail()">
<div class="modal">
<button class="close" onclick="closeDetail()">&times;</button>
<h3>LV-Details</h3>
<div id="detail-body"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
window.__PLAN_DATA__ = {
kind: "{{ kind }}",
tab: "{{ tab }}",
uid: "{{ uid }}",
kz: "{{ kz }}",
sem: "{{ sem }}",
ver: "{{ ver }}",
grp: "{{ grp }}",
semester: "{{ semester }}",
initWeek: "{{ init_week.isoformat() }}",
slots: {{ slots | tojson }},
events: {{ events_json | tojson }}
};
</script>
{% endblock %}
+76
View File
@@ -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]