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/routes/ki.py b/backend/routes/ki.py
index 3c3c3b8..2b16cbe 100644
--- a/backend/routes/ki.py
+++ b/backend/routes/ki.py
@@ -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 3abbec3..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)
@@ -317,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
# ------------------------------------------------------------------
@@ -346,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/static/js/api.js b/backend/static/js/api.js
index 0e14dc1..5781a62 100644
--- a/backend/static/js/api.js
+++ b/backend/static/js/api.js
@@ -142,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`); },
};
// ----------------------------------------------------------
@@ -287,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);
@@ -300,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}`); },
};
diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js
index 66d9150..42e0b5d 100644
--- a/backend/static/js/pages/diary.js
+++ b/backend/static/js/pages/diary.js
@@ -149,12 +149,6 @@ window.Page_diary = (() => {
// ----------------------------------------------------------
async function refresh() {
if (!_appState.activeDog) return;
- // Mehrere Hunde → Picker zeigen (User kann Hund wählen)
- if (_appState.dogs.length > 1) {
- _renderDogPicker();
- return;
- }
- // Einzelner Hund → Diary direkt neu laden
_offset = 0;
_entries = [];
_totalStats = null;
@@ -194,52 +188,7 @@ window.Page_diary = (() => {
return;
}
- if (_appState.dogs.length > 1) {
- _renderDogPicker();
- } else {
- await _renderDiary();
- }
- }
-
- // ----------------------------------------------------------
- // HUNDE-PICKER — Einstiegsseite bei mehreren Hunden
- // ----------------------------------------------------------
- function _renderDogPicker() {
- const activeDogId = _appState.activeDog?.id;
-
- const cards = _appState.dogs.map(dog => {
- const isActive = dog.id === activeDogId;
- const av = dog.foto_url
- ? ``
- : `${UI.icon('dog')}`;
- return `
-
Wessen Tagebuch?
-