diff --git a/Makefile b/Makefile index 692e7bb..910c66d 100644 --- a/Makefile +++ b/Makefile @@ -128,17 +128,6 @@ staging: check-ssh @echo " ✓ Staging fertig — https://staging.banyaro.app" @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_STAGING) --tail=10" -# ---------------------------------------------------------- -# STAGING-DB — Produktions-DB in Staging kopieren (interaktiv, braucht sudo) -# Aufruf: make staging-db -# ---------------------------------------------------------- -staging-db: check-ssh - @echo "→ Produktions-DB nach Staging kopieren..." - @ssh -t $(DS_HOST) " \ - sudo cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db && \ - sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db && \ - echo '✓ DB kopiert'" - # ---------------------------------------------------------- # RELEASE — develop → main → Production (VERSION= pflichtangabe) # Beispiel: make release VERSION=1.1.0 diff --git a/backend/database.py b/backend/database.py index 1a70aa5..e373f02 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1657,221 +1657,3 @@ 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) - ) - """) - - # Wiederkehrende Ausgaben (Daueraufträge) - conn.executescript(""" - CREATE TABLE IF NOT EXISTS recurring_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, - haeufigkeit TEXT NOT NULL, -- monatlich|quartalsweise|jaehrlich - startdatum TEXT NOT NULL, - naechste_faelligkeit TEXT NOT NULL, - notiz TEXT, - aktiv INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv); - """) diff --git a/backend/main.py b/backend/main.py index 8b259f7..6eb99a2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -189,13 +189,6 @@ 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"]) @@ -247,13 +240,6 @@ 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"]) # ------------------------------------------------------------------ @@ -1688,152 +1674,6 @@ 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 c4e830c..7b268fa 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,5 +13,3 @@ pywebpush==2.0.0 apscheduler==3.10.4 odfpy==1.4.1 polyline==2.0.2 -fpdf2==2.8.3 -python-dateutil>=2.9 diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py deleted file mode 100644 index d742ccc..0000000 --- a/backend/routes/adoption.py +++ /dev/null @@ -1,292 +0,0 @@ -""" -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 deleted file mode 100644 index 9c93475..0000000 --- a/backend/routes/expenses.py +++ /dev/null @@ -1,396 +0,0 @@ -"""BAN YARO — Ausgaben-Tracker Routes""" - -import logging -from datetime import date, timedelta -from dateutil.relativedelta import relativedelta -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 - - -class RecurringCreate(BaseModel): - dog_id: Optional[int] = None - kategorie: str - betrag: float - haeufigkeit: str # monatlich | quartalsweise | jaehrlich - startdatum: str # ISO date - notiz: Optional[str] = None - -class RecurringUpdate(BaseModel): - dog_id: Optional[int] = None - kategorie: Optional[str] = None - betrag: Optional[float] = None - haeufigkeit: Optional[str] = None - startdatum: Optional[str] = None - notiz: Optional[str] = None - aktiv: Optional[bool] = None - - -HAEUFIGKEITEN = {"monatlich", "quartalsweise", "jaehrlich"} - - -def _next_due(startdatum: str, haeufigkeit: str, after: date) -> date: - """Berechnet das nächste Fälligkeitsdatum nach `after`.""" - d = date.fromisoformat(startdatum) - if d > after: - return d - if haeufigkeit == "monatlich": - delta = relativedelta(months=1) - elif haeufigkeit == "quartalsweise": - delta = relativedelta(months=3) - else: - delta = relativedelta(years=1) - while d <= after: - d += delta - return d - - -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 - - -# ------------------------------------------------------------------ -# Wiederkehrende Ausgaben -# ------------------------------------------------------------------ -@router.get("/recurring") -async def list_recurring(user=Depends(get_current_user)): - with db() as conn: - rows = conn.execute( - """SELECT r.*, d.name AS dog_name - FROM recurring_expenses r - LEFT JOIN dogs d ON d.id = r.dog_id - WHERE r.user_id=? ORDER BY r.startdatum DESC""", - (user["id"],), - ).fetchall() - return [dict(r) for r in rows] - - -@router.post("/recurring", status_code=201) -async def create_recurring(data: RecurringCreate, user=Depends(get_current_user)): - if data.kategorie not in KATEGORIEN: - raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}") - if data.haeufigkeit not in HAEUFIGKEITEN: - raise HTTPException(400, f"Ungültige Häufigkeit: {data.haeufigkeit}") - if data.betrag <= 0: - raise HTTPException(400, "Betrag muss größer als 0 sein.") - - today = date.today() - naechste = _next_due(data.startdatum, data.haeufigkeit, today - timedelta(days=1)) - - with db() as conn: - if data.dog_id: - if not conn.execute("SELECT 1 FROM dogs WHERE id=? AND user_id=?", - (data.dog_id, user["id"])).fetchone(): - raise HTTPException(404, "Hund nicht gefunden.") - conn.execute( - """INSERT INTO recurring_expenses - (user_id, dog_id, kategorie, betrag, haeufigkeit, startdatum, naechste_faelligkeit, notiz) - VALUES (?,?,?,?,?,?,?,?)""", - (user["id"], data.dog_id, data.kategorie, data.betrag, - data.haeufigkeit, data.startdatum, str(naechste), data.notiz), - ) - row = conn.execute( - "SELECT * FROM recurring_expenses WHERE user_id=? ORDER BY id DESC LIMIT 1", - (user["id"],), - ).fetchone() - return dict(row) - - -@router.patch("/recurring/{rid}") -async def update_recurring(rid: int, data: RecurringUpdate, user=Depends(get_current_user)): - with db() as conn: - row = conn.execute( - "SELECT * FROM recurring_expenses WHERE id=? AND user_id=?", (rid, user["id"]) - ).fetchone() - if not row: - raise HTTPException(404, "Dauerauftrag nicht gefunden.") - updates: dict = {} - if data.kategorie is not None: - if data.kategorie not in KATEGORIEN: - raise HTTPException(400, f"Ungültige Kategorie.") - updates["kategorie"] = data.kategorie - if data.betrag is not None: - updates["betrag"] = data.betrag - if data.haeufigkeit is not None: - if data.haeufigkeit not in HAEUFIGKEITEN: - raise HTTPException(400, "Ungültige Häufigkeit.") - updates["haeufigkeit"] = data.haeufigkeit - if data.startdatum is not None: - updates["startdatum"] = data.startdatum - if data.notiz is not None: - updates["notiz"] = data.notiz - if data.aktiv is not None: - updates["aktiv"] = 1 if data.aktiv else 0 - if updates: - # naechste_faelligkeit neu berechnen wenn relevante Felder geändert - startdatum = updates.get("startdatum", row["startdatum"]) - haeufigkeit = updates.get("haeufigkeit", row["haeufigkeit"]) - today = date.today() - updates["naechste_faelligkeit"] = str( - _next_due(startdatum, haeufigkeit, today - timedelta(days=1)) - ) - set_clause = ", ".join(f"{k}=?" for k in updates) - conn.execute(f"UPDATE recurring_expenses SET {set_clause} WHERE id=?", - [*updates.values(), rid]) - row = conn.execute("SELECT * FROM recurring_expenses WHERE id=?", (rid,)).fetchone() - return dict(row) - - -@router.delete("/recurring/{rid}", status_code=204) -async def delete_recurring(rid: int, user=Depends(get_current_user)): - with db() as conn: - if not conn.execute("SELECT 1 FROM recurring_expenses WHERE id=? AND user_id=?", - (rid, user["id"])).fetchone(): - raise HTTPException(404, "Dauerauftrag nicht gefunden.") - conn.execute("DELETE FROM recurring_expenses WHERE id=?", (rid,)) - return None - - -def process_due_recurring(user_id: int | None = None): - """Legt fällige Daueraufträge als Einträge an. Wird vom Scheduler aufgerufen.""" - today = date.today() - today_str = str(today) - with db() as conn: - where = "aktiv=1 AND naechste_faelligkeit <= ?" - params: list = [today_str] - if user_id: - where += " AND user_id=?" - params.append(user_id) - rows = conn.execute( - f"SELECT * FROM recurring_expenses WHERE {where}", params - ).fetchall() - - for r in rows: - # Eintrag anlegen - conn.execute( - """INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz) - VALUES (?,?,?,?,?,?)""", - (r["user_id"], r["dog_id"], r["kategorie"], r["betrag"], - r["naechste_faelligkeit"], - f"[Dauerauftrag] {r['notiz'] or r['kategorie']}"), - ) - # Nächste Fälligkeit berechnen - naechste = _next_due(r["startdatum"], r["haeufigkeit"], - date.fromisoformat(r["naechste_faelligkeit"])) - conn.execute( - "UPDATE recurring_expenses SET naechste_faelligkeit=? WHERE id=?", - (str(naechste), r["id"]), - ) - return len(rows) if rows else 0 diff --git a/backend/routes/health_docs.py b/backend/routes/health_docs.py deleted file mode 100644 index 0c2d4a7..0000000 --- a/backend/routes/health_docs.py +++ /dev/null @@ -1,138 +0,0 @@ -"""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 80d663c..aa8d001 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -1,11 +1,10 @@ """BAN YARO — KI Routes""" -from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, Request 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() @@ -63,224 +62,3 @@ 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 deleted file mode 100644 index 884e8d3..0000000 --- a/backend/routes/passport.py +++ /dev/null @@ -1,377 +0,0 @@ -"""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 deleted file mode 100644 index 01d57ae..0000000 --- a/backend/routes/playdate.py +++ /dev/null @@ -1,364 +0,0 @@ -"""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 deleted file mode 100644 index d0182a3..0000000 --- a/backend/routes/recalls.py +++ /dev/null @@ -1,138 +0,0 @@ -"""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 deleted file mode 100644 index ea03522..0000000 --- a/backend/routes/streak.py +++ /dev/null @@ -1,114 +0,0 @@ -"""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 48287f9..55107ec 100644 --- a/backend/routes/tieraerzte.py +++ b/backend/routes/tieraerzte.py @@ -63,68 +63,15 @@ 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. Enthält is_favorite.""" + """Alle Tierärzte des Users — aktive zuerst, dann inaktive.""" with db() as conn: rows = conn.execute( "SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name", (user["id"],) ).fetchall() - 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 + return [dict(r) for r in rows] @router.get("/osm-nearby") diff --git a/backend/scheduler.py b/backend/scheduler.py index 4aeb89a..68a4c07 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -124,14 +124,6 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) - # Täglich 06:30 — Wiederkehrende Ausgaben anlegen - _scheduler.add_job( - _job_recurring_expenses, - CronTrigger(hour=6, minute=30), - id="recurring_expenses", - replace_existing=True, - misfire_grace_time=3600, - ) # 1. des Monats 00:05 — Hund des Monats Sieger festlegen _scheduler.add_job( _job_hdm_winner, @@ -140,24 +132,8 @@ 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, Streak-Reminder 19:00, Rückruf-Check 08: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. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -879,8 +855,6 @@ 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 = "" @@ -1198,93 +1172,3 @@ 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)) - - -# ------------------------------------------------------------------ -# JOB: Wiederkehrende Ausgaben anlegen -# ------------------------------------------------------------------ -async def _job_recurring_expenses(): - try: - from routes.expenses import process_due_recurring - count = process_due_recurring() - logger.info(f"Daueraufträge: {count} Einträge angelegt.") - _log_job("recurring_expenses", "ok", f"{count} Einträge") - except Exception as e: - logger.error(f"Daueraufträge-Job Fehler: {e}") - _log_job("recurring_expenses", "error", str(e)) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 9e16ea7..3582760 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -6803,716 +6803,3 @@ 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; -} - -/* Rückrufe — Warnbanner (Dark-Mode-sicher) */ -.recalls-warning-banner { - background: var(--c-danger-subtle); - border: 1px solid var(--c-danger); - border-radius: var(--radius-md); - padding: var(--space-3) var(--space-4); - margin-bottom: var(--space-4); - display: flex; - align-items: flex-start; - gap: var(--space-2); -} -.recalls-warning-icon { - color: var(--c-danger); - flex-shrink: 0; - margin-top: 2px; -} -.recalls-warning-text { - margin: 0; - font-size: var(--text-sm); - color: var(--c-text); - line-height: 1.5; -} - -/* ============================================================ - Ausgaben-Tracker (expenses.js) - ============================================================ */ - -/* FAB */ -.exp-fab { - position: fixed; - bottom: calc(var(--nav-height, 64px) + var(--space-4)); - right: var(--space-4); - z-index: 100; - width: 52px; - height: 52px; - border-radius: 50%; - background: var(--c-primary); - color: #fff; - border: none; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 4px 14px rgba(0,0,0,.25); - cursor: pointer; - font-size: 1.35rem; - transition: transform .15s, box-shadow .15s; -} -.exp-fab:active { - transform: scale(.93); - box-shadow: 0 2px 8px rgba(0,0,0,.2); -} - -/* Lade-/Fehler-Zustände */ -.exp-loading { padding: var(--space-4); } -.exp-error { - padding: var(--space-4); - color: var(--c-danger); - font-size: var(--text-sm); - text-align: center; -} -.exp-empty-hint { - color: var(--c-text-secondary); - font-size: var(--text-sm); - padding: var(--space-3) 0; - text-align: center; -} - -/* ---- Hero-Card (Übersicht & Statistik oben) ---- */ -.exp-hero-card { - background: linear-gradient(135deg, var(--c-primary) 0%, color-mix(in srgb, var(--c-primary) 75%, #000) 100%); - color: #fff; - border-radius: var(--radius-xl, 16px); - padding: var(--space-5) var(--space-4); - margin: var(--space-3) var(--space-3) var(--space-4); - text-align: center; - box-shadow: 0 6px 20px rgba(0,0,0,.15); -} -.exp-hero-card--sm { - padding: var(--space-4) var(--space-4); -} -.exp-hero-label { - font-size: var(--text-sm); - font-weight: var(--weight-medium); - opacity: .85; - margin-bottom: var(--space-1); - text-transform: uppercase; - letter-spacing: .04em; -} -.exp-hero-betrag { - font-size: clamp(1.9rem, 7vw, 2.8rem); - font-weight: var(--weight-bold); - line-height: 1.1; - letter-spacing: -.02em; -} -.exp-hero-meta { - margin-top: var(--space-2); - font-size: var(--text-sm); - opacity: .85; - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: var(--space-2); -} - -/* Trend-Badge */ -.exp-trend { - display: inline-flex; - align-items: center; - gap: 2px; - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - padding: 2px 8px; - border-radius: 999px; -} -.exp-trend--up { background: rgba(239,68,68,.25); } -.exp-trend--down { background: rgba(16,185,129,.25); } - -/* ---- Kachel-Grid (Übersicht) ---- */ -.exp-kachel-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: var(--space-2); - padding: 0 var(--space-3) var(--space-3); -} -.exp-kachel { - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - padding: var(--space-3) var(--space-2); - text-align: center; - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-1); -} -.exp-kachel-icon { - width: 44px; - height: 44px; - border-radius: var(--radius-md); - display: flex; - align-items: center; - justify-content: center; - font-size: 1.3rem; - margin-bottom: var(--space-1); -} -.exp-kachel-betrag { - font-size: var(--text-sm); - font-weight: var(--weight-bold); - line-height: 1.1; -} -.exp-kachel-label { - font-size: var(--text-xs); - color: var(--c-text-secondary); - line-height: 1.2; -} - -/* ---- Sektion-Block (Verlauf etc.) ---- */ -.exp-section { - margin: 0 var(--space-3) var(--space-4); - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - padding: var(--space-4); -} -.exp-section-title { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); - margin-bottom: var(--space-3); - display: flex; - align-items: center; - gap: var(--space-1); - text-transform: uppercase; - letter-spacing: .04em; -} - -/* ---- Balkendiagramm (Verlauf) ---- */ -.exp-bar-chart { - display: flex; - align-items: flex-end; - gap: var(--space-1); - height: 80px; -} -.exp-bar-chart--12 { - height: 90px; - gap: 4px; -} -.exp-bar-item { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: 3px; -} -.exp-bar-item--aktiv .exp-bar-label { - color: var(--c-primary); - font-weight: var(--weight-semibold); -} -.exp-bar-track { - width: 100%; - height: 60px; - background: var(--c-surface-2, #f3f4f6); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; - display: flex; - flex-direction: column; - justify-content: flex-end; - overflow: hidden; -} -.exp-bar-track--stack { - height: 70px; -} -.exp-bar-fill { - width: 100%; - background: var(--c-primary); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; - transition: height .4s ease; -} -.exp-bar-fill--aktiv { background: var(--c-primary); } -.exp-stack-seg { - width: 100%; - min-height: 2px; - transition: height .4s ease; -} -.exp-bar-label { - font-size: var(--text-xs); - color: var(--c-text-muted, #9ca3af); - white-space: nowrap; -} -.exp-bar-val { - font-size: var(--text-xs); - color: var(--c-text-secondary); -} - -/* ---- Einträge-Liste ---- */ -.exp-list { - padding: 0 var(--space-3); -} -.exp-month-group { - margin-bottom: var(--space-3); -} -.exp-month-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-2) var(--space-3); - background: var(--c-surface-2, #f3f4f6); - border-radius: var(--radius-md); - margin-bottom: var(--space-2); -} -.exp-month-title { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); - text-transform: uppercase; - letter-spacing: .04em; -} -.exp-month-summe { - font-size: var(--text-sm); - font-weight: var(--weight-bold); - color: var(--c-primary); -} - -/* Einzelner Eintrag */ -.exp-entry { - display: flex; - align-items: center; - gap: var(--space-3); - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-md); - padding: var(--space-3); - margin-bottom: var(--space-2); - cursor: pointer; - transition: background .15s; -} -.exp-entry:active { background: var(--c-surface-2, #f3f4f6); } - -/* Icon-Badge mit Kategorie-Farbe */ -.exp-entry-icon-badge { - flex-shrink: 0; - width: 40px; - height: 40px; - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--kat-color) 15%, transparent); - color: var(--kat-color); - display: flex; - align-items: center; - justify-content: center; - font-size: 1.1rem; -} - -.exp-entry-body { - flex: 1; - min-width: 0; -} -.exp-entry-head { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-1); - margin-bottom: 2px; -} -.exp-entry-datum { - font-size: var(--text-xs); - color: var(--c-text-muted, #9ca3af); - flex-shrink: 0; -} -.exp-entry-kat { - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--c-text); -} -.exp-entry-notiz { - display: block; - font-size: var(--text-xs); - color: var(--c-text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.exp-dog-badge { - display: inline-flex; - align-items: center; - gap: 2px; - font-size: var(--text-xs); - color: var(--c-text-secondary); - background: var(--c-surface-2, #f3f4f6); - border-radius: 999px; - padding: 1px 6px; -} - -/* Rechte Spalte: Betrag + Löschen-Icon */ -.exp-entry-right { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: var(--space-1); - flex-shrink: 0; -} -.exp-entry-betrag { - font-size: var(--text-base); - font-weight: var(--weight-bold); - color: var(--c-text); - white-space: nowrap; -} -.exp-entry-del { - background: transparent; - border: none; - color: var(--c-text-muted, #9ca3af); - cursor: pointer; - padding: 2px 4px; - border-radius: var(--radius-sm); - font-size: 1rem; - line-height: 1; - transition: color .15s; -} -.exp-entry-del:hover { color: var(--c-danger); } - -/* ---- Statistik: Kategorie-Balken-Reihen ---- */ -.exp-stat-rows { - display: flex; - flex-direction: column; - gap: var(--space-2); -} -.exp-stat-row { - display: grid; - grid-template-columns: 120px 1fr 36px 80px; - align-items: center; - gap: var(--space-2); -} -.exp-stat-label { - display: flex; - align-items: center; - gap: var(--space-1); - font-size: var(--text-sm); - color: var(--c-text); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} -.exp-stat-icon { flex-shrink: 0; } -.exp-stat-bar-wrap { - height: 8px; - background: var(--c-surface-2, #f3f4f6); - border-radius: 999px; - overflow: hidden; -} -.exp-stat-bar { - height: 8px; - border-radius: 999px; - transition: width .5s ease; -} -.exp-stat-pct { - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); - text-align: right; -} -.exp-stat-val { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-text); - text-align: right; - white-space: nowrap; -} - -/* ---- Donut-Diagramm (CSS conic-gradient) ---- */ -.exp-donut-wrap { - display: flex; - align-items: center; - gap: var(--space-5); - flex-wrap: wrap; -} -.exp-donut { - position: relative; - width: 120px; - height: 120px; - border-radius: 50%; - flex-shrink: 0; -} -.exp-donut-hole { - position: absolute; - inset: 28px; - background: var(--c-surface); - border-radius: 50%; -} -.exp-donut-legend { - flex: 1; - display: flex; - flex-direction: column; - gap: var(--space-2); - min-width: 130px; -} -.exp-donut-legend-item { - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--text-sm); -} -.exp-donut-dot { - width: 10px; - height: 10px; - border-radius: 50%; - flex-shrink: 0; -} -.exp-donut-legend-label { - flex: 1; - color: var(--c-text); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} -.exp-donut-legend-pct { - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); -} - -/* Daueraufträge */ -.exp-recurring-card { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - background: var(--c-surface); - border: 1.5px solid var(--c-border); - border-radius: var(--radius-lg); - margin-bottom: var(--space-2); - transition: opacity .2s; -} -.exp-recurring-card--inaktiv { opacity: .55; } -.exp-recurring-freq { - font-size: var(--text-xs); - color: var(--c-primary); - font-weight: var(--weight-semibold); - background: var(--c-primary-subtle); - padding: 1px var(--space-2); - border-radius: var(--radius-full); -} -.exp-recurring-next { - font-size: var(--text-xs); - color: var(--c-text-muted); - margin-top: var(--space-1); - display: flex; - align-items: center; - gap: var(--space-1); - flex-wrap: wrap; -} -.exp-badge-inaktiv { - background: var(--c-surface-2); - color: var(--c-text-muted); - padding: 1px var(--space-2); - border-radius: var(--radius-full); - font-size: var(--text-xs); -} -.exp-icon-btn { - width: 28px; - height: 28px; - border: 1.5px solid var(--c-border); - border-radius: var(--radius-sm); - background: var(--c-surface); - color: var(--c-text-secondary); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: color .15s, border-color .15s; -} -.exp-icon-btn:hover { color: var(--c-text); border-color: var(--c-text-muted); } -.exp-icon-btn--danger:hover { color: var(--c-danger); border-color: var(--c-danger); } - -/* Ausgaben-Formular — Kategorie-Kacheln */ -.exp-kat-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: var(--space-2); -} -.exp-kat-tile { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-1); - padding: var(--space-3) var(--space-2); - border: 1.5px solid var(--c-border); - border-radius: var(--radius-md); - cursor: pointer; - transition: border-color .15s, background .15s; - background: var(--c-surface); - user-select: none; -} -.exp-kat-tile:hover { border-color: var(--c-primary); } -.exp-kat-tile--sel { - border-color: var(--c-primary); - background: var(--c-primary-subtle); -} -.exp-kat-tile-icon { font-size: 1.4rem; line-height: 1; } -.exp-kat-tile-label { - font-size: var(--text-xs); - font-weight: var(--weight-medium); - color: var(--c-text-secondary); - text-align: center; -} -.exp-kat-tile--sel .exp-kat-tile-label { color: var(--c-primary); } - -/* Betrag-Feld mit €-Prefix */ -.exp-betrag-wrap { - position: relative; - display: flex; - align-items: center; -} -.exp-betrag-prefix { - position: absolute; - left: var(--space-3); - color: var(--c-text-muted); - font-weight: var(--weight-semibold); - pointer-events: none; -} -.exp-betrag-input { padding-left: calc(var(--space-3) + 14px + var(--space-2)) !important; } - -/* Form-Label Hint */ -.form-label-hint { color: var(--c-text-muted); font-weight: normal; font-size: var(--text-xs); } - -/* Wiederholungs-Sektion */ -.exp-repeat-section { - margin-top: var(--space-4); - padding-top: var(--space-4); - border-top: 1px solid var(--c-border-light); -} -.exp-repeat-toggle { - display: flex; - align-items: center; - gap: var(--space-2); - cursor: pointer; - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--c-text); - user-select: none; -} -.exp-repeat-toggle-box { - width: 18px; - height: 18px; - border: 1.5px solid var(--c-border); - border-radius: var(--radius-sm); - background: var(--c-surface); - flex-shrink: 0; - transition: background .15s, border-color .15s; -} -.exp-repeat-toggle input:checked ~ .exp-repeat-toggle-box { - background: var(--c-primary); - border-color: var(--c-primary); -} diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index a9189b9..3fcf69f 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -190,84 +190,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index f42f248..8356b59 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -158,9 +158,6 @@ - Entdecken - `}; - - - const favCard = _favoritVet ? ` -
-
- ${UI.icon('heart')} Mein Tierarzt -
- ${renderCard(_favoritVet)} -
` : ''; - - const ohneGesetzt = aktive.filter(p => p.id !== _favoritVet?.id); + `; return `
${addBtn}
- ${favCard}
- ${ohneGesetzt.map(renderCard).join('')} + ${aktive.map(renderCard).join('')} ${inaktive.length ? `
@@ -2224,306 +2156,6 @@ 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)}
` : ''} - -
-
`; - }).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'); @@ -2691,129 +2323,6 @@ 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 deleted file mode 100644 index 60eba05..0000000 --- a/backend/static/js/pages/playdate.js +++ /dev/null @@ -1,708 +0,0 @@ -/* ============================================================ - 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 deleted file mode 100644 index dfd9bbe..0000000 --- a/backend/static/js/pages/recalls.js +++ /dev/null @@ -1,188 +0,0 @@ -/* ============================================================ - 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: UI.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 fed9bac..f5019a7 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -1747,16 +1747,6 @@ 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 e3514d5..e917eca 100644 --- a/backend/static/js/pages/welcome.js +++ b/backend/static/js/pages/welcome.js @@ -463,8 +463,6 @@ window.Page_welcome = (() => { `).join('')}
- ${dog?.id ? `
` : ''} -

Mehr entdecken

${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => ` @@ -499,85 +497,9 @@ 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 b2b6390..69f2e4a 100644 --- a/backend/static/js/pages/wiki.js +++ b/backend/static/js/pages/wiki.js @@ -255,15 +255,6 @@ window.Page_wiki = (() => {
-
- - -