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

292
backend/routes/adoption.py Normal file
View file

@ -0,0 +1,292 @@
"""
BAN YARO Adoption (Tierheim-Hunde in der Nähe)
Strategie:
1. PetFinder API (falls API-Key gesetzt) hat kaum deutsche Tierheime, nur als Bonus
2. Statische Daten: Liste großer deutscher Tierheime mit Koordinaten
3. Fallback: Weiterleitung zu tierheimhelden.de
Caching: adoption_cache Tabelle, 24h TTL.
"""
import os
import math
import logging
import asyncio
import httpx
from datetime import datetime, timedelta
from fastapi import APIRouter, Query, BackgroundTasks
from database import db
logger = logging.getLogger(__name__)
router = APIRouter()
PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "")
PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "")
# ------------------------------------------------------------------
# Haversine — Distanz in km
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6371.0
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------
# Statische Tierheim-Daten (große deutsche Tierheime)
# ------------------------------------------------------------------
GERMAN_SHELTERS = [
# (id, name, plz, stadt, lat, lon, url)
("de_berlin_tierschutz", "Tierheim Berlin", "12459", "Berlin", 52.4862, 13.5301, "https://www.tierheim-berlin.de/tiere/hunde/"),
("de_hamburg_tierschutz", "Tierheim Hamburg (Süderstraße)", "20537", "Hamburg", 53.5539, 10.0416, "https://www.hamburger-tierschutzverein.de/hunde/"),
("de_muenchen_tierschutz", "Tierheim München", "81379", "München", 48.0982, 11.5248, "https://www.tierheim-muenchen.de/hunde/"),
("de_koeln_tierschutz", "Tierheim Köln", "50769", "Köln", 51.0168, 6.9369, "https://www.tierschutzverein-koeln.de/tiere/hunde/"),
("de_frankfurt_tierschutz", "Tierheim Frankfurt (Fechenheim)", "60386", "Frankfurt", 50.1246, 8.7597, "https://www.tierheim-frankfurt.de/hunde/"),
("de_stuttgart_tierschutz", "Tierschutzverein Stuttgart", "70193", "Stuttgart", 48.7790, 9.1634, "https://www.tierschutzverein-stuttgart.de/vermittlung/hunde/"),
("de_duesseldorf_tierheim", "Tierheim Düsseldorf", "40599", "Düsseldorf", 51.1948, 6.8488, "https://www.tierheim-duesseldorf.de/tiere/hunde/"),
("de_dortmund_tierheim", "Tierheim Dortmund", "44339", "Dortmund", 51.5481, 7.4584, "https://www.tierschutzverein-dortmund.de/hunde/"),
("de_essen_tierheim", "Tierheim Essen", "45276", "Essen", 51.4341, 7.0985, "https://www.tierheim-essen.de/tiere/hunde/"),
("de_leipzig_tierheim", "Tierheim Leipzig", "04109", "Leipzig", 51.3396, 12.3713, "https://www.tierschutzverein-leipzig.de/hunde/"),
("de_dresden_tierheim", "Tierheim Dresden", "01127", "Dresden", 51.0789, 13.7319, "https://www.tierschutzverein-dresden-heidenau.de/tiere/hunde/"),
("de_hannover_tierheim", "Tierheim Hannover", "30855", "Hannover", 52.3484, 9.7411, "https://www.tierschutzverein-hannover.de/hunde/"),
("de_nuernberg_tierheim", "Tierschutzverein Nürnberg", "90461", "Nürnberg", 49.4182, 11.0830, "https://www.tierschutzverein-nuernberg.de/tiere/hunde/"),
("de_bremen_tierheim", "Tierheim Bremen", "28307", "Bremen", 53.0440, 8.9128, "https://www.tierheim-bremen.de/hunde/"),
("de_bochum_tierheim", "Tierheim Bochum", "44793", "Bochum", 51.4753, 7.2128, "https://www.tierschutzverein-bochum.de/hunde/"),
("de_wuppertal_tierheim", "Tierheim Wuppertal", "42283", "Wuppertal", 51.2571, 7.1705, "https://www.tierschutz-wuppertal.de/hunde/"),
("de_bielefeld_tierheim", "Tierheim Bielefeld", "33649", "Bielefeld", 51.9951, 8.5327, "https://www.tierschutzverein-bielefeld.de/hunde/"),
("de_mannheim_tierheim", "Tierheim Mannheim", "68309", "Mannheim", 49.5079, 8.5033, "https://www.tierschutzverein-mannheim.de/hunde/"),
("de_karlsruhe_tierheim", "Tierheim Karlsruhe", "76229", "Karlsruhe", 48.9960, 8.4290, "https://www.tierschutzverein-karlsruhe.de/hunde/"),
("de_augsburg_tierheim", "Tierheim Augsburg", "86159", "Augsburg", 48.3668, 10.8978, "https://www.tierschutz-augsburg.de/tiere/hunde/"),
("de_freiburg_tierheim", "Tierheim Freiburg", "79115", "Freiburg", 47.9855, 7.8352, "https://www.tierschutz-freiburg.de/tiere/hunde/"),
("de_kiel_tierheim", "Tierheim Kiel", "24113", "Kiel", 54.3203, 10.1228, "https://www.tierschutzverein-kiel.de/hunde/"),
("de_magdeburg_tierheim", "Tierheim Magdeburg", "39118", "Magdeburg", 52.0814, 11.5939, "https://www.tierschutz-magdeburg.de/hunde/"),
("de_erfurt_tierheim", "Tierheim Erfurt", "99099", "Erfurt", 50.9985, 11.0424, "https://www.tierschutzverein-erfurt.de/hunde/"),
("de_rostock_tierheim", "Tierheim Rostock", "18059", "Rostock", 54.0831, 12.0965, "https://www.tierschutzverein-rostock.de/hunde/"),
]
# ------------------------------------------------------------------
# PetFinder OAuth2 Token
# ------------------------------------------------------------------
_pf_token = None
_pf_token_exp = 0.0
async def _get_pf_token() -> str | None:
global _pf_token, _pf_token_exp
if not (PETFINDER_KEY and PETFINDER_SECRET):
return None
now = asyncio.get_event_loop().time()
if _pf_token and now < _pf_token_exp - 60:
return _pf_token
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.post(
"https://api.petfinder.com/v2/oauth2/token",
data={"grant_type": "client_credentials",
"client_id": PETFINDER_KEY,
"client_secret": PETFINDER_SECRET},
)
if r.status_code == 200:
data = r.json()
_pf_token = data.get("access_token")
_pf_token_exp = now + data.get("expires_in", 3600)
return _pf_token
except Exception as e:
logger.warning(f"PetFinder OAuth: {e}")
return None
# ------------------------------------------------------------------
# PetFinder: Hunde in der Nähe holen
# ------------------------------------------------------------------
async def _fetch_petfinder(lat: float, lon: float, radius: int) -> list[dict]:
token = await _get_pf_token()
if not token:
return []
try:
async with httpx.AsyncClient(timeout=12) as client:
r = await client.get(
"https://api.petfinder.com/v2/animals",
headers={"Authorization": f"Bearer {token}"},
params={
"type": "dog",
"location": f"{lat},{lon}",
"distance": radius,
"limit": 20,
"sort": "distance",
"status": "adoptable",
},
)
if r.status_code != 200:
logger.warning(f"PetFinder API: HTTP {r.status_code}")
return []
animals = r.json().get("animals", [])
result = []
for a in animals:
org = a.get("organization_id", "")
loc = a.get("contact", {}).get("address", {})
photos = a.get("photos", [])
foto = photos[0].get("medium") if photos else None
age_map = {"Baby": 0.25, "Young": 1.0, "Adult": 4.0, "Senior": 9.0}
result.append({
"external_id": f"pf_{a['id']}",
"name": a.get("name", "Unbekannt"),
"rasse": ", ".join(
filter(None, [
a.get("breeds", {}).get("primary"),
a.get("breeds", {}).get("secondary"),
])
) or None,
"alter_jahre": age_map.get(a.get("age"), None),
"geschlecht": {"Male": "männlich", "Female": "weiblich"}.get(a.get("gender"), None),
"foto_url": foto,
"tierheim": org,
"tierheim_plz": loc.get("postcode"),
"tierheim_lat": None,
"tierheim_lon": None,
"adoptions_url": a.get("url", "https://www.petfinder.com/"),
"quelle": "petfinder",
})
return result
except Exception as e:
logger.warning(f"PetFinder Fetch: {e}")
return []
# ------------------------------------------------------------------
# Cache befüllen
# ------------------------------------------------------------------
async def _refresh_cache(lat: float, lon: float, radius: int):
"""Holt frische Daten und schreibt sie in adoption_cache."""
animals = await _fetch_petfinder(lat, lon, radius)
if not animals:
return
expires = (datetime.utcnow() + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
with db() as conn:
for a in animals:
try:
conn.execute("""
INSERT INTO adoption_cache
(external_id, name, rasse, alter_jahre, geschlecht,
foto_url, tierheim, tierheim_plz, tierheim_lat, tierheim_lon,
adoptions_url, expires_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(external_id) DO UPDATE SET
name=excluded.name,
rasse=excluded.rasse,
alter_jahre=excluded.alter_jahre,
geschlecht=excluded.geschlecht,
foto_url=excluded.foto_url,
tierheim=excluded.tierheim,
tierheim_plz=excluded.tierheim_plz,
tierheim_lat=excluded.tierheim_lat,
tierheim_lon=excluded.tierheim_lon,
adoptions_url=excluded.adoptions_url,
expires_at=excluded.expires_at
""", (
a["external_id"], a["name"], a["rasse"], a["alter_jahre"],
a["geschlecht"], a["foto_url"], a["tierheim"], a["tierheim_plz"],
a["tierheim_lat"], a["tierheim_lon"], a["adoptions_url"], expires,
))
except Exception as e:
logger.warning(f"Cache insert: {e}")
# ------------------------------------------------------------------
# GET /api/adoption/nearby
# ------------------------------------------------------------------
@router.get("/nearby")
async def adoption_nearby(
lat: float = Query(..., description="Breitengrad"),
lon: float = Query(..., description="Längengrad"),
radius: int = Query(50, ge=5, le=200, description="Radius in km"),
background_tasks: BackgroundTasks = None,
):
"""
Gibt Adoptionshunde in der Nähe zurück.
Priorisierung:
1. Frische PetFinder-Einträge aus Cache
2. Statische Tierheim-Liste (immer vorhanden, mit Entfernung)
"""
now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
# ------ Cache lesen ------
cached_animals = []
with db() as conn:
rows = conn.execute("""
SELECT * FROM adoption_cache
WHERE expires_at > ?
ORDER BY created_at DESC
""", (now_str,)).fetchall()
for row in rows:
d = dict(row)
if d.get("tierheim_lat") and d.get("tierheim_lon"):
dist = _haversine(lat, lon, d["tierheim_lat"], d["tierheim_lon"])
if dist <= radius:
d["distanz_km"] = round(dist, 1)
cached_animals.append(d)
else:
# PetFinder-Einträge ohne Koordinaten: immer anzeigen
d["distanz_km"] = None
cached_animals.append(d)
# ------ Cache refreshen wenn leer oder alt ------
if not cached_animals and background_tasks is not None:
background_tasks.add_task(_refresh_cache, lat, lon, radius)
# ------ Statische Tierheime (immer) ------
shelters = []
for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS:
dist = _haversine(lat, lon, slat, slon)
if dist <= radius:
shelters.append({
"id": sid,
"name": name,
"plz": plz,
"stadt": stadt,
"lat": slat,
"lon": slon,
"url": url,
"distanz_km": round(dist, 1),
})
shelters.sort(key=lambda x: x["distanz_km"])
return {
"animals": cached_animals[:40],
"shelters": shelters[:10],
"has_petfinder": bool(PETFINDER_KEY),
}
# ------------------------------------------------------------------
# GET /api/adoption/geocode?plz=… — PLZ → Koordinaten via Nominatim
# ------------------------------------------------------------------
@router.get("/geocode")
async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
"""Wandelt eine PLZ in Koordinaten um (via Nominatim)."""
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.get(
"https://nominatim.openstreetmap.org/search",
params={
"q": f"{plz}, Germany",
"format": "json",
"limit": 1,
"accept-language": "de",
"countrycodes": "de",
},
headers={"User-Agent": "BanYaro/1.0 (https://banyaro.app)"},
)
results = r.json()
if results:
return {"lat": float(results[0]["lat"]), "lon": float(results[0]["lon"]), "display": results[0].get("display_name", plz)}
except Exception as e:
logger.warning(f"Geocode PLZ {plz}: {e}")
return {"lat": None, "lon": None, "display": plz}

228
backend/routes/expenses.py Normal file
View file

@ -0,0 +1,228 @@
"""BAN YARO — Ausgaben-Tracker Routes"""
import logging
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonstiges"}
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ExpenseCreate(BaseModel):
dog_id: Optional[int] = None
kategorie: str
betrag: float
datum: str
notiz: Optional[str] = None
class ExpenseUpdate(BaseModel):
dog_id: Optional[int] = None
kategorie: Optional[str] = None
betrag: Optional[float] = None
datum: Optional[str] = None
notiz: Optional[str] = None
def _serialize(row) -> dict:
return dict(row)
# ------------------------------------------------------------------
# GET /api/expenses/summary — Monats- und Jahressummen
# WICHTIG: Diese Route muss VOR /{id} stehen!
# ------------------------------------------------------------------
@router.get("/summary")
async def get_summary(
dog_id: Optional[int] = Query(default=None),
user=Depends(get_current_user),
):
today = date.today()
monat_prefix = today.strftime("%Y-%m")
jahr_prefix = today.strftime("%Y")
extra_cond = ""
extra_params: list = []
if dog_id is not None:
extra_cond = " AND dog_id=?"
extra_params = [dog_id]
with db() as conn:
# Monats-Summen pro Kategorie
rows_monat = conn.execute(
f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
FROM expenses
WHERE user_id=? AND datum LIKE ?{extra_cond}
GROUP BY kategorie""",
[user["id"], f"{monat_prefix}%"] + extra_params,
).fetchall()
# Jahres-Summen pro Kategorie
rows_jahr = conn.execute(
f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
FROM expenses
WHERE user_id=? AND datum LIKE ?{extra_cond}
GROUP BY kategorie""",
[user["id"], f"{jahr_prefix}%"] + extra_params,
).fetchall()
monat = {r["kategorie"]: round(r["summe"], 2) for r in rows_monat}
jahr = {r["kategorie"]: round(r["summe"], 2) for r in rows_jahr}
gesamt_monat = round(sum(monat.values()), 2)
gesamt_jahr = round(sum(jahr.values()), 2)
return {
"monat": monat,
"jahr": jahr,
"gesamt_monat": gesamt_monat,
"gesamt_jahr": gesamt_jahr,
}
# ------------------------------------------------------------------
# GET /api/expenses — Liste mit optionalen Filtern
# ------------------------------------------------------------------
@router.get("")
async def list_expenses(
dog_id: Optional[int] = Query(default=None),
von: Optional[str] = Query(default=None),
bis: Optional[str] = Query(default=None),
limit: int = Query(default=100, le=500),
offset: int = Query(default=0),
user=Depends(get_current_user),
):
conditions = ["e.user_id=?"]
params: list = [user["id"]]
if dog_id is not None:
conditions.append("e.dog_id=?")
params.append(dog_id)
if von:
conditions.append("e.datum >= ?")
params.append(von)
if bis:
conditions.append("e.datum <= ?")
params.append(bis)
where = " AND ".join(conditions)
params += [limit, offset]
with db() as conn:
rows = conn.execute(
f"""SELECT e.*, d.name AS dog_name
FROM expenses e
LEFT JOIN dogs d ON d.id = e.dog_id
WHERE {where}
ORDER BY e.datum DESC, e.id DESC
LIMIT ? OFFSET ?""",
params,
).fetchall()
return [_serialize(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/expenses — neuer Eintrag
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def create_expense(data: ExpenseCreate, user=Depends(get_current_user)):
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
if data.betrag <= 0:
raise HTTPException(400, "Betrag muss größer als 0 sein.")
with db() as conn:
# dog_id prüfen — muss dem User gehören
if data.dog_id is not None:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, user["id"]),
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
conn.execute(
"""INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
VALUES (?, ?, ?, ?, ?, ?)""",
(user["id"], data.dog_id, data.kategorie, data.betrag, data.datum, data.notiz),
)
row = conn.execute(
"SELECT * FROM expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],),
).fetchone()
return _serialize(row)
# ------------------------------------------------------------------
# PATCH /api/expenses/{id} — bearbeiten
# ------------------------------------------------------------------
@router.patch("/{expense_id}")
async def update_expense(
expense_id: int, data: ExpenseUpdate, user=Depends(get_current_user)
):
with db() as conn:
row = conn.execute(
"SELECT * FROM expenses WHERE id=? AND user_id=?",
(expense_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
updates = {}
if data.kategorie is not None:
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
updates["kategorie"] = data.kategorie
if data.betrag is not None:
if data.betrag <= 0:
raise HTTPException(400, "Betrag muss größer als 0 sein.")
updates["betrag"] = data.betrag
if data.datum is not None:
updates["datum"] = data.datum
if data.notiz is not None:
updates["notiz"] = data.notiz
if data.dog_id is not None:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, user["id"]),
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
updates["dog_id"] = data.dog_id
if not updates:
return _serialize(row)
set_clause = ", ".join(f"{k}=?" for k in updates)
values = list(updates.values()) + [expense_id]
conn.execute(f"UPDATE expenses SET {set_clause} WHERE id=?", values)
row = conn.execute("SELECT * FROM expenses WHERE id=?", (expense_id,)).fetchone()
return _serialize(row)
# ------------------------------------------------------------------
# DELETE /api/expenses/{id} — löschen
# ------------------------------------------------------------------
@router.delete("/{expense_id}", status_code=204)
async def delete_expense(expense_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id FROM expenses WHERE id=? AND user_id=?",
(expense_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
return None

View file

@ -0,0 +1,138 @@
"""BAN YARO — Gesundheitsdokumente (Befunde, Röntgen, Rezepte …)"""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
ERLAUBTE_TYPEN = {"blutbild", "roentgen", "rezept", "impfausweis", "sonstiges"}
def _check_dog_owner(conn, dog_id: int, user_id: int):
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
return dog
# ------------------------------------------------------------------
# GET /api/health-docs?dog_id=...
# ------------------------------------------------------------------
@router.get("")
async def list_docs(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
rows = conn.execute(
"""SELECT hd.*, t.name AS vet_name
FROM health_documents hd
LEFT JOIN tieraerzte t ON t.id = hd.vet_id
WHERE hd.dog_id=?
ORDER BY hd.created_at DESC""",
(dog_id,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/health-docs/upload (multipart/form-data)
# ------------------------------------------------------------------
@router.post("/upload", status_code=201)
async def upload_doc(
dog_id: int = Form(...),
typ: str = Form(...),
titel: str = Form(...),
beschreibung: Optional[str] = Form(None),
datum: Optional[str] = Form(None),
vet_id: Optional[int] = Form(None),
file: UploadFile = File(...),
user=Depends(get_current_user),
):
if typ not in ERLAUBTE_TYPEN:
raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(sorted(ERLAUBTE_TYPEN))}")
ext = os.path.splitext(file.filename or "")[1].lower()
if not ext:
ext = ".jpg"
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.")
content = await file.read()
if len(content) > MAX_SIZE_BYTES:
raise HTTPException(413, "Datei zu groß. Maximal 10 MB erlaubt.")
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
if vet_id:
vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (vet_id,)).fetchone()
if not vet:
vet_id = None
# Datei speichern
dog_dir = os.path.join(MEDIA_DIR, "health_docs", str(dog_id))
os.makedirs(dog_dir, exist_ok=True)
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(dog_dir, filename)
with open(filepath, "wb") as f:
f.write(content)
file_url = f"/media/health_docs/{dog_id}/{filename}"
file_type = "pdf" if ext == ".pdf" else ext.lstrip(".")
with db() as conn:
conn.execute(
"""INSERT INTO health_documents
(dog_id, user_id, typ, titel, beschreibung, file_path, file_type, datum, vet_id)
VALUES (?,?,?,?,?,?,?,?,?)""",
(dog_id, user["id"], typ, titel.strip(), beschreibung,
file_url, file_type, datum or None, vet_id)
)
row = conn.execute(
"""SELECT hd.*, t.name AS vet_name
FROM health_documents hd
LEFT JOIN tieraerzte t ON t.id = hd.vet_id
WHERE hd.id = last_insert_rowid()"""
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/health-docs/{id}
# ------------------------------------------------------------------
@router.delete("/{doc_id}", status_code=204)
async def delete_doc(doc_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT * FROM health_documents WHERE id=? AND user_id=?",
(doc_id, user["id"])
).fetchone()
if not row:
raise HTTPException(404, "Dokument nicht gefunden.")
# Datei löschen — file_path ist z.B. /media/health_docs/42/abc123.pdf
file_path = row["file_path"]
if file_path:
# /media/... → MEDIA_DIR/...
rel = file_path.lstrip("/")
if rel.startswith("media/"):
rel = rel[len("media/"):]
abs_path = os.path.join(MEDIA_DIR, rel)
if os.path.isfile(abs_path):
try:
os.remove(abs_path)
except OSError:
pass
conn.execute("DELETE FROM health_documents WHERE id=?", (doc_id,))
return None

View file

@ -1,10 +1,11 @@
"""BAN YARO — KI Routes"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel
from typing import Optional
import ki as ki_module
from auth import get_current_user
from ratelimit import check as rl_check
from database import db
router = APIRouter()
@ -62,3 +63,224 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(500, "KI momentan nicht verfügbar.")
# ------------------------------------------------------------------
# POST /ki/tierarzt — KI-Tierarztfragen
# ------------------------------------------------------------------
class TierarztRequest(BaseModel):
symptom: str
dog_id: Optional[int] = None
dog_name: Optional[str] = None
rasse: Optional[str] = None
@router.post("/tierarzt")
async def ki_tierarzt(req: TierarztRequest, request: Request,
user=Depends(get_current_user)):
"""KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung."""
if not req.symptom or len(req.symptom.strip()) < 5:
raise HTTPException(400, "Bitte beschreibe das Symptom genauer.")
if len(req.symptom) > 1000:
raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).")
# Rate-Limit: max 5 Anfragen pro User pro Tag
with db() as conn:
count = conn.execute(
"SELECT COUNT(*) FROM ki_tierarzt_log "
"WHERE user_id=? AND created_at >= datetime('now','-1 day')",
(user["id"],)
).fetchone()[0]
if count >= 5:
raise HTTPException(429, "Tageslimit erreicht. Du kannst maximal 5 Tierarztfragen pro Tag stellen.")
dog_name = req.dog_name or "unbekannt"
rasse = req.rasse or "unbekannt"
system = (
"Du bist ein erfahrener Tierarzt-Assistent für Hunde. "
"Deine Aufgabe ist es, Hundebesitzern eine erste Orientierung zu geben — "
"kein Ersatz für eine echte tierärztliche Untersuchung. "
"Antworte immer auf Deutsch, klar und verständlich. "
"Stelle keine medizinischen Diagnosen. "
"Empfehle im Zweifel immer den Gang zum Tierarzt."
)
prompt = f"""Hund: {dog_name}, Rasse: {rasse}
Symptom: {req.symptom.strip()}
Gib eine strukturierte, verständliche Einschätzung:
1. Mögliche Ursachen (2-3 wahrscheinlichste)
2. Was der Besitzer jetzt tun kann (Erstmaßnahmen)
3. Wann unbedingt zum Tierarzt (Dringlichkeit: beobachten / bald / sofort)
Antworte auf Deutsch, klar und verständlich. Maximal 300 Wörter.
Schreibe KEINE medizinischen Diagnosen und empfehle im Zweifel immer den Tierarzt."""
try:
antwort = await ki_module.complete(
prompt=prompt,
system=system,
max_tokens=600,
requires_premium=False,
user_id=user["id"],
)
# Erfolg: Rate-Limit-Eintrag speichern
with db() as conn:
conn.execute(
"INSERT INTO ki_tierarzt_log (user_id, dog_id) VALUES (?, ?)",
(user["id"], req.dog_id)
)
return {"antwort": antwort, "anfragen_heute": count + 1, "limit": 5}
except ki_module.KIUnavailableError as e:
raise HTTPException(503, str(e))
except HTTPException:
raise
except Exception:
raise HTTPException(500, "KI momentan nicht verfügbar.")
# ------------------------------------------------------------------
# Rate-Limit-Helfer für Rassen-Erkennung
# ------------------------------------------------------------------
_RASSE_DAILY_LIMIT = 10
def _check_rasse_limit(user_id: int) -> int:
"""Gibt verbleibende Erkennungen zurück. Wirft HTTPException wenn Limit erreicht."""
with db() as conn:
used = conn.execute(
"""SELECT COUNT(*) FROM ki_rasse_log
WHERE user_id = ? AND created_at >= datetime('now', 'start of day')""",
(user_id,)
).fetchone()[0]
remaining = _RASSE_DAILY_LIMIT - used
if remaining <= 0:
raise HTTPException(429, f"Tageslimit erreicht ({_RASSE_DAILY_LIMIT} Erkennungen/Tag). Morgen wieder verfügbar.")
return remaining
def _log_rasse_request(user_id: int):
with db() as conn:
conn.execute(
"INSERT INTO ki_rasse_log (user_id) VALUES (?)", (user_id,)
)
# ------------------------------------------------------------------
# POST /ki/rasse-erkennung — Vision-basierte Rassenerkennung
# ------------------------------------------------------------------
@router.post("/rasse-erkennung")
async def ki_rasse_erkennung(
request: Request,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
"""Hunderassen per Foto erkennen (Claude Vision, max 5 MB, 10x/Tag)."""
import base64
import json
import re
import anthropic
# Dateigröße prüfen
content = await file.read()
if len(content) > 5 * 1024 * 1024:
raise HTTPException(400, "Bild zu groß. Maximal 5 MB erlaubt.")
# MIME-Typ prüfen
ct = (file.content_type or "").lower()
if not ct.startswith("image/"):
raise HTTPException(400, "Nur Bilddateien erlaubt (JPG, PNG, WebP).")
# MIME-Typ auf erlaubte Werte beschränken
allowed_mimes = {"image/jpeg", "image/png", "image/webp", "image/gif"}
mime_type = ct if ct in allowed_mimes else "image/jpeg"
# Rate-Limit prüfen
remaining_before = _check_rasse_limit(user["id"])
# Anthropic-Client holen (nutzt cached Instanz aus ki.py)
if not ki_module.ANTHROPIC_KEY:
raise HTTPException(503, "KI-Bildanalyse ist momentan nicht verfügbar.")
api_key = ki_module.ANTHROPIC_KEY
base64_data = base64.standard_b64encode(content).decode("utf-8")
prompt_text = """Analysiere dieses Bild und erkenne die Hunderasse(n).
Antworte NUR im folgenden JSON-Format (kein anderer Text):
{
"rassen": [
{"name": "Labrador Retriever", "sicherheit": 85, "beschreibung": "Kurze Begründung"},
{"name": "Golden Retriever", "sicherheit": 12, "beschreibung": "Falls Mischling"}
],
"ist_hund": true,
"hinweis": "Optionaler Hinweis z.B. bei Welpen oder schlechter Bildqualität"
}
Gib 1-3 Rassen nach Wahrscheinlichkeit sortiert an. Sicherheit in Prozent (0-100).
Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
try:
def _sync_call():
client = anthropic.Anthropic(api_key=api_key)
return client.messages.create(
model="claude-opus-4-7",
max_tokens=500,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": base64_data,
}
},
{
"type": "text",
"text": prompt_text,
}
]
}]
)
import asyncio
response = await asyncio.get_event_loop().run_in_executor(None, _sync_call)
raw = response.content[0].text.strip()
except anthropic.APIError as e:
raise HTTPException(503, f"KI-Bildanalyse nicht verfügbar: {e}")
except Exception as e:
raise HTTPException(500, "Fehler bei der Bildanalyse.")
# JSON parsen — Claude kann manchmal ```json ... ``` wrappen
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.DOTALL).strip()
try:
parsed = json.loads(cleaned)
except json.JSONDecodeError:
raise HTTPException(500, "KI-Antwort konnte nicht verarbeitet werden.")
# Usage loggen (erst nach erfolgreicher KI-Antwort)
_log_rasse_request(user["id"])
remaining_after = remaining_before - 1
# Wiki-Slugs für erkannte Rassen nachschlagen
rassen = parsed.get("rassen", [])
if rassen:
with db() as conn:
for r in rassen:
name = r.get("name", "")
# Exakter Name-Match (case-insensitive)
row = conn.execute(
"SELECT slug FROM wiki_rassen WHERE LOWER(name) = LOWER(?)", (name,)
).fetchone()
r["wiki_slug"] = row["slug"] if row else None
return {
"rassen": rassen,
"ist_hund": parsed.get("ist_hund", False),
"hinweis": parsed.get("hinweis") or None,
"verbleibende_anfragen": remaining_after,
}

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())

