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"""\ - - - - - - -
- -
-
🐾 Ban Yaro
-
- -
- {body_html}{cta_block} -
- -
- {footer} -
- -
- -""" - - async def send_verify_email(to: str, name: str, token: str): url = f"{APP_URL}/api/auth/verify/{token}" subject = "Ban Yaro — E-Mail-Adresse bestätigen" - body = f""" -

Hallo {name},

-

+ html = f"""\ + + + + +

+

Ban Yaro 🐾

+

Hallo {name},

+

bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.

-

Der Link ist 48 Stunden gültig.

-

+

+ + E-Mail bestätigen + +

+

+ Der Link ist 48 Stunden gültig. +

+

Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach. -

""" +

+
+ +""" - html = email_html(body, cta_url=url, cta_label="E-Mail bestätigen") - plain = f"Ban Yaro — E-Mail bestätigen\n\nHallo {name},\n\nbitte bestätige:\n{url}\n\nLink ist 48h gültig.\n" + plain = ( + f"Ban Yaro — E-Mail-Adresse bestätigen\n\n" + f"Hallo {name},\n\n" + f"bitte bestätige deine E-Mail-Adresse:\n{url}\n\n" + f"Der Link ist 48 Stunden gültig.\n" + ) await send_email(to, subject, html, plain) diff --git a/backend/main.py b/backend/main.py index 229a856..e8720c9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,7 +11,6 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from fastapi.middleware.gzip import GZipMiddleware -from brotli_asgi import BrotliMiddleware from contextlib import asynccontextmanager from database import init_db @@ -47,8 +46,6 @@ logger = logging.getLogger(__name__) async def lifespan(app: FastAPI): logger.info("Ban Yaro startet...") init_db() - from routes.movies import seed_movies - seed_movies() logger.info(f"KI-Modus: {ki.KI_MODE}") sched.start() yield @@ -70,20 +67,11 @@ app = FastAPI( class SecurityHeadersMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): response = await call_next(request) - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" - response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" - response.headers["Content-Security-Policy"] = ( - "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " - "style-src 'self' 'unsafe-inline'; " - "img-src 'self' data: blob: https:; " - "connect-src 'self' https:; " - "frame-ancestors 'none'; " - "base-uri 'self'; " - "form-action 'self';" - ) + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" + response.headers["X-XSS-Protection"] = "1; mode=block" return response app.add_middleware(SecurityHeadersMiddleware) @@ -135,7 +123,6 @@ class MediaCacheMiddleware(BaseHTTPMiddleware): return response app.add_middleware(MediaCacheMiddleware) -app.add_middleware(BrotliMiddleware, minimum_size=1000, quality=4) app.add_middleware(GZipMiddleware, minimum_size=1000) @@ -190,14 +177,6 @@ from routes.breeder_export import router as breeder_export_router from routes.zucht_ki import router as zucht_ki_router from routes.partner import router as partner_router from routes.outreach import router as outreach_router -from routes.jobs import router as jobs_router -from routes.streak import router as streak_router -from routes.expenses import router as expenses_router -from routes.recalls import router as recalls_router -from routes.adoption import router as adoption_router -from routes.health_docs import router as health_docs_router -from routes.passport import router as passport_router -from routes.playdate import router as playdate_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -231,7 +210,6 @@ app.include_router(breeder_export_router, prefix="/api", tags=["Export"]) app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"]) app.include_router(partner_router, prefix="/api", tags=["Partner"]) app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"]) -app.include_router(jobs_router, prefix="/api/jobs", tags=["Jobs"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(import_router, prefix="/api/import", tags=["Import"]) @@ -249,13 +227,6 @@ app.include_router(training_router, prefix="/api/training", tags= app.include_router(praise_router, prefix="/api/praise", tags=["Praise"]) app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"]) app.include_router(notes_router, prefix="/api/notes", tags=["Notes"]) -app.include_router(streak_router, prefix="/api", tags=["Streak"]) -app.include_router(expenses_router, prefix="/api/expenses", tags=["Ausgaben"]) -app.include_router(recalls_router, prefix="/api/recalls", tags=["Rückrufe"]) -app.include_router(adoption_router, prefix="/api/adoption", tags=["Adoption"]) -app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"]) -app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"]) -app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"]) # ------------------------------------------------------------------ @@ -1445,13 +1416,6 @@ async def knigge_page(): # ------------------------------------------------------------------ -# /presse — Presseseite -# ------------------------------------------------------------------ -@app.get("/presse") -async def presse(): - return FileResponse(f"{STATIC_DIR}/presse.html", headers={"Cache-Control": "max-age=3600"}) - - # /partner — Influencer-Landingpage # ------------------------------------------------------------------ @app.get("/partner") @@ -1653,189 +1617,6 @@ async def partner_landing(): return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"}) -# ------------------------------------------------------------------ -# Honeypot-Fallen für Scanner und Bots -# Jeder Aufruf → 24h IP-Sperre -# ------------------------------------------------------------------ -from ratelimit import block_ip as _block_ip - -_HONEYPOT_PATHS = [ - "/api/admin/users", - "/api/v1/users", - "/api/users", - "/api/.env", - "/api/config", - "/api/setup", - "/api/install", - "/api/phpinfo", - "/api/debug", - "/api/actuator", - "/api/actuator/health", - "/api/swagger", - "/api/graphql", -] - -async def _honeypot_handler(request: Request): - import logging as _log - _log.getLogger("banyaro.security").warning( - "Honeypot getroffen: %s %s — IP %s", - request.method, request.url.path, - request.client.host if request.client else "?" - ) - _block_ip(request, hours=24) - from fastapi.responses import JSONResponse - return JSONResponse(status_code=404, content={"detail": "Not Found"}) - -for _hp in _HONEYPOT_PATHS: - app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False) - - -# ------------------------------------------------------------------ -# Digitaler Hundepass — öffentlicher Share-Link (kein Login nötig) -# ------------------------------------------------------------------ -@app.get("/pass/{token}") -async def passport_share_page(token: str): - from fastapi.responses import HTMLResponse - from database import db as _db - from datetime import date as _date - - with _db() as conn: - share = conn.execute( - "SELECT * FROM passport_shares WHERE token=?", (token,) - ).fetchone() - if not share: - return HTMLResponse( - '' - '

Link nicht gefunden

Dieser Hundepass-Link ist ungültig.

', - status_code=404 - ) - if share["valid_until"] < _date.today().isoformat(): - return HTMLResponse( - '' - '

Link abgelaufen

Dieser Hundepass-Link ist nicht mehr gültig.

', - status_code=410 - ) - dog_id = share["dog_id"] - dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone() - meta = conn.execute("SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)).fetchone() - vaccs = conn.execute( - "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,) - ).fetchall() - meds = conn.execute( - "SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,) - ).fetchall() - def _fmt(d): - if not d: - return "–" - try: - from datetime import datetime as _dt - return _dt.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y") - except Exception: - return d - - dog = dict(dog) - meta = dict(meta) if meta else {} - vaccs = [dict(v) for v in vaccs] - meds = [dict(m) for m in meds] - - _g_map = {"m": "Rüde", "w": "Hündin"} - - vacc_rows = "".join(f""" - - {v['krankheit'] or ''} - {_fmt(v['datum'])} - {_fmt(v['naechste'])} - {v['tierarzt'] or '–'} - {v['charge_nr'] or '–'} - """ for v in vaccs) or "Keine Einträge" - - med_rows = "".join(f""" - - {m['name'] or ''} - {m['dosierung'] or '–'} - {_fmt(m['von'])} - {_fmt(m['bis']) if m['bis'] else 'dauerhaft'} - {m['notiz'] or '–'} - """ for m in meds) or "Keine Einträge" - - html = f""" - - - - - Hundepass — {dog['name']} - - - -
-

Ban Yaro

-

Digitaler Hundepass — {dog['name']}

-
-
-
-

Hundeangaben

-
-
{dog['name']}
-
{dog.get('rasse') or '–'}
-
{_fmt(dog.get('geburtstag'))}
-
{_g_map.get(dog.get('geschlecht',''), '–')}
-
{dog.get('chip_nr') or '–'}
-
{meta.get('blutgruppe') or '–'}
-
- {('
' - f'
{meta["allergien"]}
') if meta.get("allergien") else ''} - {('
' - f'
{meta["besonderheiten"]}
') if meta.get("besonderheiten") else ''} -
- -
-

Impfungen

- - - - - {vacc_rows} -
KrankheitDatumNächsteTierarztCharge
-
- -
-

Medikamente

- - - - - {med_rows} -
MedikamentDosierungVonBisNotiz
-
-
- - -""" - return HTMLResponse(html) - - # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/ratelimit.py b/backend/ratelimit.py index 7cb3a2f..661eb26 100644 --- a/backend/ratelimit.py +++ b/backend/ratelimit.py @@ -1,9 +1,9 @@ """ -BAN YARO — Rate Limiter + IP-Blocklist + Account-Lockout + Duplikat-Erkennung +BAN YARO — Rate Limiter + IP-Blocklist Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container). +Blocklist für Honeypot-Treffer. """ -import hashlib import threading from collections import defaultdict, deque from datetime import datetime, timedelta @@ -11,23 +11,18 @@ from datetime import datetime, timedelta from fastapi import HTTPException, Request _buckets: dict[str, deque] = defaultdict(deque) -_blocklist: dict[str, datetime] = {} # ip → gesperrt bis -_login_failures: dict[str, list] = defaultdict(list) # email → [datetime, ...] -_post_hashes: dict[int, dict] = defaultdict(dict) # user_id → {hash: datetime} +_blocklist: dict[str, datetime] = {} # ip → gesperrt bis _lock = threading.Lock() -_LOCKOUT_WINDOW = 15 # Minuten -_LOCKOUT_ATTEMPTS = 5 # Fehlversuche bis Sperre -_DUPLICATE_WINDOW = 300 # Sekunden (5 Minuten) - -# ------------------------------------------------------------------ -# IP-basiertes Rate Limiting -# ------------------------------------------------------------------ def check(request: Request, *, max_requests: int, window_seconds: int, key: str = ""): - """Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten.""" + """ + Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten. + key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login'). + """ ip = (request.client.host if request.client else "unknown") + # Blocklist prüfen with _lock: blocked_until = _blocklist.get(ip) if blocked_until and datetime.utcnow() < blocked_until: @@ -70,63 +65,3 @@ def is_blocked(request: Request) -> bool: elif until: del _blocklist[ip] return False - - -# ------------------------------------------------------------------ -# Account-Lockout (per E-Mail) -# ------------------------------------------------------------------ -def record_login_failure(email: str) -> int: - """Failure aufzeichnen. Gibt Anzahl Fehlversuche im Fenster zurück.""" - email = email.lower() - now = datetime.utcnow() - cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW) - with _lock: - recent = [t for t in _login_failures[email] if t > cutoff] - recent.append(now) - _login_failures[email] = recent - return len(recent) - - -def is_account_locked(email: str) -> bool: - """True wenn ≥5 Fehlversuche in den letzten 15 Minuten.""" - email = email.lower() - now = datetime.utcnow() - cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW) - with _lock: - recent = [t for t in _login_failures.get(email, []) if t > cutoff] - return len(recent) >= _LOCKOUT_ATTEMPTS - - -def clear_login_failures(email: str): - """Bei erfolgreichem Login zurücksetzen.""" - with _lock: - _login_failures.pop(email.lower(), None) - - -# ------------------------------------------------------------------ -# Duplikat-Post-Erkennung (per User, in-memory) -# ------------------------------------------------------------------ -def content_hash(text: str) -> str: - normalized = " ".join(text.lower().split()) - return hashlib.sha256(normalized.encode()).hexdigest()[:20] - - -def is_duplicate_post(user_id: int, text: str) -> bool: - """True wenn derselbe User denselben Text in den letzten 5 Minuten gepostet hat.""" - h = content_hash(text) - now = datetime.utcnow() - cutoff = now - timedelta(seconds=_DUPLICATE_WINDOW) - with _lock: - hashes = _post_hashes[user_id] - # Alte Einträge bereinigen - expired = [k for k, ts in hashes.items() if ts < cutoff] - for k in expired: - del hashes[k] - return h in hashes - - -def record_post(user_id: int, text: str): - """Post-Hash speichern nach erfolgreichem Erstellen.""" - h = content_hash(text) - with _lock: - _post_hashes[user_id][h] = datetime.utcnow() diff --git a/backend/requirements.txt b/backend/requirements.txt index 414ec32..7b268fa 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,6 +13,3 @@ pywebpush==2.0.0 apscheduler==3.10.4 odfpy==1.4.1 polyline==2.0.2 -fpdf2==2.8.3 -python-dateutil>=2.9 -brotli-asgi==1.4.0 diff --git a/backend/routes/admin.py b/backend/routes/admin.py index c2ffebb..09a4127 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -97,40 +97,6 @@ class ThreadAdminPatch(BaseModel): is_deleted: Optional[int] = None -# ------------------------------------------------------------------ -# GET /api/admin/action-items -# ------------------------------------------------------------------ -@router.get("/action-items") -async def action_items(user=Depends(require_mod)): - with db() as conn: - jobs = conn.execute( - "SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')" - ).fetchone()[0] - breeders = conn.execute( - "SELECT COUNT(*) FROM users WHERE breeder_status='pending'" - ).fetchone()[0] - reports = conn.execute( - "SELECT COUNT(*) FROM forum_reports WHERE resolved=0" - ).fetchone()[0] - fotos = conn.execute( - "SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'" - ).fetchone()[0] - poi_edits = conn.execute( - "SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'" - ).fetchone()[0] - users_today = conn.execute( - "SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')" - ).fetchone()[0] - return { - "jobs_pending": jobs, - "breeder_pending": breeders, - "reports_open": reports, - "fotos_pending": fotos, - "poi_edits_pending": poi_edits, - "users_today": users_today, - } - - # ------------------------------------------------------------------ # GET /api/admin/stats # ------------------------------------------------------------------ @@ -356,15 +322,11 @@ async def list_users( # ------------------------------------------------------------------ @router.patch("/users/{uid}") async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)): - # Rollenwechsel + Privileg-Flags nur für Admins + # Rollenwechsel nur für Admins if data.rolle is not None and user["rolle"] != "admin": raise HTTPException(403, "Rollenwechsel nur für Admins.") if data.rolle and data.rolle not in ("user", "moderator", "admin"): raise HTTPException(400, "Ungültige Rolle.") - if data.is_moderator is not None and user["rolle"] != "admin": - raise HTTPException(403, "is_moderator darf nur von Admins geändert werden.") - if data.is_social_media is not None and user["rolle"] != "admin": - raise HTTPException(403, "is_social_media darf nur von Admins geändert werden.") with db() as conn: target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone() diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py deleted file mode 100644 index bde0986..0000000 --- a/backend/routes/adoption.py +++ /dev/null @@ -1,547 +0,0 @@ -""" -BAN YARO — Adoption (Tierheim-Hunde in der Nähe) - -Strategie: - 1. PetFinder API (falls API-Key gesetzt) → hat kaum deutsche Tierheime, nur als Bonus - 2. Statische Daten: Liste großer deutscher Tierheime mit Koordinaten - 3. Fallback: Weiterleitung zu tierheimhelden.de - -Caching: adoption_cache Tabelle, 24h TTL. -""" - -import os -import math -import logging -import asyncio -import uuid -import httpx -from datetime import datetime, timedelta -from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException -from pydantic import BaseModel -from typing import Optional -from database import db -from auth import get_current_user -from routes.push import send_push_to_user - -MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") - -logger = logging.getLogger(__name__) -router = APIRouter() - -PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "") -PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "") - -# ------------------------------------------------------------------ -# Haversine — Distanz in km -# ------------------------------------------------------------------ -def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - R = 6371.0 - p1 = math.radians(lat1) - p2 = math.radians(lat2) - dp = math.radians(lat2 - lat1) - dl = math.radians(lon2 - lon1) - a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 - return 2 * R * math.asin(math.sqrt(a)) - - -# ------------------------------------------------------------------ -# Statische Tierheim-Daten (große deutsche Tierheime) -# ------------------------------------------------------------------ -GERMAN_SHELTERS = [ - # (id, name, plz, stadt, lat, lon, url) - ("de_berlin_tierschutz", "Tierheim Berlin", "12459", "Berlin", 52.4862, 13.5301, "https://www.tierheim-berlin.de/tiere/hunde/"), - ("de_hamburg_tierschutz", "Tierheim Hamburg (Süderstraße)", "20537", "Hamburg", 53.5539, 10.0416, "https://www.hamburger-tierschutzverein.de/hunde/"), - ("de_muenchen_tierschutz", "Tierheim München", "81379", "München", 48.0982, 11.5248, "https://www.tierheim-muenchen.de/hunde/"), - ("de_koeln_tierschutz", "Tierheim Köln", "50769", "Köln", 51.0168, 6.9369, "https://www.tierschutzverein-koeln.de/tiere/hunde/"), - ("de_frankfurt_tierschutz", "Tierheim Frankfurt (Fechenheim)", "60386", "Frankfurt", 50.1246, 8.7597, "https://www.tierheim-frankfurt.de/hunde/"), - ("de_stuttgart_tierschutz", "Tierschutzverein Stuttgart", "70193", "Stuttgart", 48.7790, 9.1634, "https://www.tierschutzverein-stuttgart.de/vermittlung/hunde/"), - ("de_duesseldorf_tierheim", "Tierheim Düsseldorf", "40599", "Düsseldorf", 51.1948, 6.8488, "https://www.tierheim-duesseldorf.de/tiere/hunde/"), - ("de_dortmund_tierheim", "Tierheim Dortmund", "44339", "Dortmund", 51.5481, 7.4584, "https://www.tierschutzverein-dortmund.de/hunde/"), - ("de_essen_tierheim", "Tierheim Essen", "45276", "Essen", 51.4341, 7.0985, "https://www.tierheim-essen.de/tiere/hunde/"), - ("de_leipzig_tierheim", "Tierheim Leipzig", "04109", "Leipzig", 51.3396, 12.3713, "https://www.tierschutzverein-leipzig.de/hunde/"), - ("de_dresden_tierheim", "Tierheim Dresden", "01127", "Dresden", 51.0789, 13.7319, "https://www.tierschutzverein-dresden-heidenau.de/tiere/hunde/"), - ("de_hannover_tierheim", "Tierheim Hannover", "30855", "Hannover", 52.3484, 9.7411, "https://www.tierschutzverein-hannover.de/hunde/"), - ("de_nuernberg_tierheim", "Tierschutzverein Nürnberg", "90461", "Nürnberg", 49.4182, 11.0830, "https://www.tierschutzverein-nuernberg.de/tiere/hunde/"), - ("de_bremen_tierheim", "Tierheim Bremen", "28307", "Bremen", 53.0440, 8.9128, "https://www.tierheim-bremen.de/hunde/"), - ("de_bochum_tierheim", "Tierheim Bochum", "44793", "Bochum", 51.4753, 7.2128, "https://www.tierschutzverein-bochum.de/hunde/"), - ("de_wuppertal_tierheim", "Tierheim Wuppertal", "42283", "Wuppertal", 51.2571, 7.1705, "https://www.tierschutz-wuppertal.de/hunde/"), - ("de_bielefeld_tierheim", "Tierheim Bielefeld", "33649", "Bielefeld", 51.9951, 8.5327, "https://www.tierschutzverein-bielefeld.de/hunde/"), - ("de_mannheim_tierheim", "Tierheim Mannheim", "68309", "Mannheim", 49.5079, 8.5033, "https://www.tierschutzverein-mannheim.de/hunde/"), - ("de_karlsruhe_tierheim", "Tierheim Karlsruhe", "76229", "Karlsruhe", 48.9960, 8.4290, "https://www.tierschutzverein-karlsruhe.de/hunde/"), - ("de_augsburg_tierheim", "Tierheim Augsburg", "86159", "Augsburg", 48.3668, 10.8978, "https://www.tierschutz-augsburg.de/tiere/hunde/"), - ("de_freiburg_tierheim", "Tierheim Freiburg", "79115", "Freiburg", 47.9855, 7.8352, "https://www.tierschutz-freiburg.de/tiere/hunde/"), - ("de_kiel_tierheim", "Tierheim Kiel", "24113", "Kiel", 54.3203, 10.1228, "https://www.tierschutzverein-kiel.de/hunde/"), - ("de_magdeburg_tierheim", "Tierheim Magdeburg", "39118", "Magdeburg", 52.0814, 11.5939, "https://www.tierschutz-magdeburg.de/hunde/"), - ("de_erfurt_tierheim", "Tierheim Erfurt", "99099", "Erfurt", 50.9985, 11.0424, "https://www.tierschutzverein-erfurt.de/hunde/"), - ("de_rostock_tierheim", "Tierheim Rostock", "18059", "Rostock", 54.0831, 12.0965, "https://www.tierschutzverein-rostock.de/hunde/"), -] - - -# ------------------------------------------------------------------ -# PetFinder OAuth2 Token -# ------------------------------------------------------------------ -_pf_token = None -_pf_token_exp = 0.0 - -async def _get_pf_token() -> str | None: - global _pf_token, _pf_token_exp - if not (PETFINDER_KEY and PETFINDER_SECRET): - return None - now = asyncio.get_event_loop().time() - if _pf_token and now < _pf_token_exp - 60: - return _pf_token - try: - async with httpx.AsyncClient(timeout=8) as client: - r = await client.post( - "https://api.petfinder.com/v2/oauth2/token", - data={"grant_type": "client_credentials", - "client_id": PETFINDER_KEY, - "client_secret": PETFINDER_SECRET}, - ) - if r.status_code == 200: - data = r.json() - _pf_token = data.get("access_token") - _pf_token_exp = now + data.get("expires_in", 3600) - return _pf_token - except Exception as e: - logger.warning(f"PetFinder OAuth: {e}") - return None - - -# ------------------------------------------------------------------ -# PetFinder: Hunde in der Nähe holen -# ------------------------------------------------------------------ -async def _fetch_petfinder(lat: float, lon: float, radius: int) -> list[dict]: - token = await _get_pf_token() - if not token: - return [] - try: - async with httpx.AsyncClient(timeout=12) as client: - r = await client.get( - "https://api.petfinder.com/v2/animals", - headers={"Authorization": f"Bearer {token}"}, - params={ - "type": "dog", - "location": f"{lat},{lon}", - "distance": radius, - "limit": 20, - "sort": "distance", - "status": "adoptable", - }, - ) - if r.status_code != 200: - logger.warning(f"PetFinder API: HTTP {r.status_code}") - return [] - animals = r.json().get("animals", []) - result = [] - for a in animals: - org = a.get("organization_id", "") - loc = a.get("contact", {}).get("address", {}) - photos = a.get("photos", []) - foto = photos[0].get("medium") if photos else None - age_map = {"Baby": 0.25, "Young": 1.0, "Adult": 4.0, "Senior": 9.0} - result.append({ - "external_id": f"pf_{a['id']}", - "name": a.get("name", "Unbekannt"), - "rasse": ", ".join( - filter(None, [ - a.get("breeds", {}).get("primary"), - a.get("breeds", {}).get("secondary"), - ]) - ) or None, - "alter_jahre": age_map.get(a.get("age"), None), - "geschlecht": {"Male": "männlich", "Female": "weiblich"}.get(a.get("gender"), None), - "foto_url": foto, - "tierheim": org, - "tierheim_plz": loc.get("postcode"), - "tierheim_lat": None, - "tierheim_lon": None, - "adoptions_url": a.get("url", "https://www.petfinder.com/"), - "quelle": "petfinder", - }) - return result - except Exception as e: - logger.warning(f"PetFinder Fetch: {e}") - return [] - - -# ------------------------------------------------------------------ -# Cache befüllen -# ------------------------------------------------------------------ -async def _refresh_cache(lat: float, lon: float, radius: int): - """Holt frische Daten und schreibt sie in adoption_cache.""" - animals = await _fetch_petfinder(lat, lon, radius) - if not animals: - return - expires = (datetime.utcnow() + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S") - with db() as conn: - for a in animals: - try: - conn.execute(""" - INSERT INTO adoption_cache - (external_id, name, rasse, alter_jahre, geschlecht, - foto_url, tierheim, tierheim_plz, tierheim_lat, tierheim_lon, - adoptions_url, expires_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(external_id) DO UPDATE SET - name=excluded.name, - rasse=excluded.rasse, - alter_jahre=excluded.alter_jahre, - geschlecht=excluded.geschlecht, - foto_url=excluded.foto_url, - tierheim=excluded.tierheim, - tierheim_plz=excluded.tierheim_plz, - tierheim_lat=excluded.tierheim_lat, - tierheim_lon=excluded.tierheim_lon, - adoptions_url=excluded.adoptions_url, - expires_at=excluded.expires_at - """, ( - a["external_id"], a["name"], a["rasse"], a["alter_jahre"], - a["geschlecht"], a["foto_url"], a["tierheim"], a["tierheim_plz"], - a["tierheim_lat"], a["tierheim_lon"], a["adoptions_url"], expires, - )) - except Exception as e: - logger.warning(f"Cache insert: {e}") - - -# ------------------------------------------------------------------ -# GET /api/adoption/nearby -# ------------------------------------------------------------------ -@router.get("/nearby") -async def adoption_nearby( - lat: float = Query(..., description="Breitengrad"), - lon: float = Query(..., description="Längengrad"), - radius: int = Query(50, ge=5, le=200, description="Radius in km"), - background_tasks: BackgroundTasks = None, -): - """ - Gibt Adoptionshunde in der Nähe zurück. - - Priorisierung: - 1. Frische PetFinder-Einträge aus Cache - 2. Statische Tierheim-Liste (immer vorhanden, mit Entfernung) - """ - now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") - - # ------ Cache lesen ------ - cached_animals = [] - with db() as conn: - rows = conn.execute(""" - SELECT * FROM adoption_cache - WHERE expires_at > ? - ORDER BY created_at DESC - """, (now_str,)).fetchall() - for row in rows: - d = dict(row) - if d.get("tierheim_lat") and d.get("tierheim_lon"): - dist = _haversine(lat, lon, d["tierheim_lat"], d["tierheim_lon"]) - if dist <= radius: - d["distanz_km"] = round(dist, 1) - cached_animals.append(d) - else: - # PetFinder-Einträge ohne Koordinaten: immer anzeigen - d["distanz_km"] = None - cached_animals.append(d) - - # ------ Cache refreshen wenn leer oder alt ------ - if not cached_animals and background_tasks is not None: - background_tasks.add_task(_refresh_cache, lat, lon, radius) - - # ------ Statische Tierheime (immer) ------ - shelters = [] - for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS: - dist = _haversine(lat, lon, slat, slon) - if dist <= radius: - shelters.append({ - "id": sid, - "name": name, - "plz": plz, - "stadt": stadt, - "lat": slat, - "lon": slon, - "url": url, - "distanz_km": round(dist, 1), - }) - - shelters.sort(key=lambda x: x["distanz_km"]) - - return { - "animals": cached_animals[:40], - "shelters": shelters[:10], - "has_petfinder": bool(PETFINDER_KEY), - } - - -# ------------------------------------------------------------------ -# GET /api/adoption/geocode?plz=… — PLZ → Koordinaten via Nominatim -# ------------------------------------------------------------------ -@router.get("/geocode") -async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)): - """Wandelt eine PLZ in Koordinaten um (via Nominatim).""" - try: - async with httpx.AsyncClient(timeout=8) as client: - r = await client.get( - "https://nominatim.openstreetmap.org/search", - params={ - "q": f"{plz}, Germany", - "format": "json", - "limit": 1, - "accept-language": "de", - "countrycodes": "de", - }, - headers={"User-Agent": "BanYaro/1.0 (https://banyaro.app)"}, - ) - results = r.json() - if results: - return {"lat": float(results[0]["lat"]), "lon": float(results[0]["lon"]), "display": results[0].get("display_name", plz)} - except Exception as e: - logger.warning(f"Geocode PLZ {plz}: {e}") - return {"lat": None, "lon": None, "display": plz} - - -# ================================================================== -# Community Adoption — Privates Weitervermittlungs-Board -# ================================================================== - -class InterestBody(BaseModel): - nachricht: Optional[str] = None - - -# ------------------------------------------------------------------ -# GET /api/adoption/community/my — eigene Inserate -# ------------------------------------------------------------------ -@router.get("/community/my") -def community_my(user=Depends(get_current_user)): - with db() as conn: - rows = conn.execute(""" - SELECT ca.*, - u.name AS besitzer_name, - (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count - FROM community_adoption ca - JOIN users u ON u.id = ca.user_id - WHERE ca.user_id = ? AND ca.status != 'deleted' - ORDER BY ca.created_at DESC - """, (user["id"],)).fetchall() - return [dict(r) for r in rows] - - -# ------------------------------------------------------------------ -# GET /api/adoption/community — alle aktiven Inserate (mit optionaler Nähe) -# ------------------------------------------------------------------ -@router.get("/community") -def community_list( - lat: Optional[float] = Query(None), - lon: Optional[float] = Query(None), - radius: float = Query(200.0, description="Radius in km (default 200)"), - user=Depends(get_current_user), -): - with db() as conn: - rows = conn.execute(""" - SELECT ca.*, - u.name AS besitzer_name, - (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count, - (SELECT COUNT(*) FROM community_adoption_interest i2 - WHERE i2.listing_id = ca.id AND i2.user_id = ?) AS _user_interested - FROM community_adoption ca - JOIN users u ON u.id = ca.user_id - WHERE ca.status = 'active' - ORDER BY ca.created_at DESC - LIMIT 50 - """, (user["id"],)).fetchall() - - result = [] - for row in rows: - d = dict(row) - d["user_interested"] = bool(d.pop("_user_interested", 0)) - if lat is not None and lon is not None and d.get("lat") and d.get("lon"): - dist = _haversine(lat, lon, d["lat"], d["lon"]) - d["distanz_km"] = round(dist, 1) - if dist > radius: - continue - else: - d["distanz_km"] = None - result.append(d) - - if lat is not None and lon is not None: - result.sort(key=lambda x: x["distanz_km"] if x["distanz_km"] is not None else 9999) - - return result - - -# ------------------------------------------------------------------ -# POST /api/adoption/community — Inserat erstellen -# ------------------------------------------------------------------ -@router.post("/community", status_code=201) -async def community_create( - name: str = Form(...), - beschreibung: str = Form(...), - rasse: str = Form(""), - alter_jahre: Optional[float] = Form(None), - geschlecht: str = Form(""), - gruende: str = Form(""), - ort: str = Form(""), - plz: str = Form(""), - lat: Optional[float] = Form(None), - lon: Optional[float] = Form(None), - dog_id: Optional[int] = Form(None), - foto: Optional[UploadFile] = File(None), - user=Depends(get_current_user), -): - foto_url = None - - if foto and foto.filename: - MAX_SIZE = 5 * 1024 * 1024 - header = await foto.read(12) - if len(header) < 3: - raise HTTPException(400, "Ungültige Datei") - is_jpeg = header[:3] == b"\xff\xd8\xff" - is_png = header[:4] == b"\x89PNG" - is_webp = header[:4] == b"RIFF" and len(header) >= 12 and header[8:12] == b"WEBP" - if not (is_jpeg or is_png or is_webp): - raise HTTPException(400, "Nur JPEG, PNG oder WebP erlaubt") - rest = await foto.read(MAX_SIZE) - if len(rest) >= MAX_SIZE: - raise HTTPException(400, "Foto zu groß (max 5 MB)") - data = header + rest - - folder = os.path.join(MEDIA_DIR, "adoption") - os.makedirs(folder, exist_ok=True) - filename = f"{uuid.uuid4()}.jpg" - filepath = os.path.join(folder, filename) - with open(filepath, "wb") as f: - f.write(data) - foto_url = f"/media/adoption/{filename}" - - with db() as conn: - cur = conn.execute(""" - INSERT INTO community_adoption - (user_id, dog_id, name, rasse, alter_jahre, geschlecht, - foto_url, beschreibung, gruende, ort, plz, lat, lon) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) - """, ( - user["id"], dog_id, name, rasse or None, alter_jahre, - geschlecht or None, foto_url, beschreibung, - gruende or None, ort or None, plz or None, lat, lon, - )) - new_id = cur.lastrowid - row = conn.execute( - "SELECT * FROM community_adoption WHERE id = ?", (new_id,) - ).fetchone() - return dict(row) - - -# ------------------------------------------------------------------ -# PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer) -# ------------------------------------------------------------------ -class _StatusBody(BaseModel): - status: str - -@router.patch("/community/{listing_id}") -def community_update_status( - listing_id: int, - body: _StatusBody, - user=Depends(get_current_user), -): - allowed = {"active", "reserved", "vermittelt"} - if body.status not in allowed: - raise HTTPException(400, f"Status muss einer von {allowed} sein") - status = body.status - with db() as conn: - cur = conn.execute(""" - UPDATE community_adoption - SET status = ?, updated_at = datetime('now') - WHERE id = ? AND user_id = ? - """, (status, listing_id, user["id"])) - if cur.rowcount == 0: - raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff") - return {"ok": True} - - -# ------------------------------------------------------------------ -# DELETE /api/adoption/community/{id} — Soft-Delete (nur Besitzer) -# ------------------------------------------------------------------ -@router.delete("/community/{listing_id}") -def community_delete(listing_id: int, user=Depends(get_current_user)): - with db() as conn: - cur = conn.execute(""" - UPDATE community_adoption - SET status = 'deleted', updated_at = datetime('now') - WHERE id = ? AND user_id = ? - """, (listing_id, user["id"])) - if cur.rowcount == 0: - raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff") - return {"ok": True} - - -# ------------------------------------------------------------------ -# POST /api/adoption/community/{id}/interest — Interesse bekunden -# ------------------------------------------------------------------ -@router.post("/community/{listing_id}/interest", status_code=201) -def community_interest(listing_id: int, body: InterestBody = None, user=Depends(get_current_user)): - nachricht = (body.nachricht if body else None) or None - with db() as conn: - listing = conn.execute( - "SELECT id, name, user_id FROM community_adoption WHERE id = ? AND status != 'deleted'", - (listing_id,) - ).fetchone() - if not listing: - raise HTTPException(404, "Inserat nicht gefunden") - if listing["user_id"] == user["id"]: - raise HTTPException(400, "Eigenes Inserat") - try: - conn.execute(""" - INSERT INTO community_adoption_interest (listing_id, user_id, nachricht) - VALUES (?, ?, ?) - """, (listing_id, user["id"], nachricht)) - except Exception: - raise HTTPException(409, "Interesse bereits bekundet") - - try: - send_push_to_user(listing["user_id"], { - "title": "Jemand interessiert sich für deinen Hund \U0001f43e", - "body": f"{user['name']} möchte mehr über {listing['name']} erfahren.", - "url": "/#adoption", - }) - except Exception as e: - logger.warning(f"Push interest: {e}") - - return {"ok": True} - - -# ------------------------------------------------------------------ -# DELETE /api/adoption/community/{id}/interest — Interesse zurückziehen -# ------------------------------------------------------------------ -@router.delete("/community/{listing_id}/interest") -def community_interest_withdraw(listing_id: int, user=Depends(get_current_user)): - with db() as conn: - cur = conn.execute(""" - DELETE FROM community_adoption_interest - WHERE listing_id = ? AND user_id = ? - """, (listing_id, user["id"])) - if cur.rowcount == 0: - raise HTTPException(404, "Kein Interesse gefunden") - return {"ok": True} - - -# ------------------------------------------------------------------ -# GET /api/adoption/community/{id}/interests — Interessenten (nur Besitzer) -# ------------------------------------------------------------------ -@router.get("/community/{listing_id}/interests") -def community_interests(listing_id: int, user=Depends(get_current_user)): - with db() as conn: - listing = conn.execute( - "SELECT user_id FROM community_adoption WHERE id = ? AND status != 'deleted'", - (listing_id,) - ).fetchone() - if not listing: - raise HTTPException(404, "Inserat nicht gefunden") - if listing["user_id"] != user["id"]: - raise HTTPException(403, "Nur der Besitzer kann Interessenten sehen") - rows = conn.execute(""" - SELECT i.id, i.nachricht, i.created_at, u.id AS user_id, u.name, u.avatar_url - FROM community_adoption_interest i - JOIN users u ON u.id = i.user_id - WHERE i.listing_id = ? - ORDER BY i.created_at ASC - """, (listing_id,)).fetchall() - return [dict(r) for r in rows] diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 4772ae6..f810217 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -15,7 +15,7 @@ from auth import ( get_current_user ) from username_blocklist import is_username_blocked -from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures +from ratelimit import check as rl_check router = APIRouter() COOKIE_NAME = "by_token" @@ -26,25 +26,18 @@ _SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_P def _send_verification_email(email: str, name: str, token: str): if not _SMTP_READY: return - import html as _html from routes.outreach import _send_smtp - from mailer import email_html - url = f"{_APP_URL}/api/auth/verify-email/{token}" subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse" - _ename = _html.escape(name) - body_html = f""" -

Hallo {_ename},

-

- willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird. -

-

Der Link ist 7 Tage gültig.

-

- Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach. -

""" - html = email_html(body_html, cta_url=url, cta_label="E-Mail bestätigen") - plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n" + 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}
""" + admin_html = f""" +

Neuer Züchter-Antrag

+

Von: {user['name']} ({user['email']})

+

Zwingername: {zwingername}

+

Rasse: {rasse_text}

+

Verein: {verein}

+

VDH: {'Ja' if vdh_mitglied else 'Nein'}

+

Stadt: {stadt}

+

Im Admin-Bereich prüfen

+ """ 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. -

""" + html = f""" +

Willkommen als Züchter bei Banyaro!

+

Hallo {user['name']},

+

dein Züchter-Profil wurde erfolgreich verifiziert.

+

Ab sofort hast du Zugang zu allen Züchter-Features.

+

Zur App

+ """ 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. -

-
- Grund: {_h.escape(body.grund)} -
-

- Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter - {ADMIN_EMAIL}. -

""" + html = f""" +

Dein Züchter-Antrag bei Banyaro

+

Hallo {user['name']},

+

leider konnten wir deinen Antrag aktuell nicht bestätigen.

+

Grund: {body.grund}

+

Du kannst jederzeit einen neuen Antrag stellen.

+

Bei Fragen: {ADMIN_EMAIL}

+ """ 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.

" if user_id else ""} -

Das Ban Yaro Team

""" - await send_email( - email, - "Deine Bewerbung bei Ban Yaro 🐾", - email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), - f"Hallo {_name}, deine Bewerbung ist eingegangen!", - ) - except Exception: - pass - - # Admin benachrichtigen - try: - admin_email = os.getenv("ADMIN_EMAIL", "") - if admin_email: - _ename = _html.escape(name) - _eemail = _html.escape(email) - _edog_name = _html.escape(dog_name) - _edog_rasse = _html.escape(dog_rasse) - _ehandle = _html.escape(social_handle) - _emotivation = _html.escape(motivation[:300]) - admin_body = f""" -

Neue Job-Bewerbung eingegangen:

- - - - - - -
Name{_ename}
E-Mail{_eemail}
Hund{_edog_name} ({_edog_rasse})
Social{_ehandle}
Anhänge{len([f for f in files if f.filename])} Datei(en)
-

{_emotivation}{"…" if len(motivation)>300 else ""}

""" - await send_email( - admin_email, - f"[Banyaro Jobs] Neue Bewerbung — {name}", - email_html(admin_body, cta_url="https://banyaro.app/#admin", cta_label="Im Admin-Bereich prüfen"), - f"Neue Bewerbung von {name} ({email})", - ) - except Exception: - pass - - return { - "ok": True, - "application_id": app_id, - "luna_trial": user_id is not None, - "trial_days": TRIAL_DAYS, - } - - -# FastAPI braucht expliziten Router-Decorator -router.add_api_route("/apply", apply, methods=["POST"], status_code=201) - - -# ------------------------------------------------------------------ -# GET /api/jobs/my-application -# ------------------------------------------------------------------ -@router.get("/my-application") -async def my_application(user=Depends(get_current_user)): - with db() as conn: - row = conn.execute( - """SELECT id, status, admin_note, created_at - FROM job_applications WHERE user_id=? - ORDER BY created_at DESC LIMIT 1""", - (user["id"],) - ).fetchone() - if not row: - return {"application": None} - return {"application": dict(row)} - - -# ------------------------------------------------------------------ -# GET /api/jobs/luna-trial-status -# ------------------------------------------------------------------ -@router.get("/luna-trial-status") -async def luna_trial_status(user=Depends(get_current_user)): - from datetime import datetime as _dt - trial = user.get("luna_trial_until") - if not trial: - return {"active": False} - remaining = (_dt.fromisoformat(trial) - _dt.utcnow()).days - return {"active": remaining > 0, "until": trial, "remaining_days": max(0, remaining)} - - -# ------------------------------------------------------------------ -# Admin: Bewerbungen verwalten -# ------------------------------------------------------------------ -@router.get("/admin/applications") -async def list_applications( - status: str = "pending", - admin = Depends(require_admin), -): - where = "" if status == "alle" else "WHERE a.status=?" - params = [] if status == "alle" else [status] - with db() as conn: - rows = conn.execute(f""" - SELECT a.*, u.name AS username, - COUNT(d.id) AS doc_count - FROM job_applications a - LEFT JOIN users u ON u.id = a.user_id - LEFT JOIN job_application_docs d ON d.application_id = a.id - {where} - GROUP BY a.id - ORDER BY a.created_at DESC - """, params).fetchall() - return [dict(r) for r in rows] - - -@router.get("/admin/applications/{app_id}") -async def get_application(app_id: int, admin=Depends(require_admin)): - with db() as conn: - row = conn.execute( - """SELECT a.*, u.name AS username, u.email AS user_email - FROM job_applications a - LEFT JOIN users u ON u.id = a.user_id - WHERE a.id=?""", - (app_id,) - ).fetchone() - if not row: - raise HTTPException(404) - docs = conn.execute( - "SELECT id, filename, uploaded_at FROM job_application_docs WHERE application_id=?", - (app_id,) - ).fetchall() - return {**dict(row), "docs": [dict(d) for d in docs]} - - -@router.patch("/admin/applications/{app_id}") -async def update_application( - app_id: int, - status: Optional[str] = None, - admin_note: Optional[str] = None, - admin = Depends(require_admin), -): - valid = {"pending", "reviewing", "accepted", "rejected"} - if status and status not in valid: - raise HTTPException(400, f"Ungültiger Status. Erlaubt: {valid}") - - with db() as conn: - row = conn.execute( - "SELECT user_id, email, name, status FROM job_applications WHERE id=?", - (app_id,) - ).fetchone() - if not row: - raise HTTPException(404) - - updates: dict = {"reviewed_at": datetime.utcnow().isoformat()} - if status: - updates["status"] = status - if admin_note is not None: - updates["admin_note"] = admin_note - - set_clause = ", ".join(f"{k}=?" for k in updates) - conn.execute( - f"UPDATE job_applications SET {set_clause} WHERE id=?", - (*updates.values(), app_id) - ) - - # Bei Annahme: is_social_media aktivieren + Gründer-Status - if status == "accepted" and row["user_id"]: - conn.execute( - "UPDATE users SET is_social_media=1 WHERE id=?", - (row["user_id"],) - ) - founder_count = conn.execute( - "SELECT COUNT(*) FROM users WHERE is_founder=1" - ).fetchone()[0] - if founder_count < 100: - conn.execute( - "UPDATE users SET is_founder=1 WHERE id=? AND is_founder=0", - (row["user_id"],) - ) - - # Status-Mail an Bewerber - try: - if status in ("accepted", "rejected", "reviewing"): - _send_status_mail(row["email"], row["name"], status, admin_note or "") - except Exception: - pass - - return {"ok": True} - - -@router.get("/admin/applications/{app_id}/docs/{doc_id}") -async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)): - with db() as conn: - doc = conn.execute( - "SELECT file_path, filename FROM job_application_docs WHERE id=? AND application_id=?", - (doc_id, app_id) - ).fetchone() - if not doc or not os.path.exists(doc["file_path"]): - raise HTTPException(404) - return FileResponse(doc["file_path"], filename=doc["filename"]) - - -def _send_status_mail(email: str, name: str, status: str, note: str): - import asyncio - _ename = _html.escape(name) - texts = { - "reviewing": ("Wir schauen uns deine Bewerbung genauer an 🐾", - f"

Hallo {_ename},

wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!

"), - "accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉", - f"

Hallo {_ename},

wir freuen uns, dir mitzuteilen: du bist unser neuer Social-Media-Manager/in für Ban Yaro!
Du erhältst außerdem den Gründer-Status in unserer Community. Willkommen im Team!

"), - "rejected": ("Deine Bewerbung bei Ban Yaro", - f"

Hallo {_ename},

vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!

"), - } - subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"

Hallo {_ename},

")) - note_html = f'
{_html.escape(note)}
' if note else "" - body = body_start + note_html - - async def _send(): - await send_email(email, subj, email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), subj) - - try: - loop = asyncio.get_event_loop() - if loop.is_running(): - asyncio.ensure_future(_send()) - else: - loop.run_until_complete(_send()) - except Exception: - pass diff --git a/backend/routes/ki.py b/backend/routes/ki.py index 80d663c..aa8d001 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -1,11 +1,10 @@ """BAN YARO — KI Routes""" -from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from typing import Optional import ki as ki_module from auth import get_current_user from ratelimit import check as rl_check -from database import db router = APIRouter() @@ -63,224 +62,3 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon.""" raise HTTPException(503, str(e)) except Exception as e: raise HTTPException(500, "KI momentan nicht verfügbar.") - - -# ------------------------------------------------------------------ -# POST /ki/tierarzt — KI-Tierarztfragen -# ------------------------------------------------------------------ -class TierarztRequest(BaseModel): - symptom: str - dog_id: Optional[int] = None - dog_name: Optional[str] = None - rasse: Optional[str] = None - - -@router.post("/tierarzt") -async def ki_tierarzt(req: TierarztRequest, request: Request, - user=Depends(get_current_user)): - """KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung.""" - if not req.symptom or len(req.symptom.strip()) < 5: - raise HTTPException(400, "Bitte beschreibe das Symptom genauer.") - if len(req.symptom) > 1000: - raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).") - - # Rate-Limit: max 5 Anfragen pro User pro Tag - with db() as conn: - count = conn.execute( - "SELECT COUNT(*) FROM ki_tierarzt_log " - "WHERE user_id=? AND created_at >= datetime('now','-1 day')", - (user["id"],) - ).fetchone()[0] - if count >= 5: - raise HTTPException(429, "Tageslimit erreicht. Du kannst maximal 5 Tierarztfragen pro Tag stellen.") - - dog_name = req.dog_name or "unbekannt" - rasse = req.rasse or "unbekannt" - - system = ( - "Du bist ein erfahrener Tierarzt-Assistent für Hunde. " - "Deine Aufgabe ist es, Hundebesitzern eine erste Orientierung zu geben — " - "kein Ersatz für eine echte tierärztliche Untersuchung. " - "Antworte immer auf Deutsch, klar und verständlich. " - "Stelle keine medizinischen Diagnosen. " - "Empfehle im Zweifel immer den Gang zum Tierarzt." - ) - - prompt = f"""Hund: {dog_name}, Rasse: {rasse} -Symptom: {req.symptom.strip()} - -Gib eine strukturierte, verständliche Einschätzung: -1. Mögliche Ursachen (2-3 wahrscheinlichste) -2. Was der Besitzer jetzt tun kann (Erstmaßnahmen) -3. Wann unbedingt zum Tierarzt (Dringlichkeit: beobachten / bald / sofort) - -Antworte auf Deutsch, klar und verständlich. Maximal 300 Wörter. -Schreibe KEINE medizinischen Diagnosen und empfehle im Zweifel immer den Tierarzt.""" - - try: - antwort = await ki_module.complete( - prompt=prompt, - system=system, - max_tokens=600, - requires_premium=False, - user_id=user["id"], - ) - # Erfolg: Rate-Limit-Eintrag speichern - with db() as conn: - conn.execute( - "INSERT INTO ki_tierarzt_log (user_id, dog_id) VALUES (?, ?)", - (user["id"], req.dog_id) - ) - return {"antwort": antwort, "anfragen_heute": count + 1, "limit": 5} - except ki_module.KIUnavailableError as e: - raise HTTPException(503, str(e)) - except HTTPException: - raise - except Exception: - raise HTTPException(500, "KI momentan nicht verfügbar.") - - -# ------------------------------------------------------------------ -# Rate-Limit-Helfer für Rassen-Erkennung -# ------------------------------------------------------------------ -_RASSE_DAILY_LIMIT = 10 - - -def _check_rasse_limit(user_id: int) -> int: - """Gibt verbleibende Erkennungen zurück. Wirft HTTPException wenn Limit erreicht.""" - with db() as conn: - used = conn.execute( - """SELECT COUNT(*) FROM ki_rasse_log - WHERE user_id = ? AND created_at >= datetime('now', 'start of day')""", - (user_id,) - ).fetchone()[0] - remaining = _RASSE_DAILY_LIMIT - used - if remaining <= 0: - raise HTTPException(429, f"Tageslimit erreicht ({_RASSE_DAILY_LIMIT} Erkennungen/Tag). Morgen wieder verfügbar.") - return remaining - - -def _log_rasse_request(user_id: int): - with db() as conn: - conn.execute( - "INSERT INTO ki_rasse_log (user_id) VALUES (?)", (user_id,) - ) - - -# ------------------------------------------------------------------ -# POST /ki/rasse-erkennung — Vision-basierte Rassenerkennung -# ------------------------------------------------------------------ -@router.post("/rasse-erkennung") -async def ki_rasse_erkennung( - request: Request, - file: UploadFile = File(...), - user=Depends(get_current_user), -): - """Hunderassen per Foto erkennen (Claude Vision, max 5 MB, 10x/Tag).""" - import base64 - import json - import re - import anthropic - - # Dateigröße prüfen - content = await file.read() - if len(content) > 5 * 1024 * 1024: - raise HTTPException(400, "Bild zu groß. Maximal 5 MB erlaubt.") - - # MIME-Typ prüfen - ct = (file.content_type or "").lower() - if not ct.startswith("image/"): - raise HTTPException(400, "Nur Bilddateien erlaubt (JPG, PNG, WebP).") - - # MIME-Typ auf erlaubte Werte beschränken - allowed_mimes = {"image/jpeg", "image/png", "image/webp", "image/gif"} - mime_type = ct if ct in allowed_mimes else "image/jpeg" - - # Rate-Limit prüfen - remaining_before = _check_rasse_limit(user["id"]) - - # Anthropic-Client holen (nutzt cached Instanz aus ki.py) - if not ki_module.ANTHROPIC_KEY: - raise HTTPException(503, "KI-Bildanalyse ist momentan nicht verfügbar.") - - api_key = ki_module.ANTHROPIC_KEY - base64_data = base64.standard_b64encode(content).decode("utf-8") - - prompt_text = """Analysiere dieses Bild und erkenne die Hunderasse(n). - -Antworte NUR im folgenden JSON-Format (kein anderer Text): -{ - "rassen": [ - {"name": "Labrador Retriever", "sicherheit": 85, "beschreibung": "Kurze Begründung"}, - {"name": "Golden Retriever", "sicherheit": 12, "beschreibung": "Falls Mischling"} - ], - "ist_hund": true, - "hinweis": "Optionaler Hinweis z.B. bei Welpen oder schlechter Bildqualität" -} - -Gib 1-3 Rassen nach Wahrscheinlichkeit sortiert an. Sicherheit in Prozent (0-100). -Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array.""" - - try: - def _sync_call(): - client = anthropic.Anthropic(api_key=api_key) - return client.messages.create( - model="claude-opus-4-7", - max_tokens=500, - messages=[{ - "role": "user", - "content": [ - { - "type": "image", - "source": { - "type": "base64", - "media_type": mime_type, - "data": base64_data, - } - }, - { - "type": "text", - "text": prompt_text, - } - ] - }] - ) - - import asyncio - response = await asyncio.get_event_loop().run_in_executor(None, _sync_call) - raw = response.content[0].text.strip() - - except anthropic.APIError as e: - raise HTTPException(503, f"KI-Bildanalyse nicht verfügbar: {e}") - except Exception as e: - raise HTTPException(500, "Fehler bei der Bildanalyse.") - - # JSON parsen — Claude kann manchmal ```json ... ``` wrappen - cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.DOTALL).strip() - try: - parsed = json.loads(cleaned) - except json.JSONDecodeError: - raise HTTPException(500, "KI-Antwort konnte nicht verarbeitet werden.") - - # Usage loggen (erst nach erfolgreicher KI-Antwort) - _log_rasse_request(user["id"]) - remaining_after = remaining_before - 1 - - # Wiki-Slugs für erkannte Rassen nachschlagen - rassen = parsed.get("rassen", []) - if rassen: - with db() as conn: - for r in rassen: - name = r.get("name", "") - # Exakter Name-Match (case-insensitive) - row = conn.execute( - "SELECT slug FROM wiki_rassen WHERE LOWER(name) = LOWER(?)", (name,) - ).fetchone() - r["wiki_slug"] = row["slug"] if row else None - - return { - "rassen": rassen, - "ist_hund": parsed.get("ist_hund", False), - "hinweis": parsed.get("hinweis") or None, - "verbleibende_anfragen": remaining_after, - } diff --git a/backend/routes/litters.py b/backend/routes/litters.py index ddc810c..2bcf629 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -240,7 +240,7 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)): # ------------------------------------------------------------------ @router.post("/litters/{litter_id}/welfare-confirm") async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)): - from mailer import send_email, email_html + from mailer import send_email import os, logging as _log _logger = _log.getLogger(__name__) @@ -265,21 +265,19 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)): eltern = conn.execute( "SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,) ).fetchone() - import html as _html - welfare_body = f""" -

Kritischer Tierschutz-Hinweis bestätigt

- - - - - - -
Züchter{_html.escape(zuechter)}
Zwinger{_html.escape(zwinger)}
Vater{_html.escape(eltern['vater_name'] or '—')}
Mutter{_html.escape(eltern['mutter_name'] or '—')}
Wurf-ID#{litter_id}
""" + html = f""" +

Tierschutz-Hinweis bestätigt

+

Züchter {zuechter} (Zwinger: {zwinger}) hat einen Wurf mit + kritischen Tierschutz-Hinweisen trotzdem angelegt.

+

Vater: {eltern['vater_name'] or '—'}  ·  Mutter: {eltern['mutter_name'] or '—'}

+

Wurf-ID: {litter_id}

+

Im Admin-Bereich prüfen

+ """ 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", "
") - parts.append(f'

{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'{label}' - f'{count}' - for label, count in overdue.items() - ) - html = f"""\ - - -
-
-
⚠️ Moderation überfällig
-
{now_str} · SLA: {SLA_H}h
-
-
-

Folgende Einträge warten seit mehr als {SLA_H} Stunden auf Bearbeitung:

- - - - - - {rows_html} -
BereichAnzahl
-
- - → Admin-Panel öffnen - -
-
-
- Ban Yaro · banyaro.app -
-
""" - - plain = f"Ban Yaro — Moderation überfällig ({now_str})\n\nSeit >{SLA_H}h offen:\n" + \ - "\n".join(f" • {l}: {c}" for l, c in overdue.items()) + \ - "\n\nhttps://banyaro.app/app/admin" - - try: - await send_email(admin, f"⚠️ Ban Yaro — Moderation überfällig ({', '.join(overdue)})", html, plain) - logger.info(f"Moderation-Overdue-Mail gesendet: {overdue}") - except Exception as e: - logger.error(f"Moderation-Overdue-Mail fehlgeschlagen: {e}") - - -def _action_items_html(metrics: dict) -> str: - items = [ - ("jobs_pending", "Bewerbungen offen"), - ("breeder_pending", "Züchter-Anträge"), - ("reports_open", "Forum-Meldungen"), - ("fotos_pending", "Foto-Einreichungen"), - ("poi_edits_pending", "POI-Korrekturen"), - ] - open_items = [(label, metrics.get(key, 0)) for key, label in items if metrics.get(key, 0) > 0] - - if not open_items: - body = '✅ Alles erledigt — nichts offen' - else: - pills = "".join( - f'' - f'{label} {count}' - for label, count in open_items - ) - body = f'
⚠️ {len(open_items)} Punkt{"e" if len(open_items)!=1 else ""} brauchen deine Aufmerksamkeit
{pills}' - - link = '
→ Admin-Panel öffnen
' - return f'
' \ - f'
Heute zu erledigen
' \ - f'{body}{link}
' - - # JOB: Status-Report per Mail (täglich 06:00 Uhr) # ------------------------------------------------------------------ async def _job_status_report(): @@ -826,7 +669,6 @@ async def _job_status_report(): # Community metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] - metrics["users_today"] = conn.execute("SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')").fetchone()[0] metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0] metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0] metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0] @@ -835,28 +677,6 @@ async def _job_status_report(): except Exception: metrics["lost_active"] = 0 - # Action Items - try: - metrics["jobs_pending"] = conn.execute("SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')").fetchone()[0] - except Exception: - metrics["jobs_pending"] = 0 - try: - metrics["breeder_pending"] = conn.execute("SELECT COUNT(*) FROM users WHERE breeder_status='pending'").fetchone()[0] - except Exception: - metrics["breeder_pending"] = 0 - try: - metrics["reports_open"] = conn.execute("SELECT COUNT(*) FROM forum_reports WHERE resolved=0").fetchone()[0] - except Exception: - metrics["reports_open"] = 0 - try: - metrics["fotos_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'").fetchone()[0] - except Exception: - metrics["fotos_pending"] = 0 - try: - metrics["poi_edits_pending"] = conn.execute("SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'").fetchone()[0] - except Exception: - metrics["poi_edits_pending"] = 0 - # Wiki-Interesse try: metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0] @@ -878,9 +698,6 @@ async def _job_status_report(): "seed_wikidata": "Rassen-Seed (Wikidata, monatlich)", "weekly_praise": "Wöchentlicher Lober (Mo 09:00)", "ki_health_report": "KI-Gesundheitsberichte", - "quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)", - "streak_reminder": "Streak-Erinnerung (täglich 19:00)", - "recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)", } job_rows_html = "" job_rows_txt = "" @@ -910,9 +727,6 @@ async def _job_status_report():
{now_str} Uhr
- - {_action_items_html(metrics)} -
Scheduler-Jobs
@@ -926,14 +740,14 @@ async def _job_status_report():
Community
{"".join(f'
{v}
{k}
' for k,v in [ - ("Nutzer gesamt",metrics["users"]), - ("Neue Nutzer heute",metrics["users_today"]), + ("Nutzer",metrics["users"]), ("Hunde",metrics["dogs"]), ("Tagebuch-Einträge",metrics["diary_entries"]), ("Aktive Giftköder",metrics["poison_active"]), ("Vermisste Hunde",metrics["lost_active"]), ("'So einen hab ich'",metrics["interesse_hat"]), ("'Interessiert mich'",metrics["interesse_will"]), + ("Züchter (pending)",metrics["zuchter_pending"]), ])}
@@ -947,28 +761,19 @@ async def _job_status_report(): """ - action_open = [l for k,l in [ - ("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"), - ("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"), - ] if metrics.get(k,0) > 0] plain = f"""Ban Yaro Status-Report — {now_str} -=== HEUTE ZU ERLEDIGEN === -{"✅ Alles erledigt" if not action_open else "⚠️ " + ", ".join(f"{l} ({metrics[k]})" for k,l in [ - ("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"), - ("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"), -] if metrics.get(k,0) > 0)} - === Scheduler-Jobs === {job_rows_txt} === Community === -Nutzer gesamt: {metrics['users']} (+{metrics['users_today']} heute) +Nutzer: {metrics['users']} Hunde: {metrics['dogs']} Tagebuch-Einträge: {metrics['diary_entries']} Aktive Giftköder: {metrics['poison_active']} Vermisste Hunde: {metrics['lost_active']} 'So einen hab ich': {metrics['interesse_hat']} 'Interessiert mich': {metrics['interesse_will']} +Züchter (pending): {metrics['zuchter_pending']} """ try: @@ -978,133 +783,6 @@ Vermisste Hunde: {metrics['lost_active']} logger.error(f"Status-Report: Mail-Fehler: {e}") -async def _job_quarterly_report(): - """Quartalsbericht: alle Report-Sections per Mail an ADMIN_EMAIL.""" - import os, sys - from mailer import send_email, email_html - - admin = os.getenv("ADMIN_EMAIL", "") - if not admin: - logger.info("Quartalsbericht: ADMIN_EMAIL nicht gesetzt, übersprungen.") - _log_job("quarterly_report", "ok", "ADMIN_EMAIL nicht gesetzt") - return - - now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y") - quarter = (datetime.now(tz=_TZ).month - 1) // 3 + 1 - - try: - # Report-Script importieren und alle Sections aufrufen - sys.path.insert(0, "/app/scripts") - import importlib, generate_reports as gr - importlib.reload(gr) # sicherstellen dass aktuelle Version - - sections = [ - ("Sicherheit", gr.report_sicherheit), - ("Funktionsumfang", gr.report_funktionsumfang), - ("Dateien", gr.report_dateien), - ("Nutzerübersicht", gr.report_nutzer), - ("Partnerliste", gr.report_partner), - ("Server & Speicher", gr.report_server), - ] - - def md_to_html_simple(text: str) -> str: - """Minimale Markdown→HTML-Konvertierung für E-Mail.""" - import html as _h - lines_out = [] - in_code = False - in_table = False - for line in text.split("\n"): - if line.startswith("```"): - if in_code: - lines_out.append("") - in_code = False - else: - lines_out.append('
')
-                        in_code = True
-                    continue
-                if in_code:
-                    lines_out.append(_h.escape(line))
-                    continue
-                if line.startswith("#### "):
-                    lines_out.append(f'

{line[5:]}

') - elif line.startswith("### "): - lines_out.append(f'

{line[4:]}

') - elif line.startswith("## "): - lines_out.append(f'

{line[3:]}

') - elif line.startswith("# "): - pass # Haupttitel kommt vom äußeren Template - elif line.startswith("---"): - pass # Trennlinie überspringen - elif line.startswith("| "): - if not in_table: - lines_out.append('') - in_table = True - if set(line.replace("|","").replace("-","").replace(" ","")) == set(): - continue # Trenn-Zeile - cells = [c.strip() for c in line.split("|")[1:-1]] - row_html = "".join(f'' for c in cells) - lines_out.append(f"{row_html}") - continue - elif line.startswith("- ") or line.startswith("* "): - if in_table: - lines_out.append("
{_h.escape(c)}
") - in_table = False - lines_out.append(f'
  • {line[2:]}
  • ') - elif line.startswith("> "): - if in_table: - lines_out.append("") - in_table = False - lines_out.append(f'
    {line[2:]}
    ') - elif line.strip() == "": - if in_table: - lines_out.append("") - in_table = False - lines_out.append("") - else: - if in_table: - lines_out.append("") - in_table = False - styled = line.replace("**", "", 1).replace("**", "", 1) - lines_out.append(f'

    {styled}

    ') - if in_table: - lines_out.append("") - if in_code: - lines_out.append("
    ") - return "\n".join(lines_out) - - # Body aus allen Sections zusammensetzen - body_parts = [] - plain_parts = [f"Ban Yaro Quartalsbericht Q{quarter} {now_str}\n", "=" * 50] - - for title, fn in sections: - try: - md = fn() - body_parts.append( - f'
    ' - f'

    {title}

    ' - f'{md_to_html_simple(md)}' - f'
    ' - ) - plain_parts.append(f"\n=== {title.upper()} ===\n{md}\n") - except Exception as e: - body_parts.append(f'

    Fehler in Section {title}: {e}

    ') - plain_parts.append(f"\n=== {title.upper()} ===\nFehler: {e}\n") - - full_body = "\n".join(body_parts) - full_plain = "\n".join(plain_parts) - subject = f"Ban Yaro Quartalsbericht Q{quarter}/{datetime.now(tz=_TZ).year} — {now_str}" - html = email_html(full_body, footer_text=f"Ban Yaro · banyaro.app · Quartalsbericht Q{quarter}") - - await send_email(admin, subject, html, full_plain) - logger.info(f"Quartalsbericht Q{quarter} gesendet an {admin}.") - _log_job("quarterly_report", "ok", f"Q{quarter} → {admin}") - - except Exception as e: - logger.error(f"Quartalsbericht: Fehler: {e}") - _log_job("quarterly_report", "error", str(e)) - - def _compute_milestone(today: date, bday: date, dog_name: str): """ Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist, @@ -1144,147 +822,3 @@ def _compute_milestone(today: date, bday: date, dog_name: str): return titel, text return None - - -# ------------------------------------------------------------------ -# JOB: Hund des Monats — Sieger des Vormonats festlegen -# ------------------------------------------------------------------ -async def _job_hdm_winner(): - """Läuft am 1. des Monats 00:05 und schreibt den Sieger des Vormonats.""" - today = datetime.now(tz=_TZ) - # Vormonat berechnen - first_this = today.replace(day=1) - last_month = (first_this - timedelta(days=1)).replace(day=1) - monat = last_month.strftime("%Y-%m") - - with db() as conn: - # Schon eingetragen? - existing = conn.execute( - "SELECT id FROM hund_des_monats_wins WHERE monat=?", (monat,) - ).fetchone() - if existing: - logger.info(f"HdM-Winner {monat}: bereits eingetragen, übersprungen.") - _log_job("hdm_winner", "ok", f"bereits vorhanden für {monat}") - return - - winner = conn.execute(""" - SELECT v.dog_id, d.name, d.user_id, COUNT(v.id) AS stimmen - FROM hund_des_monats_votes v - JOIN dogs d ON d.id = v.dog_id - WHERE v.monat = ? - GROUP BY v.dog_id - ORDER BY stimmen DESC - LIMIT 1 - """, (monat,)).fetchone() - - if not winner: - logger.info(f"HdM-Winner {monat}: keine Stimmen, kein Sieger.") - _log_job("hdm_winner", "ok", f"keine Stimmen für {monat}") - return - - conn.execute( - "INSERT OR IGNORE INTO hund_des_monats_wins (dog_id, monat, stimmen) VALUES (?, ?, ?)", - (winner["dog_id"], monat, winner["stimmen"]), - ) - - month_label = last_month.strftime("%B %Y") - send_push_to_user(winner["user_id"], { - "type": "hdm_winner", - "title": f"🏆 {winner['name']} ist Hund des Monats!", - "body": f"{winner['name']} hat den {month_label} gewonnen — herzlichen Glückwunsch!", - "data": {"page": "forum"}, - "tag": f"hdm-{monat}", - }) - - logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.") - _log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)") - - -# ------------------------------------------------------------------ -# JOB: Streak-Erinnerung (täglich 19:00) -# ------------------------------------------------------------------ -async def _job_streak_reminder(): - """ - Findet alle User die heute noch nicht trainiert haben (last_training_date < heute) - und deren current_streak > 0. Sendet einen motivierenden Push pro Hund. - """ - today = str(date.today()) - logger.info(f"Streak-Reminder Job läuft für {today}") - - with db() as conn: - rows = conn.execute(""" - SELECT ts.user_id, ts.dog_id, ts.current_streak, d.name AS dog_name - FROM training_streaks ts - JOIN dogs d ON d.id = ts.dog_id - WHERE ts.current_streak > 0 - AND (ts.last_training_date IS NULL OR ts.last_training_date < ?) - """, (today,)).fetchall() - - sent_total = 0 - for r in rows: - n = r["current_streak"] - sent = send_push_to_user(r["user_id"], { - "type": "streak_reminder", - "title": f"🔥 {r['dog_name']} wartet auf sein Training!", - "body": f"Streak: {n} {'Tag' if n == 1 else 'Tage'} — nicht jetzt aufhören.", - "data": {"page": "uebungen"}, - "tag": f"streak-{r['dog_id']}-{today}", - }) - sent_total += sent - - logger.info(f"Streak-Reminder Job fertig — {len(rows)} Hunde geprüft, {sent_total} Push gesendet.") - _log_job("streak_reminder", "ok", f"{sent_total} Push an {len(rows)} Hunde") - - -# ------------------------------------------------------------------ -# JOB: Tierfutter-Rückrufe prüfen (RASFF, täglich 08:00) -# ------------------------------------------------------------------ -async def _job_recall_check(): - """ - Fragt täglich die RASFF EU-API nach neuen Tierfutter-Rückrufen ab. - Neue Einträge werden in DB gespeichert, für jeden wird ein Push - an alle abonnierten User gesendet. - """ - logger.info("Rückruf-Check Job läuft") - try: - from routes.recalls import fetch_rasff_recalls, save_new_recalls - entries = await fetch_rasff_recalls() - if not entries: - logger.info("Rückruf-Check: Keine Einträge von RASFF erhalten (API-Fehler oder leer).") - _log_job("recall_check", "ok", "0 neue Rückrufe (API leer)") - return - - new_entries = save_new_recalls(entries) - logger.info(f"Rückruf-Check: {len(new_entries)} neue von {len(entries)} geprüften Einträgen.") - - for entry in new_entries: - produkt = entry.get("produkt") or entry.get("titel") or "Unbekanntes Produkt" - gefahr = entry.get("gefahr") or "Bitte Produktdetails prüfen" - ext_id = entry["external_id"] - body = f"{produkt} — {gefahr[:80]}" - send_push_to_all({ - "title": "⚠️ Tierfutter-Rückruf", - "body": body, - "data": {"page": "recalls"}, - "tag": f"recall-{ext_id}", - }) - logger.info(f"Rückruf-Push gesendet: {ext_id} — {produkt}") - - _log_job("recall_check", "ok", f"{len(new_entries)} neue Rückrufe") - except Exception as e: - logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}") - _log_job("recall_check", "error", str(e)) - - -# ------------------------------------------------------------------ -# JOB: Wiederkehrende Ausgaben anlegen -# ------------------------------------------------------------------ -async def _job_recurring_expenses(): - try: - from routes.expenses import process_due_recurring - count = process_due_recurring() - logger.info(f"Daueraufträge: {count} Einträge angelegt.") - _log_job("recurring_expenses", "ok", f"{count} Einträge") - except Exception as e: - logger.error(f"Daueraufträge-Job Fehler: {e}") - _log_job("recurring_expenses", "error", str(e)) diff --git a/backend/scripts/generate_reports.py b/backend/scripts/generate_reports.py deleted file mode 100644 index 6484c70..0000000 --- a/backend/scripts/generate_reports.py +++ /dev/null @@ -1,725 +0,0 @@ -#!/usr/bin/env python3 -""" -BAN YARO — Quarterly Report Generator -Aufruf: python3 scripts/generate_reports.py
    -Sections: sicherheit | funktionsumfang | dateien | nutzer | partner | server | all -""" - -import os -import sys -import sqlite3 -import subprocess -from datetime import datetime -from pathlib import Path - -DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db") -MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") -APP_DIR = "/app" -NOW = datetime.now() -DATE_STR = NOW.strftime("%d.%m.%Y %H:%M") -ISO_DATE = NOW.strftime("%Y-%m-%d") - - -# ────────────────────────────────────────────────────────────────────────────── -# Hilfsfunktionen -# ────────────────────────────────────────────────────────────────────────────── - -def db(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - - -def q(sql, params=()): - try: - with db() as conn: - return conn.execute(sql, params).fetchall() - except Exception as e: - return [] - - -def q1(sql, params=()): - rows = q(sql, params) - return rows[0] if rows else None - - -def val(sql, params=(), default=0): - row = q1(sql, params) - if row is None: - return default - return row[0] - - -def sh(cmd): - try: - r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10) - return r.stdout.strip() - except Exception: - return "(nicht verfügbar)" - - -def hr(): - return "\n---\n" - - -def h(level, text): - return f"\n{'#' * level} {text}\n" - - -def table(headers, rows): - col_widths = [len(h) for h in headers] - for row in rows: - for i, cell in enumerate(row): - if i < len(col_widths): - col_widths[i] = max(col_widths[i], len(str(cell))) - sep = "| " + " | ".join("-" * w for w in col_widths) + " |" - hdr = "| " + " | ".join(str(h).ljust(col_widths[i]) for i, h in enumerate(headers)) + " |" - lines = [hdr, sep] - for row in rows: - line = "| " + " | ".join(str(row[i] if i < len(row) else "").ljust(col_widths[i]) for i in range(len(headers))) + " |" - lines.append(line) - return "\n".join(lines) - - -def bytes_human(b): - for unit in ("B", "KB", "MB", "GB"): - if b < 1024: - return f"{b:.1f} {unit}" - b /= 1024 - return f"{b:.1f} TB" - - -# ────────────────────────────────────────────────────────────────────────────── -# 1 SICHERHEITSBERICHT -# ────────────────────────────────────────────────────────────────────────────── - -def report_sicherheit(): - # Aktive Bans aus DB - banned = val("SELECT COUNT(*) FROM users WHERE is_banned=1") - unverifiziert = val("SELECT COUNT(*) FROM users WHERE email_verified=0") - outreach_rows = q("SELECT COUNT(*) as n, from_account FROM outreach_log GROUP BY from_account") - - lines = [ - f"# Sicherheitsbericht — Ban Yaro", - f"\n_Erstellt: {DATE_STR}_\n", - hr(), - h(2, "Übersicht implementierter Schutzmaßnahmen"), - h(3, "1. Authentifizierung & Passwörter"), - "- **JWT** (HS256) mit 30-Tage-Ablauf, HttpOnly + Secure + SameSite=lax Cookie", - "- **Bcrypt**-Passwort-Hashing mit automatischem Salt", - "- Mindestlänge 8 Zeichen, serverseitig erzwungen", - "- Passwort-Reset: kryptographisches Token, 2 Stunden Ablauf", - "", - h(3, "2. Registrierung"), - "- **E-Mail-Verifikation** zwingend vor dem ersten Login", - "- Verifikationslink läuft nach 7 Tagen ab", - "- Rate Limit: 5 Registrierungen / Stunde / IP", - "- Username-Blocklist: >200 reservierte und unangemessene Begriffe", - "- Keine Doppelanmeldung (E-Mail und Username unique)", - "", - h(3, "3. Login-Schutz"), - "- **IP-Rate-Limit**: 10 Versuche / 5 Minuten", - "- **Email-Rate-Limit**: 5 Versuche / 5 Minuten pro E-Mail-Adresse", - "- **Account-Lockout**: 5 Fehlversuche → 15 Minuten gesperrt (in-memory)", - "- Fehlerzähler wird bei erfolgreichem Login zurückgesetzt", - "- Gleiche Fehlermeldung bei falschem Passwort UND unbekannter E-Mail (kein User-Enumeration)", - "", - h(3, "4. Forum-Schutz"), - "- E-Mail-Verifikation Pflicht zum Posten", - "- **Post-Cooldown**: 30 Sekunden zwischen beliebigen Beiträgen", - "- **Stunden-Limit Threads**: max. 5 neue Threads / Stunde / User", - "- **Stunden-Limit Antworten**: max. 20 Antworten / Stunde / User", - "- **Duplikat-Erkennung**: gleicher Text in 5 Minuten → blockiert", - "- **Content-Filter**: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio", - "- Moderatoren können Threads sperren, Beiträge löschen (Soft-Delete)", - "- Report-System: User können Beiträge melden", - "", - h(3, "5. HTTP-Security-Headers"), - "| Header | Wert |", - "|--------|------|", - "| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |", - "| `Content-Security-Policy` | default-src 'self'; frame-ancestors 'none'; … |", - "| `X-Content-Type-Options` | `nosniff` |", - "| `Referrer-Policy` | `strict-origin-when-cross-origin` |", - "| `Permissions-Policy` | camera=(), microphone=(), geolocation=(self) |", - "", - h(3, "6. Rate Limiting (alle Endpunkte)"), - table( - ["Endpunkt", "Limit", "Fenster"], - [ - ["/auth/register", "5 Req", "60 Min"], - ["/auth/login (IP)", "10 Req", "5 Min"], - ["/auth/login (Email)", "5 Req", "5 Min"], - ["/auth/forgot-password", "3 Req", "60 Min"], - ["/auth/resend-verification", "3 Req", "60 Min / Email"], - ["/auth/reset-password", "5 Req", "60 Min"], - ["KI-Features", "10 Req", "60 Min"], - ["Poison-Reports", "3 Req", "60 Min"], - ["Wiki-Liste", "60 Req", "60 Sek"], - ["Wiki-Detail", "30 Req", "60 Sek"], - ] - ), - "", - h(3, "7. Honeypot-Fallen"), - "Folgende Pfade blockieren Scanner-IPs sofort für 24 Stunden:", - "", - "```", - "/api/admin/users /api/v1/users /api/users /api/.env", - "/api/config /api/setup /api/install /api/phpinfo", - "/api/debug /api/actuator /api/swagger /api/graphql", - "/api/wiki/trap", - "```", - "", - h(3, "8. Datei-Upload-Sicherheit"), - "- **Magic-Byte-Prüfung**: JPEG, PNG, GIF, WebP, MP4, WebM", - "- **Path-Traversal-Schutz**: alle Pfade bleiben innerhalb `MEDIA_DIR`", - "- **Größenbeschränkung**: 20 MB globales Limit (Middleware)", - "- Automatische Konvertierung: HEIC→JPEG, MOV/AVI→MP4", - "- Max. 5 Fotos pro Forum-Thread", - "", - h(3, "9. Admin & Moderation"), - "- Admin-Endpoints per `require_admin` Dependency geschützt", - "- Moderatoren-Rolle mit eingeschränkten Rechten", - "- User-Banning mit Sperrgrund, geprüft bei jedem Request", - "- Outreach-Mailing nur über Admin-Panel, vollständiges Log", - "", - h(2, "Aktuelle Kennzahlen"), - table( - ["Metrik", "Wert"], - [ - ["Gesperrte Accounts", str(banned)], - ["Unverifizierte Accounts", str(unverifiziert)], - ["Gesendete Outreach-Mails", str(sum(r[0] for r in outreach_rows))], - ] - ), - "", - h(2, "Bekannte Einschränkungen"), - "- Rate-Limit-Daten und IP-Blocklist sind **in-memory** → Reset bei Container-Neustart", - "- Kein CAPTCHA (bewusst: Nutzerfreundlichkeit vs. Bot-Schutz)", - "- Keine Refresh-Token-Rotation (JWT ist 30 Tage gültig)", - "- Analytics (Besucher) extern über Umami — kein Zugriff aus dem Container", - "", - h(2, "Empfehlungen für nächste Überprüfung"), - "- [ ] Prüfen ob IP-Blocklist-Persistenz via DB sinnvoll wäre", - "- [ ] CSP weiter verschärfen (nonce-basiert statt unsafe-inline)", - "- [ ] Login-Logs in DB schreiben (für Audit-Trail)", - "- [ ] Zwei-Faktor-Authentifizierung für Admin-Accounts evaluieren", - ] - return "\n".join(lines) - - -# ────────────────────────────────────────────────────────────────────────────── -# 2 FUNKTIONSUMFANG -# ────────────────────────────────────────────────────────────────────────────── - -def report_funktionsumfang(): - BEREICHE = [ - ("Authentifizierung", [ - "Registrierung mit E-Mail-Verifikation", - "Login / Logout (JWT + HttpOnly-Cookie)", - "Passwort vergessen / zurücksetzen", - "Verifikations-Mail erneut senden", - "Referral-System (3 Stufen: 10/20/50 Refs → 20/30/50 % Rabatt)", - "Partner-Codes (Gründer-Slot, eigene Einladungen)", - ]), - ("Hunde-Profile", [ - "Anlegen / Bearbeiten von Hunde-Profilen (Rasse, Geburtsdatum, Gewicht, …)", - "Avatar-Upload (JPEG/WebP-Konvertierung, Vorschau)", - "Öffentliches Profil mit QR-Code und Teilen-Link", - "Hunde-Ausweis (druckbares HTML-Dokument)", - "Mehrere Hunde pro Account", - ]), - ("Forum", [ - "Thread erstellen mit Kategorien (allgemein, rasse, region, …)", - "Antworten, Likes, Foto-Anhänge (max. 5 pro Thread)", - "Moderatoren: Thread pinnen, sperren, löschen", - "Report-System: Beiträge melden", - "Push-Benachrichtigungen bei neuer Antwort", - "Öffentlich lesbar, Schreiben nur für verifizierte User", - ]), - ("Tagebuch", [ - "Tageseinträge mit Freitext, Fotos, GPS-Koordinaten", - "EXIF-GPS-Extraktion aus Foto-Uploads", - "Kartenansicht aller Tagebuch-Pins", - "Kalenderansicht nach Datum", - "Medienansicht (Galerie aller Fotos)", - "Day-One-kompatibles Format", - ]), - ("Gesundheit & Training", [ - "Gewichtsverlauf mit Diagramm", - "Gesundheits-Erinnerungen (Push, täglich 08:00)", - "104 Übungen (DB-basiert, KI-Trainingspläne)", - "Training-Logging mit Fortschrittsverfolgung", - "KI-Gesundheitsberichte (wöchentlich, cloud/lokal)", - ]), - ("Karte & POIs", [ - "Leaflet-Karte mit Cluster-Markern", - "Nearby-Alerts: Giftköder, Vermisste Hunde in der Nähe", - "Overpass-API-Integration (Tierärzte, Hundewiesen, Parks, …)", - "90-Tage-Cache für Overpass-Abfragen", - "ORS-Routenvorschläge zu Hundeparks", - ]), - ("Wiki & Rassen", [ - "Rassen-Datenbank (TheDogAPI + Wikidata-Enrichment)", - "Züchter-Verzeichnis mit Verifikation", - "Breed-Interest-Tracking ('So einen hab ich' / 'Interessiert mich')", - "KI-gestützte Rassen-Anreicherung", - "Wikipedia-basierte Beschreibungen", - ]), - ("Züchter-Features", [ - "Züchter-Antrag mit Dokument-Upload", - "Admin-Prüfung und Freischaltung", - "Züchter-Profil (Zwingername, Rassen, VDH, Stadt)", - "Wurfverwaltung mit Elterntieren, Welpen, Fotos", - "Tierschutz-Check vor Wurf-Anlage", - "Stammbaum-Ansicht", - "Genetik-Tracking (Farbgene, Erbkrankheiten)", - "Kaufvertrags-Generator", - "Jahresbericht-Export", - ]), - ("Social Features", [ - "Freundschaften (anfragen, annehmen, ablehnen)", - "Social-Media-Posts (Luna — KI-Social-Manager)", - "Lober: wöchentlicher KI-Lob-Push (Mo 09:00)", - "Benachrichtigungen (in-app + Push-Notifications)", - ]), - ("Admin & Moderation", [ - "Admin-Dashboard: User-Verwaltung, Ban/Unban", - "Moderation-Queue: gemeldete Beiträge", - "Outreach-Mailing: Templates, Versand, Log", - "Statistiken: User-Wachstum, Aktivität", - "Züchter-Anträge prüfen", - "Partner-Codes verwalten", - "KI-Konfiguration (cloud/lokal, Limits)", - ]), - ("Infrastruktur", [ - "Service Worker (Offline-Stufen 1–3)", - "Push-Notifications (VAPID)", - "APScheduler: 9 Hintergrund-Jobs (Gesundheit, Wetter, Events, …)", - "Brevo E-Mail-API + SMTP-Fallback", - "Analytics: Umami v2 (extern)", - "SEO: robots.txt, sitemap.xml, llms.txt", - "Landing Page + Widget", - ]), - ] - - lines = [ - "# Funktionsumfang — Ban Yaro", - f"\n_Erstellt: {DATE_STR}_\n", - hr(), - ] - for bereich, features in BEREICHE: - lines.append(h(2, bereich)) - for f in features: - lines.append(f"- {f}") - lines.append("") - - # Anzahl Routes aus DB-Query-Kontext (statisch) - lines += [ - hr(), - h(2, "Backend-Routers"), - table( - ["Router", "Präfix"], - [ - ["auth", "/api/auth"], - ["dogs", "/api/dogs"], - ["diary", "/api/diary"], - ["health", "/api/health"], - ["forum", "/api/forum"], - ["wiki", "/api/wiki"], - ["map", "/api/map"], - ["poison", "/api/poison"], - ["lost", "/api/lost"], - ["breeder", "/api/breeder"], - ["litters", "/api/litters"], - ["training", "/api/training"], - ["outreach", "/api/outreach"], - ["moderation", "/api/moderation"], - ["notes", "/api/notes"], - ["notifications", "/api/notifications"], - ["push", "/api/push"], - ["friends", "/api/friends"], - ["profile", "/api/profile"], - ["social", "/api/social"], - ["sitting", "/api/sitting"], - ["achievements", "/api/achievements"], - ["stats", "/api/stats"], - ["walks", "/api/walks"], - ["events", "/api/events"], - ["alerts", "/api/alerts"], - ["ratings", "/api/ratings"], - ] - ), - ] - return "\n".join(lines) - - -# ────────────────────────────────────────────────────────────────────────────── -# 3 DATEILISTE -# ────────────────────────────────────────────────────────────────────────────── - -def report_dateien(): - lines = [ - "# Dateiliste — Ban Yaro", - f"\n_Erstellt: {DATE_STR}_\n", - hr(), - ] - - def scan_dir(title, path, ext): - lines.append(h(2, title)) - files = sorted(Path(path).rglob(f"*.{ext}")) if Path(path).exists() else [] - rows = [] - total = 0 - for f in files: - try: - size = f.stat().st_size - total += size - rows.append([str(f.relative_to(path)), bytes_human(size)]) - except Exception: - pass - if rows: - lines.append(table(["Datei", "Größe"], rows)) - lines.append(f"\n**Gesamt**: {len(rows)} Dateien, {bytes_human(total)}\n") - - scan_dir("Backend — Python-Dateien", APP_DIR, "py") - scan_dir("Frontend — JavaScript", f"{APP_DIR}/static/js", "js") - scan_dir("Frontend — CSS", f"{APP_DIR}/static/css", "css") - - # HTML-Templates - html_files = list(Path(f"{APP_DIR}/static").glob("*.html")) if Path(f"{APP_DIR}/static").exists() else [] - if html_files: - lines.append(h(2, "Frontend — HTML")) - rows = [[f.name, bytes_human(f.stat().st_size)] for f in sorted(html_files)] - lines.append(table(["Datei", "Größe"], rows)) - lines.append("") - - return "\n".join(lines) - - -# ────────────────────────────────────────────────────────────────────────────── -# 4 NUTZERÜBERSICHT -# ────────────────────────────────────────────────────────────────────────────── - -def report_nutzer(): - lines = [ - "# Nutzerübersicht — Ban Yaro", - f"\n_Erstellt: {DATE_STR}_\n", - hr(), - ] - - # Nutzer nach Rolle - lines.append(h(2, "Nutzer nach Rolle")) - total_users = val("SELECT COUNT(*) FROM users") - admins = val("SELECT COUNT(*) FROM users WHERE rolle='admin'") - mods = val("SELECT COUNT(*) FROM users WHERE rolle='moderator' OR is_moderator=1") - breeders = val("SELECT COUNT(*) FROM users WHERE rolle='breeder'") - founders = val("SELECT COUNT(*) FROM users WHERE is_founder=1") - partners = val("SELECT COUNT(*) FROM users WHERE is_partner=1") - banned = val("SELECT COUNT(*) FROM users WHERE is_banned=1") - unverifiziert = val("SELECT COUNT(*) FROM users WHERE email_verified=0") - premium = val("SELECT COUNT(*) FROM users WHERE is_premium=1") - - lines.append(table( - ["Gruppe", "Anzahl"], - [ - ["Gesamt Nutzer", str(total_users)], - ["Admin", str(admins)], - ["Moderatoren", str(mods)], - ["Züchter", str(breeders)], - ["Gründer (aktiv)", str(founders)], - ["Partner", str(partners)], - ["Premium", str(premium)], - ["Gesperrt (banned)", str(banned)], - ["E-Mail unverifiziert", str(unverifiziert)], - ] - )) - - # Registrierungen pro Monat (letzte 6 Monate) - lines.append(h(2, "Registrierungen (letzte 6 Monate)")) - reg_rows = q(""" - SELECT strftime('%Y-%m', created_at) as monat, COUNT(*) as n - FROM users - WHERE created_at >= date('now', '-6 months') - GROUP BY monat ORDER BY monat - """) - if reg_rows: - lines.append(table(["Monat", "Neue Nutzer"], [(r[0], r[1]) for r in reg_rows])) - else: - lines.append("_Keine Daten_") - lines.append("") - - # Hunde - lines.append(h(2, "Hunde")) - dogs = val("SELECT COUNT(*) FROM dogs") - dogs_with_diary = val("SELECT COUNT(DISTINCT dog_id) FROM diary") - lines.append(table( - ["Metrik", "Anzahl"], - [ - ["Hunde gesamt", str(dogs)], - ["Hunde mit Tagebuch-Einträgen", str(dogs_with_diary)], - ] - )) - lines.append("") - - # Forum - lines.append(h(2, "Forum")) - threads = val("SELECT COUNT(*) FROM forum_threads WHERE is_deleted=0") - posts = val("SELECT COUNT(*) FROM forum_posts WHERE is_deleted=0") - reports_open = val("SELECT COUNT(*) FROM forum_reports WHERE resolved=0", default=0) - lines.append(table( - ["Metrik", "Anzahl"], - [ - ["Threads", str(threads)], - ["Antworten", str(posts)], - ["Offene Meldungen", str(reports_open)], - ] - )) - - # Kategorie-Verteilung - kat_rows = q(""" - SELECT kategorie, COUNT(*) as n - FROM forum_threads WHERE is_deleted=0 - GROUP BY kategorie ORDER BY n DESC - """) - if kat_rows: - lines.append("\n**Threads nach Kategorie:**\n") - lines.append(table(["Kategorie", "Threads"], [(r[0], r[1]) for r in kat_rows])) - lines.append("") - - # Tagebuch - lines.append(h(2, "Tagebuch")) - diary_total = val("SELECT COUNT(*) FROM diary") - diary_mit_foto = val("SELECT COUNT(*) FROM diary WHERE foto_url IS NOT NULL AND foto_url != ''") - diary_mit_gps = val("SELECT COUNT(*) FROM diary WHERE lat IS NOT NULL") - lines.append(table( - ["Metrik", "Anzahl"], - [ - ["Einträge gesamt", str(diary_total)], - ["Mit Foto", str(diary_mit_foto)], - ["Mit GPS-Koordinaten", str(diary_mit_gps)], - ] - )) - lines.append("") - - # Medien (Dateisystem) - lines.append(h(2, "Medien auf dem Server")) - media_root = Path(MEDIA_DIR) - if media_root.exists(): - rows = [] - total_size = 0 - total_count = 0 - for subdir in sorted(media_root.iterdir()): - if subdir.is_dir(): - files = list(subdir.rglob("*")) - files = [f for f in files if f.is_file()] - size = sum(f.stat().st_size for f in files if f.is_file()) - total_size += size - total_count += len(files) - rows.append([subdir.name, str(len(files)), bytes_human(size)]) - rows.append(["**GESAMT**", str(total_count), bytes_human(total_size)]) - lines.append(table(["Verzeichnis", "Dateien", "Größe"], rows)) - else: - lines.append(f"_Media-Verzeichnis nicht gefunden: {MEDIA_DIR}_") - lines.append("") - - # Outreach-Mails - lines.append(h(2, "Gesendete E-Mails")) - mail_rows = q(""" - SELECT from_account, COUNT(*) as n, - MIN(sent_at) as erste, MAX(sent_at) as letzte - FROM outreach_log - GROUP BY from_account ORDER BY n DESC - """) - if mail_rows: - lines.append(table( - ["Absender", "Anzahl", "Erste Mail", "Letzte Mail"], - [(r[0], r[1], r[2][:10] if r[2] else "—", r[3][:10] if r[3] else "—") for r in mail_rows] - )) - total_mails = sum(r[1] for r in mail_rows) - lines.append(f"\n**Gesamt**: {total_mails} Mails gesendet\n") - else: - lines.append("_Noch keine Mails versendet_\n") - - # Analytics-Hinweis - lines += [ - h(2, "Besuche (Analytics)"), - "> **Hinweis:** Besucher-Statistiken (Besuche/Tag und Monat) werden extern " - "über **Umami** erfasst und sind nicht im Container verfügbar. " - "Bitte Umami-Dashboard direkt aufrufen.", - "", - ] - - return "\n".join(lines) - - -# ────────────────────────────────────────────────────────────────────────────── -# 5 PARTNERLISTE -# ────────────────────────────────────────────────────────────────────────────── - -def report_partner(): - lines = [ - "# Partnerliste — Ban Yaro", - f"\n_Erstellt: {DATE_STR}_\n", - hr(), - ] - - # Partner-User - lines.append(h(2, "Partner-Accounts")) - partner_users = q(""" - SELECT name, email, created_at, founder_number - FROM users WHERE is_partner=1 - ORDER BY created_at - """) - if partner_users: - lines.append(table( - ["Name", "E-Mail", "Partner seit", "Gründer-Nr."], - [(r[0], r[1], r[2][:10] if r[2] else "—", str(r[3]) if r[3] else "—") for r in partner_users] - )) - else: - lines.append("_Keine Partner-Accounts_") - lines.append("") - - # Partner-Codes - lines.append(h(2, "Partner-Codes")) - codes = q(""" - SELECT code, grants_founder, max_uses, uses, created_at - FROM partner_codes ORDER BY created_at - """) - if codes: - lines.append(table( - ["Code", "Gründer-Slot", "Max. Nutzungen", "Verwendet", "Erstellt"], - [( - r[0], - "Ja" if r[1] else "Nein", - str(r[2]) if r[2] else "∞", - str(r[3]), - r[4][:10] if r[4] else "—" - ) for r in codes] - )) - else: - lines.append("_Keine Partner-Codes_") - lines.append("") - - # Gründer - lines.append(h(2, "Gründer")) - gruender = q(""" - SELECT founder_number, name, email, created_at - FROM users WHERE is_founder=1 - ORDER BY founder_number - """) - if gruender: - lines.append(table( - ["Nr.", "Name", "E-Mail", "Registriert"], - [(r[0], r[1], r[2], r[3][:10] if r[3] else "—") for r in gruender] - )) - lines.append(f"\n**{len(gruender)} von 100 Gründer-Plätzen belegt.**\n") - else: - lines.append("_Noch keine Gründer_") - lines.append("") - - return "\n".join(lines) - - -# ────────────────────────────────────────────────────────────────────────────── -# 6 SERVER & SPEICHER -# ────────────────────────────────────────────────────────────────────────────── - -def report_server(): - lines = [ - "# Server & Speicherbelegung — Ban Yaro", - f"\n_Erstellt: {DATE_STR}_\n", - hr(), - ] - - # Disk Usage - lines.append(h(2, "Festplattenbelegung")) - df_out = sh("df -h /data 2>/dev/null || df -h /") - lines.append(f"```\n{df_out}\n```\n") - - # Media-Verzeichnisse - lines.append(h(2, "Media-Verzeichnisse")) - du_media = sh(f"du -sh {MEDIA_DIR}/* 2>/dev/null | sort -rh") - du_total = sh(f"du -sh {MEDIA_DIR} 2>/dev/null") - if du_media: - lines.append(f"```\n{du_media}\n\nGesamt: {du_total}\n```\n") - else: - lines.append("_Keine Media-Daten_\n") - - # DB-Größe - lines.append(h(2, "Datenbank")) - db_size = sh(f"du -sh {DB_PATH} 2>/dev/null") - db_rows = {} - try: - with db() as conn: - tables = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" - ).fetchall() - for t in tables: - name = t[0] - count = conn.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0] - db_rows[name] = count - except Exception: - pass - lines.append(f"**DB-Größe:** {db_size}\n") - if db_rows: - rows_sorted = sorted(db_rows.items(), key=lambda x: x[1], reverse=True) - lines.append(table(["Tabelle", "Zeilen"], [(k, f"{v:,}") for k, v in rows_sorted])) - lines.append("") - - # App-Code Größe - lines.append(h(2, "App-Code")) - du_app = sh(f"du -sh {APP_DIR} 2>/dev/null") - lines.append(f"**App-Verzeichnis ({APP_DIR}):** {du_app}\n") - - # Speicher-Kapazität (Warnung wenn >80 %) - lines.append(h(2, "Kapazitäts-Warnung")) - df_pct = sh("df /data 2>/dev/null | awk 'NR==2{print $5}' | tr -d '%' || df / | awk 'NR==2{print $5}' | tr -d '%'") - try: - pct = int(df_pct.strip()) - if pct >= 90: - lines.append(f"> ⚠️ **KRITISCH: {pct} % Festplatte belegt!** Sofortige Maßnahmen nötig.") - elif pct >= 80: - lines.append(f"> ⚠️ **Warnung: {pct} % Festplatte belegt.** Bald aufrüsten.") - elif pct >= 70: - lines.append(f"> ℹ️ {pct} % Festplatte belegt — im Blick behalten.") - else: - lines.append(f"> ✅ {pct} % Festplatte belegt — ausreichend Kapazität.") - except (ValueError, TypeError): - lines.append(f"> Belegung: {df_pct}") - lines.append("") - - # Python-Pakete - lines.append(h(2, "Installierte Python-Pakete")) - pip_list = sh("pip list --format=columns 2>/dev/null | head -40") - lines.append(f"```\n{pip_list}\n```\n") - - return "\n".join(lines) - - -# ────────────────────────────────────────────────────────────────────────────── -# Main -# ────────────────────────────────────────────────────────────────────────────── - -REPORTS = { - "sicherheit": report_sicherheit, - "funktionsumfang": report_funktionsumfang, - "dateien": report_dateien, - "nutzer": report_nutzer, - "partner": report_partner, - "server": report_server, -} - -if __name__ == "__main__": - section = sys.argv[1] if len(sys.argv) > 1 else "all" - - if section == "all": - for name, fn in REPORTS.items(): - print(f"=== REPORT:{name} ===") - print(fn()) - print() - elif section in REPORTS: - print(REPORTS[section]()) - else: - print(f"Unbekannte Section: {section}", file=sys.stderr) - print(f"Verfügbar: {', '.join(REPORTS.keys())}", file=sys.stderr) - sys.exit(1) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 27cf0d9..073821a 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -435,7 +435,7 @@ .form-label { font-size: var(--text-sm); font-weight: var(--weight-medium); - color: var(--c-text); + color: var(--c-text-secondary); } /* iOS Safari: font-size < 16px triggert Auto-Zoom beim Fokus — muss alle Klassen überschreiben */ @@ -3078,10 +3078,10 @@ html.modal-open { ============================================================ */ .map-full-layout { position: fixed; - top: var(--safe-top); + top: calc(var(--header-height) + var(--safe-top)); left: 0; right: 0; - bottom: 0; + bottom: calc(var(--nav-bottom-height) + var(--safe-bottom)); overflow: hidden; z-index: 1; } @@ -3148,11 +3148,11 @@ html.modal-open { color: #fff; } -/* FAB-Gruppe rechts unten — direkt über dem Zurück-Button */ +/* FAB-Gruppe rechts unten */ .map-fabs { position: absolute; - bottom: calc(var(--safe-bottom) + 82px); /* 54px back + 20px bottom + 8px gap */ - right: 20px; + bottom: var(--space-4); + right: var(--space-3); z-index: 1000; display: flex; flex-direction: column; @@ -3160,8 +3160,8 @@ html.modal-open { align-items: center; } .map-fab { - width: 54px; - height: 54px; + width: 44px; + height: 44px; border-radius: 50%; background: #C4843A; color: #fff; @@ -3975,10 +3975,11 @@ html.modal-open { .rk-map-loc-input:focus { outline: none; border-color: var(--c-primary); } .rk-map-section { position: fixed; - top: var(--safe-top); + /* Unter dem App-Header, über der Bottom-Nav */ + top: calc(var(--header-height) + var(--safe-top)); left: 0; right: 0; - bottom: 0; + bottom: calc(var(--nav-bottom-height) + var(--safe-bottom)); z-index: 200; display: flex; flex-direction: column; @@ -4178,19 +4179,6 @@ html.modal-open { text-overflow: ellipsis; max-width: 10rem; /* prevents single pill from being wider than ~160px on mobile */ } -.by-tab-text { - display: inline-block; - white-space: nowrap; - transition: transform 0.3s ease; -} -.by-tab-text.scrolling { - animation: forum-tab-scroll 1.8s ease-in-out 0.3s infinite alternate; - transition: none; -} -@keyframes forum-tab-scroll { - from { transform: translateX(0); } - to { transform: translateX(var(--tab-scroll-px, 0)); } -} /* Category badge (colored pill) */ .forum-category-badge { @@ -4212,59 +4200,6 @@ html.modal-open { .forum-category-badge--tauschboerse { background: #fce7f3; color: #9d174d; } /* Search */ -/* Hund des Monats — kompakte Forum-Kachel */ -.forum-hdm-tile { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - background: linear-gradient(135deg, var(--c-surface-2) 0%, var(--c-bg) 100%); - border: 1.5px solid var(--c-border-light); - border-radius: var(--radius-lg); - cursor: pointer; - margin-bottom: var(--space-3); - min-width: 0; - transition: border-color .15s, box-shadow .15s; -} -.forum-hdm-tile:hover { border-color: var(--c-primary); box-shadow: var(--shadow-sm); } -.forum-hdm-tile-trophy { font-size: 1.5rem; flex-shrink: 0; } -.forum-hdm-tile-body { - flex: 1; - min-width: 0; -} -.forum-hdm-tile-title { - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--c-text-muted); - text-transform: uppercase; - letter-spacing: .04em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.forum-hdm-tile-winner { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.forum-hdm-tile-meta { - font-size: var(--text-xs); - color: var(--c-text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.forum-hdm-tile-cta { - flex-shrink: 0; - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--c-primary); - white-space: nowrap; -} - .forum-search-wrap { position: relative; } @@ -4983,25 +4918,6 @@ html.modal-open { } /* Filter-Row */ -.movies-search-row { - position: relative; - padding: var(--space-3) 0 var(--space-1); -} -.movies-search-icon { - position: absolute; - left: var(--space-3); - top: 50%; - transform: translateY(-50%); - width: 16px; - height: 16px; - color: var(--c-text-muted); - pointer-events: none; -} -.movies-search-input { - padding-left: calc(var(--space-3) + 16px + var(--space-2)) !important; - font-size: var(--text-sm); -} - .movies-filter-row { display: flex; gap: var(--space-2); @@ -5240,19 +5156,11 @@ html.modal-open { margin-bottom: var(--space-3); } -/* Kandidaten-Suche */ -.hdm-kandidaten-search { - margin-bottom: var(--space-3); -} - /* Vote-Grid */ .hdm-vote-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-3); - max-height: 340px; - overflow-y: auto; - padding-right: var(--space-1); } .hdm-vote-card { @@ -6064,21 +5972,6 @@ html.modal-open { cursor: pointer; } -/* Hund des Monats — Profil-Badge */ -.dp-hdm-badge { - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-3); - background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); - color: #78350f; - border-radius: var(--radius-full); - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - letter-spacing: .02em; - box-shadow: 0 1px 3px rgba(0,0,0,.15); -} - /* --- Foto-Editor Modal --- */ .photo-editor { display: flex; flex-direction: column; gap: var(--space-3); align-items: center; } .photo-editor-preview { @@ -6802,1092 +6695,3 @@ svg.empty-state-icon { pointer-events: none; letter-spacing: 0.01em; } - -/* ------------------------------------------------------------ - STREAK-WIDGET (Welcome-Seite) - ------------------------------------------------------------ */ -.wc-streak-card { - display: flex; - align-items: center; - gap: var(--space-3); - margin-top: var(--space-5); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-lg, 14px); - background: linear-gradient(135deg, #ff6b00 0%, #c0392b 100%); - color: #fff; - box-shadow: 0 4px 18px rgba(196, 63, 0, 0.35); - position: relative; - overflow: hidden; -} -.wc-streak-card::before { - content: ''; - position: absolute; - inset: 0; - background: radial-gradient(ellipse at 10% 50%, rgba(255,255,255,0.15) 0%, transparent 60%); - pointer-events: none; -} -.wc-streak-flame-wrap { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; -} -.wc-streak-flame { - font-size: 2.2rem; - line-height: 1; - filter: drop-shadow(0 2px 6px rgba(0,0,0,0.3)); -} -.wc-streak-number { - font-size: 2.6rem; - font-weight: 800; - line-height: 1; - letter-spacing: -0.03em; - text-shadow: 0 2px 8px rgba(0,0,0,0.2); -} -.wc-streak-info { - flex: 1; - min-width: 0; -} -.wc-streak-label { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - opacity: 0.95; -} -.wc-streak-best { - font-size: var(--text-xs); - opacity: 0.75; - margin-top: 2px; -} -.wc-streak-lb-btn { - background: rgba(255,255,255,0.2); - border: 1.5px solid rgba(255,255,255,0.45); - border-radius: 50%; - width: 38px; - height: 38px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: #fff; - transition: background 0.15s; -} -.wc-streak-lb-btn:active { background: rgba(255,255,255,0.35); } - -/* ------------------------------------------------------------ - KI RASSEN-ERKENNUNG — Ergebnis-Block - ------------------------------------------------------------ */ -.rasse-result-card { - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - padding: var(--space-4); - margin-bottom: var(--space-3); -} -.rasse-result-card--top { - border-color: var(--c-primary); - background: var(--c-primary-subtle, #f0f9ff); -} -.rasse-result-name { - font-size: var(--text-base); - font-weight: var(--weight-semibold); - color: var(--c-text); - margin-bottom: var(--space-1); -} -.rasse-result-bar-wrap { - background: var(--c-surface-2); - border-radius: 999px; - height: 8px; - overflow: hidden; - margin: var(--space-2) 0; -} -.rasse-result-bar { - height: 8px; - border-radius: 999px; - background: var(--c-primary); - transition: width 0.6s ease; -} -.rasse-result-bar--dim { - background: var(--c-text-muted, #9ca3af); -} -.rasse-result-pct { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-primary); -} -.rasse-result-pct--dim { - color: var(--c-text-muted); -} -.rasse-result-desc { - font-size: var(--text-xs); - color: var(--c-text-secondary); - margin-top: var(--space-1); - line-height: 1.4; -} - -/* Rückrufe — Warnbanner (Dark-Mode-sicher) */ -.recalls-warning-banner { - background: var(--c-danger-subtle); - border: 1px solid var(--c-danger); - border-radius: var(--radius-md); - padding: var(--space-3) var(--space-4); - margin-bottom: var(--space-4); - display: flex; - align-items: flex-start; - gap: var(--space-2); -} -.recalls-warning-icon { - color: var(--c-danger); - flex-shrink: 0; - margin-top: 2px; -} -.recalls-warning-text { - margin: 0; - font-size: var(--text-sm); - color: var(--c-text); - line-height: 1.5; -} - -/* ============================================================ - Ausgaben-Tracker (expenses.js) - ============================================================ */ - -/* FAB */ -.exp-fab { - position: fixed; - bottom: calc(var(--nav-height, 64px) + var(--space-4)); - right: var(--space-4); - z-index: 100; - width: 52px; - height: 52px; - border-radius: 50%; - background: var(--c-primary); - color: #fff; - border: none; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 4px 14px rgba(0,0,0,.25); - cursor: pointer; - font-size: 1.35rem; - transition: transform .15s, box-shadow .15s; -} -.exp-fab:active { - transform: scale(.93); - box-shadow: 0 2px 8px rgba(0,0,0,.2); -} - -/* Lade-/Fehler-Zustände */ -.exp-loading { padding: var(--space-4); } -.exp-error { - padding: var(--space-4); - color: var(--c-danger); - font-size: var(--text-sm); - text-align: center; -} -.exp-empty-hint { - color: var(--c-text-secondary); - font-size: var(--text-sm); - padding: var(--space-3) 0; - text-align: center; -} - -/* ---- Hero-Card (Übersicht & Statistik oben) ---- */ -.exp-hero-card { - background: linear-gradient(135deg, var(--c-primary) 0%, color-mix(in srgb, var(--c-primary) 75%, #000) 100%); - color: #fff; - border-radius: var(--radius-xl, 16px); - padding: var(--space-5) var(--space-4); - margin: var(--space-3) var(--space-3) var(--space-4); - text-align: center; - box-shadow: 0 6px 20px rgba(0,0,0,.15); -} -.exp-hero-card--sm { - padding: var(--space-4) var(--space-4); -} -.exp-hero-label { - font-size: var(--text-sm); - font-weight: var(--weight-medium); - opacity: .85; - margin-bottom: var(--space-1); - text-transform: uppercase; - letter-spacing: .04em; -} -.exp-hero-betrag { - font-size: clamp(1.9rem, 7vw, 2.8rem); - font-weight: var(--weight-bold); - line-height: 1.1; - letter-spacing: -.02em; -} -.exp-hero-meta { - margin-top: var(--space-2); - font-size: var(--text-sm); - opacity: .85; - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: var(--space-2); -} - -/* Trend-Badge */ -.exp-trend { - display: inline-flex; - align-items: center; - gap: 2px; - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - padding: 2px 8px; - border-radius: 999px; -} -.exp-trend--up { background: rgba(239,68,68,.25); } -.exp-trend--down { background: rgba(16,185,129,.25); } - -/* ---- Kachel-Grid (Übersicht) ---- */ -.exp-kachel-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: var(--space-2); - padding: 0 var(--space-3) var(--space-3); -} -.exp-kachel { - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - padding: var(--space-3) var(--space-2); - text-align: center; - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-1); -} -.exp-kachel-icon { - width: 44px; - height: 44px; - border-radius: var(--radius-md); - display: flex; - align-items: center; - justify-content: center; - font-size: 1.3rem; - margin-bottom: var(--space-1); -} -.exp-kachel-betrag { - font-size: var(--text-sm); - font-weight: var(--weight-bold); - line-height: 1.1; -} -.exp-kachel-label { - font-size: var(--text-xs); - color: var(--c-text-secondary); - line-height: 1.2; -} - -/* ---- Sektion-Block (Verlauf etc.) ---- */ -.exp-section { - margin: 0 var(--space-3) var(--space-4); - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - padding: var(--space-4); -} -.exp-section-title { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); - margin-bottom: var(--space-3); - display: flex; - align-items: center; - gap: var(--space-1); - text-transform: uppercase; - letter-spacing: .04em; -} - -/* ---- Balkendiagramm (Verlauf) ---- */ -.exp-bar-chart { - display: flex; - align-items: flex-end; - gap: var(--space-1); - height: 80px; -} -.exp-bar-chart--12 { - height: 90px; - gap: 4px; -} -.exp-bar-item { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: 3px; -} -.exp-bar-item--aktiv .exp-bar-label { - color: var(--c-primary); - font-weight: var(--weight-semibold); -} -.exp-bar-track { - width: 100%; - height: 60px; - background: var(--c-surface-2, #f3f4f6); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; - display: flex; - flex-direction: column; - justify-content: flex-end; - overflow: hidden; -} -.exp-bar-track--stack { - height: 70px; -} -.exp-bar-fill { - width: 100%; - background: var(--c-primary); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; - transition: height .4s ease; -} -.exp-bar-fill--aktiv { background: var(--c-primary); } -.exp-stack-seg { - width: 100%; - min-height: 2px; - transition: height .4s ease; -} -.exp-bar-label { - font-size: var(--text-xs); - color: var(--c-text-muted, #9ca3af); - white-space: nowrap; -} -.exp-bar-val { - font-size: var(--text-xs); - color: var(--c-text-secondary); -} - -/* ---- Einträge-Liste ---- */ -.exp-list { - padding: 0 var(--space-3); -} -.exp-month-group { - margin-bottom: var(--space-3); -} -.exp-month-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-2) var(--space-3); - background: var(--c-surface-2, #f3f4f6); - border-radius: var(--radius-md); - margin-bottom: var(--space-2); -} -.exp-month-title { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); - text-transform: uppercase; - letter-spacing: .04em; -} -.exp-month-summe { - font-size: var(--text-sm); - font-weight: var(--weight-bold); - color: var(--c-primary); -} - -/* Einzelner Eintrag */ -.exp-entry { - display: flex; - align-items: center; - gap: var(--space-3); - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-md); - padding: var(--space-3); - margin-bottom: var(--space-2); - cursor: pointer; - transition: background .15s; -} -.exp-entry:active { background: var(--c-surface-2, #f3f4f6); } - -/* Icon-Badge mit Kategorie-Farbe */ -.exp-entry-icon-badge { - flex-shrink: 0; - width: 40px; - height: 40px; - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--kat-color) 15%, transparent); - color: var(--kat-color); - display: flex; - align-items: center; - justify-content: center; - font-size: 1.1rem; -} - -.exp-entry-body { - flex: 1; - min-width: 0; -} -.exp-entry-head { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-1); - margin-bottom: 2px; -} -.exp-entry-datum { - font-size: var(--text-xs); - color: var(--c-text-muted, #9ca3af); - flex-shrink: 0; -} -.exp-entry-kat { - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--c-text); -} -.exp-entry-notiz { - display: block; - font-size: var(--text-xs); - color: var(--c-text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.exp-dog-badge { - display: inline-flex; - align-items: center; - gap: 2px; - font-size: var(--text-xs); - color: var(--c-text-secondary); - background: var(--c-surface-2, #f3f4f6); - border-radius: 999px; - padding: 1px 6px; -} - -/* Rechte Spalte: Betrag + Löschen-Icon */ -.exp-entry-right { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: var(--space-1); - flex-shrink: 0; -} -.exp-entry-betrag { - font-size: var(--text-base); - font-weight: var(--weight-bold); - color: var(--c-text); - white-space: nowrap; -} -.exp-entry-del { - background: transparent; - border: none; - color: var(--c-text-muted, #9ca3af); - cursor: pointer; - padding: 2px 4px; - border-radius: var(--radius-sm); - font-size: 1rem; - line-height: 1; - transition: color .15s; -} -.exp-entry-del:hover { color: var(--c-danger); } - -/* ---- Statistik: Kategorie-Balken-Reihen ---- */ -.exp-stat-rows { - display: flex; - flex-direction: column; - gap: var(--space-2); -} -.exp-stat-row { - display: grid; - grid-template-columns: 120px 1fr 36px 80px; - align-items: center; - gap: var(--space-2); -} -.exp-stat-label { - display: flex; - align-items: center; - gap: var(--space-1); - font-size: var(--text-sm); - color: var(--c-text); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} -.exp-stat-icon { flex-shrink: 0; } -.exp-stat-bar-wrap { - height: 8px; - background: var(--c-surface-2, #f3f4f6); - border-radius: 999px; - overflow: hidden; -} -.exp-stat-bar { - height: 8px; - border-radius: 999px; - transition: width .5s ease; -} -.exp-stat-pct { - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); - text-align: right; -} -.exp-stat-val { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-text); - text-align: right; - white-space: nowrap; -} - -/* ---- Donut-Diagramm (CSS conic-gradient) ---- */ -.exp-donut-wrap { - display: flex; - align-items: center; - gap: var(--space-5); - flex-wrap: wrap; -} -.exp-donut { - position: relative; - width: 120px; - height: 120px; - border-radius: 50%; - flex-shrink: 0; -} -.exp-donut-hole { - position: absolute; - inset: 28px; - background: var(--c-surface); - border-radius: 50%; -} -.exp-donut-legend { - flex: 1; - display: flex; - flex-direction: column; - gap: var(--space-2); - min-width: 130px; -} -.exp-donut-legend-item { - display: flex; - align-items: center; - gap: var(--space-2); - font-size: var(--text-sm); -} -.exp-donut-dot { - width: 10px; - height: 10px; - border-radius: 50%; - flex-shrink: 0; -} -.exp-donut-legend-label { - flex: 1; - color: var(--c-text); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} -.exp-donut-legend-pct { - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); -} - -/* Daueraufträge */ -.exp-recurring-card { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - background: var(--c-surface); - border: 1.5px solid var(--c-border); - border-radius: var(--radius-lg); - margin-bottom: var(--space-2); - transition: opacity .2s; -} -.exp-recurring-card--inaktiv { opacity: .55; } -.exp-recurring-freq { - font-size: var(--text-xs); - color: var(--c-primary); - font-weight: var(--weight-semibold); - background: var(--c-primary-subtle); - padding: 1px var(--space-2); - border-radius: var(--radius-full); -} -.exp-recurring-next { - font-size: var(--text-xs); - color: var(--c-text-muted); - margin-top: var(--space-1); - display: flex; - align-items: center; - gap: var(--space-1); - flex-wrap: wrap; -} -.exp-badge-inaktiv { - background: var(--c-surface-2); - color: var(--c-text-muted); - padding: 1px var(--space-2); - border-radius: var(--radius-full); - font-size: var(--text-xs); -} -.exp-icon-btn { - width: 28px; - height: 28px; - border: 1.5px solid var(--c-border); - border-radius: var(--radius-sm); - background: var(--c-surface); - color: var(--c-text-secondary); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: color .15s, border-color .15s; -} -.exp-icon-btn:hover { color: var(--c-text); border-color: var(--c-text-muted); } -.exp-icon-btn--danger:hover { color: var(--c-danger); border-color: var(--c-danger); } - -/* Ausgaben-Formular — Kategorie-Kacheln */ -.exp-kat-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: var(--space-2); -} -.exp-kat-tile { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-1); - padding: var(--space-3) var(--space-2); - border: 1.5px solid var(--c-border); - border-radius: var(--radius-md); - cursor: pointer; - transition: border-color .15s, background .15s; - background: var(--c-surface); - user-select: none; -} -.exp-kat-tile:hover { border-color: var(--c-primary); } -.exp-kat-tile--sel { - border-color: var(--c-primary); - background: var(--c-primary-subtle); -} -.exp-kat-tile-icon { font-size: 1.4rem; line-height: 1; } -.exp-kat-tile-label { - font-size: var(--text-xs); - font-weight: var(--weight-medium); - color: var(--c-text-secondary); - text-align: center; -} -.exp-kat-tile--sel .exp-kat-tile-label { color: var(--c-primary); } - -/* Betrag-Feld mit €-Prefix */ -.exp-betrag-wrap { - position: relative; - display: flex; - align-items: center; -} -.exp-betrag-prefix { - position: absolute; - left: var(--space-3); - color: var(--c-text-muted); - font-weight: var(--weight-semibold); - pointer-events: none; -} -.exp-betrag-input { padding-left: calc(var(--space-3) + 14px + var(--space-2)) !important; } - -/* Form-Label Hint */ -.form-label-hint { color: var(--c-text-muted); font-weight: normal; font-size: var(--text-xs); } - -/* Wiederholungs-Sektion */ -.exp-repeat-section { - margin-top: var(--space-4); - padding-top: var(--space-4); - border-top: 1px solid var(--c-border-light); -} -.exp-repeat-toggle { - display: flex; - align-items: center; - gap: var(--space-2); - cursor: pointer; - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--c-text); - user-select: none; -} -.exp-repeat-toggle-box { - width: 18px; - height: 18px; - border: 1.5px solid var(--c-border); - border-radius: var(--radius-sm); - background: var(--c-surface); - flex-shrink: 0; - transition: background .15s, border-color .15s; -} -.exp-repeat-toggle input:checked ~ .exp-repeat-toggle-box { - background: var(--c-primary); - border-color: var(--c-primary); -} - -/* ================================================================ - DREI WELTEN NAVIGATION — JETZT | HUND | WELT - ================================================================ */ - -/* Overlay */ -#worlds-overlay { - position: fixed; - inset: 0; - z-index: 50; - overflow: hidden; - background: var(--c-bg); - display: none; - opacity: 0; - transition: opacity 0.2s; -} -#worlds-overlay.worlds-visible { opacity: 1; } - -/* Track */ -#worlds-track { - display: flex; - width: 300%; - height: 100%; - will-change: transform; - transform: translateX(-33.333%); -} -.world-panel { - width: 33.333%; - height: 100%; - overflow-y: auto; - overflow-x: hidden; - -webkit-overflow-scrolling: touch; - overscroll-behavior-y: contain; -} -#wp-welt { overflow: hidden; position: relative; } - -/* Navigation-Punkte */ -#world-dots { - position: fixed; - top: calc(env(safe-area-inset-top, 0px) + 14px); - left: 0; right: 0; - display: flex; - justify-content: center; - gap: 5px; - z-index: 60; - pointer-events: none; -} -.wdot { - width: 6px; height: 6px; - border-radius: 50%; - background: var(--c-text); - opacity: 0.2; - transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); - cursor: pointer; -} -.wdot.active { - width: 22px; - border-radius: 3px; - opacity: 1; - background: var(--c-primary); -} - -/* Welt-Labels */ -#world-labels { - position: fixed; - top: calc(env(safe-area-inset-top, 0px) + 28px); - left: 0; right: 0; - display: flex; - justify-content: center; - gap: 28px; - z-index: 59; - pointer-events: none; -} -.wlabel { - font-size: 9px; - font-weight: 800; - letter-spacing: 0.12em; - color: white; - opacity: 0.4; - text-transform: uppercase; - transition: opacity 0.18s; -} -.wlabel.active { opacity: 1; } - -@media (min-width: 768px) { - #world-labels { gap: 48px; font-size: 11px; } - .wlabel { opacity: 0.5; padding: 4px 10px; border-radius: 8px; } - .wlabel:hover { opacity: 0.8; background: rgba(255,255,255,0.08); } - .wlabel.active { opacity: 1; background: rgba(255,255,255,0.12); } -} - -/* Settings-Button */ -#worlds-settings { - position: fixed; - top: calc(env(safe-area-inset-top, 0px) + 6px); - right: 10px; - width: 38px; height: 38px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - z-index: 61; - color: var(--c-text-secondary); - border: none; - background: none; - border-radius: 50%; -} - -/* FAB */ -#worlds-fab { - position: fixed; - bottom: calc(env(safe-area-inset-bottom, 16px) + 16px); - right: 20px; - width: 54px; height: 54px; - border-radius: 50%; - background: var(--c-primary); - color: white; - border: none; - cursor: pointer; - z-index: 60; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 4px 20px rgba(196,132,58,0.45); - transition: transform 0.12s, box-shadow 0.12s; -} -#worlds-fab:active { transform: scale(0.92); box-shadow: 0 2px 10px rgba(196,132,58,0.3); } - -/* Header + Bottom-Nav: vollständig entfernt — Welten übernehmen Navigation */ -#app-header { display: none !important; } -#bottom-nav { display: none !important; } - -/* Zurück-FAB — gleiche Farbe und Größe wie Seiten-FABs */ -#worlds-back { - position: fixed; - bottom: calc(var(--safe-bottom) + 20px); - right: 20px; - width: 54px; - height: 54px; - border-radius: 50%; - background: var(--c-primary); - border: none; - color: #fff; - cursor: pointer; - z-index: 200; - display: none; - align-items: center; - justify-content: center; - box-shadow: 0 4px 18px rgba(196, 132, 58, 0.45); - transition: transform 0.12s, box-shadow 0.12s; - -webkit-tap-highlight-color: transparent; -} -#worlds-back.worlds-back-visible { display: flex; } -#worlds-back:active { transform: scale(0.92); box-shadow: 0 2px 8px rgba(196,132,58,0.3); } - -/* worlds-hidden bleibt für eventuelle andere Verwendung */ -.worlds-hidden { display: none !important; } - -/* ── JETZT WORLD ─────────────────────────────────────────── */ - -/* ═══════════════════════════════════════════════════════════════ - DREI WELTEN — PANORAMA-BILD + FROSTED GLASS CHIPS - ═══════════════════════════════════════════════════════════════ */ - -/* Bild auf dem Track: alle 3 Panels teilen dasselbe Panorama */ -#worlds-track { - background-size: 100% auto; - background-position: 0 40%; - background-repeat: no-repeat; -} - -/* World Panel: Gradient-Overlay — Mitte leuchtet, oben/unten gedämpft für Lesbarkeit */ -.world-panel { - background: linear-gradient( - to bottom, - rgba(0,0,0,0.52) 0%, - rgba(0,0,0,0.18) 28%, - rgba(0,0,0,0.14) 55%, - rgba(0,0,0,0.52) 100% - ); - display: flex; - flex-direction: column; - justify-content: space-between; /* Info oben, Chips unten */ - padding: calc(env(safe-area-inset-top, 0px) + 58px) 14px - calc(env(safe-area-inset-bottom, 0px) + 88px); - overflow-y: auto; - overflow-x: hidden; - -webkit-overflow-scrolling: touch; - overscroll-behavior-y: contain; -} - -/* Content-Divs füllen den Panel und verteilen Top/Bottom */ -#wj-content, #wh-content, #ww-content { - flex: 1; - display: flex; - flex-direction: column; - justify-content: space-between; - min-height: 100%; -} - -/* Oberer Bereich: Info + Reminders */ -.world-top { display: flex; flex-direction: column; gap: 10px; } - -/* Unterer Bereich: Chips (Daumen-Zone) */ -.world-bottom { display: flex; flex-direction: column; gap: 8px; } - -/* Frosted-Glass Info-Card (oben in jeder Welt) */ -.world-info-card { - background: rgba(0, 0, 0, 0.38); - backdrop-filter: blur(18px) saturate(1.6); - -webkit-backdrop-filter: blur(18px) saturate(1.6); - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 20px; - padding: 16px 18px; - color: white; - flex-shrink: 0; -} -.world-info-title { - font-size: var(--text-xl); - font-weight: 800; - line-height: 1.1; - color: white; -} -.world-info-sub { - font-size: var(--text-xs); - color: rgba(255, 255, 255, 0.65); - margin-top: 4px; -} - -/* Frosted-Glass Reminder-Card (für Streak, Alerts) */ -.world-reminder { - background: rgba(0, 0, 0, 0.32); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-radius: 16px; - padding: 12px 16px; - display: flex; - align-items: center; - gap: 12px; - cursor: pointer; - color: white; - flex-shrink: 0; -} -.world-reminder:active { background: rgba(0,0,0,0.55); } - -/* Chip-Grid: GLEICH auf allen drei Welten */ -.world-chips-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - grid-auto-rows: 80px; /* alle Chips gleich hoch */ - gap: 8px; - margin-top: auto; -} - -/* Einzelner Chip: Frosted Glass */ -.world-chip { - background: rgba(0, 0, 0, 0.35); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-radius: 16px; - padding: 14px 6px 11px; - text-align: center; - cursor: pointer; - display: flex; - flex-direction: column; - align-items: center; - gap: 7px; - color: white; - transition: background 0.12s, transform 0.1s; - -webkit-tap-highlight-color: transparent; - user-select: none; -} -.world-chip:active { - background: rgba(0, 0, 0, 0.6); - transform: scale(0.93); -} -.world-chip svg { color: white; } -.world-chip-label { - font-size: 10px; - font-weight: 600; - color: rgba(255, 255, 255, 0.9); - line-height: 1.2; -} - -/* Chip-Umrandung je Welt */ -#wp-jetzt .world-chip { border: 1px solid rgba(196, 132, 58, 0.55); } -#wp-hund .world-chip { border: 1px solid rgba(196, 132, 58, 0.65); } -#wp-welt .world-chip { border: 1px solid rgba(99, 130, 220, 0.55); } - -/* Sektion-Label über Chip-Gruppen */ -.world-section-label { - font-size: 9px; - font-weight: 800; - letter-spacing: 0.10em; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.45); - padding: 4px 2px 0; -} - -/* JETZT-Chip-Reihe: Streak | Gassirunde | Übung */ -.wj-chip-row { - display: flex; - gap: 8px; -} -.wj-chip { - flex: 1; - min-width: 0; - background: rgba(0, 0, 0, 0.32); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-radius: 14px; - padding: 10px 6px 9px; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - cursor: pointer; - -webkit-tap-highlight-color: transparent; - transition: background 0.12s; -} -.wj-chip:active { background: rgba(0, 0, 0, 0.52); } -.wj-chip-label { - font-size: 8px; - font-weight: 700; - letter-spacing: .08em; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.45); - line-height: 1; -} -.wj-chip-val { - font-size: 10px; - font-weight: 700; - color: white; - line-height: 1.25; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - -/* Footer-Links (Impressum / Die 100 / Datenschutz) */ -.world-footer-links { - text-align: center; - padding: 10px 0 2px; -} -.world-footer-links span { - font-size: 11px; - color: rgba(255, 255, 255, 0.6); - cursor: pointer; - letter-spacing: 0.05em; - text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); - text-decoration: underline; - text-underline-offset: 3px; - text-decoration-color: rgba(255, 255, 255, 0.3); - transition: color 0.15s; -} -.world-footer-links span:hover, -.world-footer-links span:active { - color: rgba(255, 255, 255, 0.9); -} - -/* Desktop-Sidebar + Zahnrad entfernt — Welten übernehmen Navigation */ -@media (min-width: 768px) { - #sidebar { display: none !important; } - #worlds-settings { display: none !important; } - #page-content { padding-left: 0 !important; } - .map-full-layout { left: 0 !important; } - .rk-map-section { left: 0 !important; } -} - -/* ── KEYFRAMES ───────────────────────────────────────────── */ -@keyframes pulse { - 0%, 100% { opacity: 0.5; } - 50% { opacity: 0.25; } -} diff --git a/backend/static/css/design-system.css b/backend/static/css/design-system.css index cc9c40f..5b6f1e0 100644 --- a/backend/static/css/design-system.css +++ b/backend/static/css/design-system.css @@ -105,9 +105,9 @@ --transition-slow: 320ms ease; /* Navigation */ - --nav-bottom-height: 78px; /* Welten-Zurück-FAB: 54px + 20px bottom + 4px Abstand */ + --nav-bottom-height: 64px; --nav-sidebar-width: 240px; - --header-height: 0px; /* Header entfernt — Welten-Navigation übernimmt */ + --header-height: 56px; /* Safe Areas (iPhone Notch/Home Indicator) */ --safe-top: env(safe-area-inset-top, 0px); diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css index 1a0a778..399a9c7 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -19,7 +19,6 @@ min-height: 0; /* iOS-Flex-Bug: ohne das scrollt body statt #page-content */ overflow-y: auto; overflow-x: hidden; - padding-top: var(--safe-top); padding-bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + 16px); -webkit-overflow-scrolling: touch; } diff --git a/backend/static/icons/founder.jpg b/backend/static/icons/founder.jpg deleted file mode 100644 index 5afbfe9..0000000 Binary files a/backend/static/icons/founder.jpg and /dev/null differ diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 2b8028e..3fcf69f 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -190,1441 +190,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index cb75a8f..21afd73 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -8,11 +8,6 @@ - - - - - @@ -93,9 +88,9 @@ - - - + + + @@ -163,9 +158,6 @@ -
    -
    -
    -
    - -
    -
    -
    - -
    -
    -
    - -
    -
    -
    - -
    -
    -
    - -
    -
    -
    - @@ -527,34 +480,6 @@ - -
    -
    - - - -
    -
    - JETZT - HUND - WELT -
    - -
    -
    -
    -
    -
    - -
    -
    - -
    - @@ -565,7 +490,6 @@ - diff --git a/backend/static/js/api.js b/backend/static/js/api.js index c6b26da..4abebef 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -6,84 +6,69 @@ const API = (() => { - // ---------------------------------------------------------- - // Request-Deduplication: gleiche GET-URL nur einmal in-flight - // ---------------------------------------------------------- - const _inflight = new Map(); - // ---------------------------------------------------------- // Interner HTTP-Kern // ---------------------------------------------------------- - async function _doRequest(method, path, body, attempt) { + async function _request(method, path, body = null, options = {}) { const config = { method, headers: { 'Content-Type': 'application/json' }, - credentials: 'include', + credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet }; if (body && !(body instanceof FormData)) { config.body = JSON.stringify(body); } else if (body instanceof FormData) { - delete config.headers['Content-Type']; + delete config.headers['Content-Type']; // Browser setzt multipart/form-data config.body = body; } + // JWT aus localStorage als Bearer (für API-Calls die das brauchen) const token = localStorage.getItem('by_token'); - if (token) config.headers['Authorization'] = `Bearer ${token}`; + if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } let response; try { response = await fetch(`/api${path}`, config); - } catch { - // Netzwerkfehler: bei GET bis zu 2 Retry-Versuche - if (method === 'GET' && attempt < 2) { - await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt))); - return _doRequest(method, path, body, attempt + 1); - } - const msg = 'Kein Internet — du bist offline.'; - if (window.UI?.toast) UI.toast.warning(msg, 4000); - throw new APIError(msg, 0, 'network'); + } catch (err) { + const offlineMsg = 'Kein Internet — du bist offline.'; + if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000); + throw new APIError(offlineMsg, 0, 'network'); } + // 204 No Content if (response.status === 204) return null; let data; - try { data = await response.json(); } catch { data = null; } + try { + data = await response.json(); + } catch { + data = null; + } if (!response.ok) { const message = data?.detail || data?.message || `Fehler ${response.status}`; - const isSwOffline = response.status === 503 && message.startsWith('Offline'); - - // Retry: GET auf echte 5xx (nicht SW-generierte Offline-503) - if (method === 'GET' && response.status >= 500 && !isSwOffline && attempt < 2) { - await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt))); - return _doRequest(method, path, body, attempt + 1); + // SW gibt bei Offline-Anfragen 503 + 'Offline — keine Verbindung.' zurück + const isOffline = response.status === 503 && message.startsWith('Offline'); + if (isOffline && window.UI && UI.toast) { + UI.toast.warning('Kein Internet — du bist offline.', 4000); } - - if (isSwOffline && window.UI?.toast) UI.toast.warning('Kein Internet — du bist offline.', 4000); - throw new APIError(message, response.status, isSwOffline ? 'network' : data?.code); + throw new APIError(message, response.status, isOffline ? 'network' : data?.code); } + // SW hat die Anfrage in die Offline-Queue eingereiht if (data?._queued) { - if (typeof UI !== 'undefined' && UI.toast) + if (typeof UI !== 'undefined' && UI.toast) { UI.toast.info('Offline gespeichert — wird automatisch synchronisiert'); + } return data; } return data; } - async function _request(method, path, body = null) { - // GET-Deduplication: laufende identische Anfragen zusammenfassen - if (method === 'GET') { - if (_inflight.has(path)) return _inflight.get(path); - const promise = _doRequest('GET', path, null, 0).finally(() => _inflight.delete(path)); - _inflight.set(path, promise); - return promise; - } - return _doRequest(method, path, body, 0); - } - // ---------------------------------------------------------- // Öffentliche HTTP-Methoden // ---------------------------------------------------------- @@ -210,17 +195,6 @@ const API = (() => { create(data) { return post('/tieraerzte', data); }, update(id, d) { return patch(`/tieraerzte/${id}`, d); }, osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); }, - myFavorite() { return get('/tieraerzte/my-favorite'); }, - toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); }, - }; - - // ---------------------------------------------------------- - // GESUNDHEITSDOKUMENTE - // ---------------------------------------------------------- - const healthDocs = { - list(dogId) { return get(`/health-docs?dog_id=${dogId}`); }, - upload(formData) { return upload('/health-docs/upload', formData); }, - delete(id) { return del(`/health-docs/${id}`); }, }; // ---------------------------------------------------------- @@ -441,9 +415,8 @@ const API = (() => { // WETTER // ---------------------------------------------------------- const weather = { - alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); }, - get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); }, - forecast(lat, lon) { return get(`/weather/forecast?lat=${lat}&lon=${lon}`); }, + alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); }, + get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); }, }; // ---------------------------------------------------------- @@ -739,7 +712,7 @@ const API = (() => { // Öffentliche API return { get, post, put, patch, del, upload, - auth, dogs, diary, health, tieraerzte, healthDocs, poison, + auth, dogs, diary, health, tieraerzte, poison, places, routes, walks, events, sitting, forum, lost, knigge, weather, push, friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes, breeder, litters, breederPhotos, zuchthunde, zuchtKi, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a248787..f1d577b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,8 +3,8 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt +const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; const App = (() => { @@ -70,12 +70,6 @@ const App = (() => { zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true }, 'zucht-profil': { title: 'Hunde-Profil', module: null }, gruender: { title: '100 Gründer', module: null }, - jobs: { title: 'Wir suchen dich', module: null }, - expenses: { title: 'Ausgaben', module: null, requiresAuth: true }, - recalls: { title: 'Rückrufe', module: null }, - adoption: { title: 'Adoption', module: null }, - playdate: { title: 'Playdate', module: null, requiresAuth: true }, - wetter: { title: 'Wetter', module: null }, }; // ---------------------------------------------------------- @@ -91,7 +85,6 @@ const App = (() => { sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null }, uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' }, notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null }, - playdate: { icon: 'paw-print', text: 'Finde Spielkameraden für deinen Hund in der Nähe und verabrede ein Treffen.', preview: null }, }; // ---------------------------------------------------------- @@ -99,7 +92,6 @@ const App = (() => { // ---------------------------------------------------------- function navigate(pageId, pushHistory = true, params = {}) { if (!pages[pageId]) return; - if (window.Worlds?._visible) window.Worlds.hide(); // Aktive Seite ausblenden document.querySelector('.page.active')?.classList.remove('active'); @@ -572,7 +564,7 @@ const App = (() => { banner.style.display = 'flex'; document.getElementById('verify-resend-btn')?.addEventListener('click', async () => { - await API.post('/auth/resend-verification', { email: state.user.email }); + await API.post('/auth/resend-verification', {}); UI.toast.success('Bestätigungs-Mail erneut gesendet.'); }, { once: true }); @@ -854,9 +846,6 @@ const App = (() => { const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome'; // Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc. navigate(state.user ? startPage : 'welcome', false, hashParams); - - // Drei Welten nach initialer Navigation starten (damit hide() in navigate() sie nicht gleich killt) - if (window.Worlds) window.Worlds.init(state); } async function _handleInvite(token) { @@ -930,8 +919,6 @@ const App = (() => { })(); -window.App = App; // Worlds kann App.navigate() aufrufen - // App starten document.addEventListener('DOMContentLoaded', () => { App.init(); diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 4e89f32..cd34154 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -14,12 +14,11 @@ window.Page_admin = (() => { { id: 'nutzer', label: 'Nutzer', icon: 'users' }, { id: 'moderation', label: 'Moderation', icon: 'shield-check' }, { id: 'zuchter', label: 'Züchter', icon: 'certificate' }, - { id: 'forum', label: 'Forum', icon: 'chat-circle-dots' }, + { id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' }, { id: 'social', label: 'Social Media', icon: 'camera' }, { id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'system', label: 'System', icon: 'gear' }, - { id: 'jobs', label: 'Scheduler', icon: 'clock' }, - { id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' }, + { id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'partner', label: 'Partner', icon: 'handshake' }, { id: 'outreach', label: 'Outreach', icon: 'envelope-simple' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, @@ -48,9 +47,6 @@ window.Page_admin = (() => { // ------------------------------------------------------------------ function _render() { _container.innerHTML = ` - -
    -
    ${TABS.map(t => ` @@ -76,68 +72,9 @@ window.Page_admin = (() => { }); }); - _renderActionItems(); _renderTab(); } - async function _renderActionItems() { - const el = _container.querySelector('#adm-action-items'); - if (!el) return; - let d; - try { d = await API.get('/admin/action-items'); } catch { return; } - - const items = [ - { key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' }, - { key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' }, - { key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' }, - { key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' }, - { key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' }, - ]; - - const open = items.filter(i => d[i.key] > 0); - const usersToday = d.users_today || 0; - - el.innerHTML = ` -
    - - ${UI.icon('check-square')} Zu erledigen - - ${open.length === 0 - ? ` - ${UI.icon('check-circle')} Alles erledigt - ` - : open.map(i => ` - `).join('') - } - - ${UI.icon('user-plus')} ${usersToday} neue Nutzer heute - -
    `; - - el.querySelectorAll('[data-action-tab]').forEach(btn => { - btn.addEventListener('click', () => { - _tab = btn.dataset.actionTab; - _container.querySelectorAll('#adm-tabs .by-tab').forEach(b => - b.classList.toggle('active', b.dataset.tab === _tab) - ); - _renderTab(); - }); - }); - } - async function _renderTab() { const el = _container.querySelector('#adm-content'); if (!el) return; @@ -156,7 +93,6 @@ window.Page_admin = (() => { case 'partner': await _renderPartner(el); break; case 'outreach': await _renderOutreach(el); break; case 'audit': await _renderAudit(el); break; - case 'bewerbungen': await _renderBewerbungen(el); break; } } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); @@ -1460,43 +1396,6 @@ window.Page_admin = (() => { // ------------------------------------------------------------------ // TAB: MODERATION // ------------------------------------------------------------------ - function _ageLabel(createdAt) { - if (!createdAt) return ''; - const h = (Date.now() - new Date(createdAt + 'Z').getTime()) / 3600000; - const overdue = h >= 24; - const label = h < 1 ? '<1h' : h < 24 ? `${Math.floor(h)}h` : `${Math.floor(h/24)}d ${Math.floor(h%24)}h`; - return ` - ${overdue ? '⚠️ ' : ''}${label} - `; - } - - function _historySection(label, items, renderItem) { - const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`; - return ` -
    - - ${UI.icon('clock-countdown')} ${items.length} erledigte ${label} - - -
    - ${items.map(item => ` -
    - ${renderItem(item)} -
    `).join('')} -
    -
    `; - } - async function _renderModeration(el) { el.innerHTML = `
    @@ -1511,52 +1410,12 @@ window.Page_admin = (() => { async function _loadModeration(el) { el.innerHTML = `
    Lade…
    `; - const [zuchter, fotos, reports, poiEdits] = await Promise.all([ + const [zuchter, fotos] = await Promise.all([ API.get('/wiki/zuchter/pending').catch(() => []), API.get('/wiki/foto-submissions').catch(() => []), - API.get('/moderation/reports').catch(() => []), - API.get('/moderation/poi-edits').catch(() => []), ]); - const zuchterPending = zuchter.filter(z => !z.verified); - const zuchterDone = zuchter.filter(z => z.verified); - const fotosPending = fotos.filter(f => f.status === 'pending'); - const fotosDone = fotos.filter(f => f.status !== 'pending'); - const reportsPending = reports.filter(r => !r.resolved); - const reportsDone = reports.filter(r => r.resolved); - const poiPending = poiEdits.filter(e => e.status === 'pending'); - const poiDone = poiEdits.filter(e => e.status !== 'pending'); - const modItems = [ - { label: 'Züchter-Einreichungen', count: zuchterPending.length, icon: 'certificate' }, - { label: 'Foto-Einreichungen', count: fotosPending.length, icon: 'image' }, - { label: 'Forum-Meldungen', count: reportsPending.length, icon: 'warning' }, - { label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' }, - ].filter(i => i.count > 0); - - let html = ` -
    - - ${UI.icon('check-square')} Zu erledigen - - ${modItems.length === 0 - ? ` - ${UI.icon('check-circle')} Alles erledigt - ` - : modItems.map(i => ` - - ${UI.icon(i.icon)} ${i.label} - ${i.count} - `).join('') - } -
    `; + let html = ''; // --- Züchter-Einreichungen --- html += `

    Züchter-Einreichungen ${zuchterPending.length} + padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchter.length}

    `; - if (!zuchterPending.length) { - html += `

    Keine ausstehenden Einreichungen.

    `; + if (!zuchter.length) { + html += `

    Keine ausstehenden Einreichungen.

    `; } else { - html += `
    + html += `
    - + - ${zuchterPending.map((z, i) => ` + ${zuchter.map((z, i) => ` - `).join('')}
    RasseName / ZwingernameOrtVDHAlterWebsiteOrtVDHWebsite
    ${_esc(z.rasse_slug)} ${_esc(z.name)}${z.zwingername ? `
    ${_esc(z.zwingername)}` : ''}
    ${_esc([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))} ${z.vdh_mitglied ? ` VDH` : '—'}${_ageLabel(z.created_at)} ${z.website ? `Link` : '—'} @@ -1590,10 +1448,6 @@ window.Page_admin = (() => {
    `; } - // Züchter-History - if (zuchterDone.length) html += _historySection('Züchter-Einreichungen', zuchterDone, - z => `${_esc(z.name)} · ${_esc(z.rasse_slug)} · - ${UI.icon('check-circle')} ${_esc(z.verified_by_name||'?')} · ${(z.verified_at||'').slice(0,10)}`); // --- Wiki-Foto-Einreichungen --- html += `

    Wiki-Foto-Einreichungen ${fotosPending.length} + padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotos.length}

    `; - if (!fotosPending.length) { - html += `

    Keine ausstehenden Foto-Einreichungen.

    `; + if (!fotos.length) { + html += `

    Keine ausstehenden Foto-Einreichungen.

    `; } else { - html += `
    - ${fotosPending.map(f => ` + html += `
    + ${fotos.map(f => `
    ${_esc(f.rasse_name)}
    -
    von ${_esc(f.user_name)}
    -
    ${_ageLabel(f.created_at)}
    +
    von ${_esc(f.user_name)}
    ${f.aktuell_foto ? `Aktuell @@ -1627,111 +1480,6 @@ window.Page_admin = (() => {
    `; } - // Fotos-History - if (fotosDone.length) html += _historySection('Foto-Einreichungen', fotosDone, - f => ` - ${_esc(f.rasse_name||'?')} · von ${_esc(f.user_name||'?')} · - ${f.status==='approved' ? `${UI.icon('check-circle')} genehmigt` : `${UI.icon('x-circle')} abgelehnt`} - ${f.reviewed_by_name ? ` von ${_esc(f.reviewed_by_name)}` : ''} · ${(f.reviewed_at||'').slice(0,10)}`); - - // --- Forum-Meldungen --- - html += `

    - Forum-Meldungen - - ${reportsPending.length} - -

    `; - if (!reportsPending.length) { - html += `

    Keine offenen Meldungen.

    `; - } else { - html += `
    - ${reportsPending.map(r => ` -
    -
    -
    -
    - ${_esc(r.target_type)} #${r.target_id} · Gemeldet von ${_esc(r.melder_name || '?')} - ${_ageLabel(r.created_at)} -
    -
    - Grund: ${_esc(r.grund)} -
    - ${r.content_preview ? ` -
    ${_esc(r.content_preview)}
    ` : ''} -
    - -
    -
    `).join('')} -
    `; - } - - // Meldungen-History - if (reportsDone.length) html += _historySection('Forum-Meldungen', reportsDone, - r => `${_esc(r.target_type)} #${r.target_id} · ${_esc(r.grund)} · Gemeldet von ${_esc(r.melder_name||'?')} · - ${UI.icon('check-circle')} ${_esc(r.resolved_by_name||'?')} · ${(r.resolved_at||'').slice(0,10)}`); - - // --- POI-Korrekturen --- - html += `

    - POI-Korrekturen - - ${poiPending.length} - -

    `; - if (!poiPending.length) { - html += `

    Keine ausstehenden POI-Korrekturen.

    `; - } else { - html += `
    - - - - - - - - - - - - ${poiPending.map((e, i) => ` - - - - - - - - - `).join('')} - -
    OrtFeldAltNeuVonAlter
    ${_esc(e.poi_name || `OSM #${e.osm_id}`)}${_esc(e.field)}${_esc(e.old_value || '—')}${_esc(e.new_value || '—')}${_esc(e.einreicher_name || '?')}${_ageLabel(e.created_at)} - - -
    -
    `; - } - // POI-History - if (poiDone.length) html += _historySection('POI-Korrekturen', poiDone, - e => `${_esc(e.poi_name||`OSM #${e.osm_id}`)} · - ${_esc(e.field)}: - ${_esc(e.old_value||'—')} → - ${_esc(e.new_value||'—')} · - ${e.status==='approved' ? `${UI.icon('check-circle')} freigegeben` : `${UI.icon('x-circle')} abgelehnt`} - ${e.mod_name ? ` von ${_esc(e.mod_name)}` : ''} · ${(e.resolved_at||'').slice(0,10)}`); - el.innerHTML = html; // Züchter freigeben @@ -1770,41 +1518,6 @@ window.Page_admin = (() => { await _loadModeration(el); }); }); - - // Forum-Meldung erledigen - el.querySelectorAll('.adm-mod-resolve').forEach(btn => { - btn.addEventListener('click', async () => { - btn.disabled = true; - try { - await API.patch(`/moderation/reports/${btn.dataset.rid}`, {}); - await _loadModeration(el); - } catch (e) { UI.toast.error(e.message); btn.disabled = false; } - }); - }); - - // POI-Korrektur freigeben - el.querySelectorAll('.adm-poi-approve').forEach(btn => { - btn.addEventListener('click', async () => { - btn.disabled = true; - try { - await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'approve' }); - UI.toast.success('Korrektur übernommen.'); - await _loadModeration(el); - } catch (e) { UI.toast.error(e.message); btn.disabled = false; } - }); - }); - - // POI-Korrektur ablehnen - el.querySelectorAll('.adm-poi-reject').forEach(btn => { - btn.addEventListener('click', async () => { - btn.disabled = true; - try { - await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'reject' }); - UI.toast.success('Korrektur abgelehnt.'); - await _loadModeration(el); - } catch (e) { UI.toast.error(e.message); btn.disabled = false; } - }); - }); } // ------------------------------------------------------------------ @@ -2419,10 +2132,8 @@ window.Page_admin = (() => { - ${log.map((l, i) => ` - + ${log.map(l => ` + ${accountBadge(l.from_account)} ${_esc(l.recipient)} ${_esc(l.subject)} @@ -2436,28 +2147,6 @@ window.Page_admin = (() => {
    `; - // Log-Zeile: Mail-Inhalt anzeigen - el.querySelectorAll('tr[data-log-idx]').forEach(row => { - row.addEventListener('click', () => { - const l = log[Number(row.dataset.logIdx)]; - if (!l) return; - UI.modal.open({ - title: _esc(l.subject), - body: ` -
    - An: ${_esc(l.recipient)}  ·  - Von: ${_esc(l.from_account)}@banyaro.app  ·  - ${(l.sent_at||'').slice(0,16).replace('T',' ')} -
    -
    ${_esc(l.body || '(kein Text gespeichert)')}
    `, - footer: ``, - }); - }); - }); - // Vorlage in Compose laden function _loadTplIntoCompose(id) { const tpl = templates.find(t => t.id === id); @@ -2686,129 +2375,6 @@ window.Page_admin = (() => { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } - // ------------------------------------------------------------------ - // BEWERBUNGEN — Social-Media-Job - // ------------------------------------------------------------------ - async function _renderBewerbungen(el) { - let _statusFilter = 'pending'; - - async function _load() { - el.innerHTML = ` -
    - ${['pending','reviewing','accepted','rejected','alle'].map(s => ` - `).join('')} -
    -
    ${UI.skeleton(3)}
    `; - - el.querySelectorAll('.adm-bew-filter').forEach(btn => { - btn.addEventListener('click', () => { - _statusFilter = btn.dataset.s; - _load(); - }); - }); - - try { - const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`); - const list = el.querySelector('#adm-bew-list'); - if (!rows.length) { - list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.'); - return; - } - list.innerHTML = rows.map(r => ` -
    -
    -
    -
    ${_esc(r.name)} - ${r.username ? `(@${_esc(r.username)})` : ''} -
    -
    - ${_esc(r.email)} · @${_esc(r.social_handle||'—')} - ${r.dog_name ? ` · 🐕 ${_esc(r.dog_name)} (${_esc(r.dog_rasse||'')})` : ''} -
    -
    - ${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge -
    -
    - ${_esc((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''} -
    -
    -
    - - -
    -
    -
    `).join(''); - - list.querySelectorAll('.adm-bew-status').forEach(sel => { - sel.addEventListener('change', async () => { - const id = sel.dataset.id; - await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value }); - UI.toast.success('Status aktualisiert.'); - setTimeout(_load, 500); - }); - }); - - list.querySelectorAll('.adm-bew-view').forEach(btn => { - btn.addEventListener('click', async () => { - const id = btn.dataset.id; - const app = await API.get(`/jobs/admin/applications/${id}`); - const docsHtml = app.docs?.length - ? app.docs.map(d => ` - 📎 ${_esc(d.filename)}`).join('') - : 'Keine Anhänge'; - - UI.modal.open({ - title: `Bewerbung — ${_esc(app.name)}`, - body: ` -
    -
    E-Mail: ${_esc(app.email)}
    -
    Social: @${_esc(app.social_handle||'—')}
    - ${app.dog_name ? `
    Hund: ${_esc(app.dog_name)} (${_esc(app.dog_rasse||'')})
    ` : ''} -
    Motivation:
    -
    ${_esc(app.motivation)}
    -
    -
    Anhänge:
    ${docsHtml}
    -
    - Admin-Notiz: - -
    -
    `, - footer: ` - - `, - }); - document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => { - const note = document.getElementById('adm-bew-note')?.value || ''; - await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note }); - UI.toast.success('Notiz gespeichert.'); - UI.modal.close(); - }); - }); - }); - } catch (e) { - el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message); - } - } - - await _load(); - } - // ------------------------------------------------------------------ return { init, refresh, onDogChange }; diff --git a/backend/static/js/pages/adoption.js b/backend/static/js/pages/adoption.js deleted file mode 100644 index b20682e..0000000 --- a/backend/static/js/pages/adoption.js +++ /dev/null @@ -1,958 +0,0 @@ -/* ============================================================ - BAN YARO — Adoption (Tierheim-Hunde in der Nähe) - Seiten-Modul: Hunde aus deutschen Tierheimen finden. - ============================================================ */ - -window.Page_adoption = (() => { - - // ---------------------------------------------------------- - // MODUL-STATE - // ---------------------------------------------------------- - let _container = null; - let _appState = null; - let _lat = null; - let _lon = null; - let _radius = 50; - let _rasseFilter = ''; - let _activeTab = 'hunde'; - let _data = null; // { animals, shelters, has_petfinder } - let _loading = false; - let _communityData = null; // [] listings from /adoption/community - let _myListings = null; // [] eigene Inserate - - // ---------------------------------------------------------- - // INIT - // ---------------------------------------------------------- - async function init(container, appState) { - _container = container; - _appState = appState; - _render(); - // Standort automatisch versuchen - _tryAutoLocate(); - } - - // ---------------------------------------------------------- - // REFRESH - // ---------------------------------------------------------- - async function refresh() { - if (_lat && _lon) { - await _loadData(); - } - } - - // ---------------------------------------------------------- - // RENDER — Grundstruktur - // ---------------------------------------------------------- - function _render() { - _container.innerHTML = ` - -
    - - - -
    - - - - - -
    - - - -
    - - -
    - ${UI.skeleton(4)} -
    - `; - - // Events - _container.querySelector('#adp-radius') - ?.addEventListener('change', e => { - _radius = parseInt(e.target.value); - if (_lat && _lon) _loadData(); - }); - - _container.querySelector('#adp-rasse') - ?.addEventListener('input', e => { - _rasseFilter = e.target.value.trim().toLowerCase(); - _renderContent(); - }); - - _container.querySelector('#adp-btn-locate') - ?.addEventListener('click', _locateUser); - - _container.querySelector('#adp-btn-geocode') - ?.addEventListener('click', _geocodePLZ); - - _container.querySelector('#adp-plz') - ?.addEventListener('keydown', e => { - if (e.key === 'Enter') _geocodePLZ(); - }); - - _container.querySelectorAll('.adp-tab').forEach(btn => { - btn.addEventListener('click', () => { - _activeTab = btn.dataset.tab; - _container.querySelectorAll('.adp-tab').forEach(b => { - const isActive = b.dataset.tab === _activeTab; - b.style.color = isActive ? 'var(--c-primary)' : 'var(--c-text-secondary)'; - b.style.fontWeight = isActive ? '600' : 'normal'; - b.style.borderBottom = isActive ? '2px solid var(--c-primary)' : '2px solid transparent'; - }); - _renderContent(); - }); - }); - } - - // ---------------------------------------------------------- - // STANDORT AUTOMATISCH ERMITTELN - // ---------------------------------------------------------- - async function _tryAutoLocate() { - try { - const pos = await API.getLocation({ timeout: 6000, maximumAge: 300_000 }); - _lat = pos.lat; - _lon = pos.lon; - await _loadData(); - } catch { - // Standort nicht verfügbar → PLZ-Eingabe zeigen - document.getElementById('adp-plz-row')?.style.setProperty('display', 'flex', 'important'); - document.getElementById('adp-plz-row').style.display = 'flex'; - _showNoLocation(); - } - } - - async function _locateUser() { - const btn = _container.querySelector('#adp-btn-locate'); - if (btn) btn.disabled = true; - try { - const pos = await API.getLocation({ timeout: 10000 }); - _lat = pos.lat; - _lon = pos.lon; - document.getElementById('adp-plz-row').style.display = 'none'; - await _loadData(); - } catch { - UI.toast.error('Standort konnte nicht ermittelt werden. Bitte PLZ eingeben.'); - document.getElementById('adp-plz-row').style.display = 'flex'; - } finally { - if (btn) btn.disabled = false; - } - } - - async function _geocodePLZ() { - const plz = (_container.querySelector('#adp-plz')?.value || '').trim(); - if (!plz) return; - const btn = _container.querySelector('#adp-btn-geocode'); - if (btn) btn.disabled = true; - try { - const geo = await API.get(`/adoption/geocode?plz=${encodeURIComponent(plz)}`); - if (geo.lat && geo.lon) { - _lat = geo.lat; - _lon = geo.lon; - await _loadData(); - } else { - UI.toast.error(`PLZ "${plz}" nicht gefunden.`); - } - } catch { - UI.toast.error('Geocoding fehlgeschlagen. Bitte erneut versuchen.'); - } finally { - if (btn) btn.disabled = false; - } - } - - // ---------------------------------------------------------- - // DATEN LADEN - // ---------------------------------------------------------- - async function _loadData() { - if (_loading || !_lat || !_lon) return; - _loading = true; - const content = _container.querySelector('#adp-content'); - if (content) content.innerHTML = UI.skeleton(4); - try { - _data = await API.get(`/adoption/nearby?lat=${_lat}&lon=${_lon}&radius=${_radius}`); - _renderContent(); - } catch { - if (content) content.innerHTML = UI.emptyState({ - icon: 'warning', - title: 'Daten konnten nicht geladen werden', - text: 'Bitte versuche es erneut.', - }); - } finally { - _loading = false; - } - } - - async function _loadCommunity() { - const content = _container.querySelector('#adp-content'); - if (content) content.innerHTML = UI.skeleton(4); - try { - const url = _lat && _lon - ? `/adoption/community?lat=${_lat}&lon=${_lon}` - : '/adoption/community'; - _communityData = await API.get(url); - if (_appState?.user) { - try { - _myListings = await API.get('/adoption/community/my'); - } catch { - _myListings = []; - } - } - _renderCommunity(content); - } catch { - if (content) content.innerHTML = UI.emptyState({ - icon: 'warning', - title: 'Weitervermittlungs-Inserate konnten nicht geladen werden', - text: 'Bitte versuche es erneut.', - }); - } - } - - // ---------------------------------------------------------- - // INHALT RENDERN (je nach Tab) - // ---------------------------------------------------------- - function _renderContent() { - const content = _container.querySelector('#adp-content'); - if (!content) return; - - if (_activeTab === 'community') { - _loadCommunity(); - return; - } - - if (!_data) { _showNoLocation(); return; } - - if (_activeTab === 'hunde') _renderHunde(content); - else _renderTierheime(content); - } - - function _showNoLocation() { - const content = _container.querySelector('#adp-content'); - if (!content) return; - content.innerHTML = ` -
    -
    🐾
    -

    Finde Hunde in deiner Nähe

    -

    - Erlaube den Zugriff auf deinen Standort oder gib eine PLZ ein, um Tierheim-Hunde - in deiner Umgebung zu finden. -

    - - ${UI.icon('arrow-square-out')} Alle Hunde auf Tierheimhelden.de - -
    - `; - } - - // ------------------------------------------------------------------ - // TAB: HUNDE - // ------------------------------------------------------------------ - function _renderHunde(content) { - let animals = (_data?.animals || []); - - // Rasse-Filter - if (_rasseFilter) { - animals = animals.filter(a => - (a.rasse || '').toLowerCase().includes(_rasseFilter) || - (a.name || '').toLowerCase().includes(_rasseFilter) - ); - } - - const hasPetFinder = _data?.has_petfinder; - const infoText = hasPetFinder - ? `${animals.length} Hunde im Umkreis von ${_radius} km (via PetFinder)` - : ''; - - if (!animals.length) { - content.innerHTML = ` -

    - ${_rasseFilter ? `Keine Hunde gefunden für "${_esc(_rasseFilter)}"` : `Keine Hunde im Umkreis von ${_radius} km gefunden.`} -

    -
    - - ${UI.icon('arrow-square-out')} Hunde auf Tierheimhelden.de suchen - - - ${UI.icon('magnifying-glass')} Tierheimsuche auf tierschutz.com - -
    -

    - Tipp: Schau auch im Tab „Tierheime" nach lokalen Tierheimen direkt. -

    - `; - return; - } - - content.innerHTML = ` - ${infoText ? `

    ${infoText}

    ` : ''} -
    - ${animals.map(a => _animalCard(a)).join('')} -
    -
    -

    - Mehr Hunde finden: -

    - - ${UI.icon('arrow-square-out')} Tierheimhelden.de — alle Hunde - -
    - `; - - // Klick-Events - content.querySelectorAll('[data-adp-url]').forEach(card => { - card.addEventListener('click', () => { - window.open(card.dataset.adpUrl, '_blank', 'noopener,noreferrer'); - }); - }); - } - - function _animalCard(a) { - const foto = a.foto_url - ? `${_esc(a.name)}` - : '
    🐶
    '; - - const distTxt = a.distanz_km != null ? `${a.distanz_km} km` : ''; - const alterTxt = a.alter_jahre != null ? `${_formatAlter(a.alter_jahre)}` : ''; - const rasseTxt = a.rasse || ''; - const tierheim = a.tierheim || ''; - - return ` -
    -
    - ${foto} -
    -
    -
    - ${_esc(a.name)} -
    - ${rasseTxt ? `
    - ${_esc(rasseTxt)} -
    ` : ''} -
    - ${alterTxt ? ` - ${_esc(alterTxt)} - ` : ''} - ${a.geschlecht ? ` - ${a.geschlecht === 'männlich' ? '♂' : '♀'} - ` : ''} - ${distTxt ? ` - ${_esc(distTxt)} - ` : ''} -
    - ${tierheim ? `
    - ${UI.icon('house-line')} ${_esc(tierheim)} -
    ` : ''} -
    -
    - `; - } - - // ------------------------------------------------------------------ - // TAB: TIERHEIME - // ------------------------------------------------------------------ - function _renderTierheime(content) { - const shelters = _data?.shelters || []; - - if (!shelters.length) { - content.innerHTML = ` -
    -

    - Keine Tierheime im Umkreis von ${_radius} km gefunden. -

    - - ${UI.icon('arrow-square-out')} Tierheimhelden.de - -
    - `; - return; - } - - content.innerHTML = ` -

    - ${shelters.length} Tierheim${shelters.length !== 1 ? 'e' : ''} im Umkreis von ${_radius} km -

    -
    - ${shelters.map(s => _shelterRow(s)).join('')} -
    -
    -

    - Noch mehr Tierheime: -

    -
    - - ${UI.icon('arrow-square-out')} Tierheimhelden.de - - - ${UI.icon('magnifying-glass')} tierschutz.com - -
    -
    - `; - } - - function _shelterRow(s) { - return ` - -
    - 🏠 -
    -
    -
    - ${_esc(s.name)} -
    -
    - ${_esc(s.plz)} ${_esc(s.stadt)} -
    -
    -
    - - ${s.distanz_km} km - - Hunde ansehen ${UI.icon('arrow-right')} -
    -
    - `; - } - - // ------------------------------------------------------------------ - // TAB: WEITERVERMITTLUNG (Community) - // ------------------------------------------------------------------ - function _renderCommunity(content) { - if (!content) return; - - const listings = _communityData || []; - const isLoggedIn = !!_appState?.user; - - const fabHtml = isLoggedIn ? ` - - ` : ''; - - if (!listings.length) { - content.innerHTML = ` -
    -
    🐾
    -

    Noch keine Hunde zur Weitervermittlung

    -

    - Hier können Halter Hunde privat zur Weitervermittlung anbieten — - zum Beispiel bei Umzug, Krankheit oder Allergie. -

    - ${isLoggedIn ? ` - - ` : ` -

    - Bitte anmelden, um ein Inserat zu erstellen. -

    - `} -
    - ${fabHtml} - `; - content.querySelector('#adp-empty-create')?.addEventListener('click', _openCreateModal); - content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal); - return; - } - - // Eigene Inserate trennen - const myIds = new Set((_myListings || []).map(l => l.id)); - - content.innerHTML = ` -

    - ${listings.length} Inserat${listings.length !== 1 ? 'e' : ''} zur Weitervermittlung -

    -
    - ${listings.map(l => _communityCard(l)).join('')} -
    - - ${isLoggedIn && _myListings && _myListings.length ? ` -
    -

    Meine Inserate

    -
    - ${_myListings.map(l => _myListingRow(l)).join('')} -
    -
    - ` : ''} - - ${fabHtml} - `; - - // Interest-Button Events - content.querySelectorAll('[data-adp-interest]').forEach(btn => { - btn.addEventListener('click', () => { - const id = btn.dataset.adpInterest; - const interested = btn.dataset.adpInterested === 'true'; - _handleInterest(id, interested, btn); - }); - }); - - // FAB - content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal); - - // Meine Inserate: Status-Dropdown + Löschen - content.querySelectorAll('[data-adp-status-change]').forEach(sel => { - sel.addEventListener('change', async () => { - const id = sel.dataset.adpStatusChange; - try { - await API.patch(`/adoption/community/${id}`, { status: sel.value }); - UI.toast.success('Status aktualisiert.'); - _loadCommunity(); - } catch { - UI.toast.error('Status konnte nicht aktualisiert werden.'); - } - }); - }); - - content.querySelectorAll('[data-adp-delete]').forEach(btn => { - btn.addEventListener('click', async () => { - if (!window.confirm('Inserat wirklich löschen?')) return; - try { - await API.del(`/adoption/community/${btn.dataset.adpDelete}`); - UI.toast.success('Inserat gelöscht.'); - _communityData = null; - _myListings = null; - _loadCommunity(); - } catch { - UI.toast.error('Löschen fehlgeschlagen.'); - } - }); - }); - } - - function _communityCard(l) { - const foto = l.foto_url - ? `${_esc(l.name)}` - : '
    🐾
    '; - - const isActive = !l.status || l.status === 'active'; - const statusLabel = l.status === 'reserved' ? 'Reserviert' - : l.status === 'adopted' ? 'Vermittelt' - : ''; - - const alterLabel = l.alter_kategorie === 'welpe' ? 'Welpe <6Mo' - : l.alter_kategorie === 'jung' ? 'Jung 6Mo–2J' - : l.alter_kategorie === 'adult' ? 'Adult 2–8J' - : l.alter_kategorie === 'senior' ? 'Senior >8J' - : ''; - - const genderIcon = l.geschlecht === 'maennlich' ? '♂' - : l.geschlecht === 'weiblich' ? '♀' - : ''; - - const distTxt = l.distanz_km != null ? `${l.distanz_km} km` : ''; - const ort = [l.plz, l.ort].filter(Boolean).join(' '); - - const interestBtn = l.user_interested - ? `` - : ``; - - return ` -
    - -
    - ${foto} - ${!isActive ? ` -
    - - ${_esc(statusLabel)} - -
    - ` : ''} -
    - -
    -
    - ${_esc(l.name)} -
    - ${l.rasse ? `
    - ${_esc(l.rasse)} -
    ` : ''} - -
    - ${alterLabel ? ` - ${_esc(alterLabel)} - ` : ''} - ${genderIcon ? ` - ${genderIcon} - ` : ''} - ${distTxt ? ` - ${_esc(distTxt)} - ` : ''} -
    - ${ort ? `
    ${_esc(ort)}
    ` : ''} - ${l.beschreibung ? `
    - ${_esc(l.beschreibung)} -
    ` : ''} - ${l.interesse_count ? `
    - ❤️ ${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''} -
    ` : ''} -
    - ${interestBtn} -
    -
    -
    - `; - } - - function _myListingRow(l) { - const statusOptions = [ - { value: 'active', label: 'Aktiv' }, - { value: 'reserved', label: 'Reserviert' }, - { value: 'adopted', label: 'Vermittelt' }, - ]; - return ` -
    -
    -
    - ${_esc(l.name)} -
    -
    - ${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''} -
    -
    - - -
    - `; - } - - // ------------------------------------------------------------------ - // INTERESSE BEKUNDEN / ZURÜCKZIEHEN - // ------------------------------------------------------------------ - async function _handleInterest(id, isInterested, btn) { - if (!_appState?.user) { - UI.toast.error('Bitte anmelden um Interesse zu bekunden.'); - return; - } - - if (isInterested) { - // Interesse zurückziehen - try { - btn.disabled = true; - await API.del(`/adoption/community/${id}/interest`); - UI.toast.success('Interesse zurückgezogen.'); - _communityData = null; - _myListings = null; - _loadCommunity(); - } catch { - UI.toast.error('Fehler beim Zurückziehen des Interesses.'); - btn.disabled = false; - } - return; - } - - // Interesse bekunden — Modal mit optionaler Nachricht - const body = ` -
    -

    - Du kannst optional eine Nachricht an den Anbieter schicken. -

    -
    - - -
    -
    - `; - const footer = ` -
    - - -
    - `; - - UI.modal.open({ title: 'Interesse bekunden', body, footer }); - - document.getElementById('adp-interest-form')?.addEventListener('submit', async e => { - e.preventDefault(); - const submitBtn = document.getElementById('adp-interest-submit'); - const fd = new FormData(e.target); - const payload = { nachricht: fd.get('nachricht') || null }; - if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } - try { - await API.post(`/adoption/community/${id}/interest`, payload); - UI.modal.close(); - UI.toast.success('Interesse gemeldet!'); - _communityData = null; - _myListings = null; - _loadCommunity(); - } catch { - UI.toast.error('Fehler beim Melden des Interesses.'); - if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = `${UI.icon('heart')} Interesse bekunden`; } - } - }); - } - - // ------------------------------------------------------------------ - // INSERAT ERSTELLEN — Modal - // ------------------------------------------------------------------ - function _openCreateModal() { - if (!_appState?.user) { - UI.toast.error('Bitte anmelden um ein Inserat zu erstellen.'); - return; - } - - const body = ` -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    Mindestens 80 Zeichen
    -
    -
    - - -
    -
    - - -
    -
    - `; - - const footer = ` -
    - - -
    - `; - - UI.modal.open({ title: 'Hund zur Vermittlung anbieten', body, footer }); - - document.getElementById('adp-create-form')?.addEventListener('submit', async e => { - e.preventDefault(); - const submitBtn = document.getElementById('adp-create-submit'); - const fd = new FormData(e.target); - - // Mindestlänge Beschreibung manuell prüfen (minlength gilt nur für text) - const beschreibung = (fd.get('beschreibung') || '').trim(); - if (beschreibung.length < 80) { - UI.toast.error('Beschreibung muss mindestens 80 Zeichen lang sein.'); - return; - } - - // FormData für multipart aufbauen - const postData = new FormData(); - postData.append('name', fd.get('name') || ''); - postData.append('rasse', fd.get('rasse') || ''); - postData.append('alter_kategorie', fd.get('alter_kategorie') || ''); - postData.append('geschlecht', fd.get('geschlecht') || ''); - postData.append('plz', fd.get('plz') || ''); - postData.append('ort', fd.get('ort') || ''); - postData.append('beschreibung', beschreibung); - postData.append('hintergrund', fd.get('hintergrund') || ''); - if (_lat) postData.append('lat', _lat); - if (_lon) postData.append('lon', _lon); - const fotoFile = document.getElementById('adp-create-foto')?.files?.[0]; - if (fotoFile) postData.append('foto', fotoFile); - - if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } - try { - await API.upload('/adoption/community', postData); - UI.modal.close(); - UI.toast.success('Inserat erstellt!'); - _communityData = null; - _myListings = null; - _loadCommunity(); - } catch (err) { - UI.toast.error(err.message || 'Inserat konnte nicht erstellt werden.'); - if (submitBtn) { - submitBtn.disabled = false; - submitBtn.innerHTML = `${UI.icon('plus')} Inserat erstellen`; - } - } - }); - } - - // ---------------------------------------------------------- - // HILFSFUNKTIONEN - // ---------------------------------------------------------- - function _formatAlter(jahre) { - if (jahre < 0.5) return 'Welpe'; - if (jahre < 1) return 'Jungtier'; - if (jahre < 2) return `${Math.round(jahre)} Jahr`; - if (jahre < 10) return `${Math.round(jahre)} Jahre`; - return 'Senior'; - } - - function _esc(s) { - if (s == null) return ''; - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - - // ---------------------------------------------------------- - // PUBLIC API - // ---------------------------------------------------------- - return { init, refresh }; - -})(); diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 82a2e8a..953232e 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -187,14 +187,16 @@ window.Page_dog_profile = (() => { ${!dog.is_guest ? `` : ''} - ${!dog.is_guest ? ` + ${!dog.is_guest ? `` : ''} - ${!dog.is_guest ? `` : ''} +
    ${!dog.is_guest ? `` : ''} @@ -207,8 +209,7 @@ window.Page_dog_profile = (() => {
    Sitter-Zugang
    - Gib einem Freund temporären Schreibzugang für diesen Hund. - Deine bestehenden Daten und Medien bleiben unsichtbar und privat — der Sitter kann nur neue Einträge anlegen. + Gib einem Freund temporären Schreibzugang für diesen Hund
    Lade…
    @@ -256,12 +257,12 @@ window.Page_dog_profile = (() => { _showChipEdit(dog); }); - document.getElementById('dp-share-btn')?.addEventListener('click', () => { - _showShareModal(dog); + document.getElementById('dp-ausweis-btn')?.addEventListener('click', () => { + _showAusweisModal(dog.id); }); - document.getElementById('dp-passport-btn')?.addEventListener('click', () => { - _showPassportModal(dog); + document.getElementById('dp-share-btn')?.addEventListener('click', () => { + _showShareModal(dog); }); // Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig. @@ -744,7 +745,13 @@ window.Page_dog_profile = (() => { // AUSWEIS // ---------------------------------------------------------- function _showAusweisModal(dogId) { - window.open(`/ausweis/${dogId}`, '_blank', 'noopener'); + UI.modal.open({ + title: 'Heimtierausweis', + body: ``, + footer: ` + ${UI.icon('printer')} Drucken`, + size: 'fullscreen', + }); } // ---------------------------------------------------------- @@ -989,7 +996,7 @@ window.Page_dog_profile = (() => {
    -
    +
    - - -
    -
    - Foto hochladen um die Rasse per KI zu erkennen
    @@ -1089,9 +1086,6 @@ window.Page_dog_profile = (() => { }); } - // Rassen-Erkennung per KI - _bindRasseErkennung(); - document.getElementById('dp-form-cancel') ?.addEventListener('click', UI.modal.close); @@ -1177,152 +1171,6 @@ window.Page_dog_profile = (() => { }); } - // ---------------------------------------------------------- - // RASSEN-ERKENNUNG PER KI (Formular) - // ---------------------------------------------------------- - function _bindRasseErkennung() { - const btn = document.getElementById('dp-rasse-erkennen-btn'); - const fileInput = document.getElementById('dp-rasse-foto-input'); - if (!btn || !fileInput) return; - - btn.addEventListener('click', () => { - fileInput.value = ''; - fileInput.click(); - }); - - fileInput.addEventListener('change', async () => { - const file = fileInput.files[0]; - if (!file) return; - - if (file.size > 5 * 1024 * 1024) { - UI.toast.error('Bild zu groß (max. 5 MB).'); - return; - } - - const origLabel = btn.innerHTML; - btn.disabled = true; - btn.innerHTML = ` KI analysiert…`; - - try { - const fd = new FormData(); - fd.append('file', file); - const token = localStorage.getItem('by_token'); - const resp = await fetch('/api/ki/rasse-erkennung', { - method: 'POST', - credentials: 'include', - headers: token ? { 'Authorization': `Bearer ${token}` } : {}, - body: fd, - }); - const data = await resp.json(); - if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.'); - - btn.disabled = false; - btn.innerHTML = origLabel; - _showRasseErgebnis(data); - } catch (e) { - btn.disabled = false; - btn.innerHTML = origLabel; - UI.toast.error(e.message || 'Fehler bei der Rassen-Erkennung.'); - } - }); - } - - function _showRasseErgebnis(data) { - if (!data.ist_hund) { - UI.modal.open({ - title: 'Kein Hund erkannt', - body: `
    -
    🐾
    -

    - Auf diesem Foto konnte kein Hund erkannt werden.
    - Bitte lade ein deutlicheres Foto hoch. -

    - ${data.hinweis ? `

    ${_esc(data.hinweis)}

    ` : ''} -
    `, - footer: ``, - }); - return; - } - - const rassen = data.rassen || []; - const cardsHtml = rassen.map((r, i) => { - const isTop = i === 0; - return ` -
    -
    -
    ${isTop ? '🐕 ' : ''}${_esc(r.name)}
    - ${r.sicherheit}% -
    -
    -
    -
    - ${r.beschreibung ? `
    ${_esc(r.beschreibung)}
    ` : ''} -
    - ${isTop ? `` : ``} - ${r.wiki_slug ? `` : ''} -
    -
    - `; - }).join(''); - - UI.modal.open({ - title: 'Erkannte Rasse', - body: ` -
    - ${data.hinweis ? `
    ℹ️ ${_esc(data.hinweis)}
    ` : ''} - ${cardsHtml} -

    - Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar -

    -
    - `, - footer: ``, - }); - - document.getElementById('dp-rasse-modal-schliessen') - ?.addEventListener('click', UI.modal.close); - - document.querySelectorAll('[data-action="uebernehmen"]').forEach(btn => { - btn.addEventListener('click', () => { - const rasse = btn.dataset.rasse; - const rasseInput = document.getElementById('dp-rasse-input'); - const rasseIdInput = document.getElementById('dp-rasse-id'); - const matchBadge = document.getElementById('dp-rasse-match'); - if (rasseInput) { - rasseInput.value = rasse; - rasseInput.dispatchEvent(new Event('input')); - } - UI.modal.close(); - UI.toast.success(`Rasse "${rasse}" übernommen.`); - }); - }); - - document.querySelectorAll('[data-action="wiki"]').forEach(btn => { - btn.addEventListener('click', () => { - UI.modal.close(); - App.navigate('wiki'); - setTimeout(() => { - if (window.Page_wiki && typeof Page_wiki._openBreedDetail === 'function') { - Page_wiki._openBreedDetail(btn.dataset.slug); - } - }, 400); - }); - }); - } - // ---------------------------------------------------------- // HELPER // ---------------------------------------------------------- @@ -1348,444 +1196,6 @@ window.Page_dog_profile = (() => { .replace(/"/g, '"'); } - // ---------------------------------------------------------- - // HUNDEPASS - // ---------------------------------------------------------- - async function _showPassportModal(dog) { - UI.modal.open({ - title: `Hundepass — ${_esc(dog.name)}`, - body: `
    -
    - -
    -
    `, - footer: ` -
    - - - - Ausweis öffnen - - - - - PDF - -
    `, - size: 'large', - }); - - document.getElementById('pp-share-btn')?.addEventListener('click', () => { - _createPassportShare(dog); - }); - - await _loadPassportBody(dog); - } - - async function _loadPassportBody(dog) { - const wrap = document.getElementById('pp-body'); - if (!wrap) return; - - let data; - try { - data = await API.get(`/passport/${dog.id}`); - } catch (e) { - wrap.innerHTML = `

    Fehler beim Laden: ${_esc(e.message)}

    `; - return; - } - - const _fmt = d => { - if (!d) return '–'; - try { - const p = d.substring(0, 10).split('-'); - return `${p[2]}.${p[1]}.${p[0]}`; - } catch { return d; } - }; - - const meta = data.meta || {}; - const vaccs = data.vaccinations || []; - const meds = data.medications || []; - - wrap.innerHTML = ` - -
    -
    - - - Gesundheits-Info - - -
    -
    -
    -
    Blutgruppe
    -
    - ${_esc(meta.blutgruppe) || 'nicht eingetragen'} -
    -
    -
    -
    Allergien
    -
    - ${_esc(meta.allergien) || 'keine'} -
    -
    -
    - ${meta.besonderheiten ? ` -
    -
    Besonderheiten
    -
    - ${_esc(meta.besonderheiten)} -
    -
    ` : ''} -
    - - -
    -
    - - - Impfungen - - -
    -
    - ${vaccs.length === 0 - ? `
    - -

    Noch keine Impfungen eingetragen.
    Klicke auf „+ Eintragen" um loszulegen.

    -
    ` - : vaccs.map(v => ` -
    -
    -
    ${_esc(v.krankheit)}
    -
    - Gegeben: ${_fmt(v.datum)} - ${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''} - ${v.tierarzt ? ` · ${_esc(v.tierarzt)}` : ''} - ${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''} -
    -
    - -
    `).join('') - } -
    -
    - - -
    -
    - - - Medikamente - - -
    -
    - ${meds.length === 0 - ? `
    - -

    Noch keine Medikamente eingetragen.
    Klicke auf „+ Eintragen" um loszulegen.

    -
    ` - : meds.map(m => ` -
    -
    -
    ${_esc(m.name)}
    -
    - ${m.dosierung ? `${_esc(m.dosierung)} · ` : ''} - ${m.von ? `Von ${_fmt(m.von)}` : ''} - ${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''} - ${m.notiz ? ` · ${_esc(m.notiz)}` : ''} -
    -
    - -
    `).join('') - } -
    -
    - `; - - // Meta bearbeiten - document.getElementById('pp-meta-edit-btn')?.addEventListener('click', () => { - _editPassportMeta(dog, meta, () => _loadPassportBody(dog)); - }); - - // Impfung hinzufügen - document.getElementById('pp-vacc-add-btn')?.addEventListener('click', () => { - _addVaccination(dog, () => _loadPassportBody(dog)); - }); - - // Impfung löschen - wrap.querySelectorAll('.pp-vacc-del').forEach(btn => { - btn.addEventListener('click', async () => { - if (!confirm('Impfung wirklich löschen?')) return; - try { - await API.del(`/passport/${dog.id}/vaccinations/${btn.dataset.id}`); - _loadPassportBody(dog); - } catch (e) { - UI.toast.error(e.message || 'Fehler'); - } - }); - }); - - // Medikament hinzufügen - document.getElementById('pp-med-add-btn')?.addEventListener('click', () => { - _addMedication(dog, () => _loadPassportBody(dog)); - }); - - // Medikament löschen - wrap.querySelectorAll('.pp-med-del').forEach(btn => { - btn.addEventListener('click', async () => { - if (!confirm('Medikament wirklich löschen?')) return; - try { - await API.del(`/passport/${dog.id}/medications/${btn.dataset.id}`); - _loadPassportBody(dog); - } catch (e) { - UI.toast.error(e.message || 'Fehler'); - } - }); - }); - } - - function _editPassportMeta(dog, current, onSave) { - UI.modal.open({ - title: 'Gesundheits-Info bearbeiten', - body: ` -
    - - -
    -
    - - -
    -
    - - -
    `, - footer: ` -
    - - -
    `, - }); - - document.getElementById('pp-meta-save').addEventListener('click', async () => { - const btn = document.getElementById('pp-meta-save'); - UI.setLoading(btn, true); - try { - await API.put(`/passport/${dog.id}/meta`, { - blutgruppe: document.getElementById('pp-meta-bg').value.trim() || null, - allergien: document.getElementById('pp-meta-al').value.trim() || null, - besonderheiten: document.getElementById('pp-meta-be').value.trim() || null, - }); - UI.modal.close(); - UI.toast.success('Gesundheits-Info gespeichert.'); - onSave(); - } catch (e) { - UI.setLoading(btn, false); - UI.toast.error(e.message || 'Fehler'); - } - }); - } - - function _addVaccination(dog, onSave) { - const today = new Date().toISOString().slice(0, 10); - UI.modal.open({ - title: 'Impfung eintragen', - body: ` -
    - - - - -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    `, - footer: ` -
    - - -
    `, - }); - - document.getElementById('pp-vacc-save').addEventListener('click', async () => { - const krankheit = document.getElementById('pp-vacc-krankheit').value.trim(); - const datum = document.getElementById('pp-vacc-datum').value; - if (!krankheit || !datum) { - UI.toast.warning('Bitte Krankheit und Datum angeben.'); - return; - } - const btn = document.getElementById('pp-vacc-save'); - UI.setLoading(btn, true); - try { - await API.post(`/passport/${dog.id}/vaccinations`, { - krankheit, - datum, - naechste: document.getElementById('pp-vacc-naechste').value || null, - tierarzt: document.getElementById('pp-vacc-tierarzt').value.trim() || null, - charge_nr: document.getElementById('pp-vacc-charge').value.trim() || null, - }); - UI.modal.close(); - UI.toast.success('Impfung eingetragen.'); - onSave(); - } catch (e) { - UI.setLoading(btn, false); - UI.toast.error(e.message || 'Fehler'); - } - }); - } - - function _addMedication(dog, onSave) { - const today = new Date().toISOString().slice(0, 10); - UI.modal.open({ - title: 'Medikament eintragen', - body: ` -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    `, - footer: ` -
    - - -
    `, - }); - - document.getElementById('pp-med-save').addEventListener('click', async () => { - const name = document.getElementById('pp-med-name').value.trim(); - if (!name) { - UI.toast.warning('Bitte einen Namen angeben.'); - return; - } - const btn = document.getElementById('pp-med-save'); - UI.setLoading(btn, true); - try { - await API.post(`/passport/${dog.id}/medications`, { - name, - dosierung: document.getElementById('pp-med-dosierung').value.trim() || null, - von: document.getElementById('pp-med-von').value || null, - bis: document.getElementById('pp-med-bis').value || null, - notiz: document.getElementById('pp-med-notiz').value.trim() || null, - }); - UI.modal.close(); - UI.toast.success('Medikament eingetragen.'); - onSave(); - } catch (e) { - UI.setLoading(btn, false); - UI.toast.error(e.message || 'Fehler'); - } - }); - } - - async function _createPassportShare(dog) { - const btn = document.getElementById('pp-share-btn'); - if (btn) UI.setLoading(btn, true); - try { - const res = await API.post(`/passport/${dog.id}/share`, {}); - const url = `${location.origin}${res.url}`; - if (btn) UI.setLoading(btn, false); - // Zeige Share-Link im Modal (window.confirm wäre zu kurz) - const shareWrap = document.createElement('div'); - shareWrap.innerHTML = ` -

    - Dieser Link ist 30 Tage gültig. Tierärzte und Sitter können den Pass ohne Login öffnen. -

    -
    - - -
    -

    - Gültig bis: ${res.valid_until.split('-').reverse().join('.')} -

    `; - UI.modal.open({ - title: 'Hundepass-Link teilen', - body: shareWrap.innerHTML, - footer: ``, - }); - document.getElementById('pp-sharelink-copy')?.addEventListener('click', async () => { - await navigator.clipboard.writeText(url).catch(() => {}); - UI.toast.success('Link kopiert!'); - }); - } catch (e) { - if (btn) UI.setLoading(btn, false); - UI.toast.error(e.message || 'Fehler beim Erstellen des Links.'); - } - } - // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- diff --git a/backend/static/js/pages/expenses.js b/backend/static/js/pages/expenses.js deleted file mode 100644 index 3bed1dd..0000000 --- a/backend/static/js/pages/expenses.js +++ /dev/null @@ -1,828 +0,0 @@ -/* ============================================================ - BAN YARO — Ausgaben-Tracker - Tabs: Übersicht | Einträge | Statistik - ============================================================ */ - -window.Page_expenses = (() => { - - let _container = null; - let _appState = null; - let _tab = 'uebersicht'; - - // Cache - let _summary = null; - let _entries = []; - let _statsData = null; - - const TABS = [ - { id: 'uebersicht', label: 'Übersicht', icon: 'house-line' }, - { id: 'eintraege', label: 'Ausgaben', icon: 'currency-eur' }, - { id: 'dauerauftraege', label: 'Daueraufträge', icon: 'arrows-clockwise' }, - { id: 'statistik', label: 'Statistik', icon: 'chart-bar' }, - ]; - - const KATEGORIEN = [ - { id: 'tierarzt', label: 'Tierarzt', icon: 'syringe', color: '#ef4444' }, - { id: 'futter', label: 'Futter', icon: 'bowl-food', color: '#f59e0b' }, - { id: 'zubehoer', label: 'Zubehör', icon: 'shopping-bag', color: '#8b5cf6' }, - { id: 'versicherung',label: 'Versicherung',icon: 'shield', color: '#3b82f6' }, - { id: 'sitter', label: 'Sitter', icon: 'paw-print', color: '#10b981' }, - { id: 'sonstiges', label: 'Sonstiges', icon: 'receipt', color: '#6b7280' }, - ]; - - function _kat(id) { - return KATEGORIEN.find(k => k.id === id) || { id, label: id, icon: 'receipt', color: '#6b7280' }; - } - - // ---------------------------------------------------------- - // LIFECYCLE - // ---------------------------------------------------------- - async function init(container, appState) { - _container = container; - _appState = appState; - _summary = null; - _entries = []; - _statsData = null; - _render(); - } - - async function refresh() { - _summary = null; - _entries = []; - _statsData = null; - await _renderTab(); - } - - // ---------------------------------------------------------- - // SHELL - // ---------------------------------------------------------- - function _render() { - _container.innerHTML = ` -
    - ${TABS.map(t => ` - - `).join('')} -
    -
    - - `; - - _container.querySelectorAll('#exp-tabs .by-tab').forEach(btn => { - btn.addEventListener('click', () => { - _tab = btn.dataset.tab; - _container.querySelectorAll('#exp-tabs .by-tab').forEach(b => - b.classList.toggle('active', b.dataset.tab === _tab) - ); - _renderTab(); - }); - }); - - _container.querySelector('#exp-fab') - ?.addEventListener('click', () => _showForm(null)); - - _renderTab(); - } - - // ---------------------------------------------------------- - // TAB ROUTER - // ---------------------------------------------------------- - async function _renderTab() { - const el = _container.querySelector('#exp-content'); - if (!el) return; - el.innerHTML = `
    ${UI.skeleton(4)}
    `; - try { - switch (_tab) { - case 'uebersicht': await _renderUebersicht(el); break; - case 'eintraege': await _renderEintraege(el); break; - case 'dauerauftraege': await _renderDauerauftraege(el); break; - case 'statistik': await _renderStatistik(el); break; - } - } catch (e) { - el.innerHTML = `
    Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}
    `; - } - } - - // ---------------------------------------------------------- - // TAB: ÜBERSICHT - // ---------------------------------------------------------- - async function _renderUebersicht(el) { - if (!_summary) { - _summary = await API.get('/expenses/summary'); - } - const s = _summary; - - // Vormonatsvergleich berechnen - const letzteMonat = await _getLetzteMonateData(); - const trendHtml = _trendHtml(letzteMonat); - - const kacheln = KATEGORIEN.map(k => { - const betrag = s.monat[k.id] || 0; - return ` -
    -
    - ${UI.icon(k.icon)} -
    -
    ${_fmt(betrag)}
    -
    ${k.label}
    -
    `; - }).join(''); - - const verlauf = letzteMonat.length > 1 ? _vergleichHtml(letzteMonat) : ''; - - el.innerHTML = ` -
    -
    Dieser Monat
    -
    ${_fmt(s.gesamt_monat)}
    -
    - ${UI.icon('calendar')} Dieses Jahr: ${_fmt(s.gesamt_jahr)} - ${trendHtml} -
    -
    -
    ${kacheln}
    - ${verlauf} -
    - `; - } - - async function _getLetzteMonateData() { - if (!_entries.length) { - _entries = await API.get('/expenses?limit=500'); - } - const monatMap = {}; - _entries.forEach(e => { - const m = e.datum.substring(0, 7); - monatMap[m] = (monatMap[m] || 0) + e.betrag; - }); - return Object.entries(monatMap) - .sort((a, b) => b[0].localeCompare(a[0])) - .slice(0, 6) - .reverse(); - } - - function _trendHtml(data) { - // Vergleich: aktueller Monat vs. Vormonat - if (data.length < 2) return ''; - const aktuell = data[data.length - 1][1]; - const vormonat = data[data.length - 2][1]; - if (!vormonat) return ''; - const diff = aktuell - vormonat; - const pct = Math.round(Math.abs(diff / vormonat) * 100); - if (pct === 0) return ''; - const pfeil = diff > 0 - ? `${UI.icon('arrow-up')} +${pct}% ggü. Vormonat` - : `${UI.icon('arrow-down')} −${pct}% ggü. Vormonat`; - return pfeil; - } - - function _vergleichHtml(data) { - if (!data.length) return ''; - const max = Math.max(...data.map(d => d[1]), 1); - const balken = data.map(([monat, summe]) => { - const pct = Math.round((summe / max) * 100); - const [y, m] = monat.split('-'); - const label = new Date(parseInt(y), parseInt(m) - 1, 1) - .toLocaleString('de-DE', { month: 'short' }); - return ` -
    -
    -
    -
    -
    ${label}
    -
    ${_fmtShort(summe)}
    -
    `; - }).join(''); - - return ` -
    -
    ${UI.icon('chart-bar')} Verlauf (6 Monate)
    -
    ${balken}
    -
    `; - } - - // ---------------------------------------------------------- - // TAB: EINTRÄGE - // ---------------------------------------------------------- - async function _renderEintraege(el) { - if (!_entries.length) { - _entries = await API.get('/expenses?limit=500'); - } - - if (!_entries.length) { - el.innerHTML = UI.emptyState({ - icon: UI.icon('receipt'), - title: 'Noch keine Ausgaben', - text: 'Tippe auf + um deine erste Ausgabe einzutragen.', - }); - return; - } - - // Nach Monat gruppieren - const groups = {}; - _entries.forEach(e => { - const m = e.datum.substring(0, 7); - if (!groups[m]) groups[m] = []; - groups[m].push(e); - }); - - const html = Object.entries(groups) - .sort((a, b) => b[0].localeCompare(a[0])) - .map(([monat, items]) => { - const [y, m] = monat.split('-'); - const titel = new Date(parseInt(y), parseInt(m) - 1, 1) - .toLocaleString('de-DE', { month: 'long', year: 'numeric' }); - const summe = items.reduce((s, e) => s + e.betrag, 0); - - const rows = items.map(e => { - const k = _kat(e.kategorie); - const datum = new Date(e.datum + 'T00:00:00') - .toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); - const dogBadge = e.dog_name - ? `${UI.icon('paw-print')} ${_esc(e.dog_name)}` - : ''; - const notiz = e.notiz - ? `${_esc(e.notiz)}` - : ''; - return ` -
    -
    - ${UI.icon(k.icon)} -
    -
    -
    - ${datum} - ${k.label} - ${dogBadge} -
    - ${notiz} -
    -
    -
    ${_fmt(e.betrag)}
    - -
    -
    `; - }).join(''); - - return ` -
    -
    - ${titel} - ${_fmt(summe)} -
    - ${rows} -
    `; - }).join(''); - - el.innerHTML = `
    ${html}
    `; - - // Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button) - el.querySelectorAll('.exp-entry').forEach(row => { - row.addEventListener('click', (ev) => { - if (ev.target.closest('.exp-entry-del')) return; - const id = parseInt(row.dataset.id); - const entry = _entries.find(e => e.id === id); - if (entry) _showForm(entry); - }); - }); - - // Löschen-Buttons - el.querySelectorAll('.exp-entry-del').forEach(btn => { - btn.addEventListener('click', async (ev) => { - ev.stopPropagation(); - const id = parseInt(btn.dataset.del); - if (!window.confirm('Diesen Eintrag wirklich löschen?')) return; - try { - await API.del(`/expenses/${id}`); - UI.toast.success('Ausgabe gelöscht.'); - _invalidateCache(); - await _renderTab(); - } catch (e) { - UI.toast.error(e.message || 'Fehler beim Löschen.'); - } - }); - }); - } - - // ---------------------------------------------------------- - // TAB: DAUERAUFTRÄGE - // ---------------------------------------------------------- - const HAEUFIGKEIT_LABEL = { - monatlich: 'Monatlich', - quartalsweise: 'Quartalsweise', - jaehrlich: 'Jährlich', - }; - - async function _renderDauerauftraege(el) { - let recurring = []; - try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ } - - const cards = recurring.map(r => { - const k = _kat(r.kategorie); - const naechste = r.naechste_faelligkeit - ? new Date(r.naechste_faelligkeit + 'T00:00:00') - .toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) - : '—'; - return ` -
    -
    ${UI.icon(k.icon)}
    -
    -
    - ${k.label} - ${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit} - ${r.dog_name ? `${UI.icon('paw-print')} ${_esc(r.dog_name)}` : ''} -
    - ${r.notiz ? `
    ${_esc(r.notiz)}
    ` : ''} -
    - ${UI.icon('calendar')} Nächste Buchung: ${naechste} - ${!r.aktiv ? 'Pausiert' : ''} -
    -
    -
    -
    ${_fmt(r.betrag)}
    -
    - - -
    -
    -
    `; - }).join(''); - - el.innerHTML = ` -
    - -
    - ${recurring.length - ? `
    ${cards}
    ` - : UI.emptyState({ icon: UI.icon('arrows-clockwise'), - title: 'Keine Daueraufträge', - text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })} -
    `; - - el.querySelector('#exp-recurring-add')?.addEventListener('click', () => _showRecurringForm(null, () => { - _tab = 'dauerauftraege'; _renderTab(); - })); - - el.querySelectorAll('.exp-recurring-toggle').forEach(btn => { - btn.addEventListener('click', async () => { - const rid = parseInt(btn.dataset.rid); - const aktiv = btn.dataset.aktiv === '1'; - await UI.asyncButton(btn, async () => { - await API.patch(`/expenses/recurring/${rid}`, { aktiv: !aktiv }); - _renderTab(); - }); - }); - }); - - el.querySelectorAll('.exp-recurring-del').forEach(btn => { - btn.addEventListener('click', async () => { - if (!window.confirm('Dauerauftrag löschen?')) return; - await UI.asyncButton(btn, async () => { - await API.del(`/expenses/recurring/${btn.dataset.rid}`); - _renderTab(); - }); - }); - }); - } - - function _showRecurringForm(r, onSave) { - const today = new Date().toISOString().slice(0, 10); - const katOptions = [ - { id: 'tierarzt', label: 'Tierarzt' }, { id: 'futter', label: 'Futter' }, - { id: 'zubehoer', label: 'Zubehör' }, { id: 'versicherung', label: 'Versicherung' }, - { id: 'sitter', label: 'Sitter' }, { id: 'sonstiges', label: 'Sonstiges' }, - ].map(k => ``).join(''); - - const dogOptions = (_appState.dogs || []).map(d => - `` - ).join(''); - - const body = ` -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    - ${dogOptions ? ` -
    - - -
    ` : ''} -
    - - -
    -
    `; - - const footer = ` - - `; - - UI.modal.open({ title: r ? 'Dauerauftrag bearbeiten' : 'Neuer Dauerauftrag', body, footer }); - - document.getElementById('exp-recurring-form')?.addEventListener('submit', async e => { - e.preventDefault(); - const btn = document.querySelector('[form="exp-recurring-form"][type="submit"]'); - const fd = UI.formData(e.target); - const payload = { - kategorie: fd.kategorie, - betrag: parseFloat(fd.betrag), - haeufigkeit: fd.haeufigkeit, - startdatum: fd.startdatum, - notiz: fd.notiz || null, - dog_id: fd.dog_id ? parseInt(fd.dog_id) : null, - }; - await UI.asyncButton(btn, async () => { - if (r) { - await API.patch(`/expenses/recurring/${r.id}`, payload); - } else { - await API.post('/expenses/recurring', payload); - } - UI.modal.close(); - onSave?.(); - }); - }); - } - - // ---------------------------------------------------------- - // TAB: STATISTIK - // ---------------------------------------------------------- - async function _renderStatistik(el) { - if (!_summary) { - _summary = await API.get('/expenses/summary'); - } - if (!_entries.length) { - _entries = await API.get('/expenses?limit=500'); - } - - const s = _summary; - const gesamtJahr = s.gesamt_jahr || 1; - - // Jahres-Aufteilung nach Kategorien (als Balken-Reihen) - const katBalken = KATEGORIEN - .filter(k => (s.jahr[k.id] || 0) > 0) - .sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0)) - .map(k => { - const val = s.jahr[k.id] || 0; - const pct = Math.round((val / gesamtJahr) * 100); - return ` -
    -
    - ${UI.icon(k.icon)} - ${k.label} -
    -
    -
    -
    -
    ${pct}%
    -
    ${_fmt(val)}
    -
    `; - }).join(''); - - // Monats-Balken mit gestapelten Top-2-Kategorien - const heute = new Date(); - const jahrStr = heute.getFullYear().toString(); - - // Pro Monat: Summe je Kategorie berechnen - const monatKatMap = {}; // { monat: { katId: summe } } - _entries - .filter(e => e.datum.startsWith(jahrStr)) - .forEach(e => { - const m = parseInt(e.datum.split('-')[1]); - if (!monatKatMap[m]) monatKatMap[m] = {}; - monatKatMap[m][e.kategorie] = (monatKatMap[m][e.kategorie] || 0) + e.betrag; - }); - - const monatTotalMap = {}; - Object.entries(monatKatMap).forEach(([m, katObj]) => { - monatTotalMap[m] = Object.values(katObj).reduce((a, b) => a + b, 0); - }); - - const maxMonat = Math.max(...Object.values(monatTotalMap), 1); - const MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; - - const monatsBalken = MONATE.map((label, i) => { - const mi = i + 1; - const total = monatTotalMap[mi] || 0; - const pct = Math.round((total / maxMonat) * 100); - const isAktiv = mi === (heute.getMonth() + 1); - - // Top-2-Kategorien für gestapelten Balken - let stackHtml = ''; - if (total > 0 && monatKatMap[mi]) { - const sorted = Object.entries(monatKatMap[mi]) - .sort((a, b) => b[1] - a[1]) - .slice(0, 2); - // Gesamthöhe = pct%, verteile anteilig auf Top-2 - let rest = pct; - const segments = sorted.map(([katId, val], idx) => { - const k = _kat(katId); - const segPct = idx < sorted.length - 1 - ? Math.round((val / total) * pct) - : rest; - rest -= segPct; - return `
    `; - }); - stackHtml = segments.join(''); - } else { - stackHtml = `
    `; - } - - return ` -
    -
    - ${stackHtml} -
    -
    ${label}
    -
    `; - }).join(''); - - // Donut-Übersicht (CSS-gradient) - const donutHtml = _donutHtml(s, gesamtJahr); - - el.innerHTML = ` -
    -
    Gesamt dieses Jahr
    -
    ${_fmt(s.gesamt_jahr)}
    -
    - -
    -
    ${UI.icon('chart-bar')} Monatsübersicht ${jahrStr}
    -
    ${monatsBalken}
    -
    - - ${donutHtml} - -
    -
    ${UI.icon('chart-pie')} Aufteilung nach Kategorie
    -
    - ${katBalken || `
    Noch keine Ausgaben dieses Jahr.
    `} -
    -
    -
    - `; - } - - // Donut via CSS conic-gradient - function _donutHtml(s, gesamt) { - const aktiveKat = KATEGORIEN.filter(k => (s.jahr[k.id] || 0) > 0); - if (!aktiveKat.length) return ''; - - // Stops für conic-gradient berechnen - let offset = 0; - const stops = []; - aktiveKat.forEach(k => { - const pct = (s.jahr[k.id] || 0) / gesamt * 100; - stops.push(`${k.color} ${offset.toFixed(1)}% ${(offset + pct).toFixed(1)}%`); - offset += pct; - }); - const gradient = `conic-gradient(${stops.join(', ')})`; - - const legendeItems = aktiveKat - .sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0)) - .map(k => { - const pct = Math.round((s.jahr[k.id] || 0) / gesamt * 100); - return ` -
    - - ${k.label} - ${pct}% -
    `; - }).join(''); - - return ` -
    -
    ${UI.icon('chart-pie')} Kategorien-Verteilung
    -
    -
    -
    -
    -
    ${legendeItems}
    -
    -
    `; - } - - // ---------------------------------------------------------- - // FORMULAR — Neu / Bearbeiten - // ---------------------------------------------------------- - function _showForm(entry) { - const isEdit = !!entry; - const today = new Date().toISOString().split('T')[0]; - const formId = 'exp-form'; - const selKat = entry?.kategorie || 'sonstiges'; - - const dogOptions = (_appState.dogs || []).map(d => - `` - ).join(''); - - // Kategorie-Kacheln statt Dropdown - const katKacheln = KATEGORIEN.map(k => ` - `).join(''); - - const body = ` -
    - -
    - -
    ${katKacheln}
    -
    - -
    -
    - -
    - - -
    -
    -
    - - -
    -
    - - ${dogOptions ? ` -
    - - -
    ` : ''} - -
    - - -
    - - ${!isEdit ? ` -
    - - -
    ` : ''} - -
    `; - - const footer = isEdit ? ` - - - - ` : ` - - - `; - - const modal = UI.modal.open({ title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe', body, footer }); - - // Kategorie-Kacheln interaktiv - modal.querySelectorAll('.exp-kat-tile').forEach(tile => { - tile.addEventListener('click', () => { - modal.querySelectorAll('.exp-kat-tile').forEach(t => t.classList.remove('exp-kat-tile--sel')); - tile.classList.add('exp-kat-tile--sel'); - }); - }); - - // Wiederholen-Toggle - modal.querySelector('#exp-wiederholen')?.addEventListener('change', e => { - modal.querySelector('#exp-repeat-opts').style.display = e.target.checked ? 'block' : 'none'; - }); - - if (isEdit) { - modal.querySelector('#exp-delete-btn')?.addEventListener('click', async () => { - if (!window.confirm('Diesen Eintrag wirklich löschen?')) return; - try { - await API.del(`/expenses/${entry.id}`); - UI.modal.close(); - UI.toast.success('Ausgabe gelöscht.'); - _invalidateCache(); - await _renderTab(); - } catch (e) { - UI.toast.error(e.message || 'Fehler beim Löschen.'); - } - }); - } - - modal.querySelector(`#${formId}`)?.addEventListener('submit', async (ev) => { - ev.preventDefault(); - const fd = UI.formData(ev.target); - const payload = { - kategorie: fd.kategorie, - betrag: parseFloat(fd.betrag), - datum: fd.datum, - notiz: fd.notiz || null, - dog_id: fd.dog_id ? parseInt(fd.dog_id) : null, - }; - - try { - if (isEdit) { - await API.patch(`/expenses/${entry.id}`, payload); - UI.toast.success('Ausgabe aktualisiert.'); - } else { - await API.post('/expenses', payload); - // Auch als Dauerauftrag anlegen wenn gewünscht - if (fd.wiederholen) { - await API.post('/expenses/recurring', { - ...payload, - haeufigkeit: fd.haeufigkeit || 'jaehrlich', - startdatum: fd.datum, - }); - UI.toast.success('Ausgabe + Dauerauftrag gespeichert.'); - } else { - UI.toast.success('Ausgabe gespeichert.'); - } - } - UI.modal.close(); - _invalidateCache(); - await _renderTab(); - } catch (e) { - UI.toast.error(e.message || 'Fehler beim Speichern.'); - } - }); - } - - // ---------------------------------------------------------- - // Hilfsfunktionen - // ---------------------------------------------------------- - function _invalidateCache() { - _summary = null; - _entries = []; - _statsData = null; - } - - function _fmt(val) { - return (val || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }); - } - - function _fmtShort(val) { - if (!val) return '0 €'; - if (val >= 1000) return (val / 1000).toFixed(1).replace('.', ',') + ' k€'; - return Math.round(val) + ' €'; - } - - function _esc(s) { - if (!s) return ''; - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - - return { init, refresh }; -})(); diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index 8c28a1b..f29ff18 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -66,7 +66,6 @@ window.Page_forum = (() => { _container = container; _appState = appState; _render(); - _loadHdmCard(); _loadThreads(true); } @@ -99,17 +98,15 @@ window.Page_forum = (() => {
    ${KATEGORIEN.map(k => ` + data-kat="${k.key}">${_esc(k.label)} `).join('')} + data-section="map">${UI.icon('users')} Mitgliederkarte
    - +
    -
    -
    @@ -130,23 +127,6 @@ window.Page_forum = (() => { const _tabCount = _tabsEl.querySelectorAll('.by-tab').length; _tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2)); - // Marquee-Scroll: nur Tabs animieren, bei denen Text wirklich abgeschnitten ist - _tabsEl.addEventListener('mouseenter', e => { - const btn = e.target.closest('.by-tab'); - const span = btn?.querySelector('.by-tab-text'); - if (!span) return; - const style = getComputedStyle(btn); - const padH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); - const overflow = span.scrollWidth - (btn.clientWidth - padH); - if (overflow <= 2) return; - span.style.setProperty('--tab-scroll-px', `-${overflow}px`); - span.classList.add('scrolling'); - }, true); - _tabsEl.addEventListener('mouseleave', e => { - const span = e.target.closest('.by-tab')?.querySelector('.by-tab-text'); - if (span) span.classList.remove('scrolling'); - }, true); - // Tab-Klicks _tabsEl.addEventListener('click', e => { const btn = e.target.closest('[data-kat], [data-section]'); @@ -195,177 +175,6 @@ window.Page_forum = (() => { document.getElementById('forum-rules-btn').addEventListener('click', _showRules); } - // ---------------------------------------------------------- - // Hund des Monats — Kachel + Modal - // ---------------------------------------------------------- - async function _loadHdmCard() { - const card = document.getElementById('forum-hdm-card'); - if (!card) return; - try { - const data = await API.get('/movies/hund-des-monats'); - const [year, month] = data.monat.split('-'); - const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long' }) - .format(new Date(+year, +month - 1, 1)); - const top = data.top?.[0]; - const winnerLine = top - ? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}` - : 'Noch keine Stimmen'; - const metaLine = top - ? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}` - : 'Sei der Erste!'; - - card.innerHTML = ` -
    -
    🏆
    -
    -
    Hund des Monats · ${_esc(monthName)}
    -
    ${winnerLine}
    -
    ${metaLine}
    -
    -
    ${UI.icon('arrow-right')}
    -
    `; - - document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data)); - } catch { - // Kachel bleibt leer bei Fehler - } - } - - async function _openHdmModal(data) { - try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ } - - const [year, month] = data.monat.split('-'); - const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }) - .format(new Date(+year, +month - 1, 1)); - - const topList = data.top?.length - ? data.top.slice(0, 5).map((dog, i) => { - const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][i]; - const av = dog.foto_url - ? `${_esc(dog.name)}` - : `${_esc(dog.name.charAt(0).toUpperCase())}`; - const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : ''; - return ` -
    - ${medal} -
    ${av}
    -
    -
    ${_esc(dog.name)}
    - ${dog.rasse ? `
    ${_esc(dog.rasse)}
    ` : ''} - ${vorname ? `
    von ${vorname}
    ` : ''} -
    -
    ${dog.stimmen} ${UI.icon('star')}
    -
    `; - }).join('') - : `

    Noch keine Stimmen. Sei der Erste!

    `; - - const voteHint = !_appState.user - ? `
    -

    - Anmelden - um abstimmen zu können. -

    -
    ` - : `
    -

    Für welchen Hund möchtest du abstimmen?

    - -
    - ${UI.skeleton(3)} -
    -
    `; - - const body = ` -
    -
    🏆
    -

    Hund des Monats

    -
    ${_esc(monthName)}
    -
    - ${voteHint} -
    -

    Top 5 diesen Monat

    -
    ${topList}
    -
    `; - - UI.modal.open({ title: '🏆 Hund des Monats', body, - footer: `` }); - - document.getElementById('hdm-login-link')?.addEventListener('click', e => { - e.preventDefault(); UI.modal.close(); App.navigate('settings'); - }); - - if (!_appState.user) return; - - // Kandidaten laden und rendern - let _kandidaten = []; - const _renderKandidaten = (list) => { - const grid = document.getElementById('hdm-kandidaten-grid'); - if (!grid) return; - if (!list.length) { - grid.innerHTML = `

    Keine Hunde gefunden.

    `; - return; - } - grid.innerHTML = list.map(dog => { - const isVoted = data.user_vote === dog.id; - const av = dog.foto_url - ? `${_esc(dog.name)}` - : `${_esc(dog.name.charAt(0).toUpperCase())}`; - const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : ''; - return ` -
    -
    ${av}
    -
    ${_esc(dog.name)}
    - ${dog.rasse ? `
    ${_esc(dog.rasse)}
    ` : ''} - ${vorname ? `
    von ${vorname}
    ` : ''} - ${dog.stimmen > 0 ? `
    ${dog.stimmen} ${UI.icon('star')}
    ` : ''} - -
    `; - }).join(''); - - grid.querySelectorAll('.hdm-vote-btn:not([disabled])').forEach(btn => { - btn.addEventListener('click', async () => { - const dogId = parseInt(btn.dataset.dogId); - await UI.asyncButton(btn, async () => { - try { - await API.post('/movies/hund-des-monats/vote', { dog_id: dogId }); - data.user_vote = dogId; - UI.toast.success('Stimme abgegeben!'); - UI.modal.close(); - _loadHdmCard(); - } catch (err) { - UI.toast.error(err.message || 'Fehler beim Abstimmen.'); - } - }); - }); - }); - }; - - try { - _kandidaten = await API.get('/movies/hund-des-monats/kandidaten'); - } catch { - document.getElementById('hdm-kandidaten-grid').innerHTML = - `

    Kandidaten konnten nicht geladen werden.

    `; - return; - } - _renderKandidaten(_kandidaten); - - document.getElementById('hdm-search')?.addEventListener('input', e => { - const q = e.target.value.trim().toLowerCase(); - _renderKandidaten(q - ? _kandidaten.filter(d => - (d.name || '').toLowerCase().includes(q) || - (d.rasse || '').toLowerCase().includes(q)) - : _kandidaten - ); - }); - } - // ---------------------------------------------------------- // Threads laden // ---------------------------------------------------------- diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 6308eda..1d6400b 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -6,13 +6,11 @@ window.Page_health = (() => { - let _container = null; - let _appState = null; - let _data = {}; - let _praxen = []; - let _activeTab = 'impfung'; - let _favoritVet = null; - let _healthDocs = []; + let _container = null; + let _appState = null; + let _data = {}; + let _praxen = []; + let _activeTab = 'impfung'; const BASE_TABS = [ { key: 'impfung', label: 'Impfpass', icon: '' }, @@ -22,6 +20,7 @@ window.Page_health = (() => { { key: 'allergie', label: 'Allergien', icon: '' }, { key: 'dokument', label: 'Dokumente', icon: '' }, { key: 'praxen', label: 'Praxen', icon: '' }, + { key: 'symptomcheck', label: 'Symptom-Check', icon: '' }, ]; const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '' }; @@ -151,9 +150,6 @@ window.Page_health = (() => { -
    ${transponderHtml}
    @@ -166,8 +162,6 @@ window.Page_health = (() => { _renderTabBar(); _container.querySelector('#health-ki-btn') .addEventListener('click', _showKiSummary); - _container.querySelector('#health-ki-tierarzt-btn') - .addEventListener('click', _showKiTierarzt); _container.querySelector('#health-transponder-edit') .addEventListener('click', () => _editTransponder(dog)); @@ -176,7 +170,6 @@ window.Page_health = (() => { _renderTab(); _loadKiBerichte(dog.id); _loadTerminvorschlaege(dog.id); - _loadMeinTierarzt(); } // ---------------------------------------------------------- @@ -349,16 +342,6 @@ window.Page_health = (() => { } catch (err) { _data['gewicht_chart'] = []; } - try { - _favoritVet = await API.tieraerzte.myFavorite(); - } catch (err) { - _favoritVet = null; - } - try { - _healthDocs = await API.healthDocs.list(dogId); - } catch (err) { - _healthDocs = []; - } } // ---------------------------------------------------------- @@ -379,6 +362,7 @@ window.Page_health = (() => { case 'allergie': content.innerHTML = _renderAllergien(entries); break; case 'dokument': content.innerHTML = _renderDokumente(entries); break; case 'praxen': content.innerHTML = _renderPraxen(); break; + case 'symptomcheck': _renderSymptomCheck(content); break; } _bindTabEvents(content); @@ -917,8 +901,7 @@ window.Page_health = (() => { }).join(''); return `
    ${items}
    -
    ${addBtn}
    - ${_renderBefundeSection()}`; +
    ${addBtn}
    `; } // ---------------------------------------------------------- @@ -974,32 +957,6 @@ window.Page_health = (() => { // Praxis hinzufügen content.querySelector('[data-action="add-praxis"]') ?.addEventListener('click', () => _showPraxForm(null)); - // Favorit-Toggle für Praxen - content.querySelectorAll('[data-action="toggle-fav"]').forEach(btn => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const vetId = parseInt(btn.dataset.praxisId); - await UI.asyncButton(btn, async () => { - const res = await API.tieraerzte.toggleFavorite(vetId); - if (res.is_favorite) { - _favoritVet = _praxen.find(p => p.id === vetId) || null; - UI.toast.success('Als Favorit-Tierarzt gespeichert.'); - } else { - _favoritVet = null; - UI.toast.success('Favorit entfernt.'); - } - // is_favorite in _praxen aktualisieren - _praxen = _praxen.map(p => ({ ...p, is_favorite: p.id === vetId ? res.is_favorite : false })); - const elFav = _container.querySelector('#health-mein-tierarzt'); - if (elFav) _renderMeinTierarztKachel(elFav); - _renderTab(); - }); - }); - }); - // Befunde & Dokumente - if (_activeTab === 'dokument') { - _bindBefundeEvents(content); - } } // ---------------------------------------------------------- @@ -1640,9 +1597,7 @@ window.Page_health = (() => { action: addBtn }); - const renderCard = p => { - const isFav = _favoritVet?.id === p.id || p.is_favorite; - return ` + const renderCard = p => `
    @@ -1671,40 +1626,17 @@ window.Page_health = (() => { onclick="event.stopPropagation()"> Notfall ` : ''} -
    - `}; - - - const favCard = _favoritVet ? ` -
    -
    - ${UI.icon('heart')} Mein Tierarzt -
    - ${renderCard(_favoritVet)} -
    ` : ''; - - const ohneGesetzt = aktive.filter(p => p.id !== _favoritVet?.id); + `; return `
    ${addBtn}
    - ${favCard}
    - ${ohneGesetzt.map(renderCard).join('')} + ${aktive.map(renderCard).join('')} ${inaktive.length ? `
    @@ -2224,306 +2156,6 @@ window.Page_health = (() => { }); } - // ---------------------------------------------------------- - // MEIN TIERARZT — Kachel - // ---------------------------------------------------------- - async function _loadMeinTierarzt() { - const el = _container.querySelector('#health-mein-tierarzt'); - if (!el) return; - _renderMeinTierarztKachel(el); - } - - function _renderMeinTierarztKachel(el) { - if (!el) return; - const vet = _favoritVet; - const adresse = vet - ? [vet.strasse, [vet.plz, vet.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ') - : ''; - - el.innerHTML = ` -
    -
    - Mein Tierarzt -
    -
    -
    - -
    -
    - ${vet ? ` -
    ${_esc(vet.name)}
    - ${adresse ? `
    ${_esc(adresse)}
    ` : ''} - ${vet.telefon ? ` - ` : ''} - ${vet.notfall_telefon ? ` - ` : ''} - ` : ` -
    - Noch kein Tierarzt als Favorit gespeichert. -
    - - `} -
    - ${vet ? ` - - ` : ''} -
    -
    - `; - - el.querySelector('#health-suche-tierarzt-btn')?.addEventListener('click', () => { - App.navigate('map', { filter: 'tierarzt' }); - }); - - el.querySelector('#health-remove-fav-btn')?.addEventListener('click', async (e) => { - e.stopPropagation(); - const btn = e.currentTarget; - await UI.asyncButton(btn, async () => { - await API.tieraerzte.toggleFavorite(_favoritVet.id); - _favoritVet = null; - const elAgain = _container.querySelector('#health-mein-tierarzt'); - if (elAgain) _renderMeinTierarztKachel(elAgain); - UI.toast.success('Tierarzt-Favorit entfernt.'); - }); - }); - } - - // ---------------------------------------------------------- - // BEFUNDE & DOKUMENTE — Sektion (unabhängig von den Tabs) - // ---------------------------------------------------------- - // Diese Sektion erscheint im "dokument"-Tab als zweite Liste. - // Wir ergänzen _renderDokumente um einen Abschnitt unten. - - function _renderBefundeSection() { - const dog = _appState.activeDog; - const docs = _healthDocs; - const DOC_ICONS = { - blutbild: 'drop', - roentgen: 'file-text', - rezept: 'note', - impfausweis:'certificate', - sonstiges: 'file-text', - }; - const DOC_LABELS = { - blutbild: 'Blutbild', - roentgen: 'Röntgen', - rezept: 'Rezept', - impfausweis:'Impfausweis', - sonstiges: 'Sonstiges', - }; - - const uploadBtn = ` - `; - - const items = docs.length - ? docs.map(doc => { - const icon = DOC_ICONS[doc.typ] || 'file-text'; - const label = DOC_LABELS[doc.typ] || doc.typ; - const isImg = !['pdf'].includes(doc.file_type); - const datum = doc.datum ? UI.time.format(doc.datum + 'T00:00:00') : ''; - return ` -
    -
    - -
    -
    -
    ${_esc(doc.titel)}
    -
    - ${_esc(label)}${datum ? ' · ' + datum : ''} - ${doc.vet_name ? ' · ' + _esc(doc.vet_name) : ''} -
    - ${doc.beschreibung ? `
    ${_esc(doc.beschreibung)}
    ` : ''} - -
    -
    `; - }).join('') - : `

    - Noch keine Befunde hochgeladen. -

    `; - - return ` -
    -
    -
    - Befunde & Dokumente -
    - ${uploadBtn} -
    -
    ${items}
    -
    - `; - } - - function _bindBefundeEvents(content) { - content.querySelector('#health-docs-upload-btn')?.addEventListener('click', () => { - _showBefundUploadModal(); - }); - content.querySelectorAll('[data-action="delete-hdoc"]').forEach(btn => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const docId = parseInt(btn.dataset.docId); - const ok = window.confirm('Befund wirklich löschen?'); - if (!ok) return; - await UI.asyncButton(btn, async () => { - await API.healthDocs.delete(docId); - _healthDocs = _healthDocs.filter(d => d.id !== docId); - _renderTab(); - UI.toast.success('Befund gelöscht.'); - }); - }); - }); - } - - function _showBefundUploadModal() { - const aktivePraxen = _praxen.filter(p => p.aktiv); - const dog = _appState.activeDog; - - UI.modal.open({ - title: ` Befund hochladen`, - body: ` -
    -
    - - -
    -
    - - -
    -
    - - -
    - ${aktivePraxen.length ? ` -
    - - -
    ` : ''} -
    - - -
    -
    - - -
    -
    -
    - `, - footer: ` - - - `, - }); - - document.getElementById('befund-cancel')?.addEventListener('click', UI.modal.close); - - document.getElementById('befund-file-input')?.addEventListener('change', function () { - const preview = document.getElementById('befund-file-preview'); - if (this.files?.length) { - const f = this.files[0]; - preview.textContent = `${f.name} (${(f.size / 1024 / 1024).toFixed(2)} MB)`; - } else { - preview.textContent = ''; - } - }); - - document.getElementById('befund-form')?.addEventListener('submit', async (e) => { - e.preventDefault(); - const btn = document.querySelector('[form="befund-form"][type="submit"]'); - const form = e.target; - const fd = UI.formData(form); - const fileInput = form.querySelector('[name="file"]'); - const file = fileInput?.files?.[0]; - - if (!fd.typ) { UI.toast.warning('Bitte Art des Dokuments wählen.'); return; } - if (!fd.titel) { UI.toast.warning('Bitte Titel eingeben.'); return; } - if (!file) { UI.toast.warning('Bitte eine Datei auswählen.'); return; } - - if (file.size > 10 * 1024 * 1024) { - UI.toast.error('Datei ist zu groß. Maximum: 10 MB.'); - return; - } - - await UI.asyncButton(btn, async () => { - const formData = new FormData(); - formData.append('dog_id', String(dog.id)); - formData.append('typ', fd.typ); - formData.append('titel', fd.titel); - formData.append('beschreibung', fd.beschreibung || ''); - formData.append('datum', fd.datum || ''); - if (fd.vet_id) formData.append('vet_id', fd.vet_id); - formData.append('file', file); - - try { - const doc = await API.healthDocs.upload(formData); - _healthDocs.unshift(doc); - UI.modal.close(); - _renderTab(); - UI.toast.success('Befund hochgeladen.'); - } catch (err) { - UI.toast.error(err.message || 'Fehler beim Hochladen.'); - } - }); - }); - } - // ---------------------------------------------------------- async function _showKiSummary() { const btn = _container.querySelector('#health-ki-btn'); @@ -2691,129 +2323,6 @@ window.Page_health = (() => { }); } - // ---------------------------------------------------------- - // KI-TIERARZTFRAGEN - // ---------------------------------------------------------- - function _showKiTierarzt() { - const dog = _appState.activeDog; - const dogName = dog?.name || ''; - const rasse = dog?.rasse || ''; - const placeholder = dogName - ? `Welche Symptome zeigt ${dogName}? z.B. frisst nicht, schläft viel, lahmt...` - : 'Welche Symptome zeigt dein Hund? z.B. frisst nicht, schläft viel, lahmt...'; - - UI.modal.open({ - title: ' KI-Tierarzt', - body: ` -

    - Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung — - kein Ersatz für einen echten Tierarzt. -

    -
    - -
    - -
    - ⚠️ Hinweis: Dies ist keine medizinische Diagnose. - Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt. -
    `, - footer: ` - - `, - }); - - document.getElementById('ki-tierarzt-submit-btn') - .addEventListener('click', async function () { - const btn = this; - const symptom = document.getElementById('ki-tierarzt-symptom').value.trim(); - const resultEl = document.getElementById('ki-tierarzt-result'); - - if (!symptom) { - UI.toast.warning('Bitte Symptome eingeben.'); - return; - } - - await UI.asyncButton(btn, async () => { - resultEl.style.display = 'none'; - resultEl.innerHTML = ''; - - let result; - try { - result = await API.post('/ki/tierarzt', { - symptom, - dog_id: dog?.id || null, - dog_name: dogName || null, - rasse: rasse || null, - }); - } catch (err) { - if (err.status === 429) { - resultEl.innerHTML = ` -
    - - 5 Anfragen pro Tag erreicht. Morgen wieder verfügbar. -
    `; - } else if (err.status === 503) { - resultEl.innerHTML = ` -
    - KI momentan nicht verfügbar. Bitte später versuchen. -
    `; - } else { - UI.toast.error(err.message || 'Fehler bei der KI-Anfrage.'); - return; - } - resultEl.style.display = ''; - return; - } - - const antwortHtml = _esc(result.antwort) - .replace(/\n\n/g, '

    ') - .replace(/\n/g, '
    '); - const restHtml = result.limit - result.anfragen_heute > 0 - ? `

    - Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar. -

    ` - : `

    - Tageslimit erreicht. Morgen wieder verfügbar. -

    `; - - resultEl.innerHTML = ` -
    -
    - - Einschätzung -
    -

    ${antwortHtml}

    - ${restHtml} -
    -
    - ⚠️ Dies ist keine medizinische Diagnose. - Bei ernsthaften Symptomen sofort zum Tierarzt. -
    `; - resultEl.style.display = ''; - - // Submit-Button ausblenden wenn Limit erschöpft - if (result.anfragen_heute >= result.limit) { - btn.disabled = true; - btn.textContent = 'Limit erreicht'; - } - }); - }); - } - return { init, refresh, openNew, onDogChange }; })(); diff --git a/backend/static/js/pages/jobs.js b/backend/static/js/pages/jobs.js deleted file mode 100644 index b056d24..0000000 --- a/backend/static/js/pages/jobs.js +++ /dev/null @@ -1,269 +0,0 @@ -/* ============================================================ - BAN YARO — Social-Media-Job Bewerbung - ============================================================ */ - -window.Page_jobs = (() => { - - let _container = null; - let _appState = null; - - const _esc = s => UI.escape(s ?? ''); - const _ph = (name, size = 22) => - ``; - - async function init(container, appState) { - _container = container; - _appState = appState; - await _render(); - } - - async function _render() { - // Bestehende Bewerbung prüfen (nur wenn eingeloggt) - let existingApp = null; - let trialStatus = null; - if (_appState.user) { - try { - const r = await API.get('/jobs/my-application'); - existingApp = r.application; - trialStatus = await API.get('/jobs/luna-trial-status'); - } catch { /* ignorieren */ } - } - - _container.innerHTML = ` -
    - - -
    -
    🐾
    -

    - Social-Media-Manager/in gesucht -

    -

    - Werde das Gesicht von Ban Yaro auf Instagram & TikTok -

    -
    - - -
    -
    -

    Die Stelle

    -
    - ${_infoRow(_ph('map-pin'), 'Remote', '100 % flexibel — du arbeitest wann und wie du willst')} - ${_infoRow(_ph('calendar-dots'), 'Umfang', '1–2 Posts pro Woche auf Instagram & TikTok')} - ${_infoRow(_ph('tag'), 'Vergütung', '50 € / Monat — wächst mit der Community')} - ${_infoRow(_ph('robot'), 'Luna an deiner Seite', 'Unser KI-Assistent schreibt Captions, generiert Post-Ideen und Hashtags — du wählst aus und postest')} - ${_infoRow(_ph('star'), 'Gründer-Status', 'Du wirst Teil der ersten 100 Gründer — für immer')} -
    -
    -
    - - -
    -
    - - Luna 14 Tage kostenlos testen -
    -

    - Mit deiner Bewerbung schalten wir dir sofort den vollen Zugang zu Luna frei — - unserem KI-Assistenten für Social-Media-Posts. Probiere ihn einfach aus, - bevor du dich entscheidest. -

    - ${trialStatus?.active ? `
    - - Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage
    ` : ''} -
    - - -
    -
    -

    Wen wir suchen

    -
      -
    • Du hast einen Hund — und liebst ihn sehr 🐕
    • -
    • Du bist auf Instagram oder TikTok zuhause (nicht professionell, aber aktiv)
    • -
    • Du schreibst gerne und authentisch auf Deutsch
    • -
    • Du hast Lust, eine junge App bekannt zu machen — aus Überzeugung
    • -
    • Kein Lebenslauf nötig. Kein Bewerbungs-Anschreiben. Einfach du.
    • -
    -
    -
    - - - ${existingApp ? _renderStatus(existingApp) : _renderForm()} - -
    - `; - - if (!existingApp) { - _bindForm(); - } - } - - function _infoRow(icon, label, text) { - return ` -
    -
    ${icon}
    -
    -
    ${label}
    -
    ${text}
    -
    -
    `; - } - - function _renderStatus(app) { - const statusMap = { - pending: { icon: 'clock', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' }, - reviewing: { icon: 'magnifying-glass', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' }, - accepted: { icon: 'check-circle', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' }, - rejected: { icon: 'x', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' }, - }; - const s = statusMap[app.status] || statusMap.pending; - return ` -
    -
    - -
    -
    ${s.text}
    -
    - Bewerbung eingereicht: ${app.created_at?.slice(0,10)} -
    - ${app.admin_note ? `
    ${_esc(app.admin_note)}
    ` : ''} -
    `; - } - - function _renderForm() { - const u = _appState.user; - return ` -
    -
    -

    - Jetzt bewerben -

    -
    - -
    - - -
    - -
    - - -
    - -
    -
    - - -
    -
    - - -
    -
    - -
    - -
    - @ - -
    -

    - Dein öffentliches Profil auf Instagram oder TikTok -

    -
    - -
    - - -

    - Mindestens 80 Zeichen -

    -
    - -
    - - -

    - Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund — max. 3 Dateien, je 10 MB. - PDF, Bild oder Video. -

    -
    - - ${!u ? `
    - 💡 Tipp: Wenn du dich vorher - anmeldest oder registrierst, - bekommst du sofort den 14-tägigen Luna-Probezugang. -
    ` : ''} - - - -
    -
    -
    `; - } - - function _bindForm() { - document.getElementById('jobs-login-link')?.addEventListener('click', e => { - e.preventDefault(); - if (window.App) App.navigate('settings'); - }); - - document.getElementById('jobs-form')?.addEventListener('submit', async e => { - e.preventDefault(); - const btn = e.target.querySelector('[type="submit"]'); - const fd = new FormData(e.target); - - // Dateien aus file-input übernehmen - const fileInput = document.getElementById('jobs-files'); - if (fileInput?.files?.length) { - fd.delete('files'); - for (const f of fileInput.files) fd.append('files', f); - } - - await UI.asyncButton(btn, async () => { - const resp = await fetch('/api/jobs/apply', { - method: 'POST', - body: fd, - headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` }, - credentials: 'include', - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || 'Fehler beim Absenden.'); - } - const result = await resp.json(); - - if (result.luna_trial) { - UI.toast.success('🎉 Bewerbung eingegangen! Dein Luna-Probezugang ist jetzt aktiv.'); - // User-State aktualisieren damit Luna sofort zugänglich ist - if (_appState.user && window.API) { - try { _appState.user = await API.auth.me(); } catch { /* ignore */ } - } - } else { - UI.toast.success('Bewerbung eingegangen! Wir melden uns bald.'); - } - - await _render(); - }); - }); - } - - return { init }; -})(); diff --git a/backend/static/js/pages/movies.js b/backend/static/js/pages/movies.js index a36cf73..441a0df 100644 --- a/backend/static/js/pages/movies.js +++ b/backend/static/js/pages/movies.js @@ -13,9 +13,6 @@ window.Page_movies = (() => { let _filme = []; let _activeTab = 'filme'; let _filter = 'alle'; - let _typ = 'alle'; // alle | film | serie | doku - let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung - let _search = ''; // ---------------------------------------------------------- // INIT @@ -42,6 +39,7 @@ window.Page_movies = (() => {
    +
    `; @@ -66,54 +64,26 @@ window.Page_movies = (() => { if (_activeTab === 'filme') await _renderFilme(content); if (_activeTab === 'promis') _renderPromis(content); + if (_activeTab === 'hdm') await _renderHundDesMonats(content); } // ---------------------------------------------------------- // TAB 1: FILME // ---------------------------------------------------------- - async function _loadFilme() { - _filme = await API.get(`/movies/filme?sort=${_sort}&typ=${_typ}`); - } - async function _renderFilme(content) { try { - await _loadFilme(); + _filme = await API.get('/movies/filme'); } catch { content.innerHTML = UI.emptyState({ icon: 'film-slate', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' }); return; } content.innerHTML = ` -
    -
    - - -
    -
    - - - - -
    -
    - - - - -
    -
    - - - -
    +
    + + + +
    `; @@ -127,31 +97,6 @@ window.Page_movies = (() => { }); }); - content.querySelectorAll('.movies-type-btn').forEach(btn => { - btn.addEventListener('click', async () => { - _typ = btn.dataset.typ; - content.querySelectorAll('.movies-type-btn').forEach(b => b.classList.remove('movies-filter-btn--active')); - btn.classList.add('movies-filter-btn--active'); - const grid = content.querySelector('#movie-grid'); - grid.innerHTML = UI.skeleton(3); - await _loadFilme(); - _renderMovieGrid(grid); - }); - }); - - content.querySelector('#movies-search')?.addEventListener('input', e => { - _search = e.target.value.trim().toLowerCase(); - _renderMovieGrid(content.querySelector('#movie-grid')); - }); - - content.querySelector('#movies-sort')?.addEventListener('change', async e => { - _sort = e.target.value; - const grid = content.querySelector('#movie-grid'); - grid.innerHTML = UI.skeleton(3); - await _loadFilme(); - _renderMovieGrid(grid); - }); - _renderMovieGrid(content.querySelector('#movie-grid')); } @@ -161,18 +106,7 @@ window.Page_movies = (() => { if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund); if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund); - if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0); - if (_search) { - list = list.filter(f => - (f.titel || '').toLowerCase().includes(_search) || - (f.hund_rasse || '').toLowerCase().includes(_search) || - (f.genre || '').toLowerCase().includes(_search) || - (f.beschreibung || '').toLowerCase().includes(_search) - ); - } - - const countEl = document.getElementById('movies-count'); - if (countEl) countEl.textContent = `${list.length} Einträge`; + if (_filter === 'top') list = list.filter(f => f.bewertung_avg >= 4.0); if (list.length === 0) { grid.innerHTML = `
    Keine Filme für diesen Filter.
    `; @@ -196,25 +130,18 @@ window.Page_movies = (() => { function _movieCard(film) { const stirbt = film.stirbt_der_hund; const tag = stirbt - ? `
    Hund stirbt
    ` - : `
    Hund überlebt
    `; + ? `
    ACHTUNG: Der Hund stirbt
    ` + : `
    Der Hund überlebt
    `; const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false); - const _ico = name => ``; - const typLabel = film.typ === 'serie' ? `${_ico('list')} Serie` : film.typ === 'doku' ? `${_ico('camera')} Doku` : ''; - const imdb = film.imdb_rating ? `IMDb ${film.imdb_rating}` : ''; - const streaming = film.streaming ? `${_esc(film.streaming)}` : ''; return `
    ${film.bild_emoji}
    ${_esc(film.titel)} (${film.jahr})
    -
    - ${_esc(film.genre)}${typLabel ? `${typLabel}` : ''} -
    +
    ${_esc(film.genre)}
    ${_esc(film.hund_rasse)}
    ${tag} -
    ${imdb}${streaming}
    ${stars}
    diff --git a/backend/static/js/pages/playdate.js b/backend/static/js/pages/playdate.js deleted file mode 100644 index 60eba05..0000000 --- a/backend/static/js/pages/playdate.js +++ /dev/null @@ -1,708 +0,0 @@ -/* ============================================================ - BAN YARO — Playdate-Matching - Spielkameraden in der Nähe finden, Inserate verwalten, Anfragen - ============================================================ */ - -window.Page_playdate = (() => { - - let _container = null; - let _appState = null; - let _activeTab = 'nearby'; // 'nearby' | 'listings' | 'requests' - let _userPos = null; - let _radius = 10; - let _dogs = []; - - // ------------------------------------------------------------------ - // Helpers - // ------------------------------------------------------------------ - function _esc(s) { - return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } - - function _fmtDate(iso) { - if (!iso) return ''; - const d = new Date(iso.replace(' ', 'T')); - return d.toLocaleDateString('de-DE'); - } - - function _dogAvatar(foto_url, name, size = 48) { - const initials = _esc((name || '?').charAt(0).toUpperCase()); - if (foto_url) { - return `${initials}`; - } - return `
    ${initials}
    `; - } - - function _statusBadge(status) { - const map = { - pending: ['warning', 'Ausstehend'], - accepted: ['success', 'Angenommen'], - declined: ['danger', 'Abgelehnt'], - }; - const [type, label] = map[status] || ['default', status]; - const colors = { - warning: 'var(--c-warning, #f59e0b)', - success: 'var(--c-success, #10b981)', - danger: 'var(--c-danger, #ef4444)', - default: 'var(--c-text-muted)', - }; - return `${label}`; - } - - // ------------------------------------------------------------------ - // INIT - // ------------------------------------------------------------------ - async function init(container, appState) { - _container = container; - _appState = appState; - _dogs = appState.dogs?.filter(d => !d.is_guest) || []; - _render(); - _switchTab(_activeTab); - } - - function refresh() { - _dogs = _appState?.dogs?.filter(d => !d.is_guest) || []; - _switchTab(_activeTab); - } - - function onDogChange() { - _dogs = _appState?.dogs?.filter(d => !d.is_guest) || []; - if (_activeTab === 'listings') _loadListings(); - } - - // ------------------------------------------------------------------ - // RENDER — Grundstruktur mit Tabs - // ------------------------------------------------------------------ - function _render() { - _container.innerHTML = ` -
    - - -
    - - - -
    - - -
    - -
    - `; - - document.getElementById('playdate-tabs').addEventListener('click', e => { - const btn = e.target.closest('.by-tab'); - if (!btn) return; - _switchTab(btn.dataset.tab); - }); - } - - function _switchTab(tab) { - _activeTab = tab; - document.querySelectorAll('#playdate-tabs .by-tab').forEach(b => { - b.classList.toggle('active', b.dataset.tab === tab); - }); - const content = document.getElementById('playdate-content'); - if (!content) return; - - if (tab === 'nearby') _renderNearby(content); - if (tab === 'listings') _renderListings(content); - if (tab === 'requests') _renderRequests(content); - } - - // ------------------------------------------------------------------ - // TAB: IN DER NÄHE - // ------------------------------------------------------------------ - async function _renderNearby(el) { - el.innerHTML = ` -
    - -
    -
    - ${UI.icon('map-pin')} - - ${_userPos ? 'Standort bekannt' : 'Kein Standort'} - -
    - - -
    - - -
    - ${UI.icon('info')} Nur Hunde von Nutzern die sich ebenfalls mit einem Inserat eingetragen haben. - Dein genauer Standort bleibt privat — es wird nur der Ortsname und die ungefähre Entfernung angezeigt. -
    - - -
    -

    - Standort wird ermittelt… -

    -
    -
    - `; - - document.getElementById('nearby-radius').addEventListener('change', e => { - _radius = parseInt(e.target.value, 10); - _loadNearby(); - }); - - document.getElementById('nearby-locate-btn').addEventListener('click', async () => { - const btn = document.getElementById('nearby-locate-btn'); - UI.setLoading(btn, true); - try { - _userPos = await API.getLocation(); - const label = document.getElementById('nearby-location-label'); - if (label) label.textContent = 'Standort aktualisiert'; - await _loadNearby(); - } catch { - UI.toast.error('Standort konnte nicht ermittelt werden.'); - } finally { - UI.setLoading(btn, false); - } - }); - - if (!_userPos) { - try { - _userPos = await API.getLocation(); - const label = document.getElementById('nearby-location-label'); - if (label) label.textContent = 'Standort bekannt'; - } catch { - document.getElementById('nearby-results').innerHTML = ` -
    - ${UI.icon('map-pin')} -

    - Standort konnte nicht automatisch ermittelt werden.
    - Klicke auf "Standort aktualisieren". -

    -
    - `; - return; - } - } - await _loadNearby(); - } - - async function _loadNearby() { - if (!_userPos) return; - const resultsEl = document.getElementById('nearby-results'); - if (!resultsEl) return; - resultsEl.innerHTML = `

    ${UI.icon('spinner')} Suche…

    `; - - try { - const data = await API.get(`/playdate/nearby?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=${_radius}`); - - if (!data || data.length === 0) { - resultsEl.innerHTML = UI.emptyState({ - icon: UI.icon('paw-print'), - title: 'Niemand in der Nähe', - text: `Aktuell hat sich noch niemand in ${_radius} km Umkreis eingetragen. Trage deinen Hund unter "Meine Inserate" ein!`, - }); - return; - } - - resultsEl.innerHTML = ` -
    - ${data.map(d => _nearbyCard(d)).join('')} -
    - `; - - resultsEl.querySelectorAll('.playdate-anfrage-btn').forEach(btn => { - btn.addEventListener('click', () => { - const toDogId = parseInt(btn.dataset.dogId, 10); - const dogName = btn.dataset.dogName; - _showRequestModal(toDogId, dogName); - }); - }); - - } catch (err) { - resultsEl.innerHTML = `

    ${err.message}

    `; - } - } - - function _nearbyCard(d) { - return ` -
    -
    - ${_dogAvatar(d.foto_url, d.dog_name, 56)} -
    -
    ${_esc(d.dog_name)}
    - ${d.rasse ? `
    ${_esc(d.rasse)}
    ` : ''} - ${d.alter ? `
    ${_esc(d.alter)}
    ` : ''} -
    -
    - -
    - - ${UI.icon('map-pin')} - ${d.ort_name ? _esc(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt - - ${d.geschlecht ? `${_esc(d.geschlecht)}` : ''} -
    - - ${d.beschreibung ? ` -

    - ${_esc(d.beschreibung)} -

    ` : ''} - - -
    - `; - } - - function _showRequestModal(toDogId, dogName) { - const formId = 'playdate-req-form'; - UI.modal.open({ - title: `Anfrage an ${dogName}`, - body: ` -
    -
    - - -
    -
    - `, - footer: ` - - - `, - }); - - document.getElementById('req-cancel-btn').addEventListener('click', () => UI.modal.close()); - document.getElementById('req-send-btn').addEventListener('click', async () => { - const btn = document.getElementById('req-send-btn'); - const nachricht = document.getElementById('req-nachricht').value.trim(); - await UI.asyncButton(btn, async () => { - const result = await API.post('/playdate/request', { - to_dog_id: toDogId, - nachricht: nachricht || null, - }); - UI.modal.close(); - UI.toast.success('Anfrage gesendet! Ein Chat wurde geöffnet.'); - // Zum Chat navigieren - if (result.conversation_id) { - setTimeout(() => { - App.navigate('chat', true, { conversation_id: result.conversation_id }); - }, 800); - } - }, { errorMsg: null }); - }); - } - - // ------------------------------------------------------------------ - // TAB: MEINE INSERATE - // ------------------------------------------------------------------ - async function _renderListings(el) { - el.innerHTML = `

    ${UI.icon('spinner')} Lädt…

    `; - await _loadListings(el); - } - - async function _loadListings(el) { - const target = el || document.getElementById('playdate-content'); - if (!target) return; - - if (_dogs.length === 0) { - target.innerHTML = UI.emptyState({ - icon: UI.icon('paw-print'), - title: 'Noch kein Hund', - text: 'Lege zuerst einen Hund in deinem Profil an.', - action: ``, - }); - return; - } - - // Listings für alle eigenen Hunde laden - const listings = {}; - await Promise.all(_dogs.map(async dog => { - try { - const data = await API.get(`/playdate/my-listing/${dog.id}`); - listings[dog.id] = data; - } catch { - listings[dog.id] = null; - } - })); - - target.innerHTML = ` -
    - ${_dogs.map(dog => _listingCard(dog, listings[dog.id])).join('')} -
    - `; - - // Event-Delegation für alle Buttons - target.addEventListener('click', async e => { - const btn = e.target.closest('button[data-action]'); - if (!btn) return; - const action = btn.dataset.action; - const dogId = parseInt(btn.dataset.dogId, 10); - const dog = _dogs.find(d => d.id === dogId); - - if (action === 'edit') { - _showListingModal(dog, listings[dogId], async () => { - await _loadListings(); - }); - } - if (action === 'deactivate') { - if (!window.confirm(`Inserat für ${dog?.name} deaktivieren?`)) return; - try { - await API.del(`/playdate/listing/${dogId}`); - UI.toast.success('Inserat deaktiviert.'); - await _loadListings(); - } catch (err) { - UI.toast.error(err.message); - } - } - }); - } - - function _listingCard(dog, listing) { - const isAktiv = listing && listing.aktiv; - return ` -
    -
    - ${_dogAvatar(dog.foto_url, dog.name, 44)} -
    -
    ${_esc(dog.name)}
    - ${dog.rasse ? `
    ${_esc(dog.rasse)}
    ` : ''} -
    - - ${isAktiv ? 'Aktiv' : 'Inaktiv'} - -
    - - ${isAktiv ? ` -
    - ${UI.icon('map-pin')} - ${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''} - Radius: ${listing.radius_km} km -
    - ${listing.beschreibung ? ` -

    ${_esc(listing.beschreibung)}

    ` : ''} - ` : ` -

    - Noch kein Inserat — trage dich ein, damit andere dich finden können. -

    - `} - -
    - - ${isAktiv ? ` - ` : ''} -
    -
    - `; - } - - function _showListingModal(dog, existing, onSaved) { - const formId = 'listing-form'; - UI.modal.open({ - title: `Inserat für ${dog.name}`, - body: ` -
    -
    - -
    - - -
    - - -
    - Tipp: Klicke auf ${UI.icon('crosshair')} um deinen Ortsnamen automatisch zu ermitteln. - Nur der Ortsname wird für andere sichtbar — nicht dein genauer Standort. -
    -
    - -
    - - -
    - -
    - - -
    -
    - `, - footer: ` - - - `, - }); - - // GPS-Button - document.getElementById('listing-gps-btn').addEventListener('click', async () => { - const gpsBtn = document.getElementById('listing-gps-btn'); - UI.setLoading(gpsBtn, true); - try { - const pos = await API.getLocation(); - document.getElementById('listing-lat').value = pos.lat; - document.getElementById('listing-lon').value = pos.lon; - - // Reverse-Geocoding für Ortsname - try { - const rev = await fetch( - `https://nominatim.openstreetmap.org/reverse?lat=${pos.lat}&lon=${pos.lon}&format=json&zoom=10&accept-language=de`, - { cache: 'no-store' } - ); - const geoData = await rev.json(); - const a = geoData.address || {}; - const ort = a.city || a.town || a.village || a.municipality || ''; - if (ort) document.getElementById('listing-ort').value = ort; - } catch {} - UI.toast.success('Standort ermittelt.'); - } catch { - UI.toast.error('Standort konnte nicht ermittelt werden.'); - } finally { - UI.setLoading(gpsBtn, false); - } - }); - - document.getElementById('listing-cancel-btn').addEventListener('click', () => UI.modal.close()); - - document.getElementById('listing-save-btn').addEventListener('click', async () => { - const btn = document.getElementById('listing-save-btn'); - const lat = parseFloat(document.getElementById('listing-lat').value); - const lon = parseFloat(document.getElementById('listing-lon').value); - const ort = document.getElementById('listing-ort').value.trim(); - const rad = parseInt(document.getElementById('listing-radius').value, 10); - const desc = document.getElementById('listing-beschreibung').value.trim(); - - if (!lat || !lon) { - UI.toast.error('Bitte ermittle zuerst deinen Standort über den GPS-Button.'); - return; - } - - await UI.asyncButton(btn, async () => { - await API.put('/playdate/listing', { - dog_id: dog.id, - lat, - lon, - ort_name: ort || null, - radius_km: rad, - beschreibung: desc || null, - }); - UI.modal.close(); - UI.toast.success('Inserat gespeichert!'); - onSaved?.(); - }, { errorMsg: null }); - }); - } - - // ------------------------------------------------------------------ - // TAB: ANFRAGEN - // ------------------------------------------------------------------ - async function _renderRequests(el) { - el.innerHTML = `

    ${UI.icon('spinner')} Lädt…

    `; - try { - const data = await API.get('/playdate/requests'); - const incoming = data.incoming || []; - const outgoing = data.outgoing || []; - - // Badge aktualisieren - const pendingCount = incoming.filter(r => r.status === 'pending').length; - const badge = document.getElementById('playdate-req-badge'); - if (badge) { - badge.textContent = pendingCount; - badge.style.display = pendingCount > 0 ? '' : 'none'; - } - - if (incoming.length === 0 && outgoing.length === 0) { - el.innerHTML = UI.emptyState({ - icon: UI.icon('paw-print'), - title: 'Noch keine Anfragen', - text: 'Wenn du oder jemand anderes eine Playdate-Anfrage schickt, erscheint sie hier.', - }); - return; - } - - el.innerHTML = ` -
    - - ${incoming.length > 0 ? ` -
    -

    Eingehende Anfragen

    -
    - ${incoming.map(r => _incomingCard(r)).join('')} -
    -
    ` : ''} - - ${outgoing.length > 0 ? ` -
    -

    Ausgehende Anfragen

    -
    - ${outgoing.map(r => _outgoingCard(r)).join('')} -
    -
    ` : ''} - -
    - `; - - // Button-Events (Accept/Decline) - el.querySelectorAll('.req-accept-btn, .req-decline-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const reqId = parseInt(btn.dataset.reqId, 10); - const status = btn.dataset.status; - await UI.asyncButton(btn, async () => { - const result = await API.patch(`/playdate/requests/${reqId}`, { status }); - if (status === 'accepted' && result.conversation_id) { - UI.toast.success('Anfrage angenommen! Chat wurde geöffnet.'); - setTimeout(() => { - App.navigate('chat', true, { conversation_id: result.conversation_id }); - }, 800); - } else { - UI.toast.success(status === 'accepted' ? 'Anfrage angenommen.' : 'Anfrage abgelehnt.'); - } - await _renderRequests(el); - }, { errorMsg: null }); - }); - }); - - // Chat-Buttons - el.querySelectorAll('.req-chat-btn').forEach(btn => { - btn.addEventListener('click', () => { - App.navigate('chat', true); - }); - }); - - } catch (err) { - el.innerHTML = `

    ${err.message}

    `; - } - } - - function _incomingCard(r) { - const isPending = r.status === 'pending'; - return ` -
    -
    - ${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)} -
    -
    ${_esc(r.from_dog_name)}
    -
    - ${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''} - ${r.alter ? _esc(r.alter) + ' · ' : ''} - von ${_esc(r.from_user_name)} -
    -
    ${_fmtDate(r.created_at)}
    -
    - ${_statusBadge(r.status)} -
    - - ${r.nachricht ? ` -
    - "${_esc(r.nachricht)}" -
    ` : ''} - - ${isPending ? ` -
    - - -
    ` : ` - ${r.status === 'accepted' ? ` - ` : ''} - `} -
    - `; - } - - function _outgoingCard(r) { - return ` -
    -
    - ${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)} -
    -
    ${_esc(r.to_dog_name)}
    -
    - ${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''} - von ${_esc(r.to_user_name)} -
    -
    ${_fmtDate(r.created_at)}
    -
    - ${_statusBadge(r.status)} -
    - - ${r.nachricht ? ` -

    - "${_esc(r.nachricht)}" -

    ` : ''} - - ${r.status === 'accepted' ? ` - ` : ''} -
    - `; - } - - // ------------------------------------------------------------------ - return { init, refresh, onDogChange }; -})(); diff --git a/backend/static/js/pages/recalls.js b/backend/static/js/pages/recalls.js deleted file mode 100644 index dfd9bbe..0000000 --- a/backend/static/js/pages/recalls.js +++ /dev/null @@ -1,188 +0,0 @@ -/* ============================================================ - BAN YARO — Tierfutter-Rückrufe - Seiten-Modul: RASFF EU Rückruf-Alarm für Heimtierfutter. - ============================================================ */ - -window.Page_recalls = (() => { - - // ---------------------------------------------------------- - // MODUL-STATE - // ---------------------------------------------------------- - let _container = null; - let _appState = null; - let _recalls = []; - let _query = ''; - - // ---------------------------------------------------------- - // INIT - // ---------------------------------------------------------- - async function init(container, appState) { - _container = container; - _appState = appState; - _query = ''; - await _render(); - } - - // ---------------------------------------------------------- - // REFRESH - // ---------------------------------------------------------- - async function refresh() { - _recalls = []; - _query = ''; - await _render(); - } - - // ---------------------------------------------------------- - // RENDER - // ---------------------------------------------------------- - async function _render() { - _container.innerHTML = ` - -
    - -

    - Hinweis: Prüfe immer das Mindesthaltbarkeitsdatum und die Chargen-Nummer - bevor du ein gemeldetes Produkt entsorgst oder zurückgibst. -

    -
    - - -
    - - -
    - - -
    ${UI.skeleton(4)}
    - `; - - // Suchfeld-Handler - _container.querySelector('#recalls-search').addEventListener('input', (e) => { - _query = e.target.value.trim(); - _renderList(); - }); - - await _loadRecalls(); - } - - // ---------------------------------------------------------- - // DATEN LADEN - // ---------------------------------------------------------- - async function _loadRecalls() { - try { - const url = _query ? `/recalls?q=${encodeURIComponent(_query)}` : '/recalls'; - _recalls = await API.get(url); - } catch { - _container.querySelector('#recalls-list').innerHTML = UI.emptyState({ - icon: 'warning-circle', - title: 'Rückrufe konnten nicht geladen werden', - text: 'Bitte versuche es später erneut.', - }); - return; - } - _renderList(); - } - - // ---------------------------------------------------------- - // LISTE RENDERN - // ---------------------------------------------------------- - function _renderList() { - const listEl = _container.querySelector('#recalls-list'); - if (!listEl) return; - - const filtered = _query - ? _recalls.filter(r => { - const q = _query.toLowerCase(); - return (r.titel || '').toLowerCase().includes(q) - || (r.produkt || '').toLowerCase().includes(q) - || (r.gefahr || '').toLowerCase().includes(q) - || (r.herkunft || '').toLowerCase().includes(q); - }) - : _recalls; - - if (!filtered.length) { - const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); - listEl.innerHTML = UI.emptyState({ - icon: UI.icon('check-circle'), - title: 'Aktuell keine Rückrufe', - text: `Letzte Prüfung: ${today}`, - }); - return; - } - - listEl.innerHTML = filtered.map(r => _cardHtml(r)).join(''); - } - - // ---------------------------------------------------------- - // EINZELNE KARTE - // ---------------------------------------------------------- - function _cardHtml(r) { - const datum = r.datum - ? new Date(r.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) - : ''; - - const meta = [ - r.herkunft ? `${UI.icon('globe-hemisphere-west')} ${UI.escape(r.herkunft)}` : '', - datum ? `${UI.icon('calendar-blank')} ${datum}` : '', - r.quelle ? `${UI.escape(r.quelle)}` : '', - ].filter(Boolean).join(' · '); - - const linkHtml = r.url - ? ` - ${UI.icon('arrow-square-out')} Details auf RASFF - ` - : ''; - - return ` -
    - -
    - - - ${UI.escape(r.produkt || r.titel)} - -
    - - - ${r.gefahr ? ` -

    - ${UI.escape(r.gefahr)} -

    ` : ''} - - -
    - ${meta} -
    - - - ${linkHtml ? `
    ${linkHtml}
    ` : ''} -
    - `; - } - - // ---------------------------------------------------------- - // PUBLIC API - // ---------------------------------------------------------- - return { init, refresh }; - -})(); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 8829bb6..7c3679d 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -263,12 +263,6 @@ window.Page_settings = (() => { Kalender abonnieren
    - - ${dog?.id ? `
    ` : ''} -

    Mehr entdecken

    ${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => ` @@ -499,85 +497,9 @@ window.Page_welcome = (() => { _updateChipsFromDash(dash); _tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen }).catch(() => { /* Skeleton bleibt sichtbar */ }); - - // Streak-Widget asynchron laden - _loadStreakWidget(dog.id); } } - // ---------------------------------------------------------- - // STREAK-WIDGET - // ---------------------------------------------------------- - async function _loadStreakWidget(dogId) { - const slot = _container.querySelector('#wc-streak-widget'); - if (!slot) return; - - let streak; - try { - streak = await API.get(`/streak/${dogId}`); - } catch { return; } - - if (!streak || (streak.current_streak === 0 && streak.longest_streak === 0)) return; - - slot.innerHTML = _streakWidgetHTML(streak); - - slot.querySelector('#wc-streak-leaderboard-btn')?.addEventListener('click', async () => { - const modalEl = UI.modal.open({ - title: '🔥 Trainings-Bestenliste', - body: '

    Wird geladen…

    ', - }); - let board; - try { board = await API.get('/streak/leaderboard'); } catch { board = []; } - const bodyEl = modalEl?.querySelector('.modal-body'); - if (bodyEl) bodyEl.innerHTML = _leaderboardHTML(board); - }); - } - - function _streakWidgetHTML(s) { - const cur = s.current_streak || 0; - const best = s.longest_streak || 0; - return ` -
    -
    - 🔥 - ${cur} -
    -
    -
    Tage in Folge trainiert
    -
    Rekord: ${best} ${best === 1 ? 'Tag' : 'Tage'}
    -
    - -
    `; - } - - function _leaderboardHTML(rows) { - if (!rows || !rows.length) { - return '

    Noch keine Einträge.

    '; - } - const medals = ['🥇', '🥈', '🥉']; - return ` -
    - ${rows.map((r, i) => ` -
    - ${medals[i] || (i + 1) + '.'} - ${r.foto_url - ? `` - : `
    `} -
    -
    ${UI.escape(r.dog_name)}
    -
    ${UI.escape(r.rasse || '')}${r.user_name ? ' · ' + UI.escape(r.user_name) : ''}
    -
    -
    - 🔥 - ${r.current_streak} -
    -
    - `).join('')} -
    `; - } - function _updateHeroFromDash(dash, dog) { const heroBox = _container.querySelector('#wc-hero-box'); if (!heroBox) return; diff --git a/backend/static/js/pages/wetter.js b/backend/static/js/pages/wetter.js deleted file mode 100644 index c838916..0000000 --- a/backend/static/js/pages/wetter.js +++ /dev/null @@ -1,581 +0,0 @@ -/* ============================================================ - BAN YARO — Wetter (7-Tage-Wettervorhersage) - Seiten-Modul: Hunde-optimierte Wettervorhersage mit GPS. - ============================================================ */ - -window.Page_wetter = (() => { - - // ---------------------------------------------------------- - // KONSTANTEN - // ---------------------------------------------------------- - // WMO-Code → Phosphor-Icon-Name (aus Sprite) - const WMO_ICON = { - 0:'sun', 1:'sun-dim', 2:'cloud-sun', 3:'cloud', - 45:'cloud-fog', 48:'cloud-fog', - 51:'cloud-rain', 53:'cloud-rain', 55:'cloud-rain', - 61:'cloud-rain', 63:'cloud-rain', 65:'cloud-rain', - 71:'cloud-snow', 73:'cloud-snow', 75:'cloud-snow', 77:'snowflake', - 80:'rainbow-cloud', 81:'cloud-rain', 82:'cloud-rain', - 85:'cloud-snow', 86:'cloud-snow', - 95:'cloud-lightning', 96:'cloud-lightning', 99:'cloud-lightning', - }; - // Farben passend zum Wetter (für Icon-Tinting) - const WMO_COLOR = { - 0:'#F59E0B', 1:'#F59E0B', 2:'#94A3B8', 3:'#64748B', - 45:'#94A3B8', 48:'#94A3B8', - 51:'#60A5FA', 53:'#3B82F6', 55:'#2563EB', - 61:'#3B82F6', 63:'#2563EB', 65:'#1D4ED8', - 71:'#BAE6FD', 73:'#7DD3FC', 75:'#38BDF8', 77:'#BAE6FD', - 80:'#60A5FA', 81:'#3B82F6', 82:'#2563EB', - 85:'#7DD3FC', 86:'#38BDF8', - 95:'#7C3AED', 96:'#6D28D9', 99:'#5B21B6', - }; - function _wmoIcon(code, size = '2rem', extraStyle = '') { - const name = WMO_ICON[code] || 'cloud'; - const color = WMO_COLOR[code] || 'var(--c-text-secondary)'; - return ``; - } - - const WMO_DESC = { - 0:'Klarer Himmel', 1:'Überwiegend klar', 2:'Teilweise bewölkt', 3:'Bedeckt', - 45:'Nebel', 48:'Gefrierender Nebel', - 51:'Leichter Sprühregen', 53:'Mäßiger Sprühregen', 55:'Starker Sprühregen', - 61:'Leichter Regen', 63:'Mäßiger Regen', 65:'Starker Regen', - 71:'Leichter Schneefall', 73:'Mäßiger Schneefall', 75:'Starker Schneefall', 77:'Schneekörner', - 80:'Leichte Regenschauer', 81:'Mäßige Regenschauer', 82:'Starke Regenschauer', - 85:'Leichte Schneeschauer', 86:'Starke Schneeschauer', - 95:'Gewitter', 96:'Gewitter mit leichtem Hagel', 99:'Gewitter mit starkem Hagel' - }; - - const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; - - // ---------------------------------------------------------- - // MODUL-STATE - // ---------------------------------------------------------- - let _container = null; - let _appState = null; - let _data = null; - let _selDay = 0; - let _loading = false; - - // ---------------------------------------------------------- - // INIT - // ---------------------------------------------------------- - async function init(container, appState) { - _container = container; - _appState = appState; - _selDay = 0; - _renderShell(); - _tryAutoLocate(); - } - - // ---------------------------------------------------------- - // REFRESH - // ---------------------------------------------------------- - async function refresh() { - _selDay = 0; - _renderShell(); - _tryAutoLocate(); - } - - // ---------------------------------------------------------- - // RENDER — Grundstruktur - // ---------------------------------------------------------- - function _renderShell() { - _container.innerHTML = ` -
    -
    -
    ${_wmoIcon(2, '2.5rem')}
    -

    Standort wird ermittelt…

    -
    -
    - `; - } - - // ---------------------------------------------------------- - // STANDORT AUTOMATISCH ERMITTELN - // ---------------------------------------------------------- - async function _tryAutoLocate() { - try { - const pos = await API.getLocation({ timeout: 8000, maximumAge: 300_000 }); - await _loadData(pos.lat, pos.lon); - } catch { - _showLocationError(); - } - } - - function _showLocationError() { - const body = _container.querySelector('#wttr-body'); - if (!body) return; - body.innerHTML = ` -
    -
    📍
    -

    Standort nicht verfügbar

    -

    - Bitte erlaube den Zugriff auf deinen Standort, um die Wettervorhersage zu laden. -

    - -
    - `; - body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => { - _renderShell(); - _tryAutoLocate(); - }); - } - - // ---------------------------------------------------------- - // DATEN LADEN - // ---------------------------------------------------------- - async function _loadData(lat, lon) { - if (_loading) return; - _loading = true; - try { - _data = await API.weather.forecast(lat, lon); - _selDay = 0; - _renderWeather(); - } catch { - const body = _container.querySelector('#wttr-body'); - if (body) body.innerHTML = ` -
    -
    ⚠️
    -

    Wetter nicht verfügbar

    -

    - Die Wetterdaten konnten nicht geladen werden. -

    - -
    - `; - body?.querySelector('#wttr-btn-reload')?.addEventListener('click', () => { - refresh(); - }); - } finally { - _loading = false; - } - } - - // ---------------------------------------------------------- - // HAUPT-RENDER - // ---------------------------------------------------------- - function _renderWeather() { - const body = _container.querySelector('#wttr-body'); - if (!body || !_data) return; - - const days = _data.days || []; - if (!days.length) return; - - body.innerHTML = ` - -
    -
    - ${days.map((d, i) => _dayCard(d, i)).join('')} -
    -
    - - -
    -
    - - -
    -
    - `; - - // Strip-Klick-Events - body.querySelectorAll('[data-wttr-day]').forEach(card => { - card.addEventListener('click', () => { - _selDay = parseInt(card.dataset.wttrDay); - _updateStrip(); - _renderDetail(); - _renderDog(); - }); - }); - - _renderDetail(); - _renderDog(); - } - - // ---------------------------------------------------------- - // STRIP AKTUALISIEREN (aktiver Tag) - // ---------------------------------------------------------- - function _updateStrip() { - const body = _container.querySelector('#wttr-body'); - if (!body) return; - const days = _data?.days || []; - body.querySelectorAll('[data-wttr-day]').forEach((card, i) => { - const active = i === _selDay; - card.style.background = active ? 'var(--c-primary)' : 'var(--c-bg-card)'; - card.style.color = active ? '#fff' : 'var(--c-text)'; - card.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)'; - card.style.transform = active ? 'translateY(-2px)' : ''; - card.style.boxShadow = active ? '0 4px 12px rgba(196,132,58,0.3)' : '0 1px 3px rgba(0,0,0,0.07)'; - // Temperatur-Farbe im aktiven Zustand - const tempEl = card.querySelector('.wttr-temp'); - if (tempEl) tempEl.style.color = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)'; - const precipEl = card.querySelector('.wttr-precip'); - if (precipEl) precipEl.style.color = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)'; - }); - } - - // ---------------------------------------------------------- - // TAG-KARTE (Strip) - // ---------------------------------------------------------- - function _dayCard(d, i) { - const active = i === _selDay; - const dateObj = new Date(d.date); - const dayName = i === 0 ? 'Heute' : DAY_NAMES[dateObj.getDay()]; - const bg = active ? 'var(--c-primary)' : 'var(--c-bg-card)'; - const col = active ? '#fff' : 'var(--c-text)'; - const shadow = active - ? '0 4px 12px rgba(196,132,58,0.3)' - : '0 1px 3px rgba(0,0,0,0.07)'; - const border = active ? 'var(--c-primary)' : 'var(--c-border)'; - const transform = active ? 'translateY(-2px)' : ''; - const textSec = active ? 'rgba(255,255,255,0.85)' : 'var(--c-text-secondary)'; - const textMut = active ? 'rgba(255,255,255,0.75)' : 'var(--c-text-secondary)'; - - return ` -
    - ${_esc(dayName)} -
    ${_wmoIcon(d.weathercode, '1.5rem', active ? 'filter:brightness(0) invert(1)' : '')}
    - - ${Math.round(d.temp_max)}°/${Math.round(d.temp_min)}° - - - ${d.precip_prob ?? 0}% - -
    - `; - } - - // ---------------------------------------------------------- - // DETAIL-CARD - // ---------------------------------------------------------- - function _renderDetail() { - const el = _container.querySelector('#wttr-detail'); - if (!el || !_data) return; - const d = (_data.days || [])[_selDay]; - if (!d) return; - - const desc = WMO_DESC[d.weathercode] || ''; - const [uvLabel, uvColor] = _uvLabel(d.uv_index ?? 0); - const uvPct = Math.min(100, ((d.uv_index ?? 0) / 11) * 100); - const bft = _beaufort(d.wind_kmh ?? 0); - const windDir = d.wind_dir_deg ?? 0; - const compass = d.wind_dir ?? _compass(windDir); - - // Sunrise/Sunset Balken - const now = new Date(); - const sunriseStr = d.sunrise || ''; - const sunsetStr = d.sunset || ''; - let sunPct = 0; - if (sunriseStr && sunsetStr) { - const [rH, rM] = sunriseStr.split(':').map(Number); - const [sH, sM] = sunsetStr.split(':').map(Number); - const riseMin = rH * 60 + rM; - const setMin = sH * 60 + sM; - const curMin = now.getHours() * 60 + now.getMinutes(); - sunPct = _selDay === 0 - ? Math.min(100, Math.max(0, ((curMin - riseMin) / (setMin - riseMin)) * 100)) - : 0; - } - - el.innerHTML = ` -
    - ${_wmoIcon(d.weathercode, '3.5rem')} -
    -
    ${_esc(desc)}
    -
    - ${Math.round(d.temp_max)}° - - / ${Math.round(d.temp_min)}° - -
    - ${d.feels_max != null ? ` -
    - Gefühlt ${Math.round(d.feels_max)}° / ${Math.round(d.feels_min ?? d.feels_max)}° -
    ` : ''} -
    -
    - - - ${sunriseStr && sunsetStr ? ` -
    -
    - - - ${_esc(sunriseStr)} - - - ${_esc(sunsetStr)} - - -
    -
    -
    -
    -
    ` : ''} - - -
    - - ${UI.icon('arrow-up')} - -
    -
    - ${_esc(compass)} · ${Math.round(d.windspeed_max ?? 0)} km/h -
    -
    ${_esc(bft)}
    -
    - ${d.precip_sum != null ? ` -
    -
    - ${d.precip_sum} mm -
    -
    Niederschlag
    -
    ` : ''} -
    - - -
    -
    - UV-Index - - ${d.uv_index ?? 0} — ${_esc(uvLabel)} - -
    -
    -
    -
    -
    - `; - } - - // ---------------------------------------------------------- - // HUNDE-WETTER - // ---------------------------------------------------------- - function _renderDog() { - const el = _container.querySelector('#wttr-dog'); - if (!el || !_data) return; - const d = (_data.days || [])[_selDay]; - if (!d) return; - - const _POLLEN_NAMES = { erle:'Erle', birke:'Birke', graeser:'Gräser', beifuss:'Beifuß', ambrosia:'Ambrosia' }; - let html = `

    - - Hunde-Wetter -

    `; - - // Asphalt-Temperatur - if (d.asphalt_temp != null) { - const [aspText, aspColor, aspAdvice] = _asphaltLevel(d.asphalt_temp); - html += ` -
    - -
    -
    - Asphalt ~${Math.round(d.asphalt_temp)}°C — ${_esc(aspText)} -
    - ${aspAdvice ? `
    - ${_esc(aspAdvice)} -
    ` : ''} -
    -
    - `; - } - - // Pfoten-Kälteschutz - if (d.paw_cold) { - html += ` -
    - -
    - Kälteschutz für Pfoten: - Eis und Streusalz können die Pfoten reizen. Pfotenpflege empfohlen. -
    -
    - `; - } - - // Gewitter - if (d.thunderstorm) { - html += ` -
    - -
    - Gewitter erwartet: - Hunde können auf Gewitter sensibel reagieren. Sichere Umgebung schaffen. -
    -
    - `; - } - - // Pollenflug - const pollen = d.pollen; - if (pollen && typeof pollen === 'object' && Object.keys(pollen).length) { - const pollenEntries = Object.entries(pollen) - .filter(([, v]) => v != null && v.level > 0); - if (pollenEntries.length) { - html += ` -
    -
    - - Pollenflug -
    -
    - ${pollenEntries.map(([key, lvlObj]) => { - const col = _pollenColor(lvlObj?.level ?? 0); - const name = _POLLEN_NAMES[key] || key; - const lbl = lvlObj?.label || ''; - return ` - - ${_esc(name)}: ${_esc(lbl)} - `; - }).join('')} -
    -
    - `; - } - } - - // Zecken - if (d.zecken != null) { - const [tickLabel, tickColor] = _tickLevel(d.zecken); - html += ` -
    - -
    - Zecken-Risiko: - - ${_esc(tickLabel)} - -
    -
    - `; - } - - // Wenn keine Hunde-Daten vorhanden - if (!d.asphalt_temp && !d.paw_cold && !d.thunderstorm - && !d.zecken && !(pollen && Object.keys(pollen).length)) { - html += ` -

    - Keine besonderen Hinweise für heute. -

    - `; - } - - el.innerHTML = html; - } - - // ---------------------------------------------------------- - // HILFSFUNKTIONEN — Wetter - // ---------------------------------------------------------- - function _beaufort(kmh) { - if (kmh < 2) return 'Windstille'; - if (kmh < 12) return 'leicht'; - if (kmh < 29) return 'mäßig'; - if (kmh < 50) return 'frisch'; - if (kmh < 62) return 'stark'; - if (kmh < 75) return 'stürmisch'; - return 'Sturm'; - } - - function _uvLabel(uv) { - if (uv <= 2) return ['niedrig', '#4CAF50']; - if (uv <= 5) return ['mittel', '#FFC107']; - if (uv <= 7) return ['hoch', '#FF9800']; - if (uv <= 10) return ['sehr hoch', '#F44336']; - return ['extrem', '#9C27B0']; - } - - function _compass(deg) { - const dirs = ['N','NO','O','SO','S','SW','W','NW']; - return dirs[Math.round(deg / 45) % 8]; - } - - function _asphaltLevel(temp) { - if (temp < 40) return ['Pfoten sicher', '#4CAF50', '']; - if (temp < 50) return ['leicht erwärmt', '#FFC107', - 'Kurze Kontaktzeiten sind unbedenklich.']; - if (temp < 60) return ['Vorsicht — Pfoten schützen!', '#FF9800', - 'Heiße Oberfläche! Auf Gras ausweichen oder Hundeschuhe verwenden.']; - return ['GEFAHR — Verbrennungsgefahr!', '#F44336', - 'Asphalt kann Pfoten in Sekunden verbrennen. Spaziergang vermeiden!']; - } - - function _pollenColor(level) { - if (level === 0) return '#9E9E9E'; - if (level === 1) return '#4CAF50'; - if (level === 2) return '#FFC107'; - if (level === 3) return '#FF9800'; - return '#F44336'; // level 4+ - } - - function _tickLevel(risk) { - const r = (risk || '').toLowerCase(); - if (r === 'niedrig') return ['niedrig', '#4CAF50']; - if (r === 'mittel') return ['mittel', '#FF9800']; - return ['hoch', '#F44336']; - } - - function _esc(s) { - if (s == null) return ''; - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - - // ---------------------------------------------------------- - // PUBLIC API - // ---------------------------------------------------------- - return { init, refresh }; - -})(); diff --git a/backend/static/js/pages/wiki.js b/backend/static/js/pages/wiki.js index b2b6390..69f2e4a 100644 --- a/backend/static/js/pages/wiki.js +++ b/backend/static/js/pages/wiki.js @@ -255,15 +255,6 @@ window.Page_wiki = (() => {
    -
    - - -
    💬 -

    Forum

    Öffentlich lesbar ohne Anmeldung. Kategorien nach Rasse, Region, Gesundheit und Erziehung. Schreiben nach E-Mail-Verifikation — für Qualität statt Spam.

    Kostenlos
    +

    Forum

    Rassen-basierte Foren, KI-Zusammenfassungen langer Threads, Experten-Badge für Tierärzte und Trainer.

    Kostenlos
    @@ -539,7 +528,7 @@
    🎬 -

    Hunde-Filmdatenbank

    68 Filme, Serien und Dokumentationen — sortierbar nach Jahr, IMDb-Bewertung oder Community-Rating. Mit der wichtigsten Frage: "Stirbt der Hund?" Nie wieder unvorbereitet stolpern.

    Kostenlos
    +

    Hundefilme

    Filmdatenbank mit der wichtigsten Frage: "Stirbt der Hund?" Nie wieder unvorbereitet in einen Film stolpern.

    Kostenlos
    🩹 @@ -615,14 +604,13 @@

    Ban Yaro vs. Konkurrenz

    -

    Andere Apps decken einzelne Bereiche ab — Ban Yaro vereint alles in einer DSGVO-konformen Plattform. Kein anderer Anbieter kombiniert Community, Training, Zucht und KI auf Deutsch.

    +

    Andere Apps decken einzelne Bereiche ab — Ban Yaro vereint alles in einer DSGVO-konformen Plattform ohne US-Datenweitergabe.

    - @@ -633,14 +621,12 @@ - - - + @@ -651,15 +637,6 @@ - - - - - - - - - @@ -667,70 +644,54 @@ - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + - - + - + + + + + + + @@ -739,15 +700,13 @@ - - + -
    Funktion Ban YaroHundeo (DE) Dogorama Tractive PetDeskKostenlos nutzbar ✓ Ja BegrenztBegrenzt ✗ Abo ✗ Nein
    DSGVO / EU-Hosting✓ DE✓ DE✓ Ja ✗ Nein Teilweise ✗ USA
    KI-Hundetrainer
    Giftköder-Alarm
    Digitaler Impfpass
    Forum & CommunityGassi-Community
    Gassi-Treffen & Community
    Wurfbörse & Zucht-Management
    Stammbaum & Inzucht-Check
    Hundesitting✓ 8%✓ (8%)
    NFC-Halsband-Tag
    Verlorener Hund Alarm ✓ GPS✓ (GPS)
    Rassen-Wiki (1003 Rassen, KI)Rassen-Wiki (1003 Rassen, KI-angereichert)
    Pflege-Tipps rassenspezifisch
    Täglicher RoutenvorschlagTäglicher Routenvorschlag (Gassirunde)
    @@ -850,20 +809,6 @@

    Karten von OpenStreetMap statt Google — keine Tracking-Cookies, kein API-Lock-in, günstiger für alle.

    -
    - 🔐 -
    -

    Aktive Sicherheit

    -

    HSTS, Content-Security-Policy, Rate Limiting auf allen Endpunkten, Account-Lockout nach Fehlversuchen, E-Mail-Verifikation. Sicherheit by Default, nicht als Nachgedanke.

    -
    -
    -
    - 🤖 -
    -

    KI Made in Europe

    -

    Alle KI-Funktionen laufen über Claude (Anthropic) — kein Training mit deinen Daten, kein Opt-out nötig, volle DSGVO-Konformität.

    -
    -
    @@ -879,16 +824,12 @@ diff --git a/backend/static/manifest.json b/backend/static/manifest.json index fbed5f2..fd71b5d 100644 --- a/backend/static/manifest.json +++ b/backend/static/manifest.json @@ -1,6 +1,6 @@ { "id": "/", - "version": "1.3.0", + "version": "1.1.4", "name": "Ban Yaro — Die Hunde-Plattform", "short_name": "Ban Yaro", "description": "Alles rund um deinen Hund. Von Welpe bis Opa.", diff --git a/backend/static/presse.html b/backend/static/presse.html deleted file mode 100644 index 2ea2e54..0000000 --- a/backend/static/presse.html +++ /dev/null @@ -1,320 +0,0 @@ - - - - - - Presse – Ban Yaro - - - - - - - -
    - Ban Yaro Icon -
    Ban Yaro
    - ← Zur App -
    - -
    - -

    Pressematerial

    -

    Logos, Screenshots und Hintergrundinformationen für Redaktionen. Alle Materialien sind zur redaktionellen Verwendung freigegeben.

    - - -
    - -
    -
    Ebersberg, 1. Mai 2026 — zur sofortigen Veröffentlichung freigegeben
    -

    Vom Gipfelfoto bis zum Giftköder-Alarm: App begleitet Hundehalter durch den ganzen Alltag

    -

    banyaro.app bündelt Tagebuch, Gesundheitsakte und Echtzeit-Warnungen in einer kostenlosen Hunde-App

    - -

    Manche Gassi-Runden sind einfach unvergesslich — der erste Schnee, der perfekte Sonnenuntergang, die Stelle am Bach, an der der Hund immer ins Wasser springt. Andere hinterlassen Angst: Ein verdächtiges Häufchen am Wegesrand, ein Hund der plötzlich würgt.

    - -

    Für beides gibt es jetzt eine App: banyaro.app ist eine kostenlose Hunde-App aus Bayern, die den ganzen Alltag mit Hund begleitet — von den schönsten Momenten bis zu den gefährlichen.

    - -

    Im Hunde-Tagebuch lassen sich Fotos, Notizen und Erinnerungen sammeln, in der Gesundheitsakte Impftermine, Medikamente und Tierarztbesuche verwalten. Die interaktive Karte zeigt die besten Hundewiesen, Wasserstellen und Auslaufgebiete in der Umgebung — und die schönsten Routen für die nächste Gassi-Runde.

    - -

    Der Giftköder-Alarm funktioniert nach dem Prinzip der Schwarmintelligenz: Wer einen verdächtigen Fund meldet, macht ihn sofort auf der Karte für alle anderen Hundehalter in der Region sichtbar. Keine Facebook-Gruppe, kein verschwundener Post — die Warnung bleibt dauerhaft abrufbar.

    - -
    „Ich wollte eine App bauen, die sich wie ein stiller Begleiter anfühlt — die im Hintergrund läuft, Erinnerungen sammelt und im Ernstfall sofort warnt. Kein App Store, keine Kosten, keine Werbung."
    — René Degelmann, Gründer
    - -

    banyaro.app ist direkt unter banyaro.app erreichbar — ohne Installation, direkt im Smartphone-Browser.

    -
    -
    - - -
    - -
    - -

    Ban Yaro ist eine kostenlose Hunde-App für den deutschsprachigen Raum. Die App läuft als Progressive Web App direkt im Smartphone-Browser — ohne Installation über den App Store. Funktionen: Hunde-Tagebuch mit Fotos und Wetter, digitale Gesundheitsakte, interaktive Karte mit Hundewiesen und Giftköder-Alarm, Community-Forum und Trainingspläne. Gegründet 2024 von René Degelmann, Ebersberg bei München. Erreichbar unter banyaro.app.

    -
    -
    - - -
    - - -
    - - -
    - - -
    - - -
    - -
    - René Degelmann mit Ban Yaro -
    -

    René Degelmann

    -
    Gründer & Entwickler, Ban Yaro
    -

    René Degelmann ist Softwareentwickler aus Ebersberg bei München. Ban Yaro hat er für seinen eigenen Hund gebaut — und dann gemerkt, dass tausende andere Hundehalter das gleiche brauchen. Die App entstand ohne Investoren, ohne App-Store-Zwang und ohne Werbung.

    -
    -
    -

    Foto zur redaktionellen Verwendung freigegeben — herunterladen ↓

    -
    - - -
    - -
    -
    Gegründet
    2026
    -
    Sitz
    Ebersberg bei München
    -
    Plattform
    Progressive Web App
    -
    Preis
    Kostenlos
    -
    Sprache
    Deutsch
    -
    Zielmarkt
    D-A-CH
    -
    -
    - - -
    - -
    -

    René Degelmann

    -

    Ringstr. 26 · 85560 Ebersberg

    -

    Telefon: 0171 1209622

    -

    E-Mail: partner@banyaro.app

    -

    Web: banyaro.app

    -
    -
    - -
    - - - - - diff --git a/backend/static/sw.js b/backend/static/sw.js index e786916..d3afae4 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v651'; +const CACHE_VERSION = 'by-v577'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache @@ -125,34 +125,11 @@ const _CACHEABLE_GET = [ /^\/api\/training\/progress/, /^\/api\/wiki\/rassen/, /^\/api\/dogs\/\d+\/diary\/stats/, - // Drei Welten — offline-fähig - /^\/api\/streak\/\d+/, - /^\/api\/forum\/threads/, - /^\/api\/weather$/, - /^\/api\/passport\/\d+$/, ]; function _isCacheableGet(pathname) { return _CACHEABLE_GET.some(re => re.test(pathname)); } -// Cache-TTL: stabile Daten länger, dynamische kürzer -const _STABLE_GET = [/^\/api\/training\/exercises/, /^\/api\/wiki\/rassen/]; -const _TTL_STABLE = 60 * 60 * 1000; // 1 Stunde -const _TTL_DEFAULT = 5 * 60 * 1000; // 5 Minuten - -const _cacheTs = new Map(); // pathname → timestamp (in-memory, ok bei SW-Neustart) - -function _cacheTTL(pathname) { - return _STABLE_GET.some(re => re.test(pathname)) ? _TTL_STABLE : _TTL_DEFAULT; -} -function _cacheStale(pathname) { - const ts = _cacheTs.get(pathname); - return !ts || (Date.now() - ts) > _cacheTTL(pathname); -} -function _cacheMark(pathname) { - _cacheTs.set(pathname, Date.now()); -} - // ---------------------------------------------------------- // INSTALL — App Shell cachen // ---------------------------------------------------------- @@ -196,27 +173,19 @@ self.addEventListener('fetch', event => { if (method === 'GET' && _isCacheableGet(url.pathname)) { event.respondWith((async () => { const cached = await caches.match(event.request); - const stale = _cacheStale(url.pathname); - const networkPromise = _fetchTimeout(event.request.clone(), 8000) .then(resp => { - if (resp.ok) { - _cacheMark(url.pathname); - caches.open(CACHE_API).then(c => c.put(event.request, resp.clone())); - } + if (resp.ok) caches.open(CACHE_API).then(c => c.put(event.request, resp.clone())); return resp; }) .catch(() => null); - - // Cache noch frisch → sofort zurückgeben, Netz im Hintergrund - if (cached && !stale) { - networkPromise.catch(() => {}); + // Stale-While-Revalidate: sofort aus Cache, im Hintergrund holen + if (cached) { + networkPromise.catch(() => {}); // fire and forget return cached; } - // Cache vorhanden aber abgelaufen → Netz zuerst, Cache als Fallback const fresh = await networkPromise; if (fresh) return fresh; - if (cached) return cached; // lieber veraltet als nichts return new Response(JSON.stringify({ detail: 'Offline — keine Daten im Cache.' }), { status: 503, headers: { 'Content-Type': 'application/json' } }); })()); diff --git a/backend/weather.py b/backend/weather.py index e5f2317..672be1e 100644 --- a/backend/weather.py +++ b/backend/weather.py @@ -161,251 +161,3 @@ async def get_weather_summary() -> dict: logger.info(f"Wetter-Zusammenfassung: max_temp={max_temp}°C, thunderstorm={thunderstorm}") return {"max_temp_c": max_temp, "thunderstorm": thunderstorm} - - -# --------------------------------------------------------------------------- -# 7-Tage-Vorhersage -# --------------------------------------------------------------------------- -import asyncio # noqa: E402 — appended section - -_forecast_cache: dict = {} -_FORECAST_TTL = 3600 # 1 Stunde - -_WDAY_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] - - -def _wind_dir(deg: float) -> str: - dirs = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW'] - idx = round(deg / 45) % 8 - return dirs[idx] - - -def _asphalt_temp(air_max: float, uv_max: float) -> tuple[float, str]: - bonus = min(uv_max * 3.0, 30.0) - asphalt = air_max + bonus - if asphalt <= 30: - warn = 'safe' - elif asphalt <= 40: - warn = 'warm' - elif asphalt <= 55: - warn = 'hot' - else: - warn = 'danger' - return round(asphalt, 1), warn - - -def _pollen_lvl(val: float | None) -> dict: - if val is None: - return {'level': 0, 'label': 'keine'} - if val < 5: - return {'level': 1, 'label': 'niedrig'} - if val < 25: - return {'level': 2, 'label': 'mittel'} - if val < 100: - return {'level': 3, 'label': 'hoch'} - return {'level': 4, 'label': 'sehr hoch'} - - -async def get_forecast(lat: float, lon: float) -> dict: - """7-Tage-Wettervorhersage inkl. Pollen, Asphalttemperatur, Zecken. 1h TTL-Cache.""" - key = (round(lat, 2), round(lon, 2)) - now = time.time() - if key in _forecast_cache: - ts, cached = _forecast_cache[key] - if now - ts < _FORECAST_TTL: - return cached - - forecast_url = ( - "https://api.open-meteo.com/v1/forecast" - f"?latitude={lat}&longitude={lon}" - "&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max," - "apparent_temperature_min,precipitation_probability_max,precipitation_sum," - "weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max," - "sunrise,sunset" - "&timezone=auto&forecast_days=7" - ) - pollen_url = ( - "https://air-quality-api.open-meteo.com/v1/air-quality" - f"?latitude={lat}&longitude={lon}" - "&hourly=alder_pollen,birch_pollen,grass_pollen,mugwort_pollen,ragweed_pollen" - "&timezone=auto&forecast_days=7" - ) - - async with httpx.AsyncClient(timeout=10.0) as client: - forecast_task = client.get(forecast_url) - pollen_task = client.get(pollen_url) - forecast_resp, pollen_resp = await asyncio.gather( - forecast_task, pollen_task, return_exceptions=True - ) - - # --- Forecast (required) --- - if isinstance(forecast_resp, Exception): - raise forecast_resp - forecast_resp.raise_for_status() - raw = forecast_resp.json() - - daily = raw.get('daily', {}) - timezone = raw.get('timezone', 'auto') - - dates = daily.get('time', []) - temp_max = daily.get('temperature_2m_max', []) - temp_min = daily.get('temperature_2m_min', []) - feels_max = daily.get('apparent_temperature_max', []) - feels_min = daily.get('apparent_temperature_min', []) - precip_prob = daily.get('precipitation_probability_max', []) - precip_sum = daily.get('precipitation_sum', []) - wcodes = daily.get('weathercode', []) - wind_kmh = daily.get('windspeed_10m_max', []) - wind_deg = daily.get('winddirection_10m_dominant', []) - uv_index = daily.get('uv_index_max', []) - sunrises = daily.get('sunrise', []) - sunsets = daily.get('sunset', []) - - # --- Pollen (optional) --- - pollen_daily: dict | None = None - if not isinstance(pollen_resp, Exception): - try: - pollen_resp.raise_for_status() - praw = pollen_resp.json() - hourly = praw.get('hourly', {}) - htimes = hourly.get('time', []) - # aggregate hourly → daily max per type - pollen_types = { - 'erle': hourly.get('alder_pollen', []), - 'birke': hourly.get('birch_pollen', []), - 'graeser': hourly.get('grass_pollen', []), - 'beifuss': hourly.get('mugwort_pollen', []), - 'ambrosia': hourly.get('ragweed_pollen', []), - } - # build date → max mapping per type - pollen_daily = {ptype: {} for ptype in pollen_types} - for i, ts_str in enumerate(htimes): - day_str = ts_str[:10] # 'YYYY-MM-DD' - for ptype, vals in pollen_types.items(): - v = vals[i] if i < len(vals) else None - if v is not None: - prev = pollen_daily[ptype].get(day_str) - pollen_daily[ptype][day_str] = max(prev, v) if prev is not None else v - except Exception as e: - logger.warning(f"Pollen-Abruf fehlgeschlagen: {e}") - pollen_daily = None - - # --- Assemble days --- - days = [] - for i, date_str in enumerate(dates): - wcode = int(wcodes[i]) if i < len(wcodes) and wcodes[i] is not None else 0 - desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud')) - - t_max = temp_max[i] if i < len(temp_max) else None - t_min = temp_min[i] if i < len(temp_min) else None - f_max = feels_max[i] if i < len(feels_max) else None - f_min = feels_min[i] if i < len(feels_min) else None - pp = precip_prob[i] if i < len(precip_prob) else None - ps = precip_sum[i] if i < len(precip_sum) else None - wk = wind_kmh[i] if i < len(wind_kmh) else None - wd_deg = wind_deg[i] if i < len(wind_deg) else None - uv = uv_index[i] if i < len(uv_index) else None - - # Sunrise / Sunset → HH:MM only (format: "2025-05-02T06:12") - sunrise_raw = sunrises[i] if i < len(sunrises) else None - sunset_raw = sunsets[i] if i < len(sunsets) else None - sunrise_hm = sunrise_raw[11:16] if sunrise_raw and len(sunrise_raw) >= 16 else sunrise_raw - sunset_hm = sunset_raw[11:16] if sunset_raw and len(sunset_raw) >= 16 else sunset_raw - - # Weekday - try: - dt_obj = datetime.strptime(date_str, '%Y-%m-%d') - wday = _WDAY_DE[dt_obj.weekday()] - except Exception: - wday = '' - - # Asphalt - asphalt_t, asphalt_w = _asphalt_temp(t_max or 0.0, uv or 0.0) - - # Zecken - month = datetime.strptime(date_str, '%Y-%m-%d').month - zecken = None - if t_max is not None and t_max > 7.0 and 3 <= month <= 10: - zecken = 'hoch' if t_max > 20 else ('mittel' if t_max > 12 else 'niedrig') - - # Pollen - if pollen_daily is not None: - pollen_out = { - pt: _pollen_lvl(pollen_daily[pt].get(date_str)) - for pt in ('erle', 'birke', 'graeser', 'beifuss', 'ambrosia') - } - else: - pollen_out = None - - days.append({ - 'date': date_str, - 'wday': wday, - 'weathercode': wcode, - 'desc': desc, - 'icon': icon, - 'temp_max': t_max, - 'temp_min': t_min, - 'feels_max': f_max, - 'feels_min': f_min, - 'precip_prob': pp, - 'precip_sum': ps, - 'wind_kmh': wk, - 'wind_dir': _wind_dir(wd_deg) if wd_deg is not None else None, - 'wind_dir_deg': wd_deg, - 'uv_index': uv, - 'sunrise': sunrise_hm, - 'sunset': sunset_hm, - 'asphalt_temp': asphalt_t, - 'asphalt_warn': asphalt_w, - 'pollen': pollen_out, - 'zecken': zecken, - 'thunderstorm': wcode in {95, 96, 99}, - 'paw_cold': wcode in {71, 73, 75, 77, 85, 86} or (t_min is not None and t_min < 0), - }) - - result = {'timezone': timezone, 'days': days} - _forecast_cache[key] = (now, result) - _log_forecast(round(lat, 1), round(lon, 1), days) - return result - - -def _log_forecast(lat_r: float, lon_r: float, days: list) -> None: - """Speichert jeden Forecast-Tag in weather_log (INSERT OR IGNORE — kein Überschreiben).""" - if not days: - return - try: - import json - from database import db - with db() as conn: - for d in days: - pollen = d.get('pollen') or {} - conn.execute(""" - INSERT OR IGNORE INTO weather_log - (date, lat_r, lon_r, - temp_max, temp_min, feels_max, - precip_prob, precip_sum, - wind_kmh, wind_dir, uv_index, - weathercode, weatherdesc, - sunrise, sunset, - asphalt_temp, asphalt_warn, zecken, - pollen_erle, pollen_birke, pollen_graeser, - pollen_beifuss, pollen_ambrosia, - forecast_json) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - """, ( - d['date'], lat_r, lon_r, - d.get('temp_max'), d.get('temp_min'), d.get('feels_max'), - d.get('precip_prob'), d.get('precip_sum'), - d.get('wind_kmh'), d.get('wind_dir'), d.get('uv_index'), - d.get('weathercode'), d.get('desc'), - d.get('sunrise'), d.get('sunset'), - d.get('asphalt_temp'), d.get('asphalt_warn'), d.get('zecken'), - pollen.get('erle', {}).get('level'), - pollen.get('birke', {}).get('level'), - pollen.get('graeser', {}).get('level'), - pollen.get('beifuss', {}).get('level'), - pollen.get('ambrosia',{}).get('level'), - json.dumps(d, ensure_ascii=False), - )) - except Exception as e: - logger.warning(f"weather_log insert fehlgeschlagen: {e}") diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 800d3f0..e7312b4 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -7,12 +7,11 @@ services: - "3012:8000" volumes: - ./data:/data - - /volume1/docker/banyaro/data/media:/prod-media:ro env_file: - .env environment: - DB_PATH=/data/banyaro.db - - MEDIA_DIR=/prod-media + - MEDIA_DIR=/data/media - STAGING=true - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 diff --git a/reports/.gitkeep b/reports/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/reports/2026-05-01-dateien.md b/reports/2026-05-01-dateien.md deleted file mode 100644 index 6ceb3c8..0000000 --- a/reports/2026-05-01-dateien.md +++ /dev/null @@ -1,180 +0,0 @@ -# Dateiliste — Ban Yaro - -_Erstellt: 01.05.2026 06:07_ - - ---- - - -## Backend — Python-Dateien - -| Datei | Größe | -| ---------------------------- | -------- | -| ._auth.py | 163.0 B | -| ._database.py | 163.0 B | -| ._ki.py | 163.0 B | -| ._main.py | 163.0 B | -| auth.py | 4.5 KB | -| content_filter.py | 2.3 KB | -| database.py | 76.6 KB | -| generate_thumbs.py | 1.0 KB | -| ki.py | 15.7 KB | -| mailer.py | 5.9 KB | -| main.py | 76.9 KB | -| media_utils.py | 7.7 KB | -| migrate_media.py | 3.3 KB | -| ratelimit.py | 4.5 KB | -| routes/.___init__.py | 163.0 B | -| routes/._auth.py | 163.0 B | -| routes/._diary.py | 163.0 B | -| routes/._dogs.py | 163.0 B | -| routes/._health.py | 163.0 B | -| routes/._ki.py | 163.0 B | -| routes/._poison.py | 163.0 B | -| routes/._push.py | 163.0 B | -| routes/__init__.py | 0.0 B | -| routes/achievements.py | 10.9 KB | -| routes/admin.py | 41.0 KB | -| routes/alerts.py | 1.5 KB | -| routes/auth.py | 13.5 KB | -| routes/breeder.py | 16.2 KB | -| routes/breeder_export.py | 22.0 KB | -| routes/breeder_photos.py | 13.4 KB | -| routes/chat.py | 10.4 KB | -| routes/diary.py | 35.8 KB | -| routes/dogs.py | 22.2 KB | -| routes/events.py | 8.9 KB | -| routes/forum.py | 27.1 KB | -| routes/friends.py | 11.8 KB | -| routes/health.py | 21.1 KB | -| routes/import_data.py | 10.0 KB | -| routes/ki.py | 2.2 KB | -| routes/knigge.py | 3.9 KB | -| routes/litters.py | 25.0 KB | -| routes/lost.py | 6.3 KB | -| routes/moderation.py | 10.0 KB | -| routes/movies.py | 10.2 KB | -| routes/notes.py | 9.5 KB | -| routes/notifications.py | 4.2 KB | -| routes/osm.py | 16.8 KB | -| routes/outreach.py | 8.9 KB | -| routes/partner.py | 7.3 KB | -| routes/places.py | 6.4 KB | -| routes/poison.py | 7.0 KB | -| routes/praise.py | 1.2 KB | -| routes/profile.py | 3.7 KB | -| routes/push.py | 5.9 KB | -| routes/ratings.py | 4.8 KB | -| routes/routen.py | 22.2 KB | -| routes/services.py | 5.1 KB | -| routes/sharing.py | 5.2 KB | -| routes/sitting.py | 10.0 KB | -| routes/sitting_access.py | 2.8 KB | -| routes/social.py | 117.2 KB | -| routes/stats.py | 1.5 KB | -| routes/tieraerzte.py | 6.1 KB | -| routes/training.py | 33.8 KB | -| routes/walks.py | 20.5 KB | -| routes/weather.py | 537.0 B | -| routes/webcal.py | 14.9 KB | -| routes/widget.py | 1.8 KB | -| routes/wiki.py | 26.6 KB | -| routes/zucht_hunde.py | 31.2 KB | -| routes/zucht_ki.py | 18.8 KB | -| scheduler.py | 32.8 KB | -| scraper/__init__.py | 0.0 B | -| scraper/breed_enricher.py | 21.5 KB | -| scraper/breed_evaluator.py | 4.9 KB | -| scraper/breeds.py | 5.9 KB | -| scraper/events_vdh.py | 10.6 KB | -| scraper/fetch_wiki_images.py | 9.0 KB | -| scraper/wikidata_breeds.py | 7.8 KB | -| scraper/wikipedia_photos.py | 6.7 KB | -| scripts/generate_reports.py | 29.4 KB | -| timeutils.py | 3.3 KB | -| username_blocklist.py | 1.2 KB | -| weather.py | 5.9 KB | -| welfare_check.py | 10.0 KB | - -**Gesamt**: 85 Dateien, 1.0 MB - - -## Frontend — JavaScript - -| Datei | Größe | -| ------------------------ | -------- | -| ._api.js | 163.0 B | -| ._app.js | 163.0 B | -| ._ui.js | 163.0 B | -| api.js | 31.2 KB | -| app.js | 38.2 KB | -| leaflet.js | 143.7 KB | -| leaflet.markercluster.js | 33.3 KB | -| pages/admin.js | 119.1 KB | -| pages/breeder.js | 8.3 KB | -| pages/chat.js | 19.0 KB | -| pages/datenschutz.js | 11.2 KB | -| pages/diary.js | 92.7 KB | -| pages/dog-profile.js | 51.5 KB | -| pages/erste-hilfe.js | 31.7 KB | -| pages/events.js | 29.8 KB | -| pages/forum.js | 52.8 KB | -| pages/friends.js | 38.6 KB | -| pages/gruender.js | 7.1 KB | -| pages/health.js | 107.5 KB | -| pages/impressum.js | 3.9 KB | -| pages/knigge.js | 16.9 KB | -| pages/litters.js | 51.6 KB | -| pages/lost.js | 30.3 KB | -| pages/map.js | 70.7 KB | -| pages/moderation.js | 23.0 KB | -| pages/movies.js | 18.6 KB | -| pages/notes.js | 38.1 KB | -| pages/notifications.js | 12.0 KB | -| pages/onboarding.js | 17.2 KB | -| pages/places.js | 19.7 KB | -| pages/poison.js | 26.9 KB | -| pages/routes.js | 132.6 KB | -| pages/settings.js | 84.2 KB | -| pages/sitting.js | 33.9 KB | -| pages/social.js | 74.3 KB | -| pages/trainingsplaene.js | 40.0 KB | -| pages/uebungen.js | 98.8 KB | -| pages/walks.js | 42.4 KB | -| pages/welcome.js | 51.1 KB | -| pages/widget.js | 5.6 KB | -| pages/wiki.js | 55.9 KB | -| pages/wurfboerse.js | 9.7 KB | -| pages/zucht-profil.js | 23.6 KB | -| pages/zuchthunde.js | 67.0 KB | -| qrcode.min.js | 19.5 KB | -| ui.js | 34.8 KB | - -**Gesamt**: 46 Dateien, 1.9 MB - - -## Frontend — CSS - -| Datei | Größe | -| ------------------------- | -------- | -| ._components.css | 163.0 B | -| ._design-system.css | 163.0 B | -| ._layout.css | 163.0 B | -| MarkerCluster.Default.css | 1.3 KB | -| MarkerCluster.css | 872.0 B | -| components.css | 178.5 KB | -| design-system.css | 10.0 KB | -| layout.css | 20.7 KB | -| leaflet.css | 14.2 KB | - -**Gesamt**: 9 Dateien, 226.1 KB - - -## Frontend — HTML - -| Datei | Größe | -| ------------ | ------- | -| ._index.html | 163.0 B | -| index.html | 25.3 KB | -| landing.html | 35.2 KB | - diff --git a/reports/2026-05-01-funktionsumfang.md b/reports/2026-05-01-funktionsumfang.md deleted file mode 100644 index 988821f..0000000 --- a/reports/2026-05-01-funktionsumfang.md +++ /dev/null @@ -1,151 +0,0 @@ -# Funktionsumfang — Ban Yaro - -_Erstellt: 01.05.2026 06:07_ - - ---- - - -## Authentifizierung - -- Registrierung mit E-Mail-Verifikation -- Login / Logout (JWT + HttpOnly-Cookie) -- Passwort vergessen / zurücksetzen -- Verifikations-Mail erneut senden -- Referral-System (3 Stufen: 10/20/50 Refs → 20/30/50 % Rabatt) -- Partner-Codes (Gründer-Slot, eigene Einladungen) - - -## Hunde-Profile - -- Anlegen / Bearbeiten von Hunde-Profilen (Rasse, Geburtsdatum, Gewicht, …) -- Avatar-Upload (JPEG/WebP-Konvertierung, Vorschau) -- Öffentliches Profil mit QR-Code und Teilen-Link -- Hunde-Ausweis (druckbares HTML-Dokument) -- Mehrere Hunde pro Account - - -## Forum - -- Thread erstellen mit Kategorien (allgemein, rasse, region, …) -- Antworten, Likes, Foto-Anhänge (max. 5 pro Thread) -- Moderatoren: Thread pinnen, sperren, löschen -- Report-System: Beiträge melden -- Push-Benachrichtigungen bei neuer Antwort -- Öffentlich lesbar, Schreiben nur für verifizierte User - - -## Tagebuch - -- Tageseinträge mit Freitext, Fotos, GPS-Koordinaten -- EXIF-GPS-Extraktion aus Foto-Uploads -- Kartenansicht aller Tagebuch-Pins -- Kalenderansicht nach Datum -- Medienansicht (Galerie aller Fotos) -- Day-One-kompatibles Format - - -## Gesundheit & Training - -- Gewichtsverlauf mit Diagramm -- Gesundheits-Erinnerungen (Push, täglich 08:00) -- 104 Übungen (DB-basiert, KI-Trainingspläne) -- Training-Logging mit Fortschrittsverfolgung -- KI-Gesundheitsberichte (wöchentlich, cloud/lokal) - - -## Karte & POIs - -- Leaflet-Karte mit Cluster-Markern -- Nearby-Alerts: Giftköder, Vermisste Hunde in der Nähe -- Overpass-API-Integration (Tierärzte, Hundewiesen, Parks, …) -- 90-Tage-Cache für Overpass-Abfragen -- ORS-Routenvorschläge zu Hundeparks - - -## Wiki & Rassen - -- Rassen-Datenbank (TheDogAPI + Wikidata-Enrichment) -- Züchter-Verzeichnis mit Verifikation -- Breed-Interest-Tracking ('So einen hab ich' / 'Interessiert mich') -- KI-gestützte Rassen-Anreicherung -- Wikipedia-basierte Beschreibungen - - -## Züchter-Features - -- Züchter-Antrag mit Dokument-Upload -- Admin-Prüfung und Freischaltung -- Züchter-Profil (Zwingername, Rassen, VDH, Stadt) -- Wurfverwaltung mit Elterntieren, Welpen, Fotos -- Tierschutz-Check vor Wurf-Anlage -- Stammbaum-Ansicht -- Genetik-Tracking (Farbgene, Erbkrankheiten) -- Kaufvertrags-Generator -- Jahresbericht-Export - - -## Social Features - -- Freundschaften (anfragen, annehmen, ablehnen) -- Social-Media-Posts (Luna — KI-Social-Manager) -- Lober: wöchentlicher KI-Lob-Push (Mo 09:00) -- Benachrichtigungen (in-app + Push-Notifications) - - -## Admin & Moderation - -- Admin-Dashboard: User-Verwaltung, Ban/Unban -- Moderation-Queue: gemeldete Beiträge -- Outreach-Mailing: Templates, Versand, Log -- Statistiken: User-Wachstum, Aktivität -- Züchter-Anträge prüfen -- Partner-Codes verwalten -- KI-Konfiguration (cloud/lokal, Limits) - - -## Infrastruktur - -- Service Worker (Offline-Stufen 1–3) -- Push-Notifications (VAPID) -- APScheduler: 9 Hintergrund-Jobs (Gesundheit, Wetter, Events, …) -- Brevo E-Mail-API + SMTP-Fallback -- Analytics: Umami v2 (extern) -- SEO: robots.txt, sitemap.xml, llms.txt -- Landing Page + Widget - - ---- - - -## Backend-Routers - -| Router | Präfix | -| ------------- | ------------------ | -| auth | /api/auth | -| dogs | /api/dogs | -| diary | /api/diary | -| health | /api/health | -| forum | /api/forum | -| wiki | /api/wiki | -| map | /api/map | -| poison | /api/poison | -| lost | /api/lost | -| breeder | /api/breeder | -| litters | /api/litters | -| training | /api/training | -| outreach | /api/outreach | -| moderation | /api/moderation | -| notes | /api/notes | -| notifications | /api/notifications | -| push | /api/push | -| friends | /api/friends | -| profile | /api/profile | -| social | /api/social | -| sitting | /api/sitting | -| achievements | /api/achievements | -| stats | /api/stats | -| walks | /api/walks | -| events | /api/events | -| alerts | /api/alerts | -| ratings | /api/ratings | diff --git a/reports/2026-05-01-nutzer.md b/reports/2026-05-01-nutzer.md deleted file mode 100644 index 7422033..0000000 --- a/reports/2026-05-01-nutzer.md +++ /dev/null @@ -1,91 +0,0 @@ -# Nutzerübersicht — Ban Yaro - -_Erstellt: 01.05.2026 06:07_ - - ---- - - -## Nutzer nach Rolle - -| Gruppe | Anzahl | -| -------------------- | ------ | -| Gesamt Nutzer | 5 | -| Admin | 1 | -| Moderatoren | 2 | -| Züchter | 0 | -| Gründer (aktiv) | 0 | -| Partner | 1 | -| Premium | 0 | -| Gesperrt (banned) | 0 | -| E-Mail unverifiziert | 4 | - -## Registrierungen (letzte 6 Monate) - -| Monat | Neue Nutzer | -| ------- | ----------- | -| 2026-04 | 5 | - - -## Hunde - -| Metrik | Anzahl | -| ---------------------------- | ------ | -| Hunde gesamt | 4 | -| Hunde mit Tagebuch-Einträgen | 3 | - - -## Forum - -| Metrik | Anzahl | -| ---------------- | ------ | -| Threads | 10 | -| Antworten | 7 | -| Offene Meldungen | 0 | - -**Threads nach Kategorie:** - -| Kategorie | Threads | -| ----------- | ------- | -| rasse | 3 | -| spaziergang | 3 | -| allgemein | 2 | -| ausflug | 2 | - - -## Tagebuch - -| Metrik | Anzahl | -| ------------------- | ------ | -| Einträge gesamt | 117 | -| Mit Foto | 0 | -| Mit GPS-Koordinaten | 0 | - - -## Medien auf dem Server - -| Verzeichnis | Dateien | Größe | -| ----------- | ------- | -------- | -| avatars | 4 | 7.1 MB | -| breeds | 820 | 212.5 MB | -| diary | 311 | 215.6 MB | -| dogs | 10 | 39.8 MB | -| forum | 44 | 112.1 MB | -| poison | 0 | 0.0 B | -| routes | 1 | 6.6 MB | -| **GESAMT** | 1190 | 593.6 MB | - - -## Gesendete E-Mails - -| Absender | Anzahl | Erste Mail | Letzte Mail | -| -------- | ------ | ---------- | ----------- | -| partner | 9 | 2026-04-30 | 2026-04-30 | - -**Gesamt**: 9 Mails gesendet - - -## Besuche (Analytics) - -> **Hinweis:** Besucher-Statistiken (Besuche/Tag und Monat) werden extern über **Umami** erfasst und sind nicht im Container verfügbar. Bitte Umami-Dashboard direkt aufrufen. - diff --git a/reports/2026-05-01-partner.md b/reports/2026-05-01-partner.md deleted file mode 100644 index 31129b6..0000000 --- a/reports/2026-05-01-partner.md +++ /dev/null @@ -1,24 +0,0 @@ -# Partnerliste — Ban Yaro - -_Erstellt: 01.05.2026 06:07_ - - ---- - - -## Partner-Accounts - -| Name | E-Mail | Partner seit | Gründer-Nr. | -| ---- | ---------------- | ------------ | ----------- | -| René | mail@motocamp.de | 2026-04-12 | — | - - -## Partner-Codes - -_Keine Partner-Codes_ - - -## Gründer - -_Noch keine Gründer_ - diff --git a/reports/2026-05-01-server.md b/reports/2026-05-01-server.md deleted file mode 100644 index 8dc3572..0000000 --- a/reports/2026-05-01-server.md +++ /dev/null @@ -1,172 +0,0 @@ -# Server & Speicherbelegung — Ban Yaro - -_Erstellt: 01.05.2026 06:07_ - - ---- - - -## Festplattenbelegung - -``` -Filesystem Size Used Avail Use% Mounted on -/dev/mapper/cachedev_0 25T 14T 11T 58% /data -``` - - -## Media-Verzeichnisse - -``` -217M /data/media/diary -215M /data/media/breeds -113M /data/media/forum -40M /data/media/dogs -7.1M /data/media/avatars -6.6M /data/media/routes -0 /data/media/poison - -Gesamt: 596M /data/media -``` - - -## Datenbank - -**DB-Größe:** 62M /data/banyaro.db - -| Tabelle | Zeilen | -| ---------------------- | ------- | -| osm_pois | 440,865 | -| osm_tiles | 7,613 | -| wiki_rassen | 1,003 | -| diary_dogs | 118 | -| diary | 117 | -| training_exercises | 110 | -| diary_media | 101 | -| pflege_tipps | 45 | -| sqlite_sequence | 42 | -| push_subscriptions | 26 | -| user_badges | 22 | -| route_walks | 19 | -| notifications | 17 | -| exercise_progress | 15 | -| routes | 13 | -| user_map_pois | 13 | -| knigge_votes | 12 | -| forum_threads | 11 | -| health | 11 | -| direct_messages | 10 | -| outreach_log | 9 | -| forum_posts | 8 | -| forum_likes | 7 | -| poison | 6 | -| events | 5 | -| ki_daily_calls | 5 | -| training_sessions | 5 | -| users | 5 | -| dogs | 4 | -| ki_health_reports | 4 | -| social_content | 4 | -| weekly_praise | 4 | -| ors_daily_total | 3 | -| walks | 3 | -| friendships | 2 | -| zucht_hunde | 2 | -| admin_audit | 1 | -| breeder_jahresberichte | 1 | -| breeder_profiles | 1 | -| conversations | 1 | -| dog_shares | 1 | -| email_templates | 1 | -| hund_des_monats_votes | 1 | -| notes | 1 | -| ratings | 1 | -| tieraerzte | 1 | -| training_ki_cache | 1 | -| wiki_breed_interest | 1 | -| wiki_foto_submissions | 1 | -| breeder_documents | 0 | -| breeder_photos | 0 | -| dog_genetic_tests | 0 | -| dog_health_tests | 0 | -| dog_titles | 0 | -| event_rsvp | 0 | -| forum_reports | 0 | -| health_media | 0 | -| litters | 0 | -| lost_dogs | 0 | -| movie_votes | 0 | -| osm_poi_edits | 0 | -| osm_reports | 0 | -| partner_codes | 0 | -| places | 0 | -| premium_orders | 0 | -| puppies | 0 | -| puppy_weights | 0 | -| route_suggest_usage | 0 | -| service_offers | 0 | -| sitters | 0 | -| sitting_requests | 0 | -| sitting_subscriptions | 0 | -| training_plan_progress | 0 | -| walk_invitations | 0 | -| walk_participant_dogs | 0 | -| walk_participants | 0 | -| wiki_berichte | 0 | -| wiki_zuchter | 0 | - - -## App-Code - -**App-Verzeichnis (/app):** 8.9M /app - - -## Kapazitäts-Warnung - -> ✅ 58 % Festplatte belegt — ausreichend Kapazität. - - -## Installierte Python-Pakete - -``` -Package Version ------------------- ------------ -aiohappyeyeballs 2.6.1 -aiohttp 3.13.5 -aiosignal 1.4.0 -annotated-types 0.7.0 -anthropic 0.49.0 -anyio 4.13.0 -APScheduler 3.10.4 -attrs 26.1.0 -bcrypt 4.3.0 -certifi 2026.4.22 -cffi 2.0.0 -charset-normalizer 3.4.7 -click 8.3.3 -cryptography 47.0.0 -defusedxml 0.7.1 -distro 1.9.0 -dnspython 2.8.0 -email-validator 2.3.0 -fastapi 0.115.0 -frozenlist 1.8.0 -h11 0.16.0 -http_ece 1.2.1 -httpcore 1.0.9 -httptools 0.7.1 -httpx 0.28.1 -idna 3.13 -jiter 0.14.0 -multidict 6.7.1 -odfpy 1.4.1 -openai 1.59.2 -pillow 11.2.1 -pillow_heif 0.22.0 -pip 25.0.1 -polyline 2.0.2 -propcache 0.4.1 -py-vapid 1.9.4 -pycparser 3.0 -pydantic 2.10.6 -``` - diff --git a/reports/2026-05-01-sicherheit.md b/reports/2026-05-01-sicherheit.md deleted file mode 100644 index 49c50ea..0000000 --- a/reports/2026-05-01-sicherheit.md +++ /dev/null @@ -1,128 +0,0 @@ -# Sicherheitsbericht — Ban Yaro - -_Erstellt: 01.05.2026 06:07_ - - ---- - - -## Übersicht implementierter Schutzmaßnahmen - - -### 1. Authentifizierung & Passwörter - -- **JWT** (HS256) mit 30-Tage-Ablauf, HttpOnly + Secure + SameSite=lax Cookie -- **Bcrypt**-Passwort-Hashing mit automatischem Salt -- Mindestlänge 8 Zeichen, serverseitig erzwungen -- Passwort-Reset: kryptographisches Token, 2 Stunden Ablauf - - -### 2. Registrierung - -- **E-Mail-Verifikation** zwingend vor dem ersten Login -- Verifikationslink läuft nach 7 Tagen ab -- Rate Limit: 5 Registrierungen / Stunde / IP -- Username-Blocklist: >200 reservierte und unangemessene Begriffe -- Keine Doppelanmeldung (E-Mail und Username unique) - - -### 3. Login-Schutz - -- **IP-Rate-Limit**: 10 Versuche / 5 Minuten -- **Email-Rate-Limit**: 5 Versuche / 5 Minuten pro E-Mail-Adresse -- **Account-Lockout**: 5 Fehlversuche → 15 Minuten gesperrt (in-memory) -- Fehlerzähler wird bei erfolgreichem Login zurückgesetzt -- Gleiche Fehlermeldung bei falschem Passwort UND unbekannter E-Mail (kein User-Enumeration) - - -### 4. Forum-Schutz - -- E-Mail-Verifikation Pflicht zum Posten -- **Post-Cooldown**: 30 Sekunden zwischen beliebigen Beiträgen -- **Stunden-Limit Threads**: max. 5 neue Threads / Stunde / User -- **Stunden-Limit Antworten**: max. 20 Antworten / Stunde / User -- **Duplikat-Erkennung**: gleicher Text in 5 Minuten → blockiert -- **Content-Filter**: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio -- Moderatoren können Threads sperren, Beiträge löschen (Soft-Delete) -- Report-System: User können Beiträge melden - - -### 5. HTTP-Security-Headers - -| Header | Wert | -|--------|------| -| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | -| `Content-Security-Policy` | default-src 'self'; frame-ancestors 'none'; … | -| `X-Content-Type-Options` | `nosniff` | -| `Referrer-Policy` | `strict-origin-when-cross-origin` | -| `Permissions-Policy` | camera=(), microphone=(), geolocation=(self) | - - -### 6. Rate Limiting (alle Endpunkte) - -| Endpunkt | Limit | Fenster | -| ------------------------- | ------ | -------------- | -| /auth/register | 5 Req | 60 Min | -| /auth/login (IP) | 10 Req | 5 Min | -| /auth/login (Email) | 5 Req | 5 Min | -| /auth/forgot-password | 3 Req | 60 Min | -| /auth/resend-verification | 3 Req | 60 Min / Email | -| /auth/reset-password | 5 Req | 60 Min | -| KI-Features | 10 Req | 60 Min | -| Poison-Reports | 3 Req | 60 Min | -| Wiki-Liste | 60 Req | 60 Sek | -| Wiki-Detail | 30 Req | 60 Sek | - - -### 7. Honeypot-Fallen - -Folgende Pfade blockieren Scanner-IPs sofort für 24 Stunden: - -``` -/api/admin/users /api/v1/users /api/users /api/.env -/api/config /api/setup /api/install /api/phpinfo -/api/debug /api/actuator /api/swagger /api/graphql -/api/wiki/trap -``` - - -### 8. Datei-Upload-Sicherheit - -- **Magic-Byte-Prüfung**: JPEG, PNG, GIF, WebP, MP4, WebM -- **Path-Traversal-Schutz**: alle Pfade bleiben innerhalb `MEDIA_DIR` -- **Größenbeschränkung**: 20 MB globales Limit (Middleware) -- Automatische Konvertierung: HEIC→JPEG, MOV/AVI→MP4 -- Max. 5 Fotos pro Forum-Thread - - -### 9. Admin & Moderation - -- Admin-Endpoints per `require_admin` Dependency geschützt -- Moderatoren-Rolle mit eingeschränkten Rechten -- User-Banning mit Sperrgrund, geprüft bei jedem Request -- Outreach-Mailing nur über Admin-Panel, vollständiges Log - - -## Aktuelle Kennzahlen - -| Metrik | Wert | -| ------------------------ | ---- | -| Gesperrte Accounts | 0 | -| Unverifizierte Accounts | 4 | -| Gesendete Outreach-Mails | 9 | - - -## Bekannte Einschränkungen - -- Rate-Limit-Daten und IP-Blocklist sind **in-memory** → Reset bei Container-Neustart -- Kein CAPTCHA (bewusst: Nutzerfreundlichkeit vs. Bot-Schutz) -- Keine Refresh-Token-Rotation (JWT ist 30 Tage gültig) -- Analytics (Besucher) extern über Umami — kein Zugriff aus dem Container - - -## Empfehlungen für nächste Überprüfung - -- [ ] Prüfen ob IP-Blocklist-Persistenz via DB sinnvoll wäre -- [ ] CSP weiter verschärfen (nonce-basiert statt unsafe-inline) -- [ ] Login-Logs in DB schreiben (für Audit-Trail) -- [ ] Zwei-Faktor-Authentifizierung für Admin-Accounts evaluieren