banyaro/backend/main.py
rene 34f29f9d0a Sprint 15: Suche, Ausweis, Teilen, Widget
- Volltext-Suche im Tagebuch (LIKE über Titel/Text/Tags, Debounce 350ms)
- Digitaler Heimtierausweis als druckbare HTML-Seite (/ausweis/{dog_id})
  Enthält Impfungen, Medikamente, Allergien, Tierärzte, Chip-Nr.
- Hund teilen: Einladungslink-System (dog_shares-Tabelle, /teilen/{token})
  Geteilte Hunde erscheinen in der Hundeliste, Tagebuch/Gesundheit lesbar
- Widget-Seite /#widget: zufälliges Tagebuchbild + nächste Erinnerung
  Als PWA-Shortcut im Manifest verankert
- SW-Cache by-v144, APP_VER 117
2026-04-17 15:51:09 +02:00

665 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
BAN YARO — FastAPI Hauptanwendung
"""
import os
import logging
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from contextlib import asynccontextmanager
from database import init_db
import ki
import scheduler as sched
logging.basicConfig(
level = logging.INFO,
format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Startup / Shutdown
# ------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Ban Yaro startet...")
init_db()
logger.info(f"KI-Modus: {ki.KI_MODE}")
sched.start()
yield
sched.stop()
logger.info("Ban Yaro beendet.")
# ------------------------------------------------------------------
# App
# ------------------------------------------------------------------
app = FastAPI(
title = "Ban Yaro API",
version = "0.1.0",
lifespan = lifespan,
docs_url = "/api/docs" if os.getenv("ENV") != "production" else None,
redoc_url = None,
)
# ------------------------------------------------------------------
# API-Router registrieren (werden nach und nach hinzugefügt)
# ------------------------------------------------------------------
from routes.auth import router as auth_router
from routes.dogs import router as dogs_router
from routes.diary import router as diary_router
from routes.health import router as health_router
from routes.poison import router as poison_router
from routes.push import router as push_router
from routes.ki import router as ki_router
from routes.tieraerzte import router as tieraerzte_router
from routes.places import router as places_router
from routes.routen import router as routen_router
from routes.walks import router as walks_router
from routes.events import router as events_router
from routes.sitting import router as sitting_router
from routes.osm import router as osm_router
from routes.forum import router as forum_router
from routes.lost import router as lost_router
from routes.knigge import router as knigge_router
from routes.wiki import router as wiki_router
from routes.movies import router as movies_router
from routes.friends import router as friends_router
from routes.chat import router as chat_router
from routes.admin import router as admin_router
from routes.webcal import router as webcal_router
from routes.profile import router as profile_router
from routes.import_data import router as import_router
from routes.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router
from routes.widget import router as widget_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
app.include_router(diary_router, prefix="/api/dogs", tags=["Tagebuch"])
app.include_router(health_router, prefix="/api/dogs", tags=["Gesundheit"])
app.include_router(poison_router, prefix="/api/poison", tags=["Giftköder"])
app.include_router(push_router, prefix="/api/push", tags=["Push"])
app.include_router(ki_router, prefix="/api/ki", tags=["KI"])
app.include_router(tieraerzte_router, prefix="/api/tieraerzte", tags=["Tierärzte"])
app.include_router(places_router, prefix="/api/places", tags=["Orte"])
app.include_router(routen_router, prefix="/api/routes", tags=["Routen"])
app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Treffen"])
app.include_router(events_router, prefix="/api/events", tags=["Events"])
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
app.include_router(osm_router, prefix="/api/osm", tags=["OSM"])
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])
app.include_router(lost_router, prefix="/api/lost", tags=["Verlorener Hund"])
app.include_router(knigge_router, prefix="/api/knigge", tags=["Knigge"])
app.include_router(wiki_router, prefix="/api/wiki", tags=["Wiki"])
app.include_router(movies_router, prefix="/api/movies", tags=["Filme"])
app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"])
app.include_router(chat_router, prefix="/api/chat", tags=["Chat"])
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
app.include_router(import_router, prefix="/api/import", tags=["Import"])
app.include_router(sharing_dog_router, prefix="/api/dogs", tags=["Teilen"])
app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen"])
app.include_router(widget_router, prefix="/api/widget", tags=["Widget"])
# ------------------------------------------------------------------
# Fehlerbehandlung — einheitliches JSON-Format
# ------------------------------------------------------------------
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error(f"Unbehandelter Fehler: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"detail": "Interner Serverfehler."}
)
# ------------------------------------------------------------------
# Statische Dateien + SPA-Fallback
# ------------------------------------------------------------------
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
app.mount("/css", StaticFiles(directory=f"{STATIC_DIR}/css"), name="css")
app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js")
app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons")
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
@app.get("/favicon.ico")
async def favicon():
return FileResponse(f"{STATIC_DIR}/icons/favicon.ico")
@app.get("/manifest.json")
async def manifest():
return FileResponse(f"{STATIC_DIR}/manifest.json")
@app.get("/sw.js")
async def service_worker():
return FileResponse(
f"{STATIC_DIR}/sw.js",
headers={"Cache-Control": "no-cache, no-store, must-revalidate"}
)
# Web Share Target
@app.post("/share")
async def share_target(request: Request):
# Empfängt geteilte Inhalte vom Handy (Fotos, Links, Text)
# Weiterleitung zur App mit den Daten
return FileResponse(
f"{STATIC_DIR}/index.html",
headers={"Cache-Control": "no-cache"}
)
# Öffentliche Hunde-Profilseite (für NFC-Tags, kein Login nötig)
@app.get("/hund/{dog_id}")
async def public_dog_page(dog_id: int):
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hunde-Profil — BAN YARO</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/components.css">
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: var(--font-sans);
background: var(--c-bg);
color: var(--c-text);
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-6) var(--space-4);
}}
.profile-card {{
background: var(--c-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
max-width: 440px;
width: 100%;
padding: var(--space-8) var(--space-6);
text-align: center;
}}
.dog-photo {{
width: 140px;
height: 140px;
border-radius: 50%;
object-fit: cover;
border: 4px solid var(--c-primary);
margin-bottom: var(--space-4);
}}
.dog-photo-placeholder {{
width: 140px;
height: 140px;
border-radius: 50%;
background: var(--c-surface-2);
border: 4px solid var(--c-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
margin: 0 auto var(--space-4);
}}
.dog-name {{
font-size: var(--text-2xl);
font-weight: var(--weight-bold);
color: var(--c-text);
margin-bottom: var(--space-1);
}}
.dog-rasse {{
font-size: var(--text-base);
color: var(--c-text-secondary);
margin-bottom: var(--space-5);
}}
.info-row {{
display: flex;
justify-content: center;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-5);
}}
.info-pill {{
background: var(--c-primary-subtle);
border-radius: var(--radius-full);
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
color: var(--c-primary-dark);
font-weight: var(--weight-medium);
}}
.dog-bio {{
background: var(--c-surface-2);
border-radius: var(--radius-md);
padding: var(--space-4);
font-style: italic;
color: var(--c-text-secondary);
line-height: var(--leading-relaxed);
margin-bottom: var(--space-5);
text-align: left;
}}
.besitzer {{
font-size: var(--text-sm);
color: var(--c-text-muted);
margin-bottom: var(--space-6);
}}
.found-section {{
border-top: 1px solid var(--c-border-light);
padding-top: var(--space-6);
}}
.found-hint {{
font-size: var(--text-sm);
color: var(--c-text-secondary);
margin-bottom: var(--space-4);
}}
.found-fields {{
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
}}
.found-input {{
width: 100%;
padding: var(--space-3) var(--space-4);
border: 1.5px solid var(--c-border);
border-radius: var(--radius-md);
font-size: var(--text-base);
font-family: var(--font-sans);
background: var(--c-surface);
color: var(--c-text);
outline: none;
transition: border-color var(--transition-fast);
}}
.found-input:focus {{
border-color: var(--c-primary);
}}
.found-btn {{
width: 100%;
}}
.success-msg {{
background: var(--c-success-subtle);
color: var(--c-success);
border-radius: var(--radius-md);
padding: var(--space-4);
font-weight: var(--weight-medium);
display: none;
}}
.error-msg {{
background: var(--c-danger-subtle);
color: var(--c-danger);
border-radius: var(--radius-md);
padding: var(--space-3);
font-size: var(--text-sm);
display: none;
}}
.loading {{
color: var(--c-text-muted);
padding: var(--space-12) 0;
font-size: var(--text-lg);
}}
.not-found {{
text-align: center;
color: var(--c-text-secondary);
padding: var(--space-12) var(--space-4);
}}
.not-found .icon {{ font-size: 4rem; margin-bottom: var(--space-4); display: block; }}
.app-logo {{
font-size: var(--text-sm);
color: var(--c-text-muted);
margin-top: var(--space-8);
}}
.app-logo a {{
color: var(--c-primary);
text-decoration: none;
font-weight: var(--weight-medium);
}}
</style>
</head>
<body>
<div class="profile-card" id="profile-card">
<div class="loading">Lade Profil…</div>
</div>
<p class="app-logo">powered by <a href="/">BAN YARO</a></p>
<script>
const DOG_ID = {dog_id};
function esc(s) {{
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}}
function calcAlter(geburtstag) {{
const born = new Date(geburtstag + 'T00:00:00');
const tage = Math.floor((Date.now() - born) / 86400000);
if (tage < 0) return '';
if (tage < 30) return tage + ' Tag' + (tage !== 1 ? 'e' : '') + ' alt';
if (tage < 365) {{
const m = Math.floor(tage / 30);
return m + ' Monat' + (m !== 1 ? 'e' : '') + ' alt';
}}
const j = Math.floor(tage / 365);
const m = Math.floor((tage % 365) / 30);
return m > 0
? j + ' Jahr' + (j !== 1 ? 'e' : '') + ', ' + m + ' Monat' + (m !== 1 ? 'e' : '') + ' alt'
: j + ' Jahr' + (j !== 1 ? 'e' : '') + ' alt';
}}
async function load() {{
const card = document.getElementById('profile-card');
try {{
const resp = await fetch('/api/dogs/public/' + DOG_ID);
if (!resp.ok) {{
card.innerHTML = `
<div class="not-found">
<span class="icon">🐾</span>
<p>Dieses Profil ist nicht öffentlich oder wurde nicht gefunden.</p>
</div>`;
return;
}}
const dog = await resp.json();
const photoHTML = dog.foto_url
? `<img class="dog-photo" src="${{esc(dog.foto_url)}}" alt="${{esc(dog.name)}}">`
: `<div class="dog-photo-placeholder">🐕</div>`;
const pills = [];
if (dog.geburtstag) {{
pills.push(`<span class="info-pill">🎂 ${{calcAlter(dog.geburtstag)}}</span>`);
}}
const bioHTML = dog.bio
? `<div class="dog-bio">"${{esc(dog.bio)}}"</div>`
: '';
// Vorname des Besitzers
const vorname = dog.besitzer_name ? dog.besitzer_name.split(' ')[0] : '';
card.innerHTML = `
${{photoHTML}}
<h1 class="dog-name">${{esc(dog.name)}}</h1>
${{dog.rasse ? `<p class="dog-rasse">${{esc(dog.rasse)}}</p>` : '<p class="dog-rasse"></p>'}}
${{pills.length ? `<div class="info-row">${{pills.join('')}}</div>` : ''}}
${{bioHTML}}
${{vorname ? `<p class="besitzer">Besitzer: ${{esc(vorname)}}</p>` : ''}}
<div class="found-section">
<p class="found-hint">Hast du diesen Hund gefunden? Benachrichtige den Besitzer!</p>
<div class="found-fields">
<input class="found-input" type="text" id="found-msg"
placeholder="Kurze Nachricht (optional)" maxlength="200">
<input class="found-input" type="text" id="found-kontakt"
placeholder="Deine Telefonnummer / E-Mail (optional)" maxlength="100">
</div>
<button class="btn btn-primary found-btn" id="found-btn"
onclick="sendFound()">
🐾 Ich habe diesen Hund gefunden
</button>
<div class="success-msg" id="found-success">
✅ Benachrichtigung wurde gesendet. Der Besitzer wurde informiert!
</div>
<div class="error-msg" id="found-error"></div>
</div>
`;
}} catch(e) {{
card.innerHTML = `<div class="not-found"><span class="icon">⚠️</span><p>Fehler beim Laden.</p></div>`;
}}
}}
async function sendFound() {{
const btn = document.getElementById('found-btn');
const success = document.getElementById('found-success');
const error = document.getElementById('found-error');
const msg = document.getElementById('found-msg')?.value || '';
const kontakt = document.getElementById('found-kontakt')?.value || '';
btn.disabled = true;
btn.textContent = 'Sende…';
error.style.display = 'none';
try {{
const resp = await fetch('/api/dogs/public/' + DOG_ID + '/found', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{ message: msg, kontakt: kontakt }}),
}});
if (!resp.ok) throw new Error('Fehler beim Senden.');
btn.style.display = 'none';
success.style.display = 'block';
}} catch(e) {{
btn.disabled = false;
btn.textContent = '🐾 Ich habe diesen Hund gefunden';
error.textContent = 'Fehler beim Senden. Bitte versuche es erneut.';
error.style.display = 'block';
}}
}}
load();
</script>
</body>
</html>"""
from fastapi.responses import HTMLResponse
return HTMLResponse(content=html)
# ------------------------------------------------------------------
# Einladungsseite /teilen/{token} — SPA lädt + nimmt Einladung an
# ------------------------------------------------------------------
@app.get("/teilen/{token}")
async def invite_page(token: str):
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"})
# ------------------------------------------------------------------
# Widget-Vorschau /widget
# ------------------------------------------------------------------
@app.get("/widget")
async def widget_page():
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"})
# ------------------------------------------------------------------
# Digitaler Heimtierausweis /ausweis/{dog_id}
# ------------------------------------------------------------------
@app.get("/ausweis/{dog_id}")
async def ausweis_page(dog_id: int, request: Request):
from fastapi.responses import HTMLResponse
from auth import get_current_user_optional, decode_token
import json as _json
# Auth via Cookie
token = request.cookies.get("by_token")
user_id = None
if token:
try:
payload = decode_token(token)
user_id = int(payload["sub"])
except Exception:
pass
if not user_id:
return HTMLResponse(
'<meta charset="UTF-8"><p style="font-family:sans-serif;padding:2rem">'
'Bitte <a href="/">einloggen</a> um den Ausweis anzuzeigen.</p>',
status_code=401
)
from database import db as _db
with _db() as conn:
dog = conn.execute(
"""SELECT d.* FROM dogs d
LEFT JOIN dog_shares ds ON ds.dog_id=d.id AND ds.shared_with_id=? AND ds.accepted_at IS NOT NULL
WHERE d.id=? AND (d.user_id=? OR ds.id IS NOT NULL)""",
(user_id, dog_id, user_id)
).fetchone()
if not dog:
return HTMLResponse("<p>Hund nicht gefunden.</p>", status_code=404)
owner = conn.execute("SELECT name, email FROM users WHERE id=?", (dog["user_id"],)).fetchone()
health_rows = conn.execute(
"SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
(dog_id,)
).fetchall()
vets = conn.execute(
"""SELECT DISTINCT t.name, t.strasse, t.plz, t.ort, t.telefon
FROM tieraerzte t
JOIN health h ON h.tierarzt_id = t.id
WHERE h.dog_id=?""",
(dog_id,)
).fetchall()
dog = dict(dog)
vets = [dict(v) for v in vets]
def esc(s):
if not s: return ""
return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace('"',"&quot;")
def fmt_date(d):
if not d: return ""
try:
from datetime import date
parts = d.split("-")
return f"{int(parts[2])}.{int(parts[1])}.{parts[0]}"
except Exception:
return d
geschlecht = {"m": "Rüde", "w": "Hündin"}.get(dog.get("geschlecht",""), "")
# Impfungen
impfungen = [r for r in health_rows if r["typ"] == "impfung"]
# Medikamente (aktiv)
medis = [r for r in health_rows if r["typ"] == "medikament" and r["aktiv"]]
# Allergien
allergien = [r for r in health_rows if r["typ"] == "allergie"]
def health_rows_html(rows, cols):
if not rows:
return '<tr><td colspan="99" style="color:#999;font-style:italic">Keine Einträge</td></tr>'
out = ""
for r in rows:
out += "<tr>" + "".join(f"<td>{esc(r[c])}</td>" for c in cols) + "</tr>"
return out
photo_html = f'<img src="{esc(dog["foto_url"])}" alt="{esc(dog["name"])}" class="dog-photo">' if dog.get("foto_url") else '<div class="dog-photo-placeholder">🐕</div>'
vets_html = ""
for v in vets:
addr = ", ".join(filter(None, [v.get("strasse"), v.get("plz"), v.get("ort")]))
vets_html += f'<div class="vet-card"><strong>{esc(v["name"])}</strong>'
if addr: vets_html += f'<br><small>{esc(addr)}</small>'
if v.get("telefon"): vets_html += f'<br><small>☎ {esc(v["telefon"])}</small>'
vets_html += "</div>"
if not vets_html:
vets_html = '<span style="color:#999;font-style:italic">Keine Tierärzte eingetragen</span>'
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Heimtierausweis {esc(dog["name"])}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: "Segoe UI", Arial, sans-serif; background: #FAF7F2; color: #1a1a1a; padding: 2rem; }}
.ausweis {{ max-width: 800px; margin: 0 auto; background: #fff; border-radius: 12px; box-shadow: 0 2px 20px rgba(0,0,0,.08); overflow: hidden; }}
.header {{ background: linear-gradient(135deg, #C4843A, #e8a857); color: #fff; padding: 2rem; display: flex; gap: 1.5rem; align-items: center; }}
.dog-photo {{ width: 100px; height: 100px; border-radius: 50%; object-fit: cover; border: 3px solid rgba(255,255,255,.6); flex-shrink: 0; }}
.dog-photo-placeholder {{ width: 100px; height: 100px; border-radius: 50%; background: rgba(255,255,255,.2); display: flex; align-items: center; justify-content: center; font-size: 2.5rem; flex-shrink: 0; }}
.header-info h1 {{ font-size: 1.8rem; font-weight: 700; }}
.header-info .rasse {{ opacity: .85; font-size: 1rem; margin-top: .2rem; }}
.meta-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: .5rem; margin-top: 1rem; }}
.meta-item {{ background: rgba(255,255,255,.15); border-radius: 8px; padding: .5rem .75rem; }}
.meta-item .label {{ font-size: .65rem; opacity: .8; text-transform: uppercase; letter-spacing: .05em; }}
.meta-item .value {{ font-weight: 600; font-size: .9rem; margin-top: .1rem; }}
.body {{ padding: 1.5rem 2rem; }}
.section {{ margin-bottom: 1.5rem; }}
.section h2 {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: #C4843A; font-weight: 700; margin-bottom: .75rem; padding-bottom: .4rem; border-bottom: 2px solid #f0e8dc; }}
table {{ width: 100%; border-collapse: collapse; font-size: .85rem; }}
th {{ text-align: left; font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; color: #888; font-weight: 600; padding: .4rem .5rem; border-bottom: 1px solid #eee; }}
td {{ padding: .45rem .5rem; border-bottom: 1px solid #f5f5f5; vertical-align: top; }}
tr:last-child td {{ border-bottom: none; }}
.vet-card {{ display: inline-block; background: #f9f6f2; border: 1px solid #ede5d8; border-radius: 8px; padding: .6rem 1rem; margin-right: .5rem; margin-bottom: .5rem; font-size: .85rem; line-height: 1.5; }}
.print-btn {{ display: block; margin: 0 auto 1.5rem; padding: .6rem 2rem; background: #C4843A; color: #fff; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; }}
.footer {{ text-align: center; padding: 1rem; font-size: .7rem; color: #aaa; border-top: 1px solid #f0f0f0; }}
@media print {{
body {{ background: #fff; padding: 0; }}
.ausweis {{ box-shadow: none; border-radius: 0; }}
.print-btn {{ display: none; }}
.no-print {{ display: none; }}
}}
</style>
</head>
<body>
<div class="ausweis">
<div class="header">
{photo_html}
<div class="header-info">
<h1>{esc(dog["name"])}</h1>
<div class="rasse">{esc(dog.get("rasse") or "Rasse unbekannt")}</div>
<div class="meta-grid">
<div class="meta-item"><div class="label">Geburtstag</div><div class="value">{fmt_date(dog.get("geburtstag"))}</div></div>
<div class="meta-item"><div class="label">Geschlecht</div><div class="value">{geschlecht}</div></div>
<div class="meta-item"><div class="label">Gewicht</div><div class="value">{f'{dog["gewicht_kg"]} kg' if dog.get("gewicht_kg") else ""}</div></div>
<div class="meta-item"><div class="label">Transponder</div><div class="value">{esc(dog.get("chip_nr")) or ""}</div></div>
<div class="meta-item"><div class="label">Besitzer</div><div class="value">{esc(owner["name"]) if owner else ""}</div></div>
</div>
</div>
</div>
<div class="body">
<button class="print-btn no-print" onclick="window.print()">🖨 Drucken / Als PDF speichern</button>
<div class="section">
<h2>Impfungen</h2>
<table>
<thead><tr><th>Impfung</th><th>Datum</th><th>Nächste Fälligkeit</th><th>Charge</th><th>Tierarzt</th></tr></thead>
<tbody>{health_rows_html(impfungen, ["bezeichnung","datum","naechstes","charge_nr","tierarzt_name"])}</tbody>
</table>
</div>
<div class="section">
<h2>Aktive Medikamente</h2>
<table>
<thead><tr><th>Medikament</th><th>Seit</th><th>Dosierung</th><th>Häufigkeit</th></tr></thead>
<tbody>{health_rows_html(medis, ["bezeichnung","datum","dosierung","haeufigkeit"])}</tbody>
</table>
</div>
<div class="section">
<h2>Allergien &amp; Unverträglichkeiten</h2>
<table>
<thead><tr><th>Allergen</th><th>Schweregrad</th><th>Reaktion</th><th>Seit</th></tr></thead>
<tbody>{health_rows_html(allergien, ["bezeichnung","schweregrad","reaktion","datum"])}</tbody>
</table>
</div>
<div class="section">
<h2>Tierärzte</h2>
{vets_html}
</div>
</div>
<div class="footer">Erstellt mit BAN YARO · banyaro.app · {fmt_date(__import__("datetime").date.today().isoformat())}</div>
</div>
</body>
</html>"""
return HTMLResponse(html)
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
return FileResponse(
f"{STATIC_DIR}/index.html",
headers={"Cache-Control": "no-cache"}
)