diff --git a/backend/auth.py b/backend/auth.py
index a4d5af3..55c63fc 100644
--- a/backend/auth.py
+++ b/backend/auth.py
@@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"])
with db() as conn:
row = conn.execute(
- "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, gassi_stunde_push, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
+ "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
(user_id,)
).fetchone()
diff --git a/backend/database.py b/backend/database.py
index 5e38a96..eeb1add 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -540,9 +540,6 @@ def _migrate(conn_factory):
("pflege_tipps", "fell_pflege_art", "TEXT"),
# Wiki-Foto-Einreichungen: Bildrechte-Bestätigung
("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"),
- # Tagebuch-Medien: Bildmaße für Querformat-Filter
- ("diary_media", "img_width", "INTEGER"),
- ("diary_media", "img_height", "INTEGER"),
# Tagebuch: Wetter + POI-Metadaten beim Eintrag
("diary", "weather_json", "TEXT"),
("diary", "poi_json", "TEXT"),
@@ -571,11 +568,6 @@ def _migrate(conn_factory):
# Passwort-Zurücksetzen
("users", "password_reset_token", "TEXT"),
("users", "password_reset_expires", "TEXT"),
- # Fell-Typ für personalisierte Wetter-Hinweise
- ("dogs", "fell_typ", "TEXT"), # kurz|mittel|lang|drahtaar|doppel|nackt
- # Tierarzt-Bewertungen: Durchschnitt + Anzahl am Tierarzt-Datensatz
- ("tieraerzte", "avg_rating", "REAL DEFAULT 0"),
- ("tieraerzte", "anz_bewertungen", "INTEGER DEFAULT 0"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
@@ -1931,44 +1923,6 @@ def _migrate(conn_factory):
)
""")
- # Welten-Chip-Konfiguration pro User
- existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
- if 'world_config' not in existing_u:
- conn.execute("ALTER TABLE users ADD COLUMN world_config TEXT")
-
- # Tagessprüche-Pool
- conn.executescript("""
- CREATE TABLE IF NOT EXISTS daily_quotes (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- text TEXT NOT NULL,
- autor TEXT,
- kategorie TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
- CREATE INDEX IF NOT EXISTS idx_dq_kategorie ON daily_quotes(kategorie);
- """)
-
- # Goldene Gassi-Stunde: User-Einstellung
- existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
- if 'gassi_stunde_push' not in existing_u:
- conn.execute("ALTER TABLE users ADD COLUMN gassi_stunde_push INTEGER NOT NULL DEFAULT 0")
- logger.info("Migration: users.gassi_stunde_push bereit.")
-
- # Futter-Profil
- conn.executescript("""
- CREATE TABLE IF NOT EXISTS futter_profil (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE UNIQUE,
- futter_typ TEXT,
- marke TEXT,
- kcal_tag INTEGER,
- portionen INTEGER DEFAULT 2,
- notizen TEXT,
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
- """)
- logger.info("Migration: futter_profil bereit.")
-
# Wiederkehrende Ausgaben (Daueraufträge)
conn.executescript("""
CREATE TABLE IF NOT EXISTS recurring_expenses (
@@ -1986,75 +1940,3 @@ def _migrate(conn_factory):
);
CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv);
""")
-
- # ---- Tierarzt-Bewertungen ----
- conn.executescript("""
- CREATE TABLE IF NOT EXISTS tierarzt_bewertungen (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- tierarzt_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE,
- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- gesamt INTEGER NOT NULL CHECK(gesamt BETWEEN 1 AND 5),
- wartezeit INTEGER CHECK(wartezeit BETWEEN 1 AND 5),
- freundlichkeit INTEGER CHECK(freundlichkeit BETWEEN 1 AND 5),
- kompetenz INTEGER CHECK(kompetenz BETWEEN 1 AND 5),
- text TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
- UNIQUE(tierarzt_id, user_id)
- );
- CREATE INDEX IF NOT EXISTS idx_tierarzt_bew_arzt
- ON tierarzt_bewertungen(tierarzt_id);
- """)
-
- # ---- Feature: Foto-Challenge der Woche ----
- conn.executescript("""
- CREATE TABLE IF NOT EXISTS foto_challenge (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- thema TEXT NOT NULL,
- beschreibung TEXT,
- start_date TEXT NOT NULL,
- end_date TEXT NOT NULL,
- created_by INTEGER REFERENCES users(id),
- created_at TEXT DEFAULT (datetime('now'))
- );
- CREATE TABLE IF NOT EXISTS challenge_submissions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- challenge_id INTEGER REFERENCES foto_challenge(id) ON DELETE CASCADE,
- user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
- dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
- foto_url TEXT NOT NULL,
- caption TEXT,
- votes INTEGER DEFAULT 0,
- created_at TEXT DEFAULT (datetime('now')),
- UNIQUE(challenge_id, user_id)
- );
- CREATE TABLE IF NOT EXISTS challenge_votes (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- submission_id INTEGER REFERENCES challenge_submissions(id) ON DELETE CASCADE,
- user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
- UNIQUE(submission_id, user_id)
- );
- CREATE INDEX IF NOT EXISTS idx_challenge_sub_chal
- ON challenge_submissions(challenge_id, created_at DESC);
- """)
- logger.info("Migration: Foto-Challenge-Tabellen bereit.")
-
- # ---- Feature: Gassi-Zeiten-Pool ----
- conn.executescript("""
- CREATE TABLE IF NOT EXISTS gassi_zeiten (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
- dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
- wochentage TEXT NOT NULL,
- uhrzeit TEXT NOT NULL,
- ort_name TEXT,
- lat REAL,
- lon REAL,
- radius_m INTEGER DEFAULT 500,
- notiz TEXT,
- aktiv INTEGER DEFAULT 1,
- created_at TEXT DEFAULT (datetime('now'))
- );
- CREATE INDEX IF NOT EXISTS idx_gassi_zeiten_user
- ON gassi_zeiten(user_id, aktiv);
- """)
- logger.info("Migration: Gassi-Zeiten-Tabelle bereit.")
diff --git a/backend/main.py b/backend/main.py
index a883e9b..229a856 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -6,10 +6,9 @@ import os
import html
import logging
from collections import deque
-import httpx
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
-from fastapi.responses import FileResponse, JSONResponse, Response
+from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from brotli_asgi import BrotliMiddleware
@@ -44,43 +43,10 @@ logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Startup / Shutdown
# ------------------------------------------------------------------
-def _backfill_image_sizes():
- """Füllt img_width/img_height für alle diary_media-Bilder ohne Maße nach."""
- import io
- from database import db
- from media_utils import get_image_size
- MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
- with db() as conn:
- rows = conn.execute(
- "SELECT id, url FROM diary_media WHERE media_type='image' AND img_width IS NULL"
- ).fetchall()
- if not rows:
- return
- logger.info("Backfill Bildmaße: %d Einträge...", len(rows))
- updated = 0
- for row in rows:
- # url ist z.B. /media/diary/xxx.jpg → Pfad: MEDIA_DIR/diary/xxx.jpg
- rel = row["url"].removeprefix("/media/")
- path = os.path.join(MEDIA_DIR, rel)
- try:
- with open(path, "rb") as f:
- data = f.read()
- size = get_image_size(data)
- if size:
- with db() as conn:
- conn.execute(
- "UPDATE diary_media SET img_width=?, img_height=? WHERE id=?",
- (size[0], size[1], row["id"])
- )
- updated += 1
- except Exception:
- pass
- logger.info("Backfill Bildmaße abgeschlossen: %d/%d aktualisiert.", updated, len(rows))
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Ban Yaro startet...")
init_db()
- _backfill_image_sizes()
from routes.movies import seed_movies
seed_movies()
logger.info(f"KI-Modus: {ki.KI_MODE}")
@@ -110,7 +76,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
- "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
@@ -232,9 +198,6 @@ from routes.adoption import router as adoption_router
from routes.health_docs import router as health_docs_router
from routes.passport import router as passport_router
from routes.playdate import router as playdate_router
-from routes.ernaehrung import router as ernaehrung_router
-from routes.challenges import router as challenges_router
-from routes.gassi_zeiten import router as gassi_zeiten_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -293,9 +256,6 @@ app.include_router(adoption_router, prefix="/api/adoption", ta
app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"])
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
-app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"])
-app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
-app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
# ------------------------------------------------------------------
@@ -325,39 +285,6 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
-APP_VER = "715" # muss mit APP_VER in app.js übereinstimmen
-
-@app.get("/api/version")
-async def app_version():
- """Aktuelle Frontend-Version — wird beim App-Start gecheckt."""
- return Response(
- content=f'{{"version":"{APP_VER}"}}',
- media_type="application/json",
- headers={"Cache-Control": "no-store"},
- )
-
-
-@app.get("/stats/script.js")
-async def umami_script_proxy():
- async with httpx.AsyncClient(timeout=10) as client:
- r = await client.get("https://umami.motocamp.de/script.js")
- return Response(content=r.content, media_type="application/javascript",
- headers={"Cache-Control": "public, max-age=86400"})
-
-@app.post("/stats/api/send")
-async def umami_send_proxy(request: Request):
- body = await request.body()
- async with httpx.AsyncClient(timeout=10) as client:
- r = await client.post(
- "https://umami.motocamp.de/api/send",
- content=body,
- headers={"Content-Type": "application/json",
- "User-Agent": request.headers.get("user-agent", "")},
- )
- return Response(content=r.content, status_code=r.status_code,
- media_type="application/json")
-
-
@app.get("/robots.txt")
async def robots():
return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain")
diff --git a/backend/media_utils.py b/backend/media_utils.py
index 4cb2e28..8a8698f 100644
--- a/backend/media_utils.py
+++ b/backend/media_utils.py
@@ -178,17 +178,6 @@ def generate_preview(data: bytes, ext: str) -> bytes | None:
return None
-def get_image_size(data: bytes) -> tuple[int, int] | None:
- """Gibt (width, height) eines Bildes zurück, oder None bei Fehler."""
- try:
- from PIL import Image, ImageOps
- img = Image.open(io.BytesIO(data))
- img = ImageOps.exif_transpose(img)
- return img.size # (width, height)
- except Exception:
- return None
-
-
def preview_url_from(url: str | None) -> str | None:
"""Leitet die Preview-URL aus der Original-URL ab (fügt _preview vor Extension ein).
Gibt None für Videos, PDFs, bereits generierte Previews und leere URLs zurück."""
diff --git a/backend/routes/achievements.py b/backend/routes/achievements.py
index e8d0cba..1c0c1e8 100644
--- a/backend/routes/achievements.py
+++ b/backend/routes/achievements.py
@@ -92,59 +92,6 @@ CATEGORIES = [
("gold", 10, "Wiki-Fotograf"),
],
},
- {
- "id": "wetter_tapfer",
- "name": "Wetter-Tapferkeit",
- "emoji": "⛈️",
- "metrik": "wetter_tapfer_score",
- "einheit": " Eintrag/Einträge",
- "stufen": [
- ("bronze", 1, "Regentrotzdem"),
- ("silber", 5, "Wettertrotzer"),
- ("gold", 15, "Allwetter-Held"),
- ("platin", 30, "Hunde-Wetterheld"),
- ],
- },
- {
- "id": "jahreszeiten",
- "name": "Jahreszeiten-Erkunder",
- "emoji": "🍃",
- "metrik": "jahreszeiten_score",
- "einheit": " Jahreszeit(en)",
- "stufen": [
- ("bronze", 1, "Frühlings-Erkunder"),
- ("silber", 2, "Sommer-Genießer"),
- ("gold", 3, "Herbst-Schnüffler"),
- ("platin", 4, "Alle-Jahreszeiten"),
- ],
- },
- {
- "id": "schnee_held",
- "name": "Schneeheld",
- "emoji": "❄️",
- "metrik": "schnee_eintraege",
- "einheit": " Eintrag/Einträge",
- "stufen": [
- ("bronze", 1, "Erster Schnee"),
- ("silber", 5, "Schneehund"),
- ("gold", 15, "Schneeheld"),
- ("platin", 30, "Schneewolf"),
- ],
- },
- {
- "id": "km_lebenswerk",
- "name": "Kilometer-Lebenswerk",
- "emoji": "🐾",
- "metrik": "gesamt_km_lebenswerk",
- "einheit": " km",
- "icon": "path",
- "stufen": [
- ("bronze", 100, "100-km-Club"),
- ("silber", 500, "500-km-Wanderer"),
- ("gold", 1000, "Tausend-km-Held"),
- ("platin", 5000, "Ultraläufer"),
- ],
- },
]
# Flat-Liste aller Badge-IDs für DB-Kompatibilität
@@ -203,53 +150,12 @@ def check_and_award(user_id: int, conn):
"SELECT current_streak FROM users WHERE id=?", (user_id,)
).fetchone()
- # Wetter-Tapferkeit: Diary-Einträge bei schlechtem Wetter (über Dog-Join)
- wetter_row = conn.execute("""
- SELECT COUNT(*) AS cnt FROM diary d
- JOIN dogs dog ON dog.id = d.dog_id
- WHERE dog.user_id = ?
- AND d.weather_json IS NOT NULL
- AND (
- CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
- OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2
- OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50
- )
- """, (user_id,)).fetchone()
-
- # Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen (über Dog-Join)
- jahreszeiten_row = conn.execute("""
- SELECT
- (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
- WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
- (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
- WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
- (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
- WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
- (CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
- WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
- AS jahreszeiten_score
- FROM (SELECT 1)
- """, (user_id, user_id, user_id, user_id)).fetchone()
-
- # Schnee: Diary-Einträge bei Schnee (weathercode 71-77, über Dog-Join)
- schnee_row = conn.execute("""
- SELECT COUNT(*) AS cnt FROM diary d
- JOIN dogs dog ON dog.id = d.dog_id
- WHERE dog.user_id = ?
- AND d.weather_json IS NOT NULL
- AND CAST(json_extract(d.weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
- """, (user_id,)).fetchone()
-
metrics = {
- "total_km": stats["total_km"] if stats else 0,
- "routen": stats["routen"] if stats else 0,
- "pois": stats["pois"] if stats else 0,
- "streak": (streak_row["current_streak"] if streak_row else 0),
- "wiki_fotos": stats["wiki_fotos"] if stats else 0,
- "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0,
- "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0),
- "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0,
- "gesamt_km_lebenswerk": stats["total_km"] if stats else 0,
+ "total_km": stats["total_km"] if stats else 0,
+ "routen": stats["routen"] if stats else 0,
+ "pois": stats["pois"] if stats else 0,
+ "streak": (streak_row["current_streak"] if streak_row else 0),
+ "wiki_fotos": stats["wiki_fotos"] if stats else 0,
}
earned = {r["badge_id"] for r in
@@ -305,38 +211,6 @@ async def my_achievements(user=Depends(get_current_user)):
"SELECT current_streak, max_streak FROM users WHERE id=?", (uid,)
).fetchone()
- # Wetter-Tapferkeit
- wetter_row = conn.execute("""
- SELECT COUNT(*) AS cnt FROM diary d
- LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
- WHERE d.user_id = ?
- AND d.weather_json IS NOT NULL
- AND (
- CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
- OR CAST(json_extract(d.weather_json, '$.temp_c') AS REAL) < 2
- OR CAST(json_extract(d.weather_json, '$.wind_kmh') AS REAL) > 50
- )
- """, (uid,)).fetchone()
-
- # Jahreszeiten
- jahreszeiten_row = conn.execute("""
- SELECT
- (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
- (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
- (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
- (CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
- AS jahreszeiten_score
- FROM (SELECT 1)
- """, (uid, uid, uid, uid)).fetchone()
-
- # Schnee-Einträge
- schnee_row = conn.execute("""
- SELECT COUNT(*) AS cnt FROM diary
- WHERE user_id = ?
- AND weather_json IS NOT NULL
- AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
- """, (uid,)).fetchone()
-
earned_rows = conn.execute(
"SELECT badge_id FROM user_badges WHERE user_id=?", (uid,)
).fetchall()
@@ -356,15 +230,11 @@ async def my_achievements(user=Depends(get_current_user)):
""", (stats["punkte"] if stats else 0,)).fetchone()
metrics = {
- "total_km": stats["total_km"] if stats else 0,
- "routen": stats["routen"] if stats else 0,
- "pois": stats["pois"] if stats else 0,
- "streak": (streak_row["current_streak"] if streak_row else 0),
- "wiki_fotos": stats["wiki_fotos"] if stats else 0,
- "wetter_tapfer_score": wetter_row["cnt"] if wetter_row else 0,
- "jahreszeiten_score": (jahreszeiten_row["jahreszeiten_score"] if jahreszeiten_row else 0),
- "schnee_eintraege": schnee_row["cnt"] if schnee_row else 0,
- "gesamt_km_lebenswerk": stats["total_km"] if stats else 0,
+ "total_km": stats["total_km"] if stats else 0,
+ "routen": stats["routen"] if stats else 0,
+ "pois": stats["pois"] if stats else 0,
+ "streak": (streak_row["current_streak"] if streak_row else 0),
+ "wiki_fotos": stats["wiki_fotos"] if stats else 0,
}
# Kategorien mit aktuellem Tier + Fortschritt aufbauen
diff --git a/backend/routes/challenges.py b/backend/routes/challenges.py
deleted file mode 100644
index f3e3f0d..0000000
--- a/backend/routes/challenges.py
+++ /dev/null
@@ -1,304 +0,0 @@
-"""BAN YARO — Foto-Challenge der Woche"""
-
-import os
-import uuid
-import logging
-from datetime import date, timedelta
-from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
-from pydantic import BaseModel
-from typing import Optional
-from database import db
-from auth import get_current_user, get_current_user_optional
-from media_utils import convert_media, generate_preview
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter()
-
-MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
-CHALLENGE_DIR = os.path.join(MEDIA_DIR, "challenges")
-
-_CHALLENGE_THEMEN = [
- "Bestes Schnüffel-Foto 👃",
- "Action-Aufnahme 🏃",
- "Schlafendes Tier 😴",
- "Gassi im Regen 🌧️",
- "Hundeblick in die Kamera 👀",
- "Spielzeit mit Freunden 🐕",
- "Herbstspaziergang 🍂",
- "Beste Sprung-Aufnahme 🦘",
- "Hund am Wasser 🌊",
- "Erstes Mal im Schnee ❄️",
- "Genuss-Moment 🦴",
- "Versteckt im Gebüsch 🌿",
- "Tierisches Selfie 🤳",
- "Hund & Kind 👶",
- "Hund & Katze zusammen 🐱",
- "Der beste Buddel-Moment 🐾",
- "Freude beim Apportieren 🎾",
- "Hund in seiner Lieblingshöhle 🛋️",
- "Sonnenuntergangs-Gassi 🌅",
- "Hundebegegnung auf dem Spaziergang 🐕🐕",
- "Ausdrucksstarker Hundeblick 😍",
- "Hund im Herbstlaub 🍁",
- "Welpenfoto 🍼",
- "Seniorenhund im Porträt 👴",
- "Lustigste Schlafposition 💤",
- "Hund trägt etwas 🎀",
- "Hund + Besitzer Spiegelfoto 🪞",
- "Hund auf Abenteuer 🏕️",
- "Beste Lauf-Action 💨",
- "Hund im Café ☕",
-]
-
-
-def _current_week_monday() -> str:
- today = date.today()
- monday = today - timedelta(days=today.weekday())
- return monday.isoformat()
-
-
-def _current_week_sunday() -> str:
- monday = date.fromisoformat(_current_week_monday())
- return (monday + timedelta(days=6)).isoformat()
-
-
-def _ensure_current_challenge(conn) -> int:
- """Stellt sicher dass eine Challenge für die aktuelle Woche existiert. Gibt die ID zurück."""
- monday = _current_week_monday()
- sunday = _current_week_sunday()
-
- existing = conn.execute(
- "SELECT id FROM foto_challenge WHERE start_date = ?", (monday,)
- ).fetchone()
- if existing:
- return existing["id"]
-
- # Thema aus Rotation wählen (Wochennummer % Anzahl Themen)
- week_num = date.today().isocalendar()[1]
- thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)]
-
- cur = conn.execute(
- "INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) "
- "VALUES (?, ?, ?, ?, NULL)",
- (thema, f"Diese Woche: {thema}", monday, sunday)
- )
- return cur.lastrowid
-
-
-# ------------------------------------------------------------------
-# GET /api/challenges/current
-# ------------------------------------------------------------------
-@router.get("/current")
-async def get_current_challenge(user=Depends(get_current_user_optional)):
- with db() as conn:
- challenge_id = _ensure_current_challenge(conn)
- challenge = conn.execute(
- "SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
- ).fetchone()
-
- submissions = conn.execute("""
- SELECT cs.id, cs.user_id, cs.dog_id, cs.foto_url, cs.caption, cs.votes, cs.created_at,
- u.name AS user_name, u.avatar_url,
- d.name AS dog_name, d.foto_url AS dog_foto_url
- FROM challenge_submissions cs
- LEFT JOIN users u ON u.id = cs.user_id
- LEFT JOIN dogs d ON d.id = cs.dog_id
- WHERE cs.challenge_id = ?
- ORDER BY cs.votes DESC, cs.created_at ASC
- """, (challenge_id,)).fetchall()
-
- my_submission = None
- my_votes = set()
- if user:
- mine = conn.execute(
- "SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
- (challenge_id, user["id"])
- ).fetchone()
- if mine:
- my_submission = mine["id"]
- voted_rows = conn.execute(
- "SELECT cv.submission_id FROM challenge_votes cv "
- "JOIN challenge_submissions cs ON cs.id = cv.submission_id "
- "WHERE cv.user_id = ? AND cs.challenge_id = ?",
- (user["id"], challenge_id)
- ).fetchall()
- my_votes = {r["submission_id"] for r in voted_rows}
-
- # Countdown bis Sonntag
- end = date.fromisoformat(challenge["end_date"])
- days_left = (end - date.today()).days + 1
-
- result_subs = []
- for s in submissions:
- sd = dict(s)
- sd["i_voted"] = (sd["id"] in my_votes) if user else False
- result_subs.append(sd)
-
- return {
- "challenge": dict(challenge),
- "submissions": result_subs,
- "my_submission_id": my_submission,
- "days_left": max(0, days_left),
- }
-
-
-# ------------------------------------------------------------------
-# POST /api/challenges/{id}/submit
-# ------------------------------------------------------------------
-@router.post("/{challenge_id}/submit", status_code=201)
-async def submit_photo(
- challenge_id: int,
- caption: Optional[str] = Form(None),
- dog_id: Optional[int] = Form(None),
- foto: UploadFile = File(...),
- user=Depends(get_current_user),
-):
- with db() as conn:
- challenge = conn.execute(
- "SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
- ).fetchone()
- if not challenge:
- raise HTTPException(404, "Challenge nicht gefunden.")
-
- today = date.today().isoformat()
- if today > challenge["end_date"]:
- raise HTTPException(400, "Die Challenge ist bereits beendet.")
-
- # Doppelt-Check
- existing = conn.execute(
- "SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
- (challenge_id, user["id"])
- ).fetchone()
- if existing:
- raise HTTPException(409, "Du hast bereits ein Foto eingereicht.")
-
- # Foto speichern
- os.makedirs(CHALLENGE_DIR, exist_ok=True)
- orig_filename = foto.filename or "foto.jpg"
- ext = os.path.splitext(orig_filename)[1] or ".jpg"
- base = uuid.uuid4().hex
-
- raw = await foto.read()
-
- # HEIC→JPEG Konvertierung falls nötig
- try:
- converted, out_ext = convert_media(raw, orig_filename)
- except Exception:
- converted, out_ext = raw, ext
-
- save_filename = f"{base}{out_ext}"
- save_path = os.path.join(CHALLENGE_DIR, save_filename)
- with open(save_path, "wb") as f:
- f.write(converted)
- foto_url = f"/media/challenges/{save_filename}"
-
- # Preview
- try:
- preview = generate_preview(converted, out_ext)
- if preview:
- prev_path = os.path.join(CHALLENGE_DIR, f"{base}_preview.webp")
- with open(prev_path, "wb") as f:
- f.write(preview)
- except Exception:
- pass
-
- with db() as conn:
- cur = conn.execute(
- "INSERT INTO challenge_submissions (challenge_id, user_id, dog_id, foto_url, caption) "
- "VALUES (?, ?, ?, ?, ?)",
- (challenge_id, user["id"], dog_id, foto_url, caption)
- )
- row = conn.execute("""
- SELECT cs.*, u.name AS user_name, d.name AS dog_name
- FROM challenge_submissions cs
- LEFT JOIN users u ON u.id = cs.user_id
- LEFT JOIN dogs d ON d.id = cs.dog_id
- WHERE cs.id = ?
- """, (cur.lastrowid,)).fetchone()
-
- return dict(row)
-
-
-# ------------------------------------------------------------------
-# POST /api/challenges/submissions/{id}/vote — Toggle-Vote
-# ------------------------------------------------------------------
-@router.post("/submissions/{submission_id}/vote")
-async def vote_submission(submission_id: int, user=Depends(get_current_user)):
- with db() as conn:
- sub = conn.execute(
- "SELECT * FROM challenge_submissions WHERE id = ?", (submission_id,)
- ).fetchone()
- if not sub:
- raise HTTPException(404, "Einreichung nicht gefunden.")
- if sub["user_id"] == user["id"]:
- raise HTTPException(400, "Du kannst nicht für dein eigenes Foto abstimmen.")
-
- existing = conn.execute(
- "SELECT id FROM challenge_votes WHERE submission_id=? AND user_id=?",
- (submission_id, user["id"])
- ).fetchone()
-
- if existing:
- # Toggle: Vote entfernen
- conn.execute(
- "DELETE FROM challenge_votes WHERE submission_id=? AND user_id=?",
- (submission_id, user["id"])
- )
- conn.execute(
- "UPDATE challenge_submissions SET votes = MAX(0, votes - 1) WHERE id=?",
- (submission_id,)
- )
- voted = False
- else:
- conn.execute(
- "INSERT INTO challenge_votes (submission_id, user_id) VALUES (?, ?)",
- (submission_id, user["id"])
- )
- conn.execute(
- "UPDATE challenge_submissions SET votes = votes + 1 WHERE id=?",
- (submission_id,)
- )
- voted = True
-
- votes = conn.execute(
- "SELECT votes FROM challenge_submissions WHERE id=?", (submission_id,)
- ).fetchone()["votes"]
-
- return {"voted": voted, "votes": votes}
-
-
-# ------------------------------------------------------------------
-# GET /api/challenges/winners — letzte 4 Gewinner
-# ------------------------------------------------------------------
-@router.get("/winners")
-async def get_winners():
- with db() as conn:
- # Vergangene Challenges (ohne aktuelle Woche)
- monday = _current_week_monday()
- challenges = conn.execute(
- "SELECT id, thema, start_date, end_date FROM foto_challenge "
- "WHERE end_date < ? ORDER BY end_date DESC LIMIT 4",
- (monday,)
- ).fetchall()
-
- winners = []
- for ch in challenges:
- winner = conn.execute("""
- SELECT cs.id, cs.user_id, cs.foto_url, cs.caption, cs.votes,
- u.name AS user_name, u.avatar_url,
- d.name AS dog_name
- FROM challenge_submissions cs
- LEFT JOIN users u ON u.id = cs.user_id
- LEFT JOIN dogs d ON d.id = cs.dog_id
- WHERE cs.challenge_id = ?
- ORDER BY cs.votes DESC, cs.created_at ASC
- LIMIT 1
- """, (ch["id"],)).fetchone()
-
- winners.append({
- "challenge": dict(ch),
- "winner": dict(winner) if winner else None,
- })
-
- return winners
diff --git a/backend/routes/diary.py b/backend/routes/diary.py
index 6f6cd12..a3dee2b 100644
--- a/backend/routes/diary.py
+++ b/backend/routes/diary.py
@@ -9,7 +9,7 @@ from auth import get_current_user, require_admin
import ki as KI
import httpx
import weather as weather_mod
-from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size
+from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from
from timeutils import safe_client_time
logger = logging.getLogger(__name__)
@@ -30,7 +30,6 @@ class DiaryCreate(BaseModel):
location_name: Optional[str] = None
is_milestone: bool = False
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
- weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS)
class DiaryUpdate(BaseModel):
@@ -351,19 +350,6 @@ async def create_diary(dog_id: int, data: DiaryCreate,
)
entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
- elif data.weather_json:
- # Client hat Wetter vorab geholt (kein GPS-Standort gesetzt) → direkt speichern
- try:
- json.loads(data.weather_json) # Validierung
- with db() as conn:
- conn.execute(
- "UPDATE diary SET weather_json=? WHERE id=?",
- (data.weather_json, entry_id)
- )
- entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
- except Exception as exc:
- logger.warning("Client-weather_json ungültig: %s", exc)
-
return _entry_dict(entry, dogs_map, media_map)
@@ -706,12 +692,10 @@ async def upload_media(dog_id: int, entry_id: int,
media_url = f"/media/diary/{filename}"
- # Bildmaße + EXIF-GPS (nur bei Bilddateien)
- exif_gps = None
- img_size = None
+ # EXIF-GPS aus Bild extrahieren (nur bei Bilddateien)
+ exif_gps = None
if media_type == "image":
exif_gps = extract_gps_from_exif(raw_data)
- img_size = get_image_size(raw_data)
with db() as conn:
# sort_order = nächste freie Position
@@ -722,9 +706,8 @@ async def upload_media(dog_id: int, entry_id: int,
# Erstes Item eines Eintrags wird automatisch Cover
is_cover = 1 if max_order == -1 else 0
conn.execute(
- "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover, img_width, img_height) VALUES (?,?,?,?,?,?,?)",
- (entry_id, media_url, media_type, max_order + 1, is_cover,
- img_size[0] if img_size else None, img_size[1] if img_size else None)
+ "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)",
+ (entry_id, media_url, media_type, max_order + 1, is_cover)
)
new_id = conn.execute(
"SELECT id FROM diary_media WHERE diary_id=? ORDER BY id DESC LIMIT 1",
diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py
index 42b9b32..a44faa0 100644
--- a/backend/routes/dogs.py
+++ b/backend/routes/dogs.py
@@ -181,29 +181,18 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
raise HTTPException(404, "Hund nicht gefunden.")
# Zufälliges Foto aus den letzten 100 Tagebuchbildern
- # Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge
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""",
+ ORDER BY d.datum DESC LIMIT 100""",
(dog_id,)
).fetchall()
- # Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill)
- 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'
- 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"]
+ day_num = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
+ chosen_url = photos[day_num % len(photos)]["url"]
random_photo = {
"url": chosen_url,
"preview_url": preview_url_from(chosen_url),
@@ -305,463 +294,6 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
}
-@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'
🐾
'
- )
-
- 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'{emoji} {temp_i}°C'
- 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", "
")
- 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''
- f'
})
'
- f'
'
- )
-
- loc_html = f'📍 {loc}' if loc else ""
- chips_html = f'{wetter}{loc_html}
' if (wetter or loc_html) else ""
- titel_html = f'{titel}
' if titel else ""
- text_html = f'{text}
' if text_raw else ""
-
- entries_html += f"""
-
- {foto_html}
-
{datum_fmt}
- {titel_html}
- {text_html}
- {chips_html}
-
-"""
-
- anzahl = len(rows)
- html_page = f"""
-
-
-
-
- Hunde-Buch — {dog_name}
-
-
-
-
-
-
-
- {cover_img}
-
{dog_name}
- {'
' + subtitle + '
' if subtitle else ''}
-
{jahr_str}
-
{anzahl} Einträge
-
-
-{entries_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:
@@ -1090,159 +622,3 @@ 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,
- }
diff --git a/backend/routes/ernaehrung.py b/backend/routes/ernaehrung.py
deleted file mode 100644
index c1f850e..0000000
--- a/backend/routes/ernaehrung.py
+++ /dev/null
@@ -1,145 +0,0 @@
-"""BAN YARO — Ernährungs-Routes"""
-
-import logging
-from fastapi import APIRouter, Depends, HTTPException, Request
-from pydantic import BaseModel
-from typing import Optional
-from database import db
-from auth import get_current_user
-import ki as ki_module
-
-router = APIRouter()
-logger = logging.getLogger(__name__)
-
-
-# ------------------------------------------------------------------
-# Schemas
-# ------------------------------------------------------------------
-class FutterProfilUpdate(BaseModel):
- futter_typ: Optional[str] = None # trocken|nass|barf|mix
- marke: Optional[str] = None
- kcal_tag: Optional[int] = None
- portionen: Optional[int] = None
- notizen: Optional[str] = None
-
-
-class KiBeratungRequest(BaseModel):
- frage: str
- dog_name: Optional[str] = None
- rasse: Optional[str] = None
- alter: Optional[str] = None
- gewicht: Optional[float] = None
- aktiv: Optional[bool] = None
-
-
-# ------------------------------------------------------------------
-# Hilfsfunktion: Zugriffsprüfung
-# ------------------------------------------------------------------
-def _check_dog_access(conn, dog_id: int, user_id: int):
- row = conn.execute(
- "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
- ).fetchone()
- if not row:
- raise HTTPException(404, "Hund nicht gefunden.")
-
-
-# ------------------------------------------------------------------
-# GET /dogs/{dog_id}/ernaehrung
-# ------------------------------------------------------------------
-@router.get("/{dog_id}/ernaehrung")
-async def get_ernaehrung(dog_id: int, user=Depends(get_current_user)):
- with db() as conn:
- _check_dog_access(conn, dog_id, user["id"])
- row = conn.execute(
- "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
- ).fetchone()
- if not row:
- return {}
- return dict(row)
-
-
-# ------------------------------------------------------------------
-# PUT /dogs/{dog_id}/ernaehrung
-# ------------------------------------------------------------------
-@router.put("/{dog_id}/ernaehrung")
-async def put_ernaehrung(dog_id: int, body: FutterProfilUpdate,
- user=Depends(get_current_user)):
- with db() as conn:
- _check_dog_access(conn, dog_id, user["id"])
- existing = conn.execute(
- "SELECT id FROM futter_profil WHERE dog_id=?", (dog_id,)
- ).fetchone()
- if existing:
- conn.execute("""
- UPDATE futter_profil
- SET futter_typ=COALESCE(?, futter_typ),
- marke=COALESCE(?, marke),
- kcal_tag=COALESCE(?, kcal_tag),
- portionen=COALESCE(?, portionen),
- notizen=COALESCE(?, notizen),
- updated_at=datetime('now')
- WHERE dog_id=?
- """, (body.futter_typ, body.marke, body.kcal_tag,
- body.portionen, body.notizen, dog_id))
- else:
- conn.execute("""
- INSERT INTO futter_profil
- (dog_id, futter_typ, marke, kcal_tag, portionen, notizen)
- VALUES (?, ?, ?, ?, ?, ?)
- """, (dog_id, body.futter_typ, body.marke, body.kcal_tag,
- body.portionen or 2, body.notizen))
- row = conn.execute(
- "SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
- ).fetchone()
- return dict(row)
-
-
-# ------------------------------------------------------------------
-# POST /dogs/{dog_id}/ernaehrung/ki-beratung
-# ------------------------------------------------------------------
-@router.post("/{dog_id}/ernaehrung/ki-beratung")
-async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest,
- request: Request,
- user=Depends(get_current_user)):
- if not body.frage or len(body.frage.strip()) < 3:
- raise HTTPException(400, "Bitte stelle eine Frage.")
- if len(body.frage) > 800:
- raise HTTPException(400, "Frage zu lang (max. 800 Zeichen).")
-
- with db() as conn:
- _check_dog_access(conn, dog_id, user["id"])
-
- dog_name = body.dog_name or "unbekannt"
- rasse = body.rasse or "unbekannt"
- alter = body.alter or "unbekannt"
- gewicht = f"{body.gewicht} kg" if body.gewicht else "unbekannt"
- aktiv_str = "aktiv" if body.aktiv else "normal aktiv"
-
- system = (
- "Du bist Ernährungsberater für Hunde. "
- "Antworte immer auf Deutsch, kurz und praktisch. "
- "Keine unnötigen Füllsätze. "
- "Weise bei ernsthaften Gesundheitsfragen immer auf den Tierarzt hin. "
- "Stelle keine medizinischen Diagnosen."
- )
-
- prompt = (
- f"Hund: {dog_name}, Rasse: {rasse}, Alter: {alter}, "
- f"Gewicht: {gewicht}, Aktivität: {aktiv_str}.\n\n"
- f"Frage: {body.frage.strip()}\n\n"
- "Antworte konkret und praktisch, maximal 200 Wörter."
- )
-
- try:
- antwort = await ki_module.complete(
- prompt=prompt,
- system=system,
- max_tokens=500,
- requires_premium=False,
- user_id=user["id"],
- )
- return {"antwort": antwort}
- except ki_module.KIUnavailableError as e:
- raise HTTPException(503, str(e))
- except Exception:
- raise HTTPException(500, "KI momentan nicht verfügbar.")
diff --git a/backend/routes/friends.py b/backend/routes/friends.py
index 7df14e4..cac5f4b 100644
--- a/backend/routes/friends.py
+++ b/backend/routes/friends.py
@@ -342,56 +342,3 @@ async def remove_friend(friend_user_id: int, user=Depends(get_current_user)):
AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
""", (uid, friend_user_id, friend_user_id, uid))
return {"ok": True}
-
-
-# ------------------------------------------------------------------
-# GET /api/friends/same-breed — andere User mit gleicher Rasse
-# ------------------------------------------------------------------
-@router.get("/same-breed")
-async def same_breed(user=Depends(get_current_user)):
- """Findet andere User mit Hunden derselben Rasse. Gibt Anzahl + Forum-Suche zurück."""
- uid = user["id"]
- with db() as conn:
- # Rassen des eingeloggten Users
- my_dogs = conn.execute(
- "SELECT rasse FROM dogs WHERE user_id=? AND rasse IS NOT NULL AND rasse != ''",
- (uid,)
- ).fetchall()
-
- if not my_dogs:
- return {"count": 0, "rassen": [], "forum_query": None}
-
- rassen = list({d["rasse"].strip() for d in my_dogs if d["rasse"]})
-
- # Andere User (nicht ich) die eine dieser Rassen haben
- ph = ",".join("?" * len(rassen))
- count_row = conn.execute(f"""
- SELECT COUNT(DISTINCT d.user_id) AS cnt
- FROM dogs d
- WHERE d.user_id != ?
- AND d.rasse IN ({ph})
- """, (uid, *rassen)).fetchone()
-
- count = count_row["cnt"] if count_row else 0
-
- # Für jede Rasse: wie viele andere User
- rassen_detail = []
- for rasse in rassen:
- n = conn.execute(
- "SELECT COUNT(DISTINCT user_id) AS cnt FROM dogs "
- "WHERE user_id != ? AND rasse = ?",
- (uid, rasse)
- ).fetchone()["cnt"]
- if n > 0:
- rassen_detail.append({"rasse": rasse, "count": n})
-
- rassen_detail.sort(key=lambda x: -x["count"])
-
- # Forum-Suche-Link für die häufigste Rasse
- forum_query = rassen_detail[0]["rasse"] if rassen_detail else None
-
- return {
- "count": count,
- "rassen": rassen_detail,
- "forum_query": forum_query,
- }
diff --git a/backend/routes/gassi_zeiten.py b/backend/routes/gassi_zeiten.py
deleted file mode 100644
index 77ff52f..0000000
--- a/backend/routes/gassi_zeiten.py
+++ /dev/null
@@ -1,190 +0,0 @@
-"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)"""
-
-import json
-import math
-import logging
-from fastapi import APIRouter, Depends, HTTPException
-from pydantic import BaseModel
-from typing import Optional, List
-from database import db
-from auth import get_current_user
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter()
-
-
-def _haversine(lat1, lon1, lat2, lon2):
- R = 6_371_000
- p1, p2 = math.radians(lat1), math.radians(lat2)
- dp = math.radians(lat2 - lat1)
- dl = math.radians(lon2 - lon1)
- a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
- return 2 * R * math.asin(math.sqrt(a))
-
-
-class GassiZeitCreate(BaseModel):
- dog_id: Optional[int] = None
- wochentage: List[str] # ["mo", "mi", "fr"]
- uhrzeit: str # "17:00"
- ort_name: Optional[str] = None
- lat: Optional[float] = None
- lon: Optional[float] = None
- radius_m: int = 500
- notiz: Optional[str] = None
-
-
-class GassiZeitUpdate(BaseModel):
- aktiv: Optional[int] = None
-
-
-# ------------------------------------------------------------------
-# GET /api/gassi-zeiten — alle in der Nähe (oder eigene)
-# ------------------------------------------------------------------
-@router.get("")
-async def list_gassi_zeiten(
- lat: Optional[float] = None,
- lon: Optional[float] = None,
- radius: int = 5000, # Meter
- nur_eigene: bool = False,
- user=Depends(get_current_user),
-):
- with db() as conn:
- if nur_eigene:
- rows = conn.execute("""
- SELECT gz.*, u.name AS user_name, u.avatar_url,
- d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
- FROM gassi_zeiten gz
- LEFT JOIN users u ON u.id = gz.user_id
- LEFT JOIN dogs d ON d.id = gz.dog_id
- WHERE gz.user_id = ?
- ORDER BY gz.uhrzeit ASC
- """, (user["id"],)).fetchall()
- else:
- rows = conn.execute("""
- SELECT gz.*, u.name AS user_name, u.avatar_url,
- d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
- FROM gassi_zeiten gz
- LEFT JOIN users u ON u.id = gz.user_id
- LEFT JOIN dogs d ON d.id = gz.dog_id
- WHERE gz.aktiv = 1
- ORDER BY gz.uhrzeit ASC
- """).fetchall()
-
- result = []
- for r in rows:
- d = dict(r)
- # wochentage JSON parsen
- try:
- d["wochentage"] = json.loads(d["wochentage"]) if isinstance(d["wochentage"], str) else d["wochentage"]
- except Exception:
- d["wochentage"] = []
- d["is_mine"] = (d["user_id"] == user["id"])
-
- # Distanz-Filter
- if lat is not None and lon is not None and d.get("lat") and d.get("lon"):
- dist = _haversine(lat, lon, d["lat"], d["lon"])
- if not nur_eigene and dist > radius:
- continue
- d["distance_m"] = int(dist)
- else:
- d["distance_m"] = None
-
- result.append(d)
-
- # Sortierung: eigene zuerst, dann nach Distanz
- result.sort(key=lambda x: (0 if x["is_mine"] else 1, x.get("distance_m") or 99999))
- return result
-
-
-# ------------------------------------------------------------------
-# POST /api/gassi-zeiten — eigene Zeit anlegen
-# ------------------------------------------------------------------
-@router.post("", status_code=201)
-async def create_gassi_zeit(data: GassiZeitCreate, user=Depends(get_current_user)):
- if not data.wochentage:
- raise HTTPException(400, "Mindestens ein Wochentag muss angegeben werden.")
- if not data.uhrzeit:
- raise HTTPException(400, "Uhrzeit muss angegeben werden.")
-
- wochentage_json = json.dumps(data.wochentage)
-
- with db() as conn:
- # Hund-Prüfung
- if data.dog_id:
- dog = conn.execute(
- "SELECT id FROM dogs WHERE id=? AND user_id=?", (data.dog_id, user["id"])
- ).fetchone()
- if not dog:
- raise HTTPException(403, "Hund nicht gefunden oder gehört nicht dir.")
-
- cur = conn.execute("""
- INSERT INTO gassi_zeiten (user_id, dog_id, wochentage, uhrzeit,
- ort_name, lat, lon, radius_m, notiz)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- """, (user["id"], data.dog_id, wochentage_json, data.uhrzeit,
- data.ort_name, data.lat, data.lon, data.radius_m, data.notiz))
-
- row = conn.execute("""
- SELECT gz.*, u.name AS user_name, d.name AS dog_name, d.foto_url AS dog_foto_url
- FROM gassi_zeiten gz
- LEFT JOIN users u ON u.id = gz.user_id
- LEFT JOIN dogs d ON d.id = gz.dog_id
- WHERE gz.id = ?
- """, (cur.lastrowid,)).fetchone()
-
- result = dict(row)
- try:
- result["wochentage"] = json.loads(result["wochentage"])
- except Exception:
- pass
- result["is_mine"] = True
- return result
-
-
-# ------------------------------------------------------------------
-# PATCH /api/gassi-zeiten/{id} — pausieren / aktivieren
-# ------------------------------------------------------------------
-@router.patch("/{gz_id}")
-async def update_gassi_zeit(gz_id: int, data: GassiZeitUpdate, user=Depends(get_current_user)):
- with db() as conn:
- gz = conn.execute(
- "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
- ).fetchone()
- if not gz:
- raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
- if gz["user_id"] != user["id"]:
- raise HTTPException(403, "Nicht deine Gassi-Zeit.")
-
- updates = data.model_dump(exclude_none=True)
- if updates:
- cols = ", ".join(f"{k} = ?" for k in updates)
- conn.execute(f"UPDATE gassi_zeiten SET {cols} WHERE id=?", [*updates.values(), gz_id])
-
- row = conn.execute(
- "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
- ).fetchone()
-
- result = dict(row)
- try:
- result["wochentage"] = json.loads(result["wochentage"])
- except Exception:
- pass
- result["is_mine"] = True
- return result
-
-
-# ------------------------------------------------------------------
-# DELETE /api/gassi-zeiten/{id}
-# ------------------------------------------------------------------
-@router.delete("/{gz_id}", status_code=204)
-async def delete_gassi_zeit(gz_id: int, user=Depends(get_current_user)):
- with db() as conn:
- gz = conn.execute(
- "SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
- ).fetchone()
- if not gz:
- raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
- if gz["user_id"] != user["id"]:
- raise HTTPException(403, "Nicht deine Gassi-Zeit.")
- conn.execute("DELETE FROM gassi_zeiten WHERE id=?", (gz_id,))
diff --git a/backend/routes/osm.py b/backend/routes/osm.py
index 998cd4b..e08742b 100644
--- a/backend/routes/osm.py
+++ b/backend/routes/osm.py
@@ -279,15 +279,9 @@ class UserPoiIn(BaseModel):
ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park',
- 'giftkoeder', # Giftköder (exklusiv, kein Kombi)
- 'gefahr', # Allgemeine Gefahr / Hinweis
- 'freilauf', # Freilauffläche
- 'restaurant', # Hundefreundliches Restaurant / Café
- 'shop', # Hundefreundlicher Shop
- 'tierarzt', # Tierarzt / Tierklinik
- 'hundeschule', # Hundeschule / Trainer
+ 'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius)
'kotbeutel', # Kotbeutelspender
- 'bank', # Sitzbank
+ 'gefahr', # Allgemeine Gefahr / Hinweis
'parkplatz', # Hundefreundlicher Parkplatz
'treffpunkt', # Treffpunkt für Hundehalter
'sonstiges',
@@ -295,8 +289,7 @@ ALLOWED_TYPES = {
@router.post('/user-poi')
async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)):
- types = [t.strip() for t in body.type.split(',') if t.strip()]
- if not types or any(t not in ALLOWED_TYPES for t in types):
+ if body.type not in ALLOWED_TYPES:
raise HTTPException(400, 'Ungültiger Typ')
with db() as conn:
row = conn.execute("""
diff --git a/backend/routes/profile.py b/backend/routes/profile.py
index 9762f95..08f403a 100644
--- a/backend/routes/profile.py
+++ b/backend/routes/profile.py
@@ -26,7 +26,6 @@ class ProfileUpdate(BaseModel):
social_link: Optional[str] = None
profil_sichtbarkeit: Optional[str] = None
notes_ki_enabled: Optional[int] = None
- gassi_stunde_push: Optional[int] = None
def _load_user(user_id: int) -> dict:
@@ -114,28 +113,3 @@ async def upload_avatar(
)
return {"avatar_url": avatar_url}
-
-
-# ----------------------------------------------------------
-# GET /profile/world-config — Welten-Chip-Konfiguration laden
-# PUT /profile/world-config — Welten-Chip-Konfiguration speichern
-# ----------------------------------------------------------
-import json as _json
-
-@router.get('/world-config')
-async def get_world_config(user=Depends(get_current_user)):
- with db() as conn:
- row = conn.execute("SELECT world_config FROM users WHERE id=?", (user['id'],)).fetchone()
- cfg = row['world_config'] if row and row['world_config'] else None
- return {"config": _json.loads(cfg) if cfg else None}
-
-
-class WorldConfigIn(BaseModel):
- config: dict
-
-@router.put('/world-config')
-async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)):
- with db() as conn:
- conn.execute("UPDATE users SET world_config=? WHERE id=?",
- (_json.dumps(body.config), user['id']))
- return {"status": "ok"}
diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py
index 8448478..48287f9 100644
--- a/backend/routes/tieraerzte.py
+++ b/backend/routes/tieraerzte.py
@@ -27,14 +27,6 @@ class TierarztCreate(BaseModel):
osm_id: Optional[str] = None
-class BewertungCreate(BaseModel):
- gesamt: int
- wartezeit: Optional[int] = None
- freundlichkeit: Optional[int] = None
- kompetenz: Optional[int] = None
- text: Optional[str] = None
-
-
class TierarztUpdate(BaseModel):
name: Optional[str] = None
strasse: Optional[str] = None
@@ -228,109 +220,3 @@ async def update_tierarzt(tierarzt_id: int, data: TierarztUpdate,
)
row = conn.execute("SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
return dict(row)
-
-
-# ------------------------------------------------------------------
-# BEWERTUNGEN
-# ------------------------------------------------------------------
-
-def _refresh_vet_rating(conn, tierarzt_id: int):
- """Aktualisiert avg_rating und anz_bewertungen in tieraerzte."""
- row = conn.execute(
- """SELECT COUNT(*) AS n, AVG(CAST(gesamt AS REAL)) AS avg
- FROM tierarzt_bewertungen WHERE tierarzt_id=?""",
- (tierarzt_id,)
- ).fetchone()
- n = row["n"] or 0
- avg = row["avg"] or 0.0
- conn.execute(
- "UPDATE tieraerzte SET avg_rating=?, anz_bewertungen=? WHERE id=?",
- (round(avg, 1), n, tierarzt_id)
- )
-
-
-@router.post("/{tierarzt_id}/bewertung", status_code=201)
-async def create_bewertung(tierarzt_id: int, data: BewertungCreate,
- user=Depends(get_current_user)):
- """Bewertung abgeben (1×pro User+Tierarzt, UPSERT)."""
- if not (1 <= data.gesamt <= 5):
- raise HTTPException(400, "Gesamtbewertung muss zwischen 1 und 5 liegen.")
- for field in ("wartezeit", "freundlichkeit", "kompetenz"):
- val = getattr(data, field)
- if val is not None and not (1 <= val <= 5):
- raise HTTPException(400, f"{field} muss zwischen 1 und 5 liegen.")
-
- text = (data.text or "").strip()[:500] or None
-
- with db() as conn:
- vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (tierarzt_id,)).fetchone()
- if not vet:
- raise HTTPException(404, "Tierarzt nicht gefunden.")
-
- conn.execute(
- """INSERT INTO tierarzt_bewertungen
- (tierarzt_id, user_id, gesamt, wartezeit, freundlichkeit, kompetenz, text)
- VALUES (?,?,?,?,?,?,?)
- ON CONFLICT(tierarzt_id, user_id) DO UPDATE SET
- gesamt=excluded.gesamt,
- wartezeit=excluded.wartezeit,
- freundlichkeit=excluded.freundlichkeit,
- kompetenz=excluded.kompetenz,
- text=excluded.text,
- created_at=datetime('now')""",
- (tierarzt_id, user["id"], data.gesamt, data.wartezeit,
- data.freundlichkeit, data.kompetenz, text)
- )
- _refresh_vet_rating(conn, tierarzt_id)
- row = conn.execute(
- "SELECT * FROM tieraerzte WHERE id=?", (tierarzt_id,)
- ).fetchone()
- return dict(row)
-
-
-@router.get("/{tierarzt_id}/bewertungen")
-async def list_bewertungen(tierarzt_id: int):
- """Alle Bewertungen für einen Tierarzt (public). Gibt Zusammenfassung + letzte 5 Texte."""
- with db() as conn:
- vet = conn.execute(
- "SELECT id, avg_rating, anz_bewertungen FROM tieraerzte WHERE id=?",
- (tierarzt_id,)
- ).fetchone()
- if not vet:
- raise HTTPException(404, "Tierarzt nicht gefunden.")
-
- # Stern-Verteilung
- verteilung = {}
- for star in range(1, 6):
- r = conn.execute(
- "SELECT COUNT(*) AS n FROM tierarzt_bewertungen WHERE tierarzt_id=? AND gesamt=?",
- (tierarzt_id, star)
- ).fetchone()
- verteilung[str(star)] = r["n"]
-
- # Letzte 5 Kommentare
- kommentare = conn.execute(
- """SELECT gesamt, wartezeit, freundlichkeit, kompetenz, text, created_at
- FROM tierarzt_bewertungen
- WHERE tierarzt_id=? AND text IS NOT NULL AND text != ''
- ORDER BY created_at DESC LIMIT 5""",
- (tierarzt_id,)
- ).fetchall()
-
- return {
- "avg_rating": vet["avg_rating"] or 0,
- "anz_bewertungen": vet["anz_bewertungen"] or 0,
- "verteilung": verteilung,
- "kommentare": [dict(k) for k in kommentare],
- }
-
-
-@router.get("/{tierarzt_id}/meine-bewertung")
-async def get_meine_bewertung(tierarzt_id: int, user=Depends(get_current_user)):
- """Eigene Bewertung für einen Tierarzt (oder null)."""
- with db() as conn:
- row = conn.execute(
- "SELECT * FROM tierarzt_bewertungen WHERE tierarzt_id=? AND user_id=?",
- (tierarzt_id, user["id"])
- ).fetchone()
- return dict(row) if row else None
diff --git a/backend/routes/weather.py b/backend/routes/weather.py
index 2167b19..fced719 100644
--- a/backend/routes/weather.py
+++ b/backend/routes/weather.py
@@ -3,11 +3,9 @@ BAN YARO — Wetter-API
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
"""
-import json
from fastapi import APIRouter, Query, HTTPException, Depends
import weather as weather_module
from auth import get_current_user
-from database import db
router = APIRouter()
@@ -33,58 +31,3 @@ async def get_weather_forecast(
return await weather_module.get_forecast(lat, lon)
except Exception as exc:
raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}')
-
-
-@router.get('/records')
-async def weather_records(user=Depends(get_current_user)):
- """Persönliche Wetterrekorde aus diary-Einträgen mit weather_json."""
- uid = user["id"]
- with db() as conn:
- rows = conn.execute("""
- SELECT d.datum, d.weather_json, d.titel
- FROM diary d
- JOIN dogs dog ON dog.id = d.dog_id
- WHERE dog.user_id = ? AND d.weather_json IS NOT NULL
- ORDER BY d.datum ASC
- """, (uid,)).fetchall()
-
- if not rows:
- return {"records": None}
-
- entries = []
- for r in rows:
- try:
- w = json.loads(r["weather_json"])
- entries.append({
- "datum": r["datum"],
- "titel": r["titel"],
- "temp_c": w.get("temp_c"),
- "wind_kmh": w.get("wind_kmh"),
- "precip_prob": w.get("precip_prob"),
- "desc": w.get("desc", ""),
- "weathercode": w.get("weathercode"),
- })
- except Exception:
- pass
-
- if not entries:
- return {"records": None}
-
- temps = [e for e in entries if e["temp_c"] is not None]
- winds = [e for e in entries if e["wind_kmh"] is not None]
-
- records = {}
- if temps:
- kaeltester = min(temps, key=lambda e: e["temp_c"])
- heissester = max(temps, key=lambda e: e["temp_c"])
- records["kaeltester"] = kaeltester
- records["heissester"] = heissester
- if winds:
- stuermischster = max(winds, key=lambda e: e["wind_kmh"])
- records["stuermischster"] = stuermischster
-
- regen_count = sum(1 for e in entries if (e.get("precip_prob") or 0) > 60)
- records["regen_eintraege"] = regen_count
- records["gesamt_eintraege"] = len(entries)
-
- return {"records": records}
diff --git a/backend/routes/widget.py b/backend/routes/widget.py
index 2c04ae8..f5cc940 100644
--- a/backend/routes/widget.py
+++ b/backend/routes/widget.py
@@ -1,33 +1,13 @@
-"""BAN YARO — Widget-Snapshot + Tagesspruch Endpoints"""
+"""BAN YARO — Widget-Snapshot Endpoint"""
-import json
-from datetime import date
-from fastapi import APIRouter, Depends, Query
-from typing import Optional
+import json, random
+from fastapi import APIRouter, Depends
from database import db
from auth import get_current_user
router = APIRouter()
-@router.get("/quote")
-async def daily_quote(kategorie: Optional[str] = Query(None)):
- """Liefert einen deterministischen Tagesspruch (wechselt täglich)."""
- day_num = (date.today() - date(2026, 1, 1)).days
- with db() as conn:
- if kategorie:
- rows = conn.execute(
- "SELECT id, text, autor, kategorie FROM daily_quotes WHERE kategorie=?",
- (kategorie,)
- ).fetchall()
- else:
- rows = conn.execute("SELECT id, text, autor, kategorie FROM daily_quotes").fetchall()
- if not rows:
- return {"quote": None}
- q = rows[day_num % len(rows)]
- return {"quote": dict(q)}
-
-
@router.get("/snapshot")
async def widget_snapshot(user=Depends(get_current_user)):
"""Liefert kompakte Widget-Daten: Hund, nächste Erinnerung, zufälliges Tagebuchbild."""
@@ -59,8 +39,7 @@ async def widget_snapshot(user=Depends(get_current_user)):
(dog_id,)
).fetchall()
- day_num = (date.today() - date(2024, 1, 1)).days
- random_photo = dict(photos[day_num % len(photos)]) if photos else None
+ random_photo = dict(random.choice(photos)) if photos else None
# Anzahl überfälliger Erinnerungen
overdue = conn.execute(
diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py
index 56df55d..45f5bfb 100644
--- a/backend/routes/wiki.py
+++ b/backend/routes/wiki.py
@@ -414,7 +414,7 @@ async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_cur
raise HTTPException(404, "Einreichung nicht gefunden.")
rasse = conn.execute(
- "SELECT id, external_id, slug, foto_url FROM wiki_rassen WHERE id=?",
+ "SELECT id, external_id, slug FROM wiki_rassen WHERE id=?",
(sub["rasse_id"],)
).fetchone()
diff --git a/backend/scheduler.py b/backend/scheduler.py
index 22d1533..4aeb89a 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -156,40 +156,8 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
- # Jeden Montag 08:00 Uhr — Neue Foto-Challenge anlegen
- _scheduler.add_job(
- _job_new_foto_challenge,
- CronTrigger(day_of_week='mon', hour=8, minute=0),
- id="new_foto_challenge",
- replace_existing=True,
- misfire_grace_time=3600,
- )
- # Täglich 07:00 Uhr — Goldene Gassi-Stunde
- _scheduler.add_job(
- _job_golden_gassi_hour,
- CronTrigger(hour=7, minute=0),
- id="golden_gassi_hour",
- replace_existing=True,
- misfire_grace_time=3600,
- )
- # Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren)
- _scheduler.add_job(
- _job_anniversary_reminders,
- CronTrigger(hour=9, minute=0),
- id="anniversary_reminders",
- replace_existing=True,
- misfire_grace_time=3600,
- )
- # 1. des Monats 10:00 — Monatlicher Rückblick per Push
- _scheduler.add_job(
- _job_monthly_recap,
- CronTrigger(day=1, hour=10, minute=0),
- id="monthly_recap",
- replace_existing=True,
- misfire_grace_time=3600,
- )
_scheduler.start()
- logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00. OSM-Cache: on-demand (kein Prewarm).")
+ logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00. OSM-Cache: on-demand (kein Prewarm).")
def stop():
@@ -913,9 +881,6 @@ async def _job_status_report():
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
"streak_reminder": "Streak-Erinnerung (täglich 19:00)",
"recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)",
- "golden_gassi_hour": "Goldene Gassi-Stunde (täglich 07:00)",
- "anniversary_reminders": "Jahrestags-Erinnerungen (täglich 09:00)",
- "monthly_recap": "Monatlicher Rückblick (1. des Monats 10:00)",
}
job_rows_html = ""
job_rows_txt = ""
@@ -1323,369 +1288,3 @@ async def _job_recurring_expenses():
except Exception as e:
logger.error(f"Daueraufträge-Job Fehler: {e}")
_log_job("recurring_expenses", "error", str(e))
-
-
-# ------------------------------------------------------------------
-# JOB: Goldene Gassi-Stunde (täglich 07:00 Uhr)
-# ------------------------------------------------------------------
-async def _job_golden_gassi_hour():
- """
- Berechnet für jeden User mit aktivierter Einstellung (gassi_stunde_push=1)
- das beste 2h-Wetterfenster des Tages und schickt eine Push-Notification.
-
- Score-Logik pro Stunde (max. 10 Punkte):
- - Temperatur 10–20°C → +3
- - Temperatur 5–10°C → +1
- - Niederschlagswahrsch. <20% → +3, <40% → +1
- - Windgeschwindigkeit <20 km/h → +2, <30 km/h → +1
- - Stunden 07–19 Uhr (Tageslicht) → +2
- Bestes fortlaufendes 2h-Fenster (Summe zweier aufeinanderfolgender Stunden).
- """
- import httpx
- from datetime import date as _date
-
- logger.info("Goldene-Gassi-Stunde Job läuft")
-
- # Alle User mit aktivierter Einstellung + mindestens einer Push-Subscription
- with db() as conn:
- users = conn.execute("""
- SELECT DISTINCT u.id AS user_id,
- ps.last_lat, ps.last_lon
- FROM users u
- JOIN push_subscriptions ps ON ps.user_id = u.id
- WHERE u.gassi_stunde_push = 1
- """).fetchall()
-
- users = [dict(u) for u in users]
- logger.info(f"Goldene-Gassi-Stunde: {len(users)} User mit aktivierter Einstellung.")
-
- if not users:
- _log_job("golden_gassi_hour", "ok", "0 User mit Einstellung aktiv")
- return
-
- sent_total = 0
-
- for u in users:
- lat = u["last_lat"] or 48.1351 # Fallback: München
- lon = u["last_lon"] or 11.5820
-
- try:
- hourly = await _fetch_hourly_weather(lat, lon)
- except Exception as e:
- logger.warning(f"Goldene-Gassi-Stunde: Wetter-Fehler für user {u['user_id']}: {e}")
- continue
-
- if not hourly:
- continue
-
- best_start, best_score, best_temp, best_wind = _find_best_gassi_window(hourly)
-
- if best_score < 3:
- # Heute kein gutes Wetterfenster → kein Push
- logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} — kein gutes Fenster (score={best_score})")
- continue
-
- hour_end = (best_start + 2) % 24
- temp_str = f"{best_temp:.0f}°C" if best_temp is not None else "–"
- wind_str = "Kaum Wind" if (best_wind is not None and best_wind < 20) else (
- f"{best_wind:.0f} km/h Wind" if best_wind is not None else "")
-
- body_parts = [f"Bestes Wetter zwischen {best_start:02d}:00–{hour_end:02d}:00 Uhr",
- f"· {temp_str}"]
- if wind_str:
- body_parts.append(f"· {wind_str}")
-
- sent = send_push_to_user(u["user_id"], {
- "type": "golden_gassi_hour",
- "title": "☀️ Goldene Gassi-Stunde heute!",
- "body": " ".join(body_parts),
- "data": {"page": "wetter"},
- "tag": f"gassi-{_date.today()}",
- })
- sent_total += sent
- logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} → {best_start:02d}:00 (score={best_score}, {temp_str}) — Push: {sent}")
-
- logger.info(f"Goldene-Gassi-Stunde Job fertig — {len(users)} User, {sent_total} Push gesendet.")
- _log_job("golden_gassi_hour", "ok", f"{sent_total} Push an {len(users)} User")
-
-
-# ------------------------------------------------------------------
-# JOB: Jahrestags-Erinnerungen (täglich 09:00)
-# ------------------------------------------------------------------
-async def _job_anniversary_reminders():
- """Prüft ob heute ein Jahrestag für diary-Einträge vorliegt und sendet Push."""
- today = datetime.now(tz=_TZ)
- today_md = today.strftime('%m-%d') # Monat-Tag ohne Jahr
-
- logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}")
-
- with db() as conn:
- entries = conn.execute("""
- SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id,
- (SELECT dm.url FROM diary_media dm
- WHERE dm.diary_id=d.id LIMIT 1) AS foto_url
- FROM diary d
- WHERE strftime('%m-%d', d.datum) = ?
- AND d.datum < date('now')
- AND d.titel IS NOT NULL
- AND d.is_milestone = 0
- """, (today_md,)).fetchall()
-
- sent_total = 0
- for e in entries:
- try:
- jahre = today.year - int(e['datum'][:4])
- if jahre < 1:
- continue
- jahre_label = f"{jahre} Jahr" if jahre == 1 else f"{jahre} Jahren"
- send_push_to_user(e['user_id'], {
- 'type': 'anniversary_reminder',
- 'title': f'📅 Vor {jahre_label}: {(e["titel"] or "")[:40]}',
- 'body': 'Erinnerung an diesen besonderen Tag mit deinem Hund',
- 'data': {'page': 'diary'},
- 'tag': f'anniversary-{e["id"]}-{today.year}',
- })
- sent_total += 1
- except Exception as ex:
- logger.warning(f"Jahrestag-Reminder: Fehler für Eintrag {e['id']}: {ex}")
-
- logger.info(f"Jahrestags-Erinnerungen Job fertig — {len(entries)} Einträge geprüft, {sent_total} Push gesendet.")
- _log_job("anniversary_reminders", "ok", f"{sent_total} Push von {len(entries)} Einträgen")
-
-
-# ------------------------------------------------------------------
-# JOB: Monatlicher Rückblick (1. des Monats 10:00)
-# ------------------------------------------------------------------
-async def _job_monthly_recap():
- """Sendet jedem User am 1. des Monats einen Rückblick des Vormonats."""
- today = datetime.now(tz=_TZ)
- first_this = today.replace(day=1)
- last_month_end = first_this - timedelta(days=1)
- last_month_start = last_month_end.replace(day=1)
- year_str = last_month_start.strftime('%Y')
- month_str = last_month_start.strftime('%m')
- month_label = last_month_start.strftime('%B %Y')
-
- logger.info(f"Monatlicher Rückblick Job läuft für {month_label}")
-
- with db() as conn:
- # Alle User mit mindestens einem Hund
- users = conn.execute(
- "SELECT DISTINCT user_id FROM dogs"
- ).fetchall()
-
- sent_total = 0
- for u in users:
- user_id = u["user_id"]
- try:
- with db() as conn:
- # Hunde des Users
- dog_rows = conn.execute(
- "SELECT id, name FROM dogs WHERE user_id=?", (user_id,)
- ).fetchall()
- if not dog_rows:
- continue
-
- dog_ids = [d["id"] for d in dog_rows]
- placeholders = ','.join('?' * len(dog_ids))
-
- # km (Routen des Users im Vormonat)
- km_row = conn.execute(
- "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes "
- "WHERE user_id=? AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?",
- (user_id, year_str, month_str)
- ).fetchone()
- gesamt_km = km_row["km"] or 0.0
-
- # Tagebucheinträge
- eintraege = conn.execute(
- f"SELECT COUNT(*) AS n FROM diary "
- f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',datum)=? AND strftime('%m',datum)=?",
- (*dog_ids, year_str, month_str)
- ).fetchone()["n"]
-
- # Training-Sessions
- training = conn.execute(
- f"SELECT COUNT(*) AS n FROM training_sessions "
- f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?",
- (*dog_ids, year_str, month_str)
- ).fetchone()["n"]
-
- # Lieblingsfoto (erstes Foto im Vormonat)
- foto_row = conn.execute(
- f"SELECT dm.url FROM diary_media dm "
- f"JOIN diary d ON d.id=dm.diary_id "
- f"WHERE d.dog_id IN ({placeholders}) AND dm.media_type='image' "
- f"AND strftime('%Y',d.datum)=? AND strftime('%m',d.datum)=? "
- f"ORDER BY d.datum ASC LIMIT 1",
- (*dog_ids, year_str, month_str)
- ).fetchone()
- foto_url = foto_row["url"] if foto_row else None
-
- # Nur senden wenn mindestens eine Aktivität vorhanden
- if eintraege == 0 and training == 0 and gesamt_km == 0:
- continue
-
- dog_name = dog_rows[0]["name"]
- parts = []
- if gesamt_km > 0:
- parts.append(f"{gesamt_km} km gelaufen")
- if eintraege > 0:
- parts.append(f"{eintraege} Tagebucheintr{'ä' if True else 'a'}ge")
- if training > 0:
- parts.append(f"{training} Training-Sessions")
-
- body_text = " · ".join(parts)
-
- send_push_to_user(user_id, {
- 'type': 'monthly_recap',
- 'title': f'📅 {month_label}: Rückblick für {dog_name}',
- 'body': body_text,
- 'data': {'page': 'diary'},
- 'tag': f'monthly-recap-{year_str}-{month_str}',
- })
- sent_total += 1
- except Exception as ex:
- logger.error(f"Monatlicher Rückblick: Fehler für user {user_id}: {ex}")
-
- logger.info(f"Monatlicher Rückblick Job fertig — {len(users)} User geprüft, {sent_total} Push gesendet.")
- _log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}")
-
-
-async def _job_new_foto_challenge():
- """Jeden Montag 08:00 — neue Foto-Challenge für die aktuelle Woche anlegen."""
- from datetime import date, timedelta
- from routes.challenges import _CHALLENGE_THEMEN, _current_week_monday, _current_week_sunday
-
- monday = _current_week_monday()
- sunday = _current_week_sunday()
-
- week_num = date.today().isocalendar()[1]
- thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)]
-
- with db() as conn:
- existing = conn.execute(
- "SELECT id FROM foto_challenge WHERE start_date = ?", (monday,)
- ).fetchone()
- if existing:
- logger.info(f"Foto-Challenge: Woche {monday} bereits vorhanden (id={existing['id']}).")
- _log_job("new_foto_challenge", "ok", f"Bereits vorhanden für {monday}")
- return
-
- cur = conn.execute(
- "INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) "
- "VALUES (?, ?, ?, ?, NULL)",
- (thema, f"Diese Woche: {thema}", monday, sunday)
- )
- challenge_id = cur.lastrowid
-
- # Push an alle User
- send_push_to_all({
- "type": "foto_challenge",
- "title": "📸 Neue Foto-Challenge!",
- "body": f"Diese Woche: {thema} — mach mit!",
- "data": {"page": "walks", "tab": "challenge"},
- "tag": f"challenge-{monday}",
- })
-
- logger.info(f"Foto-Challenge angelegt: '{thema}' für {monday}–{sunday} (id={challenge_id}).")
- _log_job("new_foto_challenge", "ok", f"'{thema}' für {monday}")
-
-
-async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]:
- """Holt stündliche Wetterdaten für heute von Open-Meteo."""
- import httpx
- from datetime import date as _date
-
- today = _date.today().isoformat()
- url = (
- "https://api.open-meteo.com/v1/forecast"
- f"?latitude={lat}&longitude={lon}"
- "&hourly=temperature_2m,precipitation_probability,windspeed_10m"
- "&timezone=Europe%2FBerlin&forecast_days=1"
- )
- async with httpx.AsyncClient(timeout=8.0) as client:
- resp = await client.get(url)
- resp.raise_for_status()
- raw = resp.json()
-
- hourly = raw.get("hourly", {})
- times = hourly.get("time", [])
- temps = hourly.get("temperature_2m", [])
- precips = hourly.get("precipitation_probability", [])
- winds = hourly.get("windspeed_10m", [])
-
- result = []
- for i, ts in enumerate(times):
- if not ts.startswith(today):
- continue
- hour = int(ts[11:13])
- result.append({
- "hour": hour,
- "temp": temps[i] if i < len(temps) else None,
- "precip": precips[i] if i < len(precips) else None,
- "wind": winds[i] if i < len(winds) else None,
- })
- return result
-
-
-def _score_hour(h: dict) -> int:
- """Berechnet Gassi-Score für eine einzelne Stunde (0–10 Punkte)."""
- score = 0
- temp = h.get("temp")
- precip = h.get("precip")
- wind = h.get("wind")
- hour = h.get("hour", 12)
-
- # Temperatur
- if temp is not None:
- if 10 <= temp <= 20:
- score += 3
- elif 5 <= temp < 10 or 20 < temp <= 25:
- score += 1
-
- # Niederschlagswahrscheinlichkeit
- if precip is not None:
- if precip < 20:
- score += 3
- elif precip < 40:
- score += 1
-
- # Wind
- if wind is not None:
- if wind < 20:
- score += 2
- elif wind < 30:
- score += 1
-
- # Tageslicht (07–19 Uhr)
- if 7 <= hour <= 19:
- score += 2
-
- return score
-
-
-def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None, float | None]:
- """
- Findet das beste aufeinanderfolgende 2h-Fenster.
- Gibt (start_hour, total_score, avg_temp, avg_wind) zurück.
- """
- best_start = 8
- best_score = -1
- best_temp = None
- best_wind = None
-
- for i in range(len(hourly) - 1):
- h1 = hourly[i]
- h2 = hourly[i + 1]
- combined = _score_hour(h1) + _score_hour(h2)
- if combined > best_score:
- best_score = combined
- best_start = h1["hour"]
- # Durchschnittswerte für Anzeige
- temps = [x for x in [h1.get("temp"), h2.get("temp")] if x is not None]
- winds = [x for x in [h1.get("wind"), h2.get("wind")] if x is not None]
- best_temp = sum(temps) / len(temps) if temps else None
- best_wind = sum(winds) / len(winds) if winds else None
-
- return best_start, best_score, best_temp, best_wind
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index d3dcc37..27cf0d9 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -5571,139 +5571,6 @@ html.modal-open {
border-radius: 0;
}
-/* ── Wiki Gallery ────────────────────────────────────────── */
-.wiki-gallery-wrap {
- position: relative;
- margin-bottom: var(--space-3);
-}
-.wiki-gallery-main {
- width: 100%;
- height: 240px;
- object-fit: cover;
- object-position: center top;
- border-radius: var(--radius-lg);
- display: block;
-}
-.wiki-gallery-strip {
- display: flex;
- gap: var(--space-2);
- overflow-x: auto;
- padding: var(--space-2) 0 0;
- scrollbar-width: none;
-}
-.wiki-gallery-strip::-webkit-scrollbar { display: none; }
-.wiki-gallery-thumb {
- flex-shrink: 0;
- width: 64px; height: 64px;
- border-radius: var(--radius-md);
- overflow: hidden;
- border: 2px solid transparent;
- padding: 0;
- background: none;
- cursor: pointer;
- position: relative;
- transition: border-color .15s;
-}
-.wiki-gallery-thumb.active { border-color: var(--c-primary); }
-.wiki-gallery-thumb img {
- width: 100%; height: 100%; object-fit: cover;
-}
-.wiki-gallery-thumb-label {
- position: absolute;
- bottom: 0; left: 0; right: 0;
- background: rgba(0,0,0,.55);
- color: #fff;
- font-size: 8px;
- padding: 2px 4px;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-.wiki-gallery-expand {
- position: absolute;
- top: var(--space-2);
- right: var(--space-2);
- width: 34px; height: 34px;
- border-radius: 50%;
- background: rgba(0,0,0,.45);
- border: none;
- color: #fff;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- backdrop-filter: blur(4px);
- transition: background .15s;
-}
-.wiki-gallery-expand:hover { background: rgba(0,0,0,.65); }
-
-/* ── Wiki Lightbox ───────────────────────────────────────── */
-#wiki-lightbox {
- position: fixed;
- inset: 0;
- z-index: 2000;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-.wlb-backdrop {
- position: absolute;
- inset: 0;
- background: rgba(0,0,0,.88);
- backdrop-filter: blur(6px);
-}
-.wlb-content {
- position: relative;
- z-index: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- max-width: min(92vw, 680px);
- width: 100%;
- gap: var(--space-2);
-}
-.wlb-img {
- width: 100%;
- max-height: 72vh;
- object-fit: contain;
- border-radius: var(--radius-lg);
-}
-.wlb-close {
- position: absolute;
- top: -44px;
- right: 0;
- background: rgba(255,255,255,.12);
- border: none;
- color: #fff;
- width: 36px; height: 36px;
- border-radius: 50%;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-.wlb-prev, .wlb-next {
- position: absolute;
- top: 50%;
- transform: translateY(-50%);
- background: rgba(255,255,255,.12);
- border: none;
- color: #fff;
- width: 40px; height: 40px;
- border-radius: 50%;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 1.4rem;
- transition: background .15s;
-}
-.wlb-prev { left: -48px; }
-.wlb-next { right: -48px; }
-.wlb-prev:hover, .wlb-next:hover { background: rgba(255,255,255,.25); }
-.wlb-caption { color: rgba(255,255,255,.75); font-size: var(--text-sm); }
-.wlb-counter { color: rgba(255,255,255,.45); font-size: var(--text-xs); }
-
/* Steckbrief-Grid */
.wiki-steckbrief-grid {
display: grid;
@@ -6683,97 +6550,6 @@ html.modal-open {
/* ============================================================
HELP TOOLTIP
============================================================ */
-/* ============================================================
- PAGE INFO — generische Seiten-Hilfe (UI.pageInfo)
- ============================================================ */
-.pinfo-trigger-inline {
- width: 26px; height: 26px;
- border-radius: 50%;
- background: var(--c-surface-2);
- border: 1px solid var(--c-border-light);
- color: var(--c-text-secondary);
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- transition: background .15s, color .15s;
-}
-.pinfo-trigger-inline:hover { background: var(--c-primary-subtle, rgba(196,132,58,.1)); color: var(--c-primary); }
-
-.pinfo-banner {
- margin: var(--space-3) var(--space-4) 0;
- padding: var(--space-3) var(--space-4);
- border-radius: var(--radius-lg);
- background: var(--c-surface-2);
- border-left: 3px solid var(--c-primary);
- font-size: var(--text-sm);
-}
-.pinfo-banner-head {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- margin-bottom: var(--space-2);
-}
-.pinfo-banner-icon { color: var(--c-primary); flex-shrink: 0; }
-.pinfo-banner-title {
- flex: 1;
- font-weight: var(--weight-semibold);
- color: var(--c-text);
-}
-.pinfo-banner-close {
- background: none; border: none; cursor: pointer;
- color: var(--c-text-muted); padding: 2px;
-}
-.pinfo-banner-intro { color: var(--c-text-secondary); margin-bottom: var(--space-2); line-height: 1.5; }
-.pinfo-banner-more {
- background: none; border: none; cursor: pointer;
- color: var(--c-primary);
- font-size: var(--text-xs);
- font-weight: var(--weight-medium);
- padding: 0;
- display: flex;
- align-items: center;
- gap: 4px;
- margin-top: var(--space-2);
-}
-
-/* MODAL BODY */
-.pinfo-modal { display: flex; flex-direction: column; gap: var(--space-3); }
-.pinfo-intro { color: var(--c-text-secondary); line-height: 1.6; margin: 0; }
-.pinfo-steps { display: flex; flex-direction: column; gap: var(--space-3); }
-.pinfo-steps--compact { gap: var(--space-2); margin-top: var(--space-2); }
-.pinfo-step {
- display: flex;
- gap: var(--space-3);
- align-items: flex-start;
-}
-.pinfo-step-icon {
- width: 32px; height: 32px;
- border-radius: var(--radius-md);
- background: var(--c-primary-subtle, rgba(196,132,58,.12));
- color: var(--c-primary);
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
-}
-.pinfo-step-title { font-weight: var(--weight-semibold); color: var(--c-text); font-size: var(--text-sm); margin-bottom: 2px; }
-.pinfo-step-text { color: var(--c-text-secondary); font-size: var(--text-sm); line-height: 1.5; }
-.pinfo-tip {
- display: flex;
- gap: var(--space-2);
- align-items: flex-start;
- padding: var(--space-3);
- background: rgba(196,132,58,.08);
- border-radius: var(--radius-md);
- color: var(--c-text-secondary);
- font-size: var(--text-sm);
- line-height: 1.5;
-}
-.pinfo-tip .ph-icon { color: var(--c-primary); flex-shrink: 0; margin-top: 1px; }
-
-
.by-help-btn {
display: inline-flex;
align-items: center;
@@ -7178,7 +6954,7 @@ svg.empty-state-icon {
/* FAB */
.exp-fab {
position: fixed;
- bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + var(--space-2));
+ bottom: calc(var(--nav-height, 64px) + var(--space-4));
right: var(--space-4);
z-index: 100;
width: 52px;
@@ -7304,20 +7080,6 @@ svg.empty-state-icon {
color: var(--c-text-secondary);
line-height: 1.2;
}
-.exp-kachel-jahr {
- font-size: 9px;
- color: var(--c-text-muted);
- margin-top: 2px;
- line-height: 1.2;
-}
-.exp-kachel-add {
- display: flex;
- align-items: center;
- gap: 2px;
- font-size: 10px;
- color: var(--c-text-muted);
- margin-top: 3px;
-}
/* ---- Sektion-Block (Verlauf etc.) ---- */
.exp-section {
@@ -7493,36 +7255,6 @@ svg.empty-state-icon {
border-radius: 999px;
padding: 1px 6px;
}
-.exp-dog-selector {
- display: flex;
- gap: 8px;
- padding: 10px 16px 4px;
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: none;
-}
-.exp-dog-selector::-webkit-scrollbar { display: none; }
-.exp-dog-pill {
- flex-shrink: 0;
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 5px 14px;
- border-radius: 999px;
- border: 1px solid var(--c-border);
- background: var(--c-bg-card);
- color: var(--c-text-secondary);
- font-size: var(--text-sm);
- font-weight: var(--weight-medium);
- cursor: pointer;
- white-space: nowrap;
- transition: background .15s, color .15s, border-color .15s;
-}
-.exp-dog-pill.active {
- background: var(--c-primary);
- color: #fff;
- border-color: var(--c-primary);
-}
/* Rechte Spalte: Betrag + Löschen-Icon */
.exp-entry-right {
@@ -7868,28 +7600,10 @@ svg.empty-state-icon {
.wlabel.active { opacity: 1; }
@media (min-width: 768px) {
- #world-labels {
- gap: 40px;
- top: calc(env(safe-area-inset-top, 0px) + 18px);
- }
- .wlabel {
- font-size: 13px;
- letter-spacing: 0.18em;
- opacity: 0.55;
- padding: 6px 14px;
- border-radius: 20px;
- text-shadow: 0 1px 6px rgba(0,0,0,0.7);
- transition: opacity 0.18s, background 0.18s;
- }
- .wlabel:hover {
- opacity: 0.85;
- background: rgba(255, 255, 255, 0.12);
- }
- .wlabel.active {
- opacity: 1;
- background: rgba(255, 255, 255, 0.18);
- text-shadow: 0 1px 8px rgba(0,0,0,0.5);
- }
+ #world-labels { gap: 48px; font-size: 11px; }
+ .wlabel { opacity: 0.5; padding: 4px 10px; border-radius: 8px; }
+ .wlabel:hover { opacity: 0.8; background: rgba(255,255,255,0.08); }
+ .wlabel.active { opacity: 1; background: rgba(255,255,255,0.12); }
}
/* Settings-Button */
@@ -8061,35 +7775,28 @@ svg.empty-state-icon {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 16px;
- padding: 12px 6px;
+ padding: 14px 6px 11px;
text-align: center;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
- justify-content: center;
- gap: 6px;
+ gap: 7px;
color: white;
transition: background 0.12s, transform 0.1s;
-webkit-tap-highlight-color: transparent;
user-select: none;
- min-height: 80px; /* alle Chips gleich hoch */
}
.world-chip:active {
background: rgba(0, 0, 0, 0.6);
transform: scale(0.93);
}
-.world-chip svg { color: white; flex-shrink: 0; }
+.world-chip svg { color: white; }
.world-chip-label {
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 1.2;
- max-height: 2.4em; /* max. 2 Zeilen */
- overflow: hidden;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
}
/* Chip-Umrandung je Welt */
@@ -8184,189 +7891,3 @@ svg.empty-state-icon {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.25; }
}
-
-/* ── COMMUNITY-FEATURES ──────────────────────────────────── */
-
-/* Walks-Tab-Bar */
-.walks-tab-panel { display: flex; flex-direction: column; min-height: 0; flex: 1; }
-
-/* Foto-Challenge */
-.challenge-banner {
- background: linear-gradient(135deg, var(--c-amber, #f59e0b), var(--c-primary, #C4843A));
- border-radius: var(--radius-lg);
- margin: var(--space-4);
- overflow: hidden;
-}
-.challenge-banner-inner {
- padding: var(--space-5) var(--space-4);
- color: #fff;
-}
-.challenge-thema {
- font-size: var(--text-xl);
- font-weight: var(--weight-bold);
- line-height: 1.2;
- margin-bottom: var(--space-2);
-}
-.challenge-meta {
- font-size: var(--text-sm);
- opacity: 0.88;
-}
-.challenge-gallery {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
- gap: var(--space-3);
- padding: 0 var(--space-4) var(--space-6);
-}
-.challenge-sub-card {
- background: var(--c-surface);
- border-radius: var(--radius-md);
- overflow: hidden;
- box-shadow: var(--shadow-sm);
-}
-.challenge-sub-card img {
- width: 100%;
- aspect-ratio: 1;
- object-fit: cover;
- display: block;
- cursor: pointer;
-}
-.challenge-sub-info {
- padding: var(--space-2);
-}
-.challenge-sub-user {
- font-size: var(--text-xs);
- color: var(--c-text-secondary);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- margin-bottom: 2px;
-}
-.challenge-sub-caption {
- font-size: var(--text-xs);
- color: var(--c-text);
- margin-bottom: var(--space-1);
- overflow: hidden;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
-}
-.challenge-vote-btn {
- border: none;
- background: transparent;
- color: var(--c-text-secondary);
- font-size: var(--text-xs);
- cursor: pointer;
- padding: 2px 6px;
- border-radius: var(--radius-sm);
- display: flex;
- align-items: center;
- gap: 3px;
-}
-.challenge-vote-btn.voted {
- color: var(--c-danger, #ef4444);
-}
-.challenge-winners { border-top: 1px solid var(--c-border); }
-.challenge-winners-row {
- display: flex;
- gap: var(--space-3);
- overflow-x: auto;
- padding: var(--space-2) var(--space-4) var(--space-3);
- scroll-snap-type: x mandatory;
-}
-.challenge-winner-chip {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- background: var(--c-surface-alt, #fdf6ef);
- border-radius: var(--radius-md);
- padding: var(--space-2) var(--space-3);
- min-width: 160px;
- flex-shrink: 0;
- scroll-snap-align: start;
-}
-.challenge-winner-chip img {
- width: 40px;
- height: 40px;
- border-radius: var(--radius-full);
- object-fit: cover;
- flex-shrink: 0;
-}
-
-/* Wochentag-Selector */
-.wd-selector {
- display: flex;
- gap: var(--space-2);
- flex-wrap: wrap;
-}
-.wd-btn {
- display: flex;
- align-items: center;
- gap: 4px;
- cursor: pointer;
- padding: 4px 10px;
- border: 1.5px solid var(--c-border);
- border-radius: var(--radius-full);
- font-size: var(--text-sm);
- user-select: none;
- transition: background .15s, border-color .15s;
-}
-.wd-btn input { display: none; }
-.wd-btn:has(input:checked) {
- background: var(--c-primary, #C4843A);
- border-color: var(--c-primary, #C4843A);
- color: #fff;
-}
-
-/* Gassi-Zeit-Karten */
-.gassi-zeit-card {
- display: flex;
- align-items: center;
- gap: var(--space-3);
- padding: var(--space-3) var(--space-4);
- border-bottom: 1px solid var(--c-border);
- background: var(--c-surface);
-}
-.gz-avatar {
- width: 48px;
- height: 48px;
- border-radius: var(--radius-full);
- overflow: hidden;
- flex-shrink: 0;
- background: var(--c-surface-alt);
- display: flex;
- align-items: center;
- justify-content: center;
-}
-.gz-avatar img { width: 100%; height: 100%; object-fit: cover; }
-.gz-avatar-placeholder { font-size: 1.5rem; color: var(--c-text-secondary); }
-.gz-body { flex: 1; min-width: 0; }
-.gz-name {
- font-weight: var(--weight-semibold);
- font-size: var(--text-sm);
- display: flex;
- align-items: center;
- gap: var(--space-1);
- flex-wrap: wrap;
-}
-.gz-meta { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; }
-.gz-notiz { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; font-style: italic; }
-.gz-actions { display: flex; gap: var(--space-1); flex-shrink: 0; }
-
-/* Rassen-Community-Chip */
-.breed-community-chip {
- display: inline-flex;
- align-items: center;
- gap: var(--space-1);
- background: var(--c-surface-alt, #fdf6ef);
- border: 1.5px solid var(--c-amber, #f59e0b);
- border-radius: var(--radius-full);
- padding: 6px 16px;
- font-size: var(--text-sm);
- font-weight: var(--weight-medium);
- color: var(--c-text);
- cursor: pointer;
- transition: background .15s;
-}
-.breed-community-chip:hover, .breed-community-chip:active {
- background: #fff3e0;
-}
diff --git a/backend/static/img/banyaro/fruehling_playdate.webp b/backend/static/img/banyaro/fruehling_playdate.webp
deleted file mode 100644
index 7defe9e..0000000
Binary files a/backend/static/img/banyaro/fruehling_playdate.webp and /dev/null differ
diff --git a/backend/static/img/banyaro/herbst_bach.webp b/backend/static/img/banyaro/herbst_bach.webp
deleted file mode 100644
index 5b7a594..0000000
Binary files a/backend/static/img/banyaro/herbst_bach.webp and /dev/null differ
diff --git a/backend/static/img/banyaro/herbst_baum.webp b/backend/static/img/banyaro/herbst_baum.webp
deleted file mode 100644
index 4ea4312..0000000
Binary files a/backend/static/img/banyaro/herbst_baum.webp and /dev/null differ
diff --git a/backend/static/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg b/backend/static/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg
deleted file mode 100644
index eb4e55a..0000000
Binary files a/backend/static/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg and /dev/null differ
diff --git a/backend/static/img/banyaro/hires/banyaro_herbst_bach_hires.jpg b/backend/static/img/banyaro/hires/banyaro_herbst_bach_hires.jpg
deleted file mode 100644
index aab4923..0000000
Binary files a/backend/static/img/banyaro/hires/banyaro_herbst_bach_hires.jpg and /dev/null differ
diff --git a/backend/static/img/banyaro/hires/banyaro_herbst_baum_hires.jpg b/backend/static/img/banyaro/hires/banyaro_herbst_baum_hires.jpg
deleted file mode 100644
index d14e80d..0000000
Binary files a/backend/static/img/banyaro/hires/banyaro_herbst_baum_hires.jpg and /dev/null differ
diff --git a/backend/static/img/banyaro/hires/banyaro_winter_schnee_hires.jpg b/backend/static/img/banyaro/hires/banyaro_winter_schnee_hires.jpg
deleted file mode 100644
index 155d65b..0000000
Binary files a/backend/static/img/banyaro/hires/banyaro_winter_schnee_hires.jpg and /dev/null differ
diff --git a/backend/static/img/banyaro/winter_schnee.webp b/backend/static/img/banyaro/winter_schnee.webp
deleted file mode 100644
index 87269d4..0000000
Binary files a/backend/static/img/banyaro/winter_schnee.webp and /dev/null differ
diff --git a/backend/static/index.html b/backend/static/index.html
index 242e2b7..cb75a8f 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -9,6 +9,7 @@
+
@@ -75,7 +76,6 @@
-
@@ -93,9 +93,9 @@
-
-
-
+
+
+
@@ -499,18 +499,6 @@
-
-
-
-
-
-
@@ -551,6 +539,9 @@
HUND
WELT
+
@@ -574,12 +565,12 @@
-
+
-
+
@@ -676,6 +667,5 @@
}
-