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

377
backend/routes/passport.py Normal file
View file

@ -0,0 +1,377 @@
"""BAN YARO — Digitaler Hundepass"""
import io
import secrets
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class PassportMeta(BaseModel):
blutgruppe: Optional[str] = None
allergien: Optional[str] = None
besonderheiten: Optional[str] = None
class VaccinationCreate(BaseModel):
krankheit: str
datum: str
naechste: Optional[str] = None
tierarzt: Optional[str] = None
charge_nr: Optional[str] = None
class MedicationCreate(BaseModel):
name: str
dosierung: Optional[str] = None
von: Optional[str] = None
bis: Optional[str] = None
notiz: Optional[str] = None
# ------------------------------------------------------------------
# Hilfsfunktion: Eigentümer-Prüfung
# ------------------------------------------------------------------
def _get_own_dog(conn, dog_id: int, user_id: int):
dog = conn.execute(
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
return dog
def _load_passport_data(conn, dog_id: int) -> dict:
dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
meta = conn.execute(
"SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)
).fetchone()
vaccinations = conn.execute(
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
).fetchall()
medications = conn.execute(
"SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
).fetchall()
return {
"dog": dict(dog),
"meta": dict(meta) if meta else {},
"vaccinations": [dict(v) for v in vaccinations],
"medications": [dict(m) for m in medications],
}
# ------------------------------------------------------------------
# GET /passport/{dog_id} — vollständige Passdaten
# ------------------------------------------------------------------
@router.get("/{dog_id}")
async def get_passport(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
return _load_passport_data(conn, dog_id)
# ------------------------------------------------------------------
# PUT /passport/{dog_id}/meta
# ------------------------------------------------------------------
@router.put("/{dog_id}/meta")
async def update_meta(dog_id: int, data: PassportMeta, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute("""
INSERT INTO dog_passport_meta (dog_id, blutgruppe, allergien, besonderheiten, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(dog_id) DO UPDATE SET
blutgruppe = excluded.blutgruppe,
allergien = excluded.allergien,
besonderheiten = excluded.besonderheiten,
updated_at = excluded.updated_at
""", (dog_id, data.blutgruppe, data.allergien, data.besonderheiten))
return {"ok": True}
# ------------------------------------------------------------------
# POST /passport/{dog_id}/vaccinations
# ------------------------------------------------------------------
@router.post("/{dog_id}/vaccinations")
async def add_vaccination(dog_id: int, data: VaccinationCreate, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute("""
INSERT INTO vaccinations (dog_id, krankheit, datum, naechste, tierarzt, charge_nr)
VALUES (?, ?, ?, ?, ?, ?)
""", (dog_id, data.krankheit, data.datum, data.naechste, data.tierarzt, data.charge_nr))
row = conn.execute(
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /passport/{dog_id}/vaccinations/{vacc_id}
# ------------------------------------------------------------------
@router.delete("/{dog_id}/vaccinations/{vacc_id}", status_code=204)
async def delete_vaccination(dog_id: int, vacc_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute(
"DELETE FROM vaccinations WHERE id=? AND dog_id=?", (vacc_id, dog_id)
)
# ------------------------------------------------------------------
# POST /passport/{dog_id}/medications
# ------------------------------------------------------------------
@router.post("/{dog_id}/medications")
async def add_medication(dog_id: int, data: MedicationCreate, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute("""
INSERT INTO medications (dog_id, name, dosierung, von, bis, notiz)
VALUES (?, ?, ?, ?, ?, ?)
""", (dog_id, data.name, data.dosierung, data.von, data.bis, data.notiz))
row = conn.execute(
"SELECT * FROM medications WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /passport/{dog_id}/medications/{med_id}
# ------------------------------------------------------------------
@router.delete("/{dog_id}/medications/{med_id}", status_code=204)
async def delete_medication(dog_id: int, med_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
conn.execute(
"DELETE FROM medications WHERE id=? AND dog_id=?", (med_id, dog_id)
)
# ------------------------------------------------------------------
# POST /passport/{dog_id}/share — Share-Token erstellen
# ------------------------------------------------------------------
@router.post("/{dog_id}/share")
async def create_share(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
token = secrets.token_urlsafe(32)
valid_until = (date.today() + timedelta(days=30)).isoformat()
conn.execute("""
INSERT INTO passport_shares (dog_id, token, valid_until)
VALUES (?, ?, ?)
""", (dog_id, token, valid_until))
return {
"token": token,
"valid_until": valid_until,
"url": f"/pass/{token}",
}
# ------------------------------------------------------------------
# GET /passport/share/{token} — öffentlicher Endpunkt (kein Auth)
# ------------------------------------------------------------------
@router.get("/share/{token}")
async def get_shared_passport(token: str):
with db() as conn:
share = conn.execute(
"SELECT * FROM passport_shares WHERE token=?", (token,)
).fetchone()
if not share:
raise HTTPException(404, "Link nicht gefunden.")
if share["valid_until"] < date.today().isoformat():
raise HTTPException(410, "Dieser Link ist abgelaufen.")
return _load_passport_data(conn, share["dog_id"])
# ------------------------------------------------------------------
# GET /passport/{dog_id}/pdf — PDF generieren
# ------------------------------------------------------------------
@router.get("/{dog_id}/pdf")
async def download_pdf(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_get_own_dog(conn, dog_id, user["id"])
data = _load_passport_data(conn, dog_id)
pdf_bytes = _generate_pdf(data)
dog_name = data["dog"]["name"].replace(" ", "_")
filename = f"Hundepass_{dog_name}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ------------------------------------------------------------------
# PDF-Generierung mit fpdf2
# ------------------------------------------------------------------
def _generate_pdf(data: dict) -> bytes:
try:
from fpdf import FPDF
except ImportError:
raise HTTPException(500, "PDF-Bibliothek nicht verfügbar. Bitte fpdf2 installieren.")
dog = data["dog"]
meta = data["meta"]
vaccs = data["vaccinations"]
meds = data["medications"]
# Datumsformatierung DE
def _fmt_date(d):
if not d:
return ""
try:
return datetime.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return d
# Geschlecht
geschlecht_map = {"m": "Rüde", "w": "Hündin"}
pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=20)
pdf.add_page()
# ---- Header ----
pdf.set_fill_color(40, 167, 100) # Ban Yaro Grün
pdf.rect(0, 0, 210, 38, style="F")
pdf.set_text_color(255, 255, 255)
pdf.set_font("Helvetica", style="B", size=20)
pdf.set_y(8)
pdf.cell(0, 10, "Ban Yaro", align="C", ln=True)
pdf.set_font("Helvetica", size=11)
pdf.cell(0, 8, f"Digitaler Hundepass — {dog['name']}", align="C", ln=True)
pdf.set_font("Helvetica", size=8)
pdf.cell(0, 6, f"Erstellt am {date.today().strftime('%d.%m.%Y')}", align="C", ln=True)
pdf.set_text_color(30, 30, 30)
pdf.set_y(46)
# ---- Hundedaten ----
pdf.set_fill_color(245, 250, 247)
pdf.set_draw_color(200, 200, 200)
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Hundeangaben", ln=True, fill=True, border="B")
pdf.ln(3)
def _info_row(label, value):
pdf.set_font("Helvetica", style="B", size=9)
pdf.cell(45, 6, label + ":", ln=False)
pdf.set_font("Helvetica", size=9)
pdf.cell(0, 6, str(value) if value else "", ln=True)
_info_row("Name", dog["name"])
_info_row("Rasse", dog.get("rasse") or "")
_info_row("Geburtstag", _fmt_date(dog.get("geburtstag")))
_info_row("Geschlecht", geschlecht_map.get(dog.get("geschlecht", ""), ""))
_info_row("Chip-Nr.", dog.get("chip_nr") or "")
if meta.get("blutgruppe"):
_info_row("Blutgruppe", meta["blutgruppe"])
pdf.ln(5)
# ---- Allergien & Besonderheiten ----
if meta.get("allergien") or meta.get("besonderheiten"):
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Allergien & Besonderheiten", ln=True, fill=True, border="B")
pdf.ln(3)
if meta.get("allergien"):
pdf.set_font("Helvetica", style="B", size=9)
pdf.cell(45, 6, "Allergien:", ln=False)
pdf.set_font("Helvetica", size=9)
pdf.multi_cell(0, 6, meta["allergien"])
if meta.get("besonderheiten"):
pdf.set_font("Helvetica", style="B", size=9)
pdf.cell(45, 6, "Besonderheiten:", ln=False)
pdf.set_font("Helvetica", size=9)
pdf.multi_cell(0, 6, meta["besonderheiten"])
pdf.ln(5)
# ---- Impfungen ----
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Impfungen", ln=True, fill=True, border="B")
pdf.ln(3)
if vaccs:
# Tabellen-Header
pdf.set_fill_color(220, 240, 228)
pdf.set_font("Helvetica", style="B", size=8)
pdf.cell(50, 6, "Krankheit", border=1, fill=True)
pdf.cell(25, 6, "Datum", border=1, fill=True)
pdf.cell(25, 6, "Nächste fällig", border=1, fill=True)
pdf.cell(55, 6, "Tierarzt", border=1, fill=True)
pdf.cell(35, 6, "Charge-Nr.", border=1, fill=True, ln=True)
pdf.set_font("Helvetica", size=8)
for i, v in enumerate(vaccs):
fill = (i % 2 == 0)
pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255)
pdf.cell(50, 6, (v["krankheit"] or "")[:28], border=1, fill=fill)
pdf.cell(25, 6, _fmt_date(v["datum"]), border=1, fill=fill)
pdf.cell(25, 6, _fmt_date(v["naechste"]), border=1, fill=fill)
pdf.cell(55, 6, (v["tierarzt"] or "")[:32], border=1, fill=fill)
pdf.cell(35, 6, (v["charge_nr"] or "")[:20], border=1, fill=fill, ln=True)
else:
pdf.set_font("Helvetica", style="I", size=9)
pdf.set_text_color(140, 140, 140)
pdf.cell(0, 6, "Keine Impfungen eingetragen.", ln=True)
pdf.set_text_color(30, 30, 30)
pdf.ln(5)
# ---- Medikamente ----
pdf.set_font("Helvetica", style="B", size=12)
pdf.set_fill_color(235, 247, 240)
pdf.cell(0, 8, " Medikamente", ln=True, fill=True, border="B")
pdf.ln(3)
if meds:
pdf.set_fill_color(220, 240, 228)
pdf.set_font("Helvetica", style="B", size=8)
pdf.cell(55, 6, "Medikament", border=1, fill=True)
pdf.cell(35, 6, "Dosierung", border=1, fill=True)
pdf.cell(25, 6, "Von", border=1, fill=True)
pdf.cell(25, 6, "Bis", border=1, fill=True)
pdf.cell(50, 6, "Notiz", border=1, fill=True, ln=True)
pdf.set_font("Helvetica", size=8)
for i, m in enumerate(meds):
fill = (i % 2 == 0)
pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255)
pdf.cell(55, 6, (m["name"] or "")[:32], border=1, fill=fill)
pdf.cell(35, 6, (m["dosierung"] or "")[:22], border=1, fill=fill)
pdf.cell(25, 6, _fmt_date(m["von"]), border=1, fill=fill)
bis = _fmt_date(m["bis"]) if m["bis"] else "dauerhaft"
pdf.cell(25, 6, bis, border=1, fill=fill)
pdf.cell(50, 6, (m["notiz"] or "")[:30], border=1, fill=fill, ln=True)
else:
pdf.set_font("Helvetica", style="I", size=9)
pdf.set_text_color(140, 140, 140)
pdf.cell(0, 6, "Keine Medikamente eingetragen.", ln=True)
pdf.set_text_color(30, 30, 30)
# ---- Footer ----
pdf.set_y(-15)
pdf.set_font("Helvetica", style="I", size=8)
pdf.set_text_color(140, 140, 140)
pdf.cell(0, 5, "Erstellt mit Ban Yaro — banyaro.app", align="C", ln=True)
return bytes(pdf.output())