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 1d23aef..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,7 +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
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -291,7 +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"])
# ------------------------------------------------------------------
@@ -321,27 +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.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/routes/achievements.py b/backend/routes/achievements.py
index 0a2988e..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,48 +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
- 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
- )
- """, (user_id,)).fetchone()
-
- # Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen
- 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)
- """, (user_id, user_id, user_id, user_id)).fetchone()
-
- # Schnee: Diary-Einträge bei Schnee (weathercode 71-77)
- 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
- """, (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
@@ -300,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()
@@ -351,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/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/osm.py b/backend/routes/osm.py
index de4cb45..e08742b 100644
--- a/backend/routes/osm.py
+++ b/backend/routes/osm.py
@@ -279,9 +279,8 @@ class UserPoiIn(BaseModel):
ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park',
- 'giftkoeder', # Giftköder (exklusiv, kein Kombi)
+ 'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius)
'kotbeutel', # Kotbeutelspender
- 'bank', # Sitzbank
'gefahr', # Allgemeine Gefahr / Hinweis
'parkplatz', # Hundefreundlicher Parkplatz
'treffpunkt', # Treffpunkt für Hundehalter
@@ -290,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 ba45306..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,57 +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
- WHERE d.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 4af2473..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, random
-from datetime import date
-from fastapi import APIRouter, Depends, Query
-from typing import Optional
+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."""
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 4d1dbff..4aeb89a 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -156,32 +156,8 @@ def start():
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. 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():
@@ -905,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 = ""
@@ -1315,329 +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 _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 c8de6a6..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;
@@ -7824,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 */
diff --git a/backend/static/index.html b/backend/static/index.html
index 77f0433..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 @@
-
+
-
+
diff --git a/backend/static/js/api.js b/backend/static/js/api.js
index 1071fdd..c6b26da 100644
--- a/backend/static/js/api.js
+++ b/backend/static/js/api.js
@@ -212,9 +212,6 @@ const API = (() => {
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
myFavorite() { return get('/tieraerzte/my-favorite'); },
toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); },
- bewertungen(id) { return get(`/tieraerzte/${id}/bewertungen`); },
- meineBewertung(id) { return get(`/tieraerzte/${id}/meine-bewertung`); },
- bewertungAbgeben(id, data) { return post(`/tieraerzte/${id}/bewertung`, data); },
};
// ----------------------------------------------------------
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index deb05c5..a248787 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '700'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
@@ -76,9 +76,6 @@ const App = (() => {
adoption: { title: 'Adoption', module: null },
playdate: { title: 'Playdate', module: null, requiresAuth: true },
wetter: { title: 'Wetter', module: null },
- ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true },
- personality: { title: 'Persönlichkeitstest', module: null },
- reise: { title: 'Reise mit Hund', module: null },
};
// ----------------------------------------------------------
diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js
index 66d9150..a39eccd 100644
--- a/backend/static/js/pages/diary.js
+++ b/backend/static/js/pages/diary.js
@@ -868,9 +868,9 @@ window.Page_diary = (() => {
if (e.weather_json) {
try {
const w = typeof e.weather_json === 'string' ? JSON.parse(e.weather_json) : e.weather_json;
- const temp = w?.temp_c ?? w?.temperature_2m;
+ const temp = w?.temperature_2m ?? w?.temp_c;
if (temp != null) {
- metaParts.push(`
${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°`);
+ metaParts.push(`
${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°`);
}
} catch (_) {}
}
@@ -1073,14 +1073,15 @@ window.Page_diary = (() => {
if (entry.weather_json) {
try {
const w = typeof entry.weather_json === 'string' ? JSON.parse(entry.weather_json) : entry.weather_json;
- const temp = w?.temp_c ?? w?.temperature_2m;
+ const temp = w?.temperature_2m ?? w?.temp_c;
if (w && temp != null) {
- const wind = w.wind_kmh ?? w.wind_speed_10m;
- const precip = w.precip_prob;
+ const feels = w.apparent_temperature ?? w.feels_like_c;
+ const wind = w.wind_speed_10m ?? w.wind_kmh;
const parts = [
- `${_weatherEmoji(w.weathercode ?? w.weather_code, w.is_day)} ${Math.round(temp)}°C`,
- wind != null ? `${Math.round(wind)} km/h Wind` : null,
- precip != null ? `${precip}% Regen` : null,
+ `${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°C`,
+ feels != null ? `gefühlt ${Math.round(feels)}°` : null,
+ wind != null ? `💨 ${Math.round(wind)} km/h` : null,
+ w.relative_humidity_2m != null ? `💧 ${w.relative_humidity_2m}%` : null,
].filter(Boolean).join(' · ');
metaItems.push(`
${parts}`);
}
@@ -1727,16 +1728,6 @@ window.Page_diary = (() => {
});
await UI.asyncButton(submitBtn, async () => {
- // Auto-Wetter: nur bei neuem Eintrag ohne GPS-Standort
- let _clientWeather = null;
- if (!isEdit && _locLat == null) {
- try {
- const pos = await API.getLocation();
- const wd = await API.weather.get(pos.lat, pos.lon);
- if (wd && wd.temp_c != null) _clientWeather = JSON.stringify(wd);
- } catch (_) { /* GPS oder Wetter nicht verfügbar → kein Problem */ }
- }
-
const payload = {
datum: fd.datum || null,
typ: fd.typ,
@@ -1748,7 +1739,6 @@ window.Page_diary = (() => {
gps_lon: _locLon,
location_name: _locName,
client_time: API.clientNow(),
- weather_json: _clientWeather,
};
async function _uploadNewFiles(entryId) {
diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js
index 09b729a..82a2e8a 100644
--- a/backend/static/js/pages/dog-profile.js
+++ b/backend/static/js/pages/dog-profile.js
@@ -195,27 +195,9 @@ window.Page_dog_profile = (() => {
Hundepass
` : ''}
- ${!dog.is_guest ? `
` : ''}
${!dog.is_guest ? `
` : ''}
- ${!dog.is_guest ? `
` : ''}
- ${!dog.is_guest ? `
` : ''}
- ${!dog.is_guest ? `
` : ''}
@@ -282,22 +264,6 @@ window.Page_dog_profile = (() => {
_showPassportModal(dog);
});
- document.getElementById('dp-vcard-btn')?.addEventListener('click', () => {
- _showVcardModal(dog);
- });
-
- document.getElementById('dp-wrapped-btn')?.addEventListener('click', () => {
- _showWrappedModal(dog);
- });
-
- document.getElementById('dp-buch-btn')?.addEventListener('click', () => {
- _showBuchModal(dog);
- });
-
- document.getElementById('dp-timeline-btn')?.addEventListener('click', () => {
- _showTimelineModal(dog);
- });
-
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
}
@@ -784,138 +750,6 @@ window.Page_dog_profile = (() => {
// ----------------------------------------------------------
// TEILEN
// ----------------------------------------------------------
- // ----------------------------------------------------------
- // HUNDE-VISITENKARTE MIT QR-CODE
- // ----------------------------------------------------------
- function _showVcardModal(dog) {
- const passportUrl = `https://banyaro.app/hund/${dog.id}`;
- const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&color=ffffff&bgcolor=1a2035&data=${encodeURIComponent(passportUrl)}`;
-
- const user = _appState?.user;
- const ownerName = user?.name || '';
- const wohnort = user?.wohnort || '';
-
- // Alter errechnen
- let alterStr = '';
- if (dog.geburtstag) {
- const birth = new Date(dog.geburtstag + 'T00:00:00');
- const now = new Date();
- const years = now.getFullYear() - birth.getFullYear()
- - (now < new Date(now.getFullYear(), birth.getMonth(), birth.getDate()) ? 1 : 0);
- alterStr = years < 1
- ? `${Math.max(1, Math.round((now - birth) / (30.5 * 86400000)))} Monate`
- : years === 1 ? '1 Jahr' : `${years} Jahre`;
- }
-
- const metaLine = [dog.rasse, alterStr].filter(Boolean).join(' · ');
-
- const cardHtml = `
-
-
-
-
-
-
-
-
- ${dog.foto_url
- ? `
})
`
- : `
🐾
`}
-
-
${_esc(dog.name)}
- ${metaLine ? `
${_esc(metaLine)}
` : ''}
- ${wohnort ? `
📍 ${_esc(wohnort)}
` : ''}
-
-
-
-
-
-
-
-
-
- ${ownerName ? `
Besitzer
-
${_esc(ownerName)}
` : ''}
-
banyaro.app
-
-
-
})
-
Profil öffnen
-
-
-
- `;
-
- UI.modal.open({
- title: 'Visitenkarte',
- body: `
- ${cardHtml}
-
- QR-Code auf NFC-Tag oder Anhänger kleben — jeder kann das Profil von ${_esc(dog.name)} sofort öffnen.
-
- `,
- footer: `
-
-
- `,
- });
-
- // Link kopieren
- document.getElementById('dp-vcard-copy-btn')?.addEventListener('click', async () => {
- try {
- await navigator.clipboard.writeText(passportUrl);
- UI.toast.success('Link kopiert!');
- } catch {
- const inp = document.createElement('input');
- inp.value = passportUrl;
- document.body.appendChild(inp);
- inp.select();
- document.execCommand('copy');
- inp.remove();
- UI.toast.success('Link kopiert!');
- }
- });
-
- // Native Share API
- document.getElementById('dp-vcard-share-btn')?.addEventListener('click', async () => {
- if (navigator.share) {
- try {
- await navigator.share({
- title: `${dog.name} auf Ban Yaro`,
- text: `Schau dir das Profil von ${dog.name} an!`,
- url: passportUrl,
- });
- } catch {}
- } else {
- // Fallback: kopieren
- try {
- await navigator.clipboard.writeText(passportUrl);
- UI.toast.success('Link kopiert!');
- } catch {
- UI.toast.error('Teilen nicht verfügbar.');
- }
- }
- });
- }
-
async function _showShareModal(dog) {
UI.modal.open({
title: `${_esc(dog.name)} teilen`,
@@ -1136,23 +970,6 @@ window.Page_dog_profile = (() => {
-
-
-
-
-
@@ -1756,226 +1716,6 @@ window.Page_health = (() => {
`;
}
- // ----------------------------------------------------------
- // PRAXEN — Sterne-Hilfs-Funktionen
- // ----------------------------------------------------------
-
- /** Rendert 5 Sterne (readonly, filled bis `rating`). */
- function _renderStarsReadonly(rating) {
- const full = Math.round(rating);
- return Array.from({ length: 5 }, (_, i) => {
- const filled = i < full;
- return `★`;
- }).join('');
- }
-
- /** Rendert 5 klickbare Sterne mit data-val. */
- function _renderStarsInput(name, current) {
- return `
- ${Array.from({ length: 5 }, (_, i) => {
- const val = i + 1;
- const filled = current >= val;
- return `★`;
- }).join('')}
-
`;
- }
-
- // ----------------------------------------------------------
- // PRAXEN — Detail-Modal (Bewertungen anzeigen)
- // ----------------------------------------------------------
- async function _showPraxisDetail(praxis) {
- // Erst mit Lade-Spinner öffnen, dann Daten laden
- UI.modal.open({
- title: _esc(praxis.name),
- body: `
-
-
`,
- footer: `
- `,
- });
-
- document.getElementById('detail-bewerten-btn')
- ?.addEventListener('click', () => { UI.modal.close(); _showBewertungModal(praxis); });
-
- let data;
- try {
- data = await API.tieraerzte.bewertungen(praxis.id);
- } catch {
- UI.modal.open({ title: praxis.name, body: 'Bewertungen konnten nicht geladen werden.
' });
- return;
- }
-
- const { avg_rating, anz_bewertungen, verteilung, kommentare } = data;
-
- // Balkendiagramm
- const balken = [5, 4, 3, 2, 1].map(s => {
- const n = verteilung[String(s)] || 0;
- const pct = anz_bewertungen > 0 ? Math.round((n / anz_bewertungen) * 100) : 0;
- return ``;
- }).join('');
-
- const kommentarHtml = kommentare.length
- ? kommentare.map(k => `
-
-
- ${_renderStarsReadonly(k.gesamt)}
-
- ${k.created_at ? k.created_at.slice(0, 10) : ''}
-
-
- ${k.wartezeit || k.freundlichkeit || k.kompetenz ? `
-
- ${k.wartezeit ? `Wartezeit: ${_renderStarsReadonly(k.wartezeit)}` : ''}
- ${k.freundlichkeit ? `Freundlichkeit: ${_renderStarsReadonly(k.freundlichkeit)}` : ''}
- ${k.kompetenz ? `Kompetenz: ${_renderStarsReadonly(k.kompetenz)}` : ''}
-
` : ''}
-
${_esc(k.text || '')}
-
`).join('')
- : `Noch keine Kommentare.
`;
-
- const bewBody = anz_bewertungen === 0
- ? `
- Noch keine Bewertungen — sei der Erste!
-
`
- : `
-
-
-
${avg_rating.toFixed(1)}
-
${_renderStarsReadonly(avg_rating)}
-
${anz_bewertungen} Bewertung${anz_bewertungen !== 1 ? 'en' : ''}
-
-
${balken}
-
- ${kommentarHtml}
`;
-
- // Modal-Body aktualisieren (ohne Modal neu zu öffnen)
- const modalBody = document.querySelector('.modal-body');
- if (modalBody) modalBody.innerHTML = bewBody;
- }
-
- // ----------------------------------------------------------
- // PRAXEN — Bewertungs-Modal
- // ----------------------------------------------------------
- async function _showBewertungModal(praxis) {
- // Ggf. bestehende Bewertung laden
- let existing = null;
- try { existing = await API.tieraerzte.meineBewertung(praxis.id); } catch { /* ok */ }
-
- const cur = existing || {};
-
- const body = `
- `;
-
- UI.modal.open({
- title: `${_esc(praxis.name)} bewerten`,
- body,
- footer: `
-
- `,
- });
-
- // Sterne-Interaktion
- document.querySelectorAll('.bew-stars').forEach(group => {
- const name = group.dataset.name;
- const hidden = document.getElementById(`bew-${name}`);
- const stars = group.querySelectorAll('.bew-star');
-
- const paint = val => {
- stars.forEach(s => {
- s.style.color = parseInt(s.dataset.val) <= val
- ? 'var(--c-warning,#f59e0b)' : 'var(--c-border)';
- });
- };
-
- stars.forEach(s => {
- s.addEventListener('mouseover', () => paint(parseInt(s.dataset.val)));
- s.addEventListener('mouseleave', () => paint(parseInt(hidden.value)));
- s.addEventListener('click', () => {
- hidden.value = s.dataset.val;
- paint(parseInt(s.dataset.val));
- });
- });
-
- paint(parseInt(hidden.value));
- });
-
- // Submit
- document.getElementById('bew-submit-btn').addEventListener('click', async (e) => {
- e.preventDefault();
- const form = document.getElementById('bew-form');
- const gesamt = parseInt(document.getElementById('bew-gesamt').value);
- if (!gesamt) { UI.toast.error('Bitte vergib mindestens einen Stern für den Gesamteindruck.'); return; }
-
- const payload = { gesamt };
- const wz = parseInt(document.getElementById('bew-wartezeit').value);
- const fr = parseInt(document.getElementById('bew-freundlichkeit').value);
- const ko = parseInt(document.getElementById('bew-kompetenz').value);
- if (wz) payload.wartezeit = wz;
- if (fr) payload.freundlichkeit = fr;
- if (ko) payload.kompetenz = ko;
- const txt = form.querySelector('textarea[name="text"]').value.trim();
- if (txt) payload.text = txt;
-
- await UI.asyncButton(document.getElementById('bew-submit-btn'), async () => {
- const saved = await API.tieraerzte.bewertungAbgeben(praxis.id, payload);
- // _praxen-Cache aktualisieren
- _praxen = _praxen.map(p =>
- p.id === praxis.id
- ? { ...p, avg_rating: saved.avg_rating, anz_bewertungen: saved.anz_bewertungen }
- : p
- );
- UI.modal.close();
- UI.toast.success('Bewertung gespeichert.');
- _renderTab();
- });
- });
- }
-
// ----------------------------------------------------------
// PRAXEN — Formular (Neu / Bearbeiten)
// ----------------------------------------------------------
diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js
index fd15e1d..ded9a7d 100644
--- a/backend/static/js/pages/map.js
+++ b/backend/static/js/pages/map.js
@@ -838,17 +838,15 @@ window.Page_map = (() => {
_tempMarker = null;
}
- // Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv)
const PIN_TYPES = [
- { type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626', exclusive: true },
- { type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' },
- { type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' },
- { type: 'bank', icon: '', label: 'Sitzbank', color: '#92400E' },
- { type: 'drinking_water',icon: '', label: 'Wasserstelle',color: '#0EA5E9' },
- { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' },
- { type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' },
- { type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' },
- { type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' },
+ { type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben
+ { type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' },
+ { type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' },
+ { type: 'drinking_water', icon: '', label: 'Wasserstelle', color: '#0EA5E9' },
+ { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' },
+ { type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' },
+ { type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' },
+ { type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' },
];
function _confirmPlacement(latlng) {
@@ -857,18 +855,18 @@ window.Page_map = (() => {
radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6,
}).addTo(_map);
- let _selectedTypes = new Set(['giftkoeder']);
+ let _selectedType = 'giftkoeder';
UI.modal.open({
title: ' Marker setzen',
body: `