Feature: Hunde-Buch — druckbare HTML-Tagebuchansicht als PDF (SW by-v700)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rene 2026-05-04 21:01:54 +02:00
parent 20a4936397
commit c5030024b0
4 changed files with 761 additions and 2 deletions

View file

@ -436,6 +436,332 @@ async def get_dog_wrapped(dog_id: int, year: int = None, user=Depends(get_curren
}
@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'<img src="{_esc(foto_url)}" alt="{dog_name}" '
f'style="border-radius:50%;width:200px;height:200px;object-fit:cover;'
f'border:4px solid #b97c2a;margin-bottom:24px;" onerror="this.style.display=\'none\'">'
if foto_url else
f'<div style="width:200px;height:200px;border-radius:50%;background:#f0e8d8;'
f'display:flex;align-items:center;justify-content:center;font-size:5rem;'
f'margin:0 auto 24px">&#x1F43E;</div>'
)
subtitle_parts = [p for p in [rasse_str, alter_str] if p]
subtitle = " &middot; ".join(subtitle_parts)
_MONATE = ["Januar","Februar","März","April","Mai","Juni",
"Juli","August","September","Oktober","November","Dezember"]
def _fmt_datum(iso: str) -> str:
try:
d = _date.fromisoformat(iso)
return f"{d.day}. {_MONATE[d.month - 1]} {d.year}"
except Exception:
return iso or ""
def _wetter_chip(wj_str: str) -> str:
if not wj_str:
return ""
try:
wj = _json.loads(wj_str)
temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp")
if temp is None:
return ""
temp_i = int(float(temp))
emoji = "&#x2600;&#xFE0F;" if temp_i > 20 else ("&#x1F327;&#xFE0F;" if temp_i < 10 else "&#x26C5;")
return f'<span class="chip">{emoji} {temp_i}&deg;C</span>'
except Exception:
return ""
entries_html = ""
for e in rows:
milestone_class = "milestone" if e.get("is_milestone") else ""
datum_fmt = _fmt_datum(e.get("datum") or "")
titel = _esc(e.get("titel") or "")
text_raw = e.get("text") or ""
text = _esc(text_raw).replace("\n", "<br>")
wetter = _wetter_chip(e.get("weather_json") or "")
loc = _esc(e.get("location_name") or "")
cover = e.get("cover_url") or ""
foto_html = ""
if cover:
foto_html = (
f'<div class="entry-photo">'
f'<img src="{_esc(cover)}" alt="" loading="lazy">'
f'</div>'
)
loc_html = f'<span class="chip">&#x1F4CD; {loc}</span>' if loc else ""
chips_html = f'<div class="chips">{wetter}{loc_html}</div>' if (wetter or loc_html) else ""
titel_html = f'<div class="entry-title">{titel}</div>' if titel else ""
text_html = f'<div class="entry-text">{text}</div>' if text_raw else ""
entries_html += f"""
<div class="entry {milestone_class}">
{foto_html}
<div class="entry-date">{datum_fmt}</div>
{titel_html}
{text_html}
{chips_html}
</div>
"""
anzahl = len(rows)
html_page = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hunde-Buch &mdash; {dog_name}</title>
<style>
*, *::before, *::after {{ box-sizing: border-box; }}
body {{
font-family: Georgia, 'Times New Roman', serif;
max-width: 800px;
margin: 0 auto;
padding: 0 20px 60px;
color: #2c2c2c;
background: #fff;
line-height: 1.6;
}}
/* Druck-Button */
.print-btn {{
position: fixed;
top: 20px;
right: 20px;
background: #b97c2a;
color: #fff;
border: none;
border-radius: 50px;
padding: 12px 24px;
font-size: 1rem;
font-family: system-ui, sans-serif;
cursor: pointer;
box-shadow: 0 4px 16px rgba(185,124,42,0.35);
z-index: 100;
display: flex;
align-items: center;
gap: 8px;
}}
.print-btn:hover {{ background: #a06820; }}
/* Titelseite */
.cover {{
text-align: center;
padding: 80px 40px 100px;
min-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}}
.cover h1 {{
font-size: 3em;
color: #b97c2a;
margin: 0 0 12px;
line-height: 1.2;
}}
.cover .subtitle {{
font-size: 1.15em;
color: #666;
margin-bottom: 8px;
}}
.cover .year-label {{
font-size: 1em;
color: #aaa;
margin-bottom: 32px;
}}
.cover .stat-line {{
font-size: 0.95em;
color: #888;
font-family: system-ui, sans-serif;
margin-top: 24px;
}}
/* Eintraege */
.entry {{
padding: 32px 0;
border-bottom: 1px solid #e8e0d0;
break-inside: avoid;
}}
.entry:last-child {{ border-bottom: none; }}
.entry-photo {{ margin-bottom: 20px; }}
.entry-photo img {{
width: 100%;
max-height: 420px;
object-fit: cover;
border-radius: 10px;
display: block;
}}
.entry-date {{
color: #999;
font-size: 0.85em;
font-family: system-ui, sans-serif;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}}
.entry-title {{
font-size: 1.45em;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 10px;
line-height: 1.3;
}}
.entry-text {{
font-size: 1em;
line-height: 1.75;
color: #333;
}}
/* Meilenstein */
.milestone {{
border-left: 4px solid #b97c2a;
padding-left: 24px;
}}
.milestone .entry-title::before {{
content: "\\2605 ";
color: #b97c2a;
}}
/* Chips */
.chips {{
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}}
.chip {{
background: #f5f0e8;
border-radius: 50px;
padding: 4px 12px;
font-size: 0.82em;
font-family: system-ui, sans-serif;
color: #666;
}}
/* Drucken */
@media print {{
.print-btn {{ display: none !important; }}
.cover {{ page-break-after: always; }}
.entry {{ break-inside: avoid; }}
body {{ padding: 0; }}
@page {{ margin: 2cm; }}
}}
</style>
</head>
<body>
<button class="print-btn" onclick="window.print()">
&#x1F5A8; Drucken / Als PDF speichern
</button>
<div class="cover">
{cover_img}
<h1>{dog_name}</h1>
{'<div class="subtitle">' + subtitle + '</div>' if subtitle else ''}
<div class="year-label">{jahr_str}</div>
<div class="stat-line">{anzahl} Eintr&auml;ge</div>
</div>
{entries_html}
</body>
</html>"""
return HTMLResponse(content=html_page)
@router.get("/{dog_id}")
async def get_dog(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
@ -764,3 +1090,159 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)):
"kategorien": list(dict.fromkeys(t["kategorie"] for t in result)),
"fell_pflege_art": fell_pflege_art_filter, # 'schneiden' | 'trimmen' | None
}
# ------------------------------------------------------------------
# LEBENS-TIMELINE
# ------------------------------------------------------------------
@router.get("/{dog_id}/timeline")
async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
"""Aggregierte Lebens-Timeline eines Hundes aus allen Datenquellen."""
import json as _json
with db() as conn:
dog = conn.execute(
"SELECT id, name, user_id, geburtstag FROM dogs WHERE id=? AND user_id=?",
(dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
events = []
with db() as conn:
# --- Tagebuch ---
diary_rows = conn.execute(
"""SELECT d.id, d.datum, d.titel, d.typ, d.is_milestone,
dm.url AS foto_url
FROM diary d
LEFT JOIN diary_media dm ON dm.diary_id = d.id AND dm.sort_order = 0
WHERE d.dog_id=?
ORDER BY d.datum ASC, d.id ASC""",
(dog_id,)
).fetchall()
for i, r in enumerate(diary_rows):
events.append({
"datum": r["datum"],
"kategorie": "tagebuch",
"titel": r["titel"] or ("Tagebucheintrag" if r["typ"] == "eintrag" else str(r["typ"]).capitalize()),
"typ": r["typ"],
"is_first": i == 0,
"is_milestone": bool(r["is_milestone"]),
"foto_url": r["foto_url"],
"ref_id": r["id"],
})
# --- Gesundheit ---
health_rows = conn.execute(
"""SELECT id, datum, bezeichnung, typ
FROM health
WHERE dog_id=?
ORDER BY datum ASC, id ASC""",
(dog_id,)
).fetchall()
typ_seen = {}
for r in health_rows:
t = r["typ"]
is_first = t not in typ_seen
if is_first:
typ_seen[t] = True
events.append({
"datum": r["datum"],
"kategorie": "gesundheit",
"titel": r["bezeichnung"],
"typ": t,
"is_first": is_first,
"is_milestone": False,
"foto_url": None,
"ref_id": r["id"],
})
# --- Training-Sessions ---
ts_rows = conn.execute(
"""SELECT id, datum, exercise_name, erfolgsquote, ist_top
FROM training_sessions
WHERE dog_id=? AND user_id=?
ORDER BY datum ASC, id ASC""",
(dog_id, user["id"])
).fetchall()
ts_first = True
ts_best = None
ts_best_score = -1
for r in ts_rows:
if r["erfolgsquote"] is not None and r["erfolgsquote"] > ts_best_score:
ts_best_score = r["erfolgsquote"]
ts_best = r
for i, r in enumerate(ts_rows):
is_first = (i == 0)
is_best = ts_best and r["id"] == ts_best["id"] and i > 0
events.append({
"datum": r["datum"],
"kategorie": "training",
"titel": r["exercise_name"],
"typ": "training",
"is_first": is_first,
"is_milestone": bool(r["ist_top"]) or is_best,
"foto_url": None,
"ref_id": r["id"],
})
# --- Routen ---
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"],)
).fetchall()
route_first = True
route_longest = None
route_max_km = -1
for r in route_rows:
km = r["distanz_km"] or 0
if km > route_max_km:
route_max_km = km
route_longest = r
for i, r in enumerate(route_rows):
is_first = (i == 0)
is_longest = route_longest and r["id"] == route_longest["id"] and i > 0
events.append({
"datum": r["datum"],
"kategorie": "route",
"titel": r["name"],
"typ": "route",
"is_first": is_first,
"is_milestone": is_longest,
"foto_url": None,
"ref_id": r["id"],
"distanz_km": r["distanz_km"],
})
# Geburtstag des Hundes als erster Eintrag
if dog["geburtstag"]:
events.append({
"datum": dog["geburtstag"],
"kategorie": "meilenstein",
"titel": f"{dog['name']} wird geboren",
"typ": "geburtstag",
"is_first": True,
"is_milestone": True,
"foto_url": None,
"ref_id": None,
})
# Chronologisch sortieren
events.sort(key=lambda e: (e["datum"] or "0000-00-00", e["kategorie"]))
return {
"dog_name": dog["name"],
"geburtstag": dog["geburtstag"],
"events": events,
}