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:
parent
20a4936397
commit
c5030024b0
4 changed files with 761 additions and 2 deletions
|
|
@ -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">🐾</div>'
|
||||
)
|
||||
|
||||
subtitle_parts = [p for p in [rasse_str, alter_str] if p]
|
||||
subtitle = " · ".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 = "☀️" if temp_i > 20 else ("🌧️" if temp_i < 10 else "⛅")
|
||||
return f'<span class="chip">{emoji} {temp_i}°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">📍 {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 — {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()">
|
||||
🖨 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ä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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue