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)."""
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
View File
@@ -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,
})
+54
View File
@@ -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();
+33
View File
@@ -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; }
}
+14 -2
View File
@@ -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, &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>
{% 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>
{% endif %}
{% 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 %}