From 742ad189e8f07b7862294e489be9592b9bd6a450 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 09:29:48 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Sprint31=20=E2=80=94=209=20Features?= =?UTF-8?q?=20merged=20(Streak,=20Ausgaben,=20KI-Tierarzt,=20R=C3=BCckrufe?= =?UTF-8?q?,=20Adoption,=20Vet+Befunde,=20Hundepass,=20Playdate,=20Rassene?= =?UTF-8?q?rkennung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/database.py | 200 +++++++ backend/main.py | 160 ++++++ backend/requirements.txt | 1 + backend/routes/adoption.py | 292 ++++++++++ backend/routes/expenses.py | 228 ++++++++ backend/routes/health_docs.py | 138 +++++ backend/routes/ki.py | 224 +++++++- backend/routes/passport.py | 377 +++++++++++++ backend/routes/playdate.py | 364 +++++++++++++ backend/routes/recalls.py | 138 +++++ backend/routes/streak.py | 114 ++++ backend/routes/tieraerzte.py | 57 +- backend/scheduler.py | 96 +++- backend/static/css/components.css | 121 +++++ backend/static/index.html | 28 + backend/static/js/api.js | 13 +- backend/static/js/app.js | 5 + backend/static/js/pages/adoption.js | 483 +++++++++++++++++ backend/static/js/pages/dog-profile.js | 609 ++++++++++++++++++++- backend/static/js/pages/expenses.js | 493 +++++++++++++++++ backend/static/js/pages/health.js | 498 ++++++++++++++++- backend/static/js/pages/playdate.js | 708 +++++++++++++++++++++++++ backend/static/js/pages/recalls.js | 190 +++++++ backend/static/js/pages/uebungen.js | 10 + backend/static/js/pages/welcome.js | 78 +++ backend/static/js/pages/wiki.js | 136 +++++ 26 files changed, 5734 insertions(+), 27 deletions(-) create mode 100644 backend/routes/adoption.py create mode 100644 backend/routes/expenses.py create mode 100644 backend/routes/health_docs.py create mode 100644 backend/routes/passport.py create mode 100644 backend/routes/playdate.py create mode 100644 backend/routes/recalls.py create mode 100644 backend/routes/streak.py create mode 100644 backend/static/js/pages/adoption.js create mode 100644 backend/static/js/pages/expenses.js create mode 100644 backend/static/js/pages/playdate.js create mode 100644 backend/static/js/pages/recalls.js diff --git a/backend/database.py b/backend/database.py index e373f02..5d992eb 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1657,3 +1657,203 @@ def _migrate(conn_factory): ); CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id); """) + + # Trainings-Streak-Tabelle + conn.execute(""" + CREATE TABLE IF NOT EXISTS training_streaks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + current_streak INTEGER NOT NULL DEFAULT 0, + longest_streak INTEGER NOT NULL DEFAULT 0, + last_training_date TEXT, + UNIQUE(user_id, dog_id) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_streaks_user ON training_streaks(user_id)") + + # Ausgaben-Tracker + conn.executescript(""" + CREATE TABLE IF NOT EXISTS expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + kategorie TEXT NOT NULL, + betrag REAL NOT NULL, + datum TEXT NOT NULL, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_expenses_user ON expenses(user_id, datum DESC); + """) + + # KI-Tierarztfragen Rate-Limit-Log + conn.execute(""" + CREATE TABLE IF NOT EXISTS ki_tierarzt_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + + # KI Rassen-Erkennungs-Log (Rate-Limit: 10/Tag pro User) + conn.execute(""" + CREATE TABLE IF NOT EXISTS ki_rasse_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_ki_rasse_log_user + ON ki_rasse_log(user_id, created_at DESC) + """) + + # feed_recalls — Rückruf-Alarm für Tierfutter (RASFF) + conn.execute(""" + CREATE TABLE IF NOT EXISTS feed_recalls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT NOT NULL UNIQUE, + titel TEXT NOT NULL, + produkt TEXT, + gefahr TEXT, + herkunft TEXT, + datum TEXT NOT NULL, + quelle TEXT NOT NULL DEFAULT 'rasff', + url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_recalls_datum ON feed_recalls(datum DESC)") + + # Adoption-Cache + conn.execute(""" + CREATE TABLE IF NOT EXISTS adoption_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + rasse TEXT, + alter_jahre REAL, + geschlecht TEXT, + foto_url TEXT, + tierheim TEXT, + tierheim_plz TEXT, + tierheim_lat REAL, + tierheim_lon REAL, + adoptions_url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL + ) + """) + + # ---- Favoriten-Tierarzt + Gesundheitsdokumente ---- + conn.execute(""" + CREATE TABLE IF NOT EXISTS favorite_vets ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + vet_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, vet_id) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS health_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + typ TEXT NOT NULL, + titel TEXT NOT NULL, + beschreibung TEXT, + file_path TEXT NOT NULL, + file_type TEXT NOT NULL, + datum TEXT, + vet_id INTEGER REFERENCES tieraerzte(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_health_docs_dog ON health_documents(dog_id, created_at DESC)") + + # Digitaler Hundepass — Impfungen, Medikamente, Metadaten, Share-Links + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS vaccinations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + krankheit TEXT NOT NULL, + datum TEXT NOT NULL, + naechste TEXT, + tierarzt TEXT, + charge_nr TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS medications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + name TEXT NOT NULL, + dosierung TEXT, + von TEXT, + bis TEXT, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS dog_passport_meta ( + dog_id INTEGER PRIMARY KEY REFERENCES dogs(id) ON DELETE CASCADE, + blutgruppe TEXT, + allergien TEXT, + besonderheiten TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS passport_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + valid_until TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_passport_shares_token ON passport_shares(token) + """) + logger.info("Migration: Hundepass-Tabellen bereit.") + except Exception as e: + logger.warning(f"Migration Hundepass: {e}") + + # ---- Playdate ---- + conn.execute(""" + CREATE TABLE IF NOT EXISTS playdate_listings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + lat REAL NOT NULL, + lon REAL NOT NULL, + ort_name TEXT, + radius_km INTEGER NOT NULL DEFAULT 10, + beschreibung TEXT, + aktiv INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(dog_id) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_playdate_listings_geo + ON playdate_listings(lat, lon) WHERE aktiv=1 + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS playdate_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + to_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + nachricht TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(from_dog_id, to_dog_id) + ) + """) diff --git a/backend/main.py b/backend/main.py index 6eb99a2..8b259f7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -189,6 +189,13 @@ from routes.zucht_ki import router as zucht_ki_router from routes.partner import router as partner_router from routes.outreach import router as outreach_router from routes.jobs import router as jobs_router +from routes.streak import router as streak_router +from routes.expenses import router as expenses_router +from routes.recalls import router as recalls_router +from routes.adoption import router as adoption_router +from routes.health_docs import router as health_docs_router +from routes.passport import router as passport_router +from routes.playdate import router as playdate_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -240,6 +247,13 @@ app.include_router(training_router, prefix="/api/training", tags= app.include_router(praise_router, prefix="/api/praise", tags=["Praise"]) app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"]) app.include_router(notes_router, prefix="/api/notes", tags=["Notes"]) +app.include_router(streak_router, prefix="/api", tags=["Streak"]) +app.include_router(expenses_router, prefix="/api/expenses", tags=["Ausgaben"]) +app.include_router(recalls_router, prefix="/api/recalls", tags=["Rückrufe"]) +app.include_router(adoption_router, prefix="/api/adoption", tags=["Adoption"]) +app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"]) +app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"]) +app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"]) # ------------------------------------------------------------------ @@ -1674,6 +1688,152 @@ for _hp in _HONEYPOT_PATHS: app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False) +# ------------------------------------------------------------------ +# Digitaler Hundepass — öffentlicher Share-Link (kein Login nötig) +# ------------------------------------------------------------------ +@app.get("/pass/{token}") +async def passport_share_page(token: str): + from fastapi.responses import HTMLResponse + from database import db as _db + from datetime import date as _date + + with _db() as conn: + share = conn.execute( + "SELECT * FROM passport_shares WHERE token=?", (token,) + ).fetchone() + if not share: + return HTMLResponse( + '' + '

