Lektoren app-weit: Plan/Details/iCal mit type=lektor, Namenssuche (Autocomplete) für Studierende und Lektoren, Lektor-Karte mit Profil-Link

This commit is contained in:
Nicolas Gruber
2026-10-19 16:01:41 +02:00
parent 6763faf9c1
commit 139b3677b5
5 changed files with 127 additions and 23 deletions
+27 -9
View File
@@ -58,10 +58,11 @@ class Event:
class Person: class Person:
uid: str uid: str
name: str name: str
stg: str stg: str = ""
semester: str semester: str = ""
verband: str verband: str = ""
gruppe: str gruppe: str = ""
kind: str = "student" # student | lektor
@dataclass @dataclass
@@ -177,7 +178,8 @@ class CISClient:
return f"{self.base}/private/lvplan/stpl_kalender.php?{q}" return f"{self.base}/private/lvplan/stpl_kalender.php?{q}"
def _ical_student_params(self, uid: str, begin: int, ende: int) -> dict: def _ical_student_params(self, uid: str, begin: int, ende: int) -> dict:
return {"type": "student", "pers_uid": uid, "ort_kurzbz": "", ptype = "student" if uid.isdigit() else "lektor"
return {"type": ptype, "pers_uid": uid, "ort_kurzbz": "",
"stg_kz": "", "sem": "", "ver": "", "grp": "", "stg_kz": "", "sem": "", "ver": "", "grp": "",
"gruppe_kurzbz": "", "lva": "", "begin": begin, "ende": ende, "gruppe_kurzbz": "", "lva": "", "begin": begin, "ende": ende,
"format": "ical", "version": "2", "target": "ical"} "format": "ical", "version": "2", "target": "ical"}
@@ -197,25 +199,38 @@ class CISClient:
return parse_ical(self._get(self._ical_url(self._ical_stg_params(kz, sem, ver, grp, begin, ende)))) 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: def ical_raw(self, kind: str, **kw) -> str:
if kind == "student": if kind in ("student", "lektor"):
return self._get(self._ical_url(self._ical_student_params(kw["uid"], kw["begin"], kw["ende"]))) 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"]))) return self._get(self._ical_url(self._ical_stg_params(kw["kz"], kw["sem"], kw["ver"], kw.get("grp", ""), kw["begin"], kw["ende"])))
# ---------- person ---------- # ---------- person ----------
@lru_cache(maxsize=256) @lru_cache(maxsize=256)
def person(self, uid: str) -> Optional[Person]: def person(self, uid: str) -> Optional[Person]:
uid = (uid or "").strip()
if not uid:
return None
is_student = uid.isdigit()
try: try:
html = self._get(f"{self.base}/private/lvplan/stpl_week.php?pers_uid={uid}") url = f"{self.base}/private/lvplan/stpl_week.php?pers_uid={uid}"
if not is_student:
url += "&type=lektor"
html = self._get(url)
except requests.HTTPError: except requests.HTTPError:
return None return None
t = re.sub(r"<[^>]+>", " ", html) t = re.sub(r"<[^>]+>", " ", html)
t = re.sub(r"\s+", " ", t) t = re.sub(r"\s+", " ", t)
if is_student:
m = re.search(r"Person:\s*(.+?)\s*-\s*(\d{9,10}).*?Studiengang:\s*(.+?)\s+Semester:\s*(\S+?)\s+Verband:\s*(\S+?)\s+Gruppe:", 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: if not m:
return None return None
name, u, stg, sem, ver = m.groups() name, u, stg, sem, ver = m.groups()
return Person(uid=u, name=name.strip(), stg=stg.strip(), return Person(uid=u, name=name.strip(), stg=stg.strip(),
semester=sem.strip(), verband=ver.strip(), gruppe="") semester=sem.strip(), verband=ver.strip(), gruppe="", kind="student")
m = re.search(r"Person:\s*(.+?)\s*-\s*([A-Za-z][\w.]*)", t)
if not m:
return None
name, u = m.groups()
return Person(uid=u, name=name.strip(), kind="lektor")
# ---------- person_id + photos ---------- # ---------- person_id + photos ----------
def person_id_for(self, uid: str) -> str: def person_id_for(self, uid: str) -> str:
@@ -416,7 +431,10 @@ class CISClient:
def detail(self, kind: str, datum: str, stunde: int, uid: str = "", def detail(self, kind: str, datum: str, stunde: int, uid: str = "",
kz: str = "", sem: str = "", ver: str = "", grp: str = "", kz: str = "", sem: str = "", ver: str = "", grp: str = "",
ort: str = "") -> dict: ort: str = "") -> dict:
if kind == "student": if kind == "lektor":
url = (f"{self.base}/private/lvplan/stpl_detail.php?type=lektor&datum={datum}"
f"&stunde={stunde}&pers_uid={uid}&stg_kz=&sem=&ver=&grp=&ort_kurzbz={ort}")
elif kind == "student":
url = (f"{self.base}/private/lvplan/stpl_detail.php?type=student&datum={datum}" 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"&stunde={stunde}&pers_uid={uid}&stg_kz={kz}&sem={sem}&ver={ver}"
f"&grp= {grp}&ort_kurzbz={ort}") f"&grp= {grp}&ort_kurzbz={ort}")
+23 -3
View File
@@ -133,7 +133,7 @@ def _plan_common(request, kind, uid, kz, sem, ver, grp, semester, week="", tab="
@app.get("/plan", response_class=HTMLResponse) @app.get("/plan", response_class=HTMLResponse)
def plan(request: Request, def plan(request: Request,
uid: str = Query("", description="Matrikelnummer"), uid: str = Query("", description="Matrikelnummer oder Lektor-Username (z.B. rmatzinger)"),
semester: str = Query(""), semester: str = Query(""),
week: str = Query("", description="Woche als YYYY-MM-DD (Monatag)"), week: str = Query("", description="Woche als YYYY-MM-DD (Monatag)"),
tab: str = Query("week", description="week|sem")): tab: str = Query("week", description="week|sem")):
@@ -444,6 +444,24 @@ def api_person_id(uid: str = Query(...)):
return {"uid": uid, "person_id": client.person_id_for(uid)} return {"uid": uid, "person_id": client.person_id_for(uid)}
@app.get("/api/benutzer")
def api_benutzer(q: str = Query("")):
"""Namensuche über Studierende + Lektoren (Autocomplete)."""
q = (q or "").strip().lower()
if len(q) < 2:
return []
out = []
for b in client.benutzer_all():
hay = f"{b['nachname']} {b['vorname']} {b['uid']}".lower()
if q in hay:
out.append({"uid": b["uid"],
"name": f"{b['nachname']}, {b['vorname']}".strip(", "),
"kind": "student" if b["uid"].isdigit() else "lektor"})
if len(out) >= 12:
break
return out
@app.get("/api/meta") @app.get("/api/meta")
def api_meta(): def api_meta():
return _meta() return _meta()
@@ -497,8 +515,10 @@ def export_ical(kind: str = "student",
if x["begin"] <= datetime.now().timestamp() <= x["ende"]), if x["begin"] <= datetime.now().timestamp() <= x["ende"]),
meta["semesters"][0])) meta["semesters"][0]))
if kind == "student": if kind == "student":
ical = client.ical_raw("student", uid=uid or OWN_UID, begin=s["begin"], ende=s["ende"]) uid = uid or OWN_UID
fname = f"lvplan_{uid or OWN_UID}_{s['label']}.ics" ptype = "student" if uid.isdigit() else "lektor"
ical = client.ical_raw(ptype, uid=uid, begin=s["begin"], ende=s["ende"])
fname = f"lvplan_{uid}_{s['label']}.ics"
else: else:
ical = client.ical_raw("stg", kz=kz, sem=sem, ver=ver, grp=grp, ical = client.ical_raw("stg", kz=kz, sem=sem, ver=ver, grp=grp,
begin=s["begin"], ende=s["ende"]) begin=s["begin"], ende=s["ende"])
+44 -4
View File
@@ -272,16 +272,19 @@ async function openEvent(ev) {
const body = $("#detail-body"); const body = $("#detail-body");
body.innerHTML = `<p style="color:var(--muted)">Lade Details vom CIS...</p>`; body.innerHTML = `<p style="color:var(--muted)">Lade Details vom CIS...</p>`;
const stunde = stundeOf({ s: parseISO(ev.start) }); const stunde = stundeOf({ s: parseISO(ev.start) });
const detailKind = plan.kind === "student"
? (plan.ptype === "lektor" ? "lektor" : "student")
: plan.kind;
const params = new URLSearchParams({ const params = new URLSearchParams({
kind: plan.kind, datum: ev.datum, stunde, ort: ev.ort, kind: detailKind, datum: ev.datum, stunde, ort: ev.ort,
}); });
if (plan.kind === "student") { if (plan.kind === "stg") {
params.set("uid", plan.uid);
} else {
params.set("kz", plan.kz); params.set("kz", plan.kz);
params.set("sem", plan.sem); params.set("sem", plan.sem);
params.set("ver", plan.ver); params.set("ver", plan.ver);
params.set("grp", plan.grp); params.set("grp", plan.grp);
} else {
params.set("uid", plan.uid);
} }
try { try {
const r = await fetch("/api/detail?" + params.toString()); const r = await fetch("/api/detail?" + params.toString());
@@ -477,11 +480,48 @@ function initSearchJob() {
poll(); poll();
} }
/* ================= Matrikel/Lektor Vorschläge ================= */
function initUidSuggest() {
const inp = $("#uid-input");
if (!inp) return;
const box = $("#uid-suggest");
let timer = null;
inp.addEventListener("input", () => {
clearTimeout(timer);
const q = inp.value.trim();
if (q.length < 2) { box.classList.remove("show"); return; }
timer = setTimeout(async () => {
try {
const r = await fetch("/api/benutzer?q=" + encodeURIComponent(q));
const arr = await r.json();
if (!arr.length) { box.classList.remove("show"); return; }
box.innerHTML = arr.map(p =>
`<a href="#" data-uid="${esc(p.uid)}">${esc(p.name)}
<span class="mono muted">${esc(p.uid)}</span>
${p.kind === "lektor" ? '<span class="badge badge-muted">Lektor</span>' : ""}</a>`
).join("");
box.classList.add("show");
} catch (e) { box.classList.remove("show"); }
}, 250);
});
box.addEventListener("click", (e) => {
const a = e.target.closest("a[data-uid]");
if (!a) return;
e.preventDefault();
inp.value = a.dataset.uid;
box.classList.remove("show");
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".uid-field")) box.classList.remove("show");
});
}
/* ================= boot ================= */ /* ================= boot ================= */
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
if (window.__PLAN_DATA__) initPlan(); if (window.__PLAN_DATA__) initPlan();
if (window.__STGS__) decodeMat(); if (window.__STGS__) decodeMat();
if (window.__SEARCH_JOB__) initSearchJob(); if (window.__SEARCH_JOB__) initSearchJob();
initUidSuggest();
}); });
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeDetail(); if (e.key === "Escape") closeDetail();
+16
View File
@@ -256,6 +256,22 @@ table.data .num { text-align: right; font-variant-numeric: tabular-nums; }
.match-tile.match-strong .sim-bar > div { background: var(--green); } .match-tile.match-strong .sim-bar > div { background: var(--green); }
.match-tile.match-strong .sim-num { color: var(--green); } .match-tile.match-strong .sim-num { color: var(--green); }
/* ---------- Matrikel/Lektor Vorschläge ---------- */
.uid-field { position: relative; }
.suggest {
display: none; position: absolute; top: 100%; left: 0; right: 0; z-index: 60;
background: #fff; border: 1px solid var(--line); border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.14); margin-top: 4px; overflow: hidden;
}
.suggest.show { display: block; }
.suggest a {
display: flex; align-items: center; gap: 8px; padding: 9px 12px; font-size: 13.5px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.suggest a:hover { background: var(--blue-light); text-decoration: none; }
.suggest a .mono { margin-left: auto; font-size: 12px; }
.muted { color: var(--muted); }
.footer { border-top: 1px solid var(--line); padding: 16px 0; color: var(--muted); font-size: 13px; background: #fff; } .footer { border-top: 1px solid var(--line); padding: 16px 0; color: var(--muted); font-size: 13px; background: #fff; }
@media (max-width: 720px) { @media (max-width: 720px) {
+14 -4
View File
@@ -15,9 +15,11 @@
<div class="card"> <div class="card">
<form class="form-row" method="get" action="/{{ kind if kind != 'student' else 'plan' }}"> <form class="form-row" method="get" action="/{{ kind if kind != 'student' else 'plan' }}">
{% if kind == 'student' %} {% if kind == 'student' %}
<div class="field"> <div class="field uid-field">
<label>Matrikelnummer</label> <label>Matrikel oder Lektor</label>
<input type="text" name="uid" value="{{ uid }}" placeholder="z.B. 2410640055" required> <input type="text" id="uid-input" name="uid" value="{{ uid }}"
placeholder="z.B. 2410640055 oder rmatzinger" required>
<div class="suggest" id="uid-suggest"></div>
</div> </div>
{% if uid and uid | length >= 9 %} {% if uid and uid | length >= 9 %}
{% set dec = decode_matrikel(uid, meta.stgs) %} {% set dec = decode_matrikel(uid, meta.stgs) %}
@@ -76,10 +78,17 @@
{% if kind == 'student' and person %} {% if kind == 'student' and person %}
<div class="card"> <div class="card">
<div class="kv"> <div class="kv">
<div class="item"><div class="k">Person</div><div class="v">{{ person.name }}</div></div> <div class="item"><div class="k">Person</div><div class="v">{{ person.name }}
{% if person.kind == 'lektor' %}<span class="badge">Lektor</span>{% endif %}
<a class="btn btn-sm" href="/person/{{ person.uid }}" style="margin-left:10px">Profil</a>
</div></div>
{% if person.kind == 'student' %}
<div class="item"><div class="k">Matrikelnummer</div><div class="v mono">{{ person.uid }}</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">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 class="item"><div class="k">Semester / Verband</div><div class="v">{{ person.semester }} / {{ person.verband }}{% if person.gruppe %} / Grp. {{ person.gruppe }}{% endif %}</div></div>
{% else %}
<div class="item"><div class="k">Username</div><div class="v mono">{{ person.uid }}</div></div>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -127,6 +136,7 @@
<script> <script>
window.__PLAN_DATA__ = { window.__PLAN_DATA__ = {
kind: "{{ kind }}", kind: "{{ kind }}",
ptype: "{{ person.kind if person else '' }}",
tab: "{{ tab }}", tab: "{{ tab }}",
uid: "{{ uid }}", uid: "{{ uid }}",
kz: "{{ kz }}", kz: "{{ kz }}",