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:
@@ -0,0 +1,9 @@
|
|||||||
|
.venv
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
|
Caddyfile.example
|
||||||
|
README.md
|
||||||
|
docker-compose.yml
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
|
# }
|
||||||
+16
@@ -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"]
|
||||||
@@ -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=<matrikelnummer>
|
||||||
|
CIS_PASSWORD=<passwort>
|
||||||
|
CIS_BASE_URL=https://cis.hochschule-burgenland.at/cis
|
||||||
|
OWN_UID=<eigene matrikelnummer>
|
||||||
|
```
|
||||||
|
|
||||||
|
`.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
|
||||||
|
```
|
||||||
+276
@@ -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
@@ -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}
|
||||||
@@ -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", `<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, "'")})'>
|
||||||
|
<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 →</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();
|
||||||
|
});
|
||||||
@@ -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%; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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">📅</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">🏫</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">👥</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 %}
|
||||||
@@ -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> </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 %}
|
||||||
@@ -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> </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)">◀</button>
|
||||||
|
<button class="btn btn-sm" onclick="goToday()">Heute</button>
|
||||||
|
<button class="btn btn-sm" onclick="shiftWeek(1)">▶</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()">×</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
@@ -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]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
cis-enhanced:
|
||||||
|
build: .
|
||||||
|
container_name: cis-enhanced
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
requests
|
||||||
|
beautifulsoup4
|
||||||
|
jinja2
|
||||||
|
python-dotenv
|
||||||
Reference in New Issue
Block a user