Link nicht gefunden

Dieser Hundepass-Link ist ungültig.

', + status_code=404 + ) + if share["valid_until"] < _date.today().isoformat(): + return HTMLResponse( + '' + '

Link abgelaufen

Dieser Hundepass-Link ist nicht mehr gültig.

', + status_code=410 + ) + dog_id = share["dog_id"] + dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone() + meta = conn.execute("SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)).fetchone() + vaccs = conn.execute( + "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,) + ).fetchall() + meds = conn.execute( + "SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,) + ).fetchall() + def _fmt(d): + if not d: + return "–" + try: + from datetime import datetime as _dt + return _dt.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y") + except Exception: + return d + + dog = dict(dog) + meta = dict(meta) if meta else {} + vaccs = [dict(v) for v in vaccs] + meds = [dict(m) for m in meds] + + _g_map = {"m": "Rüde", "w": "Hündin"} + + vacc_rows = "".join(f""" + + {v['krankheit'] or ''} + {_fmt(v['datum'])} + {_fmt(v['naechste'])} + {v['tierarzt'] or '–'} + {v['charge_nr'] or '–'} + """ for v in vaccs) or "Keine Einträge" + + med_rows = "".join(f""" + + {m['name'] or ''} + {m['dosierung'] or '–'} + {_fmt(m['von'])} + {_fmt(m['bis']) if m['bis'] else 'dauerhaft'} + {m['notiz'] or '–'} + """ for m in meds) or "Keine Einträge" + + html = f""" + + + + + Hundepass — {dog['name']} + + + +
+

Ban Yaro

+

Digitaler Hundepass — {dog['name']}

+
+
+
+

Hundeangaben

