"""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, has_pro_access
from routes.push import send_push_to_user
from media_utils import safe_media_path, preview_url_from
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
widerrist_cm: 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
rasse_id: Optional[int] = None
geburtstag: Optional[str] = None
geschlecht: Optional[str] = None
gewicht_kg: Optional[float] = None
widerrist_cm: 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=? AND (verstorben_am IS NULL) 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:
total = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if total < 100:
conn.execute(
"UPDATE users SET is_founder=1, founder_number=?, is_founder_pending=0 WHERE id=?",
(total + 1, 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'