364
backend/routes/playdate.py Normal file
View file

@ -0,0 +1,364 @@
"""BAN YARO — Playdate-Matching"""
import math
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Haversine
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
* math.sin(dlon / 2) ** 2)
return R * 2 * math.asin(math.sqrt(a))
def _calc_alter(geburtstag: Optional[str]) -> Optional[str]:
"""Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'."""
if not geburtstag:
return None
try:
from datetime import date
geb = date.fromisoformat(geburtstag[:10])
today = date.today()
monate = (today.year - geb.year) * 12 + (today.month - geb.month)
if today.day < geb.day:
monate -= 1
if monate < 0:
return None
if monate < 24:
return f"{monate} {'Monat' if monate == 1 else 'Monate'}"
jahre = monate // 12
return f"{jahre} {'Jahr' if jahre == 1 else 'Jahre'}"
except Exception:
return None
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ListingUpsert(BaseModel):
dog_id: int
lat: float
lon: float
ort_name: Optional[str] = None
radius_km: int = 10
beschreibung: Optional[str] = None
class RequestCreate(BaseModel):
to_dog_id: int
nachricht: Optional[str] = None
class RequestPatch(BaseModel):
status: str # accepted | declined
# ------------------------------------------------------------------
# Helpers — Konversation für Playdate öffnen (ohne Freundschaftspflicht)
# ------------------------------------------------------------------
def _ensure_conversation(conn, user_a: int, user_b: int) -> int:
a, b = (min(user_a, user_b), max(user_a, user_b))
existing = conn.execute(
"SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?",
(a, b)
).fetchone()
if existing:
return existing["id"]
cur = conn.execute(
"INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)",
(a, b)
)
return cur.lastrowid
# ------------------------------------------------------------------
# Routes
# ------------------------------------------------------------------
@router.get("/nearby")
async def nearby(lat: float, lon: float, radius: int = 10,
user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
rows = conn.execute("""
SELECT pl.id AS listing_id,
pl.lat, pl.lon, pl.ort_name, pl.beschreibung,
d.id AS dog_id, d.name AS dog_name, d.rasse,
d.geburtstag, d.foto_url, d.geschlecht
FROM playdate_listings pl
JOIN dogs d ON d.id = pl.dog_id
WHERE pl.aktiv = 1
AND pl.user_id != ?
""", (uid,)).fetchall()
result = []
for r in rows:
dist = _haversine(lat, lon, r["lat"], r["lon"])
if dist <= radius:
result.append({
"listing_id": r["listing_id"],
"dog_id": r["dog_id"],
"dog_name": r["dog_name"],
"rasse": r["rasse"],
"alter": _calc_alter(r["geburtstag"]),
"geschlecht": r["geschlecht"],
"foto_url": r["foto_url"],
"ort_name": r["ort_name"],
"beschreibung": r["beschreibung"],
"entfernung_km": round(dist, 1),
})
result.sort(key=lambda x: x["entfernung_km"])
return result
@router.put("/listing", status_code=200)
async def upsert_listing(data: ListingUpsert, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
# Sicherstellen dass der Hund dem User gehört
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
existing = conn.execute(
"SELECT id FROM playdate_listings WHERE dog_id=?",
(data.dog_id,)
).fetchone()
if existing:
conn.execute("""
UPDATE playdate_listings
SET lat=?, lon=?, ort_name=?, radius_km=?, beschreibung=?,
aktiv=1, updated_at=datetime('now')
WHERE dog_id=?
""", (data.lat, data.lon, data.ort_name, data.radius_km,
data.beschreibung, data.dog_id))
return {"ok": True, "id": existing["id"]}
else:
cur = conn.execute("""
INSERT INTO playdate_listings
(dog_id, user_id, lat, lon, ort_name, radius_km, beschreibung)
VALUES (?,?,?,?,?,?,?)
""", (data.dog_id, uid, data.lat, data.lon, data.ort_name,
data.radius_km, data.beschreibung))
return {"ok": True, "id": cur.lastrowid}
@router.delete("/listing/{dog_id}", status_code=200)
async def deactivate_listing(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
row = conn.execute(
"SELECT id FROM playdate_listings WHERE dog_id=? AND user_id=?",
(dog_id, uid)
).fetchone()
if not row:
raise HTTPException(404, "Inserat nicht gefunden.")
conn.execute(
"UPDATE playdate_listings SET aktiv=0, updated_at=datetime('now') WHERE dog_id=?",
(dog_id,)
)
return {"ok": True}
@router.get("/my-listing/{dog_id}")
async def my_listing(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
row = conn.execute(
"""SELECT id, dog_id, lat, lon, ort_name, radius_km, beschreibung, aktiv
FROM playdate_listings WHERE dog_id=? AND user_id=?""",
(dog_id, uid)
).fetchone()
if not row:
return None
return dict(row)
@router.post("/request", status_code=201)
async def create_request(data: RequestCreate, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
# Eigenen Hund ermitteln — nimm den ersten aktiven Hund des Users
own_dog = conn.execute(
"SELECT id FROM dogs WHERE user_id=? ORDER BY id LIMIT 1",
(uid,)
).fetchone()
if not own_dog:
raise HTTPException(400, "Du hast noch keinen Hund eingetragen.")
from_dog_id = own_dog["id"]
# Zielhund + Besitzer prüfen
target = conn.execute(
"SELECT d.id, d.user_id FROM dogs d WHERE d.id=?",
(data.to_dog_id,)
).fetchone()
if not target:
raise HTTPException(404, "Zielhund nicht gefunden.")
if target["user_id"] == uid:
raise HTTPException(400, "Du kannst nicht dir selbst eine Anfrage schicken.")
to_user_id = target["user_id"]
# Doppelte Anfrage verhindern
existing = conn.execute(
"SELECT id, status FROM playdate_requests WHERE from_dog_id=? AND to_dog_id=?",
(from_dog_id, data.to_dog_id)
).fetchone()
if existing:
if existing["status"] == "pending":
raise HTTPException(409, "Du hast bereits eine offene Anfrage an diesen Hund.")
# Alte abgelehnte Anfrage: löschen und neu anlegen
conn.execute(
"DELETE FROM playdate_requests WHERE id=?",
(existing["id"],)
)
cur = conn.execute("""
INSERT INTO playdate_requests
(from_dog_id, to_dog_id, from_user_id, to_user_id, nachricht)
VALUES (?,?,?,?,?)
""", (from_dog_id, data.to_dog_id, uid, to_user_id, data.nachricht))
request_id = cur.lastrowid
# Chat-Konversation anlegen (ohne Freundschaftspflicht)
conv_id = _ensure_conversation(conn, uid, to_user_id)
# Erste Nachricht mit Kontext senden
intro = f"Hallo! Ich habe eine Playdate-Anfrage für unsere Hunde geschickt."
if data.nachricht:
intro += f" Meine Nachricht: {data.nachricht}"
conn.execute("""
INSERT INTO direct_messages (conversation_id, sender_id, text)
VALUES (?,?,?)
""", (conv_id, uid, intro))
conn.execute(
"UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?",
(conv_id,)
)
try:
from routes.push import send_push_to_user
send_push_to_user(to_user_id, {
"title": "Playdate-Anfrage",
"body": f"{user['name']} möchte ein Treffen vereinbaren!",
"type": "playdate_request",
"tag": f"playdate-{request_id}",
"data": {"page": "playdate"},
})
except Exception:
pass
return {"ok": True, "request_id": request_id, "conversation_id": conv_id}
@router.get("/requests")
async def list_requests(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
incoming = conn.execute("""
SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
pr.from_user_id,
uf.name AS from_user_name,
df.name AS from_dog_name, df.rasse AS from_dog_rasse,
df.foto_url AS from_dog_foto,
df.geburtstag AS from_dog_geburtstag,
dt.name AS to_dog_name
FROM playdate_requests pr
JOIN users uf ON uf.id = pr.from_user_id
JOIN dogs df ON df.id = pr.from_dog_id
JOIN dogs dt ON dt.id = pr.to_dog_id
WHERE pr.to_user_id = ?
ORDER BY pr.created_at DESC
""", (uid,)).fetchall()
outgoing = conn.execute("""
SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
pr.to_user_id,
ut.name AS to_user_name,
dt.name AS to_dog_name, dt.rasse AS to_dog_rasse,
dt.foto_url AS to_dog_foto,
df.name AS from_dog_name
FROM playdate_requests pr
JOIN users ut ON ut.id = pr.to_user_id
JOIN dogs dt ON dt.id = pr.to_dog_id
JOIN dogs df ON df.id = pr.from_dog_id
WHERE pr.from_user_id = ?
ORDER BY pr.created_at DESC
""", (uid,)).fetchall()
def _enrich(rows, direction):
result = []
for r in rows:
d = dict(r)
d["direction"] = direction
if direction == "incoming":
d["alter"] = _calc_alter(d.get("from_dog_geburtstag"))
result.append(d)
return result
return {
"incoming": _enrich(incoming, "incoming"),
"outgoing": _enrich(outgoing, "outgoing"),
}
@router.patch("/requests/{req_id}", status_code=200)
async def patch_request(req_id: int, data: RequestPatch,
user=Depends(get_current_user)):
uid = user["id"]
if data.status not in ("accepted", "declined"):
raise HTTPException(400, "Status muss 'accepted' oder 'declined' sein.")
with db() as conn:
req = conn.execute(
"SELECT * FROM playdate_requests WHERE id=? AND to_user_id=?",
(req_id, uid)
).fetchone()
if not req:
raise HTTPException(404, "Anfrage nicht gefunden.")
if req["status"] != "pending":
raise HTTPException(409, "Anfrage wurde bereits beantwortet.")
conn.execute(
"UPDATE playdate_requests SET status=? WHERE id=?",
(data.status, req_id)
)
conv_id = None
if data.status == "accepted":
conv_id = _ensure_conversation(conn, uid, req["from_user_id"])
try:
from routes.push import send_push_to_user
verb = "angenommen" if data.status == "accepted" else "abgelehnt"
send_push_to_user(req["from_user_id"], {
"title": f"Playdate {verb}!",
"body": f"{user['name']} hat deine Anfrage {verb}.",
"type": "playdate_response",
"tag": f"playdate-{req_id}",
"data": {"page": "playdate"},
})
except Exception:
pass
return {"ok": True, "conversation_id": conv_id}

138
backend/routes/recalls.py Normal file
View file

@ -0,0 +1,138 @@
"""BAN YARO — Rückruf-Alarm (Tierfutter)
RASFF EU Rapid Alert System for Food and Feed
"""
import logging
import httpx
from fastapi import APIRouter
from database import db
router = APIRouter()
logger = logging.getLogger(__name__)
RASFF_URL = "https://webgate.ec.europa.eu/rasff-window/backend/public/notification/list/with-filters"
RASFF_PARAMS = {
"filters": '{"subject.product_category":["pet food and animal feed"]}',
"pageNumber": 0,
"pageSize": 20,
"sortColumn": "notificationDate",
"sortDirection": "DESC",
}
# ------------------------------------------------------------------
# GET /api/recalls — Letzte 50 Rückrufe
# ------------------------------------------------------------------
@router.get("")
async def list_recalls(q: str = ""):
with db() as conn:
if q:
like = f"%{q}%"
rows = conn.execute("""
SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at
FROM feed_recalls
WHERE titel LIKE ? OR produkt LIKE ? OR gefahr LIKE ? OR herkunft LIKE ?
ORDER BY datum DESC
LIMIT 50
""", (like, like, like, like)).fetchall()
else:
rows = conn.execute("""
SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at
FROM feed_recalls
ORDER BY datum DESC
LIMIT 50
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# Interne Hilfsfunktion: RASFF API abfragen
# ------------------------------------------------------------------
async def fetch_rasff_recalls() -> list[dict]:
"""Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(RASFF_URL, params=RASFF_PARAMS)
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.error(f"RASFF API-Fehler: {e}")
return []
entries = []
try:
items = data.get("data", {}).get("list", [])
for item in items:
reference = item.get("reference", "")
if not reference:
continue
# Datum
datum_raw = item.get("notificationDate", "")
datum = datum_raw[:10] if datum_raw else ""
# Produkt
subject = item.get("subject") or {}
produkt = subject.get("product", "") or ""
# Gefahr
hazards = subject.get("hazard") or []
gefahr = ""
if hazards:
gefahr = hazards[0].get("hazardDescription", "") or ""
# Herkunft
origin = item.get("origin") or {}
herkunft = origin.get("name", "") or ""
# URL zur RASFF-Seite
url = f"https://webgate.ec.europa.eu/rasff-window/screen/notificationDetail?notifRef={reference}"
entries.append({
"external_id": reference,
"titel": produkt or reference,
"produkt": produkt,
"gefahr": gefahr,
"herkunft": herkunft,
"datum": datum,
"quelle": "rasff",
"url": url,
})
except Exception as e:
logger.error(f"RASFF Parsing-Fehler: {e}")
return entries
# ------------------------------------------------------------------
# Interne Hilfsfunktion: Neue Einträge in DB speichern
# ------------------------------------------------------------------
def save_new_recalls(entries: list[dict]) -> list[dict]:
"""Speichert neue Einträge und gibt die Liste der neuen Einträge zurück."""
new_entries = []
for entry in entries:
try:
with db() as conn:
exists = conn.execute(
"SELECT id FROM feed_recalls WHERE external_id=?",
(entry["external_id"],)
).fetchone()
if not exists:
conn.execute("""
INSERT INTO feed_recalls
(external_id, titel, produkt, gefahr, herkunft, datum, quelle, url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
entry["external_id"],
entry["titel"],
entry["produkt"],
entry["gefahr"],
entry["herkunft"],
entry["datum"],
entry["quelle"],
entry["url"],
))
new_entries.append(entry)
except Exception as e:
logger.warning(f"Recall DB-Fehler für {entry.get('external_id')}: {e}")
return new_entries

114
backend/routes/streak.py Normal file
View file

@ -0,0 +1,114 @@
"""BAN YARO — Trainings-Streak"""
import datetime
from fastapi import APIRouter, Depends, HTTPException
from database import db
from auth import get_current_user
router = APIRouter()
_today = lambda: datetime.date.today().isoformat()
_yesterday = lambda: (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
# ------------------------------------------------------------------
# GET /streak/leaderboard — Top-10 Streaks (öffentliche Hunde)
# Muss VOR /{dog_id} stehen, sonst greift der int-Parameter zuerst.
# ------------------------------------------------------------------
@router.get("/streak/leaderboard")
async def get_leaderboard(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute("""
SELECT
u.name AS user_name,
d.name AS dog_name,
d.rasse,
d.foto_url,
ts.current_streak
FROM training_streaks ts
JOIN dogs d ON d.id = ts.dog_id
JOIN users u ON u.id = ts.user_id
WHERE ts.current_streak > 0
AND (d.is_public = 1 OR d.user_id = ts.user_id)
ORDER BY ts.current_streak DESC
LIMIT 10
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /streak/{dog_id} — aktueller Streak eines Hundes
# ------------------------------------------------------------------
@router.get("/streak/{dog_id}")
async def get_streak(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
row = conn.execute(
"SELECT current_streak, longest_streak, last_training_date "
"FROM training_streaks WHERE user_id=? AND dog_id=?",
(uid, dog_id)
).fetchone()
if not row:
return {"current_streak": 0, "longest_streak": 0, "last_training_date": None}
return dict(row)
# ------------------------------------------------------------------
# POST /streak/{dog_id}/ping — Training heute registrieren
# ------------------------------------------------------------------
@router.post("/streak/{dog_id}/ping")
async def ping_streak(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
today = _today()
yest = _yesterday()
with db() as conn:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
row = conn.execute(
"SELECT current_streak, longest_streak, last_training_date "
"FROM training_streaks WHERE user_id=? AND dog_id=?",
(uid, dog_id)
).fetchone()
if row:
cur = row["current_streak"]
longest = row["longest_streak"]
last = row["last_training_date"]
if last == today:
# Bereits heute gepingt — nichts tun
return {"current_streak": cur, "longest_streak": longest, "last_training_date": last}
elif last == yest:
cur += 1
else:
cur = 1
longest = max(longest, cur)
conn.execute(
"UPDATE training_streaks SET current_streak=?, longest_streak=?, last_training_date=? "
"WHERE user_id=? AND dog_id=?",
(cur, longest, today, uid, dog_id)
)
else:
cur = 1
longest = 1
conn.execute(
"INSERT INTO training_streaks (user_id, dog_id, current_streak, longest_streak, last_training_date) "
"VALUES (?,?,?,?,?)",
(uid, dog_id, cur, longest, today)
)
return {"current_streak": cur, "longest_streak": longest, "last_training_date": today}

View file

@ -63,15 +63,68 @@ def _fmt_opening_hours(raw: str | None) -> str | None:
return result
@router.get("/my-favorite")
async def get_my_favorite(user=Depends(get_current_user)):
"""Favoriten-Tierarzt des Users (oder null)."""
with db() as conn:
row = conn.execute(
"""SELECT t.* FROM tieraerzte t
JOIN favorite_vets fv ON fv.vet_id = t.id
WHERE fv.user_id = ?
LIMIT 1""",
(user["id"],)
).fetchone()
if not row:
return None
return dict(row)
@router.post("/{vet_id}/favorite")
async def toggle_favorite(vet_id: int, user=Depends(get_current_user)):
"""Tierarzt als Favorit setzen oder entfernen (toggle). Gibt {is_favorite: bool} zurück."""
with db() as conn:
vet = conn.execute(
"SELECT id FROM tieraerzte WHERE id=?", (vet_id,)
).fetchone()
if not vet:
raise HTTPException(404, "Tierarzt nicht gefunden.")
existing = conn.execute(
"SELECT 1 FROM favorite_vets WHERE user_id=? AND vet_id=?",
(user["id"], vet_id)
).fetchone()
if existing:
conn.execute(
"DELETE FROM favorite_vets WHERE user_id=? AND vet_id=?",
(user["id"], vet_id)
)
return {"is_favorite": False}
else:
conn.execute(
"INSERT INTO favorite_vets (user_id, vet_id) VALUES (?, ?)",
(user["id"], vet_id)
)
return {"is_favorite": True}
@router.get("")
async def list_tieraerzte(user=Depends(get_current_user)):
"""Alle Tierärzte des Users — aktive zuerst, dann inaktive."""
"""Alle Tierärzte des Users — aktive zuerst, dann inaktive. Enthält is_favorite."""
with db() as conn:
rows = conn.execute(
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name",
(user["id"],)
).fetchall()
return [dict(r) for r in rows]
favs = {r["vet_id"] for r in conn.execute(
"SELECT vet_id FROM favorite_vets WHERE user_id=?", (user["id"],)
).fetchall()}
result = []
for r in rows:
d = dict(r)
d["is_favorite"] = r["id"] in favs
result.append(d)
return result
@router.get("/osm-nearby")