Feature: Sprint31 — 9 Features merged (Streak, Ausgaben, KI-Tierarzt, Rückrufe, Adoption, Vet+Befunde, Hundepass, Playdate, Rassenerkennung)

- Trainings-Streak: streak.py, DB training_streaks, Scheduler 19:00, Widget in welcome.js, Ping in uebungen.js
- Ausgaben-Tracker: expenses.py, expenses.js, DB expenses-Tabelle
- KI-Tierarztfragen: ki.py /tierarzt, health.js Button+Modal, DB ki_tierarzt_log
- Rückruf-Alarm: recalls.py, recalls.js, DB feed_recalls, Scheduler 08:00 RASFF
- Adoption: adoption.py, adoption.js, DB adoption_cache
- Tierarzt-Favorit + Befunde: tieraerzte.py /my-favorite+/favorite, health_docs.py, health.js, api.js, DB favorite_vets+health_documents
- Digitaler Hundepass: passport.py, dog-profile.js, main.py /pass/{token}, DB vaccinations+medications+dog_passport_meta+passport_shares, requirements.txt fpdf2
- Playdate-Matching: playdate.py, playdate.js, DB playdate_listings+playdate_requests
- Rassen-Erkennung: ki.py /rasse-erkennung (Claude Vision), dog-profile.js+wiki.js, CSS .rasse-result-card, DB ki_rasse_log
This commit is contained in:
rene 2026-05-02 09:29:48 +02:00
parent 031c6028ac
commit 742ad189e8
26 changed files with 5734 additions and 27 deletions

View file

