From 6763faf9c104492b7d44d8a8c13525943763dfd9 Mon Sep 17 00:00:00 2001 From: Nicolas Gruber Date: Mon, 19 Oct 2026 14:43:09 +0200 Subject: [PATCH] Fotos: Gesichtssuche FH-weit (Studierende + Lektoren) als Job mit Live-Progress, Person-Info-Seiten, Scope-Auswahl --- app/cis.py | 113 ++++++++++-- app/main.py | 172 ++++++++++++++---- app/static/app.js | 54 ++++++ app/static/style.css | 33 ++++ app/templates/fotos.html | 16 +- .../{fotos_result.html => fotos_search.html} | 40 ++-- app/templates/person.html | 49 +++++ 7 files changed, 405 insertions(+), 72 deletions(-) rename app/templates/{fotos_result.html => fotos_search.html} (52%) create mode 100644 app/templates/person.html diff --git a/app/cis.py b/app/cis.py index de4db86..f6add81 100644 --- a/app/cis.py +++ b/app/cis.py @@ -1,6 +1,7 @@ """CIS API client for FH-Burgenland (basic auth, HTML/iCal scraping).""" import json import re +import threading import time from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone @@ -14,6 +15,7 @@ from bs4 import BeautifulSoup DATA_DIR = Path(__file__).resolve().parent.parent / "data" PERSON_ID_CACHE = DATA_DIR / "person_ids.json" PHOTO_DIR = DATA_DIR / "photos" +_FILE_LOCK = threading.Lock() TZ = timezone(timedelta(hours=1)) # Europe/Vienna winter; events carry their own tz, we just parse naive @@ -114,6 +116,9 @@ class CISClient: self.s.headers["User-Agent"] = "Mozilla/5.0 (cis-enhanced)" self._meta_cache: Optional[dict] = None self._meta_ts = 0.0 + self._benutzer: Optional[list[dict]] = None + self._benutzer_ts = 0.0 + self._profile_cache: dict = {} # ---------- metadata (stg list, semesters, rooms) ---------- def meta(self, max_age: int = 3600) -> dict: @@ -218,14 +223,15 @@ class CISClient: uid = (uid or "").strip() if not uid: return "" - cache: dict = {} - if PERSON_ID_CACHE.exists(): - try: - cache = json.loads(PERSON_ID_CACHE.read_text()) - except Exception: - cache = {} - if uid in cache: - return cache[uid] + with _FILE_LOCK: + cache: dict = {} + if PERSON_ID_CACHE.exists(): + try: + cache = json.loads(PERSON_ID_CACHE.read_text()) + except Exception: + cache = {} + if uid in cache: + return cache[uid] try: html = self._get(f"{self.base}/private/profile/index.php?uid={uid}") except requests.HTTPError: @@ -234,12 +240,18 @@ class CISClient: if not m: return "" pid = m.group(1) - cache[uid] = pid - try: - PERSON_ID_CACHE.parent.mkdir(parents=True, exist_ok=True) - PERSON_ID_CACHE.write_text(json.dumps(cache, indent=0)) - except OSError: - pass + with _FILE_LOCK: + if PERSON_ID_CACHE.exists(): + try: + cache = json.loads(PERSON_ID_CACHE.read_text()) + except Exception: + cache = {} + cache[uid] = pid + try: + PERSON_ID_CACHE.parent.mkdir(parents=True, exist_ok=True) + PERSON_ID_CACHE.write_text(json.dumps(cache, indent=0)) + except OSError: + pass return pid def photo_bytes(self, person_id: str, max_age: int = 7 * 86400) -> Optional[tuple[bytes, str]]: @@ -310,6 +322,79 @@ class CISClient: except Exception: return [] + # ---------- Benutzer (alle Studierenden + Lektoren) ---------- + def benutzer_all(self, max_age: int = 6 * 3600) -> list[dict]: + """Alle aktiven Benutzer (Studierende + Lektoren) via CIS-Autocomplete. + Substring-Suche über Vokale liefert die Vollmenge.""" + if self._benutzer and time.time() - self._benutzer_ts < max_age: + return self._benutzer + merged: dict[str, dict] = {} + for ch in "aeiou": + try: + r = self.s.get(f"{self.base}/private/lvplan/lvplan_autocomplete.php", + params={"autocomplete": "benutzer", "term": ch}, timeout=60) + t = r.text.strip() + if not t: + continue + for d in r.json(): + uid = str(d.get("uid") or "").strip() + if not uid: + continue + merged[uid] = {"uid": uid, + "vorname": (d.get("vorname") or "").strip(), + "nachname": (d.get("nachname") or "").strip(), + "mitarbeiter_uid": (d.get("mitarbeiter_uid") or "").strip()} + except Exception: + continue + self._benutzer = list(merged.values()) + self._benutzer_ts = time.time() + return self._benutzer + + # ---------- Profilseite ---------- + def profile(self, uid: str, max_age: int = 3600) -> Optional[dict]: + """Profilseite parsen (StudentIn oder MitarbeiterIn).""" + uid = (uid or "").strip() + if not uid: + return None + hit = self._profile_cache.get(uid) + if hit and time.time() - hit[0] < max_age: + return hit[1] + try: + html = self._get(f"{self.base}/private/profile/index.php?uid={uid}") + except requests.HTTPError: + return None + t = re.sub(r"<[^>]+>", " ", html) + t = re.sub(r"\s+", " ", t) + def _pick(pattern: str, group: int = 1) -> str: + m = re.search(pattern, t) + return m.group(group).strip() if m else "" + role = "" + for cand in ("StudentIn", "MitarbeiterIn Extern", "MitarbeiterIn Intern", "MitarbeiterIn"): + if re.search(rf"\b{cand}\b", t): + role = cand + break + m = re.search(r"bild\.php\?src=person&person_id=(\d+)", html) + prof = { + "uid": uid, + "role": role, + "person_id": m.group(1) if m else "", + "username": _pick(r"Username:\s*(\S+)"), + "titel": _pick(r"Titel:\s*(.*?)\s*Vorname:").strip(), + "vorname": _pick(r"Vorname:\s*(.*?)\s*Nachname:"), + "nachname": _pick(r"Nachname:\s*(.*?)\s*Postnomen:"), + "postnomen": _pick(r"Postnomen:\s*(.*?)\s*(?:Geburtsdatum:|Kurzzeichen:|Studiengang:|eMail)"), + "kurzzeichen": _pick(r"Kurzzeichen:\s*(\S+)"), + "mail": _pick(r"Intern:\s*([^\s]+@[^\s]+)"), + "mail_alias": _pick(r"Alias:\s*([^\s]+@[^\s]+)"), + "stg": _pick(r"Studiengang:\s*(.*?)\s*Semester:"), + "semester": _pick(r"Semester:\s*(\d+)"), + "verband": _pick(r"Verband:\s*(\S+)"), + } + if not prof["nachname"] and not prof["vorname"]: + prof = None + self._profile_cache[uid] = (time.time(), prof) + return prof + # ---------- Verbände pro StG/Semester ---------- def verbands_for(self, kz: str, sem: str) -> list[str]: fallback = ["V", "B", "G", "A", "C", "D", "E", "F"] diff --git a/app/main.py b/app/main.py index 1647798..12da241 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,9 @@ """CIS Enhanced - FastAPI app.""" import json import os +import threading import time +import uuid from datetime import datetime, timedelta from pathlib import Path @@ -271,61 +273,169 @@ def fotos_img(person_id: str): return Response(data, media_type=ct) +# ---------- Gesichtssuche als Hintergrund-Job ---------- +SCOPE_LABELS = { + "verband": "Nur Verband", + "stg": "Gesamter Studiengang", + "stud": "Alle Studierenden (FH-weit)", + "lektor": "Alle Lektoren", + "alle": "Alle Personen (Studierende + Lektoren)", +} +JOBS: dict[str, dict] = {} +JOBS_LOCK = threading.Lock() +MAX_JOBS = 8 + + +def _scope_population(scope: str, kz: str, sem: str, ver: str) -> list[dict]: + """Personen des Suchbereichs, dedupliziert nach uid.""" + people: dict[str, dict] = {} + + def add(uid, nachname, vorname, kind): + uid = (uid or "").strip() + if uid and uid not in people: + people[uid] = {"uid": uid, "nachname": nachname, + "vorname": vorname, "kind": kind} + + if scope == "verband": + for s in client.namensliste(kz, sem, ver): + add(s.matrikel, s.nachname, s.vorname, "student") + elif scope == "stg": + sems = client.study_semesters(kz) or list(range(1, 7)) + for s in sems: + for v in client.verbands_for(kz, str(s)): + for st in client.namensliste(kz, str(s), v): + add(st.matrikel, st.nachname, st.vorname, "student") + else: + for b in client.benutzer_all(): + kind = "student" if b["uid"].isdigit() else "lektor" + if scope == "stud" and kind != "student": + continue + if scope == "lektor" and kind != "lektor": + continue + add(b["uid"], b["nachname"], b["vorname"], kind) + return list(people.values()) + + +def _search_worker(job: dict, emb, population: list[dict]): + try: + for p in population: + pid = client.person_id_for(p["uid"]) + if pid: + res = client.photo_bytes(pid) + if res: + e = _embed_person(pid, res[0]) + if e is not None: + sim = engine.similarity(emb, e) + if sim >= 0.20: + entry = {**p, "sim": round(sim, 4), + "img": f"/fotos/img/{pid}"} + with JOBS_LOCK: + job["results"].append(entry) + job["results"].sort(key=lambda r: r["sim"], reverse=True) + del job["results"][60:] + job["done"] += 1 + except Exception as ex: + job["error"] = str(ex) + job["status"] = "done" + job["took"] = round(time.time() - job["created"], 1) + + +def _start_job(emb, population: list[dict], scope: str) -> dict: + job = { + "id": uuid.uuid4().hex[:12], + "status": "running", + "done": 0, + "total": len(population), + "results": [], + "error": None, + "scope": scope, + "created": time.time(), + } + with JOBS_LOCK: + JOBS[job["id"]] = job + while len(JOBS) > MAX_JOBS: + oldest = min(JOBS, key=lambda k: JOBS[k]["created"]) + JOBS.pop(oldest, None) + threading.Thread(target=_search_worker, args=(job, emb, population), daemon=True).start() + return job + + @app.post("/fotos/suche", response_class=HTMLResponse) def fotos_suche(request: Request, kz: str = Form("640"), sem: str = Form("1"), ver: str = Form("V"), + scope: str = Form("verband"), file: UploadFile = File(...)): + if scope not in SCOPE_LABELS: + scope = "verband" meta = _meta() sems = _study_sem_options(kz, current_semester(meta["semesters"])) if sem not in [str(n) for n in sems]: sem = str(sems[0]) - base_ctx = { + ctx = { "request": request, "kz": kz, "sem": sem, "sems": sems, "ver": ver, - "results": [], "meta": meta, + "scope": scope, + "scope_label": SCOPE_LABELS[scope], + "meta": meta, "stg": next((x for x in meta["stgs"] if x["kz"] == kz), None), "verbands": client.verbands_for(kz, sem), "own_uid": OWN_UID, } data = file.file.read() if not data: - return templates.TemplateResponse(request, "fotos_result.html", - {**base_ctx, "error": "Leere Datei hochgeladen."}, + return templates.TemplateResponse(request, "fotos_search.html", + {**ctx, "error": "Leere Datei hochgeladen.", "job": None}, status_code=400) if len(data) > MAX_UPLOAD: - return templates.TemplateResponse(request, "fotos_result.html", - {**base_ctx, "error": "Datei zu groß (max. 8 MB)."}, + return templates.TemplateResponse(request, "fotos_search.html", + {**ctx, "error": "Datei zu groß (max. 8 MB).", "job": None}, status_code=400) emb = engine.embed(data) if emb is None: - return templates.TemplateResponse(request, "fotos_result.html", - {**base_ctx, "error": "Kein Gesicht im hochgeladenen Bild gefunden."}, + return templates.TemplateResponse(request, "fotos_search.html", + {**ctx, "error": "Kein Gesicht im hochgeladenen Bild gefunden.", "job": None}, status_code=422) - wall = _photo_wall(kz, sem, ver) - results = [] - t0 = time.time() - for p in wall: - pid = p["person_id"] - if not pid: - continue - res = client.photo_bytes(pid) - if not res: - continue - e = _embed_person(pid, res[0]) - if e is None: - continue - sim = engine.similarity(emb, e) - results.append({**p, "sim": round(sim, 4), - "img": f"/fotos/img/{pid}"}) - results.sort(key=lambda r: r["sim"], reverse=True) - return templates.TemplateResponse(request, "fotos_result.html", { - **base_ctx, - "error": None, - "took": round(time.time() - t0, 1), - "query": {"kz": kz, "sem": sem, "ver": ver}, - "results": results[:30], + population = _scope_population(scope, kz, sem, ver) + job = _start_job(emb, population, scope) + return templates.TemplateResponse(request, "fotos_search.html", + {**ctx, "error": None, "job": job}) + + +@app.get("/api/suche/status/{job_id}") +def suche_status(job_id: str): + job = JOBS.get(job_id) + if not job: + return JSONResponse({"error": "Job unbekannt oder abgelaufen"}, status_code=404) + with JOBS_LOCK: + out = { + "status": job["status"], + "done": job["done"], + "total": job["total"], + "error": job["error"], + "took": job.get("took"), + "top": job["results"][:30], + } + return JSONResponse(out) + + +@app.get("/person/{uid}", response_class=HTMLResponse) +def person_page(request: Request, uid: str): + prof = client.profile(uid) + if not prof: + return templates.TemplateResponse(request, "person.html", { + "request": request, "prof": None, "uid": uid, "meta": _meta(), + }, status_code=404) + meta = _meta() + dec = decode_matrikel(uid, meta["stgs"]) if uid.isdigit() else None + return templates.TemplateResponse(request, "person.html", { + "request": request, + "prof": prof, + "uid": uid, + "dec": dec, + "meta": meta, + "own_uid": OWN_UID, }) diff --git a/app/static/app.js b/app/static/app.js index e6b202d..86eadba 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -424,10 +424,64 @@ async function reloadVerbands() { } catch (e) { /* Keep static options */ } } +/* ================= Gesichtssuche: Job-Polling ================= */ +function initSearchJob() { + const job = window.__SEARCH_JOB__; + if (!job) return; + const state = $("#search-state"); + const count = $("#search-count"); + const progress = $("#search-progress"); + const timeBadge = $("#search-time"); + const bar = $("#search-bar"); + const grid = $("#match-grid"); + const hint = $("#search-hint"); + const t0 = Date.now(); + + function tile(r) { + const name = `${esc(r.nachname)}${r.vorname ? ", " + esc(r.vorname) : ""}`; + const pct = Math.max(0, Math.round(r.sim * 100)); + const cls = r.sim >= 0.5 ? "match-strong" : (r.sim >= 0.35 ? "match-mid" : ""); + const badge = r.kind === "lektor" ? ` Lektor` : ""; + return `
+ ${name} +
${name}${badge}
+
+
+ ${pct} % +
+
`; + } + + function poll() { + fetch(`/api/suche/status/${job.id}`) + .then(r => { if (!r.ok) throw new Error("status"); return r.json(); }) + .then(d => { + if (d.total) bar.style.width = (d.done / d.total * 100).toFixed(1) + "%"; + progress.textContent = `${d.done}/${d.total}`; + grid.innerHTML = (d.top || []).map(tile).join(""); + count.textContent = `${(d.top || []).filter(r => r.sim >= 0.35).length} Treffer`; + if (d.status === "done") { + clearInterval(timer); + state.textContent = d.error ? "Suche fehlgeschlagen" : "Fertig"; + hint.style.display = "block"; + if (d.error) hint.innerHTML = `${esc(d.error)}`; + const secs = d.took ? `${d.took} s` : `${((Date.now() - t0) / 1000).toFixed(0)} s`; + timeBadge.textContent = secs; + timeBadge.style.display = ""; + } + }) + .catch(() => { /* retry on next tick */ }); + } + + const timer = setInterval(poll, 2000); + poll(); +} + /* ================= boot ================= */ document.addEventListener("DOMContentLoaded", () => { if (window.__PLAN_DATA__) initPlan(); if (window.__STGS__) decodeMat(); + if (window.__SEARCH_JOB__) initSearchJob(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); diff --git a/app/static/style.css b/app/static/style.css index 1a7a311..b67ab01 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -215,6 +215,35 @@ table.data .num { text-align: right; font-variant-numeric: tabular-nums; } overflow: hidden; padding: 0; } .match-tile img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; background: var(--bg); } +.match-tile a { display: block; color: inherit; } +.search-progressbar { + height: 8px; background: #eef2f7; border-radius: 6px; overflow: hidden; + margin: 0 0 18px; +} +.search-progressbar > div { + height: 100%; width: 0; background: var(--blue); border-radius: 6px; + transition: width .5s ease; +} + +/* ---------- person ---------- */ +.person-card { + display: flex; gap: 28px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); box-shadow: var(--shadow); padding: 26px; + align-items: flex-start; +} +.person-photo { flex: 0 0 220px; } +.person-photo img, .person-photo .avatar-initials { + width: 220px; aspect-ratio: 3 / 4; object-fit: cover; border-radius: var(--radius); + border: 1px solid var(--line); background: var(--bg); +} +.person-name { margin: 0 0 10px; font-size: 26px; color: var(--ink); } +.person-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } +.badge-green { background: #dcfce7; color: var(--green); } +.person-kv { width: 100%; border-collapse: collapse; margin-bottom: 18px; } +.person-kv td { padding: 7px 10px 7px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; } +.person-kv td:first-child { color: var(--muted); width: 180px; font-weight: 600; } +.person-actions { display: flex; gap: 10px; flex-wrap: wrap; } +.photo-name a { color: var(--ink); } .match-name { padding: 8px 10px 4px; font-size: 13.5px; font-weight: 600; } .match-name a { color: var(--ink); } .match-sim { padding: 0 10px 12px; display: flex; align-items: center; gap: 8px; } @@ -234,4 +263,8 @@ table.data .num { text-align: right; font-variant-numeric: tabular-nums; } .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%; } + .person-card { flex-direction: column; align-items: center; text-align: center; } + .person-badges { justify-content: center; } + .person-actions { justify-content: center; } + .person-kv td:first-child { width: 130px; } } diff --git a/app/templates/fotos.html b/app/templates/fotos.html index 3a81995..31f5cd9 100644 --- a/app/templates/fotos.html +++ b/app/templates/fotos.html @@ -49,7 +49,7 @@
{{ (p.nachname[:1] ~ p.vorname[:1]) | upper }}
{% endif %} -
{{ p.nachname }}{% if p.vorname %}, {{ p.vorname }}{% endif %}
+
{{ p.nachname }}{% if p.vorname %}, {{ p.vorname }}{% endif %}
{% if p.matrikel == own_uid %}
Das bist du
{% endif %} {% else %} @@ -60,11 +60,23 @@

Gesichtssuche

-

Foto von dir hochladen - die App findet die passenden Profile in diesem Verband.

+

Foto hochladen - die App vergleicht es mit den Profilfotos im gewählten Suchbereich. + Beim ersten Mal für große Bereiche (z.B. FH-weit) kann die Suche etwas dauern; + die Ergebnisse erscheinen live, während sie berechnet werden.

+
+ + +
diff --git a/app/templates/fotos_result.html b/app/templates/fotos_search.html similarity index 52% rename from app/templates/fotos_result.html rename to app/templates/fotos_search.html index 8e7828e..063ef7c 100644 --- a/app/templates/fotos_result.html +++ b/app/templates/fotos_search.html @@ -2,7 +2,7 @@ {% block title %}Gesichtssuche{% endblock %} {% block content %}

Gesichtssuche - {% if stg %}{{ stg.code }} - {{ stg.name }}{% endif %} + {{ scope_label }}

@@ -42,33 +42,23 @@ {% else %}

- Ergebnisse - {{ results | selectattr('sim', 'ge', 0.35) | list | length }} Treffer - {% if took is defined %}{{ took }} s{% endif %} + Suche läuft… + 0 Treffer + 0/{{ job.total }} +

- {% if results | selectattr('sim', 'ge', 0.35) | list | length %} -
- {% for r in results[:30] %} -
- {{ r.nachname }} - -
-
- {{ [r.sim * 100, 0] | max | round(0) }} % -
-
- {% endfor %} -
-

+

+
+ - {% else %} -

Kein Treffer mit ≥ 35 % Ähnlichkeit gefunden. - Prüfe, ob das richtige Semester/Verband gewählt ist und das Foto ein klar sichtbares Gesicht zeigt.

- {% endif %}
{% endif %} {% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/person.html b/app/templates/person.html new file mode 100644 index 0000000..91fbd45 --- /dev/null +++ b/app/templates/person.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}{% if prof %}{{ prof.nachname }}, {{ prof.vorname }}{% else %}Person{% endif %}{% endblock %} +{% block content %} +{% if not prof %} +

Person nicht gefunden

+
+

Zu {{ uid }} existiert kein CIS-Profil + (oder das Profil ist nicht mehr aktiv).

+ Zurück zur Fotowand +
+{% else %} +
+
+ {% if prof.person_id %} + {{ prof.nachname }} + {% else %} +
{{ (prof.nachname[:1] ~ prof.vorname[:1]) | upper }}
+ {% endif %} +
+
+

+ {{ prof.titel }} {{ prof.vorname }} {{ prof.nachname }}{{ ' ' if prof.postnomen }}{{ prof.postnomen }} +

+
+ {{ prof.role or 'Person' }} + {% if dec %}Start {{ dec.start }} + {% if dec.stg_code %}{{ dec.stg_code }}{% endif %}{% endif %} + {% if uid == own_uid %}Das bist du{% endif %} +
+ {% if prof.nachname or prof.username or prof.semester or prof.stg or prof.verband or prof.kurzzeichen or prof.mail %} + + {% if prof.username %}{% endif %} + {% if prof.stg %}{% endif %} + {% if prof.semester %}{% endif %} + {% if prof.verband %}{% endif %} + {% if prof.kurzzeichen %}{% endif %} + {% if prof.mail %}{% endif %} + {% if prof.mail_alias %}{% endif %} +
Username{{ prof.username }}
Studiengang{{ prof.stg }}
Studien-Semester{{ prof.semester }}
Verband{{ prof.verband }}{{ verband_label(prof.verband) }}
Kurzzeichen{{ prof.kurzzeichen }}
E-Mail{{ prof.mail }}
E-Mail (Alias){{ prof.mail_alias }}
+ {% endif %} +
+ {% if uid.isdigit() %}LV-Plan anzeigen{% endif %} + Zurück zur Fotowand +
+
+
+{% endif %} +{% endblock %}