"""BAN YARO — Hunde-Profil Routes"""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from typing import Optional
from database import db
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
from cache import ttl_cache
# ------------------------------------------------------------------
# Pflege-Tipps sind statische Stamm-Daten → 1h TTL-Cache
# (Filterung pro Hund passiert weiter unten in-memory, NICHT gecached)
# ------------------------------------------------------------------
@ttl_cache(ttl=3600)
def _load_all_pflege_tipps() -> list[dict]:
with db() as conn:
rows = conn.execute(
"SELECT * FROM pflege_tipps ORDER BY kategorie, titel"
).fetchall()
return [dict(r) for r in rows]
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DogCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
geburtstag: Optional[str] = Field(None, max_length=32)
geschlecht: Optional[str] = Field(None, max_length=20)
gewicht_kg: Optional[float] = None
widerrist_cm: Optional[float] = None
chip_nr: Optional[str] = Field(None, max_length=50)
bio: Optional[str] = Field(None, max_length=2000)
is_public: bool = False
class DogUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
rasse_id: Optional[int] = None
geburtstag: Optional[str] = Field(None, max_length=32)
geschlecht: Optional[str] = Field(None, max_length=20)
gewicht_kg: Optional[float] = None
widerrist_cm: Optional[float] = None
chip_nr: Optional[str] = Field(None, max_length=50)
bio: Optional[str] = Field(None, max_length=2000)
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=? AND (verstorben_am IS NULL) AND (is_active IS NULL OR is_active=1) 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)
# HdM-Siege pro Hund anhängen
if result:
dog_ids = [d["id"] for d in result]
with db() as conn:
wins_rows = conn.execute(
f"SELECT dog_id, monat FROM hund_des_monats_wins WHERE dog_id IN ({','.join('?'*len(dog_ids))}) ORDER BY monat DESC",
dog_ids,
).fetchall()
wins_map: dict[int, list[str]] = {}
for w in wins_rows:
wins_map.setdefault(w["dog_id"], []).append(w["monat"])
for d in result:
d["hdm_wins"] = wins_map.get(d["id"], [])
return result
def _is_plausible_dog(name: str, rasse: str, geburtstag) -> tuple[bool, str]:
"""Einfache Plausibilitätsprüfung für Hunde-Profile."""
import re, datetime
name = (name or "").strip()
rasse = (rasse or "").strip()
if len(name) < 2:
return False, "Der Name muss mindestens 2 Zeichen haben."
if not re.search(r'[a-zA-ZäöüÄÖÜß]', name):
return False, "Der Name muss mindestens einen Buchstaben enthalten."
if len(set(name.lower())) < 2:
return False, "Bitte einen echten Namen eingeben."
if rasse and len(rasse) < 2:
return False, "Bitte eine gültige Rasse eingeben."
if rasse and not re.search(r'[a-zA-ZäöüÄÖÜß]', rasse):
return False, "Die Rasse muss Buchstaben enthalten."
if geburtstag:
try:
if isinstance(geburtstag, str):
year = int(geburtstag[:4])
else:
year = geburtstag.year
now = datetime.date.today().year
if year > now:
return False, "Das Geburtsdatum liegt in der Zukunft."
if year < now - 30:
return False, "Das Geburtsdatum ist unrealistisch."
except Exception:
pass
return True, ""
@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, widerrist_cm, chip_nr, bio, is_public)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(user["id"], data.name, data.rasse, data.geburtstag,
data.geschlecht, data.gewicht_kg, data.widerrist_cm,
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()
# Gründer-Aktivierung: erstes Hunde-Profil + is_founder_pending
user_row = conn.execute(
"SELECT is_founder_pending, is_founder FROM users WHERE id=?",
(user["id"],)
).fetchone()
if user_row and user_row["is_founder_pending"] and not user_row["is_founder"]:
dog_count = conn.execute(
"SELECT COUNT(*) FROM dogs WHERE user_id=?", (user["id"],)
).fetchone()[0]
if dog_count == 1: # genau dieser erste Hund
plausible, reason = _is_plausible_dog(data.name, data.rasse, data.geburtstag)
if plausible:
# Atomare Gründer-Vergabe — Race-frei via Sub-Query im UPDATE.
# Wenn schon 100 Founder oder User schon is_founder=1 → kein Update (rowcount=0)
conn.execute(
"""UPDATE users
SET is_founder = 1,
founder_number = (
SELECT IFNULL(MAX(founder_number), 0) + 1
FROM users WHERE is_founder = 1
),
is_founder_pending = 0
WHERE id = ?
AND is_founder_pending = 1
AND (is_founder IS NULL OR is_founder = 0)
AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < 100""",
(user["id"],)
)
return dict(dog)
@router.get("/{dog_id}/welcome-dashboard")
async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
"""Liefert kompakte Dashboard-Daten für die Welcome-Ansicht eines Hundes."""
import random as _random
with db() as conn:
# Besitz prüfen
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.")
# Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend
# Ownership bereits durch Dog-Check oben gesichert (dog gehört user)
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, d.id DESC, dm.id ASC""",
(dog_id,)
).fetchall()
# 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()
random_photo = None
if photos:
import datetime as _dt2
tick = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
chosen_url = photos[tick % len(photos)]["url"]
random_photo = {
"url": chosen_url,
"preview_url": preview_url_from(chosen_url),
}
# Neuester Tagebucheintrag
last_diary_row = conn.execute(
"SELECT titel, datum FROM diary WHERE dog_id=? ORDER BY datum DESC LIMIT 1",
(dog_id,)
).fetchone()
last_diary = dict(last_diary_row) if last_diary_row else None
# Nächster Termin (kein Gewicht, nur innerhalb 60 Tage)
next_appt_row = conn.execute(
"""SELECT bezeichnung, naechstes, typ FROM health
WHERE dog_id=? AND naechstes IS NOT NULL
AND naechstes >= date('now')
AND naechstes <= date('now', '+60 days')
AND typ != 'gewicht'
ORDER BY naechstes ASC LIMIT 1""",
(dog_id,)
).fetchone()
next_appointment = dict(next_appt_row) if next_appt_row else None
# Letztes Gewicht
last_weight_row = conn.execute(
"""SELECT wert, einheit, datum FROM health
WHERE dog_id=? AND typ='gewicht'
ORDER BY datum DESC LIMIT 1""",
(dog_id,)
).fetchone()
last_weight = dict(last_weight_row) if last_weight_row else None
# Anzahl Tagebucheinträge
diary_count = conn.execute(
"SELECT COUNT(*) AS n FROM diary WHERE dog_id=?", (dog_id,)
).fetchone()["n"]
# Tagesübung — JOIN mit training_exercises via js_exercise_id, tagesstabil
import datetime as _dt
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.dog_id = ? AND ep.status IN ('noch-nicht', 'manchmal', 'meistens')
ORDER BY ep.updated_at ASC LIMIT 50""",
(dog_id,)
).fetchall()
except Exception:
joined = []
daily_exercise = None
if joined:
row = joined[day_num % len(joined)]
# Tab-ID aus exercise_id ableiten (alles vor erstem '_' + '_')
ex_id = row["exercise_id"]
tab = ex_id.split('_')[0] if '_' in ex_id else ex_id
daily_exercise = {
"exercise_id": ex_id,
"name": row["name"],
"kategorie": tab,
"schwierigkeit": row["schwierigkeit"],
}
else:
# Fallback: exercise_progress ohne JOIN (Legacy-IDs ohne Matching in DB)
_KNOWN_PREFIXES = (
'grundkommandos_', 'tricks_', 'problemverhalten_',
'mentale-auslastung_', 'hundesport_', 'koerperpflege_', 'welpe-basics_',
)
raw = conn.execute(
"""SELECT exercise_id FROM exercise_progress
WHERE dog_id = ? AND status IN ('noch-nicht', 'manchmal', 'meistens')
ORDER BY updated_at ASC LIMIT 50""",
(dog_id,)
).fetchall()
valid = [r["exercise_id"] for r in raw
if any(r["exercise_id"].startswith(p) for p in _KNOWN_PREFIXES)]
if valid:
ex_id = valid[day_num % len(valid)]
for prefix in _KNOWN_PREFIXES:
if ex_id.startswith(prefix):
tab = prefix.rstrip('_')
name = ex_id[len(prefix):].replace('_', ' ')
daily_exercise = {"exercise_id": ex_id, "name": name, "kategorie": tab}
break
return {
"random_photo": random_photo,
"last_diary": last_diary,
"next_appointment": next_appointment,
"last_weight": last_weight,
"diary_count": diary_count,
"daily_exercise": daily_exercise,
}
@router.get("/{dog_id}/wrapped")
async def get_dog_wrapped(dog_id: int, year: int = None, user=Depends(get_current_user)):
"""Jahresrückblick ('Wrapped') für einen Hund."""
import json as _json
from datetime import date as _date
if year is None:
year = _date.today().year
with db() as conn:
dog = conn.execute(
"SELECT id, name, user_id FROM dogs WHERE id=? AND user_id=?",
(dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
# km gelaufen (eigene Routen des Users)
gesamt_km_row = conn.execute(
"SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes "
"WHERE user_id=? AND strftime('%Y', created_at)=?",
(user["id"], str(year))
).fetchone()
gesamt_km = gesamt_km_row["km"] or 0.0
# Gassi-Tage (Distinct Datum in Diary)
gassi_tage = conn.execute(
"SELECT COUNT(DISTINCT datum) AS n FROM diary "
"WHERE dog_id=? AND strftime('%Y', datum)=?",
(dog_id, str(year))
).fetchone()["n"]
# Gesamte Einträge
eintraege_gesamt = conn.execute(
"SELECT COUNT(*) AS n FROM diary "
"WHERE dog_id=? AND strftime('%Y', datum)=?",
(dog_id, str(year))
).fetchone()["n"]
# Fotos gesamt
fotos_gesamt = conn.execute(
"SELECT COUNT(*) AS n FROM diary_media dm "
"JOIN diary d ON d.id=dm.diary_id "
"WHERE d.dog_id=? AND strftime('%Y', d.datum)=? AND dm.media_type='image'",
(dog_id, str(year))
).fetchone()["n"]
# Beste Route (längste distanz)
beste_route_row = conn.execute(
"SELECT MAX(distanz_km) AS km FROM routes "
"WHERE user_id=? AND strftime('%Y', created_at)=?",
(user["id"], str(year))
).fetchone()
beste_route = beste_route_row["km"] or 0.0
# Lieblingsmonat (meiste diary-Einträge)
monat_rows = conn.execute(
"SELECT strftime('%m', datum) AS monat, COUNT(*) AS n FROM diary "
"WHERE dog_id=? AND strftime('%Y', datum)=? "
"GROUP BY monat ORDER BY n DESC LIMIT 1",
(dog_id, str(year))
).fetchone()
lieblings_monat = None
if monat_rows:
_MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']
try:
lieblings_monat = _MONATE[int(monat_rows["monat"]) - 1]
except Exception:
pass
# Lieblingsaktivität (häufigster typ)
typ_row = conn.execute(
"SELECT typ, COUNT(*) AS n FROM diary "
"WHERE dog_id=? AND strftime('%Y', datum)=? "
"GROUP BY typ ORDER BY n DESC LIMIT 1",
(dog_id, str(year))
).fetchone()
lieblings_aktivitaet = typ_row["typ"] if typ_row else None
# Training-Sessions
training_sessions = conn.execute(
"SELECT COUNT(*) AS n FROM training_sessions "
"WHERE dog_id=? AND strftime('%Y', created_at)=?",
(dog_id, str(year))
).fetchone()["n"]
# Gesundheits-Einträge
gesundheit_eintraege = conn.execute(
"SELECT COUNT(*) AS n FROM health "
"WHERE dog_id=? AND strftime('%Y', datum)=?",
(dog_id, str(year))
).fetchone()["n"]
# Wetter-Tapferkeit: Tagebuch-Einträge mit weather_json
wetter_kalt = 0
wetter_warm = 0
wetter_rows = conn.execute(
"SELECT weather_json FROM diary "
"WHERE dog_id=? AND strftime('%Y', datum)=? AND weather_json IS NOT NULL",
(dog_id, str(year))
).fetchall()
for wr in wetter_rows:
try:
wj = _json.loads(wr["weather_json"])
temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp")
if temp is not None:
if float(temp) < 5:
wetter_kalt += 1
elif float(temp) > 25:
wetter_warm += 1
except Exception:
pass
return {
"dog_id": dog_id,
"dog_name": dog["name"],
"year": year,
"gesamt_km": gesamt_km,
"gassi_tage": gassi_tage,
"eintraege_gesamt": eintraege_gesamt,
"fotos_gesamt": fotos_gesamt,
"beste_route": beste_route,
"lieblings_monat": lieblings_monat,
"lieblings_aktivitaet": lieblings_aktivitaet,
"training_sessions": training_sessions,
"gesundheit_eintraege": gesundheit_eintraege,
"wetter_kalt": wetter_kalt,
"wetter_warm": wetter_warm,
}
@router.get("/{dog_id}/buch")
async def get_hunde_buch(
dog_id: int,
jahr: int = None,
limit: int = 50,
nur_fotos: bool = False,
nur_meilensteine: bool = False,
user=Depends(get_current_user),
):
"""Hunde-Buch: druckbare HTML-Ansicht der schoensten Tagebucheintraege."""
import json as _json
from datetime import date as _date
from fastapi.responses import HTMLResponse
from html import escape as _esc
with db() as conn:
dog = conn.execute(
"SELECT id, name, rasse, geburtstag, foto_url FROM dogs WHERE id=? AND user_id=?",
(dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
dog = dict(dog)
# --- Eintraege laden ---
conditions = ["(d.dog_id=? OR dd.dog_id=?)"]
params: list = [dog_id, dog_id]
if jahr:
conditions.append("strftime('%Y', d.datum) = ?")
params.append(str(jahr))
if nur_meilensteine:
conditions.append("d.is_milestone = 1")
where = " AND ".join(conditions)
rows = conn.execute(
f"""SELECT DISTINCT d.id, d.datum, d.titel, d.text, d.tags,
d.gps_lat, d.gps_lon, d.location_name, d.weather_json,
d.is_milestone,
(SELECT dm.url FROM diary_media dm
WHERE dm.diary_id=d.id AND dm.media_type='image'
ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url
FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE {where}
AND d.datum IS NOT NULL
ORDER BY d.datum ASC""",
params
).fetchall()
rows = [dict(r) for r in rows]
# Filtern: Eintraege mit Foto bevorzugen / nur Fotos-Modus
if nur_fotos:
rows = [r for r in rows if r.get("cover_url")]
else:
# Prioritaet: Meilensteine + Foto-Eintraege; Rest auffuellen bis limit
with_photo = [r for r in rows if r.get("cover_url")]
milestones = [r for r in rows if r.get("is_milestone") and not r.get("cover_url")]
rest = [r for r in rows if not r.get("cover_url") and not r.get("is_milestone")]
rows = with_photo + milestones + rest
rows.sort(key=lambda r: r["datum"] or "")
rows = rows[:limit]
# --- Hund-Alter berechnen ---
alter_str = ""
if dog.get("geburtstag"):
try:
geb = _date.fromisoformat(dog["geburtstag"])
heute = _date.today()
jahre = (heute - geb).days // 365
alter_str = f"{jahre} Jahre"
except Exception:
pass
# --- HTML bauen ---
dog_name = _esc(dog["name"] or "Mein Hund")
rasse_str = _esc(dog.get("rasse") or "")
jahr_str = str(jahr) if jahr else "Alle Jahre"
foto_url = dog.get("foto_url") or ""
cover_img = (
f''
if foto_url else
f'