diff --git a/Makefile b/Makefile index 2427674..692e7bb 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ TAR_EXCLUDE := --exclude='.git' \ --exclude='./.DS_Store' .PHONY: help deploy deploy-clean staging release sync push restart build stop status \ - logs logs-f shell db dev clean-cache check-ssh + logs logs-f shell db dev clean-cache check-ssh reports # ---------------------------------------------------------- # SSH-Prüfung — Abhängigkeit aller DS-Befehle @@ -66,6 +66,7 @@ help: @echo "" @echo " make dev Lokaler Dev-Server auf Mac (Port 8001)" @echo " make clean-cache SW-Cache-Version erhöhen + restart" + @echo " make reports Quartalsberichte generieren + committen" @echo "" # ---------------------------------------------------------- @@ -127,6 +128,17 @@ staging: check-ssh @echo " ✓ Staging fertig — https://staging.banyaro.app" @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_STAGING) --tail=10" +# ---------------------------------------------------------- +# STAGING-DB — Produktions-DB in Staging kopieren (interaktiv, braucht sudo) +# Aufruf: make staging-db +# ---------------------------------------------------------- +staging-db: check-ssh + @echo "→ Produktions-DB nach Staging kopieren..." + @ssh -t $(DS_HOST) " \ + sudo cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db && \ + sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db && \ + echo '✓ DB kopiert'" + # ---------------------------------------------------------- # RELEASE — develop → main → Production (VERSION= pflichtangabe) # Beispiel: make release VERSION=1.1.0 @@ -235,6 +247,31 @@ dev: DB_PATH=./dev.db \ uvicorn main:app --reload --port 8001 +# ---------------------------------------------------------- +# REPORTS — Quartalsberichte generieren und committen +# Berichte laufen im Container (DB-Zugriff), werden lokal gespeichert +# ---------------------------------------------------------- +REPORT_DATE := $(shell date +%Y-%m-%d) +REPORT_SECTIONS := sicherheit funktionsumfang dateien nutzer partner server + +reports: check-ssh + @mkdir -p reports + @echo "→ Berichte generieren ($(REPORT_DATE))..." + @for section in $(REPORT_SECTIONS); do \ + echo " → $$section..."; \ + ssh $(DS_HOST) "$(DOCKER) exec $(CONTAINER) python3 scripts/generate_reports.py $$section" \ + > reports/$(REPORT_DATE)-$$section.md; \ + done + @echo "→ Berichte committen..." + @git add reports/ + @git diff --cached --quiet || git commit -m "Reports $(REPORT_DATE) — Quartalsbericht" + @echo "" + @echo " ✓ Alle Berichte erstellt und committed:" + @for section in $(REPORT_SECTIONS); do \ + echo " reports/$(REPORT_DATE)-$$section.md"; \ + done + + # ---------------------------------------------------------- # CACHE leeren — SW-Version erhöhen, dann restart # Nach größeren CSS/JS-Änderungen wenn SW gecacht hat diff --git a/backend/auth.py b/backend/auth.py index b2736f5..55c63fc 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -87,7 +87,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?", (user_id,) ).fetchone() @@ -131,7 +131,10 @@ def require_admin(user=Depends(get_current_user)): def require_social_media(user=Depends(get_current_user)): - """Dependency: Social-Media-Manager oder Admin.""" - if not (user.get("is_social_media") or user["rolle"] == "admin"): + """Dependency: Social-Media-Manager, Luna-Probezugang oder Admin.""" + from datetime import datetime as _dt + trial = user.get("luna_trial_until") + trial_active = bool(trial and _dt.utcnow().isoformat() < trial) + if not (user.get("is_social_media") or user["rolle"] == "admin" or trial_active): raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.") return user diff --git a/backend/content_filter.py b/backend/content_filter.py new file mode 100644 index 0000000..e094253 --- /dev/null +++ b/backend/content_filter.py @@ -0,0 +1,63 @@ +"""BAN YARO — Content-Filter für Spam und Missbrauch im Forum.""" + +import re +from datetime import datetime, timedelta, timezone +from fastapi import HTTPException + +# Offensichtliche Spam-Signale +_SPAM_KEYWORDS = [ + "casino", "poker", "slots", "jackpot", "sportwetten", + "viagra", "cialis", "levitra", "pharmacy", "apotheke online", + "kreditkarte sofort", "kredit ohne schufa", "schnell geld verdienen", + "passive income", "work from home", "earn money fast", + "click here", "klick hier", "free followers", "buy followers", + "whatsapp +", "telegram +", "call now", "jetzt anrufen", + "seo service", "backlinks kaufen", "website traffic", + "crypto invest", "bitcoin verdienen", "nft mint", + "lose weight fast", "abnehmen schnell", "diät pille", +] + +# URL-Muster (http/https oder nackte Domains) +_URL_RE = re.compile( + r"(https?://|www\.|\b[a-zA-Z0-9-]+\.(de|com|net|org|io|app|shop|info|biz|ru|cn)\b)", + re.IGNORECASE, +) + +# Mindest-Account-Alter für URL-Posts (Tage) +_MIN_DAYS_FOR_URLS = 7 + + +def check_forum_content(text: str, user_created_at: str | None = None) -> None: + """ + Prüft Forum-Text auf Spam. + Wirft HTTPException(400) bei Fund. + """ + lower = text.lower() + + # Spam-Keywords + for kw in _SPAM_KEYWORDS: + if kw in lower: + raise HTTPException(400, "Dein Beitrag wurde als Spam erkannt und nicht gespeichert.") + + # URLs in neuen Accounts sperren + if _URL_RE.search(text): + if user_created_at: + try: + created = datetime.fromisoformat(user_created_at) + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + age = datetime.now(timezone.utc) - created + if age < timedelta(days=_MIN_DAYS_FOR_URLS): + raise HTTPException( + 400, + "Links können erst nach 7 Tagen Mitgliedschaft gepostet werden." + ) + except (ValueError, TypeError): + pass + + # Zu viele Sonderzeichen / Zeichensalat + if len(text) > 20: + alnum = sum(c.isalnum() or c.isspace() for c in text) + ratio = alnum / len(text) + if ratio < 0.5: + raise HTTPException(400, "Dein Beitrag enthält zu viele Sonderzeichen.") diff --git a/backend/database.py b/backend/database.py index 5ea9f4a..eeb1add 100644 --- a/backend/database.py +++ b/backend/database.py @@ -701,7 +701,28 @@ def _migrate(conn_factory): CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC); """) - # Hunde-Filme: Bewertungen + Hund des Monats + # Hunde-Filme: Katalog + Bewertungen + Hund des Monats + conn.executescript(""" + CREATE TABLE IF NOT EXISTS movies ( + id TEXT PRIMARY KEY, + titel TEXT NOT NULL, + originaltitel TEXT, + jahr INTEGER, + genre TEXT, + typ TEXT NOT NULL DEFAULT 'film', + hund_rasse TEXT, + stirbt_der_hund INTEGER NOT NULL DEFAULT 0, + beschreibung TEXT, + bild_emoji TEXT DEFAULT '🐾', + imdb_rating REAL, + streaming TEXT, + sort_order INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_movies_typ ON movies(typ); + CREATE INDEX IF NOT EXISTS idx_movies_jahr ON movies(jahr DESC); + """) + conn.executescript(""" CREATE TABLE IF NOT EXISTS movie_votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1051,6 +1072,19 @@ def _migrate(conn_factory): pass logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.") + # Moderation-Logging: resolved_by/at für forum_reports, verified_by/at/reject für wiki_zuchter + for table, col, typedef in [ + ("forum_reports", "resolved_by", "INTEGER"), + ("forum_reports", "resolved_at", "TEXT"), + ("wiki_zuchter", "verified_by", "INTEGER"), + ("wiki_zuchter", "verified_at", "TEXT"), + ("wiki_zuchter", "reject_reason", "TEXT"), + ]: + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") + except Exception: + pass + # Wiki: Züchter-Verzeichnis conn.executescript(""" CREATE TABLE IF NOT EXISTS wiki_zuchter ( @@ -1561,6 +1595,35 @@ def _migrate(conn_factory): if 'from_account' not in existing_ol: conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'") + # Job-Bewerbungen + Luna-Probezugang + conn.executescript(""" + CREATE TABLE IF NOT EXISTS job_applications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + dog_name TEXT, + dog_rasse TEXT, + social_handle TEXT, + motivation TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + admin_note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + reviewed_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_job_apps_status ON job_applications(status, created_at DESC); + CREATE TABLE IF NOT EXISTS job_application_docs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + application_id INTEGER NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + file_path TEXT NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()] + if 'luna_trial_until' not in existing_u: + conn.execute("ALTER TABLE users ADD COLUMN luna_trial_until TEXT") + # js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()] if 'js_exercise_id' not in existing_te: @@ -1581,3 +1644,299 @@ def _migrate(conn_factory): conn.execute("UPDATE training_exercises SET js_exercise_id=? WHERE id=?", (js_id, row['id'])) conn.execute("CREATE INDEX IF NOT EXISTS idx_te_js_id ON training_exercises(js_exercise_id)") logger.info("Migration: training_exercises.js_exercise_id hinzugefügt, 'Fuß' bereinigt.") + + # Hund des Monats — dauerhafte Gewinner-Tabelle + conn.executescript(""" + CREATE TABLE IF NOT EXISTS hund_des_monats_wins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + monat TEXT NOT NULL, + stimmen INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(dog_id, monat) + ); + CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id); + """) + + # Trainings-Streak-Tabelle + conn.execute(""" + CREATE TABLE IF NOT EXISTS training_streaks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + current_streak INTEGER NOT NULL DEFAULT 0, + longest_streak INTEGER NOT NULL DEFAULT 0, + last_training_date TEXT, + UNIQUE(user_id, dog_id) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_streaks_user ON training_streaks(user_id)") + + # Ausgaben-Tracker + conn.executescript(""" + CREATE TABLE IF NOT EXISTS expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + kategorie TEXT NOT NULL, + betrag REAL NOT NULL, + datum TEXT NOT NULL, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_expenses_user ON expenses(user_id, datum DESC); + """) + + # KI-Tierarztfragen Rate-Limit-Log + conn.execute(""" + CREATE TABLE IF NOT EXISTS ki_tierarzt_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + + # KI Rassen-Erkennungs-Log (Rate-Limit: 10/Tag pro User) + conn.execute(""" + CREATE TABLE IF NOT EXISTS ki_rasse_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_ki_rasse_log_user + ON ki_rasse_log(user_id, created_at DESC) + """) + + # feed_recalls — Rückruf-Alarm für Tierfutter (RASFF) + conn.execute(""" + CREATE TABLE IF NOT EXISTS feed_recalls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT NOT NULL UNIQUE, + titel TEXT NOT NULL, + produkt TEXT, + gefahr TEXT, + herkunft TEXT, + datum TEXT NOT NULL, + quelle TEXT NOT NULL DEFAULT 'rasff', + url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_recalls_datum ON feed_recalls(datum DESC)") + + # Adoption-Cache + conn.execute(""" + CREATE TABLE IF NOT EXISTS adoption_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + rasse TEXT, + alter_jahre REAL, + geschlecht TEXT, + foto_url TEXT, + tierheim TEXT, + tierheim_plz TEXT, + tierheim_lat REAL, + tierheim_lon REAL, + adoptions_url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL + ) + """) + + conn.execute(""" + CREATE TABLE IF NOT EXISTS community_adoption ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + name TEXT NOT NULL, + rasse TEXT, + alter_jahre REAL, + geschlecht TEXT, + foto_url TEXT, + beschreibung TEXT NOT NULL, + gruende TEXT, + ort TEXT, + plz TEXT, + lat REAL, + lon REAL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS community_adoption_interest ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL REFERENCES community_adoption(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + nachricht TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(listing_id, user_id) + ) + """) + + # ---- Wetter-Log (historische Vorhersage-Daten) ---- + conn.execute(""" + CREATE TABLE IF NOT EXISTS weather_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + logged_at TEXT NOT NULL DEFAULT (datetime('now')), + date TEXT NOT NULL, + lat_r REAL NOT NULL, + lon_r REAL NOT NULL, + temp_max REAL, + temp_min REAL, + feels_max REAL, + precip_prob INTEGER, + precip_sum REAL, + wind_kmh REAL, + wind_dir TEXT, + uv_index REAL, + weathercode INTEGER, + weatherdesc TEXT, + sunrise TEXT, + sunset TEXT, + asphalt_temp REAL, + asphalt_warn TEXT, + zecken TEXT, + pollen_erle INTEGER, + pollen_birke INTEGER, + pollen_graeser INTEGER, + pollen_beifuss INTEGER, + pollen_ambrosia INTEGER, + forecast_json TEXT, + UNIQUE(date, lat_r, lon_r) + ) + """) + + # ---- Favoriten-Tierarzt + Gesundheitsdokumente ---- + conn.execute(""" + CREATE TABLE IF NOT EXISTS favorite_vets ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + vet_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, vet_id) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS health_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + typ TEXT NOT NULL, + titel TEXT NOT NULL, + beschreibung TEXT, + file_path TEXT NOT NULL, + file_type TEXT NOT NULL, + datum TEXT, + vet_id INTEGER REFERENCES tieraerzte(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_health_docs_dog ON health_documents(dog_id, created_at DESC)") + + # Digitaler Hundepass — Impfungen, Medikamente, Metadaten, Share-Links + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS vaccinations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + krankheit TEXT NOT NULL, + datum TEXT NOT NULL, + naechste TEXT, + tierarzt TEXT, + charge_nr TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS medications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + name TEXT NOT NULL, + dosierung TEXT, + von TEXT, + bis TEXT, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS dog_passport_meta ( + dog_id INTEGER PRIMARY KEY REFERENCES dogs(id) ON DELETE CASCADE, + blutgruppe TEXT, + allergien TEXT, + besonderheiten TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS passport_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + valid_until TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_passport_shares_token ON passport_shares(token) + """) + logger.info("Migration: Hundepass-Tabellen bereit.") + except Exception as e: + logger.warning(f"Migration Hundepass: {e}") + + # ---- Playdate ---- + conn.execute(""" + CREATE TABLE IF NOT EXISTS playdate_listings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + lat REAL NOT NULL, + lon REAL NOT NULL, + ort_name TEXT, + radius_km INTEGER NOT NULL DEFAULT 10, + beschreibung TEXT, + aktiv INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(dog_id) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_playdate_listings_geo + ON playdate_listings(lat, lon) WHERE aktiv=1 + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS playdate_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + to_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + nachricht TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(from_dog_id, to_dog_id) + ) + """) + + # Wiederkehrende Ausgaben (Daueraufträge) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS recurring_expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + kategorie TEXT NOT NULL, + betrag REAL NOT NULL, + haeufigkeit TEXT NOT NULL, -- monatlich|quartalsweise|jaehrlich + startdatum TEXT NOT NULL, + naechste_faelligkeit TEXT NOT NULL, + notiz TEXT, + aktiv INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv); + """) diff --git a/backend/mailer.py b/backend/mailer.py index e5cbdc0..344fe4f 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -106,44 +106,67 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""): logger.warning(f"Kein Mail-Backend konfiguriert — Mail NICHT gesendet: «{subject}» → {to}") +def email_html( + body_html: str, + cta_url: str = None, + cta_label: str = None, + footer_text: str = None, +) -> str: + """Shared branded HTML email template (matches Status-Report design).""" + cta_block = "" + if cta_url and cta_label: + cta_block = f""" +

+ + {cta_label} + +

""" + + footer = footer_text or "Ban Yaro · banyaro.app" + + return f"""\ + + + + + + +
+ +
+
🐾 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" - html = f"""\ - - - - -
-

Ban Yaro 🐾

-

Hallo {name},

-

+ body = f""" +

Hallo {name},

+

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

-

- - E-Mail bestätigen - -

-

- Der Link ist 48 Stunden gültig. -

-

+

Der Link ist 48 Stunden gültig.

+

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

-
- -""" +

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

Link nicht gefunden

Dieser Hundepass-Link ist ungültig.

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

Link abgelaufen

Dieser Hundepass-Link ist nicht mehr gültig.

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

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

Hallo {_ename},

+

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

+

Der Link ist 7 Tage gültig.

+

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

""" + html = email_html(body_html, cta_url=url, cta_label="E-Mail bestätigen") + plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n" try: - _send_smtp(email, subject, body, "support") + _send_smtp(email, subject, plain, "support", html=html) except Exception: pass # Nicht blockieren wenn SMTP fehlschlägt @@ -139,24 +146,32 @@ async def register(data: RegisterRequest, response: Response, request: Request): conn.execute("UPDATE users SET referred_by=? WHERE id=?", (referrer['id'], new_user_id)) - token = create_token(user["id"], user["rolle"]) - _set_cookie(response, token) _send_verification_email(data.email, name, verify_token) - return {"token": token, "name": name, "email_verified": 0} + return {"pending_verification": True} @router.post("/login") async def login(data: LoginRequest, response: Response, request: Request): rl_check(request, max_requests=10, window_seconds=300, key="login") + rl_check(request, max_requests=5, window_seconds=300, key=f"login_email:{data.email.lower()}") + + if is_account_locked(data.email): + raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.") + with db() as conn: user = conn.execute( - "SELECT id, pw_hash, name, rolle, is_premium FROM users WHERE email=?", + "SELECT id, pw_hash, name, rolle, is_premium, email_verified FROM users WHERE email=?", (data.email,) ).fetchone() if not user or not verify_password(data.password, user["pw_hash"]): + record_login_failure(data.email) raise HTTPException(401, "E-Mail oder Passwort falsch.") + if not user["email_verified"]: + raise HTTPException(403, "EMAIL_NOT_VERIFIED") + + clear_login_failures(data.email) token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) @@ -249,23 +264,24 @@ async def verify_email(token: str): return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302) +class ResendVerificationRequest(BaseModel): + email: EmailStr + @router.post("/resend-verification") -async def resend_verification(request: Request, user=Depends(get_current_user)): - rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify") +async def resend_verification(data: ResendVerificationRequest, request: Request): + rl_check(request, max_requests=3, window_seconds=3600, key=f"resend_verify_{data.email}") with db() as conn: row = conn.execute( - "SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],) + "SELECT id, name, email_verified FROM users WHERE email=?", (data.email,) ).fetchone() - if not row: - raise HTTPException(404) - if row["email_verified"]: - return {"ok": True, "already_verified": True} + if not row or row["email_verified"]: + return {"ok": True} token = secrets.token_urlsafe(32) with db() as conn: conn.execute( - "UPDATE users SET verification_token=? WHERE id=?", (token, user["id"]) + "UPDATE users SET verification_token=? WHERE id=?", (token, row["id"]) ) - _send_verification_email(row["email"], row["name"], token) + _send_verification_email(data.email, row["name"], token) return {"ok": True} @@ -292,19 +308,26 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request): "UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?", (token, expires, user["id"]) ) + import html as _html app_url = os.getenv("APP_URL", "https://banyaro.app") + url = f"{app_url}/#reset-password?token={token}" subject = "Ban Yaro — Passwort zurücksetzen" - body = ( - f"Hallo {user['name']},\n\n" - "du hast eine Passwort-Zurücksetzen-Anfrage gestellt.\n\n" - f"Klicke hier um ein neues Passwort zu setzen:\n" - f"{app_url}/#reset-password?token={token}\n\n" - "Der Link ist 2 Stunden gültig. Falls du keine Anfrage gestellt hast, ignoriere diese Mail.\n\n" - "Viele Grüße,\nDas Ban Yaro Team" - ) from routes.outreach import _send_smtp + from mailer import email_html + _ename = _html.escape(user['name']) + body_html = f""" +

Hallo {_ename},

+

+ du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen. +

+

Der Link ist 2 Stunden gültig.

+

+ Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach. +

""" + html = email_html(body_html, cta_url=url, cta_label="Passwort zurücksetzen") + plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n" try: - _send_smtp(data.email, subject, body, "support") + _send_smtp(data.email, subject, plain, "support", html=html) except Exception: pass return {"ok": True} diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index bb5efc8..355a575 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -11,7 +11,7 @@ from typing import Optional from database import db from auth import get_current_user, require_premium -from mailer import send_email +from mailer import send_email, email_html router = APIRouter() logger = logging.getLogger(__name__) @@ -131,21 +131,21 @@ async def breeder_apply( ) # Admin benachrichtigen - admin_html = f""" -

Neuer Züchter-Antrag

-

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

-

Zwingername: {zwingername}

-

Rasse: {rasse_text}

-

Verein: {verein}

-

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

-

Stadt: {stadt}

-

Im Admin-Bereich prüfen

- """ + admin_body = f""" +

Neuer Züchter-Antrag eingegangen:

+ + + + + + + +
Von{user['name']} ({user['email']})
Zwingername{zwingername}
Rasse{rasse_text}
Verein{verein}
VDH{'Ja' if vdh_mitglied else 'Nein'}
Stadt{stadt}
""" try: await send_email( ADMIN_EMAIL, f"[Banyaro] Neuer Züchter-Antrag — {zwingername}", - admin_html, + email_html(admin_body, cta_url=f"{APP_URL}/#admin", cta_label="Im Admin-Bereich prüfen"), f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}", ) except Exception as e: @@ -233,18 +233,17 @@ async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)): ) # Bestätigungs-Mail - html = f""" -

Willkommen als Züchter bei Banyaro!

-

Hallo {user['name']},

-

dein Züchter-Profil wurde erfolgreich verifiziert.

-

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

-

Zur App

- """ + approve_body = f""" +

Hallo {user['name']},

+

+ dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉
+ Ab sofort hast du Zugang zu allen Züchter-Features. +

""" try: await send_email( user["email"], - "Willkommen als Züchter bei Banyaro!", - html, + "Willkommen als Züchter bei Ban Yaro!", + email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"), f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.", ) except Exception as e: @@ -274,19 +273,25 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req ) # Ablehnungs-Mail - html = f""" -

Dein Züchter-Antrag bei Banyaro

-

Hallo {user['name']},

-

leider konnten wir deinen Antrag aktuell nicht bestätigen.

-

Grund: {body.grund}

-

Du kannst jederzeit einen neuen Antrag stellen.

-

Bei Fragen: {ADMIN_EMAIL}

- """ + import html as _h + reject_body = f""" +

