diff --git a/backend/database.py b/backend/database.py
index 01dfc17..ab82594 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -189,6 +189,13 @@ def init_db():
);
CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id);
+ CREATE TABLE IF NOT EXISTS route_dogs (
+ route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ PRIMARY KEY (route_id, dog_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_route_dogs_dog ON route_dogs(dog_id);
+
CREATE TABLE IF NOT EXISTS exercise_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -1974,6 +1981,38 @@ def _migrate(conn_factory):
""")
logger.info("Migration: futter_profil bereit.")
+ # Futter-Einträge & Reaktionen (Verträglichkeits-Tracking)
+ try:
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS futter_eintraege (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ datum TEXT NOT NULL,
+ uhrzeit TEXT NOT NULL,
+ futter_name TEXT NOT NULL,
+ futter_typ TEXT NOT NULL DEFAULT 'trockenfutter',
+ menge_g INTEGER,
+ notiz TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_futter_eintraege_dog ON futter_eintraege(dog_id, datum DESC);
+
+ CREATE TABLE IF NOT EXISTS futter_reaktionen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ datum TEXT NOT NULL,
+ uhrzeit TEXT NOT NULL,
+ reaktion_typ TEXT NOT NULL,
+ intensitaet INTEGER NOT NULL DEFAULT 3,
+ notiz TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_futter_reaktionen_dog ON futter_reaktionen(dog_id, datum DESC);
+ """)
+ logger.info("Migration: futter_eintraege + futter_reaktionen bereit.")
+ except Exception as e:
+ logger.warning(f"Migration futter_eintraege/reaktionen: {e}")
+
# Wiederkehrende Ausgaben (Daueraufträge)
conn.executescript("""
CREATE TABLE IF NOT EXISTS recurring_expenses (
@@ -2104,6 +2143,85 @@ def _migrate(conn_factory):
except Exception:
pass # Spalte existiert bereits
+ # exercise_progress + training_plan_progress: dog_id ergänzen
+ existing_ep = [r[1] for r in conn.execute("PRAGMA table_info(exercise_progress)").fetchall()]
+ if 'dog_id' not in existing_ep:
+ try:
+ # Neue Tabelle mit dog_id erstellen
+ conn.execute("""
+ CREATE TABLE exercise_progress_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE,
+ exercise_id TEXT NOT NULL,
+ status TEXT,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(dog_id, exercise_id)
+ )
+ """)
+ # Bestehende Daten migrieren: dog_id = erster Hund des Users
+ conn.execute("""
+ INSERT INTO exercise_progress_new (user_id, dog_id, exercise_id, status, updated_at)
+ SELECT ep.user_id,
+ (SELECT id FROM dogs WHERE user_id=ep.user_id ORDER BY id LIMIT 1),
+ ep.exercise_id, ep.status, ep.updated_at
+ FROM exercise_progress ep
+ """)
+ conn.execute("DROP TABLE exercise_progress")
+ conn.execute("ALTER TABLE exercise_progress_new RENAME TO exercise_progress")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_user ON exercise_progress(user_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_exercise_progress_dog ON exercise_progress(dog_id)")
+ logger.info("Migration: exercise_progress.dog_id hinzugefügt.")
+ except Exception as e:
+ logger.warning(f"Migration exercise_progress.dog_id fehlgeschlagen: {e}")
+
+ existing_tp = [r[1] for r in conn.execute("PRAGMA table_info(training_plan_progress)").fetchall()]
+ if 'dog_id' not in existing_tp:
+ try:
+ conn.execute("""
+ CREATE TABLE training_plan_progress_new (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE,
+ item_key TEXT NOT NULL,
+ checked INTEGER NOT NULL DEFAULT 1,
+ checked_at TEXT NOT NULL DEFAULT (datetime('now')),
+ PRIMARY KEY (dog_id, item_key)
+ )
+ """)
+ conn.execute("""
+ INSERT INTO training_plan_progress_new (user_id, dog_id, item_key, checked, checked_at)
+ SELECT tp.user_id,
+ (SELECT id FROM dogs WHERE user_id=tp.user_id ORDER BY id LIMIT 1),
+ tp.item_key, tp.checked, tp.checked_at
+ FROM training_plan_progress tp
+ """)
+ conn.execute("DROP TABLE training_plan_progress")
+ conn.execute("ALTER TABLE training_plan_progress_new RENAME TO training_plan_progress")
+ logger.info("Migration: training_plan_progress.dog_id hinzugefügt.")
+ except Exception as e:
+ logger.warning(f"Migration training_plan_progress.dog_id fehlgeschlagen: {e}")
+
+ # verstorben_am: Hund als verstorben markierbar
+ try:
+ conn.execute("ALTER TABLE dogs ADD COLUMN verstorben_am TEXT")
+ logger.info("Migration: dogs.verstorben_am hinzugefügt.")
+ except Exception:
+ pass
+
+ # route_dogs: bestehende Routen allen Hunden des Users zuweisen
+ try:
+ existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0]
+ if existing == 0:
+ conn.execute("""
+ INSERT OR IGNORE INTO route_dogs (route_id, dog_id)
+ SELECT r.id, d.id
+ FROM routes r
+ JOIN dogs d ON d.user_id = r.user_id
+ """)
+ logger.info("Migration: route_dogs mit bestehenden Routen befüllt.")
+ except Exception as e:
+ logger.warning(f"Migration route_dogs fehlgeschlagen: {e}")
+
def _seed_help_articles(conn):
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
diff --git a/backend/main.py b/backend/main.py
index 3ebd1fa..dc86828 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -179,7 +179,10 @@ class MediaCacheMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith('/media/'):
- response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
+ if os.getenv('STAGING') == 'true':
+ response.headers['Cache-Control'] = 'no-cache'
+ else:
+ response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
return response
app.add_middleware(MediaCacheMiddleware)
@@ -250,6 +253,7 @@ from routes.ernaehrung import router as ernaehrung_router
from routes.challenges import router as challenges_router
from routes.gassi_zeiten import router as gassi_zeiten_router
from routes.help import router as help_router
+from routes.feedback import router as feedback_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -312,6 +316,7 @@ app.include_router(ernaehrung_router, prefix="/api/dogs", tag
app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
+app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"])
# ------------------------------------------------------------------
@@ -339,9 +344,39 @@ app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img")
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
-app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
-APP_VER = "826" # muss mit APP_VER in app.js übereinstimmen
+STAGING = os.getenv("STAGING", "false").lower() == "true"
+PROD_MEDIA_DIR = "/prod-media"
+
+_MIME_MAP = {
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
+ ".webp": "image/webp", ".gif": "image/gif", ".mp4": "video/mp4",
+ ".webm": "video/webm", ".pdf": "application/pdf",
+}
+
+if STAGING and os.path.isdir(PROD_MEDIA_DIR):
+ # Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only)
+ from fastapi.responses import FileResponse as _FileResponse
+
+ def _media_response(filepath: str):
+ ext = os.path.splitext(filepath)[1].lower()
+ mt = _MIME_MAP.get(ext, "application/octet-stream")
+ return _FileResponse(filepath, media_type=mt)
+
+ @app.api_route("/media/{path:path}", methods=["GET", "HEAD"])
+ async def serve_media_staging(path: str):
+ staging_file = os.path.join(MEDIA_DIR, path)
+ if os.path.isfile(staging_file):
+ return _media_response(staging_file)
+ prod_file = os.path.join(PROD_MEDIA_DIR, path)
+ if os.path.isfile(prod_file):
+ return _media_response(prod_file)
+ from fastapi import HTTPException as _HE
+ raise _HE(404, "Media not found")
+else:
+ app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
+
+APP_VER = "872" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json")
async def assetlinks():
diff --git a/backend/routes/admin.py b/backend/routes/admin.py
index 5e82927..92a199d 100644
--- a/backend/routes/admin.py
+++ b/backend/routes/admin.py
@@ -583,6 +583,48 @@ async def scheduler_trigger(job_id: str, user=Depends(require_admin)):
return {"ok": True, "job_id": job_id}
+# ------------------------------------------------------------------
+# GET /api/admin/referrals — User-wirbt-User Top 100
+# ------------------------------------------------------------------
+@router.get("/referrals")
+async def referral_stats(user=Depends(require_mod)):
+ with db() as conn:
+ # Top-Werber mit Anzahl
+ top = conn.execute("""
+ SELECT r.id, r.name, r.email,
+ COUNT(u.id) AS invited_count,
+ r.created_at AS member_since
+ FROM users u
+ JOIN users r ON r.id = u.referred_by
+ GROUP BY r.id
+ ORDER BY invited_count DESC
+ LIMIT 100
+ """).fetchall()
+
+ # Alle Einladungen (für Detail-Ansicht)
+ invites = conn.execute("""
+ SELECT u.id, u.name, u.email, u.created_at,
+ r.id AS referrer_id, r.name AS referrer_name
+ FROM users u
+ JOIN users r ON r.id = u.referred_by
+ ORDER BY u.created_at DESC
+ LIMIT 500
+ """).fetchall()
+
+ total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
+ total_referred = conn.execute(
+ "SELECT COUNT(*) FROM users WHERE referred_by IS NOT NULL"
+ ).fetchone()[0]
+
+ return {
+ "top_referrers": [dict(r) for r in top],
+ "recent_invites": [dict(r) for r in invites],
+ "total_users": total_users,
+ "total_referred": total_referred,
+ "viral_factor": round(total_referred / max(total_users - total_referred, 1), 2),
+ }
+
+
# ------------------------------------------------------------------
# GET /api/admin/ki/history — 30-Tage-Verlauf + Top-User (all-time)
# ------------------------------------------------------------------
diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py
index 6c35334..e2985dd 100644
--- a/backend/routes/dogs.py
+++ b/backend/routes/dogs.py
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
-from auth import get_current_user
+from auth import get_current_user, has_pro_access
from routes.push import send_push_to_user
from media_utils import safe_media_path, preview_url_from
@@ -41,7 +41,7 @@ class DogUpdate(BaseModel):
async def list_dogs(user=Depends(get_current_user)):
with db() as conn:
own = conn.execute(
- "SELECT *, NULL AS shared_by, NULL AS share_role FROM dogs WHERE user_id=? ORDER BY id",
+ "SELECT *, NULL AS shared_by, NULL AS share_role FROM dogs WHERE user_id=? AND (verstorben_am IS NULL) ORDER BY id",
(user["id"],)
).fetchall()
shared = conn.execute(
@@ -131,6 +131,14 @@ def _is_plausible_dog(name: str, rasse: str, geburtstag) -> tuple[bool, str]:
@router.post("")
async def create_dog(data: DogCreate, user=Depends(get_current_user)):
with db() as conn:
+ existing = conn.execute(
+ "SELECT COUNT(*) FROM dogs WHERE user_id=?", (user["id"],)
+ ).fetchone()[0]
+ if existing >= 1 and not has_pro_access(user):
+ raise HTTPException(
+ status_code=403,
+ detail="Mehrere Hunde sind ein Pro-Feature. Upgrade auf Ban Yaro Pro, um weitere Hunde anzulegen."
+ )
conn.execute(
"""INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht,
gewicht_kg, chip_nr, bio, is_public)
@@ -180,8 +188,7 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
- # Zufälliges Foto aus den letzten 100 Tagebuchbildern
- # Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge
+ # Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend
photos = conn.execute(
"""SELECT dm.url FROM diary_media dm
JOIN diary d ON d.id = dm.diary_id
@@ -190,12 +197,13 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
(dog_id,)
).fetchall()
- # Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill)
+ # Fallback: Bilder ohne Dimensionsdaten (vor dem Backfill hochgeladen)
if not photos:
photos = conn.execute(
"""SELECT dm.url FROM diary_media dm
JOIN diary d ON d.id = dm.diary_id
WHERE d.dog_id=? AND dm.media_type='image'
+ AND dm.img_width IS NULL
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
(dog_id,)
).fetchall()
@@ -247,15 +255,16 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
day_num = (_dt.date.today() - _dt.date(2024, 1, 1)).days
# Versuche JOIN (funktioniert wenn js_exercise_id-Spalte vorhanden)
+ # Nur Übungen des aktiven Hundes, 'sitzt' ausschließen
try:
joined = conn.execute(
"""SELECT ep.exercise_id, te.name, te.kategorie AS kategorie_raw,
te.schwierigkeit, te.js_exercise_id
FROM exercise_progress ep
JOIN training_exercises te ON te.js_exercise_id = ep.exercise_id
- WHERE ep.user_id = ? AND ep.status IN ('noch-nicht', 'manchmal', 'meistens')
+ WHERE ep.dog_id = ? AND ep.status IN ('noch-nicht', 'manchmal', 'meistens')
ORDER BY ep.updated_at ASC LIMIT 50""",
- (user["id"],)
+ (dog_id,)
).fetchall()
except Exception:
joined = []
@@ -280,9 +289,9 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
)
raw = conn.execute(
"""SELECT exercise_id FROM exercise_progress
- WHERE user_id = ? AND status IN ('noch-nicht', 'manchmal', 'meistens')
+ WHERE dog_id = ? AND status IN ('noch-nicht', 'manchmal', 'meistens')
ORDER BY updated_at ASC LIMIT 50""",
- (user["id"],)
+ (dog_id,)
).fetchall()
valid = [r["exercise_id"] for r in raw
if any(r["exercise_id"].startswith(p) for p in _KNOWN_PREFIXES)]
@@ -771,6 +780,21 @@ async def get_hunde_buch(
return HTMLResponse(content=html_page)
+# ------------------------------------------------------------------
+# GET /api/dogs/verstorben — Alle verstorbenen Hunde des Users
+# ------------------------------------------------------------------
+@router.get("/verstorben")
+async def get_verstorbene_hunde(user=Depends(get_current_user)):
+ with db() as conn:
+ rows = conn.execute(
+ """SELECT id, name, rasse, foto_url, verstorben_am, geburtstag
+ FROM dogs WHERE user_id=? AND verstorben_am IS NOT NULL
+ ORDER BY verstorben_am DESC""",
+ (user["id"],)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
@router.get("/{dog_id}")
async def get_dog(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
@@ -1200,14 +1224,15 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
"ref_id": r["id"],
})
- # --- Routen ---
+ # --- Routen (nur Routen wo dieser Hund mitgegangen ist) ---
route_rows = conn.execute(
- """SELECT id, name, distanz_km,
- date(created_at) AS datum
- FROM routes
- WHERE user_id=?
- ORDER BY created_at ASC""",
- (user["id"],)
+ """SELECT r.id, r.name, r.distanz_km,
+ date(r.created_at) AS datum
+ FROM routes r
+ JOIN route_dogs rd ON rd.route_id = r.id AND rd.dog_id = ?
+ WHERE r.user_id = ?
+ ORDER BY r.created_at ASC""",
+ (dog_id, user["id"])
).fetchall()
route_first = True
@@ -1255,3 +1280,108 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
"geburtstag": dog["geburtstag"],
"events": events,
}
+
+
+# ------------------------------------------------------------------
+# POST /api/dogs/{id}/gedenken — Hund als verstorben markieren
+# ------------------------------------------------------------------
+class GedenkenData(BaseModel):
+ verstorben_am: str # YYYY-MM-DD
+
+@router.post("/{dog_id}/gedenken")
+async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)):
+ with db() as conn:
+ updated = conn.execute(
+ "UPDATE dogs SET verstorben_am=? WHERE id=? AND user_id=?",
+ (data.verstorben_am, dog_id, user["id"])
+ ).rowcount
+ if not updated:
+ raise HTTPException(404, "Hund nicht gefunden.")
+ return {"ok": True}
+
+
+# ------------------------------------------------------------------
+# GET /api/dogs/{id}/gedenkseite — Memorial-Daten
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/gedenkseite")
+async def get_gedenkseite(dog_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ dog = conn.execute(
+ "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404)
+ dog = dict(dog)
+
+ # Statistiken
+ km_total = conn.execute(
+ "SELECT COALESCE(ROUND(SUM(distanz_km),1),0) AS km FROM routes r "
+ "JOIN route_dogs rd ON rd.route_id=r.id WHERE rd.dog_id=?", (dog_id,)
+ ).fetchone()["km"]
+
+ diary_count = conn.execute(
+ "SELECT COUNT(*) FROM diary WHERE dog_id=?", (dog_id,)
+ ).fetchone()[0]
+
+ media_count = conn.execute(
+ "SELECT COUNT(*) FROM diary_media dm JOIN diary d ON d.id=dm.diary_id "
+ "WHERE d.dog_id=? AND dm.media_type='image'", (dog_id,)
+ ).fetchone()[0]
+
+ training_count = conn.execute(
+ "SELECT COUNT(*) FROM training_sessions WHERE dog_id=?", (dog_id,)
+ ).fetchone()[0]
+
+ # Letzter Tagebucheintrag
+ last_entry = conn.execute(
+ "SELECT titel, datum FROM diary WHERE dog_id=? ORDER BY datum DESC LIMIT 1",
+ (dog_id,)
+ ).fetchone()
+
+ # Erste und letzte Aufnahme
+ first_entry = conn.execute(
+ "SELECT datum FROM diary WHERE dog_id=? ORDER BY datum ASC LIMIT 1",
+ (dog_id,)
+ ).fetchone()
+
+ # Letzte 6 Fotos für Galerie
+ photos = conn.execute(
+ """SELECT dm.url FROM diary_media dm
+ JOIN diary d ON d.id=dm.diary_id
+ WHERE d.dog_id=? AND dm.media_type='image'
+ AND dm.img_width IS NOT NULL AND dm.img_width > dm.img_height
+ ORDER BY d.datum DESC, dm.id DESC LIMIT 6""",
+ (dog_id,)
+ ).fetchall()
+ if not photos:
+ photos = conn.execute(
+ """SELECT dm.url FROM diary_media dm
+ JOIN diary d ON d.id=dm.diary_id
+ WHERE d.dog_id=? AND dm.media_type='image'
+ ORDER BY d.datum DESC, dm.id DESC LIMIT 6""",
+ (dog_id,)
+ ).fetchall()
+
+ # Gemeinsame Zeit berechnen
+ joined = dog.get("geburtstag") or (first_entry["datum"] if first_entry else None)
+ passed = dog.get("verstorben_am")
+ gemeinsam_tage = None
+ if joined and passed:
+ try:
+ from datetime import date as _date
+ d1 = _date.fromisoformat(joined)
+ d2 = _date.fromisoformat(passed)
+ gemeinsam_tage = (d2 - d1).days
+ except Exception:
+ pass
+
+ return {
+ "dog": dog,
+ "km_total": km_total,
+ "diary_count": diary_count,
+ "media_count": media_count,
+ "training_count": training_count,
+ "last_entry": dict(last_entry) if last_entry else None,
+ "gemeinsam_tage": gemeinsam_tage,
+ "photos": [r["url"] for r in photos],
+ }
diff --git a/backend/routes/ernaehrung.py b/backend/routes/ernaehrung.py
index c1f850e..2aa4760 100644
--- a/backend/routes/ernaehrung.py
+++ b/backend/routes/ernaehrung.py
@@ -143,3 +143,305 @@ async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest,
raise HTTPException(503, str(e))
except Exception:
raise HTTPException(500, "KI momentan nicht verfügbar.")
+
+
+# ==================================================================
+# FUTTER-VERTRÄGLICHKEIT
+# ==================================================================
+
+REAKTION_TYPEN = {
+ # Positiv
+ "verdauung_gut": {"label": "Gute Verdauung", "kategorie": "positiv", "fenster_h": 8},
+ "energie_hoch": {"label": "Viel Energie", "kategorie": "positiv", "fenster_h": 12},
+ "fell_glaenzend": {"label": "Glänzendes Fell", "kategorie": "positiv", "fenster_h": 336}, # 2 Wochen
+ # Gastrointestinal
+ "erbrechen": {"label": "Erbrechen", "kategorie": "gastro_negativ", "fenster_h": 6},
+ "durchfall": {"label": "Durchfall", "kategorie": "gastro_negativ", "fenster_h": 8},
+ "blaehungen": {"label": "Blähungen", "kategorie": "gastro_negativ", "fenster_h": 6},
+ "weicher_stuhl": {"label": "Weicher Stuhl", "kategorie": "gastro_negativ", "fenster_h": 8},
+ "appetitlosigkeit":{"label": "Appetitlosigkeit", "kategorie": "gastro_negativ", "fenster_h": 12},
+ # Haut & Fell
+ "juckreiz": {"label": "Juckreiz / Kratzen", "kategorie": "haut_negativ", "fenster_h": 72},
+ "haarausfall": {"label": "Haarausfall", "kategorie": "haut_negativ", "fenster_h": 336},
+ "stumpfes_fell": {"label": "Stumpfes Fell", "kategorie": "haut_negativ", "fenster_h": 336},
+ "schuppenbildung":{"label": "Schuppenbildung", "kategorie": "haut_negativ", "fenster_h": 168},
+ "roetungen": {"label": "Hautrötungen / Entzündung", "kategorie": "haut_negativ", "fenster_h": 72},
+ "pfotenlecken": {"label": "Pfoten lecken (chronisch)", "kategorie": "haut_negativ", "fenster_h": 168},
+ "ohrentzuendung": {"label": "Ohrentzündung", "kategorie": "haut_negativ", "fenster_h": 168},
+ "fettiges_fell": {"label": "Fettiges Fell / Seborrhö", "kategorie": "haut_negativ", "fenster_h": 336},
+ # Allgemeinbefinden
+ "schlappheit": {"label": "Schlappheit / Apathie", "kategorie": "allgemein_negativ", "fenster_h": 12},
+ "nervositaet": {"label": "Nervosität / Unruhe", "kategorie": "allgemein_negativ", "fenster_h": 12},
+ "viel_trinken": {"label": "Ungewöhnlich viel trinken", "kategorie": "allgemein_negativ", "fenster_h": 24},
+ "sonstiges": {"label": "Sonstiges", "kategorie": "sonstiges", "fenster_h": 24},
+}
+
+_POSITIV_KAT = {"positiv"}
+_NEGATIV_KAT = {"gastro_negativ", "haut_negativ", "allgemein_negativ"}
+_HAUT_HINWEIS = "Haut- & Fell-Symptome wie {label} entwickeln sich typischerweise über Wochen. Mindestens 4–6 Wochen Beobachtung empfohlen — auch nach einem Futterwechsel dauert eine Besserung 2–6 Wochen."
+_GASTRO_HINWEIS = "Magen-Darm-Symptome wie {label} treten meist innerhalb weniger Stunden auf. Wenn sie häufig wiederkehren, ist ein Tierarztbesuch empfohlen."
+
+
+class FutterEintragCreate(BaseModel):
+ datum: str
+ uhrzeit: str
+ futter_name: str
+ futter_typ: Optional[str] = "trockenfutter"
+ menge_g: Optional[int] = None
+ notiz: Optional[str] = None
+
+
+class ReaktionCreate(BaseModel):
+ datum: str
+ uhrzeit: str
+ reaktion_typ: str
+ intensitaet: Optional[int] = 3
+ notiz: Optional[str] = None
+
+
+# ------------------------------------------------------------------
+# POST /dogs/{dog_id}/futter
+# ------------------------------------------------------------------
+@router.post("/{dog_id}/futter")
+async def create_futter_eintrag(dog_id: int, body: FutterEintragCreate,
+ user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ cur = conn.execute("""
+ INSERT INTO futter_eintraege
+ (dog_id, datum, uhrzeit, futter_name, futter_typ, menge_g, notiz)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """, (dog_id, body.datum, body.uhrzeit, body.futter_name,
+ body.futter_typ or "trockenfutter", body.menge_g, body.notiz))
+ row = conn.execute(
+ "SELECT * FROM futter_eintraege WHERE id=?", (cur.lastrowid,)
+ ).fetchone()
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# GET /dogs/{dog_id}/futter
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/futter")
+async def list_futter_eintraege(dog_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ rows = conn.execute("""
+ SELECT * FROM futter_eintraege
+ WHERE dog_id=?
+ ORDER BY datum DESC, uhrzeit DESC
+ LIMIT 50
+ """, (dog_id,)).fetchall()
+ return [dict(r) for r in rows]
+
+
+# ------------------------------------------------------------------
+# DELETE /dogs/{dog_id}/futter/{entry_id}
+# ------------------------------------------------------------------
+@router.delete("/{dog_id}/futter/{entry_id}")
+async def delete_futter_eintrag(dog_id: int, entry_id: int,
+ user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ result = conn.execute(
+ "DELETE FROM futter_eintraege WHERE id=? AND dog_id=?",
+ (entry_id, dog_id)
+ )
+ if result.rowcount == 0:
+ raise HTTPException(404, "Eintrag nicht gefunden.")
+ return {"ok": True}
+
+
+# ------------------------------------------------------------------
+# POST /dogs/{dog_id}/futter/reaktion
+# ------------------------------------------------------------------
+@router.post("/{dog_id}/futter/reaktion")
+async def create_reaktion(dog_id: int, body: ReaktionCreate,
+ user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ cur = conn.execute("""
+ INSERT INTO futter_reaktionen
+ (dog_id, datum, uhrzeit, reaktion_typ, intensitaet, notiz)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """, (dog_id, body.datum, body.uhrzeit, body.reaktion_typ,
+ body.intensitaet or 3, body.notiz))
+ row = conn.execute(
+ "SELECT * FROM futter_reaktionen WHERE id=?", (cur.lastrowid,)
+ ).fetchone()
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# GET /dogs/{dog_id}/futter/reaktionen
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/futter/reaktionen")
+async def list_reaktionen(dog_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ rows = conn.execute("""
+ SELECT * FROM futter_reaktionen
+ WHERE dog_id=?
+ ORDER BY datum DESC, uhrzeit DESC
+ LIMIT 50
+ """, (dog_id,)).fetchall()
+ return [dict(r) for r in rows]
+
+
+# ------------------------------------------------------------------
+# DELETE /dogs/{dog_id}/futter/reaktion/{react_id}
+# ------------------------------------------------------------------
+@router.delete("/{dog_id}/futter/reaktion/{react_id}")
+async def delete_reaktion(dog_id: int, react_id: int,
+ user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+ result = conn.execute(
+ "DELETE FROM futter_reaktionen WHERE id=? AND dog_id=?",
+ (react_id, dog_id)
+ )
+ if result.rowcount == 0:
+ raise HTTPException(404, "Reaktion nicht gefunden.")
+ return {"ok": True}
+
+
+# ------------------------------------------------------------------
+# GET /dogs/{dog_id}/futter/analyse
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/futter/analyse")
+async def futter_analyse(dog_id: int, user=Depends(get_current_user)):
+ from datetime import datetime
+
+ with db() as conn:
+ _check_dog_access(conn, dog_id, user["id"])
+
+ eintraege = conn.execute(
+ "SELECT * FROM futter_eintraege WHERE dog_id=? ORDER BY datum, uhrzeit",
+ (dog_id,)
+ ).fetchall()
+ reaktionen = conn.execute(
+ "SELECT * FROM futter_reaktionen WHERE dog_id=? ORDER BY datum, uhrzeit",
+ (dog_id,)
+ ).fetchall()
+
+ def parse_ts(datum, uhrzeit):
+ try:
+ return datetime.fromisoformat(f"{datum}T{uhrzeit}")
+ except Exception:
+ return None
+
+ # futter_name → {typ, mahlzeiten, positiv, negativ, kategorien: {kat: count}}
+ futter_stats: dict = {}
+
+ for e in eintraege:
+ name = e["futter_name"]
+ if name not in futter_stats:
+ futter_stats[name] = {
+ "name": name,
+ "typ": e["futter_typ"],
+ "mahlzeiten": 0,
+ "positiv": 0,
+ "negativ": 0,
+ "kategorien": {},
+ }
+ futter_stats[name]["mahlzeiten"] += 1
+
+ for r in reaktionen:
+ r_ts = parse_ts(r["datum"], r["uhrzeit"])
+ if not r_ts:
+ continue
+ r_typ = r["reaktion_typ"]
+ meta = REAKTION_TYPEN.get(r_typ, {"kategorie": "sonstiges", "fenster_h": 24})
+ kat = meta["kategorie"]
+ fenster = meta["fenster_h"]
+ # Mindestfenster 1h, maximales Fenster wie angegeben
+ min_h = 1
+
+ for e in eintraege:
+ e_ts = parse_ts(e["datum"], e["uhrzeit"])
+ if not e_ts:
+ continue
+ diff = (r_ts - e_ts).total_seconds() / 3600
+ if min_h <= diff <= fenster:
+ name = e["futter_name"]
+ if name not in futter_stats:
+ continue
+ if kat in _POSITIV_KAT:
+ futter_stats[name]["positiv"] += 1
+ elif kat in _NEGATIV_KAT:
+ futter_stats[name]["negativ"] += 1
+ # Kategorie-Zähler
+ futter_stats[name]["kategorien"][kat] = \
+ futter_stats[name]["kategorien"].get(kat, 0) + 1
+
+ result_futter = []
+ for stats in futter_stats.values():
+ positiv = stats["positiv"]
+ negativ = stats["negativ"]
+ total = positiv + negativ
+ if total == 0:
+ score = 50
+ status = "neu"
+ else:
+ raw = (positiv - negativ * 2) / max(1, total)
+ # raw liegt zwischen -2 und 1 → normieren auf 0-100
+ score = int(max(0, min(100, (raw + 2) / 3 * 100)))
+ if score >= 60:
+ status = "gut"
+ elif score >= 30:
+ status = "neutral"
+ else:
+ status = "problematisch"
+
+ result_futter.append({
+ "name": stats["name"],
+ "typ": stats["typ"],
+ "mahlzeiten": stats["mahlzeiten"],
+ "positiv": positiv,
+ "negativ": negativ,
+ "score": score,
+ "status": status,
+ "kategorien": stats["kategorien"],
+ })
+
+ # Sortierung: problematisch → neutral → gut → neu, dann nach Score
+ ORDER = {"problematisch": 0, "neutral": 1, "gut": 2, "neu": 3}
+ result_futter.sort(key=lambda x: (ORDER.get(x["status"], 9), -x["score"]))
+
+ # Hinweis ableiten: erstes problematisches Futter mit Haut/Gastro-Symptomen
+ hinweis = None
+ for f in result_futter:
+ if f["status"] != "problematisch":
+ continue
+ kats = f["kategorien"]
+ if kats.get("haut_negativ", 0) > 0:
+ # Häufigstes Haut-Symptom finden
+ haut_rxn = [
+ r["reaktion_typ"] for r in reaktionen
+ if REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie") == "haut_negativ"
+ ]
+ label = REAKTION_TYPEN.get(haut_rxn[0], {}).get("label", "Haut-Symptome") if haut_rxn else "Haut-Symptome"
+ hinweis = _HAUT_HINWEIS.format(label=label)
+ break
+ if kats.get("gastro_negativ", 0) > 0:
+ gastro_rxn = [
+ r["reaktion_typ"] for r in reaktionen
+ if REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie") == "gastro_negativ"
+ ]
+ label = REAKTION_TYPEN.get(gastro_rxn[0], {}).get("label", "Magen-Darm-Symptome") if gastro_rxn else "Magen-Darm-Symptome"
+ hinweis = _GASTRO_HINWEIS.format(label=label)
+ break
+
+ # Kategorien-Übersicht über alle Reaktionen
+ kategorien_gesamt: dict = {}
+ for r in reaktionen:
+ kat = REAKTION_TYPEN.get(r["reaktion_typ"], {}).get("kategorie", "sonstiges")
+ kategorien_gesamt[kat] = kategorien_gesamt.get(kat, 0) + 1
+
+ return {
+ "eintraege_count": len(eintraege),
+ "reaktionen_count": len(reaktionen),
+ "futter": result_futter,
+ "kategorien": kategorien_gesamt,
+ "hinweis": hinweis,
+ }
diff --git a/backend/routes/feedback.py b/backend/routes/feedback.py
new file mode 100644
index 0000000..a5c4792
--- /dev/null
+++ b/backend/routes/feedback.py
@@ -0,0 +1,56 @@
+"""
+BAN YARO — User-Feedback per E-Mail an support@banyaro.app
+"""
+
+from typing import Annotated, Literal
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, Field
+
+from auth import get_current_user
+from mailer import send_email, email_html
+
+router = APIRouter()
+
+SUPPORT_MAIL = "support@banyaro.app"
+
+KATEGORIEN = {"bug": "🐛 Bug / Fehler", "idee": "💡 Idee / Wunsch", "lob": "🎉 Lob", "sonstiges": "💬 Sonstiges"}
+
+
+class FeedbackIn(BaseModel):
+ kategorie: Literal["bug", "idee", "lob", "sonstiges"]
+ text: Annotated[str, Field(min_length=5, max_length=2000)]
+
+
+@router.post("")
+async def submit_feedback(
+ payload: FeedbackIn,
+ user=Depends(get_current_user),
+):
+ kat_label = KATEGORIEN.get(payload.kategorie, payload.kategorie)
+ username = user.get("name", "?")
+ email = user.get("email", "")
+ tier = user.get("subscription_tier", "standard")
+
+ subject = f"[Feedback] {kat_label} von @{username}"
+
+ body = f"""
+
+ Neues Feedback aus der App:
+
+
+ | Kategorie |
+ {kat_label} |
+ | User |
+ @{username} ({email}) |
+ | Tier |
+ {tier} |
+
+
+{payload.text}
+
"""
+
+ plain = f"Feedback [{kat_label}] von @{username} ({email})\n\n{payload.text}"
+
+ await send_email(SUPPORT_MAIL, subject, email_html(body), plain)
+ return {"ok": True}
diff --git a/backend/routes/friends.py b/backend/routes/friends.py
index 7df14e4..a813532 100644
--- a/backend/routes/friends.py
+++ b/backend/routes/friends.py
@@ -172,6 +172,20 @@ async def send_request(target_id: int, user=Depends(get_current_user)):
return {"ok": True}
+@router.get("/pending")
+async def pending_requests(user=Depends(get_current_user)):
+ """Eingehende Freundschaftsanfragen — kein Pro nötig, für Notification-Accept."""
+ with db() as conn:
+ rows = conn.execute("""
+ SELECT f.id, u.name AS requester_name, u.avatar_url
+ FROM friendships f
+ JOIN users u ON u.id = f.requester_id
+ WHERE f.addressee_id=? AND f.status='pending'
+ ORDER BY f.created_at DESC
+ """, (user["id"],)).fetchall()
+ return [dict(r) for r in rows]
+
+
@router.post("/{friendship_id}/accept")
async def accept_request(friendship_id: int, user=Depends(get_current_user)):
uid = user["id"]
diff --git a/backend/routes/ki.py b/backend/routes/ki.py
index 6521b90..2b16cbe 100644
--- a/backend/routes/ki.py
+++ b/backend/routes/ki.py
@@ -55,7 +55,7 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
prompt=prompt,
system=system,
max_tokens=600,
- requires_premium=False,
+ requires_premium=True,
user_id=user["id"],
)
return {"antwort": result}
@@ -361,3 +361,65 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
"hinweis": parsed.get("hinweis") or None,
"verbleibende_anfragen": remaining_after,
}
+
+
+# ------------------------------------------------------------------
+# POST /ki/abschied — Persönlicher Abschiedstext für verstorbenen Hund
+# ------------------------------------------------------------------
+class AbschiedRequest(BaseModel):
+ dog_id: int
+ name: str
+ rasse: Optional[str] = None
+ km_total: Optional[float] = None
+ diary_count: Optional[int] = None
+ gemeinsam_tage: Optional[int] = None
+ last_entry_titel: Optional[str] = None
+
+@router.post("/abschied")
+async def ki_abschied(req: AbschiedRequest, request: Request,
+ user=Depends(get_current_user)):
+ """Persönlicher Abschiedstext — einmalig generiert, DB-gecacht."""
+ with db() as conn:
+ cached = conn.execute(
+ "SELECT content FROM bday_ki_cache WHERE dog_id=? AND year=9999 AND mode='abschied'",
+ (req.dog_id,)
+ ).fetchone()
+ if cached:
+ return {"text": cached["content"], "cached": True}
+
+ name = req.name.strip()[:40]
+ rasse = req.rasse or ""
+ km = f"{req.km_total:.0f} km" if req.km_total else None
+ tage = f"{req.gemeinsam_tage} gemeinsame Tage" if req.gemeinsam_tage else None
+ eintr = f"{req.diary_count} Tagebucheinträge" if req.diary_count else None
+
+ stats_str = ", ".join(filter(None, [km, tage, eintr]))
+ rasse_str = f" ({rasse})" if rasse else ""
+
+ system = (
+ "Du bist ein einfühlsamer Begleiter für Menschen in Trauer um ihren Hund. "
+ "Schreibe warmherzig, persönlich und respektvoll auf Deutsch. "
+ "Keine Floskeln, kein Kitsch — echte Wärme. "
+ "Erwähne die Statistiken natürlich eingebunden."
+ )
+ prompt = (
+ f"{name}{rasse_str} ist über die Regenbogenbrücke gegangen. "
+ f"Schreibe einen kurzen, persönlichen Abschiedstext (ca. 80–100 Wörter) "
+ f"der die Verbundenheit würdigt. "
+ f"Statistiken: {stats_str or 'nicht bekannt'}. "
+ f"Sei warm, nicht sentimental überladen. Schließe mit einem hoffnungsvollen Gedanken."
+ )
+
+ try:
+ text = await ki_module.complete(
+ system=system, prompt=prompt, max_tokens=300,
+ requires_premium=False, user_id=user["id"],
+ )
+ with db() as conn:
+ conn.execute(
+ "INSERT OR REPLACE INTO bday_ki_cache (dog_id, year, mode, content) VALUES (?,9999,'abschied',?)",
+ (req.dog_id, text)
+ )
+ return {"text": text, "cached": False}
+ except Exception as e:
+ raise HTTPException(503, str(e))
diff --git a/backend/routes/routen.py b/backend/routes/routen.py
index 6755ccc..e1060ef 100644
--- a/backend/routes/routen.py
+++ b/backend/routes/routen.py
@@ -58,6 +58,7 @@ class RouteCreate(BaseModel):
is_public: Optional[bool] = False
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
client_time: Optional[str] = None
+ dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind
class RouteUpdate(BaseModel):
name: Optional[str] = None
@@ -69,6 +70,9 @@ class RouteUpdate(BaseModel):
is_public: Optional[bool] = None
hunde_tauglichkeit: Optional[str] = None
+class RouteDogs(BaseModel):
+ dog_ids: List[int]
+
def _simplify_track(track: list, max_pts: int = 40) -> list:
"""Reduziert GPS-Track auf max_pts Punkte für Vorschau."""
@@ -168,7 +172,26 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)):
int(data.is_public) if data.is_public is not None else 1,
data.hunde_tauglichkeit, is_valid, ct,
))
- row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone()
+ route_id = cur.lastrowid
+ row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone()
+
+ # Hunde zuordnen — entweder explizit oder alle Hunde des Users
+ dog_ids = data.dog_ids or []
+ if not dog_ids:
+ # Fallback: alle Hunde des Users
+ all_dogs = conn.execute(
+ "SELECT id FROM dogs WHERE user_id=?", (user['id'],)
+ ).fetchall()
+ dog_ids = [d['id'] for d in all_dogs]
+ for did in dog_ids:
+ try:
+ conn.execute(
+ "INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?,?)",
+ (route_id, did)
+ )
+ except Exception:
+ pass
+
update_streak(user['id'], conn)
check_and_award(user['id'], conn)
result = _parse(row)
@@ -195,6 +218,10 @@ async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)):
if not ORS_API_KEY:
raise HTTPException(503, "ORS nicht konfiguriert")
+ from auth import has_pro_access
+ if not has_pro_access(user):
+ raise HTTPException(403, "Routenvorschläge sind ein Pro-Feature.")
+
is_privileged = (
user.get("rolle") in ("admin", "moderator") or
user.get("is_moderator") or
@@ -313,9 +340,14 @@ async def get_route(route_id: int):
"SELECT r.*, u.name AS user_name FROM routes r LEFT JOIN users u ON u.id = r.user_id WHERE r.id = ?",
(route_id,)
).fetchone()
- if not row:
- raise HTTPException(404, "Route nicht gefunden.")
- return _parse(row)
+ if not row:
+ raise HTTPException(404, "Route nicht gefunden.")
+ dog_rows = conn.execute(
+ "SELECT dog_id FROM route_dogs WHERE route_id = ?", (route_id,)
+ ).fetchall()
+ result = _parse(row)
+ result['dog_ids'] = [r['dog_id'] for r in dog_rows]
+ return result
# ------------------------------------------------------------------
@@ -342,6 +374,26 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren
return _parse(row)
+# ------------------------------------------------------------------
+# PATCH /api/routes/{id}/dogs — Hunde der Route aktualisieren
+# ------------------------------------------------------------------
+@router.patch("/{route_id}/dogs")
+async def update_route_dogs(route_id: int, data: RouteDogs, user=Depends(get_current_user)):
+ with db() as conn:
+ row = conn.execute("SELECT user_id FROM routes WHERE id = ?", (route_id,)).fetchone()
+ if not row:
+ raise HTTPException(404, "Route nicht gefunden.")
+ if row['user_id'] != user['id']:
+ raise HTTPException(403, "Nicht berechtigt.")
+ conn.execute("DELETE FROM route_dogs WHERE route_id = ?", (route_id,))
+ for did in data.dog_ids:
+ conn.execute(
+ "INSERT OR IGNORE INTO route_dogs (route_id, dog_id) VALUES (?, ?)",
+ (route_id, did)
+ )
+ return {"ok": True}
+
+
# ------------------------------------------------------------------
# PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz)
# ------------------------------------------------------------------
diff --git a/backend/routes/training.py b/backend/routes/training.py
index 05e8e94..263d90d 100644
--- a/backend/routes/training.py
+++ b/backend/routes/training.py
@@ -85,28 +85,43 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ
# ------------------------------------------------------------------
class ProgressUpdate(BaseModel):
exercise_id: str
- status: Optional[str] = None # null/noch-nicht/manchmal/meistens/sitzt
+ status: Optional[str] = None
+ dog_id: Optional[int] = None
@router.get("/progress")
-async def get_progress(user=Depends(get_current_user)):
+async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
- rows = conn.execute(
- "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?",
- (uid,)
- ).fetchall()
+ if dog_id:
+ rows = conn.execute(
+ "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE dog_id=?",
+ (dog_id,)
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?",
+ (uid,)
+ ).fetchall()
return [dict(r) for r in rows]
@router.post("/progress")
async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
- conn.execute("""
- INSERT INTO exercise_progress (user_id, exercise_id, status)
- VALUES (?,?,?)
- ON CONFLICT(user_id, exercise_id) DO UPDATE
- SET status=excluded.status, updated_at=datetime('now')
- """, (uid, body.exercise_id, body.status))
+ if body.dog_id:
+ conn.execute("""
+ INSERT INTO exercise_progress (user_id, dog_id, exercise_id, status)
+ VALUES (?,?,?,?)
+ ON CONFLICT(dog_id, exercise_id) DO UPDATE
+ SET status=excluded.status, updated_at=datetime('now')
+ """, (uid, body.dog_id, body.exercise_id, body.status))
+ else:
+ conn.execute("""
+ INSERT INTO exercise_progress (user_id, exercise_id, status)
+ VALUES (?,?,?)
+ ON CONFLICT(dog_id, exercise_id) DO UPDATE
+ SET status=excluded.status, updated_at=datetime('now')
+ """, (uid, body.exercise_id, body.status))
return {"ok": True}
# ------------------------------------------------------------------
@@ -115,15 +130,22 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
class PlanProgress(BaseModel):
item_key: str
checked: bool
+ dog_id: Optional[int] = None
@router.get("/plan-progress")
-async def get_plan_progress(user=Depends(get_current_user)):
+async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
- rows = conn.execute(
- "SELECT item_key, checked FROM training_plan_progress WHERE user_id=?",
- (uid,)
- ).fetchall()
+ if dog_id:
+ rows = conn.execute(
+ "SELECT item_key, checked FROM training_plan_progress WHERE dog_id=?",
+ (dog_id,)
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT item_key, checked FROM training_plan_progress WHERE user_id=?",
+ (uid,)
+ ).fetchall()
return [dict(r) for r in rows]
@router.post("/plan-progress")
@@ -132,13 +154,13 @@ async def upsert_plan_progress(body: PlanProgress, user=Depends(get_current_user
with db() as conn:
if body.checked:
conn.execute("""
- INSERT OR REPLACE INTO training_plan_progress (user_id, item_key, checked)
- VALUES (?,?,1)
- """, (uid, body.item_key))
+ INSERT OR REPLACE INTO training_plan_progress (user_id, dog_id, item_key, checked)
+ VALUES (?,?,?,1)
+ """, (uid, body.dog_id, body.item_key))
else:
conn.execute(
- "DELETE FROM training_plan_progress WHERE user_id=? AND item_key=?",
- (uid, body.item_key)
+ "DELETE FROM training_plan_progress WHERE dog_id=? AND item_key=?",
+ (body.dog_id, body.item_key)
)
return {"ok": True}
@@ -149,13 +171,19 @@ GRUNDKOMMANDOS_ORDER = ['Sitz', 'Platz', 'Bleib', 'Hier / Komm', 'Fuß', 'Aus /
TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen']
@router.get("/suggestions")
-async def get_suggestions(user=Depends(get_current_user)):
+async def get_suggestions(dog_id: Optional[int] = None, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
- rows = conn.execute(
- "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?",
- (uid,)
- ).fetchall()
+ if dog_id:
+ rows = conn.execute(
+ "SELECT exercise_id, status FROM exercise_progress WHERE dog_id=?",
+ (dog_id,)
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?",
+ (uid,)
+ ).fetchall()
progress = {r["exercise_id"]: r["status"] for r in rows}
diff --git a/backend/routes/weather.py b/backend/routes/weather.py
index 2167b19..0bd757a 100644
--- a/backend/routes/weather.py
+++ b/backend/routes/weather.py
@@ -3,6 +3,7 @@ BAN YARO — Wetter-API
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
"""
+import os
import json
from fastapi import APIRouter, Query, HTTPException, Depends
import weather as weather_module
@@ -11,6 +12,34 @@ from database import db
router = APIRouter()
+OWM_API_KEY = os.getenv("OPENWEATHERMAP_KEY", "")
+
+
+ALLOWED_OWM_LAYERS = {"temp_new", "clouds_new", "wind_new", "pressure_new", "precipitation_new"}
+
+
+@router.get('/radar-tiles')
+async def radar_tile_config(user=Depends(get_current_user)):
+ """Regenradar-Tile-Config (RainViewer)."""
+ return {"provider": "rainviewer"}
+
+
+@router.get('/layer-tiles')
+async def layer_tile_config(
+ layer: str = "temp_new",
+ user=Depends(get_current_user),
+):
+ """OWM-Tile-URL für Wetter-Layer (Key bleibt server-seitig)."""
+ if layer not in ALLOWED_OWM_LAYERS:
+ raise HTTPException(400, f"Unbekannter Layer. Erlaubt: {', '.join(ALLOWED_OWM_LAYERS)}")
+ if not OWM_API_KEY:
+ raise HTTPException(503, "OWM nicht konfiguriert.")
+ return {
+ "url": f"https://tile.openweathermap.org/map/{layer}/{{z}}/{{x}}/{{y}}.png?appid={OWM_API_KEY}",
+ "maxNativeZoom": 18,
+ "opacity": 0.6,
+ }
+
@router.get('')
async def get_weather(
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index 49d89ab..cb54b88 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -3184,6 +3184,16 @@ html.modal-open {
color: #fff;
font-size: 1rem;
}
+#map-radar-btn.active {
+ background: #1d4ed8;
+ border-color: #1d4ed8;
+ color: #fff;
+}
+#map-temp-btn.active {
+ background: #dc2626;
+ border-color: #dc2626;
+ color: #fff;
+}
.map-fab:disabled { opacity: 0.5; cursor: default; }
.map-fab--offline.loading { animation: fab-spin 1.2s linear infinite; pointer-events: none; }
@keyframes fab-spin { to { transform: rotate(360deg); } }
diff --git a/backend/static/index.html b/backend/static/index.html
index cf2117f..f616f14 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -101,9 +101,9 @@
-
-
-
+
+
+
@@ -583,10 +583,10 @@
-
-
-
-
+
+
+
+
diff --git a/backend/static/js/api.js b/backend/static/js/api.js
index 893f1b4..5781a62 100644
--- a/backend/static/js/api.js
+++ b/backend/static/js/api.js
@@ -58,7 +58,10 @@ const API = (() => {
try { data = await response.json(); } catch { data = null; }
if (!response.ok) {
- const message = data?.detail || data?.message || `Fehler ${response.status}`;
+ const _d = data?.detail;
+ const message = (typeof _d === 'string' ? _d
+ : Array.isArray(_d) ? (_d[0]?.msg || 'Ungültige Eingabe')
+ : null) || data?.message || `Fehler ${response.status}`;
const isSwOffline = response.status === 503 && message.startsWith('Offline');
// Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)
@@ -139,6 +142,15 @@ const API = (() => {
deletePhoto(id) { return del(`/dogs/${id}/photo`); },
getSkills(id) { return get(`/dogs/${id}/skills`); },
welcomeDashboard(dogId) { return get(`/dogs/${dogId}/welcome-dashboard`); },
+ gedenken(id, datum) { return post(`/dogs/${id}/gedenken`, { verstorben_am: datum }); },
+ gedenkseite(id) { return get(`/dogs/${id}/gedenkseite`); },
+ futterList(id) { return get(`/dogs/${id}/futter`); },
+ futterCreate(id, data) { return post(`/dogs/${id}/futter`, data); },
+ futterDelete(id, eid) { return del(`/dogs/${id}/futter/${eid}`); },
+ reaktionList(id) { return get(`/dogs/${id}/futter/reaktionen`); },
+ reaktionCreate(id, d) { return post(`/dogs/${id}/futter/reaktion`, d); },
+ reaktionDelete(id, rid) { return del(`/dogs/${id}/futter/reaktion/${rid}`); },
+ futterAnalyse(id) { return get(`/dogs/${id}/futter/analyse`); },
};
// ----------------------------------------------------------
@@ -284,6 +296,7 @@ const API = (() => {
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); },
reverse(id) { return post(`/routes/${id}/reverse`, {}); },
+ updateDogs(id, dog_ids) { return patch(`/routes/${id}/dogs`, { dog_ids }); },
addPhoto(id, file) {
const fd = new FormData();
fd.append('file', file);
@@ -297,11 +310,11 @@ const API = (() => {
// TRAINING & ÜBUNGSFORTSCHRITT
// ----------------------------------------------------------
const training = {
- getProgress() { return get('/training/progress'); },
- setProgress(id, status) { return post('/training/progress', { exercise_id: id, status }); },
- getSuggestions() { return get('/training/suggestions'); },
- getPlanProgress() { return get('/training/plan-progress'); },
- setPlanProgress(key, checked) { return post('/training/plan-progress', { item_key: key, checked }); },
+ getProgress(dogId) { return get(`/training/progress${dogId ? `?dog_id=${dogId}` : ''}`); },
+ setProgress(id, status, dogId){ return post('/training/progress', { exercise_id: id, status, dog_id: dogId || null }); },
+ getSuggestions(dogId) { return get(`/training/suggestions${dogId ? `?dog_id=${dogId}` : ''}`); },
+ getPlanProgress(dogId) { return get(`/training/plan-progress${dogId ? `?dog_id=${dogId}` : ''}`); },
+ setPlanProgress(key, checked, dogId) { return post('/training/plan-progress', { item_key: key, checked, dog_id: dogId || null }); },
getRecommendations(dogId) { return get(`/training/recommendations?dog_id=${dogId}`); },
};
@@ -461,6 +474,7 @@ const API = (() => {
// ----------------------------------------------------------
const friends = {
list() { return get('/friends/'); },
+ pending() { return get('/friends/pending'); },
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
activity() { return get('/friends/activity'); },
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index db6b183..e732f44 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,8 +3,8 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '826'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
-const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt
+const APP_VER = '872'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
if (location.search.includes('_t=')) history.replaceState(null, '', '/');
@@ -57,7 +57,7 @@ const App = (() => {
'erste-hilfe': { title: 'Erste Hilfe', module: null },
settings: { title: 'Einstellungen', module: null },
lost: { title: 'Verlorener Hund', module: null },
- friends: { title: 'Freunde', module: null, requiresAuth: true, requiresPro: true },
+ friends: { title: 'Freunde', module: null, requiresAuth: true },
chat: { title: 'Nachrichten', module: null, requiresAuth: true, requiresPro: true },
social: { title: 'Social Media', module: null, requiresAuth: true },
admin: { title: 'Admin', module: null, requiresAuth: true },
@@ -198,6 +198,26 @@ const App = (() => {
return;
}
+ // Pro-Feature-Hinweis für Admins/Mods/Manager — Banner VOR .page-body, überlebt page.module.init()
+ const pageEl = document.getElementById(`page-${pageId}`);
+ if (pageEl) {
+ pageEl.querySelector('#pro-role-banner')?.remove();
+ if (page.requiresPro && state.user) {
+ const t = state.user.subscription_tier || 'standard';
+ const isRoleBased = !t.endsWith('_test') && !['pro','breeder'].includes(t) &&
+ (state.user.rolle === 'admin' || state.user.rolle === 'moderator' ||
+ state.user.is_moderator || state.user.is_social_media);
+ if (isRoleBased) {
+ const banner = document.createElement('div');
+ banner.id = 'pro-role-banner';
+ banner.style.cssText = 'background:#92400e;color:#fef3c7;padding:8px 16px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:8px;';
+ banner.innerHTML = `
+ ⭐ Pro-Feature — Standard-User sehen diese Seite nicht`;
+ pageEl.insertBefore(banner, pageEl.firstChild);
+ }
+ }
+ }
+
if (page.module) {
const hasParams = params && Object.keys(params).length > 0;
if (hasParams) {
@@ -885,6 +905,12 @@ const App = (() => {
if (!dog || dog.id === state.activeDog?.id) return;
state.activeDog = dog;
localStorage.setItem('by_active_dog', String(dogId));
+ // SW-Cache für hund-spezifische Daten invalidieren
+ navigator.serviceWorker?.controller?.postMessage({
+ type: 'INVALIDATE_CACHE',
+ paths: ['/api/training/progress', '/api/training/plan-progress',
+ '/api/training/suggestions', `/api/dogs/${dogId}/welcome-dashboard`],
+ });
_renderDogSwitcher();
_notifyDogChange();
}
diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js
index 0dfa8c7..2747256 100644
--- a/backend/static/js/pages/admin.js
+++ b/backend/static/js/pages/admin.js
@@ -25,6 +25,7 @@ window.Page_admin = (() => {
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
+ { id: 'referrals', label: 'Referrals', icon: 'share-network' },
];
// ------------------------------------------------------------------
@@ -161,6 +162,7 @@ window.Page_admin = (() => {
case 'bewerbungen': await _renderBewerbungen(el); break;
case 'hilfe': await _renderHilfe(el); break;
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
+ case 'referrals': await _renderReferrals(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@@ -534,22 +536,22 @@ window.Page_admin = (() => {
};
el.innerHTML = `
-
- ${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
- ${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')}
- ${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)')}
- ${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')}
- ${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')}
- ${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')}
- ${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
- ${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)')}
- ${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)')}
- ${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)')}
+
+ ${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)', 'nutzer')}
+ ${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)', 'nutzer')}
+ ${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)', 'nutzer')}
+ ${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)', 'nutzer')}
+ ${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)','forum')}
+ ${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'moderation')}
+ ${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'moderation')}
+ ${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'nutzer')}
+ ${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)', 'system')}
+ ${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)','system')}
${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')}
${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')}
${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')}
- ${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)')}
- ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)')}
+ ${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)', 'system')}
+ ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)', 'system')}
@@ -705,11 +707,19 @@ window.Page_admin = (() => {
`;
+
+ el.querySelector('#adm-overview-grid')?.addEventListener('click', e => {
+ const card = e.target.closest('[data-adm-tab]');
+ if (!card) return;
+ const tab = card.dataset.admTab;
+ _container.querySelector(`#adm-tabs .by-tab[data-tab="${tab}"]`)?.click();
+ });
}
- function _statCard(icon, label, value, color) {
+ function _statCard(icon, label, value, color, tab = null) {
+ const clickable = tab ? `data-adm-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer"` : `style="padding:var(--space-4);text-align:center"`;
return `
-
+