diff --git a/Makefile b/Makefile
index 2427674..692e7bb 100644
--- a/Makefile
+++ b/Makefile
@@ -27,7 +27,7 @@ TAR_EXCLUDE := --exclude='.git' \
--exclude='./.DS_Store'
.PHONY: help deploy deploy-clean staging release sync push restart build stop status \
- logs logs-f shell db dev clean-cache check-ssh
+ logs logs-f shell db dev clean-cache check-ssh reports
# ----------------------------------------------------------
# SSH-Prüfung — Abhängigkeit aller DS-Befehle
@@ -66,6 +66,7 @@ help:
@echo ""
@echo " make dev Lokaler Dev-Server auf Mac (Port 8001)"
@echo " make clean-cache SW-Cache-Version erhöhen + restart"
+ @echo " make reports Quartalsberichte generieren + committen"
@echo ""
# ----------------------------------------------------------
@@ -127,6 +128,17 @@ staging: check-ssh
@echo " ✓ Staging fertig — https://staging.banyaro.app"
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_STAGING) --tail=10"
+# ----------------------------------------------------------
+# STAGING-DB — Produktions-DB in Staging kopieren (interaktiv, braucht sudo)
+# Aufruf: make staging-db
+# ----------------------------------------------------------
+staging-db: check-ssh
+ @echo "→ Produktions-DB nach Staging kopieren..."
+ @ssh -t $(DS_HOST) " \
+ sudo cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db && \
+ sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db && \
+ echo '✓ DB kopiert'"
+
# ----------------------------------------------------------
# RELEASE — develop → main → Production (VERSION= pflichtangabe)
# Beispiel: make release VERSION=1.1.0
@@ -235,6 +247,31 @@ dev:
DB_PATH=./dev.db \
uvicorn main:app --reload --port 8001
+# ----------------------------------------------------------
+# REPORTS — Quartalsberichte generieren und committen
+# Berichte laufen im Container (DB-Zugriff), werden lokal gespeichert
+# ----------------------------------------------------------
+REPORT_DATE := $(shell date +%Y-%m-%d)
+REPORT_SECTIONS := sicherheit funktionsumfang dateien nutzer partner server
+
+reports: check-ssh
+ @mkdir -p reports
+ @echo "→ Berichte generieren ($(REPORT_DATE))..."
+ @for section in $(REPORT_SECTIONS); do \
+ echo " → $$section..."; \
+ ssh $(DS_HOST) "$(DOCKER) exec $(CONTAINER) python3 scripts/generate_reports.py $$section" \
+ > reports/$(REPORT_DATE)-$$section.md; \
+ done
+ @echo "→ Berichte committen..."
+ @git add reports/
+ @git diff --cached --quiet || git commit -m "Reports $(REPORT_DATE) — Quartalsbericht"
+ @echo ""
+ @echo " ✓ Alle Berichte erstellt und committed:"
+ @for section in $(REPORT_SECTIONS); do \
+ echo " reports/$(REPORT_DATE)-$$section.md"; \
+ done
+
+
# ----------------------------------------------------------
# CACHE leeren — SW-Version erhöhen, dann restart
# Nach größeren CSS/JS-Änderungen wenn SW gecacht hat
diff --git a/backend/auth.py b/backend/auth.py
index b2736f5..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, breeder_status, is_founder, is_partner, founder_number, email_verified 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()
@@ -131,7 +131,10 @@ def require_admin(user=Depends(get_current_user)):
def require_social_media(user=Depends(get_current_user)):
- """Dependency: Social-Media-Manager oder Admin."""
- if not (user.get("is_social_media") or user["rolle"] == "admin"):
+ """Dependency: Social-Media-Manager, Luna-Probezugang oder Admin."""
+ from datetime import datetime as _dt
+ trial = user.get("luna_trial_until")
+ trial_active = bool(trial and _dt.utcnow().isoformat() < trial)
+ if not (user.get("is_social_media") or user["rolle"] == "admin" or trial_active):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
return user
diff --git a/backend/content_filter.py b/backend/content_filter.py
new file mode 100644
index 0000000..e094253
--- /dev/null
+++ b/backend/content_filter.py
@@ -0,0 +1,63 @@
+"""BAN YARO — Content-Filter für Spam und Missbrauch im Forum."""
+
+import re
+from datetime import datetime, timedelta, timezone
+from fastapi import HTTPException
+
+# Offensichtliche Spam-Signale
+_SPAM_KEYWORDS = [
+ "casino", "poker", "slots", "jackpot", "sportwetten",
+ "viagra", "cialis", "levitra", "pharmacy", "apotheke online",
+ "kreditkarte sofort", "kredit ohne schufa", "schnell geld verdienen",
+ "passive income", "work from home", "earn money fast",
+ "click here", "klick hier", "free followers", "buy followers",
+ "whatsapp +", "telegram +", "call now", "jetzt anrufen",
+ "seo service", "backlinks kaufen", "website traffic",
+ "crypto invest", "bitcoin verdienen", "nft mint",
+ "lose weight fast", "abnehmen schnell", "diät pille",
+]
+
+# URL-Muster (http/https oder nackte Domains)
+_URL_RE = re.compile(
+ r"(https?://|www\.|\b[a-zA-Z0-9-]+\.(de|com|net|org|io|app|shop|info|biz|ru|cn)\b)",
+ re.IGNORECASE,
+)
+
+# Mindest-Account-Alter für URL-Posts (Tage)
+_MIN_DAYS_FOR_URLS = 7
+
+
+def check_forum_content(text: str, user_created_at: str | None = None) -> None:
+ """
+ Prüft Forum-Text auf Spam.
+ Wirft HTTPException(400) bei Fund.
+ """
+ lower = text.lower()
+
+ # Spam-Keywords
+ for kw in _SPAM_KEYWORDS:
+ if kw in lower:
+ raise HTTPException(400, "Dein Beitrag wurde als Spam erkannt und nicht gespeichert.")
+
+ # URLs in neuen Accounts sperren
+ if _URL_RE.search(text):
+ if user_created_at:
+ try:
+ created = datetime.fromisoformat(user_created_at)
+ if created.tzinfo is None:
+ created = created.replace(tzinfo=timezone.utc)
+ age = datetime.now(timezone.utc) - created
+ if age < timedelta(days=_MIN_DAYS_FOR_URLS):
+ raise HTTPException(
+ 400,
+ "Links können erst nach 7 Tagen Mitgliedschaft gepostet werden."
+ )
+ except (ValueError, TypeError):
+ pass
+
+ # Zu viele Sonderzeichen / Zeichensalat
+ if len(text) > 20:
+ alnum = sum(c.isalnum() or c.isspace() for c in text)
+ ratio = alnum / len(text)
+ if ratio < 0.5:
+ raise HTTPException(400, "Dein Beitrag enthält zu viele Sonderzeichen.")
diff --git a/backend/database.py b/backend/database.py
index 5ea9f4a..eeb1add 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -701,7 +701,28 @@ def _migrate(conn_factory):
CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC);
""")
- # Hunde-Filme: Bewertungen + Hund des Monats
+ # Hunde-Filme: Katalog + Bewertungen + Hund des Monats
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS movies (
+ id TEXT PRIMARY KEY,
+ titel TEXT NOT NULL,
+ originaltitel TEXT,
+ jahr INTEGER,
+ genre TEXT,
+ typ TEXT NOT NULL DEFAULT 'film',
+ hund_rasse TEXT,
+ stirbt_der_hund INTEGER NOT NULL DEFAULT 0,
+ beschreibung TEXT,
+ bild_emoji TEXT DEFAULT '🐾',
+ imdb_rating REAL,
+ streaming TEXT,
+ sort_order INTEGER DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_movies_typ ON movies(typ);
+ CREATE INDEX IF NOT EXISTS idx_movies_jahr ON movies(jahr DESC);
+ """)
+
conn.executescript("""
CREATE TABLE IF NOT EXISTS movie_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1051,6 +1072,19 @@ def _migrate(conn_factory):
pass
logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.")
+ # Moderation-Logging: resolved_by/at für forum_reports, verified_by/at/reject für wiki_zuchter
+ for table, col, typedef in [
+ ("forum_reports", "resolved_by", "INTEGER"),
+ ("forum_reports", "resolved_at", "TEXT"),
+ ("wiki_zuchter", "verified_by", "INTEGER"),
+ ("wiki_zuchter", "verified_at", "TEXT"),
+ ("wiki_zuchter", "reject_reason", "TEXT"),
+ ]:
+ try:
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
+ except Exception:
+ pass
+
# Wiki: Züchter-Verzeichnis
conn.executescript("""
CREATE TABLE IF NOT EXISTS wiki_zuchter (
@@ -1561,6 +1595,35 @@ def _migrate(conn_factory):
if 'from_account' not in existing_ol:
conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'")
+ # Job-Bewerbungen + Luna-Probezugang
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS job_applications (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ name TEXT NOT NULL,
+ email TEXT NOT NULL,
+ dog_name TEXT,
+ dog_rasse TEXT,
+ social_handle TEXT,
+ motivation TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ admin_note TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ reviewed_at TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_job_apps_status ON job_applications(status, created_at DESC);
+ CREATE TABLE IF NOT EXISTS job_application_docs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ application_id INTEGER NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE,
+ filename TEXT NOT NULL,
+ file_path TEXT NOT NULL,
+ uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ """)
+ existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
+ if 'luna_trial_until' not in existing_u:
+ conn.execute("ALTER TABLE users ADD COLUMN luna_trial_until TEXT")
+
# js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress
existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
if 'js_exercise_id' not in existing_te:
@@ -1581,3 +1644,299 @@ def _migrate(conn_factory):
conn.execute("UPDATE training_exercises SET js_exercise_id=? WHERE id=?", (js_id, row['id']))
conn.execute("CREATE INDEX IF NOT EXISTS idx_te_js_id ON training_exercises(js_exercise_id)")
logger.info("Migration: training_exercises.js_exercise_id hinzugefügt, 'Fuß' bereinigt.")
+
+ # Hund des Monats — dauerhafte Gewinner-Tabelle
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS hund_des_monats_wins (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ monat TEXT NOT NULL,
+ stimmen INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(dog_id, monat)
+ );
+ CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id);
+ """)
+
+ # Trainings-Streak-Tabelle
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS training_streaks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ current_streak INTEGER NOT NULL DEFAULT 0,
+ longest_streak INTEGER NOT NULL DEFAULT 0,
+ last_training_date TEXT,
+ UNIQUE(user_id, dog_id)
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_streaks_user ON training_streaks(user_id)")
+
+ # Ausgaben-Tracker
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS expenses (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
+ kategorie TEXT NOT NULL,
+ betrag REAL NOT NULL,
+ datum TEXT NOT NULL,
+ notiz TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_expenses_user ON expenses(user_id, datum DESC);
+ """)
+
+ # KI-Tierarztfragen Rate-Limit-Log
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS ki_tierarzt_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+
+ # KI Rassen-Erkennungs-Log (Rate-Limit: 10/Tag pro User)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS ki_rasse_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE INDEX IF NOT EXISTS idx_ki_rasse_log_user
+ ON ki_rasse_log(user_id, created_at DESC)
+ """)
+
+ # feed_recalls — Rückruf-Alarm für Tierfutter (RASFF)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS feed_recalls (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ external_id TEXT NOT NULL UNIQUE,
+ titel TEXT NOT NULL,
+ produkt TEXT,
+ gefahr TEXT,
+ herkunft TEXT,
+ datum TEXT NOT NULL,
+ quelle TEXT NOT NULL DEFAULT 'rasff',
+ url TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_recalls_datum ON feed_recalls(datum DESC)")
+
+ # Adoption-Cache
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS adoption_cache (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ external_id TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ rasse TEXT,
+ alter_jahre REAL,
+ geschlecht TEXT,
+ foto_url TEXT,
+ tierheim TEXT,
+ tierheim_plz TEXT,
+ tierheim_lat REAL,
+ tierheim_lon REAL,
+ adoptions_url TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ expires_at TEXT NOT NULL
+ )
+ """)
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS community_adoption (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
+ name TEXT NOT NULL,
+ rasse TEXT,
+ alter_jahre REAL,
+ geschlecht TEXT,
+ foto_url TEXT,
+ beschreibung TEXT NOT NULL,
+ gruende TEXT,
+ ort TEXT,
+ plz TEXT,
+ lat REAL,
+ lon REAL,
+ status TEXT NOT NULL DEFAULT 'active',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS community_adoption_interest (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ listing_id INTEGER NOT NULL REFERENCES community_adoption(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ nachricht TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(listing_id, user_id)
+ )
+ """)
+
+ # ---- Wetter-Log (historische Vorhersage-Daten) ----
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS weather_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ logged_at TEXT NOT NULL DEFAULT (datetime('now')),
+ date TEXT NOT NULL,
+ lat_r REAL NOT NULL,
+ lon_r REAL NOT NULL,
+ temp_max REAL,
+ temp_min REAL,
+ feels_max REAL,
+ precip_prob INTEGER,
+ precip_sum REAL,
+ wind_kmh REAL,
+ wind_dir TEXT,
+ uv_index REAL,
+ weathercode INTEGER,
+ weatherdesc TEXT,
+ sunrise TEXT,
+ sunset TEXT,
+ asphalt_temp REAL,
+ asphalt_warn TEXT,
+ zecken TEXT,
+ pollen_erle INTEGER,
+ pollen_birke INTEGER,
+ pollen_graeser INTEGER,
+ pollen_beifuss INTEGER,
+ pollen_ambrosia INTEGER,
+ forecast_json TEXT,
+ UNIQUE(date, lat_r, lon_r)
+ )
+ """)
+
+ # ---- Favoriten-Tierarzt + Gesundheitsdokumente ----
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS favorite_vets (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ vet_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE,
+ PRIMARY KEY (user_id, vet_id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS health_documents (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ typ TEXT NOT NULL,
+ titel TEXT NOT NULL,
+ beschreibung TEXT,
+ file_path TEXT NOT NULL,
+ file_type TEXT NOT NULL,
+ datum TEXT,
+ vet_id INTEGER REFERENCES tieraerzte(id),
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_health_docs_dog ON health_documents(dog_id, created_at DESC)")
+
+ # Digitaler Hundepass — Impfungen, Medikamente, Metadaten, Share-Links
+ try:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS vaccinations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ krankheit TEXT NOT NULL,
+ datum TEXT NOT NULL,
+ naechste TEXT,
+ tierarzt TEXT,
+ charge_nr TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS medications (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ dosierung TEXT,
+ von TEXT,
+ bis TEXT,
+ notiz TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS dog_passport_meta (
+ dog_id INTEGER PRIMARY KEY REFERENCES dogs(id) ON DELETE CASCADE,
+ blutgruppe TEXT,
+ allergien TEXT,
+ besonderheiten TEXT,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS passport_shares (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ token TEXT NOT NULL UNIQUE,
+ valid_until TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE INDEX IF NOT EXISTS idx_passport_shares_token ON passport_shares(token)
+ """)
+ logger.info("Migration: Hundepass-Tabellen bereit.")
+ except Exception as e:
+ logger.warning(f"Migration Hundepass: {e}")
+
+ # ---- Playdate ----
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS playdate_listings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ lat REAL NOT NULL,
+ lon REAL NOT NULL,
+ ort_name TEXT,
+ radius_km INTEGER NOT NULL DEFAULT 10,
+ beschreibung TEXT,
+ aktiv INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(dog_id)
+ )
+ """)
+ conn.execute("""
+ CREATE INDEX IF NOT EXISTS idx_playdate_listings_geo
+ ON playdate_listings(lat, lon) WHERE aktiv=1
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS playdate_requests (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ from_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ to_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ status TEXT NOT NULL DEFAULT 'pending',
+ nachricht TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(from_dog_id, to_dog_id)
+ )
+ """)
+
+ # Wiederkehrende Ausgaben (Daueraufträge)
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS recurring_expenses (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
+ kategorie TEXT NOT NULL,
+ betrag REAL NOT NULL,
+ haeufigkeit TEXT NOT NULL, -- monatlich|quartalsweise|jaehrlich
+ startdatum TEXT NOT NULL,
+ naechste_faelligkeit TEXT NOT NULL,
+ notiz TEXT,
+ aktiv INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv);
+ """)
diff --git a/backend/mailer.py b/backend/mailer.py
index e5cbdc0..344fe4f 100644
--- a/backend/mailer.py
+++ b/backend/mailer.py
@@ -106,44 +106,67 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""):
logger.warning(f"Kein Mail-Backend konfiguriert — Mail NICHT gesendet: «{subject}» → {to}")
+def email_html(
+ body_html: str,
+ cta_url: str = None,
+ cta_label: str = None,
+ footer_text: str = None,
+) -> str:
+ """Shared branded HTML email template (matches Status-Report design)."""
+ cta_block = ""
+ if cta_url and cta_label:
+ cta_block = f"""
+
+
+ {cta_label}
+
+
"""
+
+ footer = footer_text or "Ban Yaro · banyaro.app"
+
+ return f"""\
+
+
+
+
+
+
+
+
+
+
+
+ {body_html}{cta_block}
+
+
+
+ {footer}
+
+
+
+
+"""
+
+
async def send_verify_email(to: str, name: str, token: str):
url = f"{APP_URL}/api/auth/verify/{token}"
subject = "Ban Yaro — E-Mail-Adresse bestätigen"
- html = f"""\
-
-
-
-
-
-
Ban Yaro 🐾
-
Hallo {name},
-
+ body = f"""
+
Hallo {name} ,
+
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
-
-
- E-Mail bestätigen
-
-
-
- Der Link ist 48 Stunden gültig.
-
-
+
Der Link ist 48 Stunden gültig.
+
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
-
-
-
-"""
+ """
- plain = (
- f"Ban Yaro — E-Mail-Adresse bestätigen\n\n"
- f"Hallo {name},\n\n"
- f"bitte bestätige deine E-Mail-Adresse:\n{url}\n\n"
- f"Der Link ist 48 Stunden gültig.\n"
- )
+ html = email_html(body, cta_url=url, cta_label="E-Mail bestätigen")
+ plain = f"Ban Yaro — E-Mail bestätigen\n\nHallo {name},\n\nbitte bestätige:\n{url}\n\nLink ist 48h gültig.\n"
await send_email(to, subject, html, plain)
diff --git a/backend/main.py b/backend/main.py
index e8720c9..229a856 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
+from brotli_asgi import BrotliMiddleware
from contextlib import asynccontextmanager
from database import init_db
@@ -46,6 +47,8 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI):
logger.info("Ban Yaro startet...")
init_db()
+ from routes.movies import seed_movies
+ seed_movies()
logger.info(f"KI-Modus: {ki.KI_MODE}")
sched.start()
yield
@@ -67,11 +70,20 @@ app = FastAPI(
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
- response.headers["X-Frame-Options"] = "SAMEORIGIN"
- response.headers["X-Content-Type-Options"] = "nosniff"
- response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
- response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
- response.headers["X-XSS-Protection"] = "1; mode=block"
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
+ response.headers["Content-Security-Policy"] = (
+ "default-src 'self'; "
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
+ "style-src 'self' 'unsafe-inline'; "
+ "img-src 'self' data: blob: https:; "
+ "connect-src 'self' https:; "
+ "frame-ancestors 'none'; "
+ "base-uri 'self'; "
+ "form-action 'self';"
+ )
return response
app.add_middleware(SecurityHeadersMiddleware)
@@ -123,6 +135,7 @@ class MediaCacheMiddleware(BaseHTTPMiddleware):
return response
app.add_middleware(MediaCacheMiddleware)
+app.add_middleware(BrotliMiddleware, minimum_size=1000, quality=4)
app.add_middleware(GZipMiddleware, minimum_size=1000)
@@ -177,6 +190,14 @@ from routes.breeder_export import router as breeder_export_router
from routes.zucht_ki import router as zucht_ki_router
from routes.partner import router as partner_router
from routes.outreach import router as outreach_router
+from routes.jobs import router as jobs_router
+from routes.streak import router as streak_router
+from routes.expenses import router as expenses_router
+from routes.recalls import router as recalls_router
+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
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -210,6 +231,7 @@ app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"])
app.include_router(partner_router, prefix="/api", tags=["Partner"])
app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"])
+app.include_router(jobs_router, prefix="/api/jobs", tags=["Jobs"])
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
app.include_router(import_router, prefix="/api/import", tags=["Import"])
@@ -227,6 +249,13 @@ app.include_router(training_router, prefix="/api/training", tags=
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
+app.include_router(streak_router, prefix="/api", tags=["Streak"])
+app.include_router(expenses_router, prefix="/api/expenses", tags=["Ausgaben"])
+app.include_router(recalls_router, prefix="/api/recalls", tags=["Rückrufe"])
+app.include_router(adoption_router, prefix="/api/adoption", tags=["Adoption"])
+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"])
# ------------------------------------------------------------------
@@ -1416,6 +1445,13 @@ async def knigge_page():
# ------------------------------------------------------------------
+# /presse — Presseseite
+# ------------------------------------------------------------------
+@app.get("/presse")
+async def presse():
+ return FileResponse(f"{STATIC_DIR}/presse.html", headers={"Cache-Control": "max-age=3600"})
+
+
# /partner — Influencer-Landingpage
# ------------------------------------------------------------------
@app.get("/partner")
@@ -1617,6 +1653,189 @@ async def partner_landing():
return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"})
+# ------------------------------------------------------------------
+# Honeypot-Fallen für Scanner und Bots
+# Jeder Aufruf → 24h IP-Sperre
+# ------------------------------------------------------------------
+from ratelimit import block_ip as _block_ip
+
+_HONEYPOT_PATHS = [
+ "/api/admin/users",
+ "/api/v1/users",
+ "/api/users",
+ "/api/.env",
+ "/api/config",
+ "/api/setup",
+ "/api/install",
+ "/api/phpinfo",
+ "/api/debug",
+ "/api/actuator",
+ "/api/actuator/health",
+ "/api/swagger",
+ "/api/graphql",
+]
+
+async def _honeypot_handler(request: Request):
+ import logging as _log
+ _log.getLogger("banyaro.security").warning(
+ "Honeypot getroffen: %s %s — IP %s",
+ request.method, request.url.path,
+ request.client.host if request.client else "?"
+ )
+ _block_ip(request, hours=24)
+ from fastapi.responses import JSONResponse
+ return JSONResponse(status_code=404, content={"detail": "Not Found"})
+
+for _hp in _HONEYPOT_PATHS:
+ app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False)
+
+
+# ------------------------------------------------------------------
+# Digitaler Hundepass — öffentlicher Share-Link (kein Login nötig)
+# ------------------------------------------------------------------
+@app.get("/pass/{token}")
+async def passport_share_page(token: str):
+ from fastapi.responses import HTMLResponse
+ from database import db as _db
+ from datetime import date as _date
+
+ with _db() as conn:
+ share = conn.execute(
+ "SELECT * FROM passport_shares WHERE token=?", (token,)
+ ).fetchone()
+ if not share:
+ return HTMLResponse(
+ ' '
+ 'Link nicht gefunden Dieser Hundepass-Link ist ungültig.
',
+ status_code=404
+ )
+ if share["valid_until"] < _date.today().isoformat():
+ return HTMLResponse(
+ ' '
+ 'Link abgelaufen Dieser Hundepass-Link ist nicht mehr gültig.
',
+ status_code=410
+ )
+ dog_id = share["dog_id"]
+ dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
+ meta = conn.execute("SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)).fetchone()
+ vaccs = conn.execute(
+ "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
+ ).fetchall()
+ meds = conn.execute(
+ "SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
+ ).fetchall()
+ def _fmt(d):
+ if not d:
+ return "–"
+ try:
+ from datetime import datetime as _dt
+ return _dt.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
+ except Exception:
+ return d
+
+ dog = dict(dog)
+ meta = dict(meta) if meta else {}
+ vaccs = [dict(v) for v in vaccs]
+ meds = [dict(m) for m in meds]
+
+ _g_map = {"m": "Rüde", "w": "Hündin"}
+
+ vacc_rows = "".join(f"""
+
+ {v['krankheit'] or ''}
+ {_fmt(v['datum'])}
+ {_fmt(v['naechste'])}
+ {v['tierarzt'] or '–'}
+ {v['charge_nr'] or '–'}
+ """ for v in vaccs) or "Keine Einträge "
+
+ med_rows = "".join(f"""
+
+ {m['name'] or ''}
+ {m['dosierung'] or '–'}
+ {_fmt(m['von'])}
+ {_fmt(m['bis']) if m['bis'] else 'dauerhaft'}
+ {m['notiz'] or '–'}
+ """ for m in meds) or "Keine Einträge "
+
+ html = f"""
+
+
+
+
+ Hundepass — {dog['name']}
+
+
+
+
+
+
+
Hundeangaben
+
+
Name {dog['name']}
+
Rasse {dog.get('rasse') or '–'}
+
Geburtstag {_fmt(dog.get('geburtstag'))}
+
Geschlecht {_g_map.get(dog.get('geschlecht',''), '–')}
+
Chip-Nr. {dog.get('chip_nr') or '–'}
+
Blutgruppe {meta.get('blutgruppe') or '–'}
+
+ {('
Allergien '
+ f'
{meta["allergien"]}
') if meta.get("allergien") else ''}
+ {('
Besonderheiten '
+ f'
{meta["besonderheiten"]}
') if meta.get("besonderheiten") else ''}
+
+
+
+
Impfungen
+
+
+ Krankheit Datum Nächste Tierarzt Charge
+
+ {vacc_rows}
+
+
+
+
+
Medikamente
+
+
+ Medikament Dosierung Von Bis Notiz
+
+ {med_rows}
+
+
+
+
+
+"""
+ return HTMLResponse(html)
+
+
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
diff --git a/backend/ratelimit.py b/backend/ratelimit.py
index 661eb26..7cb3a2f 100644
--- a/backend/ratelimit.py
+++ b/backend/ratelimit.py
@@ -1,9 +1,9 @@
"""
-BAN YARO — Rate Limiter + IP-Blocklist
+BAN YARO — Rate Limiter + IP-Blocklist + Account-Lockout + Duplikat-Erkennung
Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container).
-Blocklist für Honeypot-Treffer.
"""
+import hashlib
import threading
from collections import defaultdict, deque
from datetime import datetime, timedelta
@@ -11,18 +11,23 @@ from datetime import datetime, timedelta
from fastapi import HTTPException, Request
_buckets: dict[str, deque] = defaultdict(deque)
-_blocklist: dict[str, datetime] = {} # ip → gesperrt bis
+_blocklist: dict[str, datetime] = {} # ip → gesperrt bis
+_login_failures: dict[str, list] = defaultdict(list) # email → [datetime, ...]
+_post_hashes: dict[int, dict] = defaultdict(dict) # user_id → {hash: datetime}
_lock = threading.Lock()
+_LOCKOUT_WINDOW = 15 # Minuten
+_LOCKOUT_ATTEMPTS = 5 # Fehlversuche bis Sperre
+_DUPLICATE_WINDOW = 300 # Sekunden (5 Minuten)
+
+# ------------------------------------------------------------------
+# IP-basiertes Rate Limiting
+# ------------------------------------------------------------------
def check(request: Request, *, max_requests: int, window_seconds: int, key: str = ""):
- """
- Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten.
- key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login').
- """
+ """Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten."""
ip = (request.client.host if request.client else "unknown")
- # Blocklist prüfen
with _lock:
blocked_until = _blocklist.get(ip)
if blocked_until and datetime.utcnow() < blocked_until:
@@ -65,3 +70,63 @@ def is_blocked(request: Request) -> bool:
elif until:
del _blocklist[ip]
return False
+
+
+# ------------------------------------------------------------------
+# Account-Lockout (per E-Mail)
+# ------------------------------------------------------------------
+def record_login_failure(email: str) -> int:
+ """Failure aufzeichnen. Gibt Anzahl Fehlversuche im Fenster zurück."""
+ email = email.lower()
+ now = datetime.utcnow()
+ cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW)
+ with _lock:
+ recent = [t for t in _login_failures[email] if t > cutoff]
+ recent.append(now)
+ _login_failures[email] = recent
+ return len(recent)
+
+
+def is_account_locked(email: str) -> bool:
+ """True wenn ≥5 Fehlversuche in den letzten 15 Minuten."""
+ email = email.lower()
+ now = datetime.utcnow()
+ cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW)
+ with _lock:
+ recent = [t for t in _login_failures.get(email, []) if t > cutoff]
+ return len(recent) >= _LOCKOUT_ATTEMPTS
+
+
+def clear_login_failures(email: str):
+ """Bei erfolgreichem Login zurücksetzen."""
+ with _lock:
+ _login_failures.pop(email.lower(), None)
+
+
+# ------------------------------------------------------------------
+# Duplikat-Post-Erkennung (per User, in-memory)
+# ------------------------------------------------------------------
+def content_hash(text: str) -> str:
+ normalized = " ".join(text.lower().split())
+ return hashlib.sha256(normalized.encode()).hexdigest()[:20]
+
+
+def is_duplicate_post(user_id: int, text: str) -> bool:
+ """True wenn derselbe User denselben Text in den letzten 5 Minuten gepostet hat."""
+ h = content_hash(text)
+ now = datetime.utcnow()
+ cutoff = now - timedelta(seconds=_DUPLICATE_WINDOW)
+ with _lock:
+ hashes = _post_hashes[user_id]
+ # Alte Einträge bereinigen
+ expired = [k for k, ts in hashes.items() if ts < cutoff]
+ for k in expired:
+ del hashes[k]
+ return h in hashes
+
+
+def record_post(user_id: int, text: str):
+ """Post-Hash speichern nach erfolgreichem Erstellen."""
+ h = content_hash(text)
+ with _lock:
+ _post_hashes[user_id][h] = datetime.utcnow()
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 7b268fa..414ec32 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -13,3 +13,6 @@ pywebpush==2.0.0
apscheduler==3.10.4
odfpy==1.4.1
polyline==2.0.2
+fpdf2==2.8.3
+python-dateutil>=2.9
+brotli-asgi==1.4.0
diff --git a/backend/routes/admin.py b/backend/routes/admin.py
index 09a4127..c2ffebb 100644
--- a/backend/routes/admin.py
+++ b/backend/routes/admin.py
@@ -97,6 +97,40 @@ class ThreadAdminPatch(BaseModel):
is_deleted: Optional[int] = None
+# ------------------------------------------------------------------
+# GET /api/admin/action-items
+# ------------------------------------------------------------------
+@router.get("/action-items")
+async def action_items(user=Depends(require_mod)):
+ with db() as conn:
+ jobs = conn.execute(
+ "SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')"
+ ).fetchone()[0]
+ breeders = conn.execute(
+ "SELECT COUNT(*) FROM users WHERE breeder_status='pending'"
+ ).fetchone()[0]
+ reports = conn.execute(
+ "SELECT COUNT(*) FROM forum_reports WHERE resolved=0"
+ ).fetchone()[0]
+ fotos = conn.execute(
+ "SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'"
+ ).fetchone()[0]
+ poi_edits = conn.execute(
+ "SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'"
+ ).fetchone()[0]
+ users_today = conn.execute(
+ "SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')"
+ ).fetchone()[0]
+ return {
+ "jobs_pending": jobs,
+ "breeder_pending": breeders,
+ "reports_open": reports,
+ "fotos_pending": fotos,
+ "poi_edits_pending": poi_edits,
+ "users_today": users_today,
+ }
+
+
# ------------------------------------------------------------------
# GET /api/admin/stats
# ------------------------------------------------------------------
@@ -322,11 +356,15 @@ async def list_users(
# ------------------------------------------------------------------
@router.patch("/users/{uid}")
async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
- # Rollenwechsel nur für Admins
+ # Rollenwechsel + Privileg-Flags nur für Admins
if data.rolle is not None and user["rolle"] != "admin":
raise HTTPException(403, "Rollenwechsel nur für Admins.")
if data.rolle and data.rolle not in ("user", "moderator", "admin"):
raise HTTPException(400, "Ungültige Rolle.")
+ if data.is_moderator is not None and user["rolle"] != "admin":
+ raise HTTPException(403, "is_moderator darf nur von Admins geändert werden.")
+ if data.is_social_media is not None and user["rolle"] != "admin":
+ raise HTTPException(403, "is_social_media darf nur von Admins geändert werden.")
with db() as conn:
target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone()
diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py
new file mode 100644
index 0000000..bde0986
--- /dev/null
+++ b/backend/routes/adoption.py
@@ -0,0 +1,547 @@
+"""
+BAN YARO — Adoption (Tierheim-Hunde in der Nähe)
+
+Strategie:
+ 1. PetFinder API (falls API-Key gesetzt) → hat kaum deutsche Tierheime, nur als Bonus
+ 2. Statische Daten: Liste großer deutscher Tierheime mit Koordinaten
+ 3. Fallback: Weiterleitung zu tierheimhelden.de
+
+Caching: adoption_cache Tabelle, 24h TTL.
+"""
+
+import os
+import math
+import logging
+import asyncio
+import uuid
+import httpx
+from datetime import datetime, timedelta
+from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException
+from pydantic import BaseModel
+from typing import Optional
+from database import db
+from auth import get_current_user
+from routes.push import send_push_to_user
+
+MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "")
+PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "")
+
+# ------------------------------------------------------------------
+# Haversine — Distanz in km
+# ------------------------------------------------------------------
+def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
+ R = 6371.0
+ p1 = math.radians(lat1)
+ p2 = 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))
+
+
+# ------------------------------------------------------------------
+# Statische Tierheim-Daten (große deutsche Tierheime)
+# ------------------------------------------------------------------
+GERMAN_SHELTERS = [
+ # (id, name, plz, stadt, lat, lon, url)
+ ("de_berlin_tierschutz", "Tierheim Berlin", "12459", "Berlin", 52.4862, 13.5301, "https://www.tierheim-berlin.de/tiere/hunde/"),
+ ("de_hamburg_tierschutz", "Tierheim Hamburg (Süderstraße)", "20537", "Hamburg", 53.5539, 10.0416, "https://www.hamburger-tierschutzverein.de/hunde/"),
+ ("de_muenchen_tierschutz", "Tierheim München", "81379", "München", 48.0982, 11.5248, "https://www.tierheim-muenchen.de/hunde/"),
+ ("de_koeln_tierschutz", "Tierheim Köln", "50769", "Köln", 51.0168, 6.9369, "https://www.tierschutzverein-koeln.de/tiere/hunde/"),
+ ("de_frankfurt_tierschutz", "Tierheim Frankfurt (Fechenheim)", "60386", "Frankfurt", 50.1246, 8.7597, "https://www.tierheim-frankfurt.de/hunde/"),
+ ("de_stuttgart_tierschutz", "Tierschutzverein Stuttgart", "70193", "Stuttgart", 48.7790, 9.1634, "https://www.tierschutzverein-stuttgart.de/vermittlung/hunde/"),
+ ("de_duesseldorf_tierheim", "Tierheim Düsseldorf", "40599", "Düsseldorf", 51.1948, 6.8488, "https://www.tierheim-duesseldorf.de/tiere/hunde/"),
+ ("de_dortmund_tierheim", "Tierheim Dortmund", "44339", "Dortmund", 51.5481, 7.4584, "https://www.tierschutzverein-dortmund.de/hunde/"),
+ ("de_essen_tierheim", "Tierheim Essen", "45276", "Essen", 51.4341, 7.0985, "https://www.tierheim-essen.de/tiere/hunde/"),
+ ("de_leipzig_tierheim", "Tierheim Leipzig", "04109", "Leipzig", 51.3396, 12.3713, "https://www.tierschutzverein-leipzig.de/hunde/"),
+ ("de_dresden_tierheim", "Tierheim Dresden", "01127", "Dresden", 51.0789, 13.7319, "https://www.tierschutzverein-dresden-heidenau.de/tiere/hunde/"),
+ ("de_hannover_tierheim", "Tierheim Hannover", "30855", "Hannover", 52.3484, 9.7411, "https://www.tierschutzverein-hannover.de/hunde/"),
+ ("de_nuernberg_tierheim", "Tierschutzverein Nürnberg", "90461", "Nürnberg", 49.4182, 11.0830, "https://www.tierschutzverein-nuernberg.de/tiere/hunde/"),
+ ("de_bremen_tierheim", "Tierheim Bremen", "28307", "Bremen", 53.0440, 8.9128, "https://www.tierheim-bremen.de/hunde/"),
+ ("de_bochum_tierheim", "Tierheim Bochum", "44793", "Bochum", 51.4753, 7.2128, "https://www.tierschutzverein-bochum.de/hunde/"),
+ ("de_wuppertal_tierheim", "Tierheim Wuppertal", "42283", "Wuppertal", 51.2571, 7.1705, "https://www.tierschutz-wuppertal.de/hunde/"),
+ ("de_bielefeld_tierheim", "Tierheim Bielefeld", "33649", "Bielefeld", 51.9951, 8.5327, "https://www.tierschutzverein-bielefeld.de/hunde/"),
+ ("de_mannheim_tierheim", "Tierheim Mannheim", "68309", "Mannheim", 49.5079, 8.5033, "https://www.tierschutzverein-mannheim.de/hunde/"),
+ ("de_karlsruhe_tierheim", "Tierheim Karlsruhe", "76229", "Karlsruhe", 48.9960, 8.4290, "https://www.tierschutzverein-karlsruhe.de/hunde/"),
+ ("de_augsburg_tierheim", "Tierheim Augsburg", "86159", "Augsburg", 48.3668, 10.8978, "https://www.tierschutz-augsburg.de/tiere/hunde/"),
+ ("de_freiburg_tierheim", "Tierheim Freiburg", "79115", "Freiburg", 47.9855, 7.8352, "https://www.tierschutz-freiburg.de/tiere/hunde/"),
+ ("de_kiel_tierheim", "Tierheim Kiel", "24113", "Kiel", 54.3203, 10.1228, "https://www.tierschutzverein-kiel.de/hunde/"),
+ ("de_magdeburg_tierheim", "Tierheim Magdeburg", "39118", "Magdeburg", 52.0814, 11.5939, "https://www.tierschutz-magdeburg.de/hunde/"),
+ ("de_erfurt_tierheim", "Tierheim Erfurt", "99099", "Erfurt", 50.9985, 11.0424, "https://www.tierschutzverein-erfurt.de/hunde/"),
+ ("de_rostock_tierheim", "Tierheim Rostock", "18059", "Rostock", 54.0831, 12.0965, "https://www.tierschutzverein-rostock.de/hunde/"),
+]
+
+
+# ------------------------------------------------------------------
+# PetFinder OAuth2 Token
+# ------------------------------------------------------------------
+_pf_token = None
+_pf_token_exp = 0.0
+
+async def _get_pf_token() -> str | None:
+ global _pf_token, _pf_token_exp
+ if not (PETFINDER_KEY and PETFINDER_SECRET):
+ return None
+ now = asyncio.get_event_loop().time()
+ if _pf_token and now < _pf_token_exp - 60:
+ return _pf_token
+ try:
+ async with httpx.AsyncClient(timeout=8) as client:
+ r = await client.post(
+ "https://api.petfinder.com/v2/oauth2/token",
+ data={"grant_type": "client_credentials",
+ "client_id": PETFINDER_KEY,
+ "client_secret": PETFINDER_SECRET},
+ )
+ if r.status_code == 200:
+ data = r.json()
+ _pf_token = data.get("access_token")
+ _pf_token_exp = now + data.get("expires_in", 3600)
+ return _pf_token
+ except Exception as e:
+ logger.warning(f"PetFinder OAuth: {e}")
+ return None
+
+
+# ------------------------------------------------------------------
+# PetFinder: Hunde in der Nähe holen
+# ------------------------------------------------------------------
+async def _fetch_petfinder(lat: float, lon: float, radius: int) -> list[dict]:
+ token = await _get_pf_token()
+ if not token:
+ return []
+ try:
+ async with httpx.AsyncClient(timeout=12) as client:
+ r = await client.get(
+ "https://api.petfinder.com/v2/animals",
+ headers={"Authorization": f"Bearer {token}"},
+ params={
+ "type": "dog",
+ "location": f"{lat},{lon}",
+ "distance": radius,
+ "limit": 20,
+ "sort": "distance",
+ "status": "adoptable",
+ },
+ )
+ if r.status_code != 200:
+ logger.warning(f"PetFinder API: HTTP {r.status_code}")
+ return []
+ animals = r.json().get("animals", [])
+ result = []
+ for a in animals:
+ org = a.get("organization_id", "")
+ loc = a.get("contact", {}).get("address", {})
+ photos = a.get("photos", [])
+ foto = photos[0].get("medium") if photos else None
+ age_map = {"Baby": 0.25, "Young": 1.0, "Adult": 4.0, "Senior": 9.0}
+ result.append({
+ "external_id": f"pf_{a['id']}",
+ "name": a.get("name", "Unbekannt"),
+ "rasse": ", ".join(
+ filter(None, [
+ a.get("breeds", {}).get("primary"),
+ a.get("breeds", {}).get("secondary"),
+ ])
+ ) or None,
+ "alter_jahre": age_map.get(a.get("age"), None),
+ "geschlecht": {"Male": "männlich", "Female": "weiblich"}.get(a.get("gender"), None),
+ "foto_url": foto,
+ "tierheim": org,
+ "tierheim_plz": loc.get("postcode"),
+ "tierheim_lat": None,
+ "tierheim_lon": None,
+ "adoptions_url": a.get("url", "https://www.petfinder.com/"),
+ "quelle": "petfinder",
+ })
+ return result
+ except Exception as e:
+ logger.warning(f"PetFinder Fetch: {e}")
+ return []
+
+
+# ------------------------------------------------------------------
+# Cache befüllen
+# ------------------------------------------------------------------
+async def _refresh_cache(lat: float, lon: float, radius: int):
+ """Holt frische Daten und schreibt sie in adoption_cache."""
+ animals = await _fetch_petfinder(lat, lon, radius)
+ if not animals:
+ return
+ expires = (datetime.utcnow() + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
+ with db() as conn:
+ for a in animals:
+ try:
+ conn.execute("""
+ INSERT INTO adoption_cache
+ (external_id, name, rasse, alter_jahre, geschlecht,
+ foto_url, tierheim, tierheim_plz, tierheim_lat, tierheim_lon,
+ adoptions_url, expires_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT(external_id) DO UPDATE SET
+ name=excluded.name,
+ rasse=excluded.rasse,
+ alter_jahre=excluded.alter_jahre,
+ geschlecht=excluded.geschlecht,
+ foto_url=excluded.foto_url,
+ tierheim=excluded.tierheim,
+ tierheim_plz=excluded.tierheim_plz,
+ tierheim_lat=excluded.tierheim_lat,
+ tierheim_lon=excluded.tierheim_lon,
+ adoptions_url=excluded.adoptions_url,
+ expires_at=excluded.expires_at
+ """, (
+ a["external_id"], a["name"], a["rasse"], a["alter_jahre"],
+ a["geschlecht"], a["foto_url"], a["tierheim"], a["tierheim_plz"],
+ a["tierheim_lat"], a["tierheim_lon"], a["adoptions_url"], expires,
+ ))
+ except Exception as e:
+ logger.warning(f"Cache insert: {e}")
+
+
+# ------------------------------------------------------------------
+# GET /api/adoption/nearby
+# ------------------------------------------------------------------
+@router.get("/nearby")
+async def adoption_nearby(
+ lat: float = Query(..., description="Breitengrad"),
+ lon: float = Query(..., description="Längengrad"),
+ radius: int = Query(50, ge=5, le=200, description="Radius in km"),
+ background_tasks: BackgroundTasks = None,
+):
+ """
+ Gibt Adoptionshunde in der Nähe zurück.
+
+ Priorisierung:
+ 1. Frische PetFinder-Einträge aus Cache
+ 2. Statische Tierheim-Liste (immer vorhanden, mit Entfernung)
+ """
+ now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
+
+ # ------ Cache lesen ------
+ cached_animals = []
+ with db() as conn:
+ rows = conn.execute("""
+ SELECT * FROM adoption_cache
+ WHERE expires_at > ?
+ ORDER BY created_at DESC
+ """, (now_str,)).fetchall()
+ for row in rows:
+ d = dict(row)
+ if d.get("tierheim_lat") and d.get("tierheim_lon"):
+ dist = _haversine(lat, lon, d["tierheim_lat"], d["tierheim_lon"])
+ if dist <= radius:
+ d["distanz_km"] = round(dist, 1)
+ cached_animals.append(d)
+ else:
+ # PetFinder-Einträge ohne Koordinaten: immer anzeigen
+ d["distanz_km"] = None
+ cached_animals.append(d)
+
+ # ------ Cache refreshen wenn leer oder alt ------
+ if not cached_animals and background_tasks is not None:
+ background_tasks.add_task(_refresh_cache, lat, lon, radius)
+
+ # ------ Statische Tierheime (immer) ------
+ shelters = []
+ for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS:
+ dist = _haversine(lat, lon, slat, slon)
+ if dist <= radius:
+ shelters.append({
+ "id": sid,
+ "name": name,
+ "plz": plz,
+ "stadt": stadt,
+ "lat": slat,
+ "lon": slon,
+ "url": url,
+ "distanz_km": round(dist, 1),
+ })
+
+ shelters.sort(key=lambda x: x["distanz_km"])
+
+ return {
+ "animals": cached_animals[:40],
+ "shelters": shelters[:10],
+ "has_petfinder": bool(PETFINDER_KEY),
+ }
+
+
+# ------------------------------------------------------------------
+# GET /api/adoption/geocode?plz=… — PLZ → Koordinaten via Nominatim
+# ------------------------------------------------------------------
+@router.get("/geocode")
+async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
+ """Wandelt eine PLZ in Koordinaten um (via Nominatim)."""
+ try:
+ async with httpx.AsyncClient(timeout=8) as client:
+ r = await client.get(
+ "https://nominatim.openstreetmap.org/search",
+ params={
+ "q": f"{plz}, Germany",
+ "format": "json",
+ "limit": 1,
+ "accept-language": "de",
+ "countrycodes": "de",
+ },
+ headers={"User-Agent": "BanYaro/1.0 (https://banyaro.app)"},
+ )
+ results = r.json()
+ if results:
+ return {"lat": float(results[0]["lat"]), "lon": float(results[0]["lon"]), "display": results[0].get("display_name", plz)}
+ except Exception as e:
+ logger.warning(f"Geocode PLZ {plz}: {e}")
+ return {"lat": None, "lon": None, "display": plz}
+
+
+# ==================================================================
+# Community Adoption — Privates Weitervermittlungs-Board
+# ==================================================================
+
+class InterestBody(BaseModel):
+ nachricht: Optional[str] = None
+
+
+# ------------------------------------------------------------------
+# GET /api/adoption/community/my — eigene Inserate
+# ------------------------------------------------------------------
+@router.get("/community/my")
+def community_my(user=Depends(get_current_user)):
+ with db() as conn:
+ rows = conn.execute("""
+ SELECT ca.*,
+ u.name AS besitzer_name,
+ (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count
+ FROM community_adoption ca
+ JOIN users u ON u.id = ca.user_id
+ WHERE ca.user_id = ? AND ca.status != 'deleted'
+ ORDER BY ca.created_at DESC
+ """, (user["id"],)).fetchall()
+ return [dict(r) for r in rows]
+
+
+# ------------------------------------------------------------------
+# GET /api/adoption/community — alle aktiven Inserate (mit optionaler Nähe)
+# ------------------------------------------------------------------
+@router.get("/community")
+def community_list(
+ lat: Optional[float] = Query(None),
+ lon: Optional[float] = Query(None),
+ radius: float = Query(200.0, description="Radius in km (default 200)"),
+ user=Depends(get_current_user),
+):
+ with db() as conn:
+ rows = conn.execute("""
+ SELECT ca.*,
+ u.name AS besitzer_name,
+ (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count,
+ (SELECT COUNT(*) FROM community_adoption_interest i2
+ WHERE i2.listing_id = ca.id AND i2.user_id = ?) AS _user_interested
+ FROM community_adoption ca
+ JOIN users u ON u.id = ca.user_id
+ WHERE ca.status = 'active'
+ ORDER BY ca.created_at DESC
+ LIMIT 50
+ """, (user["id"],)).fetchall()
+
+ result = []
+ for row in rows:
+ d = dict(row)
+ d["user_interested"] = bool(d.pop("_user_interested", 0))
+ 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"])
+ d["distanz_km"] = round(dist, 1)
+ if dist > radius:
+ continue
+ else:
+ d["distanz_km"] = None
+ result.append(d)
+
+ if lat is not None and lon is not None:
+ result.sort(key=lambda x: x["distanz_km"] if x["distanz_km"] is not None else 9999)
+
+ return result
+
+
+# ------------------------------------------------------------------
+# POST /api/adoption/community — Inserat erstellen
+# ------------------------------------------------------------------
+@router.post("/community", status_code=201)
+async def community_create(
+ name: str = Form(...),
+ beschreibung: str = Form(...),
+ rasse: str = Form(""),
+ alter_jahre: Optional[float] = Form(None),
+ geschlecht: str = Form(""),
+ gruende: str = Form(""),
+ ort: str = Form(""),
+ plz: str = Form(""),
+ lat: Optional[float] = Form(None),
+ lon: Optional[float] = Form(None),
+ dog_id: Optional[int] = Form(None),
+ foto: Optional[UploadFile] = File(None),
+ user=Depends(get_current_user),
+):
+ foto_url = None
+
+ if foto and foto.filename:
+ MAX_SIZE = 5 * 1024 * 1024
+ header = await foto.read(12)
+ if len(header) < 3:
+ raise HTTPException(400, "Ungültige Datei")
+ is_jpeg = header[:3] == b"\xff\xd8\xff"
+ is_png = header[:4] == b"\x89PNG"
+ is_webp = header[:4] == b"RIFF" and len(header) >= 12 and header[8:12] == b"WEBP"
+ if not (is_jpeg or is_png or is_webp):
+ raise HTTPException(400, "Nur JPEG, PNG oder WebP erlaubt")
+ rest = await foto.read(MAX_SIZE)
+ if len(rest) >= MAX_SIZE:
+ raise HTTPException(400, "Foto zu groß (max 5 MB)")
+ data = header + rest
+
+ folder = os.path.join(MEDIA_DIR, "adoption")
+ os.makedirs(folder, exist_ok=True)
+ filename = f"{uuid.uuid4()}.jpg"
+ filepath = os.path.join(folder, filename)
+ with open(filepath, "wb") as f:
+ f.write(data)
+ foto_url = f"/media/adoption/{filename}"
+
+ with db() as conn:
+ cur = conn.execute("""
+ INSERT INTO community_adoption
+ (user_id, dog_id, name, rasse, alter_jahre, geschlecht,
+ foto_url, beschreibung, gruende, ort, plz, lat, lon)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
+ """, (
+ user["id"], dog_id, name, rasse or None, alter_jahre,
+ geschlecht or None, foto_url, beschreibung,
+ gruende or None, ort or None, plz or None, lat, lon,
+ ))
+ new_id = cur.lastrowid
+ row = conn.execute(
+ "SELECT * FROM community_adoption WHERE id = ?", (new_id,)
+ ).fetchone()
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer)
+# ------------------------------------------------------------------
+class _StatusBody(BaseModel):
+ status: str
+
+@router.patch("/community/{listing_id}")
+def community_update_status(
+ listing_id: int,
+ body: _StatusBody,
+ user=Depends(get_current_user),
+):
+ allowed = {"active", "reserved", "vermittelt"}
+ if body.status not in allowed:
+ raise HTTPException(400, f"Status muss einer von {allowed} sein")
+ status = body.status
+ with db() as conn:
+ cur = conn.execute("""
+ UPDATE community_adoption
+ SET status = ?, updated_at = datetime('now')
+ WHERE id = ? AND user_id = ?
+ """, (status, listing_id, user["id"]))
+ if cur.rowcount == 0:
+ raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff")
+ return {"ok": True}
+
+
+# ------------------------------------------------------------------
+# DELETE /api/adoption/community/{id} — Soft-Delete (nur Besitzer)
+# ------------------------------------------------------------------
+@router.delete("/community/{listing_id}")
+def community_delete(listing_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ cur = conn.execute("""
+ UPDATE community_adoption
+ SET status = 'deleted', updated_at = datetime('now')
+ WHERE id = ? AND user_id = ?
+ """, (listing_id, user["id"]))
+ if cur.rowcount == 0:
+ raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff")
+ return {"ok": True}
+
+
+# ------------------------------------------------------------------
+# POST /api/adoption/community/{id}/interest — Interesse bekunden
+# ------------------------------------------------------------------
+@router.post("/community/{listing_id}/interest", status_code=201)
+def community_interest(listing_id: int, body: InterestBody = None, user=Depends(get_current_user)):
+ nachricht = (body.nachricht if body else None) or None
+ with db() as conn:
+ listing = conn.execute(
+ "SELECT id, name, user_id FROM community_adoption WHERE id = ? AND status != 'deleted'",
+ (listing_id,)
+ ).fetchone()
+ if not listing:
+ raise HTTPException(404, "Inserat nicht gefunden")
+ if listing["user_id"] == user["id"]:
+ raise HTTPException(400, "Eigenes Inserat")
+ try:
+ conn.execute("""
+ INSERT INTO community_adoption_interest (listing_id, user_id, nachricht)
+ VALUES (?, ?, ?)
+ """, (listing_id, user["id"], nachricht))
+ except Exception:
+ raise HTTPException(409, "Interesse bereits bekundet")
+
+ try:
+ send_push_to_user(listing["user_id"], {
+ "title": "Jemand interessiert sich für deinen Hund \U0001f43e",
+ "body": f"{user['name']} möchte mehr über {listing['name']} erfahren.",
+ "url": "/#adoption",
+ })
+ except Exception as e:
+ logger.warning(f"Push interest: {e}")
+
+ return {"ok": True}
+
+
+# ------------------------------------------------------------------
+# DELETE /api/adoption/community/{id}/interest — Interesse zurückziehen
+# ------------------------------------------------------------------
+@router.delete("/community/{listing_id}/interest")
+def community_interest_withdraw(listing_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ cur = conn.execute("""
+ DELETE FROM community_adoption_interest
+ WHERE listing_id = ? AND user_id = ?
+ """, (listing_id, user["id"]))
+ if cur.rowcount == 0:
+ raise HTTPException(404, "Kein Interesse gefunden")
+ return {"ok": True}
+
+
+# ------------------------------------------------------------------
+# GET /api/adoption/community/{id}/interests — Interessenten (nur Besitzer)
+# ------------------------------------------------------------------
+@router.get("/community/{listing_id}/interests")
+def community_interests(listing_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ listing = conn.execute(
+ "SELECT user_id FROM community_adoption WHERE id = ? AND status != 'deleted'",
+ (listing_id,)
+ ).fetchone()
+ if not listing:
+ raise HTTPException(404, "Inserat nicht gefunden")
+ if listing["user_id"] != user["id"]:
+ raise HTTPException(403, "Nur der Besitzer kann Interessenten sehen")
+ rows = conn.execute("""
+ SELECT i.id, i.nachricht, i.created_at, u.id AS user_id, u.name, u.avatar_url
+ FROM community_adoption_interest i
+ JOIN users u ON u.id = i.user_id
+ WHERE i.listing_id = ?
+ ORDER BY i.created_at ASC
+ """, (listing_id,)).fetchall()
+ return [dict(r) for r in rows]
diff --git a/backend/routes/auth.py b/backend/routes/auth.py
index f810217..4772ae6 100644
--- a/backend/routes/auth.py
+++ b/backend/routes/auth.py
@@ -15,7 +15,7 @@ from auth import (
get_current_user
)
from username_blocklist import is_username_blocked
-from ratelimit import check as rl_check
+from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures
router = APIRouter()
COOKIE_NAME = "by_token"
@@ -26,18 +26,25 @@ _SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_P
def _send_verification_email(email: str, name: str, token: str):
if not _SMTP_READY:
return
+ import html as _html
from routes.outreach import _send_smtp
+ from mailer import email_html
+ url = f"{_APP_URL}/api/auth/verify-email/{token}"
subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse"
- body = (
- f"Hallo {name},\n\n"
- "willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse mit diesem Link:\n\n"
- f"{_APP_URL}/api/auth/verify-email/{token}\n\n"
- "Der Link ist 7 Tage gültig.\n\n"
- "Falls du dich nicht bei Ban Yaro registriert hast, kannst du diese Mail ignorieren.\n\n"
- "Viele Grüße,\nDas Ban Yaro Team\nbanyaro.app"
- )
+ _ename = _html.escape(name)
+ body_html = f"""
+ Hallo {_ename} ,
+
+ willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
+
+ Der Link ist 7 Tage gültig.
+
+ Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
+
"""
+ html = email_html(body_html, cta_url=url, cta_label="E-Mail bestätigen")
+ plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n"
try:
- _send_smtp(email, subject, body, "support")
+ _send_smtp(email, subject, plain, "support", html=html)
except Exception:
pass # Nicht blockieren wenn SMTP fehlschlägt
@@ -139,24 +146,32 @@ async def register(data: RegisterRequest, response: Response, request: Request):
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
(referrer['id'], new_user_id))
- token = create_token(user["id"], user["rolle"])
- _set_cookie(response, token)
_send_verification_email(data.email, name, verify_token)
- return {"token": token, "name": name, "email_verified": 0}
+ return {"pending_verification": True}
@router.post("/login")
async def login(data: LoginRequest, response: Response, request: Request):
rl_check(request, max_requests=10, window_seconds=300, key="login")
+ rl_check(request, max_requests=5, window_seconds=300, key=f"login_email:{data.email.lower()}")
+
+ if is_account_locked(data.email):
+ raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.")
+
with db() as conn:
user = conn.execute(
- "SELECT id, pw_hash, name, rolle, is_premium FROM users WHERE email=?",
+ "SELECT id, pw_hash, name, rolle, is_premium, email_verified FROM users WHERE email=?",
(data.email,)
).fetchone()
if not user or not verify_password(data.password, user["pw_hash"]):
+ record_login_failure(data.email)
raise HTTPException(401, "E-Mail oder Passwort falsch.")
+ if not user["email_verified"]:
+ raise HTTPException(403, "EMAIL_NOT_VERIFIED")
+
+ clear_login_failures(data.email)
token = create_token(user["id"], user["rolle"])
_set_cookie(response, token)
@@ -249,23 +264,24 @@ async def verify_email(token: str):
return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302)
+class ResendVerificationRequest(BaseModel):
+ email: EmailStr
+
@router.post("/resend-verification")
-async def resend_verification(request: Request, user=Depends(get_current_user)):
- rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify")
+async def resend_verification(data: ResendVerificationRequest, request: Request):
+ rl_check(request, max_requests=3, window_seconds=3600, key=f"resend_verify_{data.email}")
with db() as conn:
row = conn.execute(
- "SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],)
+ "SELECT id, name, email_verified FROM users WHERE email=?", (data.email,)
).fetchone()
- if not row:
- raise HTTPException(404)
- if row["email_verified"]:
- return {"ok": True, "already_verified": True}
+ if not row or row["email_verified"]:
+ return {"ok": True}
token = secrets.token_urlsafe(32)
with db() as conn:
conn.execute(
- "UPDATE users SET verification_token=? WHERE id=?", (token, user["id"])
+ "UPDATE users SET verification_token=? WHERE id=?", (token, row["id"])
)
- _send_verification_email(row["email"], row["name"], token)
+ _send_verification_email(data.email, row["name"], token)
return {"ok": True}
@@ -292,19 +308,26 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
"UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?",
(token, expires, user["id"])
)
+ import html as _html
app_url = os.getenv("APP_URL", "https://banyaro.app")
+ url = f"{app_url}/#reset-password?token={token}"
subject = "Ban Yaro — Passwort zurücksetzen"
- body = (
- f"Hallo {user['name']},\n\n"
- "du hast eine Passwort-Zurücksetzen-Anfrage gestellt.\n\n"
- f"Klicke hier um ein neues Passwort zu setzen:\n"
- f"{app_url}/#reset-password?token={token}\n\n"
- "Der Link ist 2 Stunden gültig. Falls du keine Anfrage gestellt hast, ignoriere diese Mail.\n\n"
- "Viele Grüße,\nDas Ban Yaro Team"
- )
from routes.outreach import _send_smtp
+ from mailer import email_html
+ _ename = _html.escape(user['name'])
+ body_html = f"""
+ Hallo {_ename} ,
+
+ du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
+
+ Der Link ist 2 Stunden gültig.
+
+ Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach.
+
"""
+ html = email_html(body_html, cta_url=url, cta_label="Passwort zurücksetzen")
+ plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n"
try:
- _send_smtp(data.email, subject, body, "support")
+ _send_smtp(data.email, subject, plain, "support", html=html)
except Exception:
pass
return {"ok": True}
diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py
index bb5efc8..355a575 100644
--- a/backend/routes/breeder.py
+++ b/backend/routes/breeder.py
@@ -11,7 +11,7 @@ from typing import Optional
from database import db
from auth import get_current_user, require_premium
-from mailer import send_email
+from mailer import send_email, email_html
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -131,21 +131,21 @@ async def breeder_apply(
)
# Admin benachrichtigen
- admin_html = f"""
- Neuer Züchter-Antrag
- Von: {user['name']} ({user['email']})
- Zwingername: {zwingername}
- Rasse: {rasse_text}
- Verein: {verein}
- VDH: {'Ja' if vdh_mitglied else 'Nein'}
- Stadt: {stadt}
- Im Admin-Bereich prüfen
- """
+ admin_body = f"""
+ Neuer Züchter-Antrag eingegangen:
+
+ Von {user['name']} ({user['email']})
+ Zwingername {zwingername}
+ Rasse {rasse_text}
+ Verein {verein}
+ VDH {'Ja' if vdh_mitglied else 'Nein'}
+ Stadt {stadt}
+
"""
try:
await send_email(
ADMIN_EMAIL,
f"[Banyaro] Neuer Züchter-Antrag — {zwingername}",
- admin_html,
+ email_html(admin_body, cta_url=f"{APP_URL}/#admin", cta_label="Im Admin-Bereich prüfen"),
f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}",
)
except Exception as e:
@@ -233,18 +233,17 @@ async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)):
)
# Bestätigungs-Mail
- html = f"""
- Willkommen als Züchter bei Banyaro!
- Hallo {user['name']},
- dein Züchter-Profil wurde erfolgreich verifiziert.
- Ab sofort hast du Zugang zu allen Züchter-Features.
- Zur App
- """
+ approve_body = f"""
+ Hallo {user['name']} ,
+
+ dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉
+ Ab sofort hast du Zugang zu allen Züchter-Features.
+
"""
try:
await send_email(
user["email"],
- "Willkommen als Züchter bei Banyaro!",
- html,
+ "Willkommen als Züchter bei Ban Yaro!",
+ email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"),
f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.",
)
except Exception as e:
@@ -274,19 +273,25 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req
)
# Ablehnungs-Mail
- html = f"""
- Dein Züchter-Antrag bei Banyaro
- Hallo {user['name']},
- leider konnten wir deinen Antrag aktuell nicht bestätigen.
- Grund: {body.grund}
- Du kannst jederzeit einen neuen Antrag stellen.
- Bei Fragen: {ADMIN_EMAIL}
- """
+ import html as _h
+ reject_body = f"""
+ Hallo {user['name']} ,
+
+ leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen.
+
+
+ Grund: {_h.escape(body.grund)}
+
+
+ Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter
+ {ADMIN_EMAIL} .
+
"""
try:
await send_email(
user["email"],
- "Dein Züchter-Antrag bei Banyaro",
- html,
+ "Dein Züchter-Antrag bei Ban Yaro",
+ email_html(reject_body),
f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}",
)
except Exception as e:
diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py
index 74f1c95..a44faa0 100644
--- a/backend/routes/dogs.py
+++ b/backend/routes/dogs.py
@@ -75,6 +75,21 @@ async def list_dogs(user=Depends(get_current_user)):
d = dict(r)
d["is_guest"] = True
result.append(d)
+
+ # HdM-Siege pro Hund anhängen
+ if result:
+ dog_ids = [d["id"] for d in result]
+ with db() as conn:
+ wins_rows = conn.execute(
+ f"SELECT dog_id, monat FROM hund_des_monats_wins WHERE dog_id IN ({','.join('?'*len(dog_ids))}) ORDER BY monat DESC",
+ dog_ids,
+ ).fetchall()
+ wins_map: dict[int, list[str]] = {}
+ for w in wins_rows:
+ wins_map.setdefault(w["dog_id"], []).append(w["monat"])
+ for d in result:
+ d["hdm_wins"] = wins_map.get(d["id"], [])
+
return result
@@ -300,11 +315,13 @@ async def update_dog(dog_id: int, data: DogUpdate, user=Depends(get_current_user
values = list(fields.values()) + [dog_id, user["id"]]
with db() as conn:
- conn.execute(
+ updated = conn.execute(
f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values
- )
+ ).rowcount
+ if not updated:
+ raise HTTPException(404, "Hund nicht gefunden.")
dog = conn.execute(
- "SELECT * FROM dogs WHERE id=?", (dog_id,)
+ "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
return dict(dog)
@@ -398,8 +415,8 @@ async def delete_photo(dog_id: int, user=Depends(get_current_user)):
os.remove(path)
with db() as conn:
conn.execute(
- "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=?",
- (dog_id,)
+ "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=? AND user_id=?",
+ (dog_id, user["id"])
)
diff --git a/backend/routes/expenses.py b/backend/routes/expenses.py
new file mode 100644
index 0000000..9c93475
--- /dev/null
+++ b/backend/routes/expenses.py
@@ -0,0 +1,396 @@
+"""BAN YARO — Ausgaben-Tracker Routes"""
+
+import logging
+from datetime import date, timedelta
+from dateutil.relativedelta import relativedelta
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel
+from typing import Optional
+from database import db
+from auth import get_current_user
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonstiges"}
+
+
+# ------------------------------------------------------------------
+# Schemas
+# ------------------------------------------------------------------
+class ExpenseCreate(BaseModel):
+ dog_id: Optional[int] = None
+ kategorie: str
+ betrag: float
+ datum: str
+ notiz: Optional[str] = None
+
+
+class ExpenseUpdate(BaseModel):
+ dog_id: Optional[int] = None
+ kategorie: Optional[str] = None
+ betrag: Optional[float] = None
+ datum: Optional[str] = None
+ notiz: Optional[str] = None
+
+
+class RecurringCreate(BaseModel):
+ dog_id: Optional[int] = None
+ kategorie: str
+ betrag: float
+ haeufigkeit: str # monatlich | quartalsweise | jaehrlich
+ startdatum: str # ISO date
+ notiz: Optional[str] = None
+
+class RecurringUpdate(BaseModel):
+ dog_id: Optional[int] = None
+ kategorie: Optional[str] = None
+ betrag: Optional[float] = None
+ haeufigkeit: Optional[str] = None
+ startdatum: Optional[str] = None
+ notiz: Optional[str] = None
+ aktiv: Optional[bool] = None
+
+
+HAEUFIGKEITEN = {"monatlich", "quartalsweise", "jaehrlich"}
+
+
+def _next_due(startdatum: str, haeufigkeit: str, after: date) -> date:
+ """Berechnet das nächste Fälligkeitsdatum nach `after`."""
+ d = date.fromisoformat(startdatum)
+ if d > after:
+ return d
+ if haeufigkeit == "monatlich":
+ delta = relativedelta(months=1)
+ elif haeufigkeit == "quartalsweise":
+ delta = relativedelta(months=3)
+ else:
+ delta = relativedelta(years=1)
+ while d <= after:
+ d += delta
+ return d
+
+
+def _serialize(row) -> dict:
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# GET /api/expenses/summary — Monats- und Jahressummen
+# WICHTIG: Diese Route muss VOR /{id} stehen!
+# ------------------------------------------------------------------
+@router.get("/summary")
+async def get_summary(
+ dog_id: Optional[int] = Query(default=None),
+ user=Depends(get_current_user),
+):
+ today = date.today()
+ monat_prefix = today.strftime("%Y-%m")
+ jahr_prefix = today.strftime("%Y")
+
+ extra_cond = ""
+ extra_params: list = []
+ if dog_id is not None:
+ extra_cond = " AND dog_id=?"
+ extra_params = [dog_id]
+
+ with db() as conn:
+ # Monats-Summen pro Kategorie
+ rows_monat = conn.execute(
+ f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
+ FROM expenses
+ WHERE user_id=? AND datum LIKE ?{extra_cond}
+ GROUP BY kategorie""",
+ [user["id"], f"{monat_prefix}%"] + extra_params,
+ ).fetchall()
+
+ # Jahres-Summen pro Kategorie
+ rows_jahr = conn.execute(
+ f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
+ FROM expenses
+ WHERE user_id=? AND datum LIKE ?{extra_cond}
+ GROUP BY kategorie""",
+ [user["id"], f"{jahr_prefix}%"] + extra_params,
+ ).fetchall()
+
+ monat = {r["kategorie"]: round(r["summe"], 2) for r in rows_monat}
+ jahr = {r["kategorie"]: round(r["summe"], 2) for r in rows_jahr}
+
+ gesamt_monat = round(sum(monat.values()), 2)
+ gesamt_jahr = round(sum(jahr.values()), 2)
+
+ return {
+ "monat": monat,
+ "jahr": jahr,
+ "gesamt_monat": gesamt_monat,
+ "gesamt_jahr": gesamt_jahr,
+ }
+
+
+# ------------------------------------------------------------------
+# GET /api/expenses — Liste mit optionalen Filtern
+# ------------------------------------------------------------------
+@router.get("")
+async def list_expenses(
+ dog_id: Optional[int] = Query(default=None),
+ von: Optional[str] = Query(default=None),
+ bis: Optional[str] = Query(default=None),
+ limit: int = Query(default=100, le=500),
+ offset: int = Query(default=0),
+ user=Depends(get_current_user),
+):
+ conditions = ["e.user_id=?"]
+ params: list = [user["id"]]
+
+ if dog_id is not None:
+ conditions.append("e.dog_id=?")
+ params.append(dog_id)
+ if von:
+ conditions.append("e.datum >= ?")
+ params.append(von)
+ if bis:
+ conditions.append("e.datum <= ?")
+ params.append(bis)
+
+ where = " AND ".join(conditions)
+ params += [limit, offset]
+
+ with db() as conn:
+ rows = conn.execute(
+ f"""SELECT e.*, d.name AS dog_name
+ FROM expenses e
+ LEFT JOIN dogs d ON d.id = e.dog_id
+ WHERE {where}
+ ORDER BY e.datum DESC, e.id DESC
+ LIMIT ? OFFSET ?""",
+ params,
+ ).fetchall()
+
+ return [_serialize(r) for r in rows]
+
+
+# ------------------------------------------------------------------
+# POST /api/expenses — neuer Eintrag
+# ------------------------------------------------------------------
+@router.post("", status_code=201)
+async def create_expense(data: ExpenseCreate, user=Depends(get_current_user)):
+ if data.kategorie not in KATEGORIEN:
+ raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
+ if data.betrag <= 0:
+ raise HTTPException(400, "Betrag muss größer als 0 sein.")
+
+ with db() as conn:
+ # dog_id prüfen — muss dem User gehören
+ if data.dog_id is not None:
+ dog = conn.execute(
+ "SELECT id FROM dogs WHERE id=? AND user_id=?",
+ (data.dog_id, user["id"]),
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ conn.execute(
+ """INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (user["id"], data.dog_id, data.kategorie, data.betrag, data.datum, data.notiz),
+ )
+ row = conn.execute(
+ "SELECT * FROM expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
+ (user["id"],),
+ ).fetchone()
+
+ return _serialize(row)
+
+
+# ------------------------------------------------------------------
+# PATCH /api/expenses/{id} — bearbeiten
+# ------------------------------------------------------------------
+@router.patch("/{expense_id}")
+async def update_expense(
+ expense_id: int, data: ExpenseUpdate, user=Depends(get_current_user)
+):
+ with db() as conn:
+ row = conn.execute(
+ "SELECT * FROM expenses WHERE id=? AND user_id=?",
+ (expense_id, user["id"]),
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "Eintrag nicht gefunden.")
+
+ updates = {}
+ if data.kategorie is not None:
+ if data.kategorie not in KATEGORIEN:
+ raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
+ updates["kategorie"] = data.kategorie
+ if data.betrag is not None:
+ if data.betrag <= 0:
+ raise HTTPException(400, "Betrag muss größer als 0 sein.")
+ updates["betrag"] = data.betrag
+ if data.datum is not None:
+ updates["datum"] = data.datum
+ if data.notiz is not None:
+ updates["notiz"] = data.notiz
+ if data.dog_id is not None:
+ dog = conn.execute(
+ "SELECT id FROM dogs WHERE id=? AND user_id=?",
+ (data.dog_id, user["id"]),
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+ updates["dog_id"] = data.dog_id
+
+ if not updates:
+ return _serialize(row)
+
+ set_clause = ", ".join(f"{k}=?" for k in updates)
+ values = list(updates.values()) + [expense_id]
+ conn.execute(f"UPDATE expenses SET {set_clause} WHERE id=?", values)
+ row = conn.execute("SELECT * FROM expenses WHERE id=?", (expense_id,)).fetchone()
+
+ return _serialize(row)
+
+
+# ------------------------------------------------------------------
+# DELETE /api/expenses/{id} — löschen
+# ------------------------------------------------------------------
+@router.delete("/{expense_id}", status_code=204)
+async def delete_expense(expense_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ row = conn.execute(
+ "SELECT id FROM expenses WHERE id=? AND user_id=?",
+ (expense_id, user["id"]),
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "Eintrag nicht gefunden.")
+ conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
+ return None
+
+
+# ------------------------------------------------------------------
+# Wiederkehrende Ausgaben
+# ------------------------------------------------------------------
+@router.get("/recurring")
+async def list_recurring(user=Depends(get_current_user)):
+ with db() as conn:
+ rows = conn.execute(
+ """SELECT r.*, d.name AS dog_name
+ FROM recurring_expenses r
+ LEFT JOIN dogs d ON d.id = r.dog_id
+ WHERE r.user_id=? ORDER BY r.startdatum DESC""",
+ (user["id"],),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
+@router.post("/recurring", status_code=201)
+async def create_recurring(data: RecurringCreate, user=Depends(get_current_user)):
+ if data.kategorie not in KATEGORIEN:
+ raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
+ if data.haeufigkeit not in HAEUFIGKEITEN:
+ raise HTTPException(400, f"Ungültige Häufigkeit: {data.haeufigkeit}")
+ if data.betrag <= 0:
+ raise HTTPException(400, "Betrag muss größer als 0 sein.")
+
+ today = date.today()
+ naechste = _next_due(data.startdatum, data.haeufigkeit, today - timedelta(days=1))
+
+ with db() as conn:
+ if data.dog_id:
+ if not conn.execute("SELECT 1 FROM dogs WHERE id=? AND user_id=?",
+ (data.dog_id, user["id"])).fetchone():
+ raise HTTPException(404, "Hund nicht gefunden.")
+ conn.execute(
+ """INSERT INTO recurring_expenses
+ (user_id, dog_id, kategorie, betrag, haeufigkeit, startdatum, naechste_faelligkeit, notiz)
+ VALUES (?,?,?,?,?,?,?,?)""",
+ (user["id"], data.dog_id, data.kategorie, data.betrag,
+ data.haeufigkeit, data.startdatum, str(naechste), data.notiz),
+ )
+ row = conn.execute(
+ "SELECT * FROM recurring_expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
+ (user["id"],),
+ ).fetchone()
+ return dict(row)
+
+
+@router.patch("/recurring/{rid}")
+async def update_recurring(rid: int, data: RecurringUpdate, user=Depends(get_current_user)):
+ with db() as conn:
+ row = conn.execute(
+ "SELECT * FROM recurring_expenses WHERE id=? AND user_id=?", (rid, user["id"])
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "Dauerauftrag nicht gefunden.")
+ updates: dict = {}
+ if data.kategorie is not None:
+ if data.kategorie not in KATEGORIEN:
+ raise HTTPException(400, f"Ungültige Kategorie.")
+ updates["kategorie"] = data.kategorie
+ if data.betrag is not None:
+ updates["betrag"] = data.betrag
+ if data.haeufigkeit is not None:
+ if data.haeufigkeit not in HAEUFIGKEITEN:
+ raise HTTPException(400, "Ungültige Häufigkeit.")
+ updates["haeufigkeit"] = data.haeufigkeit
+ if data.startdatum is not None:
+ updates["startdatum"] = data.startdatum
+ if data.notiz is not None:
+ updates["notiz"] = data.notiz
+ if data.aktiv is not None:
+ updates["aktiv"] = 1 if data.aktiv else 0
+ if updates:
+ # naechste_faelligkeit neu berechnen wenn relevante Felder geändert
+ startdatum = updates.get("startdatum", row["startdatum"])
+ haeufigkeit = updates.get("haeufigkeit", row["haeufigkeit"])
+ today = date.today()
+ updates["naechste_faelligkeit"] = str(
+ _next_due(startdatum, haeufigkeit, today - timedelta(days=1))
+ )
+ set_clause = ", ".join(f"{k}=?" for k in updates)
+ conn.execute(f"UPDATE recurring_expenses SET {set_clause} WHERE id=?",
+ [*updates.values(), rid])
+ row = conn.execute("SELECT * FROM recurring_expenses WHERE id=?", (rid,)).fetchone()
+ return dict(row)
+
+
+@router.delete("/recurring/{rid}", status_code=204)
+async def delete_recurring(rid: int, user=Depends(get_current_user)):
+ with db() as conn:
+ if not conn.execute("SELECT 1 FROM recurring_expenses WHERE id=? AND user_id=?",
+ (rid, user["id"])).fetchone():
+ raise HTTPException(404, "Dauerauftrag nicht gefunden.")
+ conn.execute("DELETE FROM recurring_expenses WHERE id=?", (rid,))
+ return None
+
+
+def process_due_recurring(user_id: int | None = None):
+ """Legt fällige Daueraufträge als Einträge an. Wird vom Scheduler aufgerufen."""
+ today = date.today()
+ today_str = str(today)
+ with db() as conn:
+ where = "aktiv=1 AND naechste_faelligkeit <= ?"
+ params: list = [today_str]
+ if user_id:
+ where += " AND user_id=?"
+ params.append(user_id)
+ rows = conn.execute(
+ f"SELECT * FROM recurring_expenses WHERE {where}", params
+ ).fetchall()
+
+ for r in rows:
+ # Eintrag anlegen
+ conn.execute(
+ """INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
+ VALUES (?,?,?,?,?,?)""",
+ (r["user_id"], r["dog_id"], r["kategorie"], r["betrag"],
+ r["naechste_faelligkeit"],
+ f"[Dauerauftrag] {r['notiz'] or r['kategorie']}"),
+ )
+ # Nächste Fälligkeit berechnen
+ naechste = _next_due(r["startdatum"], r["haeufigkeit"],
+ date.fromisoformat(r["naechste_faelligkeit"]))
+ conn.execute(
+ "UPDATE recurring_expenses SET naechste_faelligkeit=? WHERE id=?",
+ (str(naechste), r["id"]),
+ )
+ return len(rows) if rows else 0
diff --git a/backend/routes/forum.py b/backend/routes/forum.py
index 0cfe1df..2834ab0 100644
--- a/backend/routes/forum.py
+++ b/backend/routes/forum.py
@@ -7,6 +7,8 @@ from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
from timeutils import safe_client_time
+from ratelimit import is_duplicate_post, record_post
+from content_filter import check_forum_content
from routes.push import send_push_to_user
from media_utils import convert_media, extract_video_thumb, generate_preview, preview_url_from
@@ -164,6 +166,50 @@ async def list_threads(
# ------------------------------------------------------------------
# POST /api/forum/threads
# ------------------------------------------------------------------
+def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None, is_thread: bool = False):
+ """Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts."""
+ # 30-Sekunden-Cooldown zwischen beliebigen Posts
+ last = conn.execute(
+ """SELECT MAX(created_at) AS last FROM (
+ SELECT created_at FROM forum_threads WHERE user_id=?
+ UNION ALL
+ SELECT created_at FROM forum_posts WHERE user_id=?
+ )""",
+ (user_id, user_id),
+ ).fetchone()["last"]
+ if last:
+ try:
+ from datetime import datetime as _dt
+ diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds()
+ if diff < 30:
+ raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.")
+ except (ValueError, TypeError):
+ pass
+
+ # Stunden-Limit
+ if is_thread:
+ count = conn.execute(
+ "SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')",
+ (user_id,),
+ ).fetchone()[0]
+ if count >= 5:
+ raise HTTPException(429, "Du hast in dieser Stunde bereits 5 Threads erstellt. Bitte warte etwas.")
+ else:
+ count = conn.execute(
+ "SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > datetime('now','-1 hour')",
+ (user_id,),
+ ).fetchone()[0]
+ if count >= 20:
+ raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.")
+
+ # Duplikat-Check
+ if is_duplicate_post(user_id, text):
+ raise HTTPException(400, "Dieser Beitrag wurde bereits kürzlich gepostet.")
+
+ # Content-Filter
+ check_forum_content(text, user_created_at)
+
+
@router.post("/threads", status_code=201)
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if not user.get("email_verified"):
@@ -177,6 +223,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, "Ungültige Kategorie.")
with db() as conn:
+ _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True)
ct = safe_client_time(data.client_time)
cur = conn.execute(
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at)
@@ -194,6 +241,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
t = dict(row)
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
t['user_liked'] = False
+ record_post(user["id"], data.text.strip())
return t
@@ -322,6 +370,8 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
if thread['is_deleted']:
raise HTTPException(404, "Thread nicht gefunden.")
+ _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False)
+
ct = safe_client_time(data.client_time)
cur = conn.execute(
"INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
@@ -347,6 +397,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
pd = dict(row)
pd['foto_urls'] = []
pd['user_liked'] = False
+ record_post(user["id"], data.text.strip())
# Push-Notification an Thread-Owner (nicht an sich selbst)
if owner_id and owner_id != user['id']:
@@ -590,7 +641,7 @@ async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_c
# GET /api/forum/members/map
# ------------------------------------------------------------------
@router.get("/members/map")
-async def members_map():
+async def members_map(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute(
"""SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname,
diff --git a/backend/routes/health_docs.py b/backend/routes/health_docs.py
new file mode 100644
index 0000000..0c2d4a7
--- /dev/null
+++ b/backend/routes/health_docs.py
@@ -0,0 +1,138 @@
+"""BAN YARO — Gesundheitsdokumente (Befunde, Röntgen, Rezepte …)"""
+
+import os
+import uuid
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
+from typing import Optional
+from database import db
+from auth import get_current_user
+
+router = APIRouter()
+MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
+
+ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
+MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
+
+ERLAUBTE_TYPEN = {"blutbild", "roentgen", "rezept", "impfausweis", "sonstiges"}
+
+
+def _check_dog_owner(conn, dog_id: int, user_id: int):
+ dog = conn.execute(
+ "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+ return dog
+
+
+# ------------------------------------------------------------------
+# GET /api/health-docs?dog_id=...
+# ------------------------------------------------------------------
+@router.get("")
+async def list_docs(dog_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_owner(conn, dog_id, user["id"])
+ rows = conn.execute(
+ """SELECT hd.*, t.name AS vet_name
+ FROM health_documents hd
+ LEFT JOIN tieraerzte t ON t.id = hd.vet_id
+ WHERE hd.dog_id=?
+ ORDER BY hd.created_at DESC""",
+ (dog_id,)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
+# ------------------------------------------------------------------
+# POST /api/health-docs/upload (multipart/form-data)
+# ------------------------------------------------------------------
+@router.post("/upload", status_code=201)
+async def upload_doc(
+ dog_id: int = Form(...),
+ typ: str = Form(...),
+ titel: str = Form(...),
+ beschreibung: Optional[str] = Form(None),
+ datum: Optional[str] = Form(None),
+ vet_id: Optional[int] = Form(None),
+ file: UploadFile = File(...),
+ user=Depends(get_current_user),
+):
+ if typ not in ERLAUBTE_TYPEN:
+ raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(sorted(ERLAUBTE_TYPEN))}")
+
+ ext = os.path.splitext(file.filename or "")[1].lower()
+ if not ext:
+ ext = ".jpg"
+ if ext not in ALLOWED_EXTENSIONS:
+ raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.")
+
+ content = await file.read()
+ if len(content) > MAX_SIZE_BYTES:
+ raise HTTPException(413, "Datei zu groß. Maximal 10 MB erlaubt.")
+
+ with db() as conn:
+ _check_dog_owner(conn, dog_id, user["id"])
+ if vet_id:
+ vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (vet_id,)).fetchone()
+ if not vet:
+ vet_id = None
+
+ # Datei speichern
+ dog_dir = os.path.join(MEDIA_DIR, "health_docs", str(dog_id))
+ os.makedirs(dog_dir, exist_ok=True)
+ filename = f"{uuid.uuid4().hex}{ext}"
+ filepath = os.path.join(dog_dir, filename)
+ with open(filepath, "wb") as f:
+ f.write(content)
+
+ file_url = f"/media/health_docs/{dog_id}/{filename}"
+ file_type = "pdf" if ext == ".pdf" else ext.lstrip(".")
+
+ with db() as conn:
+ conn.execute(
+ """INSERT INTO health_documents
+ (dog_id, user_id, typ, titel, beschreibung, file_path, file_type, datum, vet_id)
+ VALUES (?,?,?,?,?,?,?,?,?)""",
+ (dog_id, user["id"], typ, titel.strip(), beschreibung,
+ file_url, file_type, datum or None, vet_id)
+ )
+ row = conn.execute(
+ """SELECT hd.*, t.name AS vet_name
+ FROM health_documents hd
+ LEFT JOIN tieraerzte t ON t.id = hd.vet_id
+ WHERE hd.id = last_insert_rowid()"""
+ ).fetchone()
+
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# DELETE /api/health-docs/{id}
+# ------------------------------------------------------------------
+@router.delete("/{doc_id}", status_code=204)
+async def delete_doc(doc_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ row = conn.execute(
+ "SELECT * FROM health_documents WHERE id=? AND user_id=?",
+ (doc_id, user["id"])
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "Dokument nicht gefunden.")
+
+ # Datei löschen — file_path ist z.B. /media/health_docs/42/abc123.pdf
+ file_path = row["file_path"]
+ if file_path:
+ # /media/... → MEDIA_DIR/...
+ rel = file_path.lstrip("/")
+ if rel.startswith("media/"):
+ rel = rel[len("media/"):]
+ abs_path = os.path.join(MEDIA_DIR, rel)
+ if os.path.isfile(abs_path):
+ try:
+ os.remove(abs_path)
+ except OSError:
+ pass
+
+ conn.execute("DELETE FROM health_documents WHERE id=?", (doc_id,))
+
+ return None
diff --git a/backend/routes/jobs.py b/backend/routes/jobs.py
new file mode 100644
index 0000000..59c73c2
--- /dev/null
+++ b/backend/routes/jobs.py
@@ -0,0 +1,327 @@
+"""BAN YARO — Social-Media-Job Bewerbungs-System"""
+
+import html as _html
+import os
+import uuid
+from datetime import datetime, timedelta
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
+from fastapi.responses import FileResponse
+from typing import Optional
+from database import db
+from auth import get_current_user, get_current_user_optional, require_admin
+from mailer import send_email, email_html
+
+router = APIRouter()
+
+MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
+JOBS_DIR = os.path.join(MEDIA_DIR, "jobs")
+TRIAL_DAYS = 14
+MAX_FILES = 3
+MAX_FILE_MB = 10
+
+os.makedirs(JOBS_DIR, exist_ok=True)
+
+_ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"}
+
+
+# ------------------------------------------------------------------
+# POST /api/jobs/apply
+# ------------------------------------------------------------------
+async def apply(
+ name: str = Form(...),
+ email: str = Form(...),
+ dog_name: str = Form(""),
+ dog_rasse: str = Form(""),
+ social_handle: str = Form(...),
+ motivation: str = Form(...),
+ files: list[UploadFile] = File(default=[]),
+ user = Depends(get_current_user_optional),
+):
+ if len(motivation.strip()) < 80:
+ raise HTTPException(400, "Bitte schreibe etwas mehr über dich (mindestens 80 Zeichen).")
+ if len(files) > MAX_FILES:
+ raise HTTPException(400, f"Maximal {MAX_FILES} Dateien erlaubt.")
+
+ user_id = user["id"] if user else None
+
+ # Doppelbewerbung verhindern
+ if user_id:
+ with db() as conn:
+ existing = conn.execute(
+ "SELECT id FROM job_applications WHERE user_id=? AND status NOT IN ('rejected')",
+ (user_id,)
+ ).fetchone()
+ if existing:
+ raise HTTPException(400, "Du hast bereits eine aktive Bewerbung eingereicht.")
+
+ with db() as conn:
+ cur = conn.execute("""
+ INSERT INTO job_applications
+ (user_id, name, email, dog_name, dog_rasse, social_handle, motivation)
+ VALUES (?,?,?,?,?,?,?)
+ """, (user_id, name.strip(), email.strip(), dog_name.strip(),
+ dog_rasse.strip(), social_handle.strip(), motivation.strip()))
+ app_id = cur.lastrowid
+
+ # Dokumente speichern
+ app_dir = os.path.join(JOBS_DIR, str(app_id))
+ os.makedirs(app_dir, exist_ok=True)
+
+ for f in files:
+ if not f.filename:
+ continue
+ ext = os.path.splitext(f.filename)[1].lower()
+ if ext not in _ALLOWED_EXT:
+ continue
+ size = 0
+ safe_name = f"{uuid.uuid4().hex}{ext}"
+ dest = os.path.join(app_dir, safe_name)
+ with open(dest, "wb") as out:
+ while chunk := await f.read(65536):
+ size += len(chunk)
+ if size > MAX_FILE_MB * 1024 * 1024:
+ out.close()
+ os.remove(dest)
+ raise HTTPException(400, f"Datei zu groß (max. {MAX_FILE_MB} MB).")
+ out.write(chunk)
+ conn.execute("""
+ INSERT INTO job_application_docs (application_id, filename, file_path)
+ VALUES (?,?,?)
+ """, (app_id, f.filename, dest))
+
+ # Luna-Probezugang: 14 Tage ab sofort
+ if user_id:
+ trial_until = (datetime.utcnow() + timedelta(days=TRIAL_DAYS)).isoformat()
+ conn.execute(
+ "UPDATE users SET luna_trial_until=? WHERE id=?",
+ (trial_until, user_id)
+ )
+
+ # Bestätigungs-Mail an Bewerber
+ try:
+ _name = _html.escape(name)
+ body = f"""
+ Hallo {_name} ,
+
+ deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen.
+ Wir melden uns bald bei dir!
+
+ {"🎉 Luna-Probezugang aktiviert! Du hast für 14 Tage kostenlos Zugang zu Luna, unserem KI-Social-Media-Assistenten. Logge dich ein und probiere ihn aus.
" if user_id else ""}
+ Das Ban Yaro Team
"""
+ await send_email(
+ email,
+ "Deine Bewerbung bei Ban Yaro 🐾",
+ email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"),
+ f"Hallo {_name}, deine Bewerbung ist eingegangen!",
+ )
+ except Exception:
+ pass
+
+ # Admin benachrichtigen
+ try:
+ admin_email = os.getenv("ADMIN_EMAIL", "")
+ if admin_email:
+ _ename = _html.escape(name)
+ _eemail = _html.escape(email)
+ _edog_name = _html.escape(dog_name)
+ _edog_rasse = _html.escape(dog_rasse)
+ _ehandle = _html.escape(social_handle)
+ _emotivation = _html.escape(motivation[:300])
+ admin_body = f"""
+ Neue Job-Bewerbung eingegangen:
+
+ Name {_ename}
+ E-Mail {_eemail}
+ Hund {_edog_name} ({_edog_rasse})
+ Social {_ehandle}
+ Anhänge {len([f for f in files if f.filename])} Datei(en)
+
+ {_emotivation}{"…" if len(motivation)>300 else ""}
"""
+ await send_email(
+ admin_email,
+ f"[Banyaro Jobs] Neue Bewerbung — {name}",
+ email_html(admin_body, cta_url="https://banyaro.app/#admin", cta_label="Im Admin-Bereich prüfen"),
+ f"Neue Bewerbung von {name} ({email})",
+ )
+ except Exception:
+ pass
+
+ return {
+ "ok": True,
+ "application_id": app_id,
+ "luna_trial": user_id is not None,
+ "trial_days": TRIAL_DAYS,
+ }
+
+
+# FastAPI braucht expliziten Router-Decorator
+router.add_api_route("/apply", apply, methods=["POST"], status_code=201)
+
+
+# ------------------------------------------------------------------
+# GET /api/jobs/my-application
+# ------------------------------------------------------------------
+@router.get("/my-application")
+async def my_application(user=Depends(get_current_user)):
+ with db() as conn:
+ row = conn.execute(
+ """SELECT id, status, admin_note, created_at
+ FROM job_applications WHERE user_id=?
+ ORDER BY created_at DESC LIMIT 1""",
+ (user["id"],)
+ ).fetchone()
+ if not row:
+ return {"application": None}
+ return {"application": dict(row)}
+
+
+# ------------------------------------------------------------------
+# GET /api/jobs/luna-trial-status
+# ------------------------------------------------------------------
+@router.get("/luna-trial-status")
+async def luna_trial_status(user=Depends(get_current_user)):
+ from datetime import datetime as _dt
+ trial = user.get("luna_trial_until")
+ if not trial:
+ return {"active": False}
+ remaining = (_dt.fromisoformat(trial) - _dt.utcnow()).days
+ return {"active": remaining > 0, "until": trial, "remaining_days": max(0, remaining)}
+
+
+# ------------------------------------------------------------------
+# Admin: Bewerbungen verwalten
+# ------------------------------------------------------------------
+@router.get("/admin/applications")
+async def list_applications(
+ status: str = "pending",
+ admin = Depends(require_admin),
+):
+ where = "" if status == "alle" else "WHERE a.status=?"
+ params = [] if status == "alle" else [status]
+ with db() as conn:
+ rows = conn.execute(f"""
+ SELECT a.*, u.name AS username,
+ COUNT(d.id) AS doc_count
+ FROM job_applications a
+ LEFT JOIN users u ON u.id = a.user_id
+ LEFT JOIN job_application_docs d ON d.application_id = a.id
+ {where}
+ GROUP BY a.id
+ ORDER BY a.created_at DESC
+ """, params).fetchall()
+ return [dict(r) for r in rows]
+
+
+@router.get("/admin/applications/{app_id}")
+async def get_application(app_id: int, admin=Depends(require_admin)):
+ with db() as conn:
+ row = conn.execute(
+ """SELECT a.*, u.name AS username, u.email AS user_email
+ FROM job_applications a
+ LEFT JOIN users u ON u.id = a.user_id
+ WHERE a.id=?""",
+ (app_id,)
+ ).fetchone()
+ if not row:
+ raise HTTPException(404)
+ docs = conn.execute(
+ "SELECT id, filename, uploaded_at FROM job_application_docs WHERE application_id=?",
+ (app_id,)
+ ).fetchall()
+ return {**dict(row), "docs": [dict(d) for d in docs]}
+
+
+@router.patch("/admin/applications/{app_id}")
+async def update_application(
+ app_id: int,
+ status: Optional[str] = None,
+ admin_note: Optional[str] = None,
+ admin = Depends(require_admin),
+):
+ valid = {"pending", "reviewing", "accepted", "rejected"}
+ if status and status not in valid:
+ raise HTTPException(400, f"Ungültiger Status. Erlaubt: {valid}")
+
+ with db() as conn:
+ row = conn.execute(
+ "SELECT user_id, email, name, status FROM job_applications WHERE id=?",
+ (app_id,)
+ ).fetchone()
+ if not row:
+ raise HTTPException(404)
+
+ updates: dict = {"reviewed_at": datetime.utcnow().isoformat()}
+ if status:
+ updates["status"] = status
+ if admin_note is not None:
+ updates["admin_note"] = admin_note
+
+ set_clause = ", ".join(f"{k}=?" for k in updates)
+ conn.execute(
+ f"UPDATE job_applications SET {set_clause} WHERE id=?",
+ (*updates.values(), app_id)
+ )
+
+ # Bei Annahme: is_social_media aktivieren + Gründer-Status
+ if status == "accepted" and row["user_id"]:
+ conn.execute(
+ "UPDATE users SET is_social_media=1 WHERE id=?",
+ (row["user_id"],)
+ )
+ founder_count = conn.execute(
+ "SELECT COUNT(*) FROM users WHERE is_founder=1"
+ ).fetchone()[0]
+ if founder_count < 100:
+ conn.execute(
+ "UPDATE users SET is_founder=1 WHERE id=? AND is_founder=0",
+ (row["user_id"],)
+ )
+
+ # Status-Mail an Bewerber
+ try:
+ if status in ("accepted", "rejected", "reviewing"):
+ _send_status_mail(row["email"], row["name"], status, admin_note or "")
+ except Exception:
+ pass
+
+ return {"ok": True}
+
+
+@router.get("/admin/applications/{app_id}/docs/{doc_id}")
+async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)):
+ with db() as conn:
+ doc = conn.execute(
+ "SELECT file_path, filename FROM job_application_docs WHERE id=? AND application_id=?",
+ (doc_id, app_id)
+ ).fetchone()
+ if not doc or not os.path.exists(doc["file_path"]):
+ raise HTTPException(404)
+ return FileResponse(doc["file_path"], filename=doc["filename"])
+
+
+def _send_status_mail(email: str, name: str, status: str, note: str):
+ import asyncio
+ _ename = _html.escape(name)
+ texts = {
+ "reviewing": ("Wir schauen uns deine Bewerbung genauer an 🐾",
+ f"Hallo {_ename} ,
wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!
"),
+ "accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉",
+ f"Hallo {_ename} ,
wir freuen uns, dir mitzuteilen: du bist unser neuer Social-Media-Manager/in für Ban Yaro! Du erhältst außerdem den Gründer-Status in unserer Community. Willkommen im Team!
"),
+ "rejected": ("Deine Bewerbung bei Ban Yaro",
+ f"Hallo {_ename} ,
vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!
"),
+ }
+ subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"Hallo {_ename},
"))
+ note_html = f'{_html.escape(note)}
' if note else ""
+ body = body_start + note_html
+
+ async def _send():
+ await send_email(email, subj, email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), subj)
+
+ try:
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ asyncio.ensure_future(_send())
+ else:
+ loop.run_until_complete(_send())
+ except Exception:
+ pass
diff --git a/backend/routes/ki.py b/backend/routes/ki.py
index aa8d001..80d663c 100644
--- a/backend/routes/ki.py
+++ b/backend/routes/ki.py
@@ -1,10 +1,11 @@
"""BAN YARO — KI Routes"""
-from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel
from typing import Optional
import ki as ki_module
from auth import get_current_user
from ratelimit import check as rl_check
+from database import db
router = APIRouter()
@@ -62,3 +63,224 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(500, "KI momentan nicht verfügbar.")
+
+
+# ------------------------------------------------------------------
+# POST /ki/tierarzt — KI-Tierarztfragen
+# ------------------------------------------------------------------
+class TierarztRequest(BaseModel):
+ symptom: str
+ dog_id: Optional[int] = None
+ dog_name: Optional[str] = None
+ rasse: Optional[str] = None
+
+
+@router.post("/tierarzt")
+async def ki_tierarzt(req: TierarztRequest, request: Request,
+ user=Depends(get_current_user)):
+ """KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung."""
+ if not req.symptom or len(req.symptom.strip()) < 5:
+ raise HTTPException(400, "Bitte beschreibe das Symptom genauer.")
+ if len(req.symptom) > 1000:
+ raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).")
+
+ # Rate-Limit: max 5 Anfragen pro User pro Tag
+ with db() as conn:
+ count = conn.execute(
+ "SELECT COUNT(*) FROM ki_tierarzt_log "
+ "WHERE user_id=? AND created_at >= datetime('now','-1 day')",
+ (user["id"],)
+ ).fetchone()[0]
+ if count >= 5:
+ raise HTTPException(429, "Tageslimit erreicht. Du kannst maximal 5 Tierarztfragen pro Tag stellen.")
+
+ dog_name = req.dog_name or "unbekannt"
+ rasse = req.rasse or "unbekannt"
+
+ system = (
+ "Du bist ein erfahrener Tierarzt-Assistent für Hunde. "
+ "Deine Aufgabe ist es, Hundebesitzern eine erste Orientierung zu geben — "
+ "kein Ersatz für eine echte tierärztliche Untersuchung. "
+ "Antworte immer auf Deutsch, klar und verständlich. "
+ "Stelle keine medizinischen Diagnosen. "
+ "Empfehle im Zweifel immer den Gang zum Tierarzt."
+ )
+
+ prompt = f"""Hund: {dog_name}, Rasse: {rasse}
+Symptom: {req.symptom.strip()}
+
+Gib eine strukturierte, verständliche Einschätzung:
+1. Mögliche Ursachen (2-3 wahrscheinlichste)
+2. Was der Besitzer jetzt tun kann (Erstmaßnahmen)
+3. Wann unbedingt zum Tierarzt (Dringlichkeit: beobachten / bald / sofort)
+
+Antworte auf Deutsch, klar und verständlich. Maximal 300 Wörter.
+Schreibe KEINE medizinischen Diagnosen und empfehle im Zweifel immer den Tierarzt."""
+
+ try:
+ antwort = await ki_module.complete(
+ prompt=prompt,
+ system=system,
+ max_tokens=600,
+ requires_premium=False,
+ user_id=user["id"],
+ )
+ # Erfolg: Rate-Limit-Eintrag speichern
+ with db() as conn:
+ conn.execute(
+ "INSERT INTO ki_tierarzt_log (user_id, dog_id) VALUES (?, ?)",
+ (user["id"], req.dog_id)
+ )
+ return {"antwort": antwort, "anfragen_heute": count + 1, "limit": 5}
+ except ki_module.KIUnavailableError as e:
+ raise HTTPException(503, str(e))
+ except HTTPException:
+ raise
+ except Exception:
+ raise HTTPException(500, "KI momentan nicht verfügbar.")
+
+
+# ------------------------------------------------------------------
+# Rate-Limit-Helfer für Rassen-Erkennung
+# ------------------------------------------------------------------
+_RASSE_DAILY_LIMIT = 10
+
+
+def _check_rasse_limit(user_id: int) -> int:
+ """Gibt verbleibende Erkennungen zurück. Wirft HTTPException wenn Limit erreicht."""
+ with db() as conn:
+ used = conn.execute(
+ """SELECT COUNT(*) FROM ki_rasse_log
+ WHERE user_id = ? AND created_at >= datetime('now', 'start of day')""",
+ (user_id,)
+ ).fetchone()[0]
+ remaining = _RASSE_DAILY_LIMIT - used
+ if remaining <= 0:
+ raise HTTPException(429, f"Tageslimit erreicht ({_RASSE_DAILY_LIMIT} Erkennungen/Tag). Morgen wieder verfügbar.")
+ return remaining
+
+
+def _log_rasse_request(user_id: int):
+ with db() as conn:
+ conn.execute(
+ "INSERT INTO ki_rasse_log (user_id) VALUES (?)", (user_id,)
+ )
+
+
+# ------------------------------------------------------------------
+# POST /ki/rasse-erkennung — Vision-basierte Rassenerkennung
+# ------------------------------------------------------------------
+@router.post("/rasse-erkennung")
+async def ki_rasse_erkennung(
+ request: Request,
+ file: UploadFile = File(...),
+ user=Depends(get_current_user),
+):
+ """Hunderassen per Foto erkennen (Claude Vision, max 5 MB, 10x/Tag)."""
+ import base64
+ import json
+ import re
+ import anthropic
+
+ # Dateigröße prüfen
+ content = await file.read()
+ if len(content) > 5 * 1024 * 1024:
+ raise HTTPException(400, "Bild zu groß. Maximal 5 MB erlaubt.")
+
+ # MIME-Typ prüfen
+ ct = (file.content_type or "").lower()
+ if not ct.startswith("image/"):
+ raise HTTPException(400, "Nur Bilddateien erlaubt (JPG, PNG, WebP).")
+
+ # MIME-Typ auf erlaubte Werte beschränken
+ allowed_mimes = {"image/jpeg", "image/png", "image/webp", "image/gif"}
+ mime_type = ct if ct in allowed_mimes else "image/jpeg"
+
+ # Rate-Limit prüfen
+ remaining_before = _check_rasse_limit(user["id"])
+
+ # Anthropic-Client holen (nutzt cached Instanz aus ki.py)
+ if not ki_module.ANTHROPIC_KEY:
+ raise HTTPException(503, "KI-Bildanalyse ist momentan nicht verfügbar.")
+
+ api_key = ki_module.ANTHROPIC_KEY
+ base64_data = base64.standard_b64encode(content).decode("utf-8")
+
+ prompt_text = """Analysiere dieses Bild und erkenne die Hunderasse(n).
+
+Antworte NUR im folgenden JSON-Format (kein anderer Text):
+{
+ "rassen": [
+ {"name": "Labrador Retriever", "sicherheit": 85, "beschreibung": "Kurze Begründung"},
+ {"name": "Golden Retriever", "sicherheit": 12, "beschreibung": "Falls Mischling"}
+ ],
+ "ist_hund": true,
+ "hinweis": "Optionaler Hinweis z.B. bei Welpen oder schlechter Bildqualität"
+}
+
+Gib 1-3 Rassen nach Wahrscheinlichkeit sortiert an. Sicherheit in Prozent (0-100).
+Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
+
+ try:
+ def _sync_call():
+ client = anthropic.Anthropic(api_key=api_key)
+ return client.messages.create(
+ model="claude-opus-4-7",
+ max_tokens=500,
+ messages=[{
+ "role": "user",
+ "content": [
+ {
+ "type": "image",
+ "source": {
+ "type": "base64",
+ "media_type": mime_type,
+ "data": base64_data,
+ }
+ },
+ {
+ "type": "text",
+ "text": prompt_text,
+ }
+ ]
+ }]
+ )
+
+ import asyncio
+ response = await asyncio.get_event_loop().run_in_executor(None, _sync_call)
+ raw = response.content[0].text.strip()
+
+ except anthropic.APIError as e:
+ raise HTTPException(503, f"KI-Bildanalyse nicht verfügbar: {e}")
+ except Exception as e:
+ raise HTTPException(500, "Fehler bei der Bildanalyse.")
+
+ # JSON parsen — Claude kann manchmal ```json ... ``` wrappen
+ cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.DOTALL).strip()
+ try:
+ parsed = json.loads(cleaned)
+ except json.JSONDecodeError:
+ raise HTTPException(500, "KI-Antwort konnte nicht verarbeitet werden.")
+
+ # Usage loggen (erst nach erfolgreicher KI-Antwort)
+ _log_rasse_request(user["id"])
+ remaining_after = remaining_before - 1
+
+ # Wiki-Slugs für erkannte Rassen nachschlagen
+ rassen = parsed.get("rassen", [])
+ if rassen:
+ with db() as conn:
+ for r in rassen:
+ name = r.get("name", "")
+ # Exakter Name-Match (case-insensitive)
+ row = conn.execute(
+ "SELECT slug FROM wiki_rassen WHERE LOWER(name) = LOWER(?)", (name,)
+ ).fetchone()
+ r["wiki_slug"] = row["slug"] if row else None
+
+ return {
+ "rassen": rassen,
+ "ist_hund": parsed.get("ist_hund", False),
+ "hinweis": parsed.get("hinweis") or None,
+ "verbleibende_anfragen": remaining_after,
+ }
diff --git a/backend/routes/litters.py b/backend/routes/litters.py
index 2bcf629..ddc810c 100644
--- a/backend/routes/litters.py
+++ b/backend/routes/litters.py
@@ -240,7 +240,7 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
# ------------------------------------------------------------------
@router.post("/litters/{litter_id}/welfare-confirm")
async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
- from mailer import send_email
+ from mailer import send_email, email_html
import os, logging as _log
_logger = _log.getLogger(__name__)
@@ -265,19 +265,21 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
eltern = conn.execute(
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
).fetchone()
- html = f"""
- Tierschutz-Hinweis bestätigt
- Züchter {zuechter} (Zwinger: {zwinger}) hat einen Wurf mit
- kritischen Tierschutz-Hinweisen trotzdem angelegt.
- Vater: {eltern['vater_name'] or '—'} · Mutter: {eltern['mutter_name'] or '—'}
- Wurf-ID: {litter_id}
- Im Admin-Bereich prüfen
- """
+ import html as _html
+ welfare_body = f"""
+ Kritischer Tierschutz-Hinweis bestätigt
+
+ Züchter {_html.escape(zuechter)}
+ Zwinger {_html.escape(zwinger)}
+ Vater {_html.escape(eltern['vater_name'] or '—')}
+ Mutter {_html.escape(eltern['mutter_name'] or '—')}
+ Wurf-ID #{litter_id}
+
"""
try:
await send_email(
admin_email,
f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}",
- html,
+ email_html(welfare_body, cta_url=f"{app_url}/#admin", cta_label="Im Admin-Bereich prüfen"),
f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.",
)
except Exception as e:
diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py
index 95b33a9..fa74871 100644
--- a/backend/routes/moderation.py
+++ b/backend/routes/moderation.py
@@ -1,4 +1,5 @@
"""BAN YARO — Moderations-Panel Backend"""
+from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from database import db
from auth import get_current_user
@@ -69,17 +70,19 @@ async def mod_stats(user=Depends(require_moderator)):
async def mod_reports(user=Depends(require_moderator)):
with db() as conn:
rows = conn.execute("""
- SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at,
- u.name AS melder_name,
+ SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved,
+ r.created_at, r.resolved_at,
+ u.name AS melder_name,
+ m.name AS resolved_by_name,
CASE r.target_type
WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id)
WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id)
END AS content_preview
FROM forum_reports r
LEFT JOIN users u ON u.id=r.user_id
- WHERE r.resolved=0
- ORDER BY r.created_at DESC
- LIMIT 100
+ LEFT JOIN users m ON m.id=r.resolved_by
+ ORDER BY r.resolved ASC, r.created_at DESC
+ LIMIT 200
""").fetchall()
return [dict(r) for r in rows]
@@ -97,8 +100,12 @@ async def mod_resolve_report(rid: int, user=Depends(require_moderator)):
raise HTTPException(404, "Meldung nicht gefunden.")
new_state = 0 if r["resolved"] else 1
conn.execute(
- "UPDATE forum_reports SET resolved=? WHERE id=?",
- (new_state, rid)
+ """UPDATE forum_reports SET resolved=?, resolved_by=?, resolved_at=?
+ WHERE id=?""",
+ (new_state,
+ user["id"] if new_state else None,
+ datetime.utcnow().isoformat() if new_state else None,
+ rid)
)
return {"ok": True}
@@ -189,17 +196,19 @@ async def mod_patch_user(uid: int, data: dict, user=Depends(require_moderator)):
async def mod_fotos(user=Depends(require_moderator)):
with db() as conn:
rows = conn.execute("""
- SELECT s.id, s.foto_url, s.created_at,
+ SELECT s.id, s.foto_url, s.status, s.created_at,
+ s.reviewed_at, s.reject_reason,
COALESCE(s.rights_confirmed, 0) AS rights_confirmed,
- u.name AS user_name,
- r.name AS rasse_name, r.slug AS rasse_slug,
+ u.name AS user_name,
+ m.name AS reviewed_by_name,
+ r.name AS rasse_name, r.slug AS rasse_slug,
r.foto_url AS aktuell_foto
FROM wiki_foto_submissions s
LEFT JOIN users u ON u.id = s.user_id
+ LEFT JOIN users m ON m.id = s.reviewed_by
LEFT JOIN wiki_rassen r ON r.id = s.rasse_id
- WHERE s.status = 'pending'
- ORDER BY s.created_at ASC
- LIMIT 50
+ ORDER BY s.status ASC, s.created_at ASC
+ LIMIT 200
""").fetchall()
return [dict(r) for r in rows]
@@ -228,11 +237,13 @@ async def mod_poi_edits(user=Depends(require_moderator)):
SELECT e.id, e.osm_id, e.poi_name, e.field,
e.old_value, e.new_value, e.status,
e.created_at, e.resolved_at,
- u.name AS einreicher_name
+ u.name AS einreicher_name,
+ m.name AS mod_name
FROM osm_poi_edits e
JOIN users u ON u.id = e.user_id
+ LEFT JOIN users m ON m.id = e.mod_id
ORDER BY e.status ASC, e.created_at DESC
- LIMIT 100
+ LIMIT 200
""").fetchall()
return [dict(r) for r in rows]
@@ -257,6 +268,9 @@ async def mod_poi_edit_action(edit_id: int, data: dict,
raise HTTPException(409, "Korrektur wurde bereits bearbeitet.")
if action == "approve":
+ _ALLOWED_POI_FIELDS = {"opening_hours", "phone", "website", "name"}
+ if edit["field"] not in _ALLOWED_POI_FIELDS:
+ raise HTTPException(400, f"Ungültiges Feld: {edit['field']}")
conn.execute(
f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?",
(edit["new_value"], edit["osm_id"])
diff --git a/backend/routes/movies.py b/backend/routes/movies.py
index 5ef83da..da6c682 100644
--- a/backend/routes/movies.py
+++ b/backend/routes/movies.py
@@ -1,140 +1,380 @@
"""BAN YARO — Hunde-Filme Routes"""
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from database import db
-from auth import get_current_user, get_current_user_optional
+from auth import get_current_user, get_current_user_optional, require_admin
router = APIRouter()
# ------------------------------------------------------------------
-# Hardcoded Film-Daten
+# Seed-Daten — werden beim ersten Start in die DB geschrieben
# ------------------------------------------------------------------
-FILME = [
- {"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "bewertung_avg": 4.2},
- {"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "bewertung_avg": 4.0},
- {"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "bewertung_avg": 4.5},
- {"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "bewertung_avg": 4.8},
- {"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "bewertung_avg": 4.3},
- {"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "bewertung_avg": 3.8},
- {"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi/Serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "bewertung_avg": 4.1},
- {"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "bewertung_avg": 4.0},
- {"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "bewertung_avg": 3.5},
- {"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus. Kontroversiell beliebt.", "bild_emoji": "💣", "bewertung_avg": 4.6},
- {"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "bewertung_avg": 4.4},
- {"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "bewertung_avg": 4.3},
+_SEED_FILME = [
+ # ── Originalbestand ──────────────────────────────────────────────
+ {"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "imdb_rating": 7.0},
+ {"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "imdb_rating": 6.4},
+ {"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "imdb_rating": 7.1},
+ {"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "typ": "film", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "imdb_rating": 8.1},
+ {"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "imdb_rating": 7.2},
+ {"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "imdb_rating": 5.9},
+ {"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "imdb_rating": 7.5},
+ {"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.3},
+ {"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "imdb_rating": 5.7},
+ {"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus.", "bild_emoji": "💣", "imdb_rating": 7.4},
+ {"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "imdb_rating": 7.9},
+ {"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "imdb_rating": 7.3},
+ # ── Animation / Kinder ──────────────────────────────────────────
+ {"id": "lady-and-the-tramp", "titel": "Susi und Strolch", "originaltitel": "Lady and the Tramp", "jahr": 1955, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker mit der berühmtesten Spaghetti-Szene der Filmgeschichte.", "bild_emoji": "🍝", "imdb_rating": 7.3, "streaming": "Disney+"},
+ {"id": "fox-and-the-hound", "titel": "Cap und Capper", "originaltitel": "The Fox and the Hound", "jahr": 1981, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Bloodhound", "stirbt_der_hund": False, "beschreibung": "Emotionaler Disney-Film über Freundschaft zwischen Fuchs und Jagdhund — und wie die Welt sie trennt.", "bild_emoji": "🦊", "imdb_rating": 7.2, "streaming": "Disney+"},
+ {"id": "balto", "titel": "Balto", "jahr": 1995, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Husky/Wolf-Mischling", "stirbt_der_hund": False, "beschreibung": "1925 brachte Schlittenhund Balto lebensrettende Medizin nach Nome, Alaska. Basiert auf einer wahren Heldengeschichte.", "bild_emoji": "🐺", "imdb_rating": 7.1, "streaming": "Amazon Prime"},
+ {"id": "bolt", "titel": "Bolt — Ein Hund für alle Fälle", "originaltitel": "Bolt", "jahr": 2008, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Ein TV-Superhund glaubt, seine Kräfte seien echt, und reist abenteuerlich quer durch Amerika.", "bild_emoji": "⚡", "imdb_rating": 6.8, "streaming": "Disney+"},
+ {"id": "frankenweenie", "titel": "Frankenweenie", "jahr": 2012, "genre": "Animation/Horrorkomödie","typ": "film","hund_rasse": "Bullterrier", "stirbt_der_hund": True, "beschreibung": "Tim Burtons Stop-Motion-Meisterwerk: Ein Junge erweckt seinen toten Hund mit Wissenschaft wieder zum Leben.", "bild_emoji": "🧟", "imdb_rating": 6.9, "streaming": "Disney+"},
+ {"id": "secret-life-of-pets", "titel": "Pets — Geheimes Leben der Haustiere","originaltitel": "The Secret Life of Pets","jahr": 2016,"genre": "Animation/Komödie","typ": "film","hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Was machen unsere Haustiere, wenn wir nicht zu Hause sind? Rasante Antwort mit Witz und Charme.", "bild_emoji": "🏠", "imdb_rating": 6.5, "streaming": "Amazon Prime"},
+ {"id": "plague-dogs", "titel": "The Plague Dogs", "jahr": 1982, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Labrador / Mischling", "stirbt_der_hund": True, "beschreibung": "Düsterer Animationsfilm für Erwachsene: Zwei Hunde fliehen aus einem Tierversuchs-Labor. Brutal ehrlich, nach Richard Adams.", "bild_emoji": "🚫", "imdb_rating": 7.7},
+ {"id": "paw-patrol-movie", "titel": "PAW Patrol: Der Kinofilm", "originaltitel": "PAW Patrol: The Movie", "jahr": 2021, "genre": "Animation/Kinder", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Die beliebten TV-Rettungshunde auf der großen Leinwand. Für die jüngsten Fans ein Pflichtprogramm.", "bild_emoji": "🚒", "imdb_rating": 6.1, "streaming": "Amazon Prime"},
+ # ── Klassiker vor 1980 ──────────────────────────────────────────
+ {"id": "the-thin-man", "titel": "Der dünne Mann", "originaltitel": "The Thin Man", "jahr": 1934, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Drahthaariger Foxterrier", "stirbt_der_hund": False, "beschreibung": "Hollywood-Klassiker mit Nick und Nora Charles — und Asta, dem witzigsten Hund der Filmgeschichte. Mehrere Fortsetzungen.", "bild_emoji": "🍸", "imdb_rating": 7.9},
+ {"id": "lassie-come-home", "titel": "Lassie kehrt heim", "originaltitel": "Lassie Come Home", "jahr": 1943, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Originalfilm: Ein armes Farmkind muss seinen geliebten Collie verkaufen — Lassie findet trotzdem heim.", "bild_emoji": "🏡", "imdb_rating": 7.1},
+ {"id": "incredible-journey", "titel": "Die unglaubliche Reise", "originaltitel": "The Incredible Journey", "jahr": 1963, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Labrador", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze meistern 400 km kanadische Wildnis — Disney-Abenteuer nach dem Roman von Sheila Burnford.", "bild_emoji": "🗺️", "imdb_rating": 7.0},
+ {"id": "greyfriars-bobby", "titel": "Greyfriars Bobby", "jahr": 1961, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Skye Terrier", "stirbt_der_hund": False, "beschreibung": "Basiert auf der wahren Geschichte des Terriers, der 14 Jahre das Grab seines Herrchens in Edinburgh bewachte.", "bild_emoji": "⛪", "imdb_rating": 7.2, "streaming": "Disney+"},
+ {"id": "sounder", "titel": "Sounder", "jahr": 1972, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": False, "beschreibung": "Oscar-nominiertes Drama über eine schwarze Farmfamilie in der Great Depression. Ihr Hund Sounder ist das Herz der Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.5},
+ {"id": "where-red-fern-grows","titel": "Wo der rote Farn wächst", "originaltitel": "Where the Red Fern Grows", "jahr": 1974, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": True, "beschreibung": "Ein Junge spart jahrelang für zwei Jagdhunde. Kultfilm der amerikanischen Kindheit — das Ende lässt kaum jemanden trocken.", "bild_emoji": "🌿", "imdb_rating": 6.9},
+ {"id": "milo-and-otis", "titel": "Milo und Otis", "originaltitel": "The Adventures of Milo and Otis","jahr": 1986,"genre": "Abenteuer/Familie","typ": "film", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Japanischer Realfilm mit Katze und Hund auf großer Abenteuerreise. Für Kinder ein Klassiker, für Erwachsene nostalgisches Heimweh.", "bild_emoji": "🐾", "imdb_rating": 6.9},
+ {"id": "umberto-d", "titel": "Umberto D.", "jahr": 1952, "genre": "Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Meisterwerk des italienischen Neorealismus: Ein alter Rentner und sein Hund kämpfen würdevoll gegen Armut in Rom.", "bild_emoji": "🇮🇹", "imdb_rating": 8.1, "streaming": "Mubi"},
+ # ── Wahre Geschichten ───────────────────────────────────────────
+ {"id": "homeward-bound", "titel": "Auf dem Weg nach Hause", "originaltitel": "Homeward Bound", "jahr": 1993, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze kämpfen sich mit Stimmen durch die amerikanische Wildnis nach Hause. Remake des Klassikers.", "bild_emoji": "🏔️", "imdb_rating": 7.0, "streaming": "Disney+"},
+ {"id": "togo", "titel": "Togo", "jahr": 2019, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Sibirischer Husky", "stirbt_der_hund": False, "beschreibung": "Die unbekannte Geschichte hinter dem Balto-Mythos: Der echte Held des Serum-Runs 1925 war Togo. Außergewöhnlicher Disney+-Film.", "bild_emoji": "🛷", "imdb_rating": 7.9, "streaming": "Disney+"},
+ {"id": "red-dog", "titel": "Red Dog", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Kelpie", "stirbt_der_hund": True, "beschreibung": "Australischer Kultfilm über einen echten Wanderhund, der eine Minengemeinschaft im Outback zusammenbrachte. Rauh und herzlich.", "bild_emoji": "🦘", "imdb_rating": 7.3, "streaming": "Amazon Prime"},
+ {"id": "megan-leavey", "titel": "Megan Leavey", "jahr": 2017, "genre": "Biopic/Drama", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Die wahre Geschichte einer US-Marine und ihres Sprengstoff-Suchhundes Rex im Irak-Einsatz. Kate Mara in einer ihrer stärksten Rollen.", "bild_emoji": "🎖️", "imdb_rating": 7.1, "streaming": "Amazon Prime"},
+ {"id": "arthur-the-king", "titel": "Arthur der König", "originaltitel": "Arthur the King", "jahr": 2024, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Mark Wahlberg und ein streunender Hund meistern gemeinsam ein Extremrennen durch die Dominikanische Republik. Inspiriert von wahren Ereignissen.","bild_emoji": "🏆", "imdb_rating": 7.0, "streaming": "Amazon Prime"},
+ {"id": "rescued-by-ruby", "titel": "Gerettet von Ruby", "originaltitel": "Rescued by Ruby", "jahr": 2022, "genre": "Biopic/Familie", "typ": "film", "hund_rasse": "Australian Shepherd / Border Collie","stirbt_der_hund": False,"beschreibung": "Ein Polizist und ein Tierheim-Hund retten sich gegenseitig — wahre Geschichte aus Rhode Island.", "bild_emoji": "🌟", "imdb_rating": 7.2, "streaming": "Netflix"},
+ {"id": "my-dog-skip", "titel": "Mein Hund Skip", "originaltitel": "My Dog Skip", "jahr": 2000, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": True, "beschreibung": "Die Coming-of-Age-Geschichte eines einsamen Jungen im Mississippi der 1940er, der durch seinen Hund Skip Freundschaft findet.", "bild_emoji": "📚", "imdb_rating": 7.0},
+ # ── Arbeitshunde / Polizeihunde ─────────────────────────────────
+ {"id": "turner-and-hooch", "titel": "Turner & Hooch", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": True, "beschreibung": "Tom Hanks als ordentlicher Detective trifft auf Hooch, den sabbernden Chaoshund. Buddy-Cop-Klassiker mit überraschend emotionalem Ende.", "bild_emoji": "🕵️", "imdb_rating": 6.2, "streaming": "Disney+"},
+ {"id": "k9", "titel": "K-9", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Jim Belushi als Drogenfahnder bekommt zwangsweise den eigenwilligen Schäferhund Jerry als Partner. Klassiker des Buddy-Cop-Genres.", "bild_emoji": "🚔", "imdb_rating": 6.2},
+ {"id": "max", "titel": "Max", "jahr": 2015, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Ein Kriegshund aus Afghanistan wird nach dem Tod seines Handlers von dessen Familie adoptiert. Über Trauma und Vertrauen.", "bild_emoji": "🎗️", "imdb_rating": 6.6, "streaming": "Amazon Prime"},
+ {"id": "dog-2022", "titel": "Dog", "jahr": 2022, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Channing Tatum fährt mit einem traumatisierten Kriegshund quer durch Amerika — roh, komisch und berührend.", "bild_emoji": "🚗", "imdb_rating": 6.5, "streaming": "Amazon Prime"},
+ {"id": "quill", "titel": "Quill — Ein Führhund", "originaltitel": "Quill: The Life of a Guide Dog","jahr": 2004,"genre": "Drama/Familie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Japanischer Film über das Leben des Führhundes Quill vom Welpen bis zum Tod. Zeigt die unersetzliche Arbeit von Blindenführhunden.", "bild_emoji": "👁️", "imdb_rating": 7.1},
+ # ── Komödien ────────────────────────────────────────────────────
+ {"id": "beethoven-2", "titel": "Beethoven's 2nd", "jahr": 1993, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Beethoven verliebt sich und bekommt Nachwuchs — vier chaotische Welpen bringen die Familie erneut an den Rand des Nervenzusammenbruchs.", "bild_emoji": "🐶", "imdb_rating": 5.4},
+ {"id": "dog-days-2018", "titel": "Dog Days", "jahr": 2018, "genre": "Komödie/Romanze", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Mehrere Angelenos und ihre Hunde, deren Leben sich charmant verflechten. Leichte Feel-Good-Komödie mit Vanessa Hudgens.", "bild_emoji": "☀️", "imdb_rating": 6.3},
+ {"id": "as-good-as-it-gets", "titel": "Besser geht's nicht", "originaltitel": "As Good as It Gets", "jahr": 1997, "genre": "Komödie/Drama", "typ": "film", "hund_rasse": "Griffon Bruxellois", "stirbt_der_hund": False, "beschreibung": "Jack Nicholson als Misanthrop, der durch einen kleinen Hund namens Verdell sein Herz entdeckt. Oscar-Gewinner, zeitlos witzig.", "bild_emoji": "💊", "imdb_rating": 7.7, "streaming": "Amazon Prime"},
+ {"id": "eat-pray-bark", "titel": "Eat Pray Bark", "jahr": 2026, "genre": "Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Deutsche Komödie über fünf exzentrische Hundebesitzer, die gemeinsam einen Hundetrainer in den Tiroler Bergen aufsuchen. Top 10 in 49 Netflix-Ländern.", "bild_emoji": "🏔️", "imdb_rating": None, "streaming": "Netflix"},
+ {"id": "the-artist", "titel": "The Artist", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": False, "beschreibung": "Oscar-Gewinner als Stummfilm-Hommage. Uggie der Jack Russell stahl allen die Show und gewann den Palm Dog Award in Cannes.", "bild_emoji": "🎬", "imdb_rating": 7.8, "streaming": "Amazon Prime"},
+ # ── Thriller / Action / Horror ──────────────────────────────────
+ {"id": "cujo", "titel": "Cujo", "jahr": 1983, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": True, "beschreibung": "Stephen Kings Roman verfilmt: Ein tollwütiger Bernhardiner terrorisiert eine Mutter und ihr Kind in einem Auto. Klassiker des 80er-Horror.", "bild_emoji": "🩸", "imdb_rating": 6.1, "streaming": "Amazon Prime"},
+ {"id": "white-god", "titel": "White God — Hund ohne Gnade","originaltitel": "White God", "jahr": 2014, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Ungarischer Arthouse-Thriller mit 250 echten Straßenhunden. Ein Mädchen sucht seinen Hund — während die Hunde Rache nehmen. Cannes-Preis.", "bild_emoji": "🔴", "imdb_rating": 6.8},
+ {"id": "dogman-2018", "titel": "Dogman", "jahr": 2018, "genre": "Krimi/Drama", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matteo Garrones preisgekrönter Film: Ein stiller Hundegroomer verstrickt sich mit einem brutalen Kriminellen. Cannes-Gewinner 2018.", "bild_emoji": "✂️", "imdb_rating": 7.2, "streaming": "Amazon Prime"},
+ {"id": "call-of-the-wild", "titel": "Ruf der Wildnis", "originaltitel": "The Call of the Wild", "jahr": 2020, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Saint Bernard Mix (CGI)", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Harrison Ford: Hund Buck wandert vom Salon-Leben in die Wildnis des Klondike. Episch.", "bild_emoji": "🌲", "imdb_rating": 6.7, "streaming": "Disney+"},
+ # ── Deutsche / österreichische Produktionen ─────────────────────
+ {"id": "lassie-neues-abenteuer","titel": "Lassie — Ein neues Abenteuer","jahr": 2023, "genre": "Familie/Abenteuer","typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Deutsche Neuinterpretation: Lassie hilft Kindern dabei, mysteriöse Hundesdiebstähle aufzudecken. Für Kinder und Familien.", "bild_emoji": "🐕", "imdb_rating": 5.6},
+ # ── Neuere Serien ───────────────────────────────────────────────
+ {"id": "turner-hooch-serie", "titel": "Turner & Hooch (Serie)", "originaltitel": "Turner & Hooch", "jahr": 2021, "genre": "Krimi/Komödie", "typ": "serie", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": False, "beschreibung": "Disney+-Sequel zur Filmklassik: Der Sohn des Originals detektiviert mit Hoochs Nachfolger. Nach einer Staffel abgesetzt.", "bild_emoji": "📺", "imdb_rating": 6.6, "streaming": "Disney+"},
+ {"id": "healing-powers-of-dude","titel": "Dude, mein Hund", "originaltitel": "The Healing Powers of Dude", "jahr": 2020, "genre": "Komödie/Familie", "typ": "serie", "hund_rasse": "Labradoodle", "stirbt_der_hund": False, "beschreibung": "Netflix-Jugendserie: Ein Junge mit sozialer Angststörung und sein emotionaler Support-Hund meistern gemeinsam die Middle School.", "bild_emoji": "💙", "imdb_rating": 6.6, "streaming": "Netflix"},
+ {"id": "hudson-rex", "titel": "Hudson & Rex", "jahr": 2019, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Kanadische Neuauflage von Kommissar Rex: Detective Hudson und sein Schäferhund Rex lösen in Neufundland Verbrechen. Läuft seit 2019.", "bild_emoji": "🍁", "imdb_rating": 7.4},
+ # ── Klassische Serien ───────────────────────────────────────────
+ {"id": "lassie-serie", "titel": "Lassie (TV-Serie)", "originaltitel": "Lassie", "jahr": 1954, "genre": "Familie/Abenteuer", "typ": "serie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Die legendäre CBS-Serie, die 20 Jahre lief und Generationen prägte. Lassies wöchentliche Rettungsaktionen wurden zum Inbegriff des Treue-Hundes.", "bild_emoji": "📡", "imdb_rating": 7.5},
+ {"id": "rin-tin-tin-serie", "titel": "Rin Tin Tin", "originaltitel": "The Adventures of Rin Tin Tin","jahr": 1954, "genre": "Western/Familie", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Legendäre 1950er-Western-Serie. Ein Waisenjunge und sein Schäferhund helfen der US-Kavallerie im Wilden Westen.", "bild_emoji": "🤠", "imdb_rating": 7.0},
+ # ── Dokumentationen ─────────────────────────────────────────────
+ {"id": "dogs-netflix", "titel": "Dogs", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Herzzerreißende Netflix-Dokuserie über die Bindung zwischen Hunden und Menschen weltweit. Sechs Episoden, Tränen garantiert.", "bild_emoji": "❤️", "imdb_rating": 8.0, "streaming": "Netflix"},
+ {"id": "pick-of-the-litter", "titel": "Pick of the Litter", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Labrador", "stirbt_der_hund": False, "beschreibung": "Ein Labrador-Wurf wird zur Führhundausbildung bestimmt. Nicht alle schaffen es — spannend wie ein Spielfilm.", "bild_emoji": "🎗️", "imdb_rating": 7.6, "streaming": "Amazon Prime"},
+ {"id": "inside-mind-of-dog", "titel": "Im Kopf des Hundes", "originaltitel": "Inside the Mind of a Dog", "jahr": 2024, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Wissenschaftsdoku: Was wissen Hunde wirklich über uns? Rob Lowe erzählt, Forscher erklären die Kognition unserer Vierbeiner.", "bild_emoji": "🧠", "imdb_rating": 7.2, "streaming": "Netflix"},
+ {"id": "stray-doku", "titel": "Stray", "jahr": 2020, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Poetische Doku aus Hundeperspektive: Die Kamera folgt drei Straßenhunden durch Istanbul. Philosophisch, still und ungemein berührend.", "bild_emoji": "🕌", "imdb_rating": 6.9, "streaming": "Amazon Prime"},
+ {"id": "dog-by-dog", "titel": "Dog by Dog", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über den milliardenschweren Welpen-Industrie-Komplex in den USA. Folgt dem Geldfluss hinter Puppy Mills.", "bild_emoji": "💰", "imdb_rating": 8.8, "streaming": "Netflix"},
+ {"id": "gunthers-millions", "titel": "Günthers Millionen", "originaltitel": "Gunther's Millions", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Die absurde Netflix-Doku: Erbte ein Schäferhund wirklich eine halbe Milliarde Euro? Die Wahrheit ist noch seltsamer.", "bild_emoji": "💎", "imdb_rating": 5.6, "streaming": "Netflix"},
+ # ── Weitere ─────────────────────────────────────────────────────
+ {"id": "a-dogs-purpose", "titel": "Bailey — Ein Freund fürs Leben","originaltitel": "A Dog's Purpose", "jahr": 2017, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Verschiedene (Labrador, Corgi u.a.)","stirbt_der_hund": True, "beschreibung": "Ein Hund wird mehrfach wiedergeboren und sucht in jeder Inkarnation nach seinem Sinn. Taschentücher-Pflicht.", "bild_emoji": "🔄", "imdb_rating": 7.3, "streaming": "Amazon Prime"},
+ {"id": "a-dogs-journey", "titel": "Bailey 2", "originaltitel": "A Dog's Journey", "jahr": 2019, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Beagle / Bernhardiner u.a.", "stirbt_der_hund": True, "beschreibung": "Fortsetzung: Bailey beschützt in mehreren Leben die Enkelin seines Herrchens. Emotional und rührend.", "bild_emoji": "🔄", "imdb_rating": 7.5, "streaming": "Amazon Prime"},
+ {"id": "art-of-racing", "titel": "Enzo und die wundersame Welt der Menschen","originaltitel": "The Art of Racing in the Rain","jahr": 2019,"genre": "Drama","typ": "film","hund_rasse": "Golden Retriever", "stirbt_der_hund": True, "beschreibung": "Ein Golden Retriever erzählt seine Lebensgeschichte. Philosophisch, witzig, herzzerreißend — Kevin Costner leiht ihm die Stimme.", "bild_emoji": "🏎️", "imdb_rating": 7.6, "streaming": "Disney+"},
+ {"id": "dog-gone", "titel": "Dog Gone", "originaltitel": "Dog Gone", "jahr": 2023, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Vater und Sohn suchen auf dem Appalachian Trail ihren verlorenen kranken Hund — und finden dabei zueinander. Wahre Geschichte.", "bild_emoji": "🥾", "imdb_rating": 6.1, "streaming": "Netflix"},
+ {"id": "white-fang", "titel": "Wolfsblut", "originaltitel": "White Fang", "jahr": 1991, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Wolf-Hund-Hybrid", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Ethan Hawke: Ein Wolf-Hund-Hybrid findet in Alaska zwischen Wildnis und Menschenwelt seinen Platz.", "bild_emoji": "❄️", "imdb_rating": 6.7},
+ {"id": "because-of-winn-dixie","titel": "Winn-Dixie", "originaltitel": "Because of Winn-Dixie", "jahr": 2005, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein einsames Mädchen findet einen streunenden Hund im Supermarkt — und mit ihm eine ganze Gemeinschaft.", "bild_emoji": "🛒", "imdb_rating": 6.4},
+ {"id": "lassie-2005", "titel": "Lassie (2005)", "jahr": 2005, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Britische Neuverfilmung mit Peter O'Toole. Lassie flieht aus Schottland und findet den langen Weg nach Yorkshire. Atmosphärisch.", "bild_emoji": "🏴", "imdb_rating": 6.7},
+ # ── Neue Einträge: Animation ──────────────────────────────────────
+ {"id": "all-dogs-go-to-heaven", "titel": "Alle Hunde kommen in den Himmel", "originaltitel": "All Dogs Go to Heaven", "jahr": 1989, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": True, "beschreibung": "Don Bluth-Klassiker: Gauner-Hund Charlie entkommt dem Himmel und sucht Rache — bis er sich in ein Waisenmädchen verliebt.", "bild_emoji": "😇", "imdb_rating": 6.8},
+ {"id": "all-dogs-go-heaven-2", "titel": "Alle Hunde kommen in den Himmel 2", "originaltitel": "All Dogs Go to Heaven 2", "jahr": 1996, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Charlie kehrt aus dem Himmel zurück nach San Francisco — mit Charlie Sheen als Synchronstimme.", "bild_emoji": "😇", "imdb_rating": 5.4},
+ {"id": "oliver-and-company", "titel": "Oliver & Co.", "originaltitel": "Oliver & Company", "jahr": 1988, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Verschiedene (Dodger = Mischling)", "stirbt_der_hund": False, "beschreibung": "Disney modernisiert Oliver Twist: Ein obdachloser Kater schließt sich einer Hunde-Gang unter Anführer Dodger im New York der 1980er an.", "bild_emoji": "🎸", "imdb_rating": 6.7, "streaming": "Disney+"},
+ {"id": "peanuts-movie", "titel": "Die Peanuts — Der Film", "originaltitel": "The Peanuts Movie", "jahr": 2015, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Snoopy und Charlie Brown auf der großen Leinwand. Perfekt für alle, die mit dem Kultcomic aufgewachsen sind.", "bild_emoji": "✈️", "imdb_rating": 7.0, "streaming": "Disney+"},
+ {"id": "clifford-big-red-dog", "titel": "Clifford — Der große rote Hund", "originaltitel": "Clifford the Big Red Dog", "jahr": 2021, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Mischling (riesig)", "stirbt_der_hund": False, "beschreibung": "Der riesige rote Hund aus dem Kinderbuchklassiker kommt ins Kino — Chaos und Herz für die ganze Familie.", "bild_emoji": "🔴", "imdb_rating": 5.4, "streaming": "Amazon Prime"},
+ {"id": "101-dalmatians-1996", "titel": "101 Dalmatiner (1996)", "originaltitel": "101 Dalmatians", "jahr": 1996, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Glenn Close als unvergessliche Cruella de Vil im Live-Action-Remake des Disney-Klassikers — böse, schrill und absolut unterhaltsam.", "bild_emoji": "🐾", "imdb_rating": 5.7, "streaming": "Disney+"},
+ {"id": "102-dalmatians", "titel": "102 Dalmatiner", "originaltitel": "102 Dalmatians", "jahr": 2000, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Cruella ist scheinbar geläutert — aber die Dalmatiner-Welpen sind wieder in Gefahr. Fortsetzung mit Glenn Close.", "bild_emoji": "🐾", "imdb_rating": 4.9, "streaming": "Disney+"},
+ {"id": "lady-and-tramp-2019", "titel": "Susi und Strolch (2019)", "originaltitel": "Lady and the Tramp", "jahr": 2019, "genre": "Familie/Romanze", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney+ Live-Action-Remake mit echten Hunden: Die ikonische Spaghetti-Szene neu inszeniert, herzlich und mit modernem Charme.", "bild_emoji": "🍝", "imdb_rating": 6.4, "streaming": "Disney+"},
+ {"id": "my-dog-tulip", "titel": "Mein Hund Tulip", "originaltitel": "My Dog Tulip", "jahr": 2009, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": True, "beschreibung": "Animierter Arthouse-Film nach J.R. Ackerley: Ein eigenbrötlerischer Engländer und die vorbehaltlose Liebe zu seiner Schäferhündin Tulip.", "bild_emoji": "🌷", "imdb_rating": 7.0},
+ {"id": "bluey", "titel": "Bluey", "jahr": 2018, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Blue Heeler", "stirbt_der_hund": False, "beschreibung": "Australische Kinder-Animationsserie über eine Blue-Heeler-Familie, die weltweit Eltern und Kinder gleichermaßen begeistert. Meistgeliebte Kinderserie des 21. Jahrhunderts.", "bild_emoji": "💙", "imdb_rating": 9.0, "streaming": "Disney+"},
+ {"id": "strays-2023", "titel": "Strays — Lass uns Hunde sein", "originaltitel": "Strays", "jahr": 2023, "genre": "Animation/Komödie", "typ": "film", "hund_rasse": "Mischling / Australian Shepherd", "stirbt_der_hund": False, "beschreibung": "Komplett obszöne Erwachsenen-Trickfilm-Komödie: Verlassener Hund will mit neuen Hundefreunden Rache am Herrchen nehmen. Nicht für Kinder!","bild_emoji": "🤬", "imdb_rating": 5.8, "streaming": "Amazon Prime"},
+ # ── Neue Einträge: Familie/Drama ──────────────────────────────────
+ {"id": "hotel-for-dogs", "titel": "Hotel für Hunde", "originaltitel": "Hotel for Dogs", "jahr": 2009, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Zwei Geschwister verwandeln ein leerstehendes Hotel in ein geheimes Paradies für Straßenhunde. Herzerwärmende Familienunterhaltung.", "bild_emoji": "🏨", "imdb_rating": 5.8},
+ {"id": "snow-dogs", "titel": "Snowdogs", "originaltitel": "Snow Dogs", "jahr": 2002, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Siberian Husky", "stirbt_der_hund": False, "beschreibung": "Cuba Gooding Jr. erbt eine Schlittenhunde-Meute in Alaska und muss erst lernen, mit ihnen umzugehen. Leichte Disney-Komödie.", "bild_emoji": "🛷", "imdb_rating": 4.2, "streaming": "Disney+"},
+ {"id": "a-dogs-way-home", "titel": "Auf dem Heimweg — Lassie und Ich", "originaltitel": "A Dog's Way Home", "jahr": 2019, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Pit-Bull-Mischling", "stirbt_der_hund": False, "beschreibung": "Hündin Bella verliert sich 400 Meilen von zu Hause entfernt und kämpft sich durch alle Widrigkeiten zurück zu ihrem Herrchen.", "bild_emoji": "🏡", "imdb_rating": 6.7, "streaming": "Netflix"},
+ {"id": "fluke", "titel": "Fluke — Das fremde Ich", "originaltitel": "Fluke", "jahr": 1995, "genre": "Drama/Fantasy", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Mann stirbt und wird als Hund wiedergeboren — und kehrt zu seiner Familie zurück. Ungewöhnliches Dramafantasy mit tiefem emotionalem Kern.", "bild_emoji": "🔄", "imdb_rating": 6.2},
+ {"id": "zeus-and-roxanne", "titel": "Zeus und Roxanne", "originaltitel": "Zeus and Roxanne", "jahr": 1997, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Hund und ein Delfin werden beste Freunde — und bringen dabei auch ihre Besitzer zusammen. Charmante Familienunterhaltung der 90er.", "bild_emoji": "🐬", "imdb_rating": 5.2},
+ {"id": "benji-2018", "titel": "Benji (2018)", "originaltitel": "Benji", "jahr": 2018, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Netflix-Remake des Klassikers: Der streunende Hund Benji rettet erneut Kinder aus gefährlichen Händen — jetzt für eine neue Generation.", "bild_emoji": "🐾", "imdb_rating": 6.3, "streaming": "Netflix"},
+ {"id": "ugly-dachshund", "titel": "Der hässliche Dackel", "originaltitel": "The Ugly Dachshund", "jahr": 1966, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutscher Dogge / Dackel", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker: Eine Harlekindogge wird von Dackeln aufgezogen und denkt, sie sei selbst ein Dackel. Harmlose Familienkomödie.", "bild_emoji": "😅", "imdb_rating": 6.4},
+ {"id": "shiloh", "titel": "Shiloh — Mein treuer Freund", "originaltitel": "Shiloh", "jahr": 1996, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Junge in West Virginia rettet einen misshandelten Beagle vor seinem brutalen Besitzer — eine Geschichte über Mut und Gewissen.", "bild_emoji": "🌿", "imdb_rating": 6.7},
+ {"id": "iron-will", "titel": "Iron Will", "jahr": 1994, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": False, "beschreibung": "Junger Mann rettet die Farm seiner Familie mit einem gewagten Schlittenhunde-Rennen von Kanada nach Minnesota. Inspirierend und episch.", "bild_emoji": "🏆", "imdb_rating": 6.7, "streaming": "Disney+"},
+ {"id": "belle-et-sebastien", "titel": "Belle und Sébastien", "originaltitel": "Belle et Sébastien", "jahr": 2013, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Französischer Familienfilm: Ein Waisenjunge und die riesige Berghündin Belle sind beste Freunde in den Alpen des Zweiten Weltkriegs.", "bild_emoji": "🏔️", "imdb_rating": 7.0},
+ {"id": "dog-of-flanders", "titel": "Ein Hund von Flandern", "originaltitel": "A Dog of Flanders", "jahr": 1999, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Bouvier des Flandres", "stirbt_der_hund": True, "beschreibung": "Bewegende Verfilmung des Klassikers: Ein armer Junge in Belgien und sein Hund träumen von Kunst und Würde — mit tragischem Ende.", "bild_emoji": "🎨", "imdb_rating": 7.0},
+ {"id": "underdog-2007", "titel": "Underdog — Ein Held auf vier Pfoten", "originaltitel": "Underdog", "jahr": 2007, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Laborhund erhält Superkräfte und wird zum maskierten Superhelden der Stadt. Leichte Disney-Familienkomödie nach der Zeichentrickserie.", "bild_emoji": "🦸", "imdb_rating": 5.1},
+ {"id": "bingo-1991", "titel": "Bingo", "jahr": 1991, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Zirkushund reist quer durch Amerika, um seinen jungen Herrchen zu finden. Kindheitserinnerung der frühen 90er.", "bild_emoji": "🎪", "imdb_rating": 5.8},
+ {"id": "goodbye-my-lady", "titel": "Leb wohl, Lady", "originaltitel": "Goodbye, My Lady", "jahr": 1956, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Basenji", "stirbt_der_hund": False, "beschreibung": "Ein Junge im Mississippi-Sumpfland findet einen seltsamen lachenden Hund — und muss ihn am Ende zurückgeben. Zeitloser Klassiker.", "bild_emoji": "🌊", "imdb_rating": 7.1},
+ {"id": "mitt-liv-som-hund", "titel": "Mein Leben als Hund", "originaltitel": "Mitt liv som hund", "jahr": 1985, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Lasse Hallströms schwedisches Meisterwerk: Ein Junge wird aufs Land geschickt und vergleicht sein Leben mit dem Schicksal von Laika.", "bild_emoji": "🇸🇪", "imdb_rating": 7.8},
+ {"id": "homeward-bound-2", "titel": "Auf dem Weg nach Hause 2 — Im Großstadtdschungel", "originaltitel": "Homeward Bound II: Lost in San Francisco", "jahr": 1996, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Die Tiere aus Teil 1 verirren sich in San Francisco und müssen erneut den Weg nach Hause finden — diesmal durch die Stadt.", "bild_emoji": "🌉", "imdb_rating": 6.0, "streaming": "Disney+"},
+ {"id": "alpha-2018", "titel": "Alpha", "jahr": 2018, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Wolf", "stirbt_der_hund": False, "beschreibung": "Vor 20.000 Jahren: Ein junger Jäger freundet sich mit einem verletzten Wolf an und legt damit den Grundstein für die Mensch-Hund-Beziehung.", "bild_emoji": "🐺", "imdb_rating": 6.7, "streaming": "Amazon Prime"},
+ {"id": "nankyoku-monogatari", "titel": "Antarktis", "originaltitel": "Nankyoku Monogatari", "jahr": 1983, "genre": "Drama/Abenteuer", "typ": "film", "hund_rasse": "Sakhalin Husky", "stirbt_der_hund": True, "beschreibung": "Japanisches Meisterwerk: 15 Schlittenhunde werden 1958 in der Antarktis zurückgelassen — die wahre Geschichte zweier Überlebender.", "bild_emoji": "🇯🇵", "imdb_rating": 7.7},
+ # ── Neue Einträge: Komödie ────────────────────────────────────────
+ {"id": "cats-and-dogs", "titel": "Cats & Dogs", "jahr": 2001, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle / Verschiedene", "stirbt_der_hund": False, "beschreibung": "Geheimdienstagenten auf vier Pfoten: Hunde gegen Katzen im Kampf um die Weltherrschaft. Spionagefilm-Parodie für die ganze Familie.", "bild_emoji": "🐱", "imdb_rating": 5.2},
+ {"id": "cats-and-dogs-2", "titel": "Cats & Dogs 2 — Die Rache der Kitty Kahlohr", "originaltitel": "Cats & Dogs: The Revenge of Kitty Galore", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Hunde und Katzen müssen kooperieren, um eine wahnsinnige Superschurkin-Katze zu stoppen.", "bild_emoji": "😾", "imdb_rating": 4.1},
+ {"id": "shaggy-dog-1959", "titel": "Der zottige Hund", "originaltitel": "The Shaggy Dog", "jahr": 1959, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Alter Ungarischer Hirtenhund (Sheepdogähnlich)", "stirbt_der_hund": False, "beschreibung": "Disney-Familienklassiker: Ein Teenager verwandelt sich durch einen magischen Ring immer wieder in einen Hund. Mit Fred MacMurray.", "bild_emoji": "✨", "imdb_rating": 6.3},
+ {"id": "shaggy-dog-2006", "titel": "The Shaggy Dog (2006)", "originaltitel": "The Shaggy Dog", "jahr": 2006, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bobtail (Alter Englischer Schäferhund)", "stirbt_der_hund": False, "beschreibung": "Tim Allen als Staatsanwalt, der sich in einen Hund verwandelt — modernes Remake des Disney-Klassikers mit Slapstick-Humor.", "bild_emoji": "✨", "imdb_rating": 5.2, "streaming": "Disney+"},
+ {"id": "marmaduke-2010", "titel": "Marmaduke", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutsche Dogge", "stirbt_der_hund": False, "beschreibung": "Die riesige Comic-Dogge Marmaduke zieht nach Kalifornien und mischt die dortige Hunde-Gesellschaft auf. Lockerleichte Familienkomödie.", "bild_emoji": "😬", "imdb_rating": 4.6},
+ {"id": "show-dogs", "titel": "Show Dogs", "jahr": 2018, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Rottweiler", "stirbt_der_hund": False, "beschreibung": "Ein Polizei-Rottweiler muss undercover bei einer Hundeschau ermitteln. Kindliche Spionagekomödie mit Will Arnett.", "bild_emoji": "🏅", "imdb_rating": 4.3},
+ {"id": "must-love-dogs", "titel": "Must Love Dogs", "jahr": 2005, "genre": "Romanze/Komödie", "typ": "film", "hund_rasse": "Neufundländer", "stirbt_der_hund": False, "beschreibung": "Romantische Komödie: Eine frisch Geschiedene sucht online die Liebe — und ein Hund spielt dabei die entscheidende Rolle. Mit Diane Lane.", "bild_emoji": "💕", "imdb_rating": 6.0},
+ {"id": "best-in-show", "titel": "Best in Show", "jahr": 2000, "genre": "Komödie/Mockumentary", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Christopher Guest's genialer Mockumentary über völlig überdrehte Hundeshow-Besucher beim Mayflower Dog Show — vernichtende Satire auf Hundebesitzer.", "bild_emoji": "🎭", "imdb_rating": 7.8},
+ {"id": "wiener-dog", "titel": "Wiener-Dog", "jahr": 2016, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Dackel", "stirbt_der_hund": True, "beschreibung": "Todd Solondz' episodischer Indie-Film: Ein kleiner Dackel wandert durch mehrere skurrile Menschenleben. Dunkel, philosophisch, preisgekrönt.", "bild_emoji": "🌭", "imdb_rating": 6.6},
+ # ── Neue Einträge: Action/Thriller ───────────────────────────────
+ {"id": "mans-best-friend-1993", "titel": "Man's Best Friend", "originaltitel": "Man's Best Friend", "jahr": 1993, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Mastiff", "stirbt_der_hund": True, "beschreibung": "Ein genetisch manipulierter Kettenhund bricht aus einem Labor aus und entpuppt sich als tödliche Bedrohung. B-Movie-Horrorklassiker.", "bild_emoji": "🧬", "imdb_rating": 5.1},
+ {"id": "white-dog-1982", "titel": "White Dog", "originaltitel": "White Dog", "jahr": 1982, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Samuel Fullers politisch brisanter Film: Ein weißer Schäferhund wurde auf schwarze Menschen abgerichtet — und soll nun umtrainiert werden.", "bild_emoji": "⚪", "imdb_rating": 7.2},
+ {"id": "hound-of-baskervilles", "titel": "Der Hund von Baskerville", "originaltitel": "The Hound of the Baskervilles", "jahr": 1939, "genre": "Krimi/Thriller", "typ": "film", "hund_rasse": "Fantastisches Wesen", "stirbt_der_hund": False, "beschreibung": "Basil Rathbone als Sherlock Holmes in der besten Verfilmung des Doyle-Klassikers — die unheilvolle Legende des Dartmoor-Hundes.", "bild_emoji": "🔦", "imdb_rating": 7.4},
+ # ── Neue Einträge: Japan/International ───────────────────────────
+ {"id": "mari-to-koinu", "titel": "Mari und ihr Hundewelpe", "originaltitel": "Mari to koinu no monogatari", "jahr": 2007, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Shiba Inu", "stirbt_der_hund": False, "beschreibung": "Wahre Geschichte: Eine Shiba-Inu-Mutter rettet nach einem Erdbeben in den japanischen Alpen ihr Herrchen und ihre Welpen.", "bild_emoji": "🏔️", "imdb_rating": 7.2},
+ {"id": "ginga-nagareboshi-gin", "titel": "Ginga: Nagareboshi Gin", "originaltitel": "Ginga: Nagareboshi Gin", "jahr": 1986, "genre": "Anime/Abenteuer", "typ": "serie", "hund_rasse": "Akita Inu", "stirbt_der_hund": False, "beschreibung": "Legendärer japanischer Anime: Silber, ein junger Akita, kämpft gegen einen gigantischen Bären — packende Shonen-Klassikerserie der 80er.", "bild_emoji": "⭐", "imdb_rating": 8.0},
+ # ── Neue Einträge: Serien ──────────────────────────────────────────
+ {"id": "dog-whisperer", "titel": "Der Hundeflüsterer", "originaltitel": "Dog Whisperer with Cesar Millan", "jahr": 2004, "genre": "Dokumentation/Reality", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Cesar Millan rehabilitiert Problemhunde und schult deren Besitzer. Legendäre Reality-Serie, die das Hundetraining nachhaltig beeinflusst hat.", "bild_emoji": "🤫", "imdb_rating": 7.8},
+ {"id": "its-me-or-the-dog", "titel": "Ich oder der Hund", "originaltitel": "It's Me or the Dog", "jahr": 2005, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Victoria Stilwell bringt unerzogenen Hunden Manieren bei — mit Positiver Verstärkung statt Dominanztheorie. Britische Erfolgsrealityshow.", "bild_emoji": "💪", "imdb_rating": 7.2},
+ {"id": "lucky-dog", "titel": "Lucky Dog", "originaltitel": "Lucky Dog", "jahr": 2013, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Trainer Brandon McMillan rettet Todestrakt-Hunde aus Tierheimen und trainiert sie innerhalb einer Woche als perfekte Familienhunde.", "bild_emoji": "🌟", "imdb_rating": 7.5},
+ {"id": "dog-impossible", "titel": "Dog: Impossible", "originaltitel": "Dog: Impossible", "jahr": 2019, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matt Beisner arbeitet mit aggressiven und traumatisierten Hunden, die andere aufgegeben haben. National Geographic's bewegende Trainerserie.", "bild_emoji": "🙏", "imdb_rating": 8.1},
+ {"id": "belle-sebastian-anime", "titel": "Belle und Sebastian (Anime)", "originaltitel": "Belle et Sébastien", "jahr": 1984, "genre": "Anime/Familie", "typ": "serie", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Japanische Zeichentrickserie der NHK: Sébastien und sein riesiger weißer Hund Belle in den Alpen — eine Lieblingsserie mehrerer Generationen.", "bild_emoji": "⛰️", "imdb_rating": 7.5},
+ {"id": "the-dog-house", "titel": "The Dog House", "originaltitel": "The Dog House", "jahr": 2019, "genre": "Dokumentation", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Wood Green Animal Shelter: Zuschauer beobachten, wie Hunde und Menschen füreinander bestimmt werden. Britische Kultdoku.", "bild_emoji": "🏡", "imdb_rating": 8.2},
+ {"id": "puppy-dog-pals", "titel": "Puppy Dog Pals", "originaltitel": "Puppy Dog Pals", "jahr": 2017, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Disney Junior-Serie: Zwei Möpse erleben täglich Abenteuer in der Nachbarschaft, während ihr Herrchen weg ist. Ideal für Kleinkinder.", "bild_emoji": "🐾", "imdb_rating": 7.4, "streaming": "Disney+"},
+ {"id": "dogs-of-berlin", "titel": "Dogs of Berlin", "jahr": 2018, "genre": "Krimi/Drama", "typ": "serie", "hund_rasse": "Kampfhund", "stirbt_der_hund": False, "beschreibung": "Netflix Deutschland-Originalserie: Zwei Berliner Ermittler aus verschiedenen Welten jagen einen Mörder — düster, social, brutal ehrlich.", "bild_emoji": "🐕", "imdb_rating": 7.4, "streaming": "Netflix"},
+ # ── Neue Einträge: Dokumentationen ────────────────────────────────
+ {"id": "the-champions-2015", "titel": "The Champions", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Bewegende Doku über die Pitbulls von Michael Vick's Dogfighting-Ring — und ihre erstaunliche Rehabilitation durch engagierte Retter.", "bild_emoji": "💪", "imdb_rating": 7.8},
+ {"id": "one-nation-under-dog", "titel": "One Nation Under Dog", "originaltitel": "One Nation Under Dog", "jahr": 2012, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "HBO-Dokumentation über die komplexe Beziehung der Amerikaner zu Hunden — von Tierheimen bis Luxus-Hundesalons.", "bild_emoji": "🇺🇸", "imdb_rating": 7.4},
+ {"id": "dogs-on-the-inside", "titel": "Dogs on the Inside", "originaltitel": "Dogs on the Inside", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Gefangene in einem Hochsicherheitsgefängnis trainieren Tierheim-Hunde — eine Geschichte über Mitgefühl, Verantwortung und zweite Chancen.", "bild_emoji": "🔒", "imdb_rating": 7.6},
+ {"id": "wonderdog-2023", "titel": "Wonderdog", "originaltitel": "Wonderdog", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Doku über die außergewöhnlichen Fähigkeiten von Hunden: Was können sie wirklich riechen, hören und fühlen? Wissenschaft trifft Herz.", "bild_emoji": "🔬", "imdb_rating": 7.1, "streaming": "Netflix"},
+ {"id": "the-supervet", "titel": "The Supervet", "originaltitel": "The Supervet", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Noel Fitzpatrick, den visionären Veterinärchirurgen, der unheilbar verletzte Tiere mit Hightech-Prothesen rettet.", "bild_emoji": "🦾", "imdb_rating": 8.7},
+ {"id": "off-the-chain", "titel": "Off the Chain", "originaltitel": "Off the Chain", "jahr": 2004, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über Pit Bulls in Amerika — zwischen Missbrauch, Dogfighting-Kultur und den Menschen, die für sie kämpfen.", "bild_emoji": "⛓️", "imdb_rating": 7.2},
+ {"id": "war-dog-soldier", "titel": "War Dog: A Soldier's Best Friend", "originaltitel": "War Dog: A Soldier's Best Friend", "jahr": 2017, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "National Geographic-Doku über Militärhunde und die unzerstörbare Bindung zu ihren Hundeführern — von der Ausbildung bis zum Einsatz.", "bild_emoji": "🎖️", "imdb_rating": 7.8},
]
-PROMIS = [
- {"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"},
- {"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund", "bekannt_fuer": "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"},
- {"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", "emoji": "🚀"},
- {"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"},
- {"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (1800–1812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"},
- {"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": "⛪"},
+_SEED_PROMIS = [
+ {"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"},
+ {"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund","bekannt_fuer": "Filmhund der 1920er. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"},
+ {"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Sowjetische Weltraumpionierin.", "emoji": "🚀"},
+ {"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"},
+ {"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (1800–1812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"},
+ {"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": "⛪"},
+ {"name": "Balto", "rasse": "Siberian Husky", "bekannt_fuer": "Führte 1925 den letzten Abschnitt des Serum-Runs nach Nome, Alaska. Statue im Central Park New York.", "emoji": "🛷"},
+ {"name": "Togo", "rasse": "Siberian Husky", "bekannt_fuer": "Der echte Held des Serum-Runs 1925 — legte die schwierigste Strecke zurück, blieb aber lange unbekannt.", "emoji": "🏅"},
+ {"name": "Asta", "rasse": "Drahthaariger Foxterrier","bekannt_fuer": "Filmhund in der 'Dünner Mann'-Reihe (1934–1947). Hollywood-Ikone der klassischen Ära.", "emoji": "🎩"},
+ {"name": "Lassie", "rasse": "Rough Collie", "bekannt_fuer": "Meistverfilmter Hund der Geschichte. Erster Vierbeiner mit einem Stern auf dem Hollywood Walk of Fame.", "emoji": "⭐"},
]
+def seed_movies():
+ """Füllt die movies-Tabelle mit allen Seed-Einträgen (idempotent per INSERT OR IGNORE)."""
+ import logging
+ logger = logging.getLogger(__name__)
+ with db() as conn:
+ for i, f in enumerate(_SEED_FILME):
+ conn.execute("""
+ INSERT OR IGNORE INTO movies
+ (id, titel, originaltitel, jahr, genre, typ, hund_rasse,
+ stirbt_der_hund, beschreibung, bild_emoji, imdb_rating,
+ streaming, sort_order)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
+ """, (
+ f["id"], f["titel"], f.get("originaltitel"),
+ f.get("jahr"), f.get("genre"), f.get("typ", "film"),
+ f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0,
+ f.get("beschreibung"), f.get("bild_emoji", "🐾"),
+ f.get("imdb_rating"), f.get("streaming"), i,
+ ))
+ logger.info(f"movies: seed_movies() ausgeführt, {len(_SEED_FILME)} Einträge in der Liste.")
+
+
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class FilmVoteRequest(BaseModel):
bewertung: int # 1–5
-
class HundDesMonatsVoteRequest(BaseModel):
dog_id: int
+class MovieCreate(BaseModel):
+ id: str
+ titel: str
+ originaltitel: Optional[str] = None
+ jahr: Optional[int] = None
+ genre: Optional[str] = None
+ typ: str = "film"
+ hund_rasse: Optional[str] = None
+ stirbt_der_hund: bool = False
+ beschreibung: Optional[str] = None
+ bild_emoji: str = "🐾"
+ imdb_rating: Optional[float] = None
+ streaming: Optional[str] = None
+
+class MovieUpdate(BaseModel):
+ titel: Optional[str] = None
+ originaltitel: Optional[str] = None
+ jahr: Optional[int] = None
+ genre: Optional[str] = None
+ typ: Optional[str] = None
+ hund_rasse: Optional[str] = None
+ stirbt_der_hund: Optional[bool] = None
+ beschreibung: Optional[str] = None
+ bild_emoji: Optional[str] = None
+ imdb_rating: Optional[float] = None
+ streaming: Optional[str] = None
+
# ------------------------------------------------------------------
-# GET /api/movies/filme — Film-Liste mit optionaler User-Bewertung
+# GET /api/movies/filme
# ------------------------------------------------------------------
+_SORT_COLS = {
+ "titel": "m.titel ASC",
+ "jahr_desc": "m.jahr DESC",
+ "jahr_asc": "m.jahr ASC",
+ "imdb": "m.imdb_rating DESC",
+ "bewertung": "community_avg DESC",
+ "default": "CASE WHEN m.imdb_rating IS NULL THEN 1 ELSE 0 END, m.imdb_rating DESC, m.jahr DESC",
+}
+
@router.get("/filme")
-async def get_filme(user=Depends(get_current_user_optional)):
- user_ratings = {}
- community_avgs = {}
+async def get_filme(
+ sort: str = Query("default"),
+ typ: str = Query("alle"), # alle | film | serie | doku
+ user = Depends(get_current_user_optional),
+):
+ order = _SORT_COLS.get(sort, _SORT_COLS["default"])
+
+ where = ""
+ params: list = []
+ if typ != "alle":
+ where = "WHERE m.typ = ?"
+ params.append(typ)
with db() as conn:
- if user:
- rows = conn.execute(
- "SELECT film_id, bewertung FROM movie_votes WHERE user_id=?",
- (user["id"],),
- ).fetchall()
- user_ratings = {r["film_id"]: r["bewertung"] for r in rows}
-
- avg_rows = conn.execute(
- "SELECT film_id, AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes GROUP BY film_id"
- ).fetchall()
- community_avgs = {r["film_id"]: {"avg": round(r["avg_bew"], 1), "cnt": r["cnt"]} for r in avg_rows}
+ rows = conn.execute(f"""
+ SELECT m.*,
+ COALESCE(AVG(v.bewertung), 0) AS community_avg,
+ COUNT(v.id) AS bewertung_cnt,
+ uv.bewertung AS user_rating
+ FROM movies m
+ LEFT JOIN movie_votes v ON v.film_id = m.id
+ LEFT JOIN movie_votes uv ON uv.film_id = m.id
+ AND uv.user_id = ?
+ {where}
+ GROUP BY m.id
+ ORDER BY {order}
+ """, [user["id"] if user else None] + params).fetchall()
result = []
- for film in FILME:
- f = dict(film)
- f["user_rating"] = user_ratings.get(film["id"])
- if film["id"] in community_avgs:
- f["bewertung_avg"] = community_avgs[film["id"]]["avg"]
- f["bewertung_cnt"] = community_avgs[film["id"]]["cnt"]
- else:
- f["bewertung_cnt"] = 0
- result.append(f)
-
+ for r in rows:
+ d = dict(r)
+ d["stirbt_der_hund"] = bool(d["stirbt_der_hund"])
+ d["bewertung_avg"] = round(d["community_avg"] or 0, 1)
+ result.append(d)
return result
# ------------------------------------------------------------------
-# POST /api/movies/filme/{film_id}/vote — Bewertung abgeben (Upsert)
+# POST /api/movies/filme/{film_id}/vote
# ------------------------------------------------------------------
@router.post("/filme/{film_id}/vote")
async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_current_user)):
- if not any(f["id"] == film_id for f in FILME):
- raise HTTPException(404, "Film nicht gefunden.")
if data.bewertung < 1 or data.bewertung > 5:
raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.")
-
with db() as conn:
- conn.execute(
- """INSERT INTO movie_votes (user_id, film_id, bewertung)
- VALUES (?, ?, ?)
- ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung""",
- (user["id"], film_id, data.bewertung),
- )
+ if not conn.execute("SELECT 1 FROM movies WHERE id=?", (film_id,)).fetchone():
+ raise HTTPException(404, "Film nicht gefunden.")
+ conn.execute("""
+ INSERT INTO movie_votes (user_id, film_id, bewertung)
+ VALUES (?, ?, ?)
+ ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung
+ """, (user["id"], film_id, data.bewertung))
row = conn.execute(
"SELECT AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes WHERE film_id=?",
(film_id,),
).fetchone()
-
return {
- "film_id": film_id,
+ "film_id": film_id,
"bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung,
"bewertung_cnt": row["cnt"],
- "user_rating": data.bewertung,
+ "user_rating": data.bewertung,
}
# ------------------------------------------------------------------
-# GET /api/movies/hund-des-monats — Top-Votes des aktuellen Monats
+# Admin: CRUD für Filme
+# ------------------------------------------------------------------
+@router.post("/filme", status_code=201)
+async def create_film(data: MovieCreate, admin=Depends(require_admin)):
+ with db() as conn:
+ max_order = conn.execute("SELECT COALESCE(MAX(sort_order),0) FROM movies").fetchone()[0]
+ try:
+ conn.execute("""
+ INSERT INTO movies (id, titel, originaltitel, jahr, genre, typ, hund_rasse,
+ stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, streaming, sort_order)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
+ """, (data.id, data.titel, data.originaltitel, data.jahr, data.genre, data.typ,
+ data.hund_rasse, 1 if data.stirbt_der_hund else 0, data.beschreibung,
+ data.bild_emoji, data.imdb_rating, data.streaming, max_order + 1))
+ except Exception:
+ raise HTTPException(400, "Film-ID bereits vorhanden.")
+ return {"ok": True}
+
+@router.patch("/filme/{film_id}")
+async def update_film(film_id: str, data: MovieUpdate, admin=Depends(require_admin)):
+ updates = {k: v for k, v in data.model_dump(exclude_none=True).items()}
+ if "stirbt_der_hund" in updates:
+ updates["stirbt_der_hund"] = 1 if updates["stirbt_der_hund"] else 0
+ if not updates:
+ return {"ok": True}
+ set_clause = ", ".join(f"{k}=?" for k in updates)
+ with db() as conn:
+ conn.execute(f"UPDATE movies SET {set_clause} WHERE id=?", (*updates.values(), film_id))
+ return {"ok": True}
+
+@router.delete("/filme/{film_id}")
+async def delete_film(film_id: str, admin=Depends(require_admin)):
+ with db() as conn:
+ conn.execute("DELETE FROM movies WHERE id=?", (film_id,))
+ return {"ok": True}
+
+
+# ------------------------------------------------------------------
+# GET /api/movies/promis — Berühmte Hunde (aus Seed-Daten)
+# ------------------------------------------------------------------
+@router.get("/promis")
+async def get_promis():
+ return _SEED_PROMIS
+
+
+# ------------------------------------------------------------------
+# Hund des Monats
# ------------------------------------------------------------------
@router.get("/hund-des-monats")
async def get_hund_des_monats(user=Depends(get_current_user_optional)):
monat = datetime.now().strftime("%Y-%m")
-
with db() as conn:
- rows = conn.execute(
- """SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name,
- COUNT(v.id) as stimmen
- FROM hund_des_monats_votes v
- JOIN dogs d ON d.id = v.dog_id
- JOIN users u ON u.id = d.user_id
- WHERE v.monat = ?
- GROUP BY v.dog_id
- ORDER BY stimmen DESC
- LIMIT 10""",
- (monat,),
- ).fetchall()
-
+ rows = conn.execute("""
+ SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name,
+ COUNT(v.id) as stimmen
+ FROM hund_des_monats_votes v
+ JOIN dogs d ON d.id = v.dog_id
+ JOIN users u ON u.id = d.user_id
+ WHERE v.monat = ?
+ GROUP BY v.dog_id
+ ORDER BY stimmen DESC
+ LIMIT 10
+ """, (monat,)).fetchall()
user_vote = None
if user:
row = conn.execute(
@@ -143,43 +383,55 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)):
).fetchone()
if row:
user_vote = row["dog_id"]
-
- return {
- "monat": monat,
- "top": [dict(r) for r in rows],
- "user_vote": user_vote,
- }
+ return {"monat": monat, "top": [dict(r) for r in rows], "user_vote": user_vote}
+
+
+@router.get("/hund-des-monats/kandidaten")
+async def get_hdm_kandidaten(user=Depends(get_current_user)):
+ """Alle öffentlichen Hunde anderer User, mit aktuellem Stimmenstand."""
+ monat = datetime.now().strftime("%Y-%m")
+ with db() as conn:
+ rows = conn.execute("""
+ SELECT d.id, d.name, d.rasse, d.foto_url,
+ u.name AS besitzer_name,
+ COALESCE(v.stimmen, 0) AS stimmen
+ FROM dogs d
+ JOIN users u ON u.id = d.user_id
+ LEFT JOIN (
+ SELECT dog_id, COUNT(*) AS stimmen
+ FROM hund_des_monats_votes
+ WHERE monat = ?
+ GROUP BY dog_id
+ ) v ON v.dog_id = d.id
+ WHERE d.is_public = 1
+ AND d.user_id != ?
+ ORDER BY
+ CASE WHEN d.foto_url IS NOT NULL THEN 0 ELSE 1 END,
+ stimmen DESC,
+ d.name ASC
+ LIMIT 60
+ """, (monat, user["id"])).fetchall()
+ return [dict(r) for r in rows]
-# ------------------------------------------------------------------
-# POST /api/movies/hund-des-monats/vote — Abstimmen (Auth required)
-# ------------------------------------------------------------------
@router.post("/hund-des-monats/vote")
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
monat = datetime.now().strftime("%Y-%m")
-
with db() as conn:
- # Prüfen ob Hund existiert und entweder dem User gehört oder öffentlich ist
- dog = conn.execute(
- "SELECT id, user_id, is_public FROM dogs WHERE id=?",
- (data.dog_id,),
- ).fetchone()
+ dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
- if dog["user_id"] != user["id"] and not dog["is_public"]:
+ if dog["user_id"] == user["id"]:
+ raise HTTPException(403, "Du kannst nicht für deinen eigenen Hund abstimmen.")
+ if not dog["is_public"]:
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
-
- conn.execute(
- """INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
- VALUES (?, ?, ?)
- ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id""",
- (user["id"], data.dog_id, monat),
- )
-
- # Aktuelle Stimmenanzahl für den gewählten Hund
+ conn.execute("""
+ INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
+ VALUES (?, ?, ?)
+ ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id
+ """, (user["id"], data.dog_id, monat))
row = conn.execute(
"SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?",
(data.dog_id, monat),
).fetchone()
-
return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]}
diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py
index 6ec066c..4fbd03c 100644
--- a/backend/routes/outreach.py
+++ b/backend/routes/outreach.py
@@ -6,7 +6,7 @@ import smtplib
import ssl
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
-from email.utils import formataddr
+from email.utils import formataddr, formatdate
from datetime import datetime
from typing import List, Optional
@@ -84,22 +84,36 @@ def _imap_save_sent(msg_bytes: bytes, account: str):
_log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e)
-def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart:
+def _build_message(to: str, subject: str, body: str, account: str, html: str = None) -> MIMEMultipart:
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
msg = MIMEMultipart("alternative")
+ msg["Date"] = formatdate(localtime=False) # UTC explizit, Container hat keine lokale TZ
msg["Subject"] = subject
msg["From"] = formataddr((acc["name"], acc["from"]))
msg["To"] = to
msg["Reply-To"] = acc["from"]
msg.attach(MIMEText(body, "plain", "utf-8"))
+ if html:
+ msg.attach(MIMEText(html, "html", "utf-8"))
return msg
-def _send_smtp(to: str, subject: str, body: str, account: str = "partner"):
+_LEGAL_FOOTER = (
+ "\n\n---\n"
+ "Ban Yaro | René Degelmann | Ringstr. 26, D-85560 Ebersberg\n"
+ "Web: https://banyaro.app | Mail: partner@banyaro.app\n\n"
+ "Datenschutzhinweis: Deine Kontaktdaten stammen aus deinem öffentlichen Profil. "
+ "Verarbeitung auf Basis berechtigten Interesses (Art. 6 Abs. 1 lit. f DSGVO). "
+ "Datenschutzerklärung: https://banyaro.app/datenschutz\n"
+ "Widerspruch/Löschung: Einfach auf diese Mail antworten."
+)
+
+
+def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: str = None):
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
if not acc["user"] or not acc["pass"]:
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.")
- msg = _build_message(to, subject, body, account)
+ msg = _build_message(to, subject, body + _LEGAL_FOOTER, account, html=html)
msg_bytes = msg.as_bytes()
ctx = ssl.create_default_context()
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
@@ -189,6 +203,16 @@ def delete_template(tpl_id: int, user=Depends(require_admin)):
# Senden
# ------------------------------------------------------------------
+def _plain_to_html_body(text: str) -> str:
+ import html as h
+ paragraphs = text.strip().split("\n\n")
+ parts = []
+ for p in paragraphs:
+ escaped = h.escape(p).replace("\n", " ")
+ parts.append(f'{escaped}
')
+ return "".join(parts)
+
+
@router.post("/send")
def send_mail(data: SendRequest, user=Depends(require_admin)):
if not data.to:
@@ -196,13 +220,19 @@ def send_mail(data: SendRequest, user=Depends(require_admin)):
if not data.subject.strip() or not data.body.strip():
raise HTTPException(400, "Betreff und Text dürfen nicht leer sein.")
+ from mailer import email_html
+ html = email_html(
+ _plain_to_html_body(data.body),
+ footer_text=f"Ban Yaro · banyaro.app · {data.subject}",
+ )
+
sent, failed = [], []
for addr in data.to:
addr = addr.strip()
if not addr:
continue
try:
- _send_smtp(addr, data.subject, data.body, data.from_account)
+ _send_smtp(addr, data.subject, data.body, data.from_account, html=html)
sent.append(addr)
with db() as conn:
conn.execute(
@@ -224,7 +254,9 @@ def send_mail(data: SendRequest, user=Depends(require_admin)):
def send_support_mail(to: str, subject: str, body: str):
"""Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik."""
- _send_smtp(to, subject, body, "support")
+ from mailer import email_html
+ html = email_html(_plain_to_html_body(body))
+ _send_smtp(to, subject, body, "support", html=html)
# ------------------------------------------------------------------
@@ -235,7 +267,7 @@ def send_support_mail(to: str, subject: str, body: str):
def outreach_log_endpoint(user=Depends(require_admin)):
with db() as conn:
rows = conn.execute(
- """SELECT ol.id, ol.recipient, ol.subject, ol.sent_at,
+ """SELECT ol.id, ol.recipient, ol.subject, ol.body, ol.sent_at,
ol.from_account, u.name AS sent_by_name
FROM outreach_log ol
JOIN users u ON u.id = ol.sent_by
diff --git a/backend/routes/passport.py b/backend/routes/passport.py
new file mode 100644
index 0000000..884e8d3
--- /dev/null
+++ b/backend/routes/passport.py
@@ -0,0 +1,377 @@
+"""BAN YARO — Digitaler Hundepass"""
+
+import io
+import secrets
+from datetime import date, datetime, timedelta
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+from typing import Optional
+from database import db
+from auth import get_current_user
+
+router = APIRouter()
+
+
+# ------------------------------------------------------------------
+# Schemas
+# ------------------------------------------------------------------
+class PassportMeta(BaseModel):
+ blutgruppe: Optional[str] = None
+ allergien: Optional[str] = None
+ besonderheiten: Optional[str] = None
+
+
+class VaccinationCreate(BaseModel):
+ krankheit: str
+ datum: str
+ naechste: Optional[str] = None
+ tierarzt: Optional[str] = None
+ charge_nr: Optional[str] = None
+
+
+class MedicationCreate(BaseModel):
+ name: str
+ dosierung: Optional[str] = None
+ von: Optional[str] = None
+ bis: Optional[str] = None
+ notiz: Optional[str] = None
+
+
+# ------------------------------------------------------------------
+# Hilfsfunktion: Eigentümer-Prüfung
+# ------------------------------------------------------------------
+def _get_own_dog(conn, dog_id: int, user_id: int):
+ dog = conn.execute(
+ "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+ return dog
+
+
+def _load_passport_data(conn, dog_id: int) -> dict:
+ dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ meta = conn.execute(
+ "SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)
+ ).fetchone()
+ vaccinations = conn.execute(
+ "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
+ ).fetchall()
+ medications = conn.execute(
+ "SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
+ ).fetchall()
+
+ return {
+ "dog": dict(dog),
+ "meta": dict(meta) if meta else {},
+ "vaccinations": [dict(v) for v in vaccinations],
+ "medications": [dict(m) for m in medications],
+ }
+
+
+# ------------------------------------------------------------------
+# GET /passport/{dog_id} — vollständige Passdaten
+# ------------------------------------------------------------------
+@router.get("/{dog_id}")
+async def get_passport(dog_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _get_own_dog(conn, dog_id, user["id"])
+ return _load_passport_data(conn, dog_id)
+
+
+# ------------------------------------------------------------------
+# PUT /passport/{dog_id}/meta
+# ------------------------------------------------------------------
+@router.put("/{dog_id}/meta")
+async def update_meta(dog_id: int, data: PassportMeta, user=Depends(get_current_user)):
+ with db() as conn:
+ _get_own_dog(conn, dog_id, user["id"])
+ conn.execute("""
+ INSERT INTO dog_passport_meta (dog_id, blutgruppe, allergien, besonderheiten, updated_at)
+ VALUES (?, ?, ?, ?, datetime('now'))
+ ON CONFLICT(dog_id) DO UPDATE SET
+ blutgruppe = excluded.blutgruppe,
+ allergien = excluded.allergien,
+ besonderheiten = excluded.besonderheiten,
+ updated_at = excluded.updated_at
+ """, (dog_id, data.blutgruppe, data.allergien, data.besonderheiten))
+ return {"ok": True}
+
+
+# ------------------------------------------------------------------
+# POST /passport/{dog_id}/vaccinations
+# ------------------------------------------------------------------
+@router.post("/{dog_id}/vaccinations")
+async def add_vaccination(dog_id: int, data: VaccinationCreate, user=Depends(get_current_user)):
+ with db() as conn:
+ _get_own_dog(conn, dog_id, user["id"])
+ conn.execute("""
+ INSERT INTO vaccinations (dog_id, krankheit, datum, naechste, tierarzt, charge_nr)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """, (dog_id, data.krankheit, data.datum, data.naechste, data.tierarzt, data.charge_nr))
+ row = conn.execute(
+ "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,)
+ ).fetchone()
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# DELETE /passport/{dog_id}/vaccinations/{vacc_id}
+# ------------------------------------------------------------------
+@router.delete("/{dog_id}/vaccinations/{vacc_id}", status_code=204)
+async def delete_vaccination(dog_id: int, vacc_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _get_own_dog(conn, dog_id, user["id"])
+ conn.execute(
+ "DELETE FROM vaccinations WHERE id=? AND dog_id=?", (vacc_id, dog_id)
+ )
+
+
+# ------------------------------------------------------------------
+# POST /passport/{dog_id}/medications
+# ------------------------------------------------------------------
+@router.post("/{dog_id}/medications")
+async def add_medication(dog_id: int, data: MedicationCreate, user=Depends(get_current_user)):
+ with db() as conn:
+ _get_own_dog(conn, dog_id, user["id"])
+ conn.execute("""
+ INSERT INTO medications (dog_id, name, dosierung, von, bis, notiz)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """, (dog_id, data.name, data.dosierung, data.von, data.bis, data.notiz))
+ row = conn.execute(
+ "SELECT * FROM medications WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,)
+ ).fetchone()
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# DELETE /passport/{dog_id}/medications/{med_id}
+# ------------------------------------------------------------------
+@router.delete("/{dog_id}/medications/{med_id}", status_code=204)
+async def delete_medication(dog_id: int, med_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _get_own_dog(conn, dog_id, user["id"])
+ conn.execute(
+ "DELETE FROM medications WHERE id=? AND dog_id=?", (med_id, dog_id)
+ )
+
+
+# ------------------------------------------------------------------
+# POST /passport/{dog_id}/share — Share-Token erstellen
+# ------------------------------------------------------------------
+@router.post("/{dog_id}/share")
+async def create_share(dog_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _get_own_dog(conn, dog_id, user["id"])
+ token = secrets.token_urlsafe(32)
+ valid_until = (date.today() + timedelta(days=30)).isoformat()
+ conn.execute("""
+ INSERT INTO passport_shares (dog_id, token, valid_until)
+ VALUES (?, ?, ?)
+ """, (dog_id, token, valid_until))
+ return {
+ "token": token,
+ "valid_until": valid_until,
+ "url": f"/pass/{token}",
+ }
+
+
+# ------------------------------------------------------------------
+# GET /passport/share/{token} — öffentlicher Endpunkt (kein Auth)
+# ------------------------------------------------------------------
+@router.get("/share/{token}")
+async def get_shared_passport(token: str):
+ with db() as conn:
+ share = conn.execute(
+ "SELECT * FROM passport_shares WHERE token=?", (token,)
+ ).fetchone()
+ if not share:
+ raise HTTPException(404, "Link nicht gefunden.")
+ if share["valid_until"] < date.today().isoformat():
+ raise HTTPException(410, "Dieser Link ist abgelaufen.")
+ return _load_passport_data(conn, share["dog_id"])
+
+
+# ------------------------------------------------------------------
+# GET /passport/{dog_id}/pdf — PDF generieren
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/pdf")
+async def download_pdf(dog_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _get_own_dog(conn, dog_id, user["id"])
+ data = _load_passport_data(conn, dog_id)
+
+ pdf_bytes = _generate_pdf(data)
+ dog_name = data["dog"]["name"].replace(" ", "_")
+ filename = f"Hundepass_{dog_name}.pdf"
+
+ return StreamingResponse(
+ io.BytesIO(pdf_bytes),
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
+# ------------------------------------------------------------------
+# PDF-Generierung mit fpdf2
+# ------------------------------------------------------------------
+def _generate_pdf(data: dict) -> bytes:
+ try:
+ from fpdf import FPDF
+ except ImportError:
+ raise HTTPException(500, "PDF-Bibliothek nicht verfügbar. Bitte fpdf2 installieren.")
+
+ dog = data["dog"]
+ meta = data["meta"]
+ vaccs = data["vaccinations"]
+ meds = data["medications"]
+
+ # Datumsformatierung DE
+ def _fmt_date(d):
+ if not d:
+ return "–"
+ try:
+ return datetime.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
+ except Exception:
+ return d
+
+ # Geschlecht
+ geschlecht_map = {"m": "Rüde", "w": "Hündin"}
+
+ pdf = FPDF()
+ pdf.set_auto_page_break(auto=True, margin=20)
+ pdf.add_page()
+
+ # ---- Header ----
+ pdf.set_fill_color(40, 167, 100) # Ban Yaro Grün
+ pdf.rect(0, 0, 210, 38, style="F")
+
+ pdf.set_text_color(255, 255, 255)
+ pdf.set_font("Helvetica", style="B", size=20)
+ pdf.set_y(8)
+ pdf.cell(0, 10, "Ban Yaro", align="C", ln=True)
+ pdf.set_font("Helvetica", size=11)
+ pdf.cell(0, 8, f"Digitaler Hundepass — {dog['name']}", align="C", ln=True)
+ pdf.set_font("Helvetica", size=8)
+ pdf.cell(0, 6, f"Erstellt am {date.today().strftime('%d.%m.%Y')}", align="C", ln=True)
+
+ pdf.set_text_color(30, 30, 30)
+ pdf.set_y(46)
+
+ # ---- Hundedaten ----
+ pdf.set_fill_color(245, 250, 247)
+ pdf.set_draw_color(200, 200, 200)
+ pdf.set_font("Helvetica", style="B", size=12)
+ pdf.set_fill_color(235, 247, 240)
+ pdf.cell(0, 8, " Hundeangaben", ln=True, fill=True, border="B")
+ pdf.ln(3)
+
+ def _info_row(label, value):
+ pdf.set_font("Helvetica", style="B", size=9)
+ pdf.cell(45, 6, label + ":", ln=False)
+ pdf.set_font("Helvetica", size=9)
+ pdf.cell(0, 6, str(value) if value else "–", ln=True)
+
+ _info_row("Name", dog["name"])
+ _info_row("Rasse", dog.get("rasse") or "–")
+ _info_row("Geburtstag", _fmt_date(dog.get("geburtstag")))
+ _info_row("Geschlecht", geschlecht_map.get(dog.get("geschlecht", ""), "–"))
+ _info_row("Chip-Nr.", dog.get("chip_nr") or "–")
+ if meta.get("blutgruppe"):
+ _info_row("Blutgruppe", meta["blutgruppe"])
+
+ pdf.ln(5)
+
+ # ---- Allergien & Besonderheiten ----
+ if meta.get("allergien") or meta.get("besonderheiten"):
+ pdf.set_font("Helvetica", style="B", size=12)
+ pdf.set_fill_color(235, 247, 240)
+ pdf.cell(0, 8, " Allergien & Besonderheiten", ln=True, fill=True, border="B")
+ pdf.ln(3)
+ if meta.get("allergien"):
+ pdf.set_font("Helvetica", style="B", size=9)
+ pdf.cell(45, 6, "Allergien:", ln=False)
+ pdf.set_font("Helvetica", size=9)
+ pdf.multi_cell(0, 6, meta["allergien"])
+ if meta.get("besonderheiten"):
+ pdf.set_font("Helvetica", style="B", size=9)
+ pdf.cell(45, 6, "Besonderheiten:", ln=False)
+ pdf.set_font("Helvetica", size=9)
+ pdf.multi_cell(0, 6, meta["besonderheiten"])
+ pdf.ln(5)
+
+ # ---- Impfungen ----
+ pdf.set_font("Helvetica", style="B", size=12)
+ pdf.set_fill_color(235, 247, 240)
+ pdf.cell(0, 8, " Impfungen", ln=True, fill=True, border="B")
+ pdf.ln(3)
+
+ if vaccs:
+ # Tabellen-Header
+ pdf.set_fill_color(220, 240, 228)
+ pdf.set_font("Helvetica", style="B", size=8)
+ pdf.cell(50, 6, "Krankheit", border=1, fill=True)
+ pdf.cell(25, 6, "Datum", border=1, fill=True)
+ pdf.cell(25, 6, "Nächste fällig", border=1, fill=True)
+ pdf.cell(55, 6, "Tierarzt", border=1, fill=True)
+ pdf.cell(35, 6, "Charge-Nr.", border=1, fill=True, ln=True)
+
+ pdf.set_font("Helvetica", size=8)
+ for i, v in enumerate(vaccs):
+ fill = (i % 2 == 0)
+ pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255)
+ pdf.cell(50, 6, (v["krankheit"] or "")[:28], border=1, fill=fill)
+ pdf.cell(25, 6, _fmt_date(v["datum"]), border=1, fill=fill)
+ pdf.cell(25, 6, _fmt_date(v["naechste"]), border=1, fill=fill)
+ pdf.cell(55, 6, (v["tierarzt"] or "–")[:32], border=1, fill=fill)
+ pdf.cell(35, 6, (v["charge_nr"] or "–")[:20], border=1, fill=fill, ln=True)
+ else:
+ pdf.set_font("Helvetica", style="I", size=9)
+ pdf.set_text_color(140, 140, 140)
+ pdf.cell(0, 6, "Keine Impfungen eingetragen.", ln=True)
+ pdf.set_text_color(30, 30, 30)
+
+ pdf.ln(5)
+
+ # ---- Medikamente ----
+ pdf.set_font("Helvetica", style="B", size=12)
+ pdf.set_fill_color(235, 247, 240)
+ pdf.cell(0, 8, " Medikamente", ln=True, fill=True, border="B")
+ pdf.ln(3)
+
+ if meds:
+ pdf.set_fill_color(220, 240, 228)
+ pdf.set_font("Helvetica", style="B", size=8)
+ pdf.cell(55, 6, "Medikament", border=1, fill=True)
+ pdf.cell(35, 6, "Dosierung", border=1, fill=True)
+ pdf.cell(25, 6, "Von", border=1, fill=True)
+ pdf.cell(25, 6, "Bis", border=1, fill=True)
+ pdf.cell(50, 6, "Notiz", border=1, fill=True, ln=True)
+
+ pdf.set_font("Helvetica", size=8)
+ for i, m in enumerate(meds):
+ fill = (i % 2 == 0)
+ pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255)
+ pdf.cell(55, 6, (m["name"] or "")[:32], border=1, fill=fill)
+ pdf.cell(35, 6, (m["dosierung"] or "–")[:22], border=1, fill=fill)
+ pdf.cell(25, 6, _fmt_date(m["von"]), border=1, fill=fill)
+ bis = _fmt_date(m["bis"]) if m["bis"] else "dauerhaft"
+ pdf.cell(25, 6, bis, border=1, fill=fill)
+ pdf.cell(50, 6, (m["notiz"] or "–")[:30], border=1, fill=fill, ln=True)
+ else:
+ pdf.set_font("Helvetica", style="I", size=9)
+ pdf.set_text_color(140, 140, 140)
+ pdf.cell(0, 6, "Keine Medikamente eingetragen.", ln=True)
+ pdf.set_text_color(30, 30, 30)
+
+ # ---- Footer ----
+ pdf.set_y(-15)
+ pdf.set_font("Helvetica", style="I", size=8)
+ pdf.set_text_color(140, 140, 140)
+ pdf.cell(0, 5, "Erstellt mit Ban Yaro — banyaro.app", align="C", ln=True)
+
+ return bytes(pdf.output())
diff --git a/backend/routes/playdate.py b/backend/routes/playdate.py
new file mode 100644
index 0000000..01d57ae
--- /dev/null
+++ b/backend/routes/playdate.py
@@ -0,0 +1,364 @@
+"""BAN YARO — Playdate-Matching"""
+
+import math
+import logging
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from typing import Optional
+from database import db
+from auth import get_current_user
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+
+# ------------------------------------------------------------------
+# Haversine
+# ------------------------------------------------------------------
+def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
+ R = 6371.0
+ dlat = math.radians(lat2 - lat1)
+ dlon = math.radians(lon2 - lon1)
+ a = (math.sin(dlat / 2) ** 2
+ + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
+ * math.sin(dlon / 2) ** 2)
+ return R * 2 * math.asin(math.sqrt(a))
+
+
+def _calc_alter(geburtstag: Optional[str]) -> Optional[str]:
+ """Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'."""
+ if not geburtstag:
+ return None
+ try:
+ from datetime import date
+ geb = date.fromisoformat(geburtstag[:10])
+ today = date.today()
+ monate = (today.year - geb.year) * 12 + (today.month - geb.month)
+ if today.day < geb.day:
+ monate -= 1
+ if monate < 0:
+ return None
+ if monate < 24:
+ return f"{monate} {'Monat' if monate == 1 else 'Monate'}"
+ jahre = monate // 12
+ return f"{jahre} {'Jahr' if jahre == 1 else 'Jahre'}"
+ except Exception:
+ return None
+
+
+# ------------------------------------------------------------------
+# Schemas
+# ------------------------------------------------------------------
+class ListingUpsert(BaseModel):
+ dog_id: int
+ lat: float
+ lon: float
+ ort_name: Optional[str] = None
+ radius_km: int = 10
+ beschreibung: Optional[str] = None
+
+
+class RequestCreate(BaseModel):
+ to_dog_id: int
+ nachricht: Optional[str] = None
+
+
+class RequestPatch(BaseModel):
+ status: str # accepted | declined
+
+
+# ------------------------------------------------------------------
+# Helpers — Konversation für Playdate öffnen (ohne Freundschaftspflicht)
+# ------------------------------------------------------------------
+def _ensure_conversation(conn, user_a: int, user_b: int) -> int:
+ a, b = (min(user_a, user_b), max(user_a, user_b))
+ existing = conn.execute(
+ "SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?",
+ (a, b)
+ ).fetchone()
+ if existing:
+ return existing["id"]
+ cur = conn.execute(
+ "INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)",
+ (a, b)
+ )
+ return cur.lastrowid
+
+
+# ------------------------------------------------------------------
+# Routes
+# ------------------------------------------------------------------
+
+@router.get("/nearby")
+async def nearby(lat: float, lon: float, radius: int = 10,
+ user=Depends(get_current_user)):
+ uid = user["id"]
+ with db() as conn:
+ rows = conn.execute("""
+ SELECT pl.id AS listing_id,
+ pl.lat, pl.lon, pl.ort_name, pl.beschreibung,
+ d.id AS dog_id, d.name AS dog_name, d.rasse,
+ d.geburtstag, d.foto_url, d.geschlecht
+ FROM playdate_listings pl
+ JOIN dogs d ON d.id = pl.dog_id
+ WHERE pl.aktiv = 1
+ AND pl.user_id != ?
+ """, (uid,)).fetchall()
+
+ result = []
+ for r in rows:
+ dist = _haversine(lat, lon, r["lat"], r["lon"])
+ if dist <= radius:
+ result.append({
+ "listing_id": r["listing_id"],
+ "dog_id": r["dog_id"],
+ "dog_name": r["dog_name"],
+ "rasse": r["rasse"],
+ "alter": _calc_alter(r["geburtstag"]),
+ "geschlecht": r["geschlecht"],
+ "foto_url": r["foto_url"],
+ "ort_name": r["ort_name"],
+ "beschreibung": r["beschreibung"],
+ "entfernung_km": round(dist, 1),
+ })
+
+ result.sort(key=lambda x: x["entfernung_km"])
+ return result
+
+
+@router.put("/listing", status_code=200)
+async def upsert_listing(data: ListingUpsert, user=Depends(get_current_user)):
+ uid = user["id"]
+ with db() as conn:
+ # Sicherstellen dass der Hund dem User gehört
+ dog = conn.execute(
+ "SELECT id FROM dogs WHERE id=? AND user_id=?",
+ (data.dog_id, uid)
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ existing = conn.execute(
+ "SELECT id FROM playdate_listings WHERE dog_id=?",
+ (data.dog_id,)
+ ).fetchone()
+
+ if existing:
+ conn.execute("""
+ UPDATE playdate_listings
+ SET lat=?, lon=?, ort_name=?, radius_km=?, beschreibung=?,
+ aktiv=1, updated_at=datetime('now')
+ WHERE dog_id=?
+ """, (data.lat, data.lon, data.ort_name, data.radius_km,
+ data.beschreibung, data.dog_id))
+ return {"ok": True, "id": existing["id"]}
+ else:
+ cur = conn.execute("""
+ INSERT INTO playdate_listings
+ (dog_id, user_id, lat, lon, ort_name, radius_km, beschreibung)
+ VALUES (?,?,?,?,?,?,?)
+ """, (data.dog_id, uid, data.lat, data.lon, data.ort_name,
+ data.radius_km, data.beschreibung))
+ return {"ok": True, "id": cur.lastrowid}
+
+
+@router.delete("/listing/{dog_id}", status_code=200)
+async def deactivate_listing(dog_id: int, user=Depends(get_current_user)):
+ uid = user["id"]
+ with db() as conn:
+ row = conn.execute(
+ "SELECT id FROM playdate_listings WHERE dog_id=? AND user_id=?",
+ (dog_id, uid)
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "Inserat nicht gefunden.")
+ conn.execute(
+ "UPDATE playdate_listings SET aktiv=0, updated_at=datetime('now') WHERE dog_id=?",
+ (dog_id,)
+ )
+ return {"ok": True}
+
+
+@router.get("/my-listing/{dog_id}")
+async def my_listing(dog_id: int, user=Depends(get_current_user)):
+ uid = user["id"]
+ with db() as conn:
+ row = conn.execute(
+ """SELECT id, dog_id, lat, lon, ort_name, radius_km, beschreibung, aktiv
+ FROM playdate_listings WHERE dog_id=? AND user_id=?""",
+ (dog_id, uid)
+ ).fetchone()
+ if not row:
+ return None
+ return dict(row)
+
+
+@router.post("/request", status_code=201)
+async def create_request(data: RequestCreate, user=Depends(get_current_user)):
+ uid = user["id"]
+ with db() as conn:
+ # Eigenen Hund ermitteln — nimm den ersten aktiven Hund des Users
+ own_dog = conn.execute(
+ "SELECT id FROM dogs WHERE user_id=? ORDER BY id LIMIT 1",
+ (uid,)
+ ).fetchone()
+ if not own_dog:
+ raise HTTPException(400, "Du hast noch keinen Hund eingetragen.")
+
+ from_dog_id = own_dog["id"]
+
+ # Zielhund + Besitzer prüfen
+ target = conn.execute(
+ "SELECT d.id, d.user_id FROM dogs d WHERE d.id=?",
+ (data.to_dog_id,)
+ ).fetchone()
+ if not target:
+ raise HTTPException(404, "Zielhund nicht gefunden.")
+ if target["user_id"] == uid:
+ raise HTTPException(400, "Du kannst nicht dir selbst eine Anfrage schicken.")
+
+ to_user_id = target["user_id"]
+
+ # Doppelte Anfrage verhindern
+ existing = conn.execute(
+ "SELECT id, status FROM playdate_requests WHERE from_dog_id=? AND to_dog_id=?",
+ (from_dog_id, data.to_dog_id)
+ ).fetchone()
+ if existing:
+ if existing["status"] == "pending":
+ raise HTTPException(409, "Du hast bereits eine offene Anfrage an diesen Hund.")
+ # Alte abgelehnte Anfrage: löschen und neu anlegen
+ conn.execute(
+ "DELETE FROM playdate_requests WHERE id=?",
+ (existing["id"],)
+ )
+
+ cur = conn.execute("""
+ INSERT INTO playdate_requests
+ (from_dog_id, to_dog_id, from_user_id, to_user_id, nachricht)
+ VALUES (?,?,?,?,?)
+ """, (from_dog_id, data.to_dog_id, uid, to_user_id, data.nachricht))
+ request_id = cur.lastrowid
+
+ # Chat-Konversation anlegen (ohne Freundschaftspflicht)
+ conv_id = _ensure_conversation(conn, uid, to_user_id)
+
+ # Erste Nachricht mit Kontext senden
+ intro = f"Hallo! Ich habe eine Playdate-Anfrage für unsere Hunde geschickt."
+ if data.nachricht:
+ intro += f" Meine Nachricht: {data.nachricht}"
+ conn.execute("""
+ INSERT INTO direct_messages (conversation_id, sender_id, text)
+ VALUES (?,?,?)
+ """, (conv_id, uid, intro))
+ conn.execute(
+ "UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?",
+ (conv_id,)
+ )
+
+ try:
+ from routes.push import send_push_to_user
+ send_push_to_user(to_user_id, {
+ "title": "Playdate-Anfrage",
+ "body": f"{user['name']} möchte ein Treffen vereinbaren!",
+ "type": "playdate_request",
+ "tag": f"playdate-{request_id}",
+ "data": {"page": "playdate"},
+ })
+ except Exception:
+ pass
+
+ return {"ok": True, "request_id": request_id, "conversation_id": conv_id}
+
+
+@router.get("/requests")
+async def list_requests(user=Depends(get_current_user)):
+ uid = user["id"]
+ with db() as conn:
+ incoming = conn.execute("""
+ SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
+ pr.from_user_id,
+ uf.name AS from_user_name,
+ df.name AS from_dog_name, df.rasse AS from_dog_rasse,
+ df.foto_url AS from_dog_foto,
+ df.geburtstag AS from_dog_geburtstag,
+ dt.name AS to_dog_name
+ FROM playdate_requests pr
+ JOIN users uf ON uf.id = pr.from_user_id
+ JOIN dogs df ON df.id = pr.from_dog_id
+ JOIN dogs dt ON dt.id = pr.to_dog_id
+ WHERE pr.to_user_id = ?
+ ORDER BY pr.created_at DESC
+ """, (uid,)).fetchall()
+
+ outgoing = conn.execute("""
+ SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
+ pr.to_user_id,
+ ut.name AS to_user_name,
+ dt.name AS to_dog_name, dt.rasse AS to_dog_rasse,
+ dt.foto_url AS to_dog_foto,
+ df.name AS from_dog_name
+ FROM playdate_requests pr
+ JOIN users ut ON ut.id = pr.to_user_id
+ JOIN dogs dt ON dt.id = pr.to_dog_id
+ JOIN dogs df ON df.id = pr.from_dog_id
+ WHERE pr.from_user_id = ?
+ ORDER BY pr.created_at DESC
+ """, (uid,)).fetchall()
+
+ def _enrich(rows, direction):
+ result = []
+ for r in rows:
+ d = dict(r)
+ d["direction"] = direction
+ if direction == "incoming":
+ d["alter"] = _calc_alter(d.get("from_dog_geburtstag"))
+ result.append(d)
+ return result
+
+ return {
+ "incoming": _enrich(incoming, "incoming"),
+ "outgoing": _enrich(outgoing, "outgoing"),
+ }
+
+
+@router.patch("/requests/{req_id}", status_code=200)
+async def patch_request(req_id: int, data: RequestPatch,
+ user=Depends(get_current_user)):
+ uid = user["id"]
+ if data.status not in ("accepted", "declined"):
+ raise HTTPException(400, "Status muss 'accepted' oder 'declined' sein.")
+
+ with db() as conn:
+ req = conn.execute(
+ "SELECT * FROM playdate_requests WHERE id=? AND to_user_id=?",
+ (req_id, uid)
+ ).fetchone()
+ if not req:
+ raise HTTPException(404, "Anfrage nicht gefunden.")
+ if req["status"] != "pending":
+ raise HTTPException(409, "Anfrage wurde bereits beantwortet.")
+
+ conn.execute(
+ "UPDATE playdate_requests SET status=? WHERE id=?",
+ (data.status, req_id)
+ )
+
+ conv_id = None
+ if data.status == "accepted":
+ conv_id = _ensure_conversation(conn, uid, req["from_user_id"])
+
+ try:
+ from routes.push import send_push_to_user
+ verb = "angenommen" if data.status == "accepted" else "abgelehnt"
+ send_push_to_user(req["from_user_id"], {
+ "title": f"Playdate {verb}!",
+ "body": f"{user['name']} hat deine Anfrage {verb}.",
+ "type": "playdate_response",
+ "tag": f"playdate-{req_id}",
+ "data": {"page": "playdate"},
+ })
+ except Exception:
+ pass
+
+ return {"ok": True, "conversation_id": conv_id}
diff --git a/backend/routes/recalls.py b/backend/routes/recalls.py
new file mode 100644
index 0000000..d0182a3
--- /dev/null
+++ b/backend/routes/recalls.py
@@ -0,0 +1,138 @@
+"""BAN YARO — Rückruf-Alarm (Tierfutter)
+RASFF EU Rapid Alert System for Food and Feed
+"""
+
+import logging
+import httpx
+from fastapi import APIRouter
+from database import db
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+RASFF_URL = "https://webgate.ec.europa.eu/rasff-window/backend/public/notification/list/with-filters"
+RASFF_PARAMS = {
+ "filters": '{"subject.product_category":["pet food and animal feed"]}',
+ "pageNumber": 0,
+ "pageSize": 20,
+ "sortColumn": "notificationDate",
+ "sortDirection": "DESC",
+}
+
+
+# ------------------------------------------------------------------
+# GET /api/recalls — Letzte 50 Rückrufe
+# ------------------------------------------------------------------
+@router.get("")
+async def list_recalls(q: str = ""):
+ with db() as conn:
+ if q:
+ like = f"%{q}%"
+ rows = conn.execute("""
+ SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at
+ FROM feed_recalls
+ WHERE titel LIKE ? OR produkt LIKE ? OR gefahr LIKE ? OR herkunft LIKE ?
+ ORDER BY datum DESC
+ LIMIT 50
+ """, (like, like, like, like)).fetchall()
+ else:
+ rows = conn.execute("""
+ SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at
+ FROM feed_recalls
+ ORDER BY datum DESC
+ LIMIT 50
+ """).fetchall()
+ return [dict(r) for r in rows]
+
+
+# ------------------------------------------------------------------
+# Interne Hilfsfunktion: RASFF API abfragen
+# ------------------------------------------------------------------
+async def fetch_rasff_recalls() -> list[dict]:
+ """Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück."""
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(RASFF_URL, params=RASFF_PARAMS)
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as e:
+ logger.error(f"RASFF API-Fehler: {e}")
+ return []
+
+ entries = []
+ try:
+ items = data.get("data", {}).get("list", [])
+ for item in items:
+ reference = item.get("reference", "")
+ if not reference:
+ continue
+
+ # Datum
+ datum_raw = item.get("notificationDate", "")
+ datum = datum_raw[:10] if datum_raw else ""
+
+ # Produkt
+ subject = item.get("subject") or {}
+ produkt = subject.get("product", "") or ""
+
+ # Gefahr
+ hazards = subject.get("hazard") or []
+ gefahr = ""
+ if hazards:
+ gefahr = hazards[0].get("hazardDescription", "") or ""
+
+ # Herkunft
+ origin = item.get("origin") or {}
+ herkunft = origin.get("name", "") or ""
+
+ # URL zur RASFF-Seite
+ url = f"https://webgate.ec.europa.eu/rasff-window/screen/notificationDetail?notifRef={reference}"
+
+ entries.append({
+ "external_id": reference,
+ "titel": produkt or reference,
+ "produkt": produkt,
+ "gefahr": gefahr,
+ "herkunft": herkunft,
+ "datum": datum,
+ "quelle": "rasff",
+ "url": url,
+ })
+ except Exception as e:
+ logger.error(f"RASFF Parsing-Fehler: {e}")
+
+ return entries
+
+
+# ------------------------------------------------------------------
+# Interne Hilfsfunktion: Neue Einträge in DB speichern
+# ------------------------------------------------------------------
+def save_new_recalls(entries: list[dict]) -> list[dict]:
+ """Speichert neue Einträge und gibt die Liste der neuen Einträge zurück."""
+ new_entries = []
+ for entry in entries:
+ try:
+ with db() as conn:
+ exists = conn.execute(
+ "SELECT id FROM feed_recalls WHERE external_id=?",
+ (entry["external_id"],)
+ ).fetchone()
+ if not exists:
+ conn.execute("""
+ INSERT INTO feed_recalls
+ (external_id, titel, produkt, gefahr, herkunft, datum, quelle, url)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """, (
+ entry["external_id"],
+ entry["titel"],
+ entry["produkt"],
+ entry["gefahr"],
+ entry["herkunft"],
+ entry["datum"],
+ entry["quelle"],
+ entry["url"],
+ ))
+ new_entries.append(entry)
+ except Exception as e:
+ logger.warning(f"Recall DB-Fehler für {entry.get('external_id')}: {e}")
+ return new_entries
diff --git a/backend/routes/streak.py b/backend/routes/streak.py
new file mode 100644
index 0000000..c387a68
--- /dev/null
+++ b/backend/routes/streak.py
@@ -0,0 +1,114 @@
+"""BAN YARO — Trainings-Streak"""
+
+import datetime
+from fastapi import APIRouter, Depends, HTTPException
+from database import db
+from auth import get_current_user
+
+router = APIRouter()
+
+_today = lambda: datetime.date.today().isoformat()
+_yesterday = lambda: (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
+
+
+# ------------------------------------------------------------------
+# GET /streak/leaderboard — Top-10 Streaks (öffentliche Hunde)
+# Muss VOR /{dog_id} stehen, sonst greift der int-Parameter zuerst.
+# ------------------------------------------------------------------
+@router.get("/streak/leaderboard")
+async def get_leaderboard(user=Depends(get_current_user)):
+ with db() as conn:
+ rows = conn.execute("""
+ SELECT
+ u.name AS user_name,
+ d.name AS dog_name,
+ d.rasse,
+ d.foto_url,
+ ts.current_streak
+ FROM training_streaks ts
+ JOIN dogs d ON d.id = ts.dog_id
+ JOIN users u ON u.id = ts.user_id
+ WHERE ts.current_streak > 0
+ AND d.is_public = 1
+ ORDER BY ts.current_streak DESC
+ LIMIT 10
+ """).fetchall()
+ return [dict(r) for r in rows]
+
+
+# ------------------------------------------------------------------
+# GET /streak/{dog_id} — aktueller Streak eines Hundes
+# ------------------------------------------------------------------
+@router.get("/streak/{dog_id}")
+async def get_streak(dog_id: int, user=Depends(get_current_user)):
+ uid = user["id"]
+ with db() as conn:
+ dog = conn.execute(
+ "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid)
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ row = conn.execute(
+ "SELECT current_streak, longest_streak, last_training_date "
+ "FROM training_streaks WHERE user_id=? AND dog_id=?",
+ (uid, dog_id)
+ ).fetchone()
+
+ if not row:
+ return {"current_streak": 0, "longest_streak": 0, "last_training_date": None}
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# POST /streak/{dog_id}/ping — Training heute registrieren
+# ------------------------------------------------------------------
+@router.post("/streak/{dog_id}/ping")
+async def ping_streak(dog_id: int, user=Depends(get_current_user)):
+ uid = user["id"]
+ today = _today()
+ yest = _yesterday()
+
+ with db() as conn:
+ dog = conn.execute(
+ "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid)
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ row = conn.execute(
+ "SELECT current_streak, longest_streak, last_training_date "
+ "FROM training_streaks WHERE user_id=? AND dog_id=?",
+ (uid, dog_id)
+ ).fetchone()
+
+ if row:
+ cur = row["current_streak"]
+ longest = row["longest_streak"]
+ last = row["last_training_date"]
+
+ if last == today:
+ # Bereits heute gepingt — nichts tun
+ return {"current_streak": cur, "longest_streak": longest, "last_training_date": last}
+ elif last == yest:
+ cur += 1
+ else:
+ cur = 1
+
+ longest = max(longest, cur)
+
+ conn.execute(
+ "UPDATE training_streaks SET current_streak=?, longest_streak=?, last_training_date=? "
+ "WHERE user_id=? AND dog_id=?",
+ (cur, longest, today, uid, dog_id)
+ )
+ else:
+ cur = 1
+ longest = 1
+ conn.execute(
+ "INSERT INTO training_streaks (user_id, dog_id, current_streak, longest_streak, last_training_date) "
+ "VALUES (?,?,?,?,?)",
+ (uid, dog_id, cur, longest, today)
+ )
+
+ return {"current_streak": cur, "longest_streak": longest, "last_training_date": today}
diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py
index 55107ec..48287f9 100644
--- a/backend/routes/tieraerzte.py
+++ b/backend/routes/tieraerzte.py
@@ -63,15 +63,68 @@ def _fmt_opening_hours(raw: str | None) -> str | None:
return result
+@router.get("/my-favorite")
+async def get_my_favorite(user=Depends(get_current_user)):
+ """Favoriten-Tierarzt des Users (oder null)."""
+ with db() as conn:
+ row = conn.execute(
+ """SELECT t.* FROM tieraerzte t
+ JOIN favorite_vets fv ON fv.vet_id = t.id
+ WHERE fv.user_id = ?
+ LIMIT 1""",
+ (user["id"],)
+ ).fetchone()
+ if not row:
+ return None
+ return dict(row)
+
+
+@router.post("/{vet_id}/favorite")
+async def toggle_favorite(vet_id: int, user=Depends(get_current_user)):
+ """Tierarzt als Favorit setzen oder entfernen (toggle). Gibt {is_favorite: bool} zurück."""
+ with db() as conn:
+ vet = conn.execute(
+ "SELECT id FROM tieraerzte WHERE id=?", (vet_id,)
+ ).fetchone()
+ if not vet:
+ raise HTTPException(404, "Tierarzt nicht gefunden.")
+
+ existing = conn.execute(
+ "SELECT 1 FROM favorite_vets WHERE user_id=? AND vet_id=?",
+ (user["id"], vet_id)
+ ).fetchone()
+
+ if existing:
+ conn.execute(
+ "DELETE FROM favorite_vets WHERE user_id=? AND vet_id=?",
+ (user["id"], vet_id)
+ )
+ return {"is_favorite": False}
+ else:
+ conn.execute(
+ "INSERT INTO favorite_vets (user_id, vet_id) VALUES (?, ?)",
+ (user["id"], vet_id)
+ )
+ return {"is_favorite": True}
+
+
@router.get("")
async def list_tieraerzte(user=Depends(get_current_user)):
- """Alle Tierärzte des Users — aktive zuerst, dann inaktive."""
+ """Alle Tierärzte des Users — aktive zuerst, dann inaktive. Enthält is_favorite."""
with db() as conn:
rows = conn.execute(
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name",
(user["id"],)
).fetchall()
- return [dict(r) for r in rows]
+ favs = {r["vet_id"] for r in conn.execute(
+ "SELECT vet_id FROM favorite_vets WHERE user_id=?", (user["id"],)
+ ).fetchall()}
+ result = []
+ for r in rows:
+ d = dict(r)
+ d["is_favorite"] = r["id"] in favs
+ result.append(d)
+ return result
@router.get("/osm-nearby")
diff --git a/backend/routes/weather.py b/backend/routes/weather.py
index 319cfd2..fced719 100644
--- a/backend/routes/weather.py
+++ b/backend/routes/weather.py
@@ -3,8 +3,9 @@ BAN YARO — Wetter-API
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
"""
-from fastapi import APIRouter, Query, HTTPException
+from fastapi import APIRouter, Query, HTTPException, Depends
import weather as weather_module
+from auth import get_current_user
router = APIRouter()
@@ -18,3 +19,15 @@ async def get_weather(
return await weather_module.get_weather_for_location(lat, lon)
except Exception as exc:
raise HTTPException(503, f'Wetter nicht verfügbar: {exc}')
+
+
+@router.get('/forecast')
+async def get_weather_forecast(
+ lat: float = Query(..., ge=-90, le=90),
+ lon: float = Query(..., ge=-180, le=180),
+ user=Depends(get_current_user),
+):
+ try:
+ return await weather_module.get_forecast(lat, lon)
+ except Exception as exc:
+ raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}')
diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py
index 83093d7..45f5bfb 100644
--- a/backend/routes/wiki.py
+++ b/backend/routes/wiki.py
@@ -317,19 +317,24 @@ async def submit_foto(
if not rights_confirmed:
raise HTTPException(400, "Bildrechte-Bestätigung fehlt.")
- # Dateiformat prüfen
- ct = file.content_type or ""
- if not ct.startswith("image/"):
- raise HTTPException(400, "Nur Bilddateien erlaubt.")
+ _IMAGE_MAGIC = [
+ b"\xff\xd8\xff", # JPEG
+ b"\x89PNG\r\n\x1a\n", # PNG
+ b"RIFF", # WebP (RIFF....WEBP)
+ b"GIF87a", b"GIF89a", # GIF
+ ]
os.makedirs(SUBMIT_DIR, exist_ok=True)
- ts = int(time.time())
- filename = f"{slug}_{user['id']}_{ts}.jpg"
- path = os.path.join(SUBMIT_DIR, filename)
-
+ ts = int(time.time())
content = await file.read()
if len(content) > 8 * 1024 * 1024:
raise HTTPException(400, "Datei zu groß (max. 8 MB).")
+
+ if not any(content.startswith(magic) for magic in _IMAGE_MAGIC):
+ raise HTTPException(400, "Nur Bilddateien erlaubt (JPEG, PNG, WebP, GIF).")
+
+ filename = f"{slug}_{user['id']}_{ts}.jpg"
+ path = os.path.join(SUBMIT_DIR, filename)
with open(path, "wb") as f:
f.write(content)
@@ -694,11 +699,12 @@ async def list_zuchter_pending(user=Depends(get_current_user)):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
rows = conn.execute(
- """SELECT z.*, u.name AS user_name
+ """SELECT z.*, u.name AS user_name, m.name AS verified_by_name
FROM wiki_zuchter z
LEFT JOIN users u ON u.id = z.user_id
- WHERE z.verified=0
- ORDER BY z.created_at ASC""",
+ LEFT JOIN users m ON m.id = z.verified_by
+ ORDER BY z.verified ASC, z.created_at ASC
+ LIMIT 200""",
).fetchall()
return [dict(r) for r in rows]
@@ -716,8 +722,10 @@ async def verify_zuchter(zuchter_id: int, user=Depends(get_current_user)):
).fetchone()
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
+ from datetime import datetime
conn.execute(
- "UPDATE wiki_zuchter SET verified=1 WHERE id=?", (zuchter_id,)
+ "UPDATE wiki_zuchter SET verified=1, verified_by=?, verified_at=? WHERE id=?",
+ (user["id"], datetime.utcnow().isoformat(), zuchter_id)
)
result = conn.execute(
"SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,)
diff --git a/backend/scheduler.py b/backend/scheduler.py
index c99600e..4aeb89a 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -100,6 +100,22 @@ def start():
replace_existing=True,
misfire_grace_time=1800,
)
+ # Täglich 12:00 — Moderation-Overdue-Check
+ _scheduler.add_job(
+ _job_moderation_overdue,
+ CronTrigger(hour=12, minute=0),
+ id="moderation_overdue",
+ replace_existing=True,
+ misfire_grace_time=1800,
+ )
+ # 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht
+ _scheduler.add_job(
+ _job_quarterly_report,
+ CronTrigger(month="2,5,8,11", day=1, hour=7, minute=0),
+ id="quarterly_report",
+ replace_existing=True,
+ misfire_grace_time=7200,
+ )
# Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen)
_scheduler.add_job(
_job_ki_health_report,
@@ -108,8 +124,40 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
+ # Täglich 06:30 — Wiederkehrende Ausgaben anlegen
+ _scheduler.add_job(
+ _job_recurring_expenses,
+ CronTrigger(hour=6, minute=30),
+ id="recurring_expenses",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
+ # 1. des Monats 00:05 — Hund des Monats Sieger festlegen
+ _scheduler.add_job(
+ _job_hdm_winner,
+ CronTrigger(day=1, hour=0, minute=5),
+ id="hdm_winner",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
+ # Täglich 19:00 Uhr — Streak-Erinnerung
+ _scheduler.add_job(
+ _job_streak_reminder,
+ CronTrigger(hour=19, minute=0),
+ id="streak_reminder",
+ replace_existing=True,
+ misfire_grace_time=3600,
+ )
+ # Täglich 08:00 Uhr — Tierfutter-Rückrufe prüfen (RASFF)
+ _scheduler.add_job(
+ _job_recall_check,
+ CronTrigger(hour=8, minute=0),
+ id="recall_check",
+ 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. 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():
@@ -642,6 +690,115 @@ async def _job_ki_health_report():
# ------------------------------------------------------------------
+async def _job_moderation_overdue():
+ """Sendet Alarm-Mail wenn Moderations-Einträge seit >24h offen sind."""
+ import os
+ from mailer import send_email
+
+ admin = os.getenv("ADMIN_EMAIL", "")
+ if not admin:
+ return
+
+ SLA_H = 24
+ threshold = f"datetime('now', '-{SLA_H} hours')"
+
+ overdue = {}
+ try:
+ with db() as conn:
+ n = conn.execute(f"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing') AND created_at < {threshold}").fetchone()[0]
+ if n: overdue["Bewerbungen"] = n
+ n = conn.execute(f"SELECT COUNT(*) FROM users WHERE breeder_status='pending' AND created_at < {threshold}").fetchone()[0]
+ if n: overdue["Züchter-Anträge"] = n
+ n = conn.execute(f"SELECT COUNT(*) FROM forum_reports WHERE resolved=0 AND created_at < {threshold}").fetchone()[0]
+ if n: overdue["Forum-Meldungen"] = n
+ n = conn.execute(f"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending' AND created_at < {threshold}").fetchone()[0]
+ if n: overdue["Foto-Einreichungen"] = n
+ n = conn.execute(f"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending' AND created_at < {threshold}").fetchone()[0]
+ if n: overdue["POI-Korrekturen"] = n
+ n = conn.execute(f"SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0 AND created_at < {threshold}").fetchone()[0]
+ if n: overdue["Züchter-Einreichungen (Wiki)"] = n
+ except Exception as e:
+ logger.error(f"Moderation-Overdue-Check: DB-Fehler: {e}")
+ return
+
+ if not overdue:
+ logger.info("Moderation-Overdue-Check: Alles im SLA.")
+ return
+
+ now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M")
+ rows_html = "".join(
+ f'{label} '
+ f'{count} '
+ for label, count in overdue.items()
+ )
+ html = f"""\
+
+
+
+
+
⚠️ Moderation überfällig
+
{now_str} · SLA: {SLA_H}h
+
+
+
Folgende Einträge warten seit mehr als {SLA_H} Stunden auf Bearbeitung:
+
+
+ Bereich
+ Anzahl
+
+ {rows_html}
+
+
+
+
+ Ban Yaro · banyaro.app
+
+
"""
+
+ plain = f"Ban Yaro — Moderation überfällig ({now_str})\n\nSeit >{SLA_H}h offen:\n" + \
+ "\n".join(f" • {l}: {c}" for l, c in overdue.items()) + \
+ "\n\nhttps://banyaro.app/app/admin"
+
+ try:
+ await send_email(admin, f"⚠️ Ban Yaro — Moderation überfällig ({', '.join(overdue)})", html, plain)
+ logger.info(f"Moderation-Overdue-Mail gesendet: {overdue}")
+ except Exception as e:
+ logger.error(f"Moderation-Overdue-Mail fehlgeschlagen: {e}")
+
+
+def _action_items_html(metrics: dict) -> str:
+ items = [
+ ("jobs_pending", "Bewerbungen offen"),
+ ("breeder_pending", "Züchter-Anträge"),
+ ("reports_open", "Forum-Meldungen"),
+ ("fotos_pending", "Foto-Einreichungen"),
+ ("poi_edits_pending", "POI-Korrekturen"),
+ ]
+ open_items = [(label, metrics.get(key, 0)) for key, label in items if metrics.get(key, 0) > 0]
+
+ if not open_items:
+ body = '✅ Alles erledigt — nichts offen '
+ else:
+ pills = "".join(
+ f''
+ f'{label} {count} '
+ for label, count in open_items
+ )
+ body = f'⚠️ {len(open_items)} Punkt{"e" if len(open_items)!=1 else ""} brauchen deine Aufmerksamkeit
{pills}'
+
+ link = ''
+ return f'' \
+ f'
Heute zu erledigen
' \
+ f'{body}{link}
'
+
+
# JOB: Status-Report per Mail (täglich 06:00 Uhr)
# ------------------------------------------------------------------
async def _job_status_report():
@@ -669,6 +826,7 @@ async def _job_status_report():
# Community
metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
+ metrics["users_today"] = conn.execute("SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')").fetchone()[0]
metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0]
metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0]
@@ -677,6 +835,28 @@ async def _job_status_report():
except Exception:
metrics["lost_active"] = 0
+ # Action Items
+ try:
+ metrics["jobs_pending"] = conn.execute("SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')").fetchone()[0]
+ except Exception:
+ metrics["jobs_pending"] = 0
+ try:
+ metrics["breeder_pending"] = conn.execute("SELECT COUNT(*) FROM users WHERE breeder_status='pending'").fetchone()[0]
+ except Exception:
+ metrics["breeder_pending"] = 0
+ try:
+ metrics["reports_open"] = conn.execute("SELECT COUNT(*) FROM forum_reports WHERE resolved=0").fetchone()[0]
+ except Exception:
+ metrics["reports_open"] = 0
+ try:
+ metrics["fotos_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'").fetchone()[0]
+ except Exception:
+ metrics["fotos_pending"] = 0
+ try:
+ metrics["poi_edits_pending"] = conn.execute("SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'").fetchone()[0]
+ except Exception:
+ metrics["poi_edits_pending"] = 0
+
# Wiki-Interesse
try:
metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0]
@@ -698,6 +878,9 @@ async def _job_status_report():
"seed_wikidata": "Rassen-Seed (Wikidata, monatlich)",
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
"ki_health_report": "KI-Gesundheitsberichte",
+ "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)",
}
job_rows_html = ""
job_rows_txt = ""
@@ -727,6 +910,9 @@ async def _job_status_report():
{now_str} Uhr
+
+ {_action_items_html(metrics)}
+
Scheduler-Jobs
@@ -740,14 +926,14 @@ async def _job_status_report():
Community
{"".join(f'
' for k,v in [
- ("Nutzer",metrics["users"]),
+ ("Nutzer gesamt",metrics["users"]),
+ ("Neue Nutzer heute",metrics["users_today"]),
("Hunde",metrics["dogs"]),
("Tagebuch-Einträge",metrics["diary_entries"]),
("Aktive Giftköder",metrics["poison_active"]),
("Vermisste Hunde",metrics["lost_active"]),
("'So einen hab ich'",metrics["interesse_hat"]),
("'Interessiert mich'",metrics["interesse_will"]),
- ("Züchter (pending)",metrics["zuchter_pending"]),
])}
@@ -761,19 +947,28 @@ async def _job_status_report():