Fotos: Gesichtssuche FH-weit (Studierende + Lektoren) als Job mit Live-Progress, Person-Info-Seiten, Scope-Auswahl
This commit is contained in:
+85
@@ -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,6 +223,7 @@ class CISClient:
|
||||
uid = (uid or "").strip()
|
||||
if not uid:
|
||||
return ""
|
||||
with _FILE_LOCK:
|
||||
cache: dict = {}
|
||||
if PERSON_ID_CACHE.exists():
|
||||
try:
|
||||
@@ -234,6 +240,12 @@ class CISClient:
|
||||
if not m:
|
||||
return ""
|
||||
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
|
||||
try:
|
||||
PERSON_ID_CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -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"]
|
||||
|
||||
+141
-31
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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" ? ` <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 ================= */
|
||||
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();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<div class="avatar-initials">{{ (p.nachname[:1] ~ p.vorname[:1]) | upper }}</div>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
</div>
|
||||
{% else %}
|
||||
@@ -60,11 +60,23 @@
|
||||
|
||||
<div class="card">
|
||||
<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">
|
||||
<input type="hidden" name="kz" value="{{ kz }}">
|
||||
<input type="hidden" name="sem" value="{{ sem }}">
|
||||
<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">
|
||||
<label>Foto</label>
|
||||
<input type="file" name="file" accept="image/*" required>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block title %}Gesichtssuche{% endblock %}
|
||||
{% block content %}
|
||||
<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>
|
||||
|
||||
<div class="card">
|
||||
@@ -42,33 +42,23 @@
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<h2>
|
||||
Ergebnisse
|
||||
<span class="count-pill">{{ results | selectattr('sim', 'ge', 0.35) | list | length }} Treffer</span>
|
||||
{% if took is defined %}<span class="badge badge-muted">{{ took }} s</span>{% endif %}
|
||||
<span id="search-state">Suche läuft…</span>
|
||||
<span class="count-pill" id="search-count">0 Treffer</span>
|
||||
<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>
|
||||
{% if results | selectattr('sim', 'ge', 0.35) | list | length %}
|
||||
<div class="match-grid">
|
||||
{% for r in results[:30] %}
|
||||
<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">
|
||||
<div class="search-progressbar" id="search-progressbar"><div id="search-bar"></div></div>
|
||||
<div class="match-grid" id="match-grid"></div>
|
||||
<p class="hint" id="search-hint" style="display:none">
|
||||
<strong>Hinweis:</strong> Hohe Werte (grün, ≥ 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>
|
||||
{% else %}
|
||||
<p class="empty">Kein Treffer mit ≥ 35 % Ähnlichkeit gefunden.
|
||||
Prüfe, ob das richtige Semester/Verband gewählt ist und das Foto ein klar sichtbares Gesicht zeigt.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.__SEARCH_JOB__ = {{ job | tojson if job else 'null' }};
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user