Hallo {user['name']},

+

+ leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen. +

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

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

""" try: await send_email( user["email"], - "Dein Züchter-Antrag bei Banyaro", - html, + "Dein Züchter-Antrag bei Ban Yaro", + email_html(reject_body), f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}", ) except Exception as e: diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 74f1c95..a44faa0 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -75,6 +75,21 @@ async def list_dogs(user=Depends(get_current_user)): d = dict(r) d["is_guest"] = True result.append(d) + + # HdM-Siege pro Hund anhängen + if result: + dog_ids = [d["id"] for d in result] + with db() as conn: + wins_rows = conn.execute( + f"SELECT dog_id, monat FROM hund_des_monats_wins WHERE dog_id IN ({','.join('?'*len(dog_ids))}) ORDER BY monat DESC", + dog_ids, + ).fetchall() + wins_map: dict[int, list[str]] = {} + for w in wins_rows: + wins_map.setdefault(w["dog_id"], []).append(w["monat"]) + for d in result: + d["hdm_wins"] = wins_map.get(d["id"], []) + return result @@ -300,11 +315,13 @@ async def update_dog(dog_id: int, data: DogUpdate, user=Depends(get_current_user values = list(fields.values()) + [dog_id, user["id"]] with db() as conn: - conn.execute( + updated = conn.execute( f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values - ) + ).rowcount + if not updated: + raise HTTPException(404, "Hund nicht gefunden.") dog = conn.execute( - "SELECT * FROM dogs WHERE id=?", (dog_id,) + "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) ).fetchone() return dict(dog) @@ -398,8 +415,8 @@ async def delete_photo(dog_id: int, user=Depends(get_current_user)): os.remove(path) with db() as conn: conn.execute( - "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=?", - (dog_id,) + "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=? AND user_id=?", + (dog_id, user["id"]) ) diff --git a/backend/routes/expenses.py b/backend/routes/expenses.py new file mode 100644 index 0000000..9c93475 --- /dev/null +++ b/backend/routes/expenses.py @@ -0,0 +1,396 @@ +"""BAN YARO — Ausgaben-Tracker Routes""" + +import logging +from datetime import date, timedelta +from dateutil.relativedelta import relativedelta +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() +logger = logging.getLogger(__name__) + +KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonstiges"} + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class ExpenseCreate(BaseModel): + dog_id: Optional[int] = None + kategorie: str + betrag: float + datum: str + notiz: Optional[str] = None + + +class ExpenseUpdate(BaseModel): + dog_id: Optional[int] = None + kategorie: Optional[str] = None + betrag: Optional[float] = None + datum: Optional[str] = None + notiz: Optional[str] = None + + +class RecurringCreate(BaseModel): + dog_id: Optional[int] = None + kategorie: str + betrag: float + haeufigkeit: str # monatlich | quartalsweise | jaehrlich + startdatum: str # ISO date + notiz: Optional[str] = None + +class RecurringUpdate(BaseModel): + dog_id: Optional[int] = None + kategorie: Optional[str] = None + betrag: Optional[float] = None + haeufigkeit: Optional[str] = None + startdatum: Optional[str] = None + notiz: Optional[str] = None + aktiv: Optional[bool] = None + + +HAEUFIGKEITEN = {"monatlich", "quartalsweise", "jaehrlich"} + + +def _next_due(startdatum: str, haeufigkeit: str, after: date) -> date: + """Berechnet das nächste Fälligkeitsdatum nach `after`.""" + d = date.fromisoformat(startdatum) + if d > after: + return d + if haeufigkeit == "monatlich": + delta = relativedelta(months=1) + elif haeufigkeit == "quartalsweise": + delta = relativedelta(months=3) + else: + delta = relativedelta(years=1) + while d <= after: + d += delta + return d + + +def _serialize(row) -> dict: + return dict(row) + + +# ------------------------------------------------------------------ +# GET /api/expenses/summary — Monats- und Jahressummen +# WICHTIG: Diese Route muss VOR /{id} stehen! +# ------------------------------------------------------------------ +@router.get("/summary") +async def get_summary( + dog_id: Optional[int] = Query(default=None), + user=Depends(get_current_user), +): + today = date.today() + monat_prefix = today.strftime("%Y-%m") + jahr_prefix = today.strftime("%Y") + + extra_cond = "" + extra_params: list = [] + if dog_id is not None: + extra_cond = " AND dog_id=?" + extra_params = [dog_id] + + with db() as conn: + # Monats-Summen pro Kategorie + rows_monat = conn.execute( + f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe + FROM expenses + WHERE user_id=? AND datum LIKE ?{extra_cond} + GROUP BY kategorie""", + [user["id"], f"{monat_prefix}%"] + extra_params, + ).fetchall() + + # Jahres-Summen pro Kategorie + rows_jahr = conn.execute( + f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe + FROM expenses + WHERE user_id=? AND datum LIKE ?{extra_cond} + GROUP BY kategorie""", + [user["id"], f"{jahr_prefix}%"] + extra_params, + ).fetchall() + + monat = {r["kategorie"]: round(r["summe"], 2) for r in rows_monat} + jahr = {r["kategorie"]: round(r["summe"], 2) for r in rows_jahr} + + gesamt_monat = round(sum(monat.values()), 2) + gesamt_jahr = round(sum(jahr.values()), 2) + + return { + "monat": monat, + "jahr": jahr, + "gesamt_monat": gesamt_monat, + "gesamt_jahr": gesamt_jahr, + } + + +# ------------------------------------------------------------------ +# GET /api/expenses — Liste mit optionalen Filtern +# ------------------------------------------------------------------ +@router.get("") +async def list_expenses( + dog_id: Optional[int] = Query(default=None), + von: Optional[str] = Query(default=None), + bis: Optional[str] = Query(default=None), + limit: int = Query(default=100, le=500), + offset: int = Query(default=0), + user=Depends(get_current_user), +): + conditions = ["e.user_id=?"] + params: list = [user["id"]] + + if dog_id is not None: + conditions.append("e.dog_id=?") + params.append(dog_id) + if von: + conditions.append("e.datum >= ?") + params.append(von) + if bis: + conditions.append("e.datum <= ?") + params.append(bis) + + where = " AND ".join(conditions) + params += [limit, offset] + + with db() as conn: + rows = conn.execute( + f"""SELECT e.*, d.name AS dog_name + FROM expenses e + LEFT JOIN dogs d ON d.id = e.dog_id + WHERE {where} + ORDER BY e.datum DESC, e.id DESC + LIMIT ? OFFSET ?""", + params, + ).fetchall() + + return [_serialize(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/expenses — neuer Eintrag +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def create_expense(data: ExpenseCreate, user=Depends(get_current_user)): + if data.kategorie not in KATEGORIEN: + raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}") + if data.betrag <= 0: + raise HTTPException(400, "Betrag muss größer als 0 sein.") + + with db() as conn: + # dog_id prüfen — muss dem User gehören + if data.dog_id is not None: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", + (data.dog_id, user["id"]), + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + conn.execute( + """INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz) + VALUES (?, ?, ?, ?, ?, ?)""", + (user["id"], data.dog_id, data.kategorie, data.betrag, data.datum, data.notiz), + ) + row = conn.execute( + "SELECT * FROM expenses WHERE user_id=? ORDER BY id DESC LIMIT 1", + (user["id"],), + ).fetchone() + + return _serialize(row) + + +# ------------------------------------------------------------------ +# PATCH /api/expenses/{id} — bearbeiten +# ------------------------------------------------------------------ +@router.patch("/{expense_id}") +async def update_expense( + expense_id: int, data: ExpenseUpdate, user=Depends(get_current_user) +): + with db() as conn: + row = conn.execute( + "SELECT * FROM expenses WHERE id=? AND user_id=?", + (expense_id, user["id"]), + ).fetchone() + if not row: + raise HTTPException(404, "Eintrag nicht gefunden.") + + updates = {} + if data.kategorie is not None: + if data.kategorie not in KATEGORIEN: + raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}") + updates["kategorie"] = data.kategorie + if data.betrag is not None: + if data.betrag <= 0: + raise HTTPException(400, "Betrag muss größer als 0 sein.") + updates["betrag"] = data.betrag + if data.datum is not None: + updates["datum"] = data.datum + if data.notiz is not None: + updates["notiz"] = data.notiz + if data.dog_id is not None: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", + (data.dog_id, user["id"]), + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + updates["dog_id"] = data.dog_id + + if not updates: + return _serialize(row) + + set_clause = ", ".join(f"{k}=?" for k in updates) + values = list(updates.values()) + [expense_id] + conn.execute(f"UPDATE expenses SET {set_clause} WHERE id=?", values) + row = conn.execute("SELECT * FROM expenses WHERE id=?", (expense_id,)).fetchone() + + return _serialize(row) + + +# ------------------------------------------------------------------ +# DELETE /api/expenses/{id} — löschen +# ------------------------------------------------------------------ +@router.delete("/{expense_id}", status_code=204) +async def delete_expense(expense_id: int, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT id FROM expenses WHERE id=? AND user_id=?", + (expense_id, user["id"]), + ).fetchone() + if not row: + raise HTTPException(404, "Eintrag nicht gefunden.") + conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,)) + return None + + +# ------------------------------------------------------------------ +# Wiederkehrende Ausgaben +# ------------------------------------------------------------------ +@router.get("/recurring") +async def list_recurring(user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute( + """SELECT r.*, d.name AS dog_name + FROM recurring_expenses r + LEFT JOIN dogs d ON d.id = r.dog_id + WHERE r.user_id=? ORDER BY r.startdatum DESC""", + (user["id"],), + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("/recurring", status_code=201) +async def create_recurring(data: RecurringCreate, user=Depends(get_current_user)): + if data.kategorie not in KATEGORIEN: + raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}") + if data.haeufigkeit not in HAEUFIGKEITEN: + raise HTTPException(400, f"Ungültige Häufigkeit: {data.haeufigkeit}") + if data.betrag <= 0: + raise HTTPException(400, "Betrag muss größer als 0 sein.") + + today = date.today() + naechste = _next_due(data.startdatum, data.haeufigkeit, today - timedelta(days=1)) + + with db() as conn: + if data.dog_id: + if not conn.execute("SELECT 1 FROM dogs WHERE id=? AND user_id=?", + (data.dog_id, user["id"])).fetchone(): + raise HTTPException(404, "Hund nicht gefunden.") + conn.execute( + """INSERT INTO recurring_expenses + (user_id, dog_id, kategorie, betrag, haeufigkeit, startdatum, naechste_faelligkeit, notiz) + VALUES (?,?,?,?,?,?,?,?)""", + (user["id"], data.dog_id, data.kategorie, data.betrag, + data.haeufigkeit, data.startdatum, str(naechste), data.notiz), + ) + row = conn.execute( + "SELECT * FROM recurring_expenses WHERE user_id=? ORDER BY id DESC LIMIT 1", + (user["id"],), + ).fetchone() + return dict(row) + + +@router.patch("/recurring/{rid}") +async def update_recurring(rid: int, data: RecurringUpdate, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT * FROM recurring_expenses WHERE id=? AND user_id=?", (rid, user["id"]) + ).fetchone() + if not row: + raise HTTPException(404, "Dauerauftrag nicht gefunden.") + updates: dict = {} + if data.kategorie is not None: + if data.kategorie not in KATEGORIEN: + raise HTTPException(400, f"Ungültige Kategorie.") + updates["kategorie"] = data.kategorie + if data.betrag is not None: + updates["betrag"] = data.betrag + if data.haeufigkeit is not None: + if data.haeufigkeit not in HAEUFIGKEITEN: + raise HTTPException(400, "Ungültige Häufigkeit.") + updates["haeufigkeit"] = data.haeufigkeit + if data.startdatum is not None: + updates["startdatum"] = data.startdatum + if data.notiz is not None: + updates["notiz"] = data.notiz + if data.aktiv is not None: + updates["aktiv"] = 1 if data.aktiv else 0 + if updates: + # naechste_faelligkeit neu berechnen wenn relevante Felder geändert + startdatum = updates.get("startdatum", row["startdatum"]) + haeufigkeit = updates.get("haeufigkeit", row["haeufigkeit"]) + today = date.today() + updates["naechste_faelligkeit"] = str( + _next_due(startdatum, haeufigkeit, today - timedelta(days=1)) + ) + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute(f"UPDATE recurring_expenses SET {set_clause} WHERE id=?", + [*updates.values(), rid]) + row = conn.execute("SELECT * FROM recurring_expenses WHERE id=?", (rid,)).fetchone() + return dict(row) + + +@router.delete("/recurring/{rid}", status_code=204) +async def delete_recurring(rid: int, user=Depends(get_current_user)): + with db() as conn: + if not conn.execute("SELECT 1 FROM recurring_expenses WHERE id=? AND user_id=?", + (rid, user["id"])).fetchone(): + raise HTTPException(404, "Dauerauftrag nicht gefunden.") + conn.execute("DELETE FROM recurring_expenses WHERE id=?", (rid,)) + return None + + +def process_due_recurring(user_id: int | None = None): + """Legt fällige Daueraufträge als Einträge an. Wird vom Scheduler aufgerufen.""" + today = date.today() + today_str = str(today) + with db() as conn: + where = "aktiv=1 AND naechste_faelligkeit <= ?" + params: list = [today_str] + if user_id: + where += " AND user_id=?" + params.append(user_id) + rows = conn.execute( + f"SELECT * FROM recurring_expenses WHERE {where}", params + ).fetchall() + + for r in rows: + # Eintrag anlegen + conn.execute( + """INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz) + VALUES (?,?,?,?,?,?)""", + (r["user_id"], r["dog_id"], r["kategorie"], r["betrag"], + r["naechste_faelligkeit"], + f"[Dauerauftrag] {r['notiz'] or r['kategorie']}"), + ) + # Nächste Fälligkeit berechnen + naechste = _next_due(r["startdatum"], r["haeufigkeit"], + date.fromisoformat(r["naechste_faelligkeit"])) + conn.execute( + "UPDATE recurring_expenses SET naechste_faelligkeit=? WHERE id=?", + (str(naechste), r["id"]), + ) + return len(rows) if rows else 0 diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 0cfe1df..2834ab0 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -7,6 +7,8 @@ from typing import Optional from database import db from auth import get_current_user, get_current_user_optional from timeutils import safe_client_time +from ratelimit import is_duplicate_post, record_post +from content_filter import check_forum_content from routes.push import send_push_to_user from media_utils import convert_media, extract_video_thumb, generate_preview, preview_url_from @@ -164,6 +166,50 @@ async def list_threads( # ------------------------------------------------------------------ # POST /api/forum/threads # ------------------------------------------------------------------ +def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None, is_thread: bool = False): + """Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts.""" + # 30-Sekunden-Cooldown zwischen beliebigen Posts + last = conn.execute( + """SELECT MAX(created_at) AS last FROM ( + SELECT created_at FROM forum_threads WHERE user_id=? + UNION ALL + SELECT created_at FROM forum_posts WHERE user_id=? + )""", + (user_id, user_id), + ).fetchone()["last"] + if last: + try: + from datetime import datetime as _dt + diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds() + if diff < 30: + raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.") + except (ValueError, TypeError): + pass + + # Stunden-Limit + if is_thread: + count = conn.execute( + "SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')", + (user_id,), + ).fetchone()[0] + if count >= 5: + raise HTTPException(429, "Du hast in dieser Stunde bereits 5 Threads erstellt. Bitte warte etwas.") + else: + count = conn.execute( + "SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > datetime('now','-1 hour')", + (user_id,), + ).fetchone()[0] + if count >= 20: + raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.") + + # Duplikat-Check + if is_duplicate_post(user_id, text): + raise HTTPException(400, "Dieser Beitrag wurde bereits kürzlich gepostet.") + + # Content-Filter + check_forum_content(text, user_created_at) + + @router.post("/threads", status_code=201) async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): if not user.get("email_verified"): @@ -177,6 +223,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): if data.kategorie not in KATEGORIEN: raise HTTPException(400, "Ungültige Kategorie.") with db() as conn: + _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True) ct = safe_client_time(data.client_time) cur = conn.execute( """INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at) @@ -194,6 +241,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): t = dict(row) t['foto_urls'] = _parse_foto_urls(t.get('foto_urls')) t['user_liked'] = False + record_post(user["id"], data.text.strip()) return t @@ -322,6 +370,8 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current if thread['is_deleted']: raise HTTPException(404, "Thread nicht gefunden.") + _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False) + ct = safe_client_time(data.client_time) cur = conn.execute( "INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)", @@ -347,6 +397,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current pd = dict(row) pd['foto_urls'] = [] pd['user_liked'] = False + record_post(user["id"], data.text.strip()) # Push-Notification an Thread-Owner (nicht an sich selbst) if owner_id and owner_id != user['id']: @@ -590,7 +641,7 @@ async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_c # GET /api/forum/members/map # ------------------------------------------------------------------ @router.get("/members/map") -async def members_map(): +async def members_map(user=Depends(get_current_user)): with db() as conn: rows = conn.execute( """SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname, diff --git a/backend/routes/health_docs.py b/backend/routes/health_docs.py new file mode 100644 index 0000000..0c2d4a7 --- /dev/null +++ b/backend/routes/health_docs.py @@ -0,0 +1,138 @@ +"""BAN YARO — Gesundheitsdokumente (Befunde, Röntgen, Rezepte …)""" + +import os +import uuid +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"} +MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB + +ERLAUBTE_TYPEN = {"blutbild", "roentgen", "rezept", "impfausweis", "sonstiges"} + + +def _check_dog_owner(conn, dog_id: int, user_id: int): + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + return dog + + +# ------------------------------------------------------------------ +# GET /api/health-docs?dog_id=... +# ------------------------------------------------------------------ +@router.get("") +async def list_docs(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + rows = conn.execute( + """SELECT hd.*, t.name AS vet_name + FROM health_documents hd + LEFT JOIN tieraerzte t ON t.id = hd.vet_id + WHERE hd.dog_id=? + ORDER BY hd.created_at DESC""", + (dog_id,) + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/health-docs/upload (multipart/form-data) +# ------------------------------------------------------------------ +@router.post("/upload", status_code=201) +async def upload_doc( + dog_id: int = Form(...), + typ: str = Form(...), + titel: str = Form(...), + beschreibung: Optional[str] = Form(None), + datum: Optional[str] = Form(None), + vet_id: Optional[int] = Form(None), + file: UploadFile = File(...), + user=Depends(get_current_user), +): + if typ not in ERLAUBTE_TYPEN: + raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(sorted(ERLAUBTE_TYPEN))}") + + ext = os.path.splitext(file.filename or "")[1].lower() + if not ext: + ext = ".jpg" + if ext not in ALLOWED_EXTENSIONS: + raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.") + + content = await file.read() + if len(content) > MAX_SIZE_BYTES: + raise HTTPException(413, "Datei zu groß. Maximal 10 MB erlaubt.") + + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + if vet_id: + vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (vet_id,)).fetchone() + if not vet: + vet_id = None + + # Datei speichern + dog_dir = os.path.join(MEDIA_DIR, "health_docs", str(dog_id)) + os.makedirs(dog_dir, exist_ok=True) + filename = f"{uuid.uuid4().hex}{ext}" + filepath = os.path.join(dog_dir, filename) + with open(filepath, "wb") as f: + f.write(content) + + file_url = f"/media/health_docs/{dog_id}/{filename}" + file_type = "pdf" if ext == ".pdf" else ext.lstrip(".") + + with db() as conn: + conn.execute( + """INSERT INTO health_documents + (dog_id, user_id, typ, titel, beschreibung, file_path, file_type, datum, vet_id) + VALUES (?,?,?,?,?,?,?,?,?)""", + (dog_id, user["id"], typ, titel.strip(), beschreibung, + file_url, file_type, datum or None, vet_id) + ) + row = conn.execute( + """SELECT hd.*, t.name AS vet_name + FROM health_documents hd + LEFT JOIN tieraerzte t ON t.id = hd.vet_id + WHERE hd.id = last_insert_rowid()""" + ).fetchone() + + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/health-docs/{id} +# ------------------------------------------------------------------ +@router.delete("/{doc_id}", status_code=204) +async def delete_doc(doc_id: int, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT * FROM health_documents WHERE id=? AND user_id=?", + (doc_id, user["id"]) + ).fetchone() + if not row: + raise HTTPException(404, "Dokument nicht gefunden.") + + # Datei löschen — file_path ist z.B. /media/health_docs/42/abc123.pdf + file_path = row["file_path"] + if file_path: + # /media/... → MEDIA_DIR/... + rel = file_path.lstrip("/") + if rel.startswith("media/"): + rel = rel[len("media/"):] + abs_path = os.path.join(MEDIA_DIR, rel) + if os.path.isfile(abs_path): + try: + os.remove(abs_path) + except OSError: + pass + + conn.execute("DELETE FROM health_documents WHERE id=?", (doc_id,)) + + return None diff --git a/backend/routes/jobs.py b/backend/routes/jobs.py new file mode 100644 index 0000000..59c73c2 --- /dev/null +++ b/backend/routes/jobs.py @@ -0,0 +1,327 @@ +"""BAN YARO — Social-Media-Job Bewerbungs-System""" + +import html as _html +import os +import uuid +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi.responses import FileResponse +from typing import Optional +from database import db +from auth import get_current_user, get_current_user_optional, require_admin +from mailer import send_email, email_html + +router = APIRouter() + +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +JOBS_DIR = os.path.join(MEDIA_DIR, "jobs") +TRIAL_DAYS = 14 +MAX_FILES = 3 +MAX_FILE_MB = 10 + +os.makedirs(JOBS_DIR, exist_ok=True) + +_ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"} + + +# ------------------------------------------------------------------ +# POST /api/jobs/apply +# ------------------------------------------------------------------ +async def apply( + name: str = Form(...), + email: str = Form(...), + dog_name: str = Form(""), + dog_rasse: str = Form(""), + social_handle: str = Form(...), + motivation: str = Form(...), + files: list[UploadFile] = File(default=[]), + user = Depends(get_current_user_optional), +): + if len(motivation.strip()) < 80: + raise HTTPException(400, "Bitte schreibe etwas mehr über dich (mindestens 80 Zeichen).") + if len(files) > MAX_FILES: + raise HTTPException(400, f"Maximal {MAX_FILES} Dateien erlaubt.") + + user_id = user["id"] if user else None + + # Doppelbewerbung verhindern + if user_id: + with db() as conn: + existing = conn.execute( + "SELECT id FROM job_applications WHERE user_id=? AND status NOT IN ('rejected')", + (user_id,) + ).fetchone() + if existing: + raise HTTPException(400, "Du hast bereits eine aktive Bewerbung eingereicht.") + + with db() as conn: + cur = conn.execute(""" + INSERT INTO job_applications + (user_id, name, email, dog_name, dog_rasse, social_handle, motivation) + VALUES (?,?,?,?,?,?,?) + """, (user_id, name.strip(), email.strip(), dog_name.strip(), + dog_rasse.strip(), social_handle.strip(), motivation.strip())) + app_id = cur.lastrowid + + # Dokumente speichern + app_dir = os.path.join(JOBS_DIR, str(app_id)) + os.makedirs(app_dir, exist_ok=True) + + for f in files: + if not f.filename: + continue + ext = os.path.splitext(f.filename)[1].lower() + if ext not in _ALLOWED_EXT: + continue + size = 0 + safe_name = f"{uuid.uuid4().hex}{ext}" + dest = os.path.join(app_dir, safe_name) + with open(dest, "wb") as out: + while chunk := await f.read(65536): + size += len(chunk) + if size > MAX_FILE_MB * 1024 * 1024: + out.close() + os.remove(dest) + raise HTTPException(400, f"Datei zu groß (max. {MAX_FILE_MB} MB).") + out.write(chunk) + conn.execute(""" + INSERT INTO job_application_docs (application_id, filename, file_path) + VALUES (?,?,?) + """, (app_id, f.filename, dest)) + + # Luna-Probezugang: 14 Tage ab sofort + if user_id: + trial_until = (datetime.utcnow() + timedelta(days=TRIAL_DAYS)).isoformat() + conn.execute( + "UPDATE users SET luna_trial_until=? WHERE id=?", + (trial_until, user_id) + ) + + # Bestätigungs-Mail an Bewerber + try: + _name = _html.escape(name) + body = f""" +