@ -189,6 +189,13 @@ from routes.zucht_ki import router as zucht_ki_router
from routes.partner import router as partner_router
from routes.outreach import router as outreach_router
from routes.jobs import router as jobs_router
from routes.streak import router as streak_router
from routes.expenses import router as expenses_router
from routes.recalls import router as recalls_router
from routes.adoption import router as adoption_router
from routes.health_docs import router as health_docs_router
from routes.passport import router as passport_router
from routes.playdate import router as playdate_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -240,6 +247,13 @@ app.include_router(training_router, prefix="/api/training", tags=
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
app.include_router(streak_router, prefix="/api", tags=["Streak"])
app.include_router(expenses_router, prefix="/api/expenses", tags=["Ausgaben"])
app.include_router(recalls_router, prefix="/api/recalls", tags=["Rückrufe"])
app.include_router(adoption_router, prefix="/api/adoption", tags=["Adoption"])
app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"])
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
# ------------------------------------------------------------------
@ -1674,6 +1688,152 @@ for _hp in _HONEYPOT_PATHS:
app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False)
# ------------------------------------------------------------------
# Digitaler Hundepass — öffentlicher Share-Link (kein Login nötig)
# ------------------------------------------------------------------
@app.get("/pass/{token}")
async def passport_share_page(token: str):
from fastapi.responses import HTMLResponse
from database import db as _db
from datetime import date as _date
with _db() as conn:
share = conn.execute(
"SELECT * FROM passport_shares WHERE token=?", (token,)
).fetchone()
if not share:
return HTMLResponse(
'<meta charset="UTF-8"><style>body{font-family:sans-serif;padding:2rem;color:#333}</style>'
'<h2>Link nicht gefunden</h2><p>Dieser Hundepass-Link ist ungültig.</p>',
status_code=404
)
if share["valid_until"] < _date.today().isoformat():
return HTMLResponse(
'<meta charset="UTF-8"><style>body{font-family:sans-serif;padding:2rem;color:#333}</style>'
'<h2>Link abgelaufen</h2><p>Dieser Hundepass-Link ist nicht mehr gültig.</p>',
status_code=410
)
dog_id = share["dog_id"]
dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
meta = conn.execute("SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)).fetchone()
vaccs = conn.execute(
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
).fetchall()
meds = conn.execute(
"SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
).fetchall()
def _fmt(d):
if not d:
return ""
try:
from datetime import datetime as _dt
return _dt.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return d
dog = dict(dog)
meta = dict(meta) if meta else {}
vaccs = [dict(v) for v in vaccs]
meds = [dict(m) for m in meds]
_g_map = {"m": "Rüde", "w": "Hündin"}
vacc_rows = "".join(f"""
<tr>
<td>{v['krankheit'] or ''}</td>
<td>{_fmt(v['datum'])}</td>
<td>{_fmt(v['naechste'])}</td>
<td>{v['tierarzt'] or ''}</td>
<td>{v['charge_nr'] or ''}</td>
</tr>""" for v in vaccs) or "<tr><td colspan='5' style='color:#999'>Keine Einträge</td></tr>"
med_rows = "".join(f"""
<tr>
<td>{m['name'] or ''}</td>
<td>{m['dosierung'] or ''}</td>
<td>{_fmt(m['von'])}</td>
<td>{_fmt(m['bis']) if m['bis'] else 'dauerhaft'}</td>
<td>{m['notiz'] or ''}</td>
</tr>""" for m in meds) or "<tr><td colspan='5' style='color:#999'>Keine Einträge</td></tr>"
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hundepass {dog['name']}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7f5; color: #222; }}
.header {{ background: #28a764; color: #fff; padding: 24px 20px; text-align: center; }}
.header h1 {{ font-size: 1.5rem; margin-bottom: 4px; }}
.header p {{ font-size: 0.9rem; opacity: 0.85; }}
.container {{ max-width: 760px; margin: 24px auto; padding: 0 16px; }}
.card {{ background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,.08); }}
.card h2 {{ font-size: 1rem; color: #28a764; margin-bottom: 14px; display: flex;
align-items: center; gap: 8px; border-bottom: 1px solid #e8f5ee; padding-bottom: 10px; }}
.info-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }}
.info-item label {{ font-size: 0.75rem; color: #888; display: block; margin-bottom: 2px; }}
.info-item span {{ font-size: 0.9rem; font-weight: 500; }}
table {{ width: 100%; border-collapse: collapse; font-size: 0.85rem; }}
th {{ background: #e8f5ee; text-align: left; padding: 8px; font-size: 0.8rem;
color: #444; font-weight: 600; }}
td {{ padding: 8px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }}
tr:last-child td {{ border-bottom: none; }}
.footer {{ text-align: center; font-size: 0.75rem; color: #aaa; margin: 24px 0; }}
@media (max-width: 500px) {{ .info-grid {{ grid-template-columns: 1fr; }} }}
</style>
</head>
<body>
<div class="header">
<h1>Ban Yaro</h1>
<p>Digitaler Hundepass &mdash; {dog['name']}</p>
</div>
<div class="container">
<div class="card">
<h2>Hundeangaben</h2>
<div class="info-grid">
<div class="info-item"><label>Name</label><span>{dog['name']}</span></div>
<div class="info-item"><label>Rasse</label><span>{dog.get('rasse') or ''}</span></div>
<div class="info-item"><label>Geburtstag</label><span>{_fmt(dog.get('geburtstag'))}</span></div>
<div class="info-item"><label>Geschlecht</label><span>{_g_map.get(dog.get('geschlecht',''), '')}</span></div>
<div class="info-item"><label>Chip-Nr.</label><span>{dog.get('chip_nr') or ''}</span></div>
<div class="info-item"><label>Blutgruppe</label><span>{meta.get('blutgruppe') or ''}</span></div>
</div>
{('<div style="margin-top:14px"><label style="font-size:.75rem;color:#888">Allergien</label>'
f'<div style="font-size:.9rem">{meta["allergien"]}</div></div>') if meta.get("allergien") else ''}
{('<div style="margin-top:10px"><label style="font-size:.75rem;color:#888">Besonderheiten</label>'
f'<div style="font-size:.9rem">{meta["besonderheiten"]}</div></div>') if meta.get("besonderheiten") else ''}
</div>
<div class="card">
<h2>Impfungen</h2>
<table>
<thead><tr>
<th>Krankheit</th><th>Datum</th><th>Nächste</th><th>Tierarzt</th><th>Charge</th>
</tr></thead>
<tbody>{vacc_rows}</tbody>
</table>
</div>
<div class="card">
<h2>Medikamente</h2>
<table>
<thead><tr>
<th>Medikament</th><th>Dosierung</th><th>Von</th><th>Bis</th><th>Notiz</th>
</tr></thead>
<tbody>{med_rows}</tbody>
</table>
</div>
</div>
<div class="footer">Erstellt mit Ban Yaro &mdash; banyaro.app</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):