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)."""
|
"""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
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ≥ 50 %) deuten auf dieselbe Person hin.
|
<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>
|
</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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% 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