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:
parent
031c6028ac
commit
742ad189e8
26 changed files with 5734 additions and 27 deletions
292
backend/routes/adoption.py
Normal file
292
backend/routes/adoption.py
Normal 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
228
backend/routes/expenses.py
Normal 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
|
||||
138
backend/routes/health_docs.py
Normal file
138
backend/routes/health_docs.py
Normal 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
|
||||
|
|
@ -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
377
backend/routes/passport.py
Normal 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
364
backend/routes/playdate.py
Normal 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
138
backend/routes/recalls.py
Normal 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
114
backend/routes/streak.py
Normal 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}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue