From c1bb72815395bdbaa3b75324c0e9f52e1d0ec4be Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 08:07:41 +0200
Subject: [PATCH 01/63] =?UTF-8?q?Reports=202026-05-01=20=E2=80=94=20Quarta?=
=?UTF-8?q?lsbericht?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
reports/.gitkeep | 0
reports/2026-05-01-dateien.md | 180 ++++++++++++++++++++++++++
reports/2026-05-01-funktionsumfang.md | 151 +++++++++++++++++++++
reports/2026-05-01-nutzer.md | 91 +++++++++++++
reports/2026-05-01-partner.md | 24 ++++
reports/2026-05-01-server.md | 172 ++++++++++++++++++++++++
reports/2026-05-01-sicherheit.md | 128 ++++++++++++++++++
7 files changed, 746 insertions(+)
create mode 100644 reports/.gitkeep
create mode 100644 reports/2026-05-01-dateien.md
create mode 100644 reports/2026-05-01-funktionsumfang.md
create mode 100644 reports/2026-05-01-nutzer.md
create mode 100644 reports/2026-05-01-partner.md
create mode 100644 reports/2026-05-01-server.md
create mode 100644 reports/2026-05-01-sicherheit.md
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
From de1677154f6018cf76f597c07f95667f761f3085 Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 08:20:53 +0200
Subject: [PATCH 02/63] Security + E-Mail-HTML + Quartalsbericht +
Registrierungspflicht
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Registrierung & Login:
- E-Mail-Verifikation jetzt Pflicht vor erstem Login
- Register gibt keinen Token mehr zurück → "Postfach prüfen"-Screen
- Login blockt mit EMAIL_NOT_VERIFIED (403) wenn unverifiziert
- Resend-Verification ohne Auth (email-basiert)
- Frontend: _renderVerifyPending() nach Register und Login-Fehler
- Account-Lockout: 5 Fehlversuche → 15 Min gesperrt (ratelimit.py)
- Login Rate-Limit zusätzlich per E-Mail-Adresse (5/5 Min)
- Fehler-Tracking wird bei erfolgreichem Login zurückgesetzt
E-Mail-Templates (alle Mails jetzt HTML):
- email_html() Shared-Template in mailer.py (Gradient-Header, Warm-Beige)
- Verifikations-Mail, Passwort-Reset → HTML mit CTA-Button
- Admin-Outreach: plain text auto-wrapped in HTML
- Züchter-Mails (Antrag/Genehmigung/Ablehnung) → Template
- Tierschutz-Alert (litters.py) → Template
- send_support_mail → HTML
- outreach._build_message() + _send_smtp() unterstützen jetzt html= Parameter
Forum-Schutz:
- Post-Cooldown: 30 Sek zwischen beliebigen Posts (DB-Check)
- Stunden-Limit: 5 Threads / 20 Antworten pro User/Stunde
- Duplikat-Erkennung: gleicher Text in 5 Min blockiert (in-memory)
- content_filter.py: Spam-Keywords, URL-Sperre für Accounts < 7 Tage,
Sonderzeichen-Ratio-Check
Security-Headers:
- HSTS: max-age=31536000; includeSubDomains
- Content-Security-Policy: frame-ancestors none, base-uri self, …
- X-Frame-Options entfernt (CSP frame-ancestors ist moderner)
Honeypot-Fallen (13 Scanner-Pfade → 24h IP-Sperre):
- /api/admin/users, /api/v1/users, /api/.env, /api/config,
/api/setup, /api/install, /api/phpinfo, /api/debug,
/api/actuator, /api/swagger, /api/graphql u.a.
Quartalsbericht-System:
- backend/scripts/generate_reports.py: 6 Sections
(Sicherheit, Funktionsumfang, Dateien, Nutzer, Partner, Server)
- make reports: generiert alle Berichte aus dem Container, committed
- Scheduler: quarterly_report Job (1. Feb/Mai/Aug/Nov 07:00)
→ vollständige HTML-Mail an ADMIN_EMAIL
- quarterly_report erscheint im täglichen Status-Report
Admin-Panel:
- "Forum & Meldungen" → "Forum"
---
Makefile | 28 +-
backend/content_filter.py | 63 +++
backend/mailer.py | 85 ++--
backend/main.py | 56 ++-
backend/ratelimit.py | 81 +++-
backend/routes/auth.py | 83 ++--
backend/routes/breeder.py | 67 +--
backend/routes/forum.py | 51 ++
backend/routes/litters.py | 21 +-
backend/routes/outreach.py | 30 +-
backend/scheduler.py | 138 +++++-
backend/scripts/generate_reports.py | 725 ++++++++++++++++++++++++++++
backend/static/js/app.js | 2 +-
backend/static/js/pages/admin.js | 2 +-
backend/static/js/pages/settings.js | 72 ++-
15 files changed, 1363 insertions(+), 141 deletions(-)
create mode 100644 backend/content_filter.py
create mode 100644 backend/scripts/generate_reports.py
diff --git a/Makefile b/Makefile
index 2427674..910c66d 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 ""
# ----------------------------------------------------------
@@ -235,6 +236,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/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/mailer.py b/backend/mailer.py
index e5cbdc0..344fe4f 100644
--- a/backend/mailer.py
+++ b/backend/mailer.py
@@ -106,44 +106,67 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""):
logger.warning(f"Kein Mail-Backend konfiguriert — Mail NICHT gesendet: «{subject}» → {to}")
+def email_html(
+ body_html: str,
+ cta_url: str = None,
+ cta_label: str = None,
+ footer_text: str = None,
+) -> str:
+ """Shared branded HTML email template (matches Status-Report design)."""
+ cta_block = ""
+ if cta_url and cta_label:
+ cta_block = f"""
+
+
+ {cta_label}
+
+
"""
+
+ footer = footer_text or "Ban Yaro · banyaro.app"
+
+ return f"""\
+
+
+
+
+
+
+
+
+
+
+
+ {body_html}{cta_block}
+
+
+
+ {footer}
+
+
+
+
+"""
+
+
async def send_verify_email(to: str, name: str, token: str):
url = f"{APP_URL}/api/auth/verify/{token}"
subject = "Ban Yaro — E-Mail-Adresse bestätigen"
- html = f"""\
-
-
-
-
-
-
Ban Yaro 🐾
-
Hallo {name},
-
+ body = f"""
+
Hallo {name} ,
+
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
-
-
- E-Mail bestätigen
-
-
-
- Der Link ist 48 Stunden gültig.
-
-
+
Der Link ist 48 Stunden gültig.
+
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
-
-
-
-"""
+
"""
- plain = (
- f"Ban Yaro — E-Mail-Adresse bestätigen\n\n"
- f"Hallo {name},\n\n"
- f"bitte bestätige deine E-Mail-Adresse:\n{url}\n\n"
- f"Der Link ist 48 Stunden gültig.\n"
- )
+ html = email_html(body, cta_url=url, cta_label="E-Mail bestätigen")
+ plain = f"Ban Yaro — E-Mail bestätigen\n\nHallo {name},\n\nbitte bestätige:\n{url}\n\nLink ist 48h gültig.\n"
await send_email(to, subject, html, plain)
diff --git a/backend/main.py b/backend/main.py
index e8720c9..83fa934 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -67,11 +67,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)
@@ -1617,6 +1626,43 @@ 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)
+
+
# 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/routes/auth.py b/backend/routes/auth.py
index f810217..13d857d 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"
@@ -27,17 +27,22 @@ def _send_verification_email(email: str, name: str, token: str):
if not _SMTP_READY:
return
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"
- )
+ body_html = f"""
+ Hallo {name} ,
+
+ 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 +144,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 +262,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}
@@ -293,18 +307,23 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
(token, expires, user["id"])
)
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
+ body_html = f"""
+ Hallo {user['name']} ,
+
+ 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/forum.py b/backend/routes/forum.py
index 0cfe1df..fe730d5 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']:
diff --git a/backend/routes/litters.py b/backend/routes/litters.py
index 2bcf629..82ba96f 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,20 @@ 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
- """
+ welfare_body = f"""
+ Kritischer Tierschutz-Hinweis bestätigt
+
+ Züchter {zuechter}
+ Zwinger {zwinger}
+ Vater {eltern['vater_name'] or '—'}
+ Mutter {eltern['mutter_name'] or '—'}
+ Wurf-ID #{litter_id}
+
"""
try:
await send_email(
admin_email,
f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}",
- 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/outreach.py b/backend/routes/outreach.py
index 6ec066c..85eb624 100644
--- a/backend/routes/outreach.py
+++ b/backend/routes/outreach.py
@@ -84,7 +84,7 @@ 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["Subject"] = subject
@@ -92,14 +92,16 @@ def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultip
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"):
+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, 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 +191,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 +208,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 +242,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)
# ------------------------------------------------------------------
diff --git a/backend/scheduler.py b/backend/scheduler.py
index c99600e..d87ef3f 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -100,6 +100,14 @@ def start():
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,
@@ -109,7 +117,7 @@ def start():
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, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).")
def stop():
@@ -698,6 +706,7 @@ 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)",
}
job_rows_html = ""
job_rows_txt = ""
@@ -783,6 +792,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'{_h.escape(c)} ' for c in cells)
+ lines_out.append(f"{row_html} ")
+ continue
+ elif line.startswith("- ") or line.startswith("* "):
+ if in_table:
+ lines_out.append("
")
+ 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,
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/js/app.js b/backend/static/js/app.js
index f1d577b..cdc5231 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -564,7 +564,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 });
diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js
index cd34154..1775ccd 100644
--- a/backend/static/js/pages/admin.js
+++ b/backend/static/js/pages/admin.js
@@ -14,7 +14,7 @@ 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' },
diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js
index 7c3679d..f8488b6 100644
--- a/backend/static/js/pages/settings.js
+++ b/backend/static/js/pages/settings.js
@@ -1238,6 +1238,49 @@ window.Page_settings = (() => {
// ----------------------------------------------------------
// NICHT EINGELOGGT — Login / Registrierung
// ----------------------------------------------------------
+ function _renderVerifyPending(email) {
+ _container.innerHTML = `
+
+
+
+
E-Mail bestätigen
+
+
+
+ Wir haben einen Bestätigungslink an
+ ${email}
+ gesendet.
+
+
+ Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren.
+ Danach kannst du dich hier anmelden.
+
+
+
+ Link erneut senden
+
+
+ Anderes Konto / Anmelden
+
+
+ `;
+ document.getElementById('verify-resend-btn2')?.addEventListener('click', async function() {
+ this.disabled = true;
+ this.textContent = 'Gesendet …';
+ try {
+ await API.post('/auth/resend-verification', { email });
+ UI.toast.success('Bestätigungs-Mail erneut gesendet.');
+ } catch {
+ UI.toast.error('Fehler beim Senden — bitte versuche es später erneut.');
+ }
+ });
+ document.getElementById('verify-back-btn')?.addEventListener('click', () => _renderAuth('login'));
+ }
+
function _renderAuth(mode) {
// Passwort-Reset über Link aus E-Mail
const resetToken = sessionStorage.getItem('by_reset_token');
@@ -1467,7 +1510,16 @@ window.Page_settings = (() => {
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
- const result = await API.auth.login(fd.email, fd.password);
+ let result;
+ try {
+ result = await API.auth.login(fd.email, fd.password);
+ } catch (err) {
+ if (err.message === 'EMAIL_NOT_VERIFIED') {
+ _renderVerifyPending(fd.email);
+ return;
+ }
+ throw err;
+ }
localStorage.setItem('by_token', result.token);
// User-Daten laden
@@ -1583,22 +1635,12 @@ window.Page_settings = (() => {
const refCode = sessionStorage.getItem('by_ref_code') || '';
const finalCode = partnerCode || refCode || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
- localStorage.setItem('by_token', result.token);
if (refCode) sessionStorage.removeItem('by_ref_code');
- _appState.user = await API.auth.me();
- document.getElementById('sidebar-username').textContent = _appState.user.name;
- _appState.dogs = [];
- _appState.activeDog = null;
-
- document.getElementById('header-login-btn')?.remove();
- const greeting = _appState.user.is_founder_pending
- ? `Willkommen, ${_appState.user.name}! 🎉 Dein Gründer-Platz ist reserviert — leg jetzt dein Hunde-Profil an um ihn zu sichern!`
- : _appState.user.is_founder
- ? `Willkommen, Gründer ${_appState.user.name}! 🎉`
- : `Willkommen bei Ban Yaro, ${_appState.user.name}!`;
- UI.toast.success(greeting);
- App.showOnboarding();
+ if (result.pending_verification) {
+ _renderVerifyPending(fd.email);
+ return;
+ }
});
});
}
From 59856e61a1874cc2b96c5a5589f2c2812deddea1 Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 08:50:01 +0200
Subject: [PATCH 03/63] =?UTF-8?q?Filme:=20DB-Migration,=2068=20Eintr=C3=A4?=
=?UTF-8?q?ge,=20Sort=20+=20Typ-Filter?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- movies-Tabelle in SQLite (statt hardcoded Liste)
- seed_movies(): 68 Filme/Serien/Dokus beim ersten Start
- Felder: titel, originaltitel, jahr, genre, typ, hund_rasse,
stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, streaming
- GET /api/movies/filme?sort=&typ= — serverseitig sortiert
Sort: default | titel | jahr_desc | jahr_asc | imdb | bewertung
Typ: alle | film | serie | doku
- Admin-CRUD: POST/PATCH/DELETE /api/movies/filme
- Frontend: Sort-Dropdown, Typ-Filter-Buttons (Filme/Serien/Dokus),
Zähler, IMDb-Rating + Streaming auf der Karte
- Promis ebenfalls erweitert (10 statt 6 Einträge)
---
backend/database.py | 23 +-
backend/main.py | 2 +
backend/routes/movies.py | 363 +++++++++++++++++++++---------
backend/static/js/pages/movies.js | 75 +++++-
4 files changed, 350 insertions(+), 113 deletions(-)
diff --git a/backend/database.py b/backend/database.py
index 5ea9f4a..8238bca 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,
diff --git a/backend/main.py b/backend/main.py
index 83fa934..e7db176 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -46,6 +46,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
diff --git a/backend/routes/movies.py b/backend/routes/movies.py
index 5ef83da..399c583 100644
--- a/backend/routes/movies.py
+++ b/backend/routes/movies.py
@@ -1,140 +1,317 @@
"""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},
]
-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 beim ersten Start (idempotent per INSERT OR IGNORE)."""
+ import logging
+ logger = logging.getLogger(__name__)
+ with db() as conn:
+ count = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0]
+ if count == 0:
+ 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: {len(_SEED_FILME)} Filme geseedet.")
+
+
# ------------------------------------------------------------------
# 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": "m.sort_order ASC, 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 +320,25 @@ 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}
-# ------------------------------------------------------------------
-# 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"]:
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/static/js/pages/movies.js b/backend/static/js/pages/movies.js
index 441a0df..5f872cb 100644
--- a/backend/static/js/pages/movies.js
+++ b/backend/static/js/pages/movies.js
@@ -13,6 +13,8 @@ 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
// ----------------------------------------------------------
// INIT
@@ -70,20 +72,44 @@ window.Page_movies = (() => {
// ----------------------------------------------------------
// 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 = `
-
-
Alle
-
Hund stirbt
-
Hund überlebt
-
+
+
+
+ Alle
+ Hund stirbt
+ Hund überlebt
+ Top
+
+
+ Alle Typen
+ 🎬 Filme
+ 📺 Serien
+ 🎥 Dokus
+
+
+
+
+ Empfohlen
+ Community-Bewertung
+ IMDb-Bewertung
+ Neueste zuerst
+ Älteste zuerst
+ Titel A–Z
+
+
+
`;
@@ -97,6 +123,26 @@ 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-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 +152,10 @@ 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);
+
+ 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 +179,24 @@ 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 typLabel = film.typ === 'serie' ? '📺 Serie' : film.typ === 'doku' ? '🎥 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}
From f378edab5dec8edeea5f606e674da003b2975946 Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 09:30:05 +0200
Subject: [PATCH 04/63] =?UTF-8?q?Jobs:=20Bewerbungssystem=20f=C3=BCr=20Soc?=
=?UTF-8?q?ial-Media-Manager/in?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Backend:
- job_applications + job_application_docs Tabellen in DB
- luna_trial_until Spalte in users (Migration)
- routes/jobs.py: POST /apply (FormData + Datei-Upload, max 3×10MB),
GET /my-application, GET /luna-trial-status
- Admin: GET/PATCH /admin/applications, GET /admin/applications/{id}/docs/{doc_id}
- Bei Bewerbung: 14-Tage Luna-Probezugang automatisch aktiviert
- Bei Annahme: is_social_media=1 + Gründer-Status gesetzt
- Status-Mails (pending/reviewing/accepted/rejected) via email_html-Template
- auth.py: require_social_media prüft auch luna_trial_until
Frontend:
- pages/jobs.js: Stellenausschreibung + Bewerbungsformular
(Name, E-Mail, Hund, Social-Handle, Motivation, Datei-Upload)
- Luna-Probezugang Teaser mit Countdown wenn aktiv
- Bestehende Bewerbung: Status-Screen statt Formular
- app.js: 'jobs' Seite registriert
- admin.js: neuer Tab 'Bewerbungen' (filtert nach Status,
Statuswechsel per Dropdown, Detailansicht mit Anhang-Download,
Admin-Notiz-Feld)
- admin.js: Tab 'Jobs' → 'Scheduler' umbenannt
---
backend/auth.py | 9 +-
backend/database.py | 29 +++
backend/main.py | 2 +
backend/routes/jobs.py | 318 +++++++++++++++++++++++++++++++
backend/static/js/app.js | 1 +
backend/static/js/pages/admin.js | 123 +++++++++++-
backend/static/js/pages/jobs.js | 260 +++++++++++++++++++++++++
7 files changed, 738 insertions(+), 4 deletions(-)
create mode 100644 backend/routes/jobs.py
create mode 100644 backend/static/js/pages/jobs.js
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/database.py b/backend/database.py
index 8238bca..8ef5362 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -1582,6 +1582,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:
diff --git a/backend/main.py b/backend/main.py
index e7db176..d85556a 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -188,6 +188,7 @@ 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
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -221,6 +222,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"])
diff --git a/backend/routes/jobs.py b/backend/routes/jobs.py
new file mode 100644
index 0000000..8714ae2
--- /dev/null
+++ b/backend/routes/jobs.py
@@ -0,0 +1,318 @@
+"""BAN YARO — Social-Media-Job Bewerbungs-System"""
+
+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:
+ 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:
+ admin_body = f"""
+ Neue Job-Bewerbung eingegangen:
+
+ Name {name}
+ E-Mail {email}
+ Hund {dog_name} ({dog_rasse})
+ Social {social_handle}
+ Anhänge {len([f for f in files if f.filename])} Datei(en)
+
+ {motivation[:300]}{"…" 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
+ texts = {
+ "reviewing": ("Wir schauen uns deine Bewerbung genauer an 🐾",
+ f"Hallo {name} ,
wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!
"),
+ "accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉",
+ f"Hallo {name} ,
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 {name} ,
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 {name},
"))
+ note_html = f'{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/static/js/app.js b/backend/static/js/app.js
index cdc5231..b75494f 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -70,6 +70,7 @@ 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 },
};
// ----------------------------------------------------------
diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js
index 1775ccd..37d31ae 100644
--- a/backend/static/js/pages/admin.js
+++ b/backend/static/js/pages/admin.js
@@ -18,7 +18,8 @@ window.Page_admin = (() => {
{ 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' },
@@ -93,6 +94,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.');
@@ -2375,6 +2377,125 @@ 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 => `
+
+ ${s==='pending'?'⏳ Neu':s==='reviewing'?'🔍 In Prüfung':s==='accepted'?'✅ Angenommen':s==='rejected'?'❌ Abgelehnt':'Alle'}
+ `).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?'…':''}
+
+
+
+ Details
+
+ ⏳ Neu
+ 🔍 Prüfung
+ ✅ Angenommen
+ ❌ Abgelehnt
+
+
+
+
`).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: `
+ Schließen
+ Notiz speichern `,
+ });
+ 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/jobs.js b/backend/static/js/pages/jobs.js
new file mode 100644
index 0000000..7ad5e68
--- /dev/null
+++ b/backend/static/js/pages/jobs.js
@@ -0,0 +1,260 @@
+/* ============================================================
+ BAN YARO — Social-Media-Job Bewerbung
+ ============================================================ */
+
+window.Page_jobs = (() => {
+
+ let _container = null;
+ let _appState = null;
+
+ 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('📍', 'Remote', '100 % flexibel — du arbeitest wann und wie du willst')}
+ ${_infoRow('📅', 'Umfang', '1–2 Posts pro Woche auf Instagram & TikTok')}
+ ${_infoRow('💶', 'Vergütung', '50 € / Monat — wächst mit der Community')}
+ ${_infoRow('🤖', 'Luna an deiner Seite', 'Unser KI-Assistent schreibt Captions, generiert Post-Ideen und Hashtags — du wählst aus und postest')}
+ ${_infoRow('⭐', '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 `
+ `;
+ }
+
+ function _renderStatus(app) {
+ const statusMap = {
+ pending: { icon: '⏳', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' },
+ reviewing: { icon: '🔍', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' },
+ accepted: { icon: '🎉', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' },
+ rejected: { icon: '😔', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' },
+ };
+ const s = statusMap[app.status] || statusMap.pending;
+ return `
+
+
${s.icon}
+
${s.text}
+
+ Bewerbung eingereicht: ${app.created_at?.slice(0,10)}
+
+ ${app.admin_note ? `
${UI.esc(app.admin_note)}
` : ''}
+
`;
+ }
+
+ function _renderForm() {
+ const u = _appState.user;
+ return `
+ `;
+ }
+
+ 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 };
+})();
From 8561cffc80be4e13a18af3af8adf58d3a8a3a89a Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 09:34:12 +0200
Subject: [PATCH 05/63] Fix: page-jobs Section in index.html + SW by-v580
---
backend/static/index.html | 4 ++++
backend/static/js/app.js | 2 +-
backend/static/sw.js | 2 +-
3 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/backend/static/index.html b/backend/static/index.html
index 21afd73..310c6da 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -452,6 +452,10 @@
+
+
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index b75494f..a179d0e 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '580'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
diff --git a/backend/static/sw.js b/backend/static/sw.js
index d3afae4..f72df43 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-v580';
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
From d5e408528b0e2bc7b6f9d857433780d416b56910 Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 09:37:51 +0200
Subject: [PATCH 06/63] =?UTF-8?q?Fix:=20UI.esc=20=E2=86=92=20UI.escape=20i?=
=?UTF-8?q?n=20jobs.js,=20SW=20by-v581?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/static/js/app.js | 2 +-
backend/static/js/pages/jobs.js | 8 +++++---
backend/static/sw.js | 2 +-
3 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index a179d0e..25a949c 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '580'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '581'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
diff --git a/backend/static/js/pages/jobs.js b/backend/static/js/pages/jobs.js
index 7ad5e68..c8dd539 100644
--- a/backend/static/js/pages/jobs.js
+++ b/backend/static/js/pages/jobs.js
@@ -7,6 +7,8 @@ window.Page_jobs = (() => {
let _container = null;
let _appState = null;
+ const _esc = s => UI.escape(s ?? '');
+
async function init(container, appState) {
_container = container;
_appState = appState;
@@ -122,7 +124,7 @@ window.Page_jobs = (() => {
${app.admin_note ? `${UI.esc(app.admin_note)}
` : ''}
+ color:var(--c-text-secondary);text-align:left">${_esc(app.admin_note)}` : ''}
`;
}
@@ -139,13 +141,13 @@ window.Page_jobs = (() => {
Dein Name *
+ value="${u ? _esc(u.name) : ''}" placeholder="Vorname oder Nickname" required>
E-Mail *
+ value="${u ? _esc(u.email || '') : ''}" placeholder="deine@email.de" required>
diff --git a/backend/static/sw.js b/backend/static/sw.js
index f72df43..34c48e3 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-v580';
+const CACHE_VERSION = 'by-v581';
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
From b3bd34c76aa693794dd5ae047f543dbe13cb896a Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 09:44:51 +0200
Subject: [PATCH 07/63] =?UTF-8?q?UI:=20Phosphor-Icons=20f=C3=BCr=20Jobs,?=
=?UTF-8?q?=20Filme,=20Admin-Bewerbungen=20+=20Jobs-Link=20in=20Entdecken,?=
=?UTF-8?q?=20SW=20by-v582?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/static/index.html | 3 +++
backend/static/js/app.js | 2 +-
backend/static/js/pages/admin.js | 6 ++++-
backend/static/js/pages/jobs.js | 39 ++++++++++++++++++-------------
backend/static/js/pages/movies.js | 13 +++++++----
backend/static/sw.js | 2 +-
6 files changed, 42 insertions(+), 23 deletions(-)
diff --git a/backend/static/index.html b/backend/static/index.html
index 310c6da..7b8b567 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -169,6 +169,9 @@
+
@@ -58,8 +60,9 @@ window.Page_jobs = (() => {
-
- 🤖 Luna 14 Tage kostenlos testen
+
+
+ Luna 14 Tage kostenlos testen
Mit deiner Bewerbung schalten wir dir sofort den vollen Zugang zu Luna frei —
@@ -68,7 +71,8 @@ window.Page_jobs = (() => {
${trialStatus?.active ? `
- ✅ Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage
` : ''}
+
+ Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage
` : ''}
@@ -99,7 +103,7 @@ window.Page_jobs = (() => {
function _infoRow(icon, label, text) {
return `
-
${icon}
+
${icon}
${label}
${text}
@@ -109,15 +113,17 @@ window.Page_jobs = (() => {
function _renderStatus(app) {
const statusMap = {
- pending: { icon: '⏳', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' },
- reviewing: { icon: '🔍', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' },
- accepted: { icon: '🎉', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' },
- rejected: { icon: '😔', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' },
+ 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.icon}
+
+
+
${s.text}
Bewerbung eingereicht: ${app.created_at?.slice(0,10)}
@@ -203,8 +209,9 @@ window.Page_jobs = (() => {
bekommst du sofort den 14-tägigen Luna-Probezugang.
` : ''}
-
- Bewerbung absenden + Luna freischalten 🚀
+
+
+ Bewerbung absenden + Luna freischalten
diff --git a/backend/static/js/pages/movies.js b/backend/static/js/pages/movies.js
index 5f872cb..65275d1 100644
--- a/backend/static/js/pages/movies.js
+++ b/backend/static/js/pages/movies.js
@@ -95,11 +95,11 @@ window.Page_movies = (() => {
Alle Typen
🎬 Filme
- 📺 Serien
- 🎥 Dokus
+ Serien
+ Dokus
-
+
Empfohlen
Community-Bewertung
@@ -182,7 +182,12 @@ window.Page_movies = (() => {
? ` Hund stirbt
`
: ` Hund überlebt
`;
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
- const typLabel = film.typ === 'serie' ? '📺 Serie' : film.typ === 'doku' ? '🎥 Doku' : '';
+ const typIcon = film.typ === 'serie'
+ ? ` `
+ : film.typ === 'doku'
+ ? ` `
+ : '';
+ const typLabel = film.typ === 'serie' ? `${typIcon} Serie` : film.typ === 'doku' ? `${typIcon} Doku` : '';
const imdb = film.imdb_rating ? `IMDb ${film.imdb_rating} ` : '';
const streaming = film.streaming ? `${_esc(film.streaming)} ` : '';
diff --git a/backend/static/sw.js b/backend/static/sw.js
index 34c48e3..ba359c6 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-v581';
+const CACHE_VERSION = 'by-v582';
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
From da1146b047cc5285964629e2168c91f7c31581de Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 09:49:48 +0200
Subject: [PATCH 08/63] Fix: movies-type-btn bekommt movies-filter-btn CSS +
Phosphor-Icons sichtbar, SW by-v583
---
backend/static/js/app.js | 2 +-
backend/static/js/pages/movies.js | 16 ++++++----------
backend/static/sw.js | 2 +-
3 files changed, 8 insertions(+), 12 deletions(-)
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index 95fa8fb..beb7069 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
-const APP_VER = '582'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '583'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
diff --git a/backend/static/js/pages/movies.js b/backend/static/js/pages/movies.js
index 65275d1..552928d 100644
--- a/backend/static/js/pages/movies.js
+++ b/backend/static/js/pages/movies.js
@@ -93,10 +93,10 @@ window.Page_movies = (() => {
Top
- Alle Typen
- 🎬 Filme
- Serien
- Dokus
+ Alle
+ Filme
+ Serien
+ Dokus
@@ -182,12 +182,8 @@ window.Page_movies = (() => {
? `
Hund stirbt
`
: `
Hund überlebt
`;
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
- const typIcon = film.typ === 'serie'
- ? `
`
- : film.typ === 'doku'
- ? `
`
- : '';
- const typLabel = film.typ === 'serie' ? `${typIcon} Serie` : film.typ === 'doku' ? `${typIcon} Doku` : '';
+ 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)} ` : '';
diff --git a/backend/static/sw.js b/backend/static/sw.js
index ba359c6..e2dcd3f 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-v582';
+const CACHE_VERSION = 'by-v583';
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
From 062794c61a6e99597eaec0bc8824af567eaaadd9 Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 09:59:57 +0200
Subject: [PATCH 09/63] landing.html: SEO-Update, 68 Filme, Hundeo-Vergleich,
Sicherheits-USP, Social-Links, featureList aktualisiert
---
backend/static/landing.html | 171 ++++++++++++++++++++++++------------
1 file changed, 115 insertions(+), 56 deletions(-)
diff --git a/backend/static/landing.html b/backend/static/landing.html
index c92d835..a28d587 100644
--- a/backend/static/landing.html
+++ b/backend/static/landing.html
@@ -3,16 +3,16 @@
- Ban Yaro — Hunde-App für Besitzer, Züchter & Welpen-Käufer
-
-
+ Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz
+
+
-
-
+
+
@@ -20,8 +20,8 @@
-
-
+
+
@@ -50,42 +50,53 @@
"url": "https://banyaro.app"
},
"featureList": [
- "Digitales Hunde-Tagebuch mit Fotos und GPS",
+ "Digitales Hunde-Tagebuch mit Fotos, Videos und GPS",
+ "Kalender-, Karten- und Medien-Ansicht im Tagebuch",
"Digitaler Impfpass und Gesundheitsakte",
"Pflege-System mit 43 rassenspezifischen Tipps in 10 Kategorien",
- "Giftköder-Alarm mit Push-Benachrichtigungen",
- "Zecken-Warnung regelbasiert (Saison + Temperatur)",
+ "Giftköder-Alarm mit GPS und Push-Benachrichtigungen",
+ "Zecken-Warnung regelbasiert (Saison und Temperatur)",
"Wetter-Chip in der App (Open-Meteo, ohne API-Key)",
- "Gassi-Community und GPS-Routen",
- "Hundesitting-Vermittlung",
- "NFC-Halsband-Tags",
+ "Gassi-Community und GPS-Routen aufzeichnen",
+ "Täglicher Routenvorschlag (2, 4 oder 6 km via OpenRouteService)",
+ "Hundesitting-Vermittlung (nur 8% Provision)",
+ "NFC-Halsband-Tags mit öffentlichem Hunde-Profil",
+ "Forum öffentlich lesbar, schreiben nach E-Mail-Verifikation",
"1003 Hunderassen Wikipedia-grounded und KI-angereichert",
- "Community-Fotos im Rassen-Wiki mit Bildrechte-Bestätigung und Moderation",
- "Rassen-Quiz Passt diese Rasse zu mir?",
- "Verlorener Hund Alarm",
- "Forum für Hundebesitzer",
- "Offline-Modus via Service Worker",
+ "Rassen-Wiki mit Community-Fotos und Moderation",
+ "Verlorener Hund Alarm mit GPS-Position",
+ "Offline-Modus via Service Worker (3 Stufen)",
"Symptom-Checker (KI, kostenlos)",
- "Trainings-Tagebuch mit Einheiten-Logging (Wiederholungen, Erfolgsquote, Stimmung)",
- "Virtueller KI-Trainer mit täglichen Übungsempfehlungen und Fortschrittsprognose",
- "Wöchentlicher KI-Lober — jeden Montag 2-3 Sätze Lob für die Vorwoche",
- "Trainings-Gamification: Streaks, Abzeichen, Trainingskalender",
- "Kommandos & Fähigkeiten im Hundeprofil — praktisch für Hundesitter",
- "Wurfbörse — öffentliche Wurfankündigungen mit Filtersuche nach Rasse und Status",
+ "104 Trainingsübungen in Datenbank mit Schwierigkeitsgraden",
+ "Trainings-Logging: Wiederholungen, Erfolgsquote, Stimmung",
+ "Virtueller KI-Trainer mit täglichen Übungsempfehlungen",
+ "Wöchentlicher KI-Lober jeden Montag",
+ "Trainings-Streaks und Trainingskalender",
+ "Kommandos und Fähigkeiten im Hundeprofil",
+ "Events-Kalender (Agility, Ausstellungen, lokale Veranstaltungen)",
+ "Wurfbörse mit Filtersuche nach Rasse und Status",
"Züchter-Profile mit verifizierten Gesundheitstests und Gentests",
"Stammbaum-Visualisierung bis 4 Generationen",
"Inzucht-Koeffizient nach Wright's Formel mit Ampel-Bewertung",
"Probeverpaarung mit IK-Simulation und genetischer Risikoanalyse",
- "Tierschutz-Check automatisch bei jeder Verpaarung — nicht abschaltbar",
- "KI-Züchter-Assistenz: Wurfankündigungen, Genetik-Erklärung, Paarungsanalyse",
- "Datenexport als HTML und ODS — keine Datenfalle",
- "Personalisierte Tagesroute via OpenRouteService — täglich neue Gassirunde mit 2/4/6 km Wahl",
- "Übung des Tages — personalisiert aus dem persönlichen Trainingsfortschritt",
- "Dashboard-Startseite mit Hundebild-Hero, Statistik-Chips und Feature-Karten"
+ "Tierschutz-Check automatisch bei jeder Verpaarung",
+ "KI-Züchter-Assistenz: Wurfankündigungen, Genetik, Paarungsanalyse",
+ "Datenexport als HTML und ODS",
+ "Hunde-Filmdatenbank: 68 Filme, Serien und Dokumentationen sortier- und filterbar",
+ "Filmdatenbank-Feature: Stirbt der Hund? — Taschentuch-Warnung",
+ "Berühmte Hunde der Geschichte",
+ "Hund des Monats Community-Abstimmung",
+ "Push-Benachrichtigungen (VAPID, ohne Drittanbieter)",
+ "Freundschaften und Direktnachrichten",
+ "Social-Media-Manager Luna (KI-generierte Posts für Instagram und TikTok)",
+ "Notizblock mit KI-Analyse-Funktion",
+ "Erste-Hilfe-Ratgeber für häufige Notfälle",
+ "Hunde-Knigge (Begegnungen, ÖPNV, Leinenpflicht, Haftpflicht)",
+ "Admin-Panel mit Moderation, Outreach-Mailing und Statistiken"
],
"screenshot": "https://banyaro.app/icons/icon-512.png",
- "softwareVersion": "2.2",
- "datePublished": "2026-04-29",
+ "softwareVersion": "1.2.1",
+ "datePublished": "2026-05-01",
"areaServed": ["DE", "AT", "CH"],
"audience": {
"@type": "Audience",
@@ -510,7 +521,7 @@
💬
-
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.
Funktion
Ban Yaro
+ Hundeo (DE)
Dogorama
Tractive
PetDesk
@@ -621,12 +633,14 @@
Kostenlos nutzbar
✓ Ja
Begrenzt
+ Begrenzt
✗ Abo
✗ Nein
DSGVO / EU-Hosting
- ✓ Ja
+ ✓ DE
+ ✓ DE
✗ Nein
Teilweise
✗ USA
@@ -637,6 +651,15 @@
✗
✗
✗
+ ✗
+
+
+ KI-Hundetrainer
+ ✓
+ ✓
+ ✗
+ ✗
+ ✗
Giftköder-Alarm
@@ -644,54 +667,70 @@
✗
✗
✗
+ ✗
Digitaler Impfpass
✓
✗
✗
+ ✗
✓
- Gassi-Community
+ Forum & 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
- ✓
- ✗
- ✗
✗
@@ -700,13 +739,15 @@
✗
✗
✗
+ ✗
- 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 @@
From 37ad20a096570a566f6e3698751f104f957e67df Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 17:34:20 +0200
Subject: [PATCH 10/63] Outreach: 7 personalisierte Influencer-Mail-Vorlagen
(Runde 2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
@nami.and.tommy, @brina.explores, @heimatherzen, @pfotentick,
@flummis_diary, @verwolft, @babybearyuki — INSERT OR IGNORE Migration
---
backend/database.py | 61 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 61 insertions(+)
diff --git a/backend/database.py b/backend/database.py
index 8ef5362..8f52649 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -1582,6 +1582,67 @@ def _migrate(conn_factory):
if 'from_account' not in existing_ol:
conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'")
+ # Influencer-Outreach-Vorlagen (Runde 2 — Mai 2026)
+ try:
+ templates_r2 = [
+ (
+ 'influencer_nami_tommy',
+ 'Influencer: @nami.and.tommy',
+ 'Ban Yaro — Gründer-Aktion für Namis und Tommys Community',
+ 'Hallo,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA (kein App Store nötig): Tagebuch, Gesundheitsakte, Giftköder-Alarm, Trainingspläne und Karte.\n\nIch wende mich ans Management von Nami & Tommy, weil mir die Kombination aus zwei Hunden mit ganz unterschiedlichen Charakteren und die enge Verbindung zur Community auffällt — genau das, wofür Ban Yaro gebaut wurde.\n\nMein konkretes Angebot:\n\nWer sich mit eurem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42"), die dauerhaft im Profil sichtbar ist. Diese 100 Plätze gibt es genau einmal.\n\nIhr bekommt: Partner-Badge in der App, eigener Code, Platz im öffentlichen Ranking.\n\nKein verpflichtender Post, kein Budget nötig — aber eine echte Exklusivität, die ihr eurer Community geben könnt.\n\nMehr dazu: https://banyaro.app/partner\n\nWenn das interessant klingt, reicht eine kurze Antwort — ich richte den Code binnen 24 h ein.\n\nViele Grüße,\nRené\nbanyaro.app',
+ 'partner'
+ ),
+ (
+ 'influencer_brina_explores',
+ 'Influencer: @brina.explores',
+ 'Ban Yaro — Hunde-App für Outdoor-Abenteuer und Rescue-Dogs',
+ 'Hallo Sabrina,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA: Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Trainingspläne, ohne App Store.\n\nIch schreibe dir, weil Gerda und Tildi und der Ansatz "Rescue-Dogs + aktives Leben draußen" perfekt zu dem passt, wofür Ban Yaro steht. Der Giftköder-Alarm ist gerade für Wanderungen und Outdoor-Touren besonders praktisch — Nutzer melden Funde in Echtzeit, der nächste auf dem Trail sieht es sofort.\n\nMein konkretes Angebot:\n\nWer sich mit deinem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42"), dauerhaft im Profil sichtbar. Genau 100 Plätze, kein zweites Mal.\n\nDu bekommst: Partner-Badge, eigener Code, Platz im Ranking.\n\nKein verpflichtender Post — du kannst es teilen, wenn es sich für dich richtig anfühlt.\n\nMehr: https://banyaro.app/partner\n\nBei Interesse einfach kurz antworten — Code ist in 24 h fertig.\n\nViele Grüße,\nRené\nbanyaro.app',
+ 'partner'
+ ),
+ (
+ 'influencer_heimatherzen',
+ 'Influencer: @heimatherzen',
+ 'Ban Yaro — Gründer-Plätze für Blue, Sky und ihre Community',
+ 'Hallo Svenja und Simon,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA (kein App Store): Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Training.\n\nIch schreibe euch, weil @heimatherzen für mich das zeigt, was Ban Yaro im Kern ist: Hund als fester Teil des Alltags, nicht als Hobby nebenbei. Blue und Sky, das Münsterland, die ehrliche Art wie ihr eure Community mitnehmt — das passt.\n\nMein Angebot:\n\nWer sich mit eurem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42"), dauerhaft sichtbar. 100 Plätze, einmalig.\n\nIhr bekommt: Partner-Badge, eigener Code, Platz im Ranking.\n\nKein verpflichtender Post, kein Budget.\n\nMehr: https://banyaro.app/partner\n\nWenn ihr mögt, einfach kurz antworten — ich richte alles in 24 h ein.\n\nViele Grüße,\nRené\nbanyaro.app',
+ 'partner'
+ ),
+ (
+ 'influencer_pfotentick',
+ 'Influencer: @pfotentick',
+ 'Ban Yaro — Kooperationsanfrage für die Labrador-Community',
+ 'Hallo Daniel und Jeanette,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA: Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Trainingspläne.\n\nIch habe gesehen, dass ihr professionell mit Marken zusammenarbeitet und ein Media-Kit habt — daher schreibe ich euch direkt und konkret:\n\nMein Angebot:\n\nWer sich mit eurem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42"), dauerhaft im Profil. Exakt 100 Plätze, kein zweites Mal.\n\nIhr bekommt: Partner-Badge in der App, eigener Code, Platz im öffentlichen Ranking.\n\nKein Pflicht-Post, kein Vorab-Budget — aber eine echte Exklusivität für eure Labrador-Community.\n\nDetails: https://banyaro.app/partner\n\nBei Interesse meldet euch kurz — Code ist in 24 h eingerichtet.\n\nViele Grüße,\nRené\nbanyaro.app',
+ 'partner'
+ ),
+ (
+ 'influencer_flummis_diary',
+ 'Influencer: @flummis_diary (AT)',
+ 'Ban Yaro — für die "Vollzeitjob und Hund"-Community',
+ 'Hallo Kerstin,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA: Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Trainingspläne, direkt im Browser ohne App Store.\n\nDeine Nische "Vollzeitjob und trotzdem guter Hundehalter sein" trifft genau die Zielgruppe, für die Ban Yaro gebaut wurde — Menschen, die wenig Zeit haben und trotzdem alles im Blick behalten wollen. Eine schnelle Notiz im Tagebuch, ein Impftermin in der Gesundheitsakte, ein Giftköder-Alarm unterwegs.\n\nMein Angebot:\n\nWer sich mit deinem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — Badge "Gründer #42", dauerhaft sichtbar im Profil. Genau 100 Plätze, österreichische Community darf natürlich mitmachen.\n\nDu bekommst: Partner-Badge, eigener Code, Platz im Ranking.\n\nKein Pflicht-Post — du teilst es, wenn und wie es passt.\n\nMehr: https://banyaro.app/partner\n\nBei Interesse einfach kurz antworten.\n\nViele Grüße,\nRené\nbanyaro.app',
+ 'partner'
+ ),
+ (
+ 'influencer_verwolft',
+ 'Influencer: @verwolft',
+ 'Ban Yaro — Gründer-Aktion für Milos Community',
+ 'Hallo Nicole,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA: Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Training.\n\nDu kennst die Influencer-Seite des Hundemarkts von innen, daher schreibe ich dir etwas direkter: Ban Yaro ist jung, wächst organisch und ich suche Partner, die echte Verbindung zu ihrer Community haben — kein Reichweiten-Kauf.\n\nMein Angebot:\n\nWer sich mit deinem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — Badge "Gründer #42", dauerhaft im Profil. 100 Plätze, einmalig.\n\nDu bekommst: Partner-Badge, eigener Code, Platz im Ranking.\n\nKein verpflichtender Post, kein Budget-Pitch — du entscheidest ob und wie du es teilst.\n\nMehr: https://banyaro.app/partner\n\nWenn es interessant klingt, meld dich kurz — Code in 24 h.\n\nViele Grüße,\nRené\nbanyaro.app',
+ 'partner'
+ ),
+ (
+ 'influencer_babybearyuki',
+ 'Influencer: @babybearyuki',
+ 'Ban Yaro — Gründer-Aktion für BabyBearYukis Community',
+ 'Hello,\n\nMein Name ist René, ich bin Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA (kein App Store nötig): Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Trainingspläne.\n\nIch wende mich ans Management von BabyBearYuki, da die Kombination Samoyed + Aussie und die starke internationale wie auch deutschsprachige Community sehr gut zu Ban Yaro passt.\n\nMein konkretes Angebot:\n\nWer sich mit dem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42"), dauerhaft im Profil sichtbar. Genau 100 Plätze, exklusiv und einmalig.\n\nFür den Account: Partner-Badge in der App, eigener Code, Platz im öffentlichen Ranking.\n\nKein Pflicht-Post, kein vorab Budget — eine echte Exklusivität für die Community.\n\nDetails: https://banyaro.app/partner\n\nBei Interesse gerne kurz melden — Code-Setup dauert 24 h.\n\nViele Grüße,\nRené\nbanyaro.app',
+ 'partner'
+ ),
+ ]
+ for key, label, subject, body, from_acc in templates_r2:
+ conn.execute(
+ "INSERT OR IGNORE INTO email_templates (key, label, subject, body, from_account) VALUES (?, ?, ?, ?, ?)",
+ (key, label, subject, body, from_acc)
+ )
+ except Exception as e:
+ logger.warning(f"Migration influencer_templates_r2: {e}")
+
# Job-Bewerbungen + Luna-Probezugang
conn.executescript("""
CREATE TABLE IF NOT EXISTS job_applications (
From ab197d3ca23300f899280c3f9a485d151242d871 Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 17:46:01 +0200
Subject: [PATCH 11/63] =?UTF-8?q?Fix:=20Date-Header=20in=20Outreach-Mails?=
=?UTF-8?q?=20(Spam-Pr=C3=A4vention)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/routes/outreach.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py
index 85eb624..f59f597 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
@@ -87,6 +87,7 @@ def _imap_save_sent(msg_bytes: bytes, account: str):
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=True)
msg["Subject"] = subject
msg["From"] = formataddr((acc["name"], acc["from"]))
msg["To"] = to
From 5e0dcde5238785a0e2bdce2bf3ba675392ea53d2 Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 17:50:48 +0200
Subject: [PATCH 12/63] =?UTF-8?q?Fix:=20Pflicht-Footer=20in=20Outreach-Mai?=
=?UTF-8?q?ls=20(UWG=20=C2=A77,=20DSGVO=20Art.=2014,=20Anschrift)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/routes/outreach.py | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py
index f59f597..daeb907 100644
--- a/backend/routes/outreach.py
+++ b/backend/routes/outreach.py
@@ -98,11 +98,22 @@ def _build_message(to: str, subject: str, body: str, account: str, html: str = N
return msg
+_LEGAL_FOOTER = (
+ "\n\n---\n"
+ "Ban Yaro | René Degelmann | Ringstr. 26, D-85560 Ebersberg\n"
+ "Web: https://banyaro.app | Mail: partner@banyaro.app\n\n"
+ "Datenschutzhinweis: Deine Kontaktdaten stammen aus deinem öffentlichen Profil. "
+ "Verarbeitung auf Basis berechtigten Interesses (Art. 6 Abs. 1 lit. f DSGVO). "
+ "Datenschutzerklärung: https://banyaro.app/datenschutz\n"
+ "Widerspruch/Löschung: Einfach auf diese Mail antworten."
+)
+
+
def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: str = None):
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, html=html)
+ 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:
From b6fdb23292cabf9f12c8b9eeb7574f466d89f0a7 Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 18:15:30 +0200
Subject: [PATCH 13/63] Cleanup: Influencer-Runde-2-Vorlagen aus Migration
entfernt (bereits versendet)
---
backend/database.py | 61 ---------------------------------------------
1 file changed, 61 deletions(-)
diff --git a/backend/database.py b/backend/database.py
index 8f52649..8ef5362 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -1582,67 +1582,6 @@ def _migrate(conn_factory):
if 'from_account' not in existing_ol:
conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'")
- # Influencer-Outreach-Vorlagen (Runde 2 — Mai 2026)
- try:
- templates_r2 = [
- (
- 'influencer_nami_tommy',
- 'Influencer: @nami.and.tommy',
- 'Ban Yaro — Gründer-Aktion für Namis und Tommys Community',
- 'Hallo,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA (kein App Store nötig): Tagebuch, Gesundheitsakte, Giftköder-Alarm, Trainingspläne und Karte.\n\nIch wende mich ans Management von Nami & Tommy, weil mir die Kombination aus zwei Hunden mit ganz unterschiedlichen Charakteren und die enge Verbindung zur Community auffällt — genau das, wofür Ban Yaro gebaut wurde.\n\nMein konkretes Angebot:\n\nWer sich mit eurem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42"), die dauerhaft im Profil sichtbar ist. Diese 100 Plätze gibt es genau einmal.\n\nIhr bekommt: Partner-Badge in der App, eigener Code, Platz im öffentlichen Ranking.\n\nKein verpflichtender Post, kein Budget nötig — aber eine echte Exklusivität, die ihr eurer Community geben könnt.\n\nMehr dazu: https://banyaro.app/partner\n\nWenn das interessant klingt, reicht eine kurze Antwort — ich richte den Code binnen 24 h ein.\n\nViele Grüße,\nRené\nbanyaro.app',
- 'partner'
- ),
- (
- 'influencer_brina_explores',
- 'Influencer: @brina.explores',
- 'Ban Yaro — Hunde-App für Outdoor-Abenteuer und Rescue-Dogs',
- 'Hallo Sabrina,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA: Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Trainingspläne, ohne App Store.\n\nIch schreibe dir, weil Gerda und Tildi und der Ansatz "Rescue-Dogs + aktives Leben draußen" perfekt zu dem passt, wofür Ban Yaro steht. Der Giftköder-Alarm ist gerade für Wanderungen und Outdoor-Touren besonders praktisch — Nutzer melden Funde in Echtzeit, der nächste auf dem Trail sieht es sofort.\n\nMein konkretes Angebot:\n\nWer sich mit deinem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42"), dauerhaft im Profil sichtbar. Genau 100 Plätze, kein zweites Mal.\n\nDu bekommst: Partner-Badge, eigener Code, Platz im Ranking.\n\nKein verpflichtender Post — du kannst es teilen, wenn es sich für dich richtig anfühlt.\n\nMehr: https://banyaro.app/partner\n\nBei Interesse einfach kurz antworten — Code ist in 24 h fertig.\n\nViele Grüße,\nRené\nbanyaro.app',
- 'partner'
- ),
- (
- 'influencer_heimatherzen',
- 'Influencer: @heimatherzen',
- 'Ban Yaro — Gründer-Plätze für Blue, Sky und ihre Community',
- 'Hallo Svenja und Simon,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA (kein App Store): Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Training.\n\nIch schreibe euch, weil @heimatherzen für mich das zeigt, was Ban Yaro im Kern ist: Hund als fester Teil des Alltags, nicht als Hobby nebenbei. Blue und Sky, das Münsterland, die ehrliche Art wie ihr eure Community mitnehmt — das passt.\n\nMein Angebot:\n\nWer sich mit eurem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42"), dauerhaft sichtbar. 100 Plätze, einmalig.\n\nIhr bekommt: Partner-Badge, eigener Code, Platz im Ranking.\n\nKein verpflichtender Post, kein Budget.\n\nMehr: https://banyaro.app/partner\n\nWenn ihr mögt, einfach kurz antworten — ich richte alles in 24 h ein.\n\nViele Grüße,\nRené\nbanyaro.app',
- 'partner'
- ),
- (
- 'influencer_pfotentick',
- 'Influencer: @pfotentick',
- 'Ban Yaro — Kooperationsanfrage für die Labrador-Community',
- 'Hallo Daniel und Jeanette,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA: Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Trainingspläne.\n\nIch habe gesehen, dass ihr professionell mit Marken zusammenarbeitet und ein Media-Kit habt — daher schreibe ich euch direkt und konkret:\n\nMein Angebot:\n\nWer sich mit eurem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42"), dauerhaft im Profil. Exakt 100 Plätze, kein zweites Mal.\n\nIhr bekommt: Partner-Badge in der App, eigener Code, Platz im öffentlichen Ranking.\n\nKein Pflicht-Post, kein Vorab-Budget — aber eine echte Exklusivität für eure Labrador-Community.\n\nDetails: https://banyaro.app/partner\n\nBei Interesse meldet euch kurz — Code ist in 24 h eingerichtet.\n\nViele Grüße,\nRené\nbanyaro.app',
- 'partner'
- ),
- (
- 'influencer_flummis_diary',
- 'Influencer: @flummis_diary (AT)',
- 'Ban Yaro — für die "Vollzeitjob und Hund"-Community',
- 'Hallo Kerstin,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA: Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Trainingspläne, direkt im Browser ohne App Store.\n\nDeine Nische "Vollzeitjob und trotzdem guter Hundehalter sein" trifft genau die Zielgruppe, für die Ban Yaro gebaut wurde — Menschen, die wenig Zeit haben und trotzdem alles im Blick behalten wollen. Eine schnelle Notiz im Tagebuch, ein Impftermin in der Gesundheitsakte, ein Giftköder-Alarm unterwegs.\n\nMein Angebot:\n\nWer sich mit deinem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — Badge "Gründer #42", dauerhaft sichtbar im Profil. Genau 100 Plätze, österreichische Community darf natürlich mitmachen.\n\nDu bekommst: Partner-Badge, eigener Code, Platz im Ranking.\n\nKein Pflicht-Post — du teilst es, wenn und wie es passt.\n\nMehr: https://banyaro.app/partner\n\nBei Interesse einfach kurz antworten.\n\nViele Grüße,\nRené\nbanyaro.app',
- 'partner'
- ),
- (
- 'influencer_verwolft',
- 'Influencer: @verwolft',
- 'Ban Yaro — Gründer-Aktion für Milos Community',
- 'Hallo Nicole,\n\nich bin René, Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA: Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Training.\n\nDu kennst die Influencer-Seite des Hundemarkts von innen, daher schreibe ich dir etwas direkter: Ban Yaro ist jung, wächst organisch und ich suche Partner, die echte Verbindung zu ihrer Community haben — kein Reichweiten-Kauf.\n\nMein Angebot:\n\nWer sich mit deinem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — Badge "Gründer #42", dauerhaft im Profil. 100 Plätze, einmalig.\n\nDu bekommst: Partner-Badge, eigener Code, Platz im Ranking.\n\nKein verpflichtender Post, kein Budget-Pitch — du entscheidest ob und wie du es teilst.\n\nMehr: https://banyaro.app/partner\n\nWenn es interessant klingt, meld dich kurz — Code in 24 h.\n\nViele Grüße,\nRené\nbanyaro.app',
- 'partner'
- ),
- (
- 'influencer_babybearyuki',
- 'Influencer: @babybearyuki',
- 'Ban Yaro — Gründer-Aktion für BabyBearYukis Community',
- 'Hello,\n\nMein Name ist René, ich bin Entwickler von Ban Yaro — einer kostenlosen Hunde-App als PWA (kein App Store nötig): Tagebuch, Gesundheitsakte, Giftköder-Alarm, Karte und Trainingspläne.\n\nIch wende mich ans Management von BabyBearYuki, da die Kombination Samoyed + Aussie und die starke internationale wie auch deutschsprachige Community sehr gut zu Ban Yaro passt.\n\nMein konkretes Angebot:\n\nWer sich mit dem persönlichen Partnercode registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42"), dauerhaft im Profil sichtbar. Genau 100 Plätze, exklusiv und einmalig.\n\nFür den Account: Partner-Badge in der App, eigener Code, Platz im öffentlichen Ranking.\n\nKein Pflicht-Post, kein vorab Budget — eine echte Exklusivität für die Community.\n\nDetails: https://banyaro.app/partner\n\nBei Interesse gerne kurz melden — Code-Setup dauert 24 h.\n\nViele Grüße,\nRené\nbanyaro.app',
- 'partner'
- ),
- ]
- for key, label, subject, body, from_acc in templates_r2:
- conn.execute(
- "INSERT OR IGNORE INTO email_templates (key, label, subject, body, from_account) VALUES (?, ?, ?, ?, ?)",
- (key, label, subject, body, from_acc)
- )
- except Exception as e:
- logger.warning(f"Migration influencer_templates_r2: {e}")
-
# Job-Bewerbungen + Luna-Probezugang
conn.executescript("""
CREATE TABLE IF NOT EXISTS job_applications (
From 2cc4252120725d01d00f7c70cd79f8cac578d624 Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 18:42:59 +0200
Subject: [PATCH 14/63] =?UTF-8?q?Feature:=20Presseseite=20/presse=20mit=20?=
=?UTF-8?q?Pressemitteilung,=20Screenshots,=20Logos,=20Gr=C3=BCnderfoto?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/main.py | 7 +
backend/static/icons/founder.jpg | Bin 0 -> 515878 bytes
backend/static/presse.html | 319 +++++++++++++++++++++++++++++++
3 files changed, 326 insertions(+)
create mode 100644 backend/static/icons/founder.jpg
create mode 100644 backend/static/presse.html
diff --git a/backend/main.py b/backend/main.py
index d85556a..6eb99a2 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1429,6 +1429,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")
diff --git a/backend/static/icons/founder.jpg b/backend/static/icons/founder.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..5afbfe989c2f177f22a2f5e150c247051ef6cc41
GIT binary patch
literal 515878
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