Hallo {_name},

+

+ deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen. + Wir melden uns bald bei dir! +

+ {"

🎉 Luna-Probezugang aktiviert!
Du hast für 14 Tage kostenlos Zugang zu Luna, unserem KI-Social-Media-Assistenten. Logge dich ein und probiere ihn aus.

" if user_id else ""} +

Das Ban Yaro Team

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

Neue Job-Bewerbung eingegangen:

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

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

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

Hallo {_ename},

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

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

Hallo {_ename},

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

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

Hallo {_ename},

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

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

Hallo {_ename},

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

Tierschutz-Hinweis bestätigt

-

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

-

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

-

Wurf-ID: {litter_id}

-

Im Admin-Bereich prüfen

- """ + import html as _html + welfare_body = f""" +

Kritischer Tierschutz-Hinweis bestätigt

+ + + + + + +
Züchter{_html.escape(zuechter)}
Zwinger{_html.escape(zwinger)}
Vater{_html.escape(eltern['vater_name'] or '—')}
Mutter{_html.escape(eltern['mutter_name'] or '—')}
Wurf-ID#{litter_id}
""" try: await send_email( admin_email, f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}", - html, + email_html(welfare_body, cta_url=f"{app_url}/#admin", cta_label="Im Admin-Bereich prüfen"), f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.", ) except Exception as e: diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py index 95b33a9..fa74871 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -1,4 +1,5 @@ """BAN YARO — Moderations-Panel Backend""" +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from database import db from auth import get_current_user @@ -69,17 +70,19 @@ async def mod_stats(user=Depends(require_moderator)): async def mod_reports(user=Depends(require_moderator)): with db() as conn: rows = conn.execute(""" - SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at, - u.name AS melder_name, + SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, + r.created_at, r.resolved_at, + u.name AS melder_name, + m.name AS resolved_by_name, CASE r.target_type WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id) WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id) END AS content_preview FROM forum_reports r LEFT JOIN users u ON u.id=r.user_id - WHERE r.resolved=0 - ORDER BY r.created_at DESC - LIMIT 100 + LEFT JOIN users m ON m.id=r.resolved_by + ORDER BY r.resolved ASC, r.created_at DESC + LIMIT 200 """).fetchall() return [dict(r) for r in rows] @@ -97,8 +100,12 @@ async def mod_resolve_report(rid: int, user=Depends(require_moderator)): raise HTTPException(404, "Meldung nicht gefunden.") new_state = 0 if r["resolved"] else 1 conn.execute( - "UPDATE forum_reports SET resolved=? WHERE id=?", - (new_state, rid) + """UPDATE forum_reports SET resolved=?, resolved_by=?, resolved_at=? + WHERE id=?""", + (new_state, + user["id"] if new_state else None, + datetime.utcnow().isoformat() if new_state else None, + rid) ) return {"ok": True} @@ -189,17 +196,19 @@ async def mod_patch_user(uid: int, data: dict, user=Depends(require_moderator)): async def mod_fotos(user=Depends(require_moderator)): with db() as conn: rows = conn.execute(""" - SELECT s.id, s.foto_url, s.created_at, + SELECT s.id, s.foto_url, s.status, s.created_at, + s.reviewed_at, s.reject_reason, COALESCE(s.rights_confirmed, 0) AS rights_confirmed, - u.name AS user_name, - r.name AS rasse_name, r.slug AS rasse_slug, + u.name AS user_name, + m.name AS reviewed_by_name, + r.name AS rasse_name, r.slug AS rasse_slug, r.foto_url AS aktuell_foto FROM wiki_foto_submissions s LEFT JOIN users u ON u.id = s.user_id + LEFT JOIN users m ON m.id = s.reviewed_by LEFT JOIN wiki_rassen r ON r.id = s.rasse_id - WHERE s.status = 'pending' - ORDER BY s.created_at ASC - LIMIT 50 + ORDER BY s.status ASC, s.created_at ASC + LIMIT 200 """).fetchall() return [dict(r) for r in rows] @@ -228,11 +237,13 @@ async def mod_poi_edits(user=Depends(require_moderator)): SELECT e.id, e.osm_id, e.poi_name, e.field, e.old_value, e.new_value, e.status, e.created_at, e.resolved_at, - u.name AS einreicher_name + u.name AS einreicher_name, + m.name AS mod_name FROM osm_poi_edits e JOIN users u ON u.id = e.user_id + LEFT JOIN users m ON m.id = e.mod_id ORDER BY e.status ASC, e.created_at DESC - LIMIT 100 + LIMIT 200 """).fetchall() return [dict(r) for r in rows] @@ -257,6 +268,9 @@ async def mod_poi_edit_action(edit_id: int, data: dict, raise HTTPException(409, "Korrektur wurde bereits bearbeitet.") if action == "approve": + _ALLOWED_POI_FIELDS = {"opening_hours", "phone", "website", "name"} + if edit["field"] not in _ALLOWED_POI_FIELDS: + raise HTTPException(400, f"Ungültiges Feld: {edit['field']}") conn.execute( f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?", (edit["new_value"], edit["osm_id"]) diff --git a/backend/routes/movies.py b/backend/routes/movies.py index 5ef83da..da6c682 100644 --- a/backend/routes/movies.py +++ b/backend/routes/movies.py @@ -1,140 +1,380 @@ """BAN YARO — Hunde-Filme Routes""" -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from typing import Optional from datetime import datetime from database import db -from auth import get_current_user, get_current_user_optional +from auth import get_current_user, get_current_user_optional, require_admin router = APIRouter() # ------------------------------------------------------------------ -# Hardcoded Film-Daten +# Seed-Daten — werden beim ersten Start in die DB geschrieben # ------------------------------------------------------------------ -FILME = [ - {"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "bewertung_avg": 4.2}, - {"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "bewertung_avg": 4.0}, - {"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "bewertung_avg": 4.5}, - {"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "bewertung_avg": 4.8}, - {"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "bewertung_avg": 4.3}, - {"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "bewertung_avg": 3.8}, - {"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi/Serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "bewertung_avg": 4.1}, - {"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "bewertung_avg": 4.0}, - {"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "bewertung_avg": 3.5}, - {"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus. Kontroversiell beliebt.", "bild_emoji": "💣", "bewertung_avg": 4.6}, - {"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "bewertung_avg": 4.4}, - {"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "bewertung_avg": 4.3}, +_SEED_FILME = [ + # ── Originalbestand ────────────────────────────────────────────── + {"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "imdb_rating": 7.0}, + {"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "imdb_rating": 6.4}, + {"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "imdb_rating": 7.1}, + {"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "typ": "film", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "imdb_rating": 8.1}, + {"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "imdb_rating": 7.2}, + {"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "imdb_rating": 5.9}, + {"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "imdb_rating": 7.5}, + {"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.3}, + {"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "imdb_rating": 5.7}, + {"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus.", "bild_emoji": "💣", "imdb_rating": 7.4}, + {"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "imdb_rating": 7.9}, + {"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "imdb_rating": 7.3}, + # ── Animation / Kinder ────────────────────────────────────────── + {"id": "lady-and-the-tramp", "titel": "Susi und Strolch", "originaltitel": "Lady and the Tramp", "jahr": 1955, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker mit der berühmtesten Spaghetti-Szene der Filmgeschichte.", "bild_emoji": "🍝", "imdb_rating": 7.3, "streaming": "Disney+"}, + {"id": "fox-and-the-hound", "titel": "Cap und Capper", "originaltitel": "The Fox and the Hound", "jahr": 1981, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Bloodhound", "stirbt_der_hund": False, "beschreibung": "Emotionaler Disney-Film über Freundschaft zwischen Fuchs und Jagdhund — und wie die Welt sie trennt.", "bild_emoji": "🦊", "imdb_rating": 7.2, "streaming": "Disney+"}, + {"id": "balto", "titel": "Balto", "jahr": 1995, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Husky/Wolf-Mischling", "stirbt_der_hund": False, "beschreibung": "1925 brachte Schlittenhund Balto lebensrettende Medizin nach Nome, Alaska. Basiert auf einer wahren Heldengeschichte.", "bild_emoji": "🐺", "imdb_rating": 7.1, "streaming": "Amazon Prime"}, + {"id": "bolt", "titel": "Bolt — Ein Hund für alle Fälle", "originaltitel": "Bolt", "jahr": 2008, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Ein TV-Superhund glaubt, seine Kräfte seien echt, und reist abenteuerlich quer durch Amerika.", "bild_emoji": "⚡", "imdb_rating": 6.8, "streaming": "Disney+"}, + {"id": "frankenweenie", "titel": "Frankenweenie", "jahr": 2012, "genre": "Animation/Horrorkomödie","typ": "film","hund_rasse": "Bullterrier", "stirbt_der_hund": True, "beschreibung": "Tim Burtons Stop-Motion-Meisterwerk: Ein Junge erweckt seinen toten Hund mit Wissenschaft wieder zum Leben.", "bild_emoji": "🧟", "imdb_rating": 6.9, "streaming": "Disney+"}, + {"id": "secret-life-of-pets", "titel": "Pets — Geheimes Leben der Haustiere","originaltitel": "The Secret Life of Pets","jahr": 2016,"genre": "Animation/Komödie","typ": "film","hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Was machen unsere Haustiere, wenn wir nicht zu Hause sind? Rasante Antwort mit Witz und Charme.", "bild_emoji": "🏠", "imdb_rating": 6.5, "streaming": "Amazon Prime"}, + {"id": "plague-dogs", "titel": "The Plague Dogs", "jahr": 1982, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Labrador / Mischling", "stirbt_der_hund": True, "beschreibung": "Düsterer Animationsfilm für Erwachsene: Zwei Hunde fliehen aus einem Tierversuchs-Labor. Brutal ehrlich, nach Richard Adams.", "bild_emoji": "🚫", "imdb_rating": 7.7}, + {"id": "paw-patrol-movie", "titel": "PAW Patrol: Der Kinofilm", "originaltitel": "PAW Patrol: The Movie", "jahr": 2021, "genre": "Animation/Kinder", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Die beliebten TV-Rettungshunde auf der großen Leinwand. Für die jüngsten Fans ein Pflichtprogramm.", "bild_emoji": "🚒", "imdb_rating": 6.1, "streaming": "Amazon Prime"}, + # ── Klassiker vor 1980 ────────────────────────────────────────── + {"id": "the-thin-man", "titel": "Der dünne Mann", "originaltitel": "The Thin Man", "jahr": 1934, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Drahthaariger Foxterrier", "stirbt_der_hund": False, "beschreibung": "Hollywood-Klassiker mit Nick und Nora Charles — und Asta, dem witzigsten Hund der Filmgeschichte. Mehrere Fortsetzungen.", "bild_emoji": "🍸", "imdb_rating": 7.9}, + {"id": "lassie-come-home", "titel": "Lassie kehrt heim", "originaltitel": "Lassie Come Home", "jahr": 1943, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Originalfilm: Ein armes Farmkind muss seinen geliebten Collie verkaufen — Lassie findet trotzdem heim.", "bild_emoji": "🏡", "imdb_rating": 7.1}, + {"id": "incredible-journey", "titel": "Die unglaubliche Reise", "originaltitel": "The Incredible Journey", "jahr": 1963, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Labrador", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze meistern 400 km kanadische Wildnis — Disney-Abenteuer nach dem Roman von Sheila Burnford.", "bild_emoji": "🗺️", "imdb_rating": 7.0}, + {"id": "greyfriars-bobby", "titel": "Greyfriars Bobby", "jahr": 1961, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Skye Terrier", "stirbt_der_hund": False, "beschreibung": "Basiert auf der wahren Geschichte des Terriers, der 14 Jahre das Grab seines Herrchens in Edinburgh bewachte.", "bild_emoji": "⛪", "imdb_rating": 7.2, "streaming": "Disney+"}, + {"id": "sounder", "titel": "Sounder", "jahr": 1972, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": False, "beschreibung": "Oscar-nominiertes Drama über eine schwarze Farmfamilie in der Great Depression. Ihr Hund Sounder ist das Herz der Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.5}, + {"id": "where-red-fern-grows","titel": "Wo der rote Farn wächst", "originaltitel": "Where the Red Fern Grows", "jahr": 1974, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": True, "beschreibung": "Ein Junge spart jahrelang für zwei Jagdhunde. Kultfilm der amerikanischen Kindheit — das Ende lässt kaum jemanden trocken.", "bild_emoji": "🌿", "imdb_rating": 6.9}, + {"id": "milo-and-otis", "titel": "Milo und Otis", "originaltitel": "The Adventures of Milo and Otis","jahr": 1986,"genre": "Abenteuer/Familie","typ": "film", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Japanischer Realfilm mit Katze und Hund auf großer Abenteuerreise. Für Kinder ein Klassiker, für Erwachsene nostalgisches Heimweh.", "bild_emoji": "🐾", "imdb_rating": 6.9}, + {"id": "umberto-d", "titel": "Umberto D.", "jahr": 1952, "genre": "Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Meisterwerk des italienischen Neorealismus: Ein alter Rentner und sein Hund kämpfen würdevoll gegen Armut in Rom.", "bild_emoji": "🇮🇹", "imdb_rating": 8.1, "streaming": "Mubi"}, + # ── Wahre Geschichten ─────────────────────────────────────────── + {"id": "homeward-bound", "titel": "Auf dem Weg nach Hause", "originaltitel": "Homeward Bound", "jahr": 1993, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze kämpfen sich mit Stimmen durch die amerikanische Wildnis nach Hause. Remake des Klassikers.", "bild_emoji": "🏔️", "imdb_rating": 7.0, "streaming": "Disney+"}, + {"id": "togo", "titel": "Togo", "jahr": 2019, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Sibirischer Husky", "stirbt_der_hund": False, "beschreibung": "Die unbekannte Geschichte hinter dem Balto-Mythos: Der echte Held des Serum-Runs 1925 war Togo. Außergewöhnlicher Disney+-Film.", "bild_emoji": "🛷", "imdb_rating": 7.9, "streaming": "Disney+"}, + {"id": "red-dog", "titel": "Red Dog", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Kelpie", "stirbt_der_hund": True, "beschreibung": "Australischer Kultfilm über einen echten Wanderhund, der eine Minengemeinschaft im Outback zusammenbrachte. Rauh und herzlich.", "bild_emoji": "🦘", "imdb_rating": 7.3, "streaming": "Amazon Prime"}, + {"id": "megan-leavey", "titel": "Megan Leavey", "jahr": 2017, "genre": "Biopic/Drama", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Die wahre Geschichte einer US-Marine und ihres Sprengstoff-Suchhundes Rex im Irak-Einsatz. Kate Mara in einer ihrer stärksten Rollen.", "bild_emoji": "🎖️", "imdb_rating": 7.1, "streaming": "Amazon Prime"}, + {"id": "arthur-the-king", "titel": "Arthur der König", "originaltitel": "Arthur the King", "jahr": 2024, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Mark Wahlberg und ein streunender Hund meistern gemeinsam ein Extremrennen durch die Dominikanische Republik. Inspiriert von wahren Ereignissen.","bild_emoji": "🏆", "imdb_rating": 7.0, "streaming": "Amazon Prime"}, + {"id": "rescued-by-ruby", "titel": "Gerettet von Ruby", "originaltitel": "Rescued by Ruby", "jahr": 2022, "genre": "Biopic/Familie", "typ": "film", "hund_rasse": "Australian Shepherd / Border Collie","stirbt_der_hund": False,"beschreibung": "Ein Polizist und ein Tierheim-Hund retten sich gegenseitig — wahre Geschichte aus Rhode Island.", "bild_emoji": "🌟", "imdb_rating": 7.2, "streaming": "Netflix"}, + {"id": "my-dog-skip", "titel": "Mein Hund Skip", "originaltitel": "My Dog Skip", "jahr": 2000, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": True, "beschreibung": "Die Coming-of-Age-Geschichte eines einsamen Jungen im Mississippi der 1940er, der durch seinen Hund Skip Freundschaft findet.", "bild_emoji": "📚", "imdb_rating": 7.0}, + # ── Arbeitshunde / Polizeihunde ───────────────────────────────── + {"id": "turner-and-hooch", "titel": "Turner & Hooch", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": True, "beschreibung": "Tom Hanks als ordentlicher Detective trifft auf Hooch, den sabbernden Chaoshund. Buddy-Cop-Klassiker mit überraschend emotionalem Ende.", "bild_emoji": "🕵️", "imdb_rating": 6.2, "streaming": "Disney+"}, + {"id": "k9", "titel": "K-9", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Jim Belushi als Drogenfahnder bekommt zwangsweise den eigenwilligen Schäferhund Jerry als Partner. Klassiker des Buddy-Cop-Genres.", "bild_emoji": "🚔", "imdb_rating": 6.2}, + {"id": "max", "titel": "Max", "jahr": 2015, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Ein Kriegshund aus Afghanistan wird nach dem Tod seines Handlers von dessen Familie adoptiert. Über Trauma und Vertrauen.", "bild_emoji": "🎗️", "imdb_rating": 6.6, "streaming": "Amazon Prime"}, + {"id": "dog-2022", "titel": "Dog", "jahr": 2022, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Channing Tatum fährt mit einem traumatisierten Kriegshund quer durch Amerika — roh, komisch und berührend.", "bild_emoji": "🚗", "imdb_rating": 6.5, "streaming": "Amazon Prime"}, + {"id": "quill", "titel": "Quill — Ein Führhund", "originaltitel": "Quill: The Life of a Guide Dog","jahr": 2004,"genre": "Drama/Familie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Japanischer Film über das Leben des Führhundes Quill vom Welpen bis zum Tod. Zeigt die unersetzliche Arbeit von Blindenführhunden.", "bild_emoji": "👁️", "imdb_rating": 7.1}, + # ── Komödien ──────────────────────────────────────────────────── + {"id": "beethoven-2", "titel": "Beethoven's 2nd", "jahr": 1993, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Beethoven verliebt sich und bekommt Nachwuchs — vier chaotische Welpen bringen die Familie erneut an den Rand des Nervenzusammenbruchs.", "bild_emoji": "🐶", "imdb_rating": 5.4}, + {"id": "dog-days-2018", "titel": "Dog Days", "jahr": 2018, "genre": "Komödie/Romanze", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Mehrere Angelenos und ihre Hunde, deren Leben sich charmant verflechten. Leichte Feel-Good-Komödie mit Vanessa Hudgens.", "bild_emoji": "☀️", "imdb_rating": 6.3}, + {"id": "as-good-as-it-gets", "titel": "Besser geht's nicht", "originaltitel": "As Good as It Gets", "jahr": 1997, "genre": "Komödie/Drama", "typ": "film", "hund_rasse": "Griffon Bruxellois", "stirbt_der_hund": False, "beschreibung": "Jack Nicholson als Misanthrop, der durch einen kleinen Hund namens Verdell sein Herz entdeckt. Oscar-Gewinner, zeitlos witzig.", "bild_emoji": "💊", "imdb_rating": 7.7, "streaming": "Amazon Prime"}, + {"id": "eat-pray-bark", "titel": "Eat Pray Bark", "jahr": 2026, "genre": "Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Deutsche Komödie über fünf exzentrische Hundebesitzer, die gemeinsam einen Hundetrainer in den Tiroler Bergen aufsuchen. Top 10 in 49 Netflix-Ländern.", "bild_emoji": "🏔️", "imdb_rating": None, "streaming": "Netflix"}, + {"id": "the-artist", "titel": "The Artist", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": False, "beschreibung": "Oscar-Gewinner als Stummfilm-Hommage. Uggie der Jack Russell stahl allen die Show und gewann den Palm Dog Award in Cannes.", "bild_emoji": "🎬", "imdb_rating": 7.8, "streaming": "Amazon Prime"}, + # ── Thriller / Action / Horror ────────────────────────────────── + {"id": "cujo", "titel": "Cujo", "jahr": 1983, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": True, "beschreibung": "Stephen Kings Roman verfilmt: Ein tollwütiger Bernhardiner terrorisiert eine Mutter und ihr Kind in einem Auto. Klassiker des 80er-Horror.", "bild_emoji": "🩸", "imdb_rating": 6.1, "streaming": "Amazon Prime"}, + {"id": "white-god", "titel": "White God — Hund ohne Gnade","originaltitel": "White God", "jahr": 2014, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Ungarischer Arthouse-Thriller mit 250 echten Straßenhunden. Ein Mädchen sucht seinen Hund — während die Hunde Rache nehmen. Cannes-Preis.", "bild_emoji": "🔴", "imdb_rating": 6.8}, + {"id": "dogman-2018", "titel": "Dogman", "jahr": 2018, "genre": "Krimi/Drama", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matteo Garrones preisgekrönter Film: Ein stiller Hundegroomer verstrickt sich mit einem brutalen Kriminellen. Cannes-Gewinner 2018.", "bild_emoji": "✂️", "imdb_rating": 7.2, "streaming": "Amazon Prime"}, + {"id": "call-of-the-wild", "titel": "Ruf der Wildnis", "originaltitel": "The Call of the Wild", "jahr": 2020, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Saint Bernard Mix (CGI)", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Harrison Ford: Hund Buck wandert vom Salon-Leben in die Wildnis des Klondike. Episch.", "bild_emoji": "🌲", "imdb_rating": 6.7, "streaming": "Disney+"}, + # ── Deutsche / österreichische Produktionen ───────────────────── + {"id": "lassie-neues-abenteuer","titel": "Lassie — Ein neues Abenteuer","jahr": 2023, "genre": "Familie/Abenteuer","typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Deutsche Neuinterpretation: Lassie hilft Kindern dabei, mysteriöse Hundesdiebstähle aufzudecken. Für Kinder und Familien.", "bild_emoji": "🐕", "imdb_rating": 5.6}, + # ── Neuere Serien ─────────────────────────────────────────────── + {"id": "turner-hooch-serie", "titel": "Turner & Hooch (Serie)", "originaltitel": "Turner & Hooch", "jahr": 2021, "genre": "Krimi/Komödie", "typ": "serie", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": False, "beschreibung": "Disney+-Sequel zur Filmklassik: Der Sohn des Originals detektiviert mit Hoochs Nachfolger. Nach einer Staffel abgesetzt.", "bild_emoji": "📺", "imdb_rating": 6.6, "streaming": "Disney+"}, + {"id": "healing-powers-of-dude","titel": "Dude, mein Hund", "originaltitel": "The Healing Powers of Dude", "jahr": 2020, "genre": "Komödie/Familie", "typ": "serie", "hund_rasse": "Labradoodle", "stirbt_der_hund": False, "beschreibung": "Netflix-Jugendserie: Ein Junge mit sozialer Angststörung und sein emotionaler Support-Hund meistern gemeinsam die Middle School.", "bild_emoji": "💙", "imdb_rating": 6.6, "streaming": "Netflix"}, + {"id": "hudson-rex", "titel": "Hudson & Rex", "jahr": 2019, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Kanadische Neuauflage von Kommissar Rex: Detective Hudson und sein Schäferhund Rex lösen in Neufundland Verbrechen. Läuft seit 2019.", "bild_emoji": "🍁", "imdb_rating": 7.4}, + # ── Klassische Serien ─────────────────────────────────────────── + {"id": "lassie-serie", "titel": "Lassie (TV-Serie)", "originaltitel": "Lassie", "jahr": 1954, "genre": "Familie/Abenteuer", "typ": "serie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Die legendäre CBS-Serie, die 20 Jahre lief und Generationen prägte. Lassies wöchentliche Rettungsaktionen wurden zum Inbegriff des Treue-Hundes.", "bild_emoji": "📡", "imdb_rating": 7.5}, + {"id": "rin-tin-tin-serie", "titel": "Rin Tin Tin", "originaltitel": "The Adventures of Rin Tin Tin","jahr": 1954, "genre": "Western/Familie", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Legendäre 1950er-Western-Serie. Ein Waisenjunge und sein Schäferhund helfen der US-Kavallerie im Wilden Westen.", "bild_emoji": "🤠", "imdb_rating": 7.0}, + # ── Dokumentationen ───────────────────────────────────────────── + {"id": "dogs-netflix", "titel": "Dogs", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Herzzerreißende Netflix-Dokuserie über die Bindung zwischen Hunden und Menschen weltweit. Sechs Episoden, Tränen garantiert.", "bild_emoji": "❤️", "imdb_rating": 8.0, "streaming": "Netflix"}, + {"id": "pick-of-the-litter", "titel": "Pick of the Litter", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Labrador", "stirbt_der_hund": False, "beschreibung": "Ein Labrador-Wurf wird zur Führhundausbildung bestimmt. Nicht alle schaffen es — spannend wie ein Spielfilm.", "bild_emoji": "🎗️", "imdb_rating": 7.6, "streaming": "Amazon Prime"}, + {"id": "inside-mind-of-dog", "titel": "Im Kopf des Hundes", "originaltitel": "Inside the Mind of a Dog", "jahr": 2024, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Wissenschaftsdoku: Was wissen Hunde wirklich über uns? Rob Lowe erzählt, Forscher erklären die Kognition unserer Vierbeiner.", "bild_emoji": "🧠", "imdb_rating": 7.2, "streaming": "Netflix"}, + {"id": "stray-doku", "titel": "Stray", "jahr": 2020, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Poetische Doku aus Hundeperspektive: Die Kamera folgt drei Straßenhunden durch Istanbul. Philosophisch, still und ungemein berührend.", "bild_emoji": "🕌", "imdb_rating": 6.9, "streaming": "Amazon Prime"}, + {"id": "dog-by-dog", "titel": "Dog by Dog", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über den milliardenschweren Welpen-Industrie-Komplex in den USA. Folgt dem Geldfluss hinter Puppy Mills.", "bild_emoji": "💰", "imdb_rating": 8.8, "streaming": "Netflix"}, + {"id": "gunthers-millions", "titel": "Günthers Millionen", "originaltitel": "Gunther's Millions", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Die absurde Netflix-Doku: Erbte ein Schäferhund wirklich eine halbe Milliarde Euro? Die Wahrheit ist noch seltsamer.", "bild_emoji": "💎", "imdb_rating": 5.6, "streaming": "Netflix"}, + # ── Weitere ───────────────────────────────────────────────────── + {"id": "a-dogs-purpose", "titel": "Bailey — Ein Freund fürs Leben","originaltitel": "A Dog's Purpose", "jahr": 2017, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Verschiedene (Labrador, Corgi u.a.)","stirbt_der_hund": True, "beschreibung": "Ein Hund wird mehrfach wiedergeboren und sucht in jeder Inkarnation nach seinem Sinn. Taschentücher-Pflicht.", "bild_emoji": "🔄", "imdb_rating": 7.3, "streaming": "Amazon Prime"}, + {"id": "a-dogs-journey", "titel": "Bailey 2", "originaltitel": "A Dog's Journey", "jahr": 2019, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Beagle / Bernhardiner u.a.", "stirbt_der_hund": True, "beschreibung": "Fortsetzung: Bailey beschützt in mehreren Leben die Enkelin seines Herrchens. Emotional und rührend.", "bild_emoji": "🔄", "imdb_rating": 7.5, "streaming": "Amazon Prime"}, + {"id": "art-of-racing", "titel": "Enzo und die wundersame Welt der Menschen","originaltitel": "The Art of Racing in the Rain","jahr": 2019,"genre": "Drama","typ": "film","hund_rasse": "Golden Retriever", "stirbt_der_hund": True, "beschreibung": "Ein Golden Retriever erzählt seine Lebensgeschichte. Philosophisch, witzig, herzzerreißend — Kevin Costner leiht ihm die Stimme.", "bild_emoji": "🏎️", "imdb_rating": 7.6, "streaming": "Disney+"}, + {"id": "dog-gone", "titel": "Dog Gone", "originaltitel": "Dog Gone", "jahr": 2023, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Vater und Sohn suchen auf dem Appalachian Trail ihren verlorenen kranken Hund — und finden dabei zueinander. Wahre Geschichte.", "bild_emoji": "🥾", "imdb_rating": 6.1, "streaming": "Netflix"}, + {"id": "white-fang", "titel": "Wolfsblut", "originaltitel": "White Fang", "jahr": 1991, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Wolf-Hund-Hybrid", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Ethan Hawke: Ein Wolf-Hund-Hybrid findet in Alaska zwischen Wildnis und Menschenwelt seinen Platz.", "bild_emoji": "❄️", "imdb_rating": 6.7}, + {"id": "because-of-winn-dixie","titel": "Winn-Dixie", "originaltitel": "Because of Winn-Dixie", "jahr": 2005, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein einsames Mädchen findet einen streunenden Hund im Supermarkt — und mit ihm eine ganze Gemeinschaft.", "bild_emoji": "🛒", "imdb_rating": 6.4}, + {"id": "lassie-2005", "titel": "Lassie (2005)", "jahr": 2005, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Britische Neuverfilmung mit Peter O'Toole. Lassie flieht aus Schottland und findet den langen Weg nach Yorkshire. Atmosphärisch.", "bild_emoji": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "imdb_rating": 6.7}, + # ── Neue Einträge: Animation ────────────────────────────────────── + {"id": "all-dogs-go-to-heaven", "titel": "Alle Hunde kommen in den Himmel", "originaltitel": "All Dogs Go to Heaven", "jahr": 1989, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": True, "beschreibung": "Don Bluth-Klassiker: Gauner-Hund Charlie entkommt dem Himmel und sucht Rache — bis er sich in ein Waisenmädchen verliebt.", "bild_emoji": "😇", "imdb_rating": 6.8}, + {"id": "all-dogs-go-heaven-2", "titel": "Alle Hunde kommen in den Himmel 2", "originaltitel": "All Dogs Go to Heaven 2", "jahr": 1996, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Charlie kehrt aus dem Himmel zurück nach San Francisco — mit Charlie Sheen als Synchronstimme.", "bild_emoji": "😇", "imdb_rating": 5.4}, + {"id": "oliver-and-company", "titel": "Oliver & Co.", "originaltitel": "Oliver & Company", "jahr": 1988, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Verschiedene (Dodger = Mischling)", "stirbt_der_hund": False, "beschreibung": "Disney modernisiert Oliver Twist: Ein obdachloser Kater schließt sich einer Hunde-Gang unter Anführer Dodger im New York der 1980er an.", "bild_emoji": "🎸", "imdb_rating": 6.7, "streaming": "Disney+"}, + {"id": "peanuts-movie", "titel": "Die Peanuts — Der Film", "originaltitel": "The Peanuts Movie", "jahr": 2015, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Snoopy und Charlie Brown auf der großen Leinwand. Perfekt für alle, die mit dem Kultcomic aufgewachsen sind.", "bild_emoji": "✈️", "imdb_rating": 7.0, "streaming": "Disney+"}, + {"id": "clifford-big-red-dog", "titel": "Clifford — Der große rote Hund", "originaltitel": "Clifford the Big Red Dog", "jahr": 2021, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Mischling (riesig)", "stirbt_der_hund": False, "beschreibung": "Der riesige rote Hund aus dem Kinderbuchklassiker kommt ins Kino — Chaos und Herz für die ganze Familie.", "bild_emoji": "🔴", "imdb_rating": 5.4, "streaming": "Amazon Prime"}, + {"id": "101-dalmatians-1996", "titel": "101 Dalmatiner (1996)", "originaltitel": "101 Dalmatians", "jahr": 1996, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Glenn Close als unvergessliche Cruella de Vil im Live-Action-Remake des Disney-Klassikers — böse, schrill und absolut unterhaltsam.", "bild_emoji": "🐾", "imdb_rating": 5.7, "streaming": "Disney+"}, + {"id": "102-dalmatians", "titel": "102 Dalmatiner", "originaltitel": "102 Dalmatians", "jahr": 2000, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Cruella ist scheinbar geläutert — aber die Dalmatiner-Welpen sind wieder in Gefahr. Fortsetzung mit Glenn Close.", "bild_emoji": "🐾", "imdb_rating": 4.9, "streaming": "Disney+"}, + {"id": "lady-and-tramp-2019", "titel": "Susi und Strolch (2019)", "originaltitel": "Lady and the Tramp", "jahr": 2019, "genre": "Familie/Romanze", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney+ Live-Action-Remake mit echten Hunden: Die ikonische Spaghetti-Szene neu inszeniert, herzlich und mit modernem Charme.", "bild_emoji": "🍝", "imdb_rating": 6.4, "streaming": "Disney+"}, + {"id": "my-dog-tulip", "titel": "Mein Hund Tulip", "originaltitel": "My Dog Tulip", "jahr": 2009, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": True, "beschreibung": "Animierter Arthouse-Film nach J.R. Ackerley: Ein eigenbrötlerischer Engländer und die vorbehaltlose Liebe zu seiner Schäferhündin Tulip.", "bild_emoji": "🌷", "imdb_rating": 7.0}, + {"id": "bluey", "titel": "Bluey", "jahr": 2018, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Blue Heeler", "stirbt_der_hund": False, "beschreibung": "Australische Kinder-Animationsserie über eine Blue-Heeler-Familie, die weltweit Eltern und Kinder gleichermaßen begeistert. Meistgeliebte Kinderserie des 21. Jahrhunderts.", "bild_emoji": "💙", "imdb_rating": 9.0, "streaming": "Disney+"}, + {"id": "strays-2023", "titel": "Strays — Lass uns Hunde sein", "originaltitel": "Strays", "jahr": 2023, "genre": "Animation/Komödie", "typ": "film", "hund_rasse": "Mischling / Australian Shepherd", "stirbt_der_hund": False, "beschreibung": "Komplett obszöne Erwachsenen-Trickfilm-Komödie: Verlassener Hund will mit neuen Hundefreunden Rache am Herrchen nehmen. Nicht für Kinder!","bild_emoji": "🤬", "imdb_rating": 5.8, "streaming": "Amazon Prime"}, + # ── Neue Einträge: Familie/Drama ────────────────────────────────── + {"id": "hotel-for-dogs", "titel": "Hotel für Hunde", "originaltitel": "Hotel for Dogs", "jahr": 2009, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Zwei Geschwister verwandeln ein leerstehendes Hotel in ein geheimes Paradies für Straßenhunde. Herzerwärmende Familienunterhaltung.", "bild_emoji": "🏨", "imdb_rating": 5.8}, + {"id": "snow-dogs", "titel": "Snowdogs", "originaltitel": "Snow Dogs", "jahr": 2002, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Siberian Husky", "stirbt_der_hund": False, "beschreibung": "Cuba Gooding Jr. erbt eine Schlittenhunde-Meute in Alaska und muss erst lernen, mit ihnen umzugehen. Leichte Disney-Komödie.", "bild_emoji": "🛷", "imdb_rating": 4.2, "streaming": "Disney+"}, + {"id": "a-dogs-way-home", "titel": "Auf dem Heimweg — Lassie und Ich", "originaltitel": "A Dog's Way Home", "jahr": 2019, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Pit-Bull-Mischling", "stirbt_der_hund": False, "beschreibung": "Hündin Bella verliert sich 400 Meilen von zu Hause entfernt und kämpft sich durch alle Widrigkeiten zurück zu ihrem Herrchen.", "bild_emoji": "🏡", "imdb_rating": 6.7, "streaming": "Netflix"}, + {"id": "fluke", "titel": "Fluke — Das fremde Ich", "originaltitel": "Fluke", "jahr": 1995, "genre": "Drama/Fantasy", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Mann stirbt und wird als Hund wiedergeboren — und kehrt zu seiner Familie zurück. Ungewöhnliches Dramafantasy mit tiefem emotionalem Kern.", "bild_emoji": "🔄", "imdb_rating": 6.2}, + {"id": "zeus-and-roxanne", "titel": "Zeus und Roxanne", "originaltitel": "Zeus and Roxanne", "jahr": 1997, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Hund und ein Delfin werden beste Freunde — und bringen dabei auch ihre Besitzer zusammen. Charmante Familienunterhaltung der 90er.", "bild_emoji": "🐬", "imdb_rating": 5.2}, + {"id": "benji-2018", "titel": "Benji (2018)", "originaltitel": "Benji", "jahr": 2018, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Netflix-Remake des Klassikers: Der streunende Hund Benji rettet erneut Kinder aus gefährlichen Händen — jetzt für eine neue Generation.", "bild_emoji": "🐾", "imdb_rating": 6.3, "streaming": "Netflix"}, + {"id": "ugly-dachshund", "titel": "Der hässliche Dackel", "originaltitel": "The Ugly Dachshund", "jahr": 1966, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutscher Dogge / Dackel", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker: Eine Harlekindogge wird von Dackeln aufgezogen und denkt, sie sei selbst ein Dackel. Harmlose Familienkomödie.", "bild_emoji": "😅", "imdb_rating": 6.4}, + {"id": "shiloh", "titel": "Shiloh — Mein treuer Freund", "originaltitel": "Shiloh", "jahr": 1996, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Junge in West Virginia rettet einen misshandelten Beagle vor seinem brutalen Besitzer — eine Geschichte über Mut und Gewissen.", "bild_emoji": "🌿", "imdb_rating": 6.7}, + {"id": "iron-will", "titel": "Iron Will", "jahr": 1994, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": False, "beschreibung": "Junger Mann rettet die Farm seiner Familie mit einem gewagten Schlittenhunde-Rennen von Kanada nach Minnesota. Inspirierend und episch.", "bild_emoji": "🏆", "imdb_rating": 6.7, "streaming": "Disney+"}, + {"id": "belle-et-sebastien", "titel": "Belle und Sébastien", "originaltitel": "Belle et Sébastien", "jahr": 2013, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Französischer Familienfilm: Ein Waisenjunge und die riesige Berghündin Belle sind beste Freunde in den Alpen des Zweiten Weltkriegs.", "bild_emoji": "🏔️", "imdb_rating": 7.0}, + {"id": "dog-of-flanders", "titel": "Ein Hund von Flandern", "originaltitel": "A Dog of Flanders", "jahr": 1999, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Bouvier des Flandres", "stirbt_der_hund": True, "beschreibung": "Bewegende Verfilmung des Klassikers: Ein armer Junge in Belgien und sein Hund träumen von Kunst und Würde — mit tragischem Ende.", "bild_emoji": "🎨", "imdb_rating": 7.0}, + {"id": "underdog-2007", "titel": "Underdog — Ein Held auf vier Pfoten", "originaltitel": "Underdog", "jahr": 2007, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Laborhund erhält Superkräfte und wird zum maskierten Superhelden der Stadt. Leichte Disney-Familienkomödie nach der Zeichentrickserie.", "bild_emoji": "🦸", "imdb_rating": 5.1}, + {"id": "bingo-1991", "titel": "Bingo", "jahr": 1991, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Zirkushund reist quer durch Amerika, um seinen jungen Herrchen zu finden. Kindheitserinnerung der frühen 90er.", "bild_emoji": "🎪", "imdb_rating": 5.8}, + {"id": "goodbye-my-lady", "titel": "Leb wohl, Lady", "originaltitel": "Goodbye, My Lady", "jahr": 1956, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Basenji", "stirbt_der_hund": False, "beschreibung": "Ein Junge im Mississippi-Sumpfland findet einen seltsamen lachenden Hund — und muss ihn am Ende zurückgeben. Zeitloser Klassiker.", "bild_emoji": "🌊", "imdb_rating": 7.1}, + {"id": "mitt-liv-som-hund", "titel": "Mein Leben als Hund", "originaltitel": "Mitt liv som hund", "jahr": 1985, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Lasse Hallströms schwedisches Meisterwerk: Ein Junge wird aufs Land geschickt und vergleicht sein Leben mit dem Schicksal von Laika.", "bild_emoji": "🇸🇪", "imdb_rating": 7.8}, + {"id": "homeward-bound-2", "titel": "Auf dem Weg nach Hause 2 — Im Großstadtdschungel", "originaltitel": "Homeward Bound II: Lost in San Francisco", "jahr": 1996, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Die Tiere aus Teil 1 verirren sich in San Francisco und müssen erneut den Weg nach Hause finden — diesmal durch die Stadt.", "bild_emoji": "🌉", "imdb_rating": 6.0, "streaming": "Disney+"}, + {"id": "alpha-2018", "titel": "Alpha", "jahr": 2018, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Wolf", "stirbt_der_hund": False, "beschreibung": "Vor 20.000 Jahren: Ein junger Jäger freundet sich mit einem verletzten Wolf an und legt damit den Grundstein für die Mensch-Hund-Beziehung.", "bild_emoji": "🐺", "imdb_rating": 6.7, "streaming": "Amazon Prime"}, + {"id": "nankyoku-monogatari", "titel": "Antarktis", "originaltitel": "Nankyoku Monogatari", "jahr": 1983, "genre": "Drama/Abenteuer", "typ": "film", "hund_rasse": "Sakhalin Husky", "stirbt_der_hund": True, "beschreibung": "Japanisches Meisterwerk: 15 Schlittenhunde werden 1958 in der Antarktis zurückgelassen — die wahre Geschichte zweier Überlebender.", "bild_emoji": "🇯🇵", "imdb_rating": 7.7}, + # ── Neue Einträge: Komödie ──────────────────────────────────────── + {"id": "cats-and-dogs", "titel": "Cats & Dogs", "jahr": 2001, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle / Verschiedene", "stirbt_der_hund": False, "beschreibung": "Geheimdienstagenten auf vier Pfoten: Hunde gegen Katzen im Kampf um die Weltherrschaft. Spionagefilm-Parodie für die ganze Familie.", "bild_emoji": "🐱", "imdb_rating": 5.2}, + {"id": "cats-and-dogs-2", "titel": "Cats & Dogs 2 — Die Rache der Kitty Kahlohr", "originaltitel": "Cats & Dogs: The Revenge of Kitty Galore", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Hunde und Katzen müssen kooperieren, um eine wahnsinnige Superschurkin-Katze zu stoppen.", "bild_emoji": "😾", "imdb_rating": 4.1}, + {"id": "shaggy-dog-1959", "titel": "Der zottige Hund", "originaltitel": "The Shaggy Dog", "jahr": 1959, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Alter Ungarischer Hirtenhund (Sheepdogähnlich)", "stirbt_der_hund": False, "beschreibung": "Disney-Familienklassiker: Ein Teenager verwandelt sich durch einen magischen Ring immer wieder in einen Hund. Mit Fred MacMurray.", "bild_emoji": "✨", "imdb_rating": 6.3}, + {"id": "shaggy-dog-2006", "titel": "The Shaggy Dog (2006)", "originaltitel": "The Shaggy Dog", "jahr": 2006, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bobtail (Alter Englischer Schäferhund)", "stirbt_der_hund": False, "beschreibung": "Tim Allen als Staatsanwalt, der sich in einen Hund verwandelt — modernes Remake des Disney-Klassikers mit Slapstick-Humor.", "bild_emoji": "✨", "imdb_rating": 5.2, "streaming": "Disney+"}, + {"id": "marmaduke-2010", "titel": "Marmaduke", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutsche Dogge", "stirbt_der_hund": False, "beschreibung": "Die riesige Comic-Dogge Marmaduke zieht nach Kalifornien und mischt die dortige Hunde-Gesellschaft auf. Lockerleichte Familienkomödie.", "bild_emoji": "😬", "imdb_rating": 4.6}, + {"id": "show-dogs", "titel": "Show Dogs", "jahr": 2018, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Rottweiler", "stirbt_der_hund": False, "beschreibung": "Ein Polizei-Rottweiler muss undercover bei einer Hundeschau ermitteln. Kindliche Spionagekomödie mit Will Arnett.", "bild_emoji": "🏅", "imdb_rating": 4.3}, + {"id": "must-love-dogs", "titel": "Must Love Dogs", "jahr": 2005, "genre": "Romanze/Komödie", "typ": "film", "hund_rasse": "Neufundländer", "stirbt_der_hund": False, "beschreibung": "Romantische Komödie: Eine frisch Geschiedene sucht online die Liebe — und ein Hund spielt dabei die entscheidende Rolle. Mit Diane Lane.", "bild_emoji": "💕", "imdb_rating": 6.0}, + {"id": "best-in-show", "titel": "Best in Show", "jahr": 2000, "genre": "Komödie/Mockumentary", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Christopher Guest's genialer Mockumentary über völlig überdrehte Hundeshow-Besucher beim Mayflower Dog Show — vernichtende Satire auf Hundebesitzer.", "bild_emoji": "🎭", "imdb_rating": 7.8}, + {"id": "wiener-dog", "titel": "Wiener-Dog", "jahr": 2016, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Dackel", "stirbt_der_hund": True, "beschreibung": "Todd Solondz' episodischer Indie-Film: Ein kleiner Dackel wandert durch mehrere skurrile Menschenleben. Dunkel, philosophisch, preisgekrönt.", "bild_emoji": "🌭", "imdb_rating": 6.6}, + # ── Neue Einträge: Action/Thriller ─────────────────────────────── + {"id": "mans-best-friend-1993", "titel": "Man's Best Friend", "originaltitel": "Man's Best Friend", "jahr": 1993, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Mastiff", "stirbt_der_hund": True, "beschreibung": "Ein genetisch manipulierter Kettenhund bricht aus einem Labor aus und entpuppt sich als tödliche Bedrohung. B-Movie-Horrorklassiker.", "bild_emoji": "🧬", "imdb_rating": 5.1}, + {"id": "white-dog-1982", "titel": "White Dog", "originaltitel": "White Dog", "jahr": 1982, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Samuel Fullers politisch brisanter Film: Ein weißer Schäferhund wurde auf schwarze Menschen abgerichtet — und soll nun umtrainiert werden.", "bild_emoji": "⚪", "imdb_rating": 7.2}, + {"id": "hound-of-baskervilles", "titel": "Der Hund von Baskerville", "originaltitel": "The Hound of the Baskervilles", "jahr": 1939, "genre": "Krimi/Thriller", "typ": "film", "hund_rasse": "Fantastisches Wesen", "stirbt_der_hund": False, "beschreibung": "Basil Rathbone als Sherlock Holmes in der besten Verfilmung des Doyle-Klassikers — die unheilvolle Legende des Dartmoor-Hundes.", "bild_emoji": "🔦", "imdb_rating": 7.4}, + # ── Neue Einträge: Japan/International ─────────────────────────── + {"id": "mari-to-koinu", "titel": "Mari und ihr Hundewelpe", "originaltitel": "Mari to koinu no monogatari", "jahr": 2007, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Shiba Inu", "stirbt_der_hund": False, "beschreibung": "Wahre Geschichte: Eine Shiba-Inu-Mutter rettet nach einem Erdbeben in den japanischen Alpen ihr Herrchen und ihre Welpen.", "bild_emoji": "🏔️", "imdb_rating": 7.2}, + {"id": "ginga-nagareboshi-gin", "titel": "Ginga: Nagareboshi Gin", "originaltitel": "Ginga: Nagareboshi Gin", "jahr": 1986, "genre": "Anime/Abenteuer", "typ": "serie", "hund_rasse": "Akita Inu", "stirbt_der_hund": False, "beschreibung": "Legendärer japanischer Anime: Silber, ein junger Akita, kämpft gegen einen gigantischen Bären — packende Shonen-Klassikerserie der 80er.", "bild_emoji": "⭐", "imdb_rating": 8.0}, + # ── Neue Einträge: Serien ────────────────────────────────────────── + {"id": "dog-whisperer", "titel": "Der Hundeflüsterer", "originaltitel": "Dog Whisperer with Cesar Millan", "jahr": 2004, "genre": "Dokumentation/Reality", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Cesar Millan rehabilitiert Problemhunde und schult deren Besitzer. Legendäre Reality-Serie, die das Hundetraining nachhaltig beeinflusst hat.", "bild_emoji": "🤫", "imdb_rating": 7.8}, + {"id": "its-me-or-the-dog", "titel": "Ich oder der Hund", "originaltitel": "It's Me or the Dog", "jahr": 2005, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Victoria Stilwell bringt unerzogenen Hunden Manieren bei — mit Positiver Verstärkung statt Dominanztheorie. Britische Erfolgsrealityshow.", "bild_emoji": "💪", "imdb_rating": 7.2}, + {"id": "lucky-dog", "titel": "Lucky Dog", "originaltitel": "Lucky Dog", "jahr": 2013, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Trainer Brandon McMillan rettet Todestrakt-Hunde aus Tierheimen und trainiert sie innerhalb einer Woche als perfekte Familienhunde.", "bild_emoji": "🌟", "imdb_rating": 7.5}, + {"id": "dog-impossible", "titel": "Dog: Impossible", "originaltitel": "Dog: Impossible", "jahr": 2019, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matt Beisner arbeitet mit aggressiven und traumatisierten Hunden, die andere aufgegeben haben. National Geographic's bewegende Trainerserie.", "bild_emoji": "🙏", "imdb_rating": 8.1}, + {"id": "belle-sebastian-anime", "titel": "Belle und Sebastian (Anime)", "originaltitel": "Belle et Sébastien", "jahr": 1984, "genre": "Anime/Familie", "typ": "serie", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Japanische Zeichentrickserie der NHK: Sébastien und sein riesiger weißer Hund Belle in den Alpen — eine Lieblingsserie mehrerer Generationen.", "bild_emoji": "⛰️", "imdb_rating": 7.5}, + {"id": "the-dog-house", "titel": "The Dog House", "originaltitel": "The Dog House", "jahr": 2019, "genre": "Dokumentation", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Wood Green Animal Shelter: Zuschauer beobachten, wie Hunde und Menschen füreinander bestimmt werden. Britische Kultdoku.", "bild_emoji": "🏡", "imdb_rating": 8.2}, + {"id": "puppy-dog-pals", "titel": "Puppy Dog Pals", "originaltitel": "Puppy Dog Pals", "jahr": 2017, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Disney Junior-Serie: Zwei Möpse erleben täglich Abenteuer in der Nachbarschaft, während ihr Herrchen weg ist. Ideal für Kleinkinder.", "bild_emoji": "🐾", "imdb_rating": 7.4, "streaming": "Disney+"}, + {"id": "dogs-of-berlin", "titel": "Dogs of Berlin", "jahr": 2018, "genre": "Krimi/Drama", "typ": "serie", "hund_rasse": "Kampfhund", "stirbt_der_hund": False, "beschreibung": "Netflix Deutschland-Originalserie: Zwei Berliner Ermittler aus verschiedenen Welten jagen einen Mörder — düster, social, brutal ehrlich.", "bild_emoji": "🐕", "imdb_rating": 7.4, "streaming": "Netflix"}, + # ── Neue Einträge: Dokumentationen ──────────────────────────────── + {"id": "the-champions-2015", "titel": "The Champions", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Bewegende Doku über die Pitbulls von Michael Vick's Dogfighting-Ring — und ihre erstaunliche Rehabilitation durch engagierte Retter.", "bild_emoji": "💪", "imdb_rating": 7.8}, + {"id": "one-nation-under-dog", "titel": "One Nation Under Dog", "originaltitel": "One Nation Under Dog", "jahr": 2012, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "HBO-Dokumentation über die komplexe Beziehung der Amerikaner zu Hunden — von Tierheimen bis Luxus-Hundesalons.", "bild_emoji": "🇺🇸", "imdb_rating": 7.4}, + {"id": "dogs-on-the-inside", "titel": "Dogs on the Inside", "originaltitel": "Dogs on the Inside", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Gefangene in einem Hochsicherheitsgefängnis trainieren Tierheim-Hunde — eine Geschichte über Mitgefühl, Verantwortung und zweite Chancen.", "bild_emoji": "🔒", "imdb_rating": 7.6}, + {"id": "wonderdog-2023", "titel": "Wonderdog", "originaltitel": "Wonderdog", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Doku über die außergewöhnlichen Fähigkeiten von Hunden: Was können sie wirklich riechen, hören und fühlen? Wissenschaft trifft Herz.", "bild_emoji": "🔬", "imdb_rating": 7.1, "streaming": "Netflix"}, + {"id": "the-supervet", "titel": "The Supervet", "originaltitel": "The Supervet", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Noel Fitzpatrick, den visionären Veterinärchirurgen, der unheilbar verletzte Tiere mit Hightech-Prothesen rettet.", "bild_emoji": "🦾", "imdb_rating": 8.7}, + {"id": "off-the-chain", "titel": "Off the Chain", "originaltitel": "Off the Chain", "jahr": 2004, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über Pit Bulls in Amerika — zwischen Missbrauch, Dogfighting-Kultur und den Menschen, die für sie kämpfen.", "bild_emoji": "⛓️", "imdb_rating": 7.2}, + {"id": "war-dog-soldier", "titel": "War Dog: A Soldier's Best Friend", "originaltitel": "War Dog: A Soldier's Best Friend", "jahr": 2017, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "National Geographic-Doku über Militärhunde und die unzerstörbare Bindung zu ihren Hundeführern — von der Ausbildung bis zum Einsatz.", "bild_emoji": "🎖️", "imdb_rating": 7.8}, ] -PROMIS = [ - {"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"}, - {"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund", "bekannt_fuer": "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"}, - {"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", "emoji": "🚀"}, - {"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"}, - {"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (1800–1812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"}, - {"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": "⛪"}, +_SEED_PROMIS = [ + {"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"}, + {"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund","bekannt_fuer": "Filmhund der 1920er. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"}, + {"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Sowjetische Weltraumpionierin.", "emoji": "🚀"}, + {"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"}, + {"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (1800–1812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"}, + {"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": "⛪"}, + {"name": "Balto", "rasse": "Siberian Husky", "bekannt_fuer": "Führte 1925 den letzten Abschnitt des Serum-Runs nach Nome, Alaska. Statue im Central Park New York.", "emoji": "🛷"}, + {"name": "Togo", "rasse": "Siberian Husky", "bekannt_fuer": "Der echte Held des Serum-Runs 1925 — legte die schwierigste Strecke zurück, blieb aber lange unbekannt.", "emoji": "🏅"}, + {"name": "Asta", "rasse": "Drahthaariger Foxterrier","bekannt_fuer": "Filmhund in der 'Dünner Mann'-Reihe (1934–1947). Hollywood-Ikone der klassischen Ära.", "emoji": "🎩"}, + {"name": "Lassie", "rasse": "Rough Collie", "bekannt_fuer": "Meistverfilmter Hund der Geschichte. Erster Vierbeiner mit einem Stern auf dem Hollywood Walk of Fame.", "emoji": "⭐"}, ] +def seed_movies(): + """Füllt die movies-Tabelle mit allen Seed-Einträgen (idempotent per INSERT OR IGNORE).""" + import logging + logger = logging.getLogger(__name__) + with db() as conn: + for i, f in enumerate(_SEED_FILME): + conn.execute(""" + INSERT OR IGNORE INTO movies + (id, titel, originaltitel, jahr, genre, typ, hund_rasse, + stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, + streaming, sort_order) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + f["id"], f["titel"], f.get("originaltitel"), + f.get("jahr"), f.get("genre"), f.get("typ", "film"), + f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0, + f.get("beschreibung"), f.get("bild_emoji", "🐾"), + f.get("imdb_rating"), f.get("streaming"), i, + )) + logger.info(f"movies: seed_movies() ausgeführt, {len(_SEED_FILME)} Einträge in der Liste.") + + # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class FilmVoteRequest(BaseModel): bewertung: int # 1–5 - class HundDesMonatsVoteRequest(BaseModel): dog_id: int +class MovieCreate(BaseModel): + id: str + titel: str + originaltitel: Optional[str] = None + jahr: Optional[int] = None + genre: Optional[str] = None + typ: str = "film" + hund_rasse: Optional[str] = None + stirbt_der_hund: bool = False + beschreibung: Optional[str] = None + bild_emoji: str = "🐾" + imdb_rating: Optional[float] = None + streaming: Optional[str] = None + +class MovieUpdate(BaseModel): + titel: Optional[str] = None + originaltitel: Optional[str] = None + jahr: Optional[int] = None + genre: Optional[str] = None + typ: Optional[str] = None + hund_rasse: Optional[str] = None + stirbt_der_hund: Optional[bool] = None + beschreibung: Optional[str] = None + bild_emoji: Optional[str] = None + imdb_rating: Optional[float] = None + streaming: Optional[str] = None + # ------------------------------------------------------------------ -# GET /api/movies/filme — Film-Liste mit optionaler User-Bewertung +# GET /api/movies/filme # ------------------------------------------------------------------ +_SORT_COLS = { + "titel": "m.titel ASC", + "jahr_desc": "m.jahr DESC", + "jahr_asc": "m.jahr ASC", + "imdb": "m.imdb_rating DESC", + "bewertung": "community_avg DESC", + "default": "CASE WHEN m.imdb_rating IS NULL THEN 1 ELSE 0 END, m.imdb_rating DESC, m.jahr DESC", +} + @router.get("/filme") -async def get_filme(user=Depends(get_current_user_optional)): - user_ratings = {} - community_avgs = {} +async def get_filme( + sort: str = Query("default"), + typ: str = Query("alle"), # alle | film | serie | doku + user = Depends(get_current_user_optional), +): + order = _SORT_COLS.get(sort, _SORT_COLS["default"]) + + where = "" + params: list = [] + if typ != "alle": + where = "WHERE m.typ = ?" + params.append(typ) with db() as conn: - if user: - rows = conn.execute( - "SELECT film_id, bewertung FROM movie_votes WHERE user_id=?", - (user["id"],), - ).fetchall() - user_ratings = {r["film_id"]: r["bewertung"] for r in rows} - - avg_rows = conn.execute( - "SELECT film_id, AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes GROUP BY film_id" - ).fetchall() - community_avgs = {r["film_id"]: {"avg": round(r["avg_bew"], 1), "cnt": r["cnt"]} for r in avg_rows} + rows = conn.execute(f""" + SELECT m.*, + COALESCE(AVG(v.bewertung), 0) AS community_avg, + COUNT(v.id) AS bewertung_cnt, + uv.bewertung AS user_rating + FROM movies m + LEFT JOIN movie_votes v ON v.film_id = m.id + LEFT JOIN movie_votes uv ON uv.film_id = m.id + AND uv.user_id = ? + {where} + GROUP BY m.id + ORDER BY {order} + """, [user["id"] if user else None] + params).fetchall() result = [] - for film in FILME: - f = dict(film) - f["user_rating"] = user_ratings.get(film["id"]) - if film["id"] in community_avgs: - f["bewertung_avg"] = community_avgs[film["id"]]["avg"] - f["bewertung_cnt"] = community_avgs[film["id"]]["cnt"] - else: - f["bewertung_cnt"] = 0 - result.append(f) - + for r in rows: + d = dict(r) + d["stirbt_der_hund"] = bool(d["stirbt_der_hund"]) + d["bewertung_avg"] = round(d["community_avg"] or 0, 1) + result.append(d) return result # ------------------------------------------------------------------ -# POST /api/movies/filme/{film_id}/vote — Bewertung abgeben (Upsert) +# POST /api/movies/filme/{film_id}/vote # ------------------------------------------------------------------ @router.post("/filme/{film_id}/vote") async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_current_user)): - if not any(f["id"] == film_id for f in FILME): - raise HTTPException(404, "Film nicht gefunden.") if data.bewertung < 1 or data.bewertung > 5: raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.") - with db() as conn: - conn.execute( - """INSERT INTO movie_votes (user_id, film_id, bewertung) - VALUES (?, ?, ?) - ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung""", - (user["id"], film_id, data.bewertung), - ) + if not conn.execute("SELECT 1 FROM movies WHERE id=?", (film_id,)).fetchone(): + raise HTTPException(404, "Film nicht gefunden.") + conn.execute(""" + INSERT INTO movie_votes (user_id, film_id, bewertung) + VALUES (?, ?, ?) + ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung + """, (user["id"], film_id, data.bewertung)) row = conn.execute( "SELECT AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes WHERE film_id=?", (film_id,), ).fetchone() - return { - "film_id": film_id, + "film_id": film_id, "bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung, "bewertung_cnt": row["cnt"], - "user_rating": data.bewertung, + "user_rating": data.bewertung, } # ------------------------------------------------------------------ -# GET /api/movies/hund-des-monats — Top-Votes des aktuellen Monats +# Admin: CRUD für Filme +# ------------------------------------------------------------------ +@router.post("/filme", status_code=201) +async def create_film(data: MovieCreate, admin=Depends(require_admin)): + with db() as conn: + max_order = conn.execute("SELECT COALESCE(MAX(sort_order),0) FROM movies").fetchone()[0] + try: + conn.execute(""" + INSERT INTO movies (id, titel, originaltitel, jahr, genre, typ, hund_rasse, + stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, streaming, sort_order) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """, (data.id, data.titel, data.originaltitel, data.jahr, data.genre, data.typ, + data.hund_rasse, 1 if data.stirbt_der_hund else 0, data.beschreibung, + data.bild_emoji, data.imdb_rating, data.streaming, max_order + 1)) + except Exception: + raise HTTPException(400, "Film-ID bereits vorhanden.") + return {"ok": True} + +@router.patch("/filme/{film_id}") +async def update_film(film_id: str, data: MovieUpdate, admin=Depends(require_admin)): + updates = {k: v for k, v in data.model_dump(exclude_none=True).items()} + if "stirbt_der_hund" in updates: + updates["stirbt_der_hund"] = 1 if updates["stirbt_der_hund"] else 0 + if not updates: + return {"ok": True} + set_clause = ", ".join(f"{k}=?" for k in updates) + with db() as conn: + conn.execute(f"UPDATE movies SET {set_clause} WHERE id=?", (*updates.values(), film_id)) + return {"ok": True} + +@router.delete("/filme/{film_id}") +async def delete_film(film_id: str, admin=Depends(require_admin)): + with db() as conn: + conn.execute("DELETE FROM movies WHERE id=?", (film_id,)) + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/movies/promis — Berühmte Hunde (aus Seed-Daten) +# ------------------------------------------------------------------ +@router.get("/promis") +async def get_promis(): + return _SEED_PROMIS + + +# ------------------------------------------------------------------ +# Hund des Monats # ------------------------------------------------------------------ @router.get("/hund-des-monats") async def get_hund_des_monats(user=Depends(get_current_user_optional)): monat = datetime.now().strftime("%Y-%m") - with db() as conn: - rows = conn.execute( - """SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name, - COUNT(v.id) as stimmen - FROM hund_des_monats_votes v - JOIN dogs d ON d.id = v.dog_id - JOIN users u ON u.id = d.user_id - WHERE v.monat = ? - GROUP BY v.dog_id - ORDER BY stimmen DESC - LIMIT 10""", - (monat,), - ).fetchall() - + rows = conn.execute(""" + SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name, + COUNT(v.id) as stimmen + FROM hund_des_monats_votes v + JOIN dogs d ON d.id = v.dog_id + JOIN users u ON u.id = d.user_id + WHERE v.monat = ? + GROUP BY v.dog_id + ORDER BY stimmen DESC + LIMIT 10 + """, (monat,)).fetchall() user_vote = None if user: row = conn.execute( @@ -143,43 +383,55 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)): ).fetchone() if row: user_vote = row["dog_id"] - - return { - "monat": monat, - "top": [dict(r) for r in rows], - "user_vote": user_vote, - } + return {"monat": monat, "top": [dict(r) for r in rows], "user_vote": user_vote} + + +@router.get("/hund-des-monats/kandidaten") +async def get_hdm_kandidaten(user=Depends(get_current_user)): + """Alle öffentlichen Hunde anderer User, mit aktuellem Stimmenstand.""" + monat = datetime.now().strftime("%Y-%m") + with db() as conn: + rows = conn.execute(""" + SELECT d.id, d.name, d.rasse, d.foto_url, + u.name AS besitzer_name, + COALESCE(v.stimmen, 0) AS stimmen + FROM dogs d + JOIN users u ON u.id = d.user_id + LEFT JOIN ( + SELECT dog_id, COUNT(*) AS stimmen + FROM hund_des_monats_votes + WHERE monat = ? + GROUP BY dog_id + ) v ON v.dog_id = d.id + WHERE d.is_public = 1 + AND d.user_id != ? + ORDER BY + CASE WHEN d.foto_url IS NOT NULL THEN 0 ELSE 1 END, + stimmen DESC, + d.name ASC + LIMIT 60 + """, (monat, user["id"])).fetchall() + return [dict(r) for r in rows] -# ------------------------------------------------------------------ -# POST /api/movies/hund-des-monats/vote — Abstimmen (Auth required) -# ------------------------------------------------------------------ @router.post("/hund-des-monats/vote") async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)): monat = datetime.now().strftime("%Y-%m") - with db() as conn: - # Prüfen ob Hund existiert und entweder dem User gehört oder öffentlich ist - dog = conn.execute( - "SELECT id, user_id, is_public FROM dogs WHERE id=?", - (data.dog_id,), - ).fetchone() + dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone() if not dog: raise HTTPException(404, "Hund nicht gefunden.") - if dog["user_id"] != user["id"] and not dog["is_public"]: + if dog["user_id"] == user["id"]: + raise HTTPException(403, "Du kannst nicht für deinen eigenen Hund abstimmen.") + if not dog["is_public"]: raise HTTPException(403, "Dieser Hund ist nicht öffentlich.") - - conn.execute( - """INSERT INTO hund_des_monats_votes (user_id, dog_id, monat) - VALUES (?, ?, ?) - ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id""", - (user["id"], data.dog_id, monat), - ) - - # Aktuelle Stimmenanzahl für den gewählten Hund + conn.execute(""" + INSERT INTO hund_des_monats_votes (user_id, dog_id, monat) + VALUES (?, ?, ?) + ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id + """, (user["id"], data.dog_id, monat)) row = conn.execute( "SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?", (data.dog_id, monat), ).fetchone() - return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]} diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index 6ec066c..4fbd03c 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -6,7 +6,7 @@ import smtplib import ssl from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart -from email.utils import formataddr +from email.utils import formataddr, formatdate from datetime import datetime from typing import List, Optional @@ -84,22 +84,36 @@ def _imap_save_sent(msg_bytes: bytes, account: str): _log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e) -def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart: +def _build_message(to: str, subject: str, body: str, account: str, html: str = None) -> MIMEMultipart: acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] msg = MIMEMultipart("alternative") + msg["Date"] = formatdate(localtime=False) # UTC explizit, Container hat keine lokale TZ msg["Subject"] = subject msg["From"] = formataddr((acc["name"], acc["from"])) msg["To"] = to msg["Reply-To"] = acc["from"] msg.attach(MIMEText(body, "plain", "utf-8")) + if html: + msg.attach(MIMEText(html, "html", "utf-8")) return msg -def _send_smtp(to: str, subject: str, body: str, account: str = "partner"): +_LEGAL_FOOTER = ( + "\n\n---\n" + "Ban Yaro | René Degelmann | Ringstr. 26, D-85560 Ebersberg\n" + "Web: https://banyaro.app | Mail: partner@banyaro.app\n\n" + "Datenschutzhinweis: Deine Kontaktdaten stammen aus deinem öffentlichen Profil. " + "Verarbeitung auf Basis berechtigten Interesses (Art. 6 Abs. 1 lit. f DSGVO). " + "Datenschutzerklärung: https://banyaro.app/datenschutz\n" + "Widerspruch/Löschung: Einfach auf diese Mail antworten." +) + + +def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: str = None): acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] if not acc["user"] or not acc["pass"]: raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.") - msg = _build_message(to, subject, body, account) + msg = _build_message(to, subject, body + _LEGAL_FOOTER, account, html=html) msg_bytes = msg.as_bytes() ctx = ssl.create_default_context() with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: @@ -189,6 +203,16 @@ def delete_template(tpl_id: int, user=Depends(require_admin)): # Senden # ------------------------------------------------------------------ +def _plain_to_html_body(text: str) -> str: + import html as h + paragraphs = text.strip().split("\n\n") + parts = [] + for p in paragraphs: + escaped = h.escape(p).replace("\n", "
") + parts.append(f'

{escaped}

') + return "".join(parts) + + @router.post("/send") def send_mail(data: SendRequest, user=Depends(require_admin)): if not data.to: @@ -196,13 +220,19 @@ def send_mail(data: SendRequest, user=Depends(require_admin)): if not data.subject.strip() or not data.body.strip(): raise HTTPException(400, "Betreff und Text dürfen nicht leer sein.") + from mailer import email_html + html = email_html( + _plain_to_html_body(data.body), + footer_text=f"Ban Yaro · banyaro.app · {data.subject}", + ) + sent, failed = [], [] for addr in data.to: addr = addr.strip() if not addr: continue try: - _send_smtp(addr, data.subject, data.body, data.from_account) + _send_smtp(addr, data.subject, data.body, data.from_account, html=html) sent.append(addr) with db() as conn: conn.execute( @@ -224,7 +254,9 @@ def send_mail(data: SendRequest, user=Depends(require_admin)): def send_support_mail(to: str, subject: str, body: str): """Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik.""" - _send_smtp(to, subject, body, "support") + from mailer import email_html + html = email_html(_plain_to_html_body(body)) + _send_smtp(to, subject, body, "support", html=html) # ------------------------------------------------------------------ @@ -235,7 +267,7 @@ def send_support_mail(to: str, subject: str, body: str): def outreach_log_endpoint(user=Depends(require_admin)): with db() as conn: rows = conn.execute( - """SELECT ol.id, ol.recipient, ol.subject, ol.sent_at, + """SELECT ol.id, ol.recipient, ol.subject, ol.body, ol.sent_at, ol.from_account, u.name AS sent_by_name FROM outreach_log ol JOIN users u ON u.id = ol.sent_by diff --git a/backend/routes/passport.py b/backend/routes/passport.py new file mode 100644 index 0000000..884e8d3 --- /dev/null +++ b/backend/routes/passport.py @@ -0,0 +1,377 @@ +"""BAN YARO — Digitaler Hundepass""" + +import io +import secrets +from datetime import date, datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class PassportMeta(BaseModel): + blutgruppe: Optional[str] = None + allergien: Optional[str] = None + besonderheiten: Optional[str] = None + + +class VaccinationCreate(BaseModel): + krankheit: str + datum: str + naechste: Optional[str] = None + tierarzt: Optional[str] = None + charge_nr: Optional[str] = None + + +class MedicationCreate(BaseModel): + name: str + dosierung: Optional[str] = None + von: Optional[str] = None + bis: Optional[str] = None + notiz: Optional[str] = None + + +# ------------------------------------------------------------------ +# Hilfsfunktion: Eigentümer-Prüfung +# ------------------------------------------------------------------ +def _get_own_dog(conn, dog_id: int, user_id: int): + dog = conn.execute( + "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + return dog + + +def _load_passport_data(conn, dog_id: int) -> dict: + dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + meta = conn.execute( + "SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,) + ).fetchone() + vaccinations = conn.execute( + "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,) + ).fetchall() + medications = conn.execute( + "SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,) + ).fetchall() + + return { + "dog": dict(dog), + "meta": dict(meta) if meta else {}, + "vaccinations": [dict(v) for v in vaccinations], + "medications": [dict(m) for m in medications], + } + + +# ------------------------------------------------------------------ +# GET /passport/{dog_id} — vollständige Passdaten +# ------------------------------------------------------------------ +@router.get("/{dog_id}") +async def get_passport(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + return _load_passport_data(conn, dog_id) + + +# ------------------------------------------------------------------ +# PUT /passport/{dog_id}/meta +# ------------------------------------------------------------------ +@router.put("/{dog_id}/meta") +async def update_meta(dog_id: int, data: PassportMeta, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + conn.execute(""" + INSERT INTO dog_passport_meta (dog_id, blutgruppe, allergien, besonderheiten, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(dog_id) DO UPDATE SET + blutgruppe = excluded.blutgruppe, + allergien = excluded.allergien, + besonderheiten = excluded.besonderheiten, + updated_at = excluded.updated_at + """, (dog_id, data.blutgruppe, data.allergien, data.besonderheiten)) + return {"ok": True} + + +# ------------------------------------------------------------------ +# POST /passport/{dog_id}/vaccinations +# ------------------------------------------------------------------ +@router.post("/{dog_id}/vaccinations") +async def add_vaccination(dog_id: int, data: VaccinationCreate, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + conn.execute(""" + INSERT INTO vaccinations (dog_id, krankheit, datum, naechste, tierarzt, charge_nr) + VALUES (?, ?, ?, ?, ?, ?) + """, (dog_id, data.krankheit, data.datum, data.naechste, data.tierarzt, data.charge_nr)) + row = conn.execute( + "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /passport/{dog_id}/vaccinations/{vacc_id} +# ------------------------------------------------------------------ +@router.delete("/{dog_id}/vaccinations/{vacc_id}", status_code=204) +async def delete_vaccination(dog_id: int, vacc_id: int, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + conn.execute( + "DELETE FROM vaccinations WHERE id=? AND dog_id=?", (vacc_id, dog_id) + ) + + +# ------------------------------------------------------------------ +# POST /passport/{dog_id}/medications +# ------------------------------------------------------------------ +@router.post("/{dog_id}/medications") +async def add_medication(dog_id: int, data: MedicationCreate, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + conn.execute(""" + INSERT INTO medications (dog_id, name, dosierung, von, bis, notiz) + VALUES (?, ?, ?, ?, ?, ?) + """, (dog_id, data.name, data.dosierung, data.von, data.bis, data.notiz)) + row = conn.execute( + "SELECT * FROM medications WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /passport/{dog_id}/medications/{med_id} +# ------------------------------------------------------------------ +@router.delete("/{dog_id}/medications/{med_id}", status_code=204) +async def delete_medication(dog_id: int, med_id: int, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + conn.execute( + "DELETE FROM medications WHERE id=? AND dog_id=?", (med_id, dog_id) + ) + + +# ------------------------------------------------------------------ +# POST /passport/{dog_id}/share — Share-Token erstellen +# ------------------------------------------------------------------ +@router.post("/{dog_id}/share") +async def create_share(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + token = secrets.token_urlsafe(32) + valid_until = (date.today() + timedelta(days=30)).isoformat() + conn.execute(""" + INSERT INTO passport_shares (dog_id, token, valid_until) + VALUES (?, ?, ?) + """, (dog_id, token, valid_until)) + return { + "token": token, + "valid_until": valid_until, + "url": f"/pass/{token}", + } + + +# ------------------------------------------------------------------ +# GET /passport/share/{token} — öffentlicher Endpunkt (kein Auth) +# ------------------------------------------------------------------ +@router.get("/share/{token}") +async def get_shared_passport(token: str): + with db() as conn: + share = conn.execute( + "SELECT * FROM passport_shares WHERE token=?", (token,) + ).fetchone() + if not share: + raise HTTPException(404, "Link nicht gefunden.") + if share["valid_until"] < date.today().isoformat(): + raise HTTPException(410, "Dieser Link ist abgelaufen.") + return _load_passport_data(conn, share["dog_id"]) + + +# ------------------------------------------------------------------ +# GET /passport/{dog_id}/pdf — PDF generieren +# ------------------------------------------------------------------ +@router.get("/{dog_id}/pdf") +async def download_pdf(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + data = _load_passport_data(conn, dog_id) + + pdf_bytes = _generate_pdf(data) + dog_name = data["dog"]["name"].replace(" ", "_") + filename = f"Hundepass_{dog_name}.pdf" + + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# ------------------------------------------------------------------ +# PDF-Generierung mit fpdf2 +# ------------------------------------------------------------------ +def _generate_pdf(data: dict) -> bytes: + try: + from fpdf import FPDF + except ImportError: + raise HTTPException(500, "PDF-Bibliothek nicht verfügbar. Bitte fpdf2 installieren.") + + dog = data["dog"] + meta = data["meta"] + vaccs = data["vaccinations"] + meds = data["medications"] + + # Datumsformatierung DE + def _fmt_date(d): + if not d: + return "–" + try: + return datetime.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y") + except Exception: + return d + + # Geschlecht + geschlecht_map = {"m": "Rüde", "w": "Hündin"} + + pdf = FPDF() + pdf.set_auto_page_break(auto=True, margin=20) + pdf.add_page() + + # ---- Header ---- + pdf.set_fill_color(40, 167, 100) # Ban Yaro Grün + pdf.rect(0, 0, 210, 38, style="F") + + pdf.set_text_color(255, 255, 255) + pdf.set_font("Helvetica", style="B", size=20) + pdf.set_y(8) + pdf.cell(0, 10, "Ban Yaro", align="C", ln=True) + pdf.set_font("Helvetica", size=11) + pdf.cell(0, 8, f"Digitaler Hundepass — {dog['name']}", align="C", ln=True) + pdf.set_font("Helvetica", size=8) + pdf.cell(0, 6, f"Erstellt am {date.today().strftime('%d.%m.%Y')}", align="C", ln=True) + + pdf.set_text_color(30, 30, 30) + pdf.set_y(46) + + # ---- Hundedaten ---- + pdf.set_fill_color(245, 250, 247) + pdf.set_draw_color(200, 200, 200) + pdf.set_font("Helvetica", style="B", size=12) + pdf.set_fill_color(235, 247, 240) + pdf.cell(0, 8, " Hundeangaben", ln=True, fill=True, border="B") + pdf.ln(3) + + def _info_row(label, value): + pdf.set_font("Helvetica", style="B", size=9) + pdf.cell(45, 6, label + ":", ln=False) + pdf.set_font("Helvetica", size=9) + pdf.cell(0, 6, str(value) if value else "–", ln=True) + + _info_row("Name", dog["name"]) + _info_row("Rasse", dog.get("rasse") or "–") + _info_row("Geburtstag", _fmt_date(dog.get("geburtstag"))) + _info_row("Geschlecht", geschlecht_map.get(dog.get("geschlecht", ""), "–")) + _info_row("Chip-Nr.", dog.get("chip_nr") or "–") + if meta.get("blutgruppe"): + _info_row("Blutgruppe", meta["blutgruppe"]) + + pdf.ln(5) + + # ---- Allergien & Besonderheiten ---- + if meta.get("allergien") or meta.get("besonderheiten"): + pdf.set_font("Helvetica", style="B", size=12) + pdf.set_fill_color(235, 247, 240) + pdf.cell(0, 8, " Allergien & Besonderheiten", ln=True, fill=True, border="B") + pdf.ln(3) + if meta.get("allergien"): + pdf.set_font("Helvetica", style="B", size=9) + pdf.cell(45, 6, "Allergien:", ln=False) + pdf.set_font("Helvetica", size=9) + pdf.multi_cell(0, 6, meta["allergien"]) + if meta.get("besonderheiten"): + pdf.set_font("Helvetica", style="B", size=9) + pdf.cell(45, 6, "Besonderheiten:", ln=False) + pdf.set_font("Helvetica", size=9) + pdf.multi_cell(0, 6, meta["besonderheiten"]) + pdf.ln(5) + + # ---- Impfungen ---- + pdf.set_font("Helvetica", style="B", size=12) + pdf.set_fill_color(235, 247, 240) + pdf.cell(0, 8, " Impfungen", ln=True, fill=True, border="B") + pdf.ln(3) + + if vaccs: + # Tabellen-Header + pdf.set_fill_color(220, 240, 228) + pdf.set_font("Helvetica", style="B", size=8) + pdf.cell(50, 6, "Krankheit", border=1, fill=True) + pdf.cell(25, 6, "Datum", border=1, fill=True) + pdf.cell(25, 6, "Nächste fällig", border=1, fill=True) + pdf.cell(55, 6, "Tierarzt", border=1, fill=True) + pdf.cell(35, 6, "Charge-Nr.", border=1, fill=True, ln=True) + + pdf.set_font("Helvetica", size=8) + for i, v in enumerate(vaccs): + fill = (i % 2 == 0) + pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255) + pdf.cell(50, 6, (v["krankheit"] or "")[:28], border=1, fill=fill) + pdf.cell(25, 6, _fmt_date(v["datum"]), border=1, fill=fill) + pdf.cell(25, 6, _fmt_date(v["naechste"]), border=1, fill=fill) + pdf.cell(55, 6, (v["tierarzt"] or "–")[:32], border=1, fill=fill) + pdf.cell(35, 6, (v["charge_nr"] or "–")[:20], border=1, fill=fill, ln=True) + else: + pdf.set_font("Helvetica", style="I", size=9) + pdf.set_text_color(140, 140, 140) + pdf.cell(0, 6, "Keine Impfungen eingetragen.", ln=True) + pdf.set_text_color(30, 30, 30) + + pdf.ln(5) + + # ---- Medikamente ---- + pdf.set_font("Helvetica", style="B", size=12) + pdf.set_fill_color(235, 247, 240) + pdf.cell(0, 8, " Medikamente", ln=True, fill=True, border="B") + pdf.ln(3) + + if meds: + pdf.set_fill_color(220, 240, 228) + pdf.set_font("Helvetica", style="B", size=8) + pdf.cell(55, 6, "Medikament", border=1, fill=True) + pdf.cell(35, 6, "Dosierung", border=1, fill=True) + pdf.cell(25, 6, "Von", border=1, fill=True) + pdf.cell(25, 6, "Bis", border=1, fill=True) + pdf.cell(50, 6, "Notiz", border=1, fill=True, ln=True) + + pdf.set_font("Helvetica", size=8) + for i, m in enumerate(meds): + fill = (i % 2 == 0) + pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255) + pdf.cell(55, 6, (m["name"] or "")[:32], border=1, fill=fill) + pdf.cell(35, 6, (m["dosierung"] or "–")[:22], border=1, fill=fill) + pdf.cell(25, 6, _fmt_date(m["von"]), border=1, fill=fill) + bis = _fmt_date(m["bis"]) if m["bis"] else "dauerhaft" + pdf.cell(25, 6, bis, border=1, fill=fill) + pdf.cell(50, 6, (m["notiz"] or "–")[:30], border=1, fill=fill, ln=True) + else: + pdf.set_font("Helvetica", style="I", size=9) + pdf.set_text_color(140, 140, 140) + pdf.cell(0, 6, "Keine Medikamente eingetragen.", ln=True) + pdf.set_text_color(30, 30, 30) + + # ---- Footer ---- + pdf.set_y(-15) + pdf.set_font("Helvetica", style="I", size=8) + pdf.set_text_color(140, 140, 140) + pdf.cell(0, 5, "Erstellt mit Ban Yaro — banyaro.app", align="C", ln=True) + + return bytes(pdf.output()) diff --git a/backend/routes/playdate.py b/backend/routes/playdate.py new file mode 100644 index 0000000..01d57ae --- /dev/null +++ b/backend/routes/playdate.py @@ -0,0 +1,364 @@ +"""BAN YARO — Playdate-Matching""" + +import math +import logging +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------ +# Haversine +# ------------------------------------------------------------------ +def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + R = 6371.0 + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = (math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) + * math.sin(dlon / 2) ** 2) + return R * 2 * math.asin(math.sqrt(a)) + + +def _calc_alter(geburtstag: Optional[str]) -> Optional[str]: + """Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'.""" + if not geburtstag: + return None + try: + from datetime import date + geb = date.fromisoformat(geburtstag[:10]) + today = date.today() + monate = (today.year - geb.year) * 12 + (today.month - geb.month) + if today.day < geb.day: + monate -= 1 + if monate < 0: + return None + if monate < 24: + return f"{monate} {'Monat' if monate == 1 else 'Monate'}" + jahre = monate // 12 + return f"{jahre} {'Jahr' if jahre == 1 else 'Jahre'}" + except Exception: + return None + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class ListingUpsert(BaseModel): + dog_id: int + lat: float + lon: float + ort_name: Optional[str] = None + radius_km: int = 10 + beschreibung: Optional[str] = None + + +class RequestCreate(BaseModel): + to_dog_id: int + nachricht: Optional[str] = None + + +class RequestPatch(BaseModel): + status: str # accepted | declined + + +# ------------------------------------------------------------------ +# Helpers — Konversation für Playdate öffnen (ohne Freundschaftspflicht) +# ------------------------------------------------------------------ +def _ensure_conversation(conn, user_a: int, user_b: int) -> int: + a, b = (min(user_a, user_b), max(user_a, user_b)) + existing = conn.execute( + "SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?", + (a, b) + ).fetchone() + if existing: + return existing["id"] + cur = conn.execute( + "INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)", + (a, b) + ) + return cur.lastrowid + + +# ------------------------------------------------------------------ +# Routes +# ------------------------------------------------------------------ + +@router.get("/nearby") +async def nearby(lat: float, lon: float, radius: int = 10, + user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + rows = conn.execute(""" + SELECT pl.id AS listing_id, + pl.lat, pl.lon, pl.ort_name, pl.beschreibung, + d.id AS dog_id, d.name AS dog_name, d.rasse, + d.geburtstag, d.foto_url, d.geschlecht + FROM playdate_listings pl + JOIN dogs d ON d.id = pl.dog_id + WHERE pl.aktiv = 1 + AND pl.user_id != ? + """, (uid,)).fetchall() + + result = [] + for r in rows: + dist = _haversine(lat, lon, r["lat"], r["lon"]) + if dist <= radius: + result.append({ + "listing_id": r["listing_id"], + "dog_id": r["dog_id"], + "dog_name": r["dog_name"], + "rasse": r["rasse"], + "alter": _calc_alter(r["geburtstag"]), + "geschlecht": r["geschlecht"], + "foto_url": r["foto_url"], + "ort_name": r["ort_name"], + "beschreibung": r["beschreibung"], + "entfernung_km": round(dist, 1), + }) + + result.sort(key=lambda x: x["entfernung_km"]) + return result + + +@router.put("/listing", status_code=200) +async def upsert_listing(data: ListingUpsert, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + # Sicherstellen dass der Hund dem User gehört + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", + (data.dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + existing = conn.execute( + "SELECT id FROM playdate_listings WHERE dog_id=?", + (data.dog_id,) + ).fetchone() + + if existing: + conn.execute(""" + UPDATE playdate_listings + SET lat=?, lon=?, ort_name=?, radius_km=?, beschreibung=?, + aktiv=1, updated_at=datetime('now') + WHERE dog_id=? + """, (data.lat, data.lon, data.ort_name, data.radius_km, + data.beschreibung, data.dog_id)) + return {"ok": True, "id": existing["id"]} + else: + cur = conn.execute(""" + INSERT INTO playdate_listings + (dog_id, user_id, lat, lon, ort_name, radius_km, beschreibung) + VALUES (?,?,?,?,?,?,?) + """, (data.dog_id, uid, data.lat, data.lon, data.ort_name, + data.radius_km, data.beschreibung)) + return {"ok": True, "id": cur.lastrowid} + + +@router.delete("/listing/{dog_id}", status_code=200) +async def deactivate_listing(dog_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + row = conn.execute( + "SELECT id FROM playdate_listings WHERE dog_id=? AND user_id=?", + (dog_id, uid) + ).fetchone() + if not row: + raise HTTPException(404, "Inserat nicht gefunden.") + conn.execute( + "UPDATE playdate_listings SET aktiv=0, updated_at=datetime('now') WHERE dog_id=?", + (dog_id,) + ) + return {"ok": True} + + +@router.get("/my-listing/{dog_id}") +async def my_listing(dog_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + row = conn.execute( + """SELECT id, dog_id, lat, lon, ort_name, radius_km, beschreibung, aktiv + FROM playdate_listings WHERE dog_id=? AND user_id=?""", + (dog_id, uid) + ).fetchone() + if not row: + return None + return dict(row) + + +@router.post("/request", status_code=201) +async def create_request(data: RequestCreate, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + # Eigenen Hund ermitteln — nimm den ersten aktiven Hund des Users + own_dog = conn.execute( + "SELECT id FROM dogs WHERE user_id=? ORDER BY id LIMIT 1", + (uid,) + ).fetchone() + if not own_dog: + raise HTTPException(400, "Du hast noch keinen Hund eingetragen.") + + from_dog_id = own_dog["id"] + + # Zielhund + Besitzer prüfen + target = conn.execute( + "SELECT d.id, d.user_id FROM dogs d WHERE d.id=?", + (data.to_dog_id,) + ).fetchone() + if not target: + raise HTTPException(404, "Zielhund nicht gefunden.") + if target["user_id"] == uid: + raise HTTPException(400, "Du kannst nicht dir selbst eine Anfrage schicken.") + + to_user_id = target["user_id"] + + # Doppelte Anfrage verhindern + existing = conn.execute( + "SELECT id, status FROM playdate_requests WHERE from_dog_id=? AND to_dog_id=?", + (from_dog_id, data.to_dog_id) + ).fetchone() + if existing: + if existing["status"] == "pending": + raise HTTPException(409, "Du hast bereits eine offene Anfrage an diesen Hund.") + # Alte abgelehnte Anfrage: löschen und neu anlegen + conn.execute( + "DELETE FROM playdate_requests WHERE id=?", + (existing["id"],) + ) + + cur = conn.execute(""" + INSERT INTO playdate_requests + (from_dog_id, to_dog_id, from_user_id, to_user_id, nachricht) + VALUES (?,?,?,?,?) + """, (from_dog_id, data.to_dog_id, uid, to_user_id, data.nachricht)) + request_id = cur.lastrowid + + # Chat-Konversation anlegen (ohne Freundschaftspflicht) + conv_id = _ensure_conversation(conn, uid, to_user_id) + + # Erste Nachricht mit Kontext senden + intro = f"Hallo! Ich habe eine Playdate-Anfrage für unsere Hunde geschickt." + if data.nachricht: + intro += f" Meine Nachricht: {data.nachricht}" + conn.execute(""" + INSERT INTO direct_messages (conversation_id, sender_id, text) + VALUES (?,?,?) + """, (conv_id, uid, intro)) + conn.execute( + "UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?", + (conv_id,) + ) + + try: + from routes.push import send_push_to_user + send_push_to_user(to_user_id, { + "title": "Playdate-Anfrage", + "body": f"{user['name']} möchte ein Treffen vereinbaren!", + "type": "playdate_request", + "tag": f"playdate-{request_id}", + "data": {"page": "playdate"}, + }) + except Exception: + pass + + return {"ok": True, "request_id": request_id, "conversation_id": conv_id} + + +@router.get("/requests") +async def list_requests(user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + incoming = conn.execute(""" + SELECT pr.id, pr.status, pr.nachricht, pr.created_at, + pr.from_user_id, + uf.name AS from_user_name, + df.name AS from_dog_name, df.rasse AS from_dog_rasse, + df.foto_url AS from_dog_foto, + df.geburtstag AS from_dog_geburtstag, + dt.name AS to_dog_name + FROM playdate_requests pr + JOIN users uf ON uf.id = pr.from_user_id + JOIN dogs df ON df.id = pr.from_dog_id + JOIN dogs dt ON dt.id = pr.to_dog_id + WHERE pr.to_user_id = ? + ORDER BY pr.created_at DESC + """, (uid,)).fetchall() + + outgoing = conn.execute(""" + SELECT pr.id, pr.status, pr.nachricht, pr.created_at, + pr.to_user_id, + ut.name AS to_user_name, + dt.name AS to_dog_name, dt.rasse AS to_dog_rasse, + dt.foto_url AS to_dog_foto, + df.name AS from_dog_name + FROM playdate_requests pr + JOIN users ut ON ut.id = pr.to_user_id + JOIN dogs dt ON dt.id = pr.to_dog_id + JOIN dogs df ON df.id = pr.from_dog_id + WHERE pr.from_user_id = ? + ORDER BY pr.created_at DESC + """, (uid,)).fetchall() + + def _enrich(rows, direction): + result = [] + for r in rows: + d = dict(r) + d["direction"] = direction + if direction == "incoming": + d["alter"] = _calc_alter(d.get("from_dog_geburtstag")) + result.append(d) + return result + + return { + "incoming": _enrich(incoming, "incoming"), + "outgoing": _enrich(outgoing, "outgoing"), + } + + +@router.patch("/requests/{req_id}", status_code=200) +async def patch_request(req_id: int, data: RequestPatch, + user=Depends(get_current_user)): + uid = user["id"] + if data.status not in ("accepted", "declined"): + raise HTTPException(400, "Status muss 'accepted' oder 'declined' sein.") + + with db() as conn: + req = conn.execute( + "SELECT * FROM playdate_requests WHERE id=? AND to_user_id=?", + (req_id, uid) + ).fetchone() + if not req: + raise HTTPException(404, "Anfrage nicht gefunden.") + if req["status"] != "pending": + raise HTTPException(409, "Anfrage wurde bereits beantwortet.") + + conn.execute( + "UPDATE playdate_requests SET status=? WHERE id=?", + (data.status, req_id) + ) + + conv_id = None + if data.status == "accepted": + conv_id = _ensure_conversation(conn, uid, req["from_user_id"]) + + try: + from routes.push import send_push_to_user + verb = "angenommen" if data.status == "accepted" else "abgelehnt" + send_push_to_user(req["from_user_id"], { + "title": f"Playdate {verb}!", + "body": f"{user['name']} hat deine Anfrage {verb}.", + "type": "playdate_response", + "tag": f"playdate-{req_id}", + "data": {"page": "playdate"}, + }) + except Exception: + pass + + return {"ok": True, "conversation_id": conv_id} diff --git a/backend/routes/recalls.py b/backend/routes/recalls.py new file mode 100644 index 0000000..d0182a3 --- /dev/null +++ b/backend/routes/recalls.py @@ -0,0 +1,138 @@ +"""BAN YARO — Rückruf-Alarm (Tierfutter) +RASFF EU Rapid Alert System for Food and Feed +""" + +import logging +import httpx +from fastapi import APIRouter +from database import db + +router = APIRouter() +logger = logging.getLogger(__name__) + +RASFF_URL = "https://webgate.ec.europa.eu/rasff-window/backend/public/notification/list/with-filters" +RASFF_PARAMS = { + "filters": '{"subject.product_category":["pet food and animal feed"]}', + "pageNumber": 0, + "pageSize": 20, + "sortColumn": "notificationDate", + "sortDirection": "DESC", +} + + +# ------------------------------------------------------------------ +# GET /api/recalls — Letzte 50 Rückrufe +# ------------------------------------------------------------------ +@router.get("") +async def list_recalls(q: str = ""): + with db() as conn: + if q: + like = f"%{q}%" + rows = conn.execute(""" + SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at + FROM feed_recalls + WHERE titel LIKE ? OR produkt LIKE ? OR gefahr LIKE ? OR herkunft LIKE ? + ORDER BY datum DESC + LIMIT 50 + """, (like, like, like, like)).fetchall() + else: + rows = conn.execute(""" + SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at + FROM feed_recalls + ORDER BY datum DESC + LIMIT 50 + """).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# Interne Hilfsfunktion: RASFF API abfragen +# ------------------------------------------------------------------ +async def fetch_rasff_recalls() -> list[dict]: + """Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück.""" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(RASFF_URL, params=RASFF_PARAMS) + resp.raise_for_status() + data = resp.json() + except Exception as e: + logger.error(f"RASFF API-Fehler: {e}") + return [] + + entries = [] + try: + items = data.get("data", {}).get("list", []) + for item in items: + reference = item.get("reference", "") + if not reference: + continue + + # Datum + datum_raw = item.get("notificationDate", "") + datum = datum_raw[:10] if datum_raw else "" + + # Produkt + subject = item.get("subject") or {} + produkt = subject.get("product", "") or "" + + # Gefahr + hazards = subject.get("hazard") or [] + gefahr = "" + if hazards: + gefahr = hazards[0].get("hazardDescription", "") or "" + + # Herkunft + origin = item.get("origin") or {} + herkunft = origin.get("name", "") or "" + + # URL zur RASFF-Seite + url = f"https://webgate.ec.europa.eu/rasff-window/screen/notificationDetail?notifRef={reference}" + + entries.append({ + "external_id": reference, + "titel": produkt or reference, + "produkt": produkt, + "gefahr": gefahr, + "herkunft": herkunft, + "datum": datum, + "quelle": "rasff", + "url": url, + }) + except Exception as e: + logger.error(f"RASFF Parsing-Fehler: {e}") + + return entries + + +# ------------------------------------------------------------------ +# Interne Hilfsfunktion: Neue Einträge in DB speichern +# ------------------------------------------------------------------ +def save_new_recalls(entries: list[dict]) -> list[dict]: + """Speichert neue Einträge und gibt die Liste der neuen Einträge zurück.""" + new_entries = [] + for entry in entries: + try: + with db() as conn: + exists = conn.execute( + "SELECT id FROM feed_recalls WHERE external_id=?", + (entry["external_id"],) + ).fetchone() + if not exists: + conn.execute(""" + INSERT INTO feed_recalls + (external_id, titel, produkt, gefahr, herkunft, datum, quelle, url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + entry["external_id"], + entry["titel"], + entry["produkt"], + entry["gefahr"], + entry["herkunft"], + entry["datum"], + entry["quelle"], + entry["url"], + )) + new_entries.append(entry) + except Exception as e: + logger.warning(f"Recall DB-Fehler für {entry.get('external_id')}: {e}") + return new_entries diff --git a/backend/routes/streak.py b/backend/routes/streak.py new file mode 100644 index 0000000..c387a68 --- /dev/null +++ b/backend/routes/streak.py @@ -0,0 +1,114 @@ +"""BAN YARO — Trainings-Streak""" + +import datetime +from fastapi import APIRouter, Depends, HTTPException +from database import db +from auth import get_current_user + +router = APIRouter() + +_today = lambda: datetime.date.today().isoformat() +_yesterday = lambda: (datetime.date.today() - datetime.timedelta(days=1)).isoformat() + + +# ------------------------------------------------------------------ +# GET /streak/leaderboard — Top-10 Streaks (öffentliche Hunde) +# Muss VOR /{dog_id} stehen, sonst greift der int-Parameter zuerst. +# ------------------------------------------------------------------ +@router.get("/streak/leaderboard") +async def get_leaderboard(user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute(""" + SELECT + u.name AS user_name, + d.name AS dog_name, + d.rasse, + d.foto_url, + ts.current_streak + FROM training_streaks ts + JOIN dogs d ON d.id = ts.dog_id + JOIN users u ON u.id = ts.user_id + WHERE ts.current_streak > 0 + AND d.is_public = 1 + ORDER BY ts.current_streak DESC + LIMIT 10 + """).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# GET /streak/{dog_id} — aktueller Streak eines Hundes +# ------------------------------------------------------------------ +@router.get("/streak/{dog_id}") +async def get_streak(dog_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + row = conn.execute( + "SELECT current_streak, longest_streak, last_training_date " + "FROM training_streaks WHERE user_id=? AND dog_id=?", + (uid, dog_id) + ).fetchone() + + if not row: + return {"current_streak": 0, "longest_streak": 0, "last_training_date": None} + return dict(row) + + +# ------------------------------------------------------------------ +# POST /streak/{dog_id}/ping — Training heute registrieren +# ------------------------------------------------------------------ +@router.post("/streak/{dog_id}/ping") +async def ping_streak(dog_id: int, user=Depends(get_current_user)): + uid = user["id"] + today = _today() + yest = _yesterday() + + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + row = conn.execute( + "SELECT current_streak, longest_streak, last_training_date " + "FROM training_streaks WHERE user_id=? AND dog_id=?", + (uid, dog_id) + ).fetchone() + + if row: + cur = row["current_streak"] + longest = row["longest_streak"] + last = row["last_training_date"] + + if last == today: + # Bereits heute gepingt — nichts tun + return {"current_streak": cur, "longest_streak": longest, "last_training_date": last} + elif last == yest: + cur += 1 + else: + cur = 1 + + longest = max(longest, cur) + + conn.execute( + "UPDATE training_streaks SET current_streak=?, longest_streak=?, last_training_date=? " + "WHERE user_id=? AND dog_id=?", + (cur, longest, today, uid, dog_id) + ) + else: + cur = 1 + longest = 1 + conn.execute( + "INSERT INTO training_streaks (user_id, dog_id, current_streak, longest_streak, last_training_date) " + "VALUES (?,?,?,?,?)", + (uid, dog_id, cur, longest, today) + ) + + return {"current_streak": cur, "longest_streak": longest, "last_training_date": today} diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py index 55107ec..48287f9 100644 --- a/backend/routes/tieraerzte.py +++ b/backend/routes/tieraerzte.py @@ -63,15 +63,68 @@ def _fmt_opening_hours(raw: str | None) -> str | None: return result +@router.get("/my-favorite") +async def get_my_favorite(user=Depends(get_current_user)): + """Favoriten-Tierarzt des Users (oder null).""" + with db() as conn: + row = conn.execute( + """SELECT t.* FROM tieraerzte t + JOIN favorite_vets fv ON fv.vet_id = t.id + WHERE fv.user_id = ? + LIMIT 1""", + (user["id"],) + ).fetchone() + if not row: + return None + return dict(row) + + +@router.post("/{vet_id}/favorite") +async def toggle_favorite(vet_id: int, user=Depends(get_current_user)): + """Tierarzt als Favorit setzen oder entfernen (toggle). Gibt {is_favorite: bool} zurück.""" + with db() as conn: + vet = conn.execute( + "SELECT id FROM tieraerzte WHERE id=?", (vet_id,) + ).fetchone() + if not vet: + raise HTTPException(404, "Tierarzt nicht gefunden.") + + existing = conn.execute( + "SELECT 1 FROM favorite_vets WHERE user_id=? AND vet_id=?", + (user["id"], vet_id) + ).fetchone() + + if existing: + conn.execute( + "DELETE FROM favorite_vets WHERE user_id=? AND vet_id=?", + (user["id"], vet_id) + ) + return {"is_favorite": False} + else: + conn.execute( + "INSERT INTO favorite_vets (user_id, vet_id) VALUES (?, ?)", + (user["id"], vet_id) + ) + return {"is_favorite": True} + + @router.get("") async def list_tieraerzte(user=Depends(get_current_user)): - """Alle Tierärzte des Users — aktive zuerst, dann inaktive.""" + """Alle Tierärzte des Users — aktive zuerst, dann inaktive. Enthält is_favorite.""" with db() as conn: rows = conn.execute( "SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name", (user["id"],) ).fetchall() - return [dict(r) for r in rows] + favs = {r["vet_id"] for r in conn.execute( + "SELECT vet_id FROM favorite_vets WHERE user_id=?", (user["id"],) + ).fetchall()} + result = [] + for r in rows: + d = dict(r) + d["is_favorite"] = r["id"] in favs + result.append(d) + return result @router.get("/osm-nearby") diff --git a/backend/routes/weather.py b/backend/routes/weather.py index 319cfd2..fced719 100644 --- a/backend/routes/weather.py +++ b/backend/routes/weather.py @@ -3,8 +3,9 @@ BAN YARO — Wetter-API GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort """ -from fastapi import APIRouter, Query, HTTPException +from fastapi import APIRouter, Query, HTTPException, Depends import weather as weather_module +from auth import get_current_user router = APIRouter() @@ -18,3 +19,15 @@ async def get_weather( return await weather_module.get_weather_for_location(lat, lon) except Exception as exc: raise HTTPException(503, f'Wetter nicht verfügbar: {exc}') + + +@router.get('/forecast') +async def get_weather_forecast( + lat: float = Query(..., ge=-90, le=90), + lon: float = Query(..., ge=-180, le=180), + user=Depends(get_current_user), +): + try: + return await weather_module.get_forecast(lat, lon) + except Exception as exc: + raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}') diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index 83093d7..45f5bfb 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -317,19 +317,24 @@ async def submit_foto( if not rights_confirmed: raise HTTPException(400, "Bildrechte-Bestätigung fehlt.") - # Dateiformat prüfen - ct = file.content_type or "" - if not ct.startswith("image/"): - raise HTTPException(400, "Nur Bilddateien erlaubt.") + _IMAGE_MAGIC = [ + b"\xff\xd8\xff", # JPEG + b"\x89PNG\r\n\x1a\n", # PNG + b"RIFF", # WebP (RIFF....WEBP) + b"GIF87a", b"GIF89a", # GIF + ] os.makedirs(SUBMIT_DIR, exist_ok=True) - ts = int(time.time()) - filename = f"{slug}_{user['id']}_{ts}.jpg" - path = os.path.join(SUBMIT_DIR, filename) - + ts = int(time.time()) content = await file.read() if len(content) > 8 * 1024 * 1024: raise HTTPException(400, "Datei zu groß (max. 8 MB).") + + if not any(content.startswith(magic) for magic in _IMAGE_MAGIC): + raise HTTPException(400, "Nur Bilddateien erlaubt (JPEG, PNG, WebP, GIF).") + + filename = f"{slug}_{user['id']}_{ts}.jpg" + path = os.path.join(SUBMIT_DIR, filename) with open(path, "wb") as f: f.write(content) @@ -694,11 +699,12 @@ async def list_zuchter_pending(user=Depends(get_current_user)): raise HTTPException(403, "Nur Moderatoren.") with db() as conn: rows = conn.execute( - """SELECT z.*, u.name AS user_name + """SELECT z.*, u.name AS user_name, m.name AS verified_by_name FROM wiki_zuchter z LEFT JOIN users u ON u.id = z.user_id - WHERE z.verified=0 - ORDER BY z.created_at ASC""", + LEFT JOIN users m ON m.id = z.verified_by + ORDER BY z.verified ASC, z.created_at ASC + LIMIT 200""", ).fetchall() return [dict(r) for r in rows] @@ -716,8 +722,10 @@ async def verify_zuchter(zuchter_id: int, user=Depends(get_current_user)): ).fetchone() if not row: raise HTTPException(404, "Züchter nicht gefunden.") + from datetime import datetime conn.execute( - "UPDATE wiki_zuchter SET verified=1 WHERE id=?", (zuchter_id,) + "UPDATE wiki_zuchter SET verified=1, verified_by=?, verified_at=? WHERE id=?", + (user["id"], datetime.utcnow().isoformat(), zuchter_id) ) result = conn.execute( "SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,) diff --git a/backend/scheduler.py b/backend/scheduler.py index c99600e..4aeb89a 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -100,6 +100,22 @@ def start(): replace_existing=True, misfire_grace_time=1800, ) + # Täglich 12:00 — Moderation-Overdue-Check + _scheduler.add_job( + _job_moderation_overdue, + CronTrigger(hour=12, minute=0), + id="moderation_overdue", + replace_existing=True, + misfire_grace_time=1800, + ) + # 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht + _scheduler.add_job( + _job_quarterly_report, + CronTrigger(month="2,5,8,11", day=1, hour=7, minute=0), + id="quarterly_report", + replace_existing=True, + misfire_grace_time=7200, + ) # Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen) _scheduler.add_job( _job_ki_health_report, @@ -108,8 +124,40 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # Täglich 06:30 — Wiederkehrende Ausgaben anlegen + _scheduler.add_job( + _job_recurring_expenses, + CronTrigger(hour=6, minute=30), + id="recurring_expenses", + replace_existing=True, + misfire_grace_time=3600, + ) + # 1. des Monats 00:05 — Hund des Monats Sieger festlegen + _scheduler.add_job( + _job_hdm_winner, + CronTrigger(day=1, hour=0, minute=5), + id="hdm_winner", + replace_existing=True, + misfire_grace_time=3600, + ) + # Täglich 19:00 Uhr — Streak-Erinnerung + _scheduler.add_job( + _job_streak_reminder, + CronTrigger(hour=19, minute=0), + id="streak_reminder", + replace_existing=True, + misfire_grace_time=3600, + ) + # Täglich 08:00 Uhr — Tierfutter-Rückrufe prüfen (RASFF) + _scheduler.add_job( + _job_recall_check, + CronTrigger(hour=8, minute=0), + id="recall_check", + replace_existing=True, + misfire_grace_time=3600, + ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -642,6 +690,115 @@ async def _job_ki_health_report(): # ------------------------------------------------------------------ +async def _job_moderation_overdue(): + """Sendet Alarm-Mail wenn Moderations-Einträge seit >24h offen sind.""" + import os + from mailer import send_email + + admin = os.getenv("ADMIN_EMAIL", "") + if not admin: + return + + SLA_H = 24 + threshold = f"datetime('now', '-{SLA_H} hours')" + + overdue = {} + try: + with db() as conn: + n = conn.execute(f"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing') AND created_at < {threshold}").fetchone()[0] + if n: overdue["Bewerbungen"] = n + n = conn.execute(f"SELECT COUNT(*) FROM users WHERE breeder_status='pending' AND created_at < {threshold}").fetchone()[0] + if n: overdue["Züchter-Anträge"] = n + n = conn.execute(f"SELECT COUNT(*) FROM forum_reports WHERE resolved=0 AND created_at < {threshold}").fetchone()[0] + if n: overdue["Forum-Meldungen"] = n + n = conn.execute(f"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending' AND created_at < {threshold}").fetchone()[0] + if n: overdue["Foto-Einreichungen"] = n + n = conn.execute(f"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending' AND created_at < {threshold}").fetchone()[0] + if n: overdue["POI-Korrekturen"] = n + n = conn.execute(f"SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0 AND created_at < {threshold}").fetchone()[0] + if n: overdue["Züchter-Einreichungen (Wiki)"] = n + except Exception as e: + logger.error(f"Moderation-Overdue-Check: DB-Fehler: {e}") + return + + if not overdue: + logger.info("Moderation-Overdue-Check: Alles im SLA.") + return + + now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M") + rows_html = "".join( + f'{label}' + f'{count}' + for label, count in overdue.items() + ) + html = f"""\ + + +
+
+
⚠️ Moderation überfällig
+
{now_str} · SLA: {SLA_H}h
+
+
+

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

+ + + + + + {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(): @@ -669,6 +826,7 @@ async def _job_status_report(): # Community metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + metrics["users_today"] = conn.execute("SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')").fetchone()[0] metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0] metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0] metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0] @@ -677,6 +835,28 @@ async def _job_status_report(): except Exception: metrics["lost_active"] = 0 + # Action Items + try: + metrics["jobs_pending"] = conn.execute("SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')").fetchone()[0] + except Exception: + metrics["jobs_pending"] = 0 + try: + metrics["breeder_pending"] = conn.execute("SELECT COUNT(*) FROM users WHERE breeder_status='pending'").fetchone()[0] + except Exception: + metrics["breeder_pending"] = 0 + try: + metrics["reports_open"] = conn.execute("SELECT COUNT(*) FROM forum_reports WHERE resolved=0").fetchone()[0] + except Exception: + metrics["reports_open"] = 0 + try: + metrics["fotos_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'").fetchone()[0] + except Exception: + metrics["fotos_pending"] = 0 + try: + metrics["poi_edits_pending"] = conn.execute("SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'").fetchone()[0] + except Exception: + metrics["poi_edits_pending"] = 0 + # Wiki-Interesse try: metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0] @@ -698,6 +878,9 @@ async def _job_status_report(): "seed_wikidata": "Rassen-Seed (Wikidata, monatlich)", "weekly_praise": "Wöchentlicher Lober (Mo 09:00)", "ki_health_report": "KI-Gesundheitsberichte", + "quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)", + "streak_reminder": "Streak-Erinnerung (täglich 19:00)", + "recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)", } job_rows_html = "" job_rows_txt = "" @@ -727,6 +910,9 @@ async def _job_status_report():
{now_str} Uhr
+ + {_action_items_html(metrics)} +
Scheduler-Jobs
@@ -740,14 +926,14 @@ async def _job_status_report():
Community
{"".join(f'
{v}
{k}
' for k,v in [ - ("Nutzer",metrics["users"]), + ("Nutzer gesamt",metrics["users"]), + ("Neue Nutzer heute",metrics["users_today"]), ("Hunde",metrics["dogs"]), ("Tagebuch-Einträge",metrics["diary_entries"]), ("Aktive Giftköder",metrics["poison_active"]), ("Vermisste Hunde",metrics["lost_active"]), ("'So einen hab ich'",metrics["interesse_hat"]), ("'Interessiert mich'",metrics["interesse_will"]), - ("Züchter (pending)",metrics["zuchter_pending"]), ])}
@@ -761,19 +947,28 @@ async def _job_status_report(): """ + 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: {metrics['users']} +Nutzer gesamt: {metrics['users']} (+{metrics['users_today']} heute) 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: @@ -783,6 +978,133 @@ Züchter (pending): {metrics['zuchter_pending']} 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, @@ -822,3 +1144,147 @@ 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 new file mode 100644 index 0000000..6484c70 --- /dev/null +++ b/backend/scripts/generate_reports.py @@ -0,0 +1,725 @@ +#!/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 073821a..27cf0d9 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-secondary); + color: var(--c-text); } /* 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: calc(var(--header-height) + var(--safe-top)); + top: var(--safe-top); left: 0; right: 0; - bottom: calc(var(--nav-bottom-height) + var(--safe-bottom)); + bottom: 0; overflow: hidden; z-index: 1; } @@ -3148,11 +3148,11 @@ html.modal-open { color: #fff; } -/* FAB-Gruppe rechts unten */ +/* FAB-Gruppe rechts unten — direkt über dem Zurück-Button */ .map-fabs { position: absolute; - bottom: var(--space-4); - right: var(--space-3); + bottom: calc(var(--safe-bottom) + 82px); /* 54px back + 20px bottom + 8px gap */ + right: 20px; z-index: 1000; display: flex; flex-direction: column; @@ -3160,8 +3160,8 @@ html.modal-open { align-items: center; } .map-fab { - width: 44px; - height: 44px; + width: 54px; + height: 54px; border-radius: 50%; background: #C4843A; color: #fff; @@ -3975,11 +3975,10 @@ html.modal-open { .rk-map-loc-input:focus { outline: none; border-color: var(--c-primary); } .rk-map-section { position: fixed; - /* Unter dem App-Header, über der Bottom-Nav */ - top: calc(var(--header-height) + var(--safe-top)); + top: var(--safe-top); left: 0; right: 0; - bottom: calc(var(--nav-bottom-height) + var(--safe-bottom)); + bottom: 0; z-index: 200; display: flex; flex-direction: column; @@ -4179,6 +4178,19 @@ 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 { @@ -4200,6 +4212,59 @@ 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; } @@ -4918,6 +4983,25 @@ 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); @@ -5156,11 +5240,19 @@ 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 { @@ -5972,6 +6064,21 @@ 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 { @@ -6695,3 +6802,1092 @@ 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 5b6f1e0..cc9c40f 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: 64px; + --nav-bottom-height: 78px; /* Welten-Zurück-FAB: 54px + 20px bottom + 4px Abstand */ --nav-sidebar-width: 240px; - --header-height: 56px; + --header-height: 0px; /* Header entfernt — Welten-Navigation übernimmt */ /* 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 399a9c7..1a0a778 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -19,6 +19,7 @@ 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 new file mode 100644 index 0000000..5afbfe9 Binary files /dev/null and b/backend/static/icons/founder.jpg differ diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 3fcf69f..2b8028e 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -190,4 +190,1441 @@ - \ 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 21afd73..cb75a8f 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -8,6 +8,11 @@ + + + + + @@ -88,9 +93,9 @@ - - - + + + @@ -158,6 +163,9 @@ +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + @@ -480,6 +527,34 @@ + +
    +
    + + + +
    +
    + JETZT + HUND + WELT +
    + +
    +
    +
    +
    +
    + +
    +
    + +
    + @@ -490,6 +565,7 @@ + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 4abebef..c6b26da 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -6,69 +6,84 @@ const API = (() => { + // ---------------------------------------------------------- + // Request-Deduplication: gleiche GET-URL nur einmal in-flight + // ---------------------------------------------------------- + const _inflight = new Map(); + // ---------------------------------------------------------- // Interner HTTP-Kern // ---------------------------------------------------------- - async function _request(method, path, body = null, options = {}) { + async function _doRequest(method, path, body, attempt) { const config = { method, headers: { 'Content-Type': 'application/json' }, - credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet + credentials: 'include', }; if (body && !(body instanceof FormData)) { config.body = JSON.stringify(body); } else if (body instanceof FormData) { - delete config.headers['Content-Type']; // Browser setzt multipart/form-data + delete config.headers['Content-Type']; 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 (err) { - const offlineMsg = 'Kein Internet — du bist offline.'; - if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000); - throw new APIError(offlineMsg, 0, 'network'); + } 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'); } - // 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}`; - // 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); + 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); } - throw new APIError(message, response.status, isOffline ? 'network' : data?.code); + + if (isSwOffline && window.UI?.toast) UI.toast.warning('Kein Internet — du bist offline.', 4000); + throw new APIError(message, response.status, isSwOffline ? '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 // ---------------------------------------------------------- @@ -195,6 +210,17 @@ 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}`); }, }; // ---------------------------------------------------------- @@ -415,8 +441,9 @@ 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}`); }, + 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}`); }, }; // ---------------------------------------------------------- @@ -712,7 +739,7 @@ const API = (() => { // Öffentliche API return { get, post, put, patch, del, upload, - auth, dogs, diary, health, tieraerzte, poison, + auth, dogs, diary, health, tieraerzte, healthDocs, 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 f1d577b..a248787 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 = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt +const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; const App = (() => { @@ -70,6 +70,12 @@ 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 }, }; // ---------------------------------------------------------- @@ -85,6 +91,7 @@ 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 }, }; // ---------------------------------------------------------- @@ -92,6 +99,7 @@ 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'); @@ -564,7 +572,7 @@ const App = (() => { banner.style.display = 'flex'; document.getElementById('verify-resend-btn')?.addEventListener('click', async () => { - await API.post('/auth/resend-verification', {}); + await API.post('/auth/resend-verification', { email: state.user.email }); UI.toast.success('Bestätigungs-Mail erneut gesendet.'); }, { once: true }); @@ -846,6 +854,9 @@ 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) { @@ -919,6 +930,8 @@ 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 cd34154..4e89f32 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -14,11 +14,12 @@ 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 & Meldungen', icon: 'chat-circle-dots' }, + { id: 'forum', label: 'Forum', 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: 'Jobs', icon: 'clock' }, + { id: 'jobs', label: 'Scheduler', icon: 'clock' }, + { id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' }, { id: 'partner', label: 'Partner', icon: 'handshake' }, { id: 'outreach', label: 'Outreach', icon: 'envelope-simple' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, @@ -47,6 +48,9 @@ window.Page_admin = (() => { // ------------------------------------------------------------------ function _render() { _container.innerHTML = ` + +
    +
    ${TABS.map(t => ` @@ -72,9 +76,68 @@ 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; @@ -93,6 +156,7 @@ 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.'); @@ -1396,6 +1460,43 @@ 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 = `
    @@ -1410,12 +1511,52 @@ window.Page_admin = (() => { async function _loadModeration(el) { el.innerHTML = `
    Lade…
    `; - const [zuchter, fotos] = await Promise.all([ + const [zuchter, fotos, reports, poiEdits] = 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'); - let html = ''; + 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('') + } +
    `; // --- Züchter-Einreichungen --- html += `

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

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

    Keine ausstehenden Einreichungen.

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

    Keine ausstehenden Einreichungen.

    `; } else { - html += `
    + html += `
    - + - ${zuchter.map((z, i) => ` + ${zuchterPending.map((z, i) => ` + `).join('')}
    RasseName / ZwingernameOrtVDHWebsiteOrtVDHAlterWebsite
    ${_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` : '—'} @@ -1448,6 +1590,10 @@ 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 ${fotos.length} + padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotosPending.length}

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

    Keine ausstehenden Foto-Einreichungen.

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

    Keine ausstehenden Foto-Einreichungen.

    `; } else { - html += `
    - ${fotos.map(f => ` + html += `
    + ${fotosPending.map(f => `
    ${_esc(f.rasse_name)}
    -
    von ${_esc(f.user_name)}
    +
    von ${_esc(f.user_name)}
    +
    ${_ageLabel(f.created_at)}
    ${f.aktuell_foto ? `Aktuell @@ -1480,6 +1627,111 @@ 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 @@ -1518,6 +1770,41 @@ 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; } + }); + }); } // ------------------------------------------------------------------ @@ -2132,8 +2419,10 @@ window.Page_admin = (() => { - ${log.map(l => ` - + ${log.map((l, i) => ` + ${accountBadge(l.from_account)} ${_esc(l.recipient)} ${_esc(l.subject)} @@ -2147,6 +2436,28 @@ 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); @@ -2375,6 +2686,129 @@ 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 new file mode 100644 index 0000000..b20682e --- /dev/null +++ b/backend/static/js/pages/adoption.js @@ -0,0 +1,958 @@ +/* ============================================================ + 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 953232e..82a2e8a 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -187,16 +187,14 @@ window.Page_dog_profile = (() => { ${!dog.is_guest ? `` : ''} -
    - - ${!dog.is_guest ? `` : ''} -
    + ${!dog.is_guest ? `` : ''} ${!dog.is_guest ? `` : ''} @@ -209,7 +207,8 @@ window.Page_dog_profile = (() => {
    Sitter-Zugang
    - Gib einem Freund temporären Schreibzugang für diesen Hund + 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.
    Lade…
    @@ -257,14 +256,14 @@ window.Page_dog_profile = (() => { _showChipEdit(dog); }); - document.getElementById('dp-ausweis-btn')?.addEventListener('click', () => { - _showAusweisModal(dog.id); - }); - document.getElementById('dp-share-btn')?.addEventListener('click', () => { _showShareModal(dog); }); + document.getElementById('dp-passport-btn')?.addEventListener('click', () => { + _showPassportModal(dog); + }); + // Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig. } @@ -745,13 +744,7 @@ window.Page_dog_profile = (() => { // AUSWEIS // ---------------------------------------------------------- function _showAusweisModal(dogId) { - UI.modal.open({ - title: 'Heimtierausweis', - body: ``, - footer: ` - ${UI.icon('printer')} Drucken`, - size: 'fullscreen', - }); + window.open(`/ausweis/${dogId}`, '_blank', 'noopener'); } // ---------------------------------------------------------- @@ -996,7 +989,7 @@ window.Page_dog_profile = (() => {
    -
    +
    + + +
    +
    + Foto hochladen um die Rasse per KI zu erkennen
    @@ -1086,6 +1089,9 @@ window.Page_dog_profile = (() => { }); } + // Rassen-Erkennung per KI + _bindRasseErkennung(); + document.getElementById('dp-form-cancel') ?.addEventListener('click', UI.modal.close); @@ -1171,6 +1177,152 @@ 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 // ---------------------------------------------------------- @@ -1196,6 +1348,444 @@ 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 new file mode 100644 index 0000000..3bed1dd --- /dev/null +++ b/backend/static/js/pages/expenses.js @@ -0,0 +1,828 @@ +/* ============================================================ + 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 f29ff18..8c28a1b 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -66,6 +66,7 @@ window.Page_forum = (() => { _container = container; _appState = appState; _render(); + _loadHdmCard(); _loadThreads(true); } @@ -98,15 +99,17 @@ window.Page_forum = (() => {
    ${KATEGORIEN.map(k => ` + data-kat="${k.key}">${_esc(k.label)} `).join('')} + data-section="map">${UI.icon('users')} Mitgliederkarte
    - +
    +
    +
    @@ -127,6 +130,23 @@ 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]'); @@ -175,6 +195,177 @@ 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 1d6400b..6308eda 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -6,11 +6,13 @@ window.Page_health = (() => { - let _container = null; - let _appState = null; - let _data = {}; - let _praxen = []; - let _activeTab = 'impfung'; + let _container = null; + let _appState = null; + let _data = {}; + let _praxen = []; + let _activeTab = 'impfung'; + let _favoritVet = null; + let _healthDocs = []; const BASE_TABS = [ { key: 'impfung', label: 'Impfpass', icon: '' }, @@ -20,7 +22,6 @@ 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: '' }; @@ -150,6 +151,9 @@ window.Page_health = (() => { +
    ${transponderHtml}
    @@ -162,6 +166,8 @@ 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)); @@ -170,6 +176,7 @@ window.Page_health = (() => { _renderTab(); _loadKiBerichte(dog.id); _loadTerminvorschlaege(dog.id); + _loadMeinTierarzt(); } // ---------------------------------------------------------- @@ -342,6 +349,16 @@ 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 = []; + } } // ---------------------------------------------------------- @@ -362,7 +379,6 @@ 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); @@ -901,7 +917,8 @@ window.Page_health = (() => { }).join(''); return `
    ${items}
    -
    ${addBtn}
    `; +
    ${addBtn}
    + ${_renderBefundeSection()}`; } // ---------------------------------------------------------- @@ -957,6 +974,32 @@ 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); + } } // ---------------------------------------------------------- @@ -1597,7 +1640,9 @@ window.Page_health = (() => { action: addBtn }); - const renderCard = p => ` + const renderCard = p => { + const isFav = _favoritVet?.id === p.id || p.is_favorite; + return `
    @@ -1626,17 +1671,40 @@ 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}
    - ${aktive.map(renderCard).join('')} + ${ohneGesetzt.map(renderCard).join('')} ${inaktive.length ? `
    @@ -2156,6 +2224,306 @@ 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'); @@ -2323,6 +2691,129 @@ 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 new file mode 100644 index 0000000..b056d24 --- /dev/null +++ b/backend/static/js/pages/jobs.js @@ -0,0 +1,269 @@ +/* ============================================================ + 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 441a0df..a36cf73 100644 --- a/backend/static/js/pages/movies.js +++ b/backend/static/js/pages/movies.js @@ -13,6 +13,9 @@ 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 @@ -39,7 +42,6 @@ window.Page_movies = (() => {
    -
    `; @@ -64,26 +66,54 @@ 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 { - _filme = await API.get('/movies/filme'); + await _loadFilme(); } catch { content.innerHTML = UI.emptyState({ icon: 'film-slate', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' }); return; } content.innerHTML = ` -
    - - - - +
    +
    + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + +
    `; @@ -97,6 +127,31 @@ 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')); } @@ -106,7 +161,18 @@ 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.bewertung_avg >= 4.0); + 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 (list.length === 0) { grid.innerHTML = `
    Keine Filme für diesen Filter.
    `; @@ -130,18 +196,25 @@ window.Page_movies = (() => { function _movieCard(film) { const stirbt = film.stirbt_der_hund; const tag = stirbt - ? `
    ACHTUNG: Der Hund stirbt
    ` - : `
    Der Hund überlebt
    `; + ? `
    Hund stirbt
    ` + : `
    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)}
    +
    + ${_esc(film.genre)}${typLabel ? `${typLabel}` : ''} +
    ${_esc(film.hund_rasse)}
    ${tag} +
    ${imdb}${streaming}
    ${stars}
    diff --git a/backend/static/js/pages/playdate.js b/backend/static/js/pages/playdate.js new file mode 100644 index 0000000..60eba05 --- /dev/null +++ b/backend/static/js/pages/playdate.js @@ -0,0 +1,708 @@ +/* ============================================================ + 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 new file mode 100644 index 0000000..dfd9bbe --- /dev/null +++ b/backend/static/js/pages/recalls.js @@ -0,0 +1,188 @@ +/* ============================================================ + 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 7c3679d..8829bb6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -263,6 +263,12 @@ window.Page_settings = (() => { Kalender abonnieren
    + + ${dog?.id ? `
    ` : ''} +

    Mehr entdecken

    ${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => ` @@ -497,9 +499,85 @@ 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 new file mode 100644 index 0000000..c838916 --- /dev/null +++ b/backend/static/js/pages/wetter.js @@ -0,0 +1,581 @@ +/* ============================================================ + 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 69f2e4a..b2b6390 100644 --- a/backend/static/js/pages/wiki.js +++ b/backend/static/js/pages/wiki.js @@ -255,6 +255,15 @@ window.Page_wiki = (() => {
    +
    + + +
    💬 -

    Forum

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

    Kostenlos
    +

    Forum

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

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

    Hundefilme

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

    Kostenlos
    +

    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
    🩹 @@ -604,13 +615,14 @@

    Ban Yaro vs. Konkurrenz

    -

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

    +

    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.

    + @@ -621,12 +633,14 @@ + - + + @@ -637,6 +651,15 @@ + + + + + + + + + @@ -644,54 +667,70 @@ + + - + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - + - + - + - - - - - - @@ -700,13 +739,15 @@ + - + +
    Funktion Ban YaroHundeo (DE) Dogorama Tractive PetDeskKostenlos nutzbar ✓ Ja BegrenztBegrenzt ✗ Abo ✗ Nein
    DSGVO / EU-Hosting✓ Ja✓ DE✓ DE ✗ Nein Teilweise ✗ USA
    KI-Hundetrainer
    Giftköder-Alarm
    Digitaler Impfpass
    Gassi-CommunityForum & 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-angereichert)Rassen-Wiki (1003 Rassen, KI)
    Pflege-Tipps rassenspezifisch
    Täglicher Routenvorschlag (Gassirunde)Täglicher Routenvorschlag
    @@ -809,6 +850,20 @@

    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.

    +
    +
    @@ -824,12 +879,16 @@ diff --git a/backend/static/manifest.json b/backend/static/manifest.json index fd71b5d..fbed5f2 100644 --- a/backend/static/manifest.json +++ b/backend/static/manifest.json @@ -1,6 +1,6 @@ { "id": "/", - "version": "1.1.4", + "version": "1.3.0", "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 new file mode 100644 index 0000000..2ea2e54 --- /dev/null +++ b/backend/static/presse.html @@ -0,0 +1,320 @@ + + + + + + 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 d3afae4..e786916 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-v577'; +const CACHE_VERSION = 'by-v651'; 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,11 +125,34 @@ 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 // ---------------------------------------------------------- @@ -173,19 +196,27 @@ 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) caches.open(CACHE_API).then(c => c.put(event.request, resp.clone())); + if (resp.ok) { + _cacheMark(url.pathname); + caches.open(CACHE_API).then(c => c.put(event.request, resp.clone())); + } return resp; }) .catch(() => null); - // Stale-While-Revalidate: sofort aus Cache, im Hintergrund holen - if (cached) { - networkPromise.catch(() => {}); // fire and forget + + // Cache noch frisch → sofort zurückgeben, Netz im Hintergrund + if (cached && !stale) { + networkPromise.catch(() => {}); 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 672be1e..e5f2317 100644 --- a/backend/weather.py +++ b/backend/weather.py @@ -161,3 +161,251 @@ 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 e7312b4..800d3f0 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -7,11 +7,12 @@ 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=/data/media + - MEDIA_DIR=/prod-media - STAGING=true - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 diff --git a/reports/.gitkeep b/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/reports/2026-05-01-dateien.md b/reports/2026-05-01-dateien.md new file mode 100644 index 0000000..6ceb3c8 --- /dev/null +++ b/reports/2026-05-01-dateien.md @@ -0,0 +1,180 @@ +# 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 new file mode 100644 index 0000000..988821f --- /dev/null +++ b/reports/2026-05-01-funktionsumfang.md @@ -0,0 +1,151 @@ +# 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 new file mode 100644 index 0000000..7422033 --- /dev/null +++ b/reports/2026-05-01-nutzer.md @@ -0,0 +1,91 @@ +# 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 new file mode 100644 index 0000000..31129b6 --- /dev/null +++ b/reports/2026-05-01-partner.md @@ -0,0 +1,24 @@ +# 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 new file mode 100644 index 0000000..8dc3572 --- /dev/null +++ b/reports/2026-05-01-server.md @@ -0,0 +1,172 @@ +# 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 new file mode 100644 index 0000000..49c50ea --- /dev/null +++ b/reports/2026-05-01-sicherheit.md @@ -0,0 +1,128 @@ +# 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