+
+
{dog['name']}
+
{dog.get('rasse') or '–'}
+
{_fmt(dog.get('geburtstag'))}
+
{_g_map.get(dog.get('geschlecht',''), '–')}
+
{dog.get('chip_nr') or '–'}
+
{meta.get('blutgruppe') or '–'}
+
+ {('
' + f'
{meta["allergien"]}
') if meta.get("allergien") else ''} + {('
' + f'
{meta["besonderheiten"]}
') if meta.get("besonderheiten") else ''} +
+ +
+

Impfungen

+ + + + + {vacc_rows} +
KrankheitDatumNächsteTierarztCharge
+
+ +
+

Medikamente

+ + + + + {med_rows} +
MedikamentDosierungVonBisNotiz
+
+
+ + +""" + return HTMLResponse(html) + + # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/requirements.txt b/backend/requirements.txt index 7b268fa..17db134 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,4 @@ pywebpush==2.0.0 apscheduler==3.10.4 odfpy==1.4.1 polyline==2.0.2 +fpdf2==2.8.3 diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py new file mode 100644 index 0000000..d742ccc --- /dev/null +++ b/backend/routes/adoption.py @@ -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} diff --git a/backend/routes/expenses.py b/backend/routes/expenses.py new file mode 100644 index 0000000..a3bcf7a --- /dev/null +++ b/backend/routes/expenses.py @@ -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 diff --git a/backend/routes/health_docs.py b/backend/routes/health_docs.py new file mode 100644 index 0000000..0c2d4a7 --- /dev/null +++ b/backend/routes/health_docs.py @@ -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 diff --git a/backend/routes/ki.py b/backend/routes/ki.py index aa8d001..80d663c 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -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, + } diff --git a/backend/routes/passport.py b/backend/routes/passport.py new file mode 100644 index 0000000..884e8d3 --- /dev/null +++ b/backend/routes/passport.py @@ -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()) diff --git a/backend/routes/playdate.py b/backend/routes/playdate.py new file mode 100644 index 0000000..01d57ae --- /dev/null +++ b/backend/routes/playdate.py @@ -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} diff --git a/backend/routes/recalls.py b/backend/routes/recalls.py new file mode 100644 index 0000000..d0182a3 --- /dev/null +++ b/backend/routes/recalls.py @@ -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 diff --git a/backend/routes/streak.py b/backend/routes/streak.py new file mode 100644 index 0000000..ea03522 --- /dev/null +++ b/backend/routes/streak.py @@ -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} diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py index 55107ec..48287f9 100644 --- a/backend/routes/tieraerzte.py +++ b/backend/routes/tieraerzte.py @@ -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") diff --git a/backend/scheduler.py b/backend/scheduler.py index 68a4c07..4dcab4c 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -132,8 +132,24 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # Täglich 19:00 Uhr — Streak-Erinnerung + _scheduler.add_job( + _job_streak_reminder, + CronTrigger(hour=19, minute=0), + id="streak_reminder", + replace_existing=True, + misfire_grace_time=3600, + ) + # Täglich 08:00 Uhr — Tierfutter-Rückrufe prüfen (RASFF) + _scheduler.add_job( + _job_recall_check, + CronTrigger(hour=8, minute=0), + id="recall_check", + replace_existing=True, + misfire_grace_time=3600, + ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -855,6 +871,8 @@ async def _job_status_report(): "weekly_praise": "Wöchentlicher Lober (Mo 09:00)", "ki_health_report": "KI-Gesundheitsberichte", "quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)", + "streak_reminder": "Streak-Erinnerung (täglich 19:00)", + "recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)", } job_rows_html = "" job_rows_txt = "" @@ -1172,3 +1190,79 @@ async def _job_hdm_winner(): logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.") _log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)") + + +# ------------------------------------------------------------------ +# JOB: Streak-Erinnerung (täglich 19:00) +# ------------------------------------------------------------------ +async def _job_streak_reminder(): + """ + Findet alle User die heute noch nicht trainiert haben (last_training_date < heute) + und deren current_streak > 0. Sendet einen motivierenden Push pro Hund. + """ + today = str(date.today()) + logger.info(f"Streak-Reminder Job läuft für {today}") + + with db() as conn: + rows = conn.execute(""" + SELECT ts.user_id, ts.dog_id, ts.current_streak, d.name AS dog_name + FROM training_streaks ts + JOIN dogs d ON d.id = ts.dog_id + WHERE ts.current_streak > 0 + AND (ts.last_training_date IS NULL OR ts.last_training_date < ?) + """, (today,)).fetchall() + + sent_total = 0 + for r in rows: + n = r["current_streak"] + sent = send_push_to_user(r["user_id"], { + "type": "streak_reminder", + "title": f"🔥 {r['dog_name']} wartet auf sein Training!", + "body": f"Streak: {n} {'Tag' if n == 1 else 'Tage'} — nicht jetzt aufhören.", + "data": {"page": "uebungen"}, + "tag": f"streak-{r['dog_id']}-{today}", + }) + sent_total += sent + + logger.info(f"Streak-Reminder Job fertig — {len(rows)} Hunde geprüft, {sent_total} Push gesendet.") + _log_job("streak_reminder", "ok", f"{sent_total} Push an {len(rows)} Hunde") + + +# ------------------------------------------------------------------ +# JOB: Tierfutter-Rückrufe prüfen (RASFF, täglich 08:00) +# ------------------------------------------------------------------ +async def _job_recall_check(): + """ + Fragt täglich die RASFF EU-API nach neuen Tierfutter-Rückrufen ab. + Neue Einträge werden in DB gespeichert, für jeden wird ein Push + an alle abonnierten User gesendet. + """ + logger.info("Rückruf-Check Job läuft") + try: + from routes.recalls import fetch_rasff_recalls, save_new_recalls + entries = await fetch_rasff_recalls() + if not entries: + logger.info("Rückruf-Check: Keine Einträge von RASFF erhalten (API-Fehler oder leer).") + _log_job("recall_check", "ok", "0 neue Rückrufe (API leer)") + return + + new_entries = save_new_recalls(entries) + logger.info(f"Rückruf-Check: {len(new_entries)} neue von {len(entries)} geprüften Einträgen.") + + for entry in new_entries: + produkt = entry.get("produkt") or entry.get("titel") or "Unbekanntes Produkt" + gefahr = entry.get("gefahr") or "Bitte Produktdetails prüfen" + ext_id = entry["external_id"] + body = f"{produkt} — {gefahr[:80]}" + send_push_to_all({ + "title": "⚠️ Tierfutter-Rückruf", + "body": body, + "data": {"page": "recalls"}, + "tag": f"recall-{ext_id}", + }) + logger.info(f"Rückruf-Push gesendet: {ext_id} — {produkt}") + + _log_job("recall_check", "ok", f"{len(new_entries)} neue Rückrufe") + except Exception as e: + logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}") + _log_job("recall_check", "error", str(e)) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 3582760..ddaa344 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -6803,3 +6803,124 @@ svg.empty-state-icon { pointer-events: none; letter-spacing: 0.01em; } + +/* ------------------------------------------------------------ + STREAK-WIDGET (Welcome-Seite) + ------------------------------------------------------------ */ +.wc-streak-card { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-5); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-lg, 14px); + background: linear-gradient(135deg, #ff6b00 0%, #c0392b 100%); + color: #fff; + box-shadow: 0 4px 18px rgba(196, 63, 0, 0.35); + position: relative; + overflow: hidden; +} +.wc-streak-card::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at 10% 50%, rgba(255,255,255,0.15) 0%, transparent 60%); + pointer-events: none; +} +.wc-streak-flame-wrap { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.wc-streak-flame { + font-size: 2.2rem; + line-height: 1; + filter: drop-shadow(0 2px 6px rgba(0,0,0,0.3)); +} +.wc-streak-number { + font-size: 2.6rem; + font-weight: 800; + line-height: 1; + letter-spacing: -0.03em; + text-shadow: 0 2px 8px rgba(0,0,0,0.2); +} +.wc-streak-info { + flex: 1; + min-width: 0; +} +.wc-streak-label { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + opacity: 0.95; +} +.wc-streak-best { + font-size: var(--text-xs); + opacity: 0.75; + margin-top: 2px; +} +.wc-streak-lb-btn { + background: rgba(255,255,255,0.2); + border: 1.5px solid rgba(255,255,255,0.45); + border-radius: 50%; + width: 38px; + height: 38px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: #fff; + transition: background 0.15s; +} +.wc-streak-lb-btn:active { background: rgba(255,255,255,0.35); } + +/* ------------------------------------------------------------ + KI RASSEN-ERKENNUNG — Ergebnis-Block + ------------------------------------------------------------ */ +.rasse-result-card { + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + padding: var(--space-4); + margin-bottom: var(--space-3); +} +.rasse-result-card--top { + border-color: var(--c-primary); + background: var(--c-primary-subtle, #f0f9ff); +} +.rasse-result-name { + font-size: var(--text-base); + font-weight: var(--weight-semibold); + color: var(--c-text); + margin-bottom: var(--space-1); +} +.rasse-result-bar-wrap { + background: var(--c-surface-2); + border-radius: 999px; + height: 8px; + overflow: hidden; + margin: var(--space-2) 0; +} +.rasse-result-bar { + height: 8px; + border-radius: 999px; + background: var(--c-primary); + transition: width 0.6s ease; +} +.rasse-result-bar--dim { + background: var(--c-text-muted, #9ca3af); +} +.rasse-result-pct { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-primary); +} +.rasse-result-pct--dim { + color: var(--c-text-muted); +} +.rasse-result-desc { + font-size: var(--text-xs); + color: var(--c-text-secondary); + margin-top: var(--space-1); + line-height: 1.4; +} diff --git a/backend/static/index.html b/backend/static/index.html index 8356b59..f42f248 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -158,6 +158,9 @@ + Entdecken - `; + `}; + return `
@@ -2156,6 +2215,306 @@ window.Page_health = (() => { }); } + // ---------------------------------------------------------- + // MEIN TIERARZT — Kachel + // ---------------------------------------------------------- + async function _loadMeinTierarzt() { + const el = _container.querySelector('#health-mein-tierarzt'); + if (!el) return; + _renderMeinTierarztKachel(el); + } + + function _renderMeinTierarztKachel(el) { + if (!el) return; + const vet = _favoritVet; + const adresse = vet + ? [vet.strasse, [vet.plz, vet.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ') + : ''; + + el.innerHTML = ` +
+
+ Mein Tierarzt +
+
+
+ +
+
+ ${vet ? ` +
${_esc(vet.name)}
+ ${adresse ? `
${_esc(adresse)}
` : ''} + ${vet.telefon ? ` + ` : ''} + ${vet.notfall_telefon ? ` + ` : ''} + ` : ` +
+ Noch kein Tierarzt als Favorit gespeichert. +
+ + `} +
+ ${vet ? ` + + ` : ''} +
+
+ `; + + el.querySelector('#health-suche-tierarzt-btn')?.addEventListener('click', () => { + App.navigate('map', { filter: 'tierarzt' }); + }); + + el.querySelector('#health-remove-fav-btn')?.addEventListener('click', async (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + await UI.asyncButton(btn, async () => { + await API.tieraerzte.toggleFavorite(_favoritVet.id); + _favoritVet = null; + const elAgain = _container.querySelector('#health-mein-tierarzt'); + if (elAgain) _renderMeinTierarztKachel(elAgain); + UI.toast.success('Tierarzt-Favorit entfernt.'); + }); + }); + } + + // ---------------------------------------------------------- + // BEFUNDE & DOKUMENTE — Sektion (unabhängig von den Tabs) + // ---------------------------------------------------------- + // Diese Sektion erscheint im "dokument"-Tab als zweite Liste. + // Wir ergänzen _renderDokumente um einen Abschnitt unten. + + function _renderBefundeSection() { + const dog = _appState.activeDog; + const docs = _healthDocs; + const DOC_ICONS = { + blutbild: 'drop', + roentgen: 'file-text', + rezept: 'note', + impfausweis:'certificate', + sonstiges: 'file-text', + }; + const DOC_LABELS = { + blutbild: 'Blutbild', + roentgen: 'Röntgen', + rezept: 'Rezept', + impfausweis:'Impfausweis', + sonstiges: 'Sonstiges', + }; + + const uploadBtn = ` + `; + + const items = docs.length + ? docs.map(doc => { + const icon = DOC_ICONS[doc.typ] || 'file-text'; + const label = DOC_LABELS[doc.typ] || doc.typ; + const isImg = !['pdf'].includes(doc.file_type); + const datum = doc.datum ? UI.time.format(doc.datum + 'T00:00:00') : ''; + return ` +
+
+ +
+
+
${_esc(doc.titel)}
+
+ ${_esc(label)}${datum ? ' · ' + datum : ''} + ${doc.vet_name ? ' · ' + _esc(doc.vet_name) : ''} +
+ ${doc.beschreibung ? `
${_esc(doc.beschreibung)}
` : ''} +
+ + ${isImg + ? ' Bild öffnen' + : ' PDF öffnen'} + + +
+
+
`; + }).join('') + : `

+ Noch keine Befunde hochgeladen. +

`; + + return ` +
+
+
+ Befunde & Dokumente +
+ ${uploadBtn} +
+
${items}
+
+ `; + } + + function _bindBefundeEvents(content) { + content.querySelector('#health-docs-upload-btn')?.addEventListener('click', () => { + _showBefundUploadModal(); + }); + content.querySelectorAll('[data-action="delete-hdoc"]').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const docId = parseInt(btn.dataset.docId); + const ok = window.confirm('Befund wirklich löschen?'); + if (!ok) return; + await UI.asyncButton(btn, async () => { + await API.healthDocs.delete(docId); + _healthDocs = _healthDocs.filter(d => d.id !== docId); + _renderTab(); + UI.toast.success('Befund gelöscht.'); + }); + }); + }); + } + + function _showBefundUploadModal() { + const aktivePraxen = _praxen.filter(p => p.aktiv); + const dog = _appState.activeDog; + + UI.modal.open({ + title: ` Befund hochladen`, + body: ` +
+
+ + +
+
+ + +
+
+ + +
+ ${aktivePraxen.length ? ` +
+ + +
` : ''} +
+ + +
+
+ + +
+
+
+ `, + footer: ` + + + `, + }); + + document.getElementById('befund-cancel')?.addEventListener('click', UI.modal.close); + + document.getElementById('befund-file-input')?.addEventListener('change', function () { + const preview = document.getElementById('befund-file-preview'); + if (this.files?.length) { + const f = this.files[0]; + preview.textContent = `${f.name} (${(f.size / 1024 / 1024).toFixed(2)} MB)`; + } else { + preview.textContent = ''; + } + }); + + document.getElementById('befund-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = document.querySelector('[form="befund-form"][type="submit"]'); + const form = e.target; + const fd = UI.formData(form); + const fileInput = form.querySelector('[name="file"]'); + const file = fileInput?.files?.[0]; + + if (!fd.typ) { UI.toast.warning('Bitte Art des Dokuments wählen.'); return; } + if (!fd.titel) { UI.toast.warning('Bitte Titel eingeben.'); return; } + if (!file) { UI.toast.warning('Bitte eine Datei auswählen.'); return; } + + if (file.size > 10 * 1024 * 1024) { + UI.toast.error('Datei ist zu groß. Maximum: 10 MB.'); + return; + } + + await UI.asyncButton(btn, async () => { + const formData = new FormData(); + formData.append('dog_id', String(dog.id)); + formData.append('typ', fd.typ); + formData.append('titel', fd.titel); + formData.append('beschreibung', fd.beschreibung || ''); + formData.append('datum', fd.datum || ''); + if (fd.vet_id) formData.append('vet_id', fd.vet_id); + formData.append('file', file); + + try { + const doc = await API.healthDocs.upload(formData); + _healthDocs.unshift(doc); + UI.modal.close(); + _renderTab(); + UI.toast.success('Befund hochgeladen.'); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Hochladen.'); + } + }); + }); + } + // ---------------------------------------------------------- async function _showKiSummary() { const btn = _container.querySelector('#health-ki-btn'); @@ -2323,6 +2682,129 @@ window.Page_health = (() => { }); } + // ---------------------------------------------------------- + // KI-TIERARZTFRAGEN + // ---------------------------------------------------------- + function _showKiTierarzt() { + const dog = _appState.activeDog; + const dogName = dog?.name || ''; + const rasse = dog?.rasse || ''; + const placeholder = dogName + ? `Welche Symptome zeigt ${dogName}? z.B. frisst nicht, schläft viel, lahmt...` + : 'Welche Symptome zeigt dein Hund? z.B. frisst nicht, schläft viel, lahmt...'; + + UI.modal.open({ + title: ' KI-Tierarzt', + body: ` +

+ Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung — + kein Ersatz für einen echten Tierarzt. +

+
+ +
+ +
+ ⚠️ Hinweis: Dies ist keine medizinische Diagnose. + Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt. +
`, + footer: ` + + `, + }); + + document.getElementById('ki-tierarzt-submit-btn') + .addEventListener('click', async function () { + const btn = this; + const symptom = document.getElementById('ki-tierarzt-symptom').value.trim(); + const resultEl = document.getElementById('ki-tierarzt-result'); + + if (!symptom) { + UI.toast.warning('Bitte Symptome eingeben.'); + return; + } + + await UI.asyncButton(btn, async () => { + resultEl.style.display = 'none'; + resultEl.innerHTML = ''; + + let result; + try { + result = await API.post('/ki/tierarzt', { + symptom, + dog_id: dog?.id || null, + dog_name: dogName || null, + rasse: rasse || null, + }); + } catch (err) { + if (err.status === 429) { + resultEl.innerHTML = ` +
+ + 5 Anfragen pro Tag erreicht. Morgen wieder verfügbar. +
`; + } else if (err.status === 503) { + resultEl.innerHTML = ` +
+ KI momentan nicht verfügbar. Bitte später versuchen. +
`; + } else { + UI.toast.error(err.message || 'Fehler bei der KI-Anfrage.'); + return; + } + resultEl.style.display = ''; + return; + } + + const antwortHtml = _esc(result.antwort) + .replace(/\n\n/g, '

') + .replace(/\n/g, '
'); + const restHtml = result.limit - result.anfragen_heute > 0 + ? `

+ Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar. +

` + : `

+ Tageslimit erreicht. Morgen wieder verfügbar. +

`; + + resultEl.innerHTML = ` +
+
+ + Einschätzung +
+

${antwortHtml}

+ ${restHtml} +
+
+ ⚠️ Dies ist keine medizinische Diagnose. + Bei ernsthaften Symptomen sofort zum Tierarzt. +
`; + resultEl.style.display = ''; + + // Submit-Button ausblenden wenn Limit erschöpft + if (result.anfragen_heute >= result.limit) { + btn.disabled = true; + btn.textContent = 'Limit erreicht'; + } + }); + }); + } + return { init, refresh, openNew, onDogChange }; })(); diff --git a/backend/static/js/pages/playdate.js b/backend/static/js/pages/playdate.js new file mode 100644 index 0000000..60eba05 --- /dev/null +++ b/backend/static/js/pages/playdate.js @@ -0,0 +1,708 @@ +/* ============================================================ + BAN YARO — Playdate-Matching + Spielkameraden in der Nähe finden, Inserate verwalten, Anfragen + ============================================================ */ + +window.Page_playdate = (() => { + + let _container = null; + let _appState = null; + let _activeTab = 'nearby'; // 'nearby' | 'listings' | 'requests' + let _userPos = null; + let _radius = 10; + let _dogs = []; + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + function _esc(s) { + return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function _fmtDate(iso) { + if (!iso) return ''; + const d = new Date(iso.replace(' ', 'T')); + return d.toLocaleDateString('de-DE'); + } + + function _dogAvatar(foto_url, name, size = 48) { + const initials = _esc((name || '?').charAt(0).toUpperCase()); + if (foto_url) { + return `${initials}`; + } + return `
${initials}
`; + } + + function _statusBadge(status) { + const map = { + pending: ['warning', 'Ausstehend'], + accepted: ['success', 'Angenommen'], + declined: ['danger', 'Abgelehnt'], + }; + const [type, label] = map[status] || ['default', status]; + const colors = { + warning: 'var(--c-warning, #f59e0b)', + success: 'var(--c-success, #10b981)', + danger: 'var(--c-danger, #ef4444)', + default: 'var(--c-text-muted)', + }; + return `${label}`; + } + + // ------------------------------------------------------------------ + // INIT + // ------------------------------------------------------------------ + async function init(container, appState) { + _container = container; + _appState = appState; + _dogs = appState.dogs?.filter(d => !d.is_guest) || []; + _render(); + _switchTab(_activeTab); + } + + function refresh() { + _dogs = _appState?.dogs?.filter(d => !d.is_guest) || []; + _switchTab(_activeTab); + } + + function onDogChange() { + _dogs = _appState?.dogs?.filter(d => !d.is_guest) || []; + if (_activeTab === 'listings') _loadListings(); + } + + // ------------------------------------------------------------------ + // RENDER — Grundstruktur mit Tabs + // ------------------------------------------------------------------ + function _render() { + _container.innerHTML = ` +
+ + +
+ + + +
+ + +
+ +
+ `; + + document.getElementById('playdate-tabs').addEventListener('click', e => { + const btn = e.target.closest('.by-tab'); + if (!btn) return; + _switchTab(btn.dataset.tab); + }); + } + + function _switchTab(tab) { + _activeTab = tab; + document.querySelectorAll('#playdate-tabs .by-tab').forEach(b => { + b.classList.toggle('active', b.dataset.tab === tab); + }); + const content = document.getElementById('playdate-content'); + if (!content) return; + + if (tab === 'nearby') _renderNearby(content); + if (tab === 'listings') _renderListings(content); + if (tab === 'requests') _renderRequests(content); + } + + // ------------------------------------------------------------------ + // TAB: IN DER NÄHE + // ------------------------------------------------------------------ + async function _renderNearby(el) { + el.innerHTML = ` +
+ +
+
+ ${UI.icon('map-pin')} + + ${_userPos ? 'Standort bekannt' : 'Kein Standort'} + +
+ + +
+ + +
+ ${UI.icon('info')} Nur Hunde von Nutzern die sich ebenfalls mit einem Inserat eingetragen haben. + Dein genauer Standort bleibt privat — es wird nur der Ortsname und die ungefähre Entfernung angezeigt. +
+ + +
+

+ Standort wird ermittelt… +

+
+
+ `; + + document.getElementById('nearby-radius').addEventListener('change', e => { + _radius = parseInt(e.target.value, 10); + _loadNearby(); + }); + + document.getElementById('nearby-locate-btn').addEventListener('click', async () => { + const btn = document.getElementById('nearby-locate-btn'); + UI.setLoading(btn, true); + try { + _userPos = await API.getLocation(); + const label = document.getElementById('nearby-location-label'); + if (label) label.textContent = 'Standort aktualisiert'; + await _loadNearby(); + } catch { + UI.toast.error('Standort konnte nicht ermittelt werden.'); + } finally { + UI.setLoading(btn, false); + } + }); + + if (!_userPos) { + try { + _userPos = await API.getLocation(); + const label = document.getElementById('nearby-location-label'); + if (label) label.textContent = 'Standort bekannt'; + } catch { + document.getElementById('nearby-results').innerHTML = ` +
+ ${UI.icon('map-pin')} +

+ Standort konnte nicht automatisch ermittelt werden.
+ Klicke auf "Standort aktualisieren". +

+
+ `; + return; + } + } + await _loadNearby(); + } + + async function _loadNearby() { + if (!_userPos) return; + const resultsEl = document.getElementById('nearby-results'); + if (!resultsEl) return; + resultsEl.innerHTML = `

${UI.icon('spinner')} Suche…

`; + + try { + const data = await API.get(`/playdate/nearby?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=${_radius}`); + + if (!data || data.length === 0) { + resultsEl.innerHTML = UI.emptyState({ + icon: UI.icon('paw-print'), + title: 'Niemand in der Nähe', + text: `Aktuell hat sich noch niemand in ${_radius} km Umkreis eingetragen. Trage deinen Hund unter "Meine Inserate" ein!`, + }); + return; + } + + resultsEl.innerHTML = ` +
+ ${data.map(d => _nearbyCard(d)).join('')} +
+ `; + + resultsEl.querySelectorAll('.playdate-anfrage-btn').forEach(btn => { + btn.addEventListener('click', () => { + const toDogId = parseInt(btn.dataset.dogId, 10); + const dogName = btn.dataset.dogName; + _showRequestModal(toDogId, dogName); + }); + }); + + } catch (err) { + resultsEl.innerHTML = `

${err.message}

`; + } + } + + function _nearbyCard(d) { + return ` +
+
+ ${_dogAvatar(d.foto_url, d.dog_name, 56)} +
+
${_esc(d.dog_name)}
+ ${d.rasse ? `
${_esc(d.rasse)}
` : ''} + ${d.alter ? `
${_esc(d.alter)}
` : ''} +
+
+ +
+ + ${UI.icon('map-pin')} + ${d.ort_name ? _esc(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt + + ${d.geschlecht ? `${_esc(d.geschlecht)}` : ''} +
+ + ${d.beschreibung ? ` +

+ ${_esc(d.beschreibung)} +

` : ''} + + +
+ `; + } + + function _showRequestModal(toDogId, dogName) { + const formId = 'playdate-req-form'; + UI.modal.open({ + title: `Anfrage an ${dogName}`, + body: ` +
+
+ + +
+
+ `, + footer: ` + + + `, + }); + + document.getElementById('req-cancel-btn').addEventListener('click', () => UI.modal.close()); + document.getElementById('req-send-btn').addEventListener('click', async () => { + const btn = document.getElementById('req-send-btn'); + const nachricht = document.getElementById('req-nachricht').value.trim(); + await UI.asyncButton(btn, async () => { + const result = await API.post('/playdate/request', { + to_dog_id: toDogId, + nachricht: nachricht || null, + }); + UI.modal.close(); + UI.toast.success('Anfrage gesendet! Ein Chat wurde geöffnet.'); + // Zum Chat navigieren + if (result.conversation_id) { + setTimeout(() => { + App.navigate('chat', true, { conversation_id: result.conversation_id }); + }, 800); + } + }, { errorMsg: null }); + }); + } + + // ------------------------------------------------------------------ + // TAB: MEINE INSERATE + // ------------------------------------------------------------------ + async function _renderListings(el) { + el.innerHTML = `

${UI.icon('spinner')} Lädt…

`; + await _loadListings(el); + } + + async function _loadListings(el) { + const target = el || document.getElementById('playdate-content'); + if (!target) return; + + if (_dogs.length === 0) { + target.innerHTML = UI.emptyState({ + icon: UI.icon('paw-print'), + title: 'Noch kein Hund', + text: 'Lege zuerst einen Hund in deinem Profil an.', + action: ``, + }); + return; + } + + // Listings für alle eigenen Hunde laden + const listings = {}; + await Promise.all(_dogs.map(async dog => { + try { + const data = await API.get(`/playdate/my-listing/${dog.id}`); + listings[dog.id] = data; + } catch { + listings[dog.id] = null; + } + })); + + target.innerHTML = ` +
+ ${_dogs.map(dog => _listingCard(dog, listings[dog.id])).join('')} +
+ `; + + // Event-Delegation für alle Buttons + target.addEventListener('click', async e => { + const btn = e.target.closest('button[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + const dogId = parseInt(btn.dataset.dogId, 10); + const dog = _dogs.find(d => d.id === dogId); + + if (action === 'edit') { + _showListingModal(dog, listings[dogId], async () => { + await _loadListings(); + }); + } + if (action === 'deactivate') { + if (!window.confirm(`Inserat für ${dog?.name} deaktivieren?`)) return; + try { + await API.del(`/playdate/listing/${dogId}`); + UI.toast.success('Inserat deaktiviert.'); + await _loadListings(); + } catch (err) { + UI.toast.error(err.message); + } + } + }); + } + + function _listingCard(dog, listing) { + const isAktiv = listing && listing.aktiv; + return ` +
+
+ ${_dogAvatar(dog.foto_url, dog.name, 44)} +
+
${_esc(dog.name)}
+ ${dog.rasse ? `
${_esc(dog.rasse)}
` : ''} +
+ + ${isAktiv ? 'Aktiv' : 'Inaktiv'} + +
+ + ${isAktiv ? ` +
+ ${UI.icon('map-pin')} + ${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''} + Radius: ${listing.radius_km} km +
+ ${listing.beschreibung ? ` +

${_esc(listing.beschreibung)}

` : ''} + ` : ` +

+ Noch kein Inserat — trage dich ein, damit andere dich finden können. +

+ `} + +
+ + ${isAktiv ? ` + ` : ''} +
+
+ `; + } + + function _showListingModal(dog, existing, onSaved) { + const formId = 'listing-form'; + UI.modal.open({ + title: `Inserat für ${dog.name}`, + body: ` +
+
+ +
+ + +
+ + +
+ Tipp: Klicke auf ${UI.icon('crosshair')} um deinen Ortsnamen automatisch zu ermitteln. + Nur der Ortsname wird für andere sichtbar — nicht dein genauer Standort. +
+
+ +
+ + +
+ +
+ + +
+
+ `, + footer: ` + + + `, + }); + + // GPS-Button + document.getElementById('listing-gps-btn').addEventListener('click', async () => { + const gpsBtn = document.getElementById('listing-gps-btn'); + UI.setLoading(gpsBtn, true); + try { + const pos = await API.getLocation(); + document.getElementById('listing-lat').value = pos.lat; + document.getElementById('listing-lon').value = pos.lon; + + // Reverse-Geocoding für Ortsname + try { + const rev = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${pos.lat}&lon=${pos.lon}&format=json&zoom=10&accept-language=de`, + { cache: 'no-store' } + ); + const geoData = await rev.json(); + const a = geoData.address || {}; + const ort = a.city || a.town || a.village || a.municipality || ''; + if (ort) document.getElementById('listing-ort').value = ort; + } catch {} + UI.toast.success('Standort ermittelt.'); + } catch { + UI.toast.error('Standort konnte nicht ermittelt werden.'); + } finally { + UI.setLoading(gpsBtn, false); + } + }); + + document.getElementById('listing-cancel-btn').addEventListener('click', () => UI.modal.close()); + + document.getElementById('listing-save-btn').addEventListener('click', async () => { + const btn = document.getElementById('listing-save-btn'); + const lat = parseFloat(document.getElementById('listing-lat').value); + const lon = parseFloat(document.getElementById('listing-lon').value); + const ort = document.getElementById('listing-ort').value.trim(); + const rad = parseInt(document.getElementById('listing-radius').value, 10); + const desc = document.getElementById('listing-beschreibung').value.trim(); + + if (!lat || !lon) { + UI.toast.error('Bitte ermittle zuerst deinen Standort über den GPS-Button.'); + return; + } + + await UI.asyncButton(btn, async () => { + await API.put('/playdate/listing', { + dog_id: dog.id, + lat, + lon, + ort_name: ort || null, + radius_km: rad, + beschreibung: desc || null, + }); + UI.modal.close(); + UI.toast.success('Inserat gespeichert!'); + onSaved?.(); + }, { errorMsg: null }); + }); + } + + // ------------------------------------------------------------------ + // TAB: ANFRAGEN + // ------------------------------------------------------------------ + async function _renderRequests(el) { + el.innerHTML = `

${UI.icon('spinner')} Lädt…

`; + try { + const data = await API.get('/playdate/requests'); + const incoming = data.incoming || []; + const outgoing = data.outgoing || []; + + // Badge aktualisieren + const pendingCount = incoming.filter(r => r.status === 'pending').length; + const badge = document.getElementById('playdate-req-badge'); + if (badge) { + badge.textContent = pendingCount; + badge.style.display = pendingCount > 0 ? '' : 'none'; + } + + if (incoming.length === 0 && outgoing.length === 0) { + el.innerHTML = UI.emptyState({ + icon: UI.icon('paw-print'), + title: 'Noch keine Anfragen', + text: 'Wenn du oder jemand anderes eine Playdate-Anfrage schickt, erscheint sie hier.', + }); + return; + } + + el.innerHTML = ` +
+ + ${incoming.length > 0 ? ` +
+

Eingehende Anfragen

+
+ ${incoming.map(r => _incomingCard(r)).join('')} +
+
` : ''} + + ${outgoing.length > 0 ? ` +
+

Ausgehende Anfragen

+
+ ${outgoing.map(r => _outgoingCard(r)).join('')} +
+
` : ''} + +
+ `; + + // Button-Events (Accept/Decline) + el.querySelectorAll('.req-accept-btn, .req-decline-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const reqId = parseInt(btn.dataset.reqId, 10); + const status = btn.dataset.status; + await UI.asyncButton(btn, async () => { + const result = await API.patch(`/playdate/requests/${reqId}`, { status }); + if (status === 'accepted' && result.conversation_id) { + UI.toast.success('Anfrage angenommen! Chat wurde geöffnet.'); + setTimeout(() => { + App.navigate('chat', true, { conversation_id: result.conversation_id }); + }, 800); + } else { + UI.toast.success(status === 'accepted' ? 'Anfrage angenommen.' : 'Anfrage abgelehnt.'); + } + await _renderRequests(el); + }, { errorMsg: null }); + }); + }); + + // Chat-Buttons + el.querySelectorAll('.req-chat-btn').forEach(btn => { + btn.addEventListener('click', () => { + App.navigate('chat', true); + }); + }); + + } catch (err) { + el.innerHTML = `

${err.message}

`; + } + } + + function _incomingCard(r) { + const isPending = r.status === 'pending'; + return ` +
+
+ ${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)} +
+
${_esc(r.from_dog_name)}
+
+ ${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''} + ${r.alter ? _esc(r.alter) + ' · ' : ''} + von ${_esc(r.from_user_name)} +
+
${_fmtDate(r.created_at)}
+
+ ${_statusBadge(r.status)} +
+ + ${r.nachricht ? ` +
+ "${_esc(r.nachricht)}" +
` : ''} + + ${isPending ? ` +
+ + +
` : ` + ${r.status === 'accepted' ? ` + ` : ''} + `} +
+ `; + } + + function _outgoingCard(r) { + return ` +
+
+ ${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)} +
+
${_esc(r.to_dog_name)}
+
+ ${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''} + von ${_esc(r.to_user_name)} +
+
${_fmtDate(r.created_at)}
+
+ ${_statusBadge(r.status)} +
+ + ${r.nachricht ? ` +

+ "${_esc(r.nachricht)}" +

` : ''} + + ${r.status === 'accepted' ? ` + ` : ''} +
+ `; + } + + // ------------------------------------------------------------------ + return { init, refresh, onDogChange }; +})(); diff --git a/backend/static/js/pages/recalls.js b/backend/static/js/pages/recalls.js new file mode 100644 index 0000000..86ac5d5 --- /dev/null +++ b/backend/static/js/pages/recalls.js @@ -0,0 +1,190 @@ +/* ============================================================ + BAN YARO — Tierfutter-Rückrufe + Seiten-Modul: RASFF EU Rückruf-Alarm für Heimtierfutter. + ============================================================ */ + +window.Page_recalls = (() => { + + // ---------------------------------------------------------- + // MODUL-STATE + // ---------------------------------------------------------- + let _container = null; + let _appState = null; + let _recalls = []; + let _query = ''; + + // ---------------------------------------------------------- + // INIT + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _appState = appState; + _query = ''; + await _render(); + } + + // ---------------------------------------------------------- + // REFRESH + // ---------------------------------------------------------- + async function refresh() { + _recalls = []; + _query = ''; + await _render(); + } + + // ---------------------------------------------------------- + // RENDER + // ---------------------------------------------------------- + async function _render() { + _container.innerHTML = ` + +
+ +

+ Hinweis: Prüfe immer das Mindesthaltbarkeitsdatum und die Chargen-Nummer + bevor du ein gemeldetes Produkt entsorgst oder zurückgibst. +

+
+ + +
+ + +
+ + +
${UI.skeleton(4)}
+ `; + + // Suchfeld-Handler + _container.querySelector('#recalls-search').addEventListener('input', (e) => { + _query = e.target.value.trim(); + _renderList(); + }); + + await _loadRecalls(); + } + + // ---------------------------------------------------------- + // DATEN LADEN + // ---------------------------------------------------------- + async function _loadRecalls() { + try { + const url = _query ? `/recalls?q=${encodeURIComponent(_query)}` : '/recalls'; + _recalls = await API.get(url); + } catch { + _container.querySelector('#recalls-list').innerHTML = UI.emptyState({ + icon: 'warning-circle', + title: 'Rückrufe konnten nicht geladen werden', + text: 'Bitte versuche es später erneut.', + }); + return; + } + _renderList(); + } + + // ---------------------------------------------------------- + // LISTE RENDERN + // ---------------------------------------------------------- + function _renderList() { + const listEl = _container.querySelector('#recalls-list'); + if (!listEl) return; + + const filtered = _query + ? _recalls.filter(r => { + const q = _query.toLowerCase(); + return (r.titel || '').toLowerCase().includes(q) + || (r.produkt || '').toLowerCase().includes(q) + || (r.gefahr || '').toLowerCase().includes(q) + || (r.herkunft || '').toLowerCase().includes(q); + }) + : _recalls; + + if (!filtered.length) { + const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + listEl.innerHTML = UI.emptyState({ + icon: 'check-circle', + title: 'Aktuell keine Rückrufe', + text: `Letzte Prüfung: ${today}`, + }); + return; + } + + listEl.innerHTML = filtered.map(r => _cardHtml(r)).join(''); + } + + // ---------------------------------------------------------- + // EINZELNE KARTE + // ---------------------------------------------------------- + function _cardHtml(r) { + const datum = r.datum + ? new Date(r.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + : ''; + + const meta = [ + r.herkunft ? `${UI.icon('globe-hemisphere-west')} ${UI.escape(r.herkunft)}` : '', + datum ? `${UI.icon('calendar-blank')} ${datum}` : '', + r.quelle ? `${UI.escape(r.quelle)}` : '', + ].filter(Boolean).join(' · '); + + const linkHtml = r.url + ? ` + ${UI.icon('arrow-square-out')} Details auf RASFF + ` + : ''; + + return ` +
+ +
+ + + ${UI.escape(r.produkt || r.titel)} + +
+ + + ${r.gefahr ? ` +

+ ${UI.escape(r.gefahr)} +

` : ''} + + +
+ ${meta} +
+ + + ${linkHtml ? `
${linkHtml}
` : ''} +
+ `; + } + + // ---------------------------------------------------------- + // PUBLIC API + // ---------------------------------------------------------- + return { init, refresh }; + +})(); diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index f5019a7..fed9bac 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -1747,6 +1747,16 @@ window.Page_uebungen = (() => { _closeModal(); if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; } + // Streak aktualisieren — fire-and-forget, Toast bei neuem Streak-Rekord + API.post(`/streak/${body.dog_id}/ping`).then(streak => { + if (!streak) return; + if (streak.current_streak > 1 && streak.current_streak === streak.longest_streak) { + setTimeout(() => UI.toast.success(`🔥 Neuer Rekord! ${streak.current_streak} Tage in Folge trainiert!`), 1500); + } else if (streak.current_streak > 1) { + setTimeout(() => UI.toast.info(`🔥 Streak: ${streak.current_streak} Tage in Folge!`), 1500); + } + }).catch(() => {}); + if (resp.ist_top) { UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.'); } else { diff --git a/backend/static/js/pages/welcome.js b/backend/static/js/pages/welcome.js index e917eca..e3514d5 100644 --- a/backend/static/js/pages/welcome.js +++ b/backend/static/js/pages/welcome.js @@ -463,6 +463,8 @@ window.Page_welcome = (() => { `).join('')}
+ ${dog?.id ? `
` : ''} +

Mehr entdecken

${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => ` @@ -497,9 +499,85 @@ window.Page_welcome = (() => { _updateChipsFromDash(dash); _tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen }).catch(() => { /* Skeleton bleibt sichtbar */ }); + + // Streak-Widget asynchron laden + _loadStreakWidget(dog.id); } } + // ---------------------------------------------------------- + // STREAK-WIDGET + // ---------------------------------------------------------- + async function _loadStreakWidget(dogId) { + const slot = _container.querySelector('#wc-streak-widget'); + if (!slot) return; + + let streak; + try { + streak = await API.get(`/streak/${dogId}`); + } catch { return; } + + if (!streak || (streak.current_streak === 0 && streak.longest_streak === 0)) return; + + slot.innerHTML = _streakWidgetHTML(streak); + + slot.querySelector('#wc-streak-leaderboard-btn')?.addEventListener('click', async () => { + const modalEl = UI.modal.open({ + title: '🔥 Trainings-Bestenliste', + body: '

Wird geladen…

', + }); + let board; + try { board = await API.get('/streak/leaderboard'); } catch { board = []; } + const bodyEl = modalEl?.querySelector('.modal-body'); + if (bodyEl) bodyEl.innerHTML = _leaderboardHTML(board); + }); + } + + function _streakWidgetHTML(s) { + const cur = s.current_streak || 0; + const best = s.longest_streak || 0; + return ` +
+
+ 🔥 + ${cur} +
+
+
Tage in Folge trainiert
+
Rekord: ${best} ${best === 1 ? 'Tag' : 'Tage'}
+
+ +
`; + } + + function _leaderboardHTML(rows) { + if (!rows || !rows.length) { + return '

Noch keine Einträge.

'; + } + const medals = ['🥇', '🥈', '🥉']; + return ` +
+ ${rows.map((r, i) => ` +
+ ${medals[i] || (i + 1) + '.'} + ${r.foto_url + ? `` + : `
`} +
+
${UI.escape(r.dog_name)}
+
${UI.escape(r.rasse || '')}${r.user_name ? ' · ' + UI.escape(r.user_name) : ''}
+
+
+ 🔥 + ${r.current_streak} +
+
+ `).join('')} +
`; + } + function _updateHeroFromDash(dash, dog) { const heroBox = _container.querySelector('#wc-hero-box'); if (!heroBox) return; diff --git a/backend/static/js/pages/wiki.js b/backend/static/js/pages/wiki.js index 69f2e4a..b2b6390 100644 --- a/backend/static/js/pages/wiki.js +++ b/backend/static/js/pages/wiki.js @@ -255,6 +255,15 @@ window.Page_wiki = (() => {
+
+ + +