Fotos: Gesichtssuche FH-weit (Studierende + Lektoren) als Job mit Live-Progress, Person-Info-Seiten, Scope-Auswahl

This commit is contained in:
Nicolas Gruber
2026-10-19 14:43:09 +02:00
parent 567bcb40ee
commit 6763faf9c1
7 changed files with 405 additions and 72 deletions
+85
View File
@@ -1,6 +1,7 @@
"""CIS API client for FH-Burgenland (basic auth, HTML/iCal scraping).""" """CIS API client for FH-Burgenland (basic auth, HTML/iCal scraping)."""
import json import json
import re import re
import threading
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -14,6 +15,7 @@ from bs4 import BeautifulSoup
DATA_DIR = Path(__file__).resolve().parent.parent / "data" DATA_DIR = Path(__file__).resolve().parent.parent / "data"
PERSON_ID_CACHE = DATA_DIR / "person_ids.json" PERSON_ID_CACHE = DATA_DIR / "person_ids.json"
PHOTO_DIR = DATA_DIR / "photos" 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 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.s.headers["User-Agent"] = "Mozilla/5.0 (cis-enhanced)"
self._meta_cache: Optional[dict] = None self._meta_cache: Optional[dict] = None
self._meta_ts = 0.0 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) ---------- # ---------- metadata (stg list, semesters, rooms) ----------
def meta(self, max_age: int = 3600) -> dict: def meta(self, max_age: int = 3600) -> dict:
@@ -218,6 +223,7 @@ class CISClient:
uid = (uid or "").strip() uid = (uid or "").strip()
if not uid: if not uid:
return "" return ""
with _FILE_LOCK:
cache: dict = {} cache: dict = {}
if PERSON_ID_CACHE.exists(): if PERSON_ID_CACHE.exists():
try: try:
@@ -234,6 +240,12 @@ class CISClient:
if not m: if not m:
return "" return ""
pid = m.group(1) pid = m.group(1)
with _FILE_LOCK:
if PERSON_ID_CACHE.exists():
try:
cache = json.loads(PERSON_ID_CACHE.read_text())
except Exception:
cache = {}
cache[uid] = pid cache[uid] = pid
try: try:
PERSON_ID_CACHE.parent.mkdir(parents=True, exist_ok=True) PERSON_ID_CACHE.parent.mkdir(parents=True, exist_ok=True)
@@ -310,6 +322,79 @@ class CISClient:
except Exception: except Exception:
return [] 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 ---------- # ---------- Verbände pro StG/Semester ----------
def verbands_for(self, kz: str, sem: str) -> list[str]: def verbands_for(self, kz: str, sem: str) -> list[str]:
fallback = ["V", "B", "G", "A", "C", "D", "E", "F"] fallback = ["V", "B", "G", "A", "C", "D", "E", "F"]
+141 -31
View File
@@ -1,7 +1,9 @@
"""CIS Enhanced - FastAPI app.""" """CIS Enhanced - FastAPI app."""
import json import json
import os import os
import threading
import time import time
import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -271,61 +273,169 @@ def fotos_img(person_id: str):
return Response(data, media_type=ct) 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) @app.post("/fotos/suche", response_class=HTMLResponse)
def fotos_suche(request: Request, def fotos_suche(request: Request,
kz: str = Form("640"), kz: str = Form("640"),
sem: str = Form("1"), sem: str = Form("1"),
ver: str = Form("V"), ver: str = Form("V"),
scope: str = Form("verband"),
file: UploadFile = File(...)): file: UploadFile = File(...)):
if scope not in SCOPE_LABELS:
scope = "verband"
meta = _meta() meta = _meta()
sems = _study_sem_options(kz, current_semester(meta["semesters"])) sems = _study_sem_options(kz, current_semester(meta["semesters"]))
if sem not in [str(n) for n in sems]: if sem not in [str(n) for n in sems]:
sem = str(sems[0]) sem = str(sems[0])
base_ctx = { ctx = {
"request": request, "request": request,
"kz": kz, "sem": sem, "sems": sems, "ver": ver, "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), "stg": next((x for x in meta["stgs"] if x["kz"] == kz), None),
"verbands": client.verbands_for(kz, sem), "verbands": client.verbands_for(kz, sem),
"own_uid": OWN_UID, "own_uid": OWN_UID,
} }
data = file.file.read() data = file.file.read()
if not data: if not data:
return templates.TemplateResponse(request, "fotos_result.html", return templates.TemplateResponse(request, "fotos_search.html",
{**base_ctx, "error": "Leere Datei hochgeladen."}, {**ctx, "error": "Leere Datei hochgeladen.", "job": None},
status_code=400) status_code=400)
if len(data) > MAX_UPLOAD: if len(data) > MAX_UPLOAD:
return templates.TemplateResponse(request, "fotos_result.html", return templates.TemplateResponse(request, "fotos_search.html",
{**base_ctx, "error": "Datei zu groß (max. 8 MB)."}, {**ctx, "error": "Datei zu groß (max. 8 MB).", "job": None},
status_code=400) status_code=400)
emb = engine.embed(data) emb = engine.embed(data)
if emb is None: if emb is None:
return templates.TemplateResponse(request, "fotos_result.html", return templates.TemplateResponse(request, "fotos_search.html",
{**base_ctx, "error": "Kein Gesicht im hochgeladenen Bild gefunden."}, {**ctx, "error": "Kein Gesicht im hochgeladenen Bild gefunden.", "job": None},
status_code=422) status_code=422)
wall = _photo_wall(kz, sem, ver) population = _scope_population(scope, kz, sem, ver)
results = [] job = _start_job(emb, population, scope)
t0 = time.time() return templates.TemplateResponse(request, "fotos_search.html",
for p in wall: {**ctx, "error": None, "job": job})
pid = p["person_id"]
if not pid:
continue @app.get("/api/suche/status/{job_id}")
res = client.photo_bytes(pid) def suche_status(job_id: str):
if not res: job = JOBS.get(job_id)
continue if not job:
e = _embed_person(pid, res[0]) return JSONResponse({"error": "Job unbekannt oder abgelaufen"}, status_code=404)
if e is None: with JOBS_LOCK:
continue out = {
sim = engine.similarity(emb, e) "status": job["status"],
results.append({**p, "sim": round(sim, 4), "done": job["done"],
"img": f"/fotos/img/{pid}"}) "total": job["total"],
results.sort(key=lambda r: r["sim"], reverse=True) "error": job["error"],
return templates.TemplateResponse(request, "fotos_result.html", { "took": job.get("took"),
**base_ctx, "top": job["results"][:30],
"error": None, }
"took": round(time.time() - t0, 1), return JSONResponse(out)
"query": {"kz": kz, "sem": sem, "ver": ver},
"results": results[:30],
@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,
}) })
+54
View File
@@ -424,10 +424,64 @@ async function reloadVerbands() {
} catch (e) { /* Keep static options */ } } 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" ? ` <span class="badge badge-muted">Lektor</span>` : "";
return `<div class="match-tile ${cls}">
<a href="/person/${encodeURIComponent(r.uid)}"><img src="${esc(r.img)}" alt="${name}"></a>
<div class="match-name"><a href="/person/${encodeURIComponent(r.uid)}">${name}</a>${badge}</div>
<div class="match-sim">
<div class="sim-bar"><div style="width:${pct}%"></div></div>
<span class="sim-num">${pct} %</span>
</div>
</div>`;
}
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 = `<span style="color:#b3261e">${esc(d.error)}</span>`;
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 ================= */ /* ================= 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();
}); });
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeDetail(); if (e.key === "Escape") closeDetail();
+33
View File
@@ -215,6 +215,35 @@ table.data .num { text-align: right; font-variant-numeric: tabular-nums; }
overflow: hidden; padding: 0; overflow: hidden; padding: 0;
} }
.match-tile img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; background: var(--bg); } .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 { padding: 8px 10px 4px; font-size: 13.5px; font-weight: 600; }
.match-name a { color: var(--ink); } .match-name a { color: var(--ink); }
.match-sim { padding: 0 10px 12px; display: flex; align-items: center; gap: 8px; } .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; } .topbar nav { flex-wrap: wrap; justify-content: center; }
.form-row { flex-direction: column; align-items: stretch; } .form-row { flex-direction: column; align-items: stretch; }
.field input, .field select { min-width: 0; width: 100%; } .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; }
} }
+14 -2
View File
@@ -49,7 +49,7 @@
<div class="avatar-initials">{{ (p.nachname[:1] ~ p.vorname[:1]) | upper }}</div> <div class="avatar-initials">{{ (p.nachname[:1] ~ p.vorname[:1]) | upper }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="photo-name">{{ p.nachname }}{% if p.vorname %}, {{ p.vorname }}{% endif %}</div> <div class="photo-name"><a href="/person/{{ p.matrikel }}">{{ p.nachname }}{% if p.vorname %}, {{ p.vorname }}{% endif %}</a></div>
{% if p.matrikel == own_uid %}<div class="photo-own">Das bist du</div>{% endif %} {% if p.matrikel == own_uid %}<div class="photo-own">Das bist du</div>{% endif %}
</div> </div>
{% else %} {% else %}
@@ -60,11 +60,23 @@
<div class="card"> <div class="card">
<h2>Gesichtssuche</h2> <h2>Gesichtssuche</h2>
<p class="hint">Foto von dir hochladen - die App findet die passenden Profile in diesem Verband.</p> <p class="hint">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.</p>
<form class="form-row form-stack" method="post" action="/fotos/suche" enctype="multipart/form-data"> <form class="form-row form-stack" method="post" action="/fotos/suche" enctype="multipart/form-data">
<input type="hidden" name="kz" value="{{ kz }}"> <input type="hidden" name="kz" value="{{ kz }}">
<input type="hidden" name="sem" value="{{ sem }}"> <input type="hidden" name="sem" value="{{ sem }}">
<input type="hidden" name="ver" value="{{ ver }}"> <input type="hidden" name="ver" value="{{ ver }}">
<div class="field">
<label>Suchbereich</label>
<select name="scope">
<option value="verband">Nur Verband {{ ver }} (Sem. {{ sem }})</option>
<option value="stg">Gesamter Studiengang (alle Semester + Verbände)</option>
<option value="stud">Alle Studierenden (FH-weit)</option>
<option value="lektor">Alle Lektoren</option>
<option value="alle">Alle Personen (Studierende + Lektoren)</option>
</select>
</div>
<div class="field"> <div class="field">
<label>Foto</label> <label>Foto</label>
<input type="file" name="file" accept="image/*" required> <input type="file" name="file" accept="image/*" required>
@@ -2,7 +2,7 @@
{% block title %}Gesichtssuche{% endblock %} {% block title %}Gesichtssuche{% endblock %}
{% block content %} {% block content %}
<h1 class="page-title">Gesichtssuche <h1 class="page-title">Gesichtssuche
{% if stg %}<span class="badge">{{ stg.code }} - {{ stg.name }}</span>{% endif %} <span class="badge badge-muted">{{ scope_label }}</span>
</h1> </h1>
<div class="card"> <div class="card">
@@ -42,33 +42,23 @@
{% else %} {% else %}
<div class="card"> <div class="card">
<h2> <h2>
Ergebnisse <span id="search-state">Suche läuft…</span>
<span class="count-pill">{{ results | selectattr('sim', 'ge', 0.35) | list | length }} Treffer</span> <span class="count-pill" id="search-count">0 Treffer</span>
{% if took is defined %}<span class="badge badge-muted">{{ took }} s</span>{% endif %} <span class="badge badge-muted" id="search-progress">0/{{ job.total }}</span>
<span class="badge badge-muted" id="search-time" style="display:none"></span>
</h2> </h2>
{% if results | selectattr('sim', 'ge', 0.35) | list | length %} <div class="search-progressbar" id="search-progressbar"><div id="search-bar"></div></div>
<div class="match-grid"> <div class="match-grid" id="match-grid"></div>
{% for r in results[:30] %} <p class="hint" id="search-hint" style="display:none">
<div class="match-tile {{ 'match-strong' if r.sim >= 0.5 else ('match-mid' if r.sim >= 0.35 else '') }}">
<img src="{{ r.img }}" alt="{{ r.nachname }}">
<div class="match-name">
<a href="/plan?uid={{ r.matrikel }}">{{ r.nachname }}{% if r.vorname %}, {{ r.vorname }}{% endif %}</a>
</div>
<div class="match-sim">
<div class="sim-bar"><div style="width: {{ [r.sim * 100, 0] | max | round(0) }}%"></div></div>
<span class="sim-num">{{ [r.sim * 100, 0] | max | round(0) }} %</span>
</div>
</div>
{% endfor %}
</div>
<p class="hint" style="margin-top:12px">
<strong>Hinweis:</strong> Hohe Werte (grün, &ge; 50 %) deuten auf dieselbe Person hin. <strong>Hinweis:</strong> Hohe Werte (grün, &ge; 50 %) deuten auf dieselbe Person hin.
Werte um 35–50 % (gelb) sind mögliche Treffer - bitte prüfen. Unter 35 % wird nicht als Treffer gezählt. Werte um 35–50 % (gelb) sind mögliche Treffer - bitte prüfen.
</p> </p>
{% else %}
<p class="empty">Kein Treffer mit &ge; 35 % Ähnlichkeit gefunden.
Prüfe, ob das richtige Semester/Verband gewählt ist und das Foto ein klar sichtbares Gesicht zeigt.</p>
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block scripts %}
<script>
window.__SEARCH_JOB__ = {{ job | tojson if job else 'null' }};
</script>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}{% if prof %}{{ prof.nachname }}, {{ prof.vorname }}{% else %}Person{% endif %}{% endblock %}
{% block content %}
{% if not prof %}
<h1 class="page-title">Person nicht gefunden</h1>
<div class="card">
<p class="empty">Zu <span class="mono">{{ uid }}</span> existiert kein CIS-Profil
(oder das Profil ist nicht mehr aktiv).</p>
<a class="btn" href="/fotos">Zurück zur Fotowand</a>
</div>
{% else %}
<div class="person-card">
<div class="person-photo">
{% if prof.person_id %}
<img src="/fotos/img/{{ prof.person_id }}" alt="{{ prof.nachname }}"
onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'avatar-initials',textContent:'{{ (prof.nachname[:1] ~ prof.vorname[:1]) | upper }}'}))">
{% else %}
<div class="avatar-initials">{{ (prof.nachname[:1] ~ prof.vorname[:1]) | upper }}</div>
{% endif %}
</div>
<div class="person-info">
<h1 class="person-name">
{{ prof.titel }} {{ prof.vorname }} {{ prof.nachname }}{{ ' ' if prof.postnomen }}{{ prof.postnomen }}
</h1>
<div class="person-badges">
<span class="badge">{{ prof.role or 'Person' }}</span>
{% if dec %}<span class="badge badge-muted">Start {{ dec.start }}</span>
{% if dec.stg_code %}<span class="badge badge-muted">{{ dec.stg_code }}</span>{% endif %}{% endif %}
{% if uid == own_uid %}<span class="badge badge-green">Das bist du</span>{% endif %}
</div>
{% if prof.nachname or prof.username or prof.semester or prof.stg or prof.verband or prof.kurzzeichen or prof.mail %}
<table class="person-kv">
{% if prof.username %}<tr><td>Username</td><td class="mono">{{ prof.username }}</td></tr>{% endif %}
{% if prof.stg %}<tr><td>Studiengang</td><td>{{ prof.stg }}</td></tr>{% endif %}
{% if prof.semester %}<tr><td>Studien-Semester</td><td>{{ prof.semester }}</td></tr>{% endif %}
{% if prof.verband %}<tr><td>Verband</td><td>{{ prof.verband }}{{ verband_label(prof.verband) }}</td></tr>{% endif %}
{% if prof.kurzzeichen %}<tr><td>Kurzzeichen</td><td>{{ prof.kurzzeichen }}</td></tr>{% endif %}
{% if prof.mail %}<tr><td>E-Mail</td><td><a href="mailto:{{ prof.mail }}">{{ prof.mail }}</a></td></tr>{% endif %}
{% if prof.mail_alias %}<tr><td>E-Mail (Alias)</td><td><a href="mailto:{{ prof.mail_alias }}">{{ prof.mail_alias }}</a></td></tr>{% endif %}
</table>
{% endif %}
<div class="person-actions">
{% if uid.isdigit() %}<a class="btn btn-primary" href="/plan?uid={{ uid }}">LV-Plan anzeigen</a>{% endif %}
<a class="btn" href="/fotos">Zurück zur Fotowand</a>
</div>
</div>
</div>
{% endif %}
{% endblock %}