banyaro/backend/routes/dogs.py
rene 44081a6b9d Session 2026-04-22: Training, Fixes, KI-Cloud, Dark-Mode
Training-System:
- Einheit-Dialog Bugs behoben (UI.toast callable, _dogId via _appState, activeDog.id)
- Virtueller Trainer (rein statistisch): üben/festigen/entdecken/levelup
  Empfehlungen auf Basis exercise_progress + sessions, Prognose bis 80%
- Stand erfassen Modal: alle Übungen auf einmal setzen (onboarding)
- Erfolgsindikatoren auf Karten: Ø-Quote + Trend-Pfeil + Anzahl Sessions
- exercise_progress → synthetische Stats im Trainer (ohne Sessions nutzbar)
- Levelup: Tricks empfehlen wenn ≥4 Grundkommandos sitzen
- Kommandos & Fähigkeiten im Hundeprofil + öffentlichem Profil
- 2 neue Problemverhalten-Übungen: Bellen/Kläffen, Enttriggern

Mobile/UI-Fixes:
- Übungskarten: Name + Difficulty oben, Buttons eigene Zeile (kein Umbruch)
- Trainingsgrundlagen: Padding in allen Karten, Hinweis-Boxen Dark-Mode-sicher
- Tab-Sichtbarkeit: Trainer/Suggestions nur auf Übungs-Tabs
- Tagebuch FAB (Neu-Eintrag Button) + Quick-Add Eintrag
- FAB Abstand fix (nav-bottom-height + safe-bottom)
- Suggestion-Karten rgba (Dark-Mode)
- routes.js + uebungen.js: alle Hellfarben → rgba (Dark-Mode-sicher)
- ui.js: UI.toast als callable Function-Object (war nur plain Object)

KI & Backend:
- KI_MODE=cloud + ANTHROPIC_API_KEY gesetzt
- ki.py: Cloud-Fallback wenn local nicht erreichbar + KI_MODE=cloud
- KI-Trainer Tageslimit 10 Anfragen/User + ki_daily_calls Tabelle
- Admin-Panel: KI-Nutzung (heute/Monat/User)
- Status-Report Fix (lost-Tabelle) → 06:00 + 18:00 täglich
- Wiki-Anreicherung läuft jetzt (50 Rassen Startup, 20/Nacht)
- landing.html: Trainings-Features in JSON-LD + Feature-Karten
2026-04-22 19:41:22 +02:00

331 lines
11 KiB
Python

"""BAN YARO — Hunde-Profil Routes"""
import os
import uuid
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 routes.push import send_push_to_user
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DogCreate(BaseModel):
name: str
rasse: Optional[str] = None
geburtstag: Optional[str] = None
geschlecht: Optional[str] = None
gewicht_kg: Optional[float] = None
chip_nr: Optional[str] = None
bio: Optional[str] = None
is_public: bool = False
class DogUpdate(BaseModel):
name: Optional[str] = None
rasse: Optional[str] = None
geburtstag: Optional[str] = None
geschlecht: Optional[str] = None
gewicht_kg: Optional[float] = None
chip_nr: Optional[str] = None
bio: Optional[str] = None
is_public: Optional[bool] = None
@router.get("")
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",
(user["id"],)
).fetchall()
shared = conn.execute(
"""SELECT d.*, u.name AS shared_by, ds.role AS share_role
FROM dog_shares ds
JOIN dogs d ON d.id = ds.dog_id
JOIN users u ON u.id = ds.owner_id
WHERE ds.shared_with_id = ? AND ds.accepted_at IS NOT NULL""",
(user["id"],)
).fetchall()
guest_rows = conn.execute("""
SELECT d.*, ss.id AS sub_id, ss.valid_until AS sitting_until,
u.name AS owner_name, NULL AS shared_by, NULL AS share_role
FROM sitting_subscriptions ss
JOIN dogs d ON d.id = ss.dog_id
JOIN users u ON u.id = ss.owner_id
WHERE ss.sitter_id = ?
AND ss.valid_until >= date('now')
""", (user["id"],)).fetchall()
result = []
for r in own:
d = dict(r)
d["is_guest"] = False
result.append(d)
for r in shared:
d = dict(r)
d["is_guest"] = False
result.append(d)
for r in guest_rows:
d = dict(r)
d["is_guest"] = True
result.append(d)
return result
@router.post("")
async def create_dog(data: DogCreate, user=Depends(get_current_user)):
with db() as conn:
conn.execute(
"""INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht,
gewicht_kg, chip_nr, bio, is_public)
VALUES (?,?,?,?,?,?,?,?,?)""",
(user["id"], data.name, data.rasse, data.geburtstag,
data.geschlecht, data.gewicht_kg, data.chip_nr,
data.bio, int(data.is_public))
)
dog = conn.execute(
"SELECT * FROM dogs WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],)
).fetchone()
return dict(dog)
@router.get("/{dog_id}")
async def get_dog(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, "Hund nicht gefunden.")
return dict(dog)
@router.patch("/{dog_id}")
async def update_dog(dog_id: int, data: DogUpdate, user=Depends(get_current_user)):
fields = {k: v for k, v in data.model_dump().items() if v is not None}
if not fields:
raise HTTPException(400, "Keine Änderungen angegeben.")
set_clause = ", ".join(f"{k}=?" for k in fields)
values = list(fields.values()) + [dog_id, user["id"]]
with db() as conn:
conn.execute(
f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values
)
dog = conn.execute(
"SELECT * FROM dogs WHERE id=?", (dog_id,)
).fetchone()
return dict(dog)
@router.delete("/{dog_id}", status_code=204)
async def delete_dog(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
conn.execute(
"DELETE FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
)
@router.post("/{dog_id}/photo")
async def upload_photo(
dog_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user)
):
# Hund gehört dem User?
with db() as conn:
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.")
# Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser)
import io
from PIL import Image
try:
import pillow_heif
pillow_heif.register_heif_opener()
except ImportError:
pass
content = await file.read()
try:
from PIL import ImageOps
img = Image.open(io.BytesIO(content))
img = ImageOps.exif_transpose(img) # EXIF-Orientierung anwenden
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
content = buf.getvalue()
except Exception:
pass # Fallback: Originaldaten speichern
filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}.jpg"
path = os.path.join(MEDIA_DIR, "dogs", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(content)
foto_url = f"/media/dogs/{filename}"
with db() as conn:
conn.execute("UPDATE dogs SET foto_url=? WHERE id=?", (foto_url, dog_id))
return {"foto_url": foto_url}
class PhotoPosition(BaseModel):
zoom: float = 1.0
offset_x: float = 0.0
offset_y: float = 0.0
@router.patch("/{dog_id}/photo-position")
async def update_photo_position(dog_id: int, pos: PhotoPosition, user=Depends(get_current_user)):
with db() as conn:
updated = conn.execute(
"UPDATE dogs SET foto_zoom=?, foto_offset_x=?, foto_offset_y=? WHERE id=? AND user_id=?",
(pos.zoom, pos.offset_x, pos.offset_y, dog_id, user["id"])
).rowcount
if not updated:
raise HTTPException(404, "Hund nicht gefunden.")
return {"ok": True}
@router.delete("/{dog_id}/photo", status_code=204)
async def delete_photo(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT foto_url FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
if not row:
raise HTTPException(404, "Hund nicht gefunden.")
if row["foto_url"]:
path = os.path.join(MEDIA_DIR, row["foto_url"].lstrip("/media/"))
if os.path.exists(path):
os.remove(path)
with db() as conn:
conn.execute(
"UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=?",
(dog_id,)
)
# ------------------------------------------------------------------
# Fähigkeiten / Kommandos (für Profil + öffentliche Seite)
# ------------------------------------------------------------------
def _parse_exercise_name(exercise_id: str) -> str:
"""grundkommandos_Hier__Komm → 'Hier / Komm'"""
parts = exercise_id.split("_", 1)
if len(parts) < 2:
return exercise_id
return parts[1].replace("__", " / ").replace("_", " ")
def _load_skills(conn, dog_id: int, user_id: int) -> list:
"""Gibt Übungen mit Status 'sitzt' oder 'meistens' zurück, die mit diesem Hund trainiert wurden."""
rows = conn.execute(
"""
SELECT ep.exercise_id, ep.status,
(SELECT ts.exercise_name FROM training_sessions ts
WHERE ts.user_id = ep.user_id AND ts.dog_id = ?
AND ts.exercise_id = ep.exercise_id
ORDER BY ts.datum DESC, ts.created_at DESC LIMIT 1) AS exercise_name
FROM exercise_progress ep
WHERE ep.user_id = ?
AND ep.status IN ('sitzt', 'meistens')
AND EXISTS (SELECT 1 FROM training_sessions ts2
WHERE ts2.user_id = ep.user_id AND ts2.dog_id = ?
AND ts2.exercise_id = ep.exercise_id)
ORDER BY ep.status DESC, ep.exercise_id
""",
(dog_id, user_id, dog_id)
).fetchall()
return [
{
"exercise_id": r["exercise_id"],
"exercise_name": r["exercise_name"] or _parse_exercise_name(r["exercise_id"]),
"status": r["status"],
"tab": r["exercise_id"].split("_")[0],
}
for r in rows
]
@router.get("/{dog_id}/skills")
async def get_dog_skills(dog_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
dog = conn.execute(
"SELECT id, user_id FROM dogs WHERE id=? AND (user_id=? OR id IN (SELECT dog_id FROM sitting_access WHERE friend_id=? AND expires_at > datetime('now')))",
(dog_id, uid, uid)
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
return _load_skills(conn, dog_id, dog["user_id"])
# Öffentliches Profil (für NFC-Tag, kein Login nötig)
@router.get("/public/{dog_id}")
async def public_dog_profile(dog_id: int):
with db() as conn:
dog = conn.execute(
"""SELECT d.id, d.name, d.rasse, d.geburtstag, d.foto_url, d.bio,
d.user_id, u.name as besitzer_name
FROM dogs d JOIN users u ON d.user_id=u.id
WHERE d.id=? AND d.is_public=1""",
(dog_id,)
).fetchone()
if not dog:
raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.")
skills = _load_skills(conn, dog_id, dog["user_id"])
result = dict(dog)
result.pop("user_id", None)
result["skills"] = skills
return result
class FoundReport(BaseModel):
message: Optional[str] = None
kontakt: Optional[str] = None
# Gefunden-Meldung (kein Login nötig)
@router.post("/public/{dog_id}/found")
async def report_found(dog_id: int, data: FoundReport = FoundReport()):
with db() as conn:
row = conn.execute(
"""SELECT d.id, d.name, d.user_id
FROM dogs d
WHERE d.id=? AND d.is_public=1""",
(dog_id,)
).fetchone()
if not row:
raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.")
dog_name = row["name"]
user_id = row["user_id"]
body = data.message.strip() if data.message and data.message.strip() \
else "Jemand hat deinen Hund gefunden. Öffne die App für Details."
if data.kontakt and data.kontakt.strip():
body += f" Kontakt: {data.kontakt.strip()}"
send_push_to_user(user_id, {
"title": f"🐾 {dog_name} wurde gefunden!",
"body": body,
"data": {"page": "diary", "found": True},
"tag": f"found-{dog_id}",
})
return {"ok": True}