diff --git a/Makefile b/Makefile index 692e7bb..2427674 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 reports + logs logs-f shell db dev clean-cache check-ssh # ---------------------------------------------------------- # SSH-Prüfung — Abhängigkeit aller DS-Befehle @@ -66,7 +66,6 @@ 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 "" # ---------------------------------------------------------- @@ -128,17 +127,6 @@ 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 @@ -247,31 +235,6 @@ 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 55c63fc..b2736f5 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, luna_trial_until FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified FROM users WHERE id=?", (user_id,) ).fetchone() @@ -131,10 +131,7 @@ def require_admin(user=Depends(get_current_user)): def require_social_media(user=Depends(get_current_user)): - """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): + """Dependency: Social-Media-Manager oder Admin.""" + if not (user.get("is_social_media") or user["rolle"] == "admin"): raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.") return user diff --git a/backend/content_filter.py b/backend/content_filter.py deleted file mode 100644 index e094253..0000000 --- a/backend/content_filter.py +++ /dev/null @@ -1,63 +0,0 @@ -"""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 eeb1add..5ea9f4a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -701,28 +701,7 @@ def _migrate(conn_factory): CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC); """) - # 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); - """) - + # Hunde-Filme: Bewertungen + Hund des Monats conn.executescript(""" CREATE TABLE IF NOT EXISTS movie_votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1072,19 +1051,6 @@ 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 ( @@ -1595,35 +1561,6 @@ 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: @@ -1644,299 +1581,3 @@ 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 344fe4f..e5cbdc0 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -106,67 +106,44 @@ 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"""\ - - - - - - - - -""" - - 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" - body = f""" -Hallo {name},
-+ html = f"""\ + + +
+ +Hallo {name},
+bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
-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. -
""" + +Dieser Hundepass-Link ist ungültig.
', - status_code=404 - ) - if share["valid_until"] < _date.today().isoformat(): - return HTMLResponse( - '' - '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""" -Digitaler Hundepass — {dog['name']}
-| Krankheit | Datum | Nächste | Tierarzt | Charge | -
|---|
| Medikament | Dosierung | Von | Bis | Notiz | -
|---|
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" + 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" + ) try: - _send_smtp(email, subject, plain, "support", html=html) + _send_smtp(email, subject, body, "support") except Exception: pass # Nicht blockieren wenn SMTP fehlschlägt @@ -146,32 +139,24 @@ 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 {"pending_verification": True} + return {"token": token, "name": name, "email_verified": 0} @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, email_verified FROM users WHERE email=?", + "SELECT id, pw_hash, name, rolle, is_premium 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) @@ -264,24 +249,23 @@ 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(data: ResendVerificationRequest, request: Request): - rl_check(request, max_requests=3, window_seconds=3600, key=f"resend_verify_{data.email}") +async def resend_verification(request: Request, user=Depends(get_current_user)): + rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify") with db() as conn: row = conn.execute( - "SELECT id, name, email_verified FROM users WHERE email=?", (data.email,) + "SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],) ).fetchone() - if not row or row["email_verified"]: - return {"ok": True} + if not row: + raise HTTPException(404) + if row["email_verified"]: + return {"ok": True, "already_verified": True} token = secrets.token_urlsafe(32) with db() as conn: conn.execute( - "UPDATE users SET verification_token=? WHERE id=?", (token, row["id"]) + "UPDATE users SET verification_token=? WHERE id=?", (token, user["id"]) ) - _send_verification_email(data.email, row["name"], token) + _send_verification_email(row["email"], row["name"], token) return {"ok": True} @@ -308,26 +292,19 @@ 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, plain, "support", html=html) + _send_smtp(data.email, subject, body, "support") except Exception: pass return {"ok": True} diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index 355a575..bb5efc8 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, email_html +from mailer import send_email router = APIRouter() logger = logging.getLogger(__name__) @@ -131,21 +131,21 @@ async def breeder_apply( ) # Admin benachrichtigen - 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} |
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}", - email_html(admin_body, cta_url=f"{APP_URL}/#admin", cta_label="Im Admin-Bereich prüfen"), + admin_html, f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}", ) except Exception as e: @@ -233,17 +233,18 @@ async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)): ) # Bestätigungs-Mail - 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.
-
Hallo {user['name']},
+dein Züchter-Profil wurde erfolgreich verifiziert.
+Ab sofort hast du Zugang zu allen Züchter-Features.
+ + """ try: await send_email( user["email"], - "Willkommen als Züchter bei Ban Yaro!", - email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"), + "Willkommen als Züchter bei Banyaro!", + html, f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.", ) except Exception as e: @@ -273,25 +274,19 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req ) # Ablehnungs-Mail - import html as _h - reject_body = f""" -Hallo {user['name']},
-- leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen. -
-- Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter - {ADMIN_EMAIL}. -
""" + html = f""" +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}
+ """ try: await send_email( user["email"], - "Dein Züchter-Antrag bei Ban Yaro", - email_html(reject_body), + "Dein Züchter-Antrag bei Banyaro", + html, 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 a44faa0..74f1c95 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -75,21 +75,6 @@ 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 @@ -315,13 +300,11 @@ 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: - updated = conn.execute( + 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=? AND user_id=?", (dog_id, user["id"]) + "SELECT * FROM dogs WHERE id=?", (dog_id,) ).fetchone() return dict(dog) @@ -415,8 +398,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=? AND user_id=?", - (dog_id, user["id"]) + "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=?", + (dog_id,) ) diff --git a/backend/routes/expenses.py b/backend/routes/expenses.py deleted file mode 100644 index 9c93475..0000000 --- a/backend/routes/expenses.py +++ /dev/null @@ -1,396 +0,0 @@ -"""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 2834ab0..0cfe1df 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -7,8 +7,6 @@ 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 @@ -166,50 +164,6 @@ 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"): @@ -223,7 +177,6 @@ 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) @@ -241,7 +194,6 @@ 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 @@ -370,8 +322,6 @@ 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 (?, ?, ?, ?)", @@ -397,7 +347,6 @@ 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']: @@ -641,7 +590,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(user=Depends(get_current_user)): +async def members_map(): 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 deleted file mode 100644 index 0c2d4a7..0000000 --- a/backend/routes/health_docs.py +++ /dev/null @@ -1,138 +0,0 @@ -"""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 deleted file mode 100644 index 59c73c2..0000000 --- a/backend/routes/jobs.py +++ /dev/null @@ -1,327 +0,0 @@ -"""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.
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} |
| {_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!
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'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} |
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}
+ + """ try: await send_email( admin_email, f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}", - email_html(welfare_body, cta_url=f"{app_url}/#admin", cta_label="Im Admin-Bereich prüfen"), + html, 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 fa74871..95b33a9 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -1,5 +1,4 @@ """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 @@ -70,19 +69,17 @@ 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, r.resolved_at, - u.name AS melder_name, - m.name AS resolved_by_name, + SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at, + u.name AS melder_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 - LEFT JOIN users m ON m.id=r.resolved_by - ORDER BY r.resolved ASC, r.created_at DESC - LIMIT 200 + WHERE r.resolved=0 + ORDER BY r.created_at DESC + LIMIT 100 """).fetchall() return [dict(r) for r in rows] @@ -100,12 +97,8 @@ 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=?, resolved_by=?, resolved_at=? - WHERE id=?""", - (new_state, - user["id"] if new_state else None, - datetime.utcnow().isoformat() if new_state else None, - rid) + "UPDATE forum_reports SET resolved=? WHERE id=?", + (new_state, rid) ) return {"ok": True} @@ -196,19 +189,17 @@ 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.status, s.created_at, - s.reviewed_at, s.reject_reason, + SELECT s.id, s.foto_url, s.created_at, COALESCE(s.rights_confirmed, 0) AS rights_confirmed, - u.name AS user_name, - m.name AS reviewed_by_name, - r.name AS rasse_name, r.slug AS rasse_slug, + u.name AS user_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 - ORDER BY s.status ASC, s.created_at ASC - LIMIT 200 + WHERE s.status = 'pending' + ORDER BY s.created_at ASC + LIMIT 50 """).fetchall() return [dict(r) for r in rows] @@ -237,13 +228,11 @@ 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, - m.name AS mod_name + u.name AS einreicher_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 200 + LIMIT 100 """).fetchall() return [dict(r) for r in rows] @@ -268,9 +257,6 @@ 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 da6c682..5ef83da 100644 --- a/backend/routes/movies.py +++ b/backend/routes/movies.py @@ -1,380 +1,140 @@ """BAN YARO — Hunde-Filme Routes""" -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException 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, require_admin +from auth import get_current_user, get_current_user_optional router = APIRouter() # ------------------------------------------------------------------ -# Seed-Daten — werden beim ersten Start in die DB geschrieben +# Hardcoded Film-Daten # ------------------------------------------------------------------ -_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}, +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_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": "⭐"}, +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": "⛪"}, ] -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 +# GET /api/movies/filme — Film-Liste mit optionaler User-Bewertung # ------------------------------------------------------------------ -_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( - 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) +async def get_filme(user=Depends(get_current_user_optional)): + user_ratings = {} + community_avgs = {} with db() as conn: - 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() + 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} result = [] - 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) + 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) + return result # ------------------------------------------------------------------ -# POST /api/movies/filme/{film_id}/vote +# POST /api/movies/filme/{film_id}/vote — Bewertung abgeben (Upsert) # ------------------------------------------------------------------ @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: - 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)) + 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, } # ------------------------------------------------------------------ -# 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 +# GET /api/movies/hund-des-monats — Top-Votes des aktuellen 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( @@ -383,55 +143,43 @@ 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} - - -@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] + + return { + "monat": monat, + "top": [dict(r) for r in rows], + "user_vote": user_vote, + } +# ------------------------------------------------------------------ +# 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: - dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone() + # 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() if not dog: raise HTTPException(404, "Hund nicht gefunden.") - if dog["user_id"] == user["id"]: - raise HTTPException(403, "Du kannst nicht für deinen eigenen Hund abstimmen.") - if not dog["is_public"]: + if dog["user_id"] != user["id"] and 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)) + + 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 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 4fbd03c..6ec066c 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, formatdate +from email.utils import formataddr from datetime import datetime from typing import List, Optional @@ -84,36 +84,22 @@ 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, html: str = None) -> MIMEMultipart: +def _build_message(to: str, subject: str, body: str, account: str) -> 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 -_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): +def _send_smtp(to: str, subject: str, body: str, account: str = "partner"): 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 + _LEGAL_FOOTER, account, html=html) + msg = _build_message(to, subject, body, account) msg_bytes = msg.as_bytes() ctx = ssl.create_default_context() with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: @@ -203,16 +189,6 @@ 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", "{escaped}
') - return "".join(parts) - - @router.post("/send") def send_mail(data: SendRequest, user=Depends(require_admin)): if not data.to: @@ -220,19 +196,13 @@ 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, html=html) + _send_smtp(addr, data.subject, data.body, data.from_account) sent.append(addr) with db() as conn: conn.execute( @@ -254,9 +224,7 @@ 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.""" - from mailer import email_html - html = email_html(_plain_to_html_body(body)) - _send_smtp(to, subject, body, "support", html=html) + _send_smtp(to, subject, body, "support") # ------------------------------------------------------------------ @@ -267,7 +235,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.body, ol.sent_at, + """SELECT ol.id, ol.recipient, ol.subject, 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 deleted file mode 100644 index 884e8d3..0000000 --- a/backend/routes/passport.py +++ /dev/null @@ -1,377 +0,0 @@ -"""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 deleted file mode 100644 index 01d57ae..0000000 --- a/backend/routes/playdate.py +++ /dev/null @@ -1,364 +0,0 @@ -"""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 deleted file mode 100644 index d0182a3..0000000 --- a/backend/routes/recalls.py +++ /dev/null @@ -1,138 +0,0 @@ -"""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 deleted file mode 100644 index c387a68..0000000 --- a/backend/routes/streak.py +++ /dev/null @@ -1,114 +0,0 @@ -"""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 48287f9..55107ec 100644 --- a/backend/routes/tieraerzte.py +++ b/backend/routes/tieraerzte.py @@ -63,68 +63,15 @@ 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. Enthält is_favorite.""" + """Alle Tierärzte des Users — aktive zuerst, dann inaktive.""" with db() as conn: rows = conn.execute( "SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name", (user["id"],) ).fetchall() - 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 + return [dict(r) for r in rows] @router.get("/osm-nearby") diff --git a/backend/routes/weather.py b/backend/routes/weather.py index fced719..319cfd2 100644 --- a/backend/routes/weather.py +++ b/backend/routes/weather.py @@ -3,9 +3,8 @@ BAN YARO — Wetter-API GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort """ -from fastapi import APIRouter, Query, HTTPException, Depends +from fastapi import APIRouter, Query, HTTPException import weather as weather_module -from auth import get_current_user router = APIRouter() @@ -19,15 +18,3 @@ 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 45f5bfb..83093d7 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -317,24 +317,19 @@ async def submit_foto( if not rights_confirmed: raise HTTPException(400, "Bildrechte-Bestätigung fehlt.") - _IMAGE_MAGIC = [ - b"\xff\xd8\xff", # JPEG - b"\x89PNG\r\n\x1a\n", # PNG - b"RIFF", # WebP (RIFF....WEBP) - b"GIF87a", b"GIF89a", # GIF - ] + # Dateiformat prüfen + ct = file.content_type or "" + if not ct.startswith("image/"): + raise HTTPException(400, "Nur Bilddateien erlaubt.") os.makedirs(SUBMIT_DIR, exist_ok=True) - ts = int(time.time()) + ts = int(time.time()) + filename = f"{slug}_{user['id']}_{ts}.jpg" + path = os.path.join(SUBMIT_DIR, filename) + 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) @@ -699,12 +694,11 @@ 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, m.name AS verified_by_name + """SELECT z.*, u.name AS user_name FROM wiki_zuchter z LEFT JOIN users u ON u.id = z.user_id - LEFT JOIN users m ON m.id = z.verified_by - ORDER BY z.verified ASC, z.created_at ASC - LIMIT 200""", + WHERE z.verified=0 + ORDER BY z.created_at ASC""", ).fetchall() return [dict(r) for r in rows] @@ -722,10 +716,8 @@ 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, verified_by=?, verified_at=? WHERE id=?", - (user["id"], datetime.utcnow().isoformat(), zuchter_id) + "UPDATE wiki_zuchter SET verified=1 WHERE id=?", (zuchter_id,) ) result = conn.execute( "SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,) diff --git a/backend/scheduler.py b/backend/scheduler.py index 4aeb89a..c99600e 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -100,22 +100,6 @@ 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, @@ -124,40 +108,8 @@ 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, 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).") + 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).") def stop(): @@ -690,115 +642,6 @@ 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'