diff --git a/backend/database.py b/backend/database.py index 8ef5362..e373f02 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1072,6 +1072,19 @@ def _migrate(conn_factory): pass logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.") + # Moderation-Logging: resolved_by/at für forum_reports, verified_by/at/reject für wiki_zuchter + for table, col, typedef in [ + ("forum_reports", "resolved_by", "INTEGER"), + ("forum_reports", "resolved_at", "TEXT"), + ("wiki_zuchter", "verified_by", "INTEGER"), + ("wiki_zuchter", "verified_at", "TEXT"), + ("wiki_zuchter", "reject_reason", "TEXT"), + ]: + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") + except Exception: + pass + # Wiki: Züchter-Verzeichnis conn.executescript(""" CREATE TABLE IF NOT EXISTS wiki_zuchter ( @@ -1631,3 +1644,16 @@ def _migrate(conn_factory): conn.execute("UPDATE training_exercises SET js_exercise_id=? WHERE id=?", (js_id, row['id'])) conn.execute("CREATE INDEX IF NOT EXISTS idx_te_js_id ON training_exercises(js_exercise_id)") logger.info("Migration: training_exercises.js_exercise_id hinzugefügt, 'Fuß' bereinigt.") + + # Hund des Monats — dauerhafte Gewinner-Tabelle + conn.executescript(""" + CREATE TABLE IF NOT EXISTS hund_des_monats_wins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + monat TEXT NOT NULL, + stimmen INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(dog_id, monat) + ); + CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id); + """) 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/routes/admin.py b/backend/routes/admin.py index 09a4127..cd3fee1 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -97,6 +97,40 @@ class ThreadAdminPatch(BaseModel): is_deleted: Optional[int] = None +# ------------------------------------------------------------------ +# GET /api/admin/action-items +# ------------------------------------------------------------------ +@router.get("/action-items") +async def action_items(user=Depends(require_mod)): + with db() as conn: + jobs = conn.execute( + "SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')" + ).fetchone()[0] + breeders = conn.execute( + "SELECT COUNT(*) FROM users WHERE breeder_status='pending'" + ).fetchone()[0] + reports = conn.execute( + "SELECT COUNT(*) FROM forum_reports WHERE resolved=0" + ).fetchone()[0] + fotos = conn.execute( + "SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'" + ).fetchone()[0] + poi_edits = conn.execute( + "SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'" + ).fetchone()[0] + users_today = conn.execute( + "SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')" + ).fetchone()[0] + return { + "jobs_pending": jobs, + "breeder_pending": breeders, + "reports_open": reports, + "fotos_pending": fotos, + "poi_edits_pending": poi_edits, + "users_today": users_today, + } + + # ------------------------------------------------------------------ # GET /api/admin/stats # ------------------------------------------------------------------ diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 74f1c95..c7a9066 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -75,6 +75,21 @@ async def list_dogs(user=Depends(get_current_user)): d = dict(r) d["is_guest"] = True result.append(d) + + # HdM-Siege pro Hund anhängen + if result: + dog_ids = [d["id"] for d in result] + with db() as conn: + wins_rows = conn.execute( + f"SELECT dog_id, monat FROM hund_des_monats_wins WHERE dog_id IN ({','.join('?'*len(dog_ids))}) ORDER BY monat DESC", + dog_ids, + ).fetchall() + wins_map: dict[int, list[str]] = {} + for w in wins_rows: + wins_map.setdefault(w["dog_id"], []).append(w["monat"]) + for d in result: + d["hdm_wins"] = wins_map.get(d["id"], []) + return result diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py index 95b33a9..1357a85 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -1,4 +1,5 @@ """BAN YARO — Moderations-Panel Backend""" +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from database import db from auth import get_current_user @@ -69,17 +70,19 @@ async def mod_stats(user=Depends(require_moderator)): async def mod_reports(user=Depends(require_moderator)): with db() as conn: rows = conn.execute(""" - SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at, - u.name AS melder_name, + SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, + r.created_at, r.resolved_at, + u.name AS melder_name, + m.name AS resolved_by_name, CASE r.target_type WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id) WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id) END AS content_preview FROM forum_reports r LEFT JOIN users u ON u.id=r.user_id - WHERE r.resolved=0 - ORDER BY r.created_at DESC - LIMIT 100 + LEFT JOIN users m ON m.id=r.resolved_by + ORDER BY r.resolved ASC, r.created_at DESC + LIMIT 200 """).fetchall() return [dict(r) for r in rows] @@ -97,8 +100,12 @@ async def mod_resolve_report(rid: int, user=Depends(require_moderator)): raise HTTPException(404, "Meldung nicht gefunden.") new_state = 0 if r["resolved"] else 1 conn.execute( - "UPDATE forum_reports SET resolved=? WHERE id=?", - (new_state, rid) + """UPDATE forum_reports SET resolved=?, resolved_by=?, resolved_at=? + WHERE id=?""", + (new_state, + user["id"] if new_state else None, + datetime.utcnow().isoformat() if new_state else None, + rid) ) return {"ok": True} @@ -189,17 +196,19 @@ async def mod_patch_user(uid: int, data: dict, user=Depends(require_moderator)): async def mod_fotos(user=Depends(require_moderator)): with db() as conn: rows = conn.execute(""" - SELECT s.id, s.foto_url, s.created_at, + SELECT s.id, s.foto_url, s.status, s.created_at, + s.reviewed_at, s.reject_reason, COALESCE(s.rights_confirmed, 0) AS rights_confirmed, - u.name AS user_name, - r.name AS rasse_name, r.slug AS rasse_slug, + u.name AS user_name, + m.name AS reviewed_by_name, + r.name AS rasse_name, r.slug AS rasse_slug, r.foto_url AS aktuell_foto FROM wiki_foto_submissions s LEFT JOIN users u ON u.id = s.user_id + LEFT JOIN users m ON m.id = s.reviewed_by LEFT JOIN wiki_rassen r ON r.id = s.rasse_id - WHERE s.status = 'pending' - ORDER BY s.created_at ASC - LIMIT 50 + ORDER BY s.status ASC, s.created_at ASC + LIMIT 200 """).fetchall() return [dict(r) for r in rows] @@ -228,11 +237,13 @@ async def mod_poi_edits(user=Depends(require_moderator)): SELECT e.id, e.osm_id, e.poi_name, e.field, e.old_value, e.new_value, e.status, e.created_at, e.resolved_at, - u.name AS einreicher_name + u.name AS einreicher_name, + m.name AS mod_name FROM osm_poi_edits e JOIN users u ON u.id = e.user_id + LEFT JOIN users m ON m.id = e.mod_id ORDER BY e.status ASC, e.created_at DESC - LIMIT 100 + LIMIT 200 """).fetchall() return [dict(r) for r in rows] diff --git a/backend/routes/movies.py b/backend/routes/movies.py index 399c583..da6c682 100644 --- a/backend/routes/movies.py +++ b/backend/routes/movies.py @@ -94,6 +94,71 @@ _SEED_FILME = [ {"id": "white-fang", "titel": "Wolfsblut", "originaltitel": "White Fang", "jahr": 1991, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Wolf-Hund-Hybrid", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Ethan Hawke: Ein Wolf-Hund-Hybrid findet in Alaska zwischen Wildnis und Menschenwelt seinen Platz.", "bild_emoji": "❄️", "imdb_rating": 6.7}, {"id": "because-of-winn-dixie","titel": "Winn-Dixie", "originaltitel": "Because of Winn-Dixie", "jahr": 2005, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein einsames Mädchen findet einen streunenden Hund im Supermarkt — und mit ihm eine ganze Gemeinschaft.", "bild_emoji": "🛒", "imdb_rating": 6.4}, {"id": "lassie-2005", "titel": "Lassie (2005)", "jahr": 2005, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Britische Neuverfilmung mit Peter O'Toole. Lassie flieht aus Schottland und findet den langen Weg nach Yorkshire. Atmosphärisch.", "bild_emoji": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "imdb_rating": 6.7}, + # ── Neue Einträge: Animation ────────────────────────────────────── + {"id": "all-dogs-go-to-heaven", "titel": "Alle Hunde kommen in den Himmel", "originaltitel": "All Dogs Go to Heaven", "jahr": 1989, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": True, "beschreibung": "Don Bluth-Klassiker: Gauner-Hund Charlie entkommt dem Himmel und sucht Rache — bis er sich in ein Waisenmädchen verliebt.", "bild_emoji": "😇", "imdb_rating": 6.8}, + {"id": "all-dogs-go-heaven-2", "titel": "Alle Hunde kommen in den Himmel 2", "originaltitel": "All Dogs Go to Heaven 2", "jahr": 1996, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Charlie kehrt aus dem Himmel zurück nach San Francisco — mit Charlie Sheen als Synchronstimme.", "bild_emoji": "😇", "imdb_rating": 5.4}, + {"id": "oliver-and-company", "titel": "Oliver & Co.", "originaltitel": "Oliver & Company", "jahr": 1988, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Verschiedene (Dodger = Mischling)", "stirbt_der_hund": False, "beschreibung": "Disney modernisiert Oliver Twist: Ein obdachloser Kater schließt sich einer Hunde-Gang unter Anführer Dodger im New York der 1980er an.", "bild_emoji": "🎸", "imdb_rating": 6.7, "streaming": "Disney+"}, + {"id": "peanuts-movie", "titel": "Die Peanuts — Der Film", "originaltitel": "The Peanuts Movie", "jahr": 2015, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Snoopy und Charlie Brown auf der großen Leinwand. Perfekt für alle, die mit dem Kultcomic aufgewachsen sind.", "bild_emoji": "✈️", "imdb_rating": 7.0, "streaming": "Disney+"}, + {"id": "clifford-big-red-dog", "titel": "Clifford — Der große rote Hund", "originaltitel": "Clifford the Big Red Dog", "jahr": 2021, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Mischling (riesig)", "stirbt_der_hund": False, "beschreibung": "Der riesige rote Hund aus dem Kinderbuchklassiker kommt ins Kino — Chaos und Herz für die ganze Familie.", "bild_emoji": "🔴", "imdb_rating": 5.4, "streaming": "Amazon Prime"}, + {"id": "101-dalmatians-1996", "titel": "101 Dalmatiner (1996)", "originaltitel": "101 Dalmatians", "jahr": 1996, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Glenn Close als unvergessliche Cruella de Vil im Live-Action-Remake des Disney-Klassikers — böse, schrill und absolut unterhaltsam.", "bild_emoji": "🐾", "imdb_rating": 5.7, "streaming": "Disney+"}, + {"id": "102-dalmatians", "titel": "102 Dalmatiner", "originaltitel": "102 Dalmatians", "jahr": 2000, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Cruella ist scheinbar geläutert — aber die Dalmatiner-Welpen sind wieder in Gefahr. Fortsetzung mit Glenn Close.", "bild_emoji": "🐾", "imdb_rating": 4.9, "streaming": "Disney+"}, + {"id": "lady-and-tramp-2019", "titel": "Susi und Strolch (2019)", "originaltitel": "Lady and the Tramp", "jahr": 2019, "genre": "Familie/Romanze", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney+ Live-Action-Remake mit echten Hunden: Die ikonische Spaghetti-Szene neu inszeniert, herzlich und mit modernem Charme.", "bild_emoji": "🍝", "imdb_rating": 6.4, "streaming": "Disney+"}, + {"id": "my-dog-tulip", "titel": "Mein Hund Tulip", "originaltitel": "My Dog Tulip", "jahr": 2009, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": True, "beschreibung": "Animierter Arthouse-Film nach J.R. Ackerley: Ein eigenbrötlerischer Engländer und die vorbehaltlose Liebe zu seiner Schäferhündin Tulip.", "bild_emoji": "🌷", "imdb_rating": 7.0}, + {"id": "bluey", "titel": "Bluey", "jahr": 2018, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Blue Heeler", "stirbt_der_hund": False, "beschreibung": "Australische Kinder-Animationsserie über eine Blue-Heeler-Familie, die weltweit Eltern und Kinder gleichermaßen begeistert. Meistgeliebte Kinderserie des 21. Jahrhunderts.", "bild_emoji": "💙", "imdb_rating": 9.0, "streaming": "Disney+"}, + {"id": "strays-2023", "titel": "Strays — Lass uns Hunde sein", "originaltitel": "Strays", "jahr": 2023, "genre": "Animation/Komödie", "typ": "film", "hund_rasse": "Mischling / Australian Shepherd", "stirbt_der_hund": False, "beschreibung": "Komplett obszöne Erwachsenen-Trickfilm-Komödie: Verlassener Hund will mit neuen Hundefreunden Rache am Herrchen nehmen. Nicht für Kinder!","bild_emoji": "🤬", "imdb_rating": 5.8, "streaming": "Amazon Prime"}, + # ── Neue Einträge: Familie/Drama ────────────────────────────────── + {"id": "hotel-for-dogs", "titel": "Hotel für Hunde", "originaltitel": "Hotel for Dogs", "jahr": 2009, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Zwei Geschwister verwandeln ein leerstehendes Hotel in ein geheimes Paradies für Straßenhunde. Herzerwärmende Familienunterhaltung.", "bild_emoji": "🏨", "imdb_rating": 5.8}, + {"id": "snow-dogs", "titel": "Snowdogs", "originaltitel": "Snow Dogs", "jahr": 2002, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Siberian Husky", "stirbt_der_hund": False, "beschreibung": "Cuba Gooding Jr. erbt eine Schlittenhunde-Meute in Alaska und muss erst lernen, mit ihnen umzugehen. Leichte Disney-Komödie.", "bild_emoji": "🛷", "imdb_rating": 4.2, "streaming": "Disney+"}, + {"id": "a-dogs-way-home", "titel": "Auf dem Heimweg — Lassie und Ich", "originaltitel": "A Dog's Way Home", "jahr": 2019, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Pit-Bull-Mischling", "stirbt_der_hund": False, "beschreibung": "Hündin Bella verliert sich 400 Meilen von zu Hause entfernt und kämpft sich durch alle Widrigkeiten zurück zu ihrem Herrchen.", "bild_emoji": "🏡", "imdb_rating": 6.7, "streaming": "Netflix"}, + {"id": "fluke", "titel": "Fluke — Das fremde Ich", "originaltitel": "Fluke", "jahr": 1995, "genre": "Drama/Fantasy", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Mann stirbt und wird als Hund wiedergeboren — und kehrt zu seiner Familie zurück. Ungewöhnliches Dramafantasy mit tiefem emotionalem Kern.", "bild_emoji": "🔄", "imdb_rating": 6.2}, + {"id": "zeus-and-roxanne", "titel": "Zeus und Roxanne", "originaltitel": "Zeus and Roxanne", "jahr": 1997, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Hund und ein Delfin werden beste Freunde — und bringen dabei auch ihre Besitzer zusammen. Charmante Familienunterhaltung der 90er.", "bild_emoji": "🐬", "imdb_rating": 5.2}, + {"id": "benji-2018", "titel": "Benji (2018)", "originaltitel": "Benji", "jahr": 2018, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Netflix-Remake des Klassikers: Der streunende Hund Benji rettet erneut Kinder aus gefährlichen Händen — jetzt für eine neue Generation.", "bild_emoji": "🐾", "imdb_rating": 6.3, "streaming": "Netflix"}, + {"id": "ugly-dachshund", "titel": "Der hässliche Dackel", "originaltitel": "The Ugly Dachshund", "jahr": 1966, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutscher Dogge / Dackel", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker: Eine Harlekindogge wird von Dackeln aufgezogen und denkt, sie sei selbst ein Dackel. Harmlose Familienkomödie.", "bild_emoji": "😅", "imdb_rating": 6.4}, + {"id": "shiloh", "titel": "Shiloh — Mein treuer Freund", "originaltitel": "Shiloh", "jahr": 1996, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Junge in West Virginia rettet einen misshandelten Beagle vor seinem brutalen Besitzer — eine Geschichte über Mut und Gewissen.", "bild_emoji": "🌿", "imdb_rating": 6.7}, + {"id": "iron-will", "titel": "Iron Will", "jahr": 1994, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": False, "beschreibung": "Junger Mann rettet die Farm seiner Familie mit einem gewagten Schlittenhunde-Rennen von Kanada nach Minnesota. Inspirierend und episch.", "bild_emoji": "🏆", "imdb_rating": 6.7, "streaming": "Disney+"}, + {"id": "belle-et-sebastien", "titel": "Belle und Sébastien", "originaltitel": "Belle et Sébastien", "jahr": 2013, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Französischer Familienfilm: Ein Waisenjunge und die riesige Berghündin Belle sind beste Freunde in den Alpen des Zweiten Weltkriegs.", "bild_emoji": "🏔️", "imdb_rating": 7.0}, + {"id": "dog-of-flanders", "titel": "Ein Hund von Flandern", "originaltitel": "A Dog of Flanders", "jahr": 1999, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Bouvier des Flandres", "stirbt_der_hund": True, "beschreibung": "Bewegende Verfilmung des Klassikers: Ein armer Junge in Belgien und sein Hund träumen von Kunst und Würde — mit tragischem Ende.", "bild_emoji": "🎨", "imdb_rating": 7.0}, + {"id": "underdog-2007", "titel": "Underdog — Ein Held auf vier Pfoten", "originaltitel": "Underdog", "jahr": 2007, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Laborhund erhält Superkräfte und wird zum maskierten Superhelden der Stadt. Leichte Disney-Familienkomödie nach der Zeichentrickserie.", "bild_emoji": "🦸", "imdb_rating": 5.1}, + {"id": "bingo-1991", "titel": "Bingo", "jahr": 1991, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Zirkushund reist quer durch Amerika, um seinen jungen Herrchen zu finden. Kindheitserinnerung der frühen 90er.", "bild_emoji": "🎪", "imdb_rating": 5.8}, + {"id": "goodbye-my-lady", "titel": "Leb wohl, Lady", "originaltitel": "Goodbye, My Lady", "jahr": 1956, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Basenji", "stirbt_der_hund": False, "beschreibung": "Ein Junge im Mississippi-Sumpfland findet einen seltsamen lachenden Hund — und muss ihn am Ende zurückgeben. Zeitloser Klassiker.", "bild_emoji": "🌊", "imdb_rating": 7.1}, + {"id": "mitt-liv-som-hund", "titel": "Mein Leben als Hund", "originaltitel": "Mitt liv som hund", "jahr": 1985, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Lasse Hallströms schwedisches Meisterwerk: Ein Junge wird aufs Land geschickt und vergleicht sein Leben mit dem Schicksal von Laika.", "bild_emoji": "🇸🇪", "imdb_rating": 7.8}, + {"id": "homeward-bound-2", "titel": "Auf dem Weg nach Hause 2 — Im Großstadtdschungel", "originaltitel": "Homeward Bound II: Lost in San Francisco", "jahr": 1996, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Die Tiere aus Teil 1 verirren sich in San Francisco und müssen erneut den Weg nach Hause finden — diesmal durch die Stadt.", "bild_emoji": "🌉", "imdb_rating": 6.0, "streaming": "Disney+"}, + {"id": "alpha-2018", "titel": "Alpha", "jahr": 2018, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Wolf", "stirbt_der_hund": False, "beschreibung": "Vor 20.000 Jahren: Ein junger Jäger freundet sich mit einem verletzten Wolf an und legt damit den Grundstein für die Mensch-Hund-Beziehung.", "bild_emoji": "🐺", "imdb_rating": 6.7, "streaming": "Amazon Prime"}, + {"id": "nankyoku-monogatari", "titel": "Antarktis", "originaltitel": "Nankyoku Monogatari", "jahr": 1983, "genre": "Drama/Abenteuer", "typ": "film", "hund_rasse": "Sakhalin Husky", "stirbt_der_hund": True, "beschreibung": "Japanisches Meisterwerk: 15 Schlittenhunde werden 1958 in der Antarktis zurückgelassen — die wahre Geschichte zweier Überlebender.", "bild_emoji": "🇯🇵", "imdb_rating": 7.7}, + # ── Neue Einträge: Komödie ──────────────────────────────────────── + {"id": "cats-and-dogs", "titel": "Cats & Dogs", "jahr": 2001, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle / Verschiedene", "stirbt_der_hund": False, "beschreibung": "Geheimdienstagenten auf vier Pfoten: Hunde gegen Katzen im Kampf um die Weltherrschaft. Spionagefilm-Parodie für die ganze Familie.", "bild_emoji": "🐱", "imdb_rating": 5.2}, + {"id": "cats-and-dogs-2", "titel": "Cats & Dogs 2 — Die Rache der Kitty Kahlohr", "originaltitel": "Cats & Dogs: The Revenge of Kitty Galore", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Hunde und Katzen müssen kooperieren, um eine wahnsinnige Superschurkin-Katze zu stoppen.", "bild_emoji": "😾", "imdb_rating": 4.1}, + {"id": "shaggy-dog-1959", "titel": "Der zottige Hund", "originaltitel": "The Shaggy Dog", "jahr": 1959, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Alter Ungarischer Hirtenhund (Sheepdogähnlich)", "stirbt_der_hund": False, "beschreibung": "Disney-Familienklassiker: Ein Teenager verwandelt sich durch einen magischen Ring immer wieder in einen Hund. Mit Fred MacMurray.", "bild_emoji": "✨", "imdb_rating": 6.3}, + {"id": "shaggy-dog-2006", "titel": "The Shaggy Dog (2006)", "originaltitel": "The Shaggy Dog", "jahr": 2006, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bobtail (Alter Englischer Schäferhund)", "stirbt_der_hund": False, "beschreibung": "Tim Allen als Staatsanwalt, der sich in einen Hund verwandelt — modernes Remake des Disney-Klassikers mit Slapstick-Humor.", "bild_emoji": "✨", "imdb_rating": 5.2, "streaming": "Disney+"}, + {"id": "marmaduke-2010", "titel": "Marmaduke", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutsche Dogge", "stirbt_der_hund": False, "beschreibung": "Die riesige Comic-Dogge Marmaduke zieht nach Kalifornien und mischt die dortige Hunde-Gesellschaft auf. Lockerleichte Familienkomödie.", "bild_emoji": "😬", "imdb_rating": 4.6}, + {"id": "show-dogs", "titel": "Show Dogs", "jahr": 2018, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Rottweiler", "stirbt_der_hund": False, "beschreibung": "Ein Polizei-Rottweiler muss undercover bei einer Hundeschau ermitteln. Kindliche Spionagekomödie mit Will Arnett.", "bild_emoji": "🏅", "imdb_rating": 4.3}, + {"id": "must-love-dogs", "titel": "Must Love Dogs", "jahr": 2005, "genre": "Romanze/Komödie", "typ": "film", "hund_rasse": "Neufundländer", "stirbt_der_hund": False, "beschreibung": "Romantische Komödie: Eine frisch Geschiedene sucht online die Liebe — und ein Hund spielt dabei die entscheidende Rolle. Mit Diane Lane.", "bild_emoji": "💕", "imdb_rating": 6.0}, + {"id": "best-in-show", "titel": "Best in Show", "jahr": 2000, "genre": "Komödie/Mockumentary", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Christopher Guest's genialer Mockumentary über völlig überdrehte Hundeshow-Besucher beim Mayflower Dog Show — vernichtende Satire auf Hundebesitzer.", "bild_emoji": "🎭", "imdb_rating": 7.8}, + {"id": "wiener-dog", "titel": "Wiener-Dog", "jahr": 2016, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Dackel", "stirbt_der_hund": True, "beschreibung": "Todd Solondz' episodischer Indie-Film: Ein kleiner Dackel wandert durch mehrere skurrile Menschenleben. Dunkel, philosophisch, preisgekrönt.", "bild_emoji": "🌭", "imdb_rating": 6.6}, + # ── Neue Einträge: Action/Thriller ─────────────────────────────── + {"id": "mans-best-friend-1993", "titel": "Man's Best Friend", "originaltitel": "Man's Best Friend", "jahr": 1993, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Mastiff", "stirbt_der_hund": True, "beschreibung": "Ein genetisch manipulierter Kettenhund bricht aus einem Labor aus und entpuppt sich als tödliche Bedrohung. B-Movie-Horrorklassiker.", "bild_emoji": "🧬", "imdb_rating": 5.1}, + {"id": "white-dog-1982", "titel": "White Dog", "originaltitel": "White Dog", "jahr": 1982, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Samuel Fullers politisch brisanter Film: Ein weißer Schäferhund wurde auf schwarze Menschen abgerichtet — und soll nun umtrainiert werden.", "bild_emoji": "⚪", "imdb_rating": 7.2}, + {"id": "hound-of-baskervilles", "titel": "Der Hund von Baskerville", "originaltitel": "The Hound of the Baskervilles", "jahr": 1939, "genre": "Krimi/Thriller", "typ": "film", "hund_rasse": "Fantastisches Wesen", "stirbt_der_hund": False, "beschreibung": "Basil Rathbone als Sherlock Holmes in der besten Verfilmung des Doyle-Klassikers — die unheilvolle Legende des Dartmoor-Hundes.", "bild_emoji": "🔦", "imdb_rating": 7.4}, + # ── Neue Einträge: Japan/International ─────────────────────────── + {"id": "mari-to-koinu", "titel": "Mari und ihr Hundewelpe", "originaltitel": "Mari to koinu no monogatari", "jahr": 2007, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Shiba Inu", "stirbt_der_hund": False, "beschreibung": "Wahre Geschichte: Eine Shiba-Inu-Mutter rettet nach einem Erdbeben in den japanischen Alpen ihr Herrchen und ihre Welpen.", "bild_emoji": "🏔️", "imdb_rating": 7.2}, + {"id": "ginga-nagareboshi-gin", "titel": "Ginga: Nagareboshi Gin", "originaltitel": "Ginga: Nagareboshi Gin", "jahr": 1986, "genre": "Anime/Abenteuer", "typ": "serie", "hund_rasse": "Akita Inu", "stirbt_der_hund": False, "beschreibung": "Legendärer japanischer Anime: Silber, ein junger Akita, kämpft gegen einen gigantischen Bären — packende Shonen-Klassikerserie der 80er.", "bild_emoji": "⭐", "imdb_rating": 8.0}, + # ── Neue Einträge: Serien ────────────────────────────────────────── + {"id": "dog-whisperer", "titel": "Der Hundeflüsterer", "originaltitel": "Dog Whisperer with Cesar Millan", "jahr": 2004, "genre": "Dokumentation/Reality", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Cesar Millan rehabilitiert Problemhunde und schult deren Besitzer. Legendäre Reality-Serie, die das Hundetraining nachhaltig beeinflusst hat.", "bild_emoji": "🤫", "imdb_rating": 7.8}, + {"id": "its-me-or-the-dog", "titel": "Ich oder der Hund", "originaltitel": "It's Me or the Dog", "jahr": 2005, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Victoria Stilwell bringt unerzogenen Hunden Manieren bei — mit Positiver Verstärkung statt Dominanztheorie. Britische Erfolgsrealityshow.", "bild_emoji": "💪", "imdb_rating": 7.2}, + {"id": "lucky-dog", "titel": "Lucky Dog", "originaltitel": "Lucky Dog", "jahr": 2013, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Trainer Brandon McMillan rettet Todestrakt-Hunde aus Tierheimen und trainiert sie innerhalb einer Woche als perfekte Familienhunde.", "bild_emoji": "🌟", "imdb_rating": 7.5}, + {"id": "dog-impossible", "titel": "Dog: Impossible", "originaltitel": "Dog: Impossible", "jahr": 2019, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matt Beisner arbeitet mit aggressiven und traumatisierten Hunden, die andere aufgegeben haben. National Geographic's bewegende Trainerserie.", "bild_emoji": "🙏", "imdb_rating": 8.1}, + {"id": "belle-sebastian-anime", "titel": "Belle und Sebastian (Anime)", "originaltitel": "Belle et Sébastien", "jahr": 1984, "genre": "Anime/Familie", "typ": "serie", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Japanische Zeichentrickserie der NHK: Sébastien und sein riesiger weißer Hund Belle in den Alpen — eine Lieblingsserie mehrerer Generationen.", "bild_emoji": "⛰️", "imdb_rating": 7.5}, + {"id": "the-dog-house", "titel": "The Dog House", "originaltitel": "The Dog House", "jahr": 2019, "genre": "Dokumentation", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Wood Green Animal Shelter: Zuschauer beobachten, wie Hunde und Menschen füreinander bestimmt werden. Britische Kultdoku.", "bild_emoji": "🏡", "imdb_rating": 8.2}, + {"id": "puppy-dog-pals", "titel": "Puppy Dog Pals", "originaltitel": "Puppy Dog Pals", "jahr": 2017, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Disney Junior-Serie: Zwei Möpse erleben täglich Abenteuer in der Nachbarschaft, während ihr Herrchen weg ist. Ideal für Kleinkinder.", "bild_emoji": "🐾", "imdb_rating": 7.4, "streaming": "Disney+"}, + {"id": "dogs-of-berlin", "titel": "Dogs of Berlin", "jahr": 2018, "genre": "Krimi/Drama", "typ": "serie", "hund_rasse": "Kampfhund", "stirbt_der_hund": False, "beschreibung": "Netflix Deutschland-Originalserie: Zwei Berliner Ermittler aus verschiedenen Welten jagen einen Mörder — düster, social, brutal ehrlich.", "bild_emoji": "🐕", "imdb_rating": 7.4, "streaming": "Netflix"}, + # ── Neue Einträge: Dokumentationen ──────────────────────────────── + {"id": "the-champions-2015", "titel": "The Champions", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Bewegende Doku über die Pitbulls von Michael Vick's Dogfighting-Ring — und ihre erstaunliche Rehabilitation durch engagierte Retter.", "bild_emoji": "💪", "imdb_rating": 7.8}, + {"id": "one-nation-under-dog", "titel": "One Nation Under Dog", "originaltitel": "One Nation Under Dog", "jahr": 2012, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "HBO-Dokumentation über die komplexe Beziehung der Amerikaner zu Hunden — von Tierheimen bis Luxus-Hundesalons.", "bild_emoji": "🇺🇸", "imdb_rating": 7.4}, + {"id": "dogs-on-the-inside", "titel": "Dogs on the Inside", "originaltitel": "Dogs on the Inside", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Gefangene in einem Hochsicherheitsgefängnis trainieren Tierheim-Hunde — eine Geschichte über Mitgefühl, Verantwortung und zweite Chancen.", "bild_emoji": "🔒", "imdb_rating": 7.6}, + {"id": "wonderdog-2023", "titel": "Wonderdog", "originaltitel": "Wonderdog", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Doku über die außergewöhnlichen Fähigkeiten von Hunden: Was können sie wirklich riechen, hören und fühlen? Wissenschaft trifft Herz.", "bild_emoji": "🔬", "imdb_rating": 7.1, "streaming": "Netflix"}, + {"id": "the-supervet", "titel": "The Supervet", "originaltitel": "The Supervet", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Noel Fitzpatrick, den visionären Veterinärchirurgen, der unheilbar verletzte Tiere mit Hightech-Prothesen rettet.", "bild_emoji": "🦾", "imdb_rating": 8.7}, + {"id": "off-the-chain", "titel": "Off the Chain", "originaltitel": "Off the Chain", "jahr": 2004, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über Pit Bulls in Amerika — zwischen Missbrauch, Dogfighting-Kultur und den Menschen, die für sie kämpfen.", "bild_emoji": "⛓️", "imdb_rating": 7.2}, + {"id": "war-dog-soldier", "titel": "War Dog: A Soldier's Best Friend", "originaltitel": "War Dog: A Soldier's Best Friend", "jahr": 2017, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "National Geographic-Doku über Militärhunde und die unzerstörbare Bindung zu ihren Hundeführern — von der Ausbildung bis zum Einsatz.", "bild_emoji": "🎖️", "imdb_rating": 7.8}, ] _SEED_PROMIS = [ @@ -111,27 +176,25 @@ _SEED_PROMIS = [ def seed_movies(): - """Füllt die movies-Tabelle beim ersten Start (idempotent per INSERT OR IGNORE).""" + """Füllt die movies-Tabelle mit allen Seed-Einträgen (idempotent per INSERT OR IGNORE).""" import logging logger = logging.getLogger(__name__) with db() as conn: - 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.") + for i, f in enumerate(_SEED_FILME): + conn.execute(""" + INSERT OR IGNORE INTO movies + (id, titel, originaltitel, jahr, genre, typ, hund_rasse, + stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, + streaming, sort_order) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + f["id"], f["titel"], f.get("originaltitel"), + f.get("jahr"), f.get("genre"), f.get("typ", "film"), + f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0, + f.get("beschreibung"), f.get("bild_emoji", "🐾"), + f.get("imdb_rating"), f.get("streaming"), i, + )) + logger.info(f"movies: seed_movies() ausgeführt, {len(_SEED_FILME)} Einträge in der Liste.") # ------------------------------------------------------------------ @@ -180,7 +243,7 @@ _SORT_COLS = { "jahr_asc": "m.jahr ASC", "imdb": "m.imdb_rating DESC", "bewertung": "community_avg DESC", - "default": "m.sort_order ASC, m.jahr DESC", + "default": "CASE WHEN m.imdb_rating IS NULL THEN 1 ELSE 0 END, m.imdb_rating DESC, m.jahr DESC", } @router.get("/filme") @@ -323,6 +386,34 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)): return {"monat": monat, "top": [dict(r) for r in rows], "user_vote": user_vote} +@router.get("/hund-des-monats/kandidaten") +async def get_hdm_kandidaten(user=Depends(get_current_user)): + """Alle öffentlichen Hunde anderer User, mit aktuellem Stimmenstand.""" + monat = datetime.now().strftime("%Y-%m") + with db() as conn: + rows = conn.execute(""" + SELECT d.id, d.name, d.rasse, d.foto_url, + u.name AS besitzer_name, + COALESCE(v.stimmen, 0) AS stimmen + FROM dogs d + JOIN users u ON u.id = d.user_id + LEFT JOIN ( + SELECT dog_id, COUNT(*) AS stimmen + FROM hund_des_monats_votes + WHERE monat = ? + GROUP BY dog_id + ) v ON v.dog_id = d.id + WHERE d.is_public = 1 + AND d.user_id != ? + ORDER BY + CASE WHEN d.foto_url IS NOT NULL THEN 0 ELSE 1 END, + stimmen DESC, + d.name ASC + LIMIT 60 + """, (monat, user["id"])).fetchall() + return [dict(r) for r in rows] + + @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") @@ -330,7 +421,9 @@ async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_ dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone() if not dog: raise HTTPException(404, "Hund nicht gefunden.") - if dog["user_id"] != user["id"] and not dog["is_public"]: + if dog["user_id"] == user["id"]: + raise HTTPException(403, "Du kannst nicht für deinen eigenen Hund abstimmen.") + if not dog["is_public"]: raise HTTPException(403, "Dieser Hund ist nicht öffentlich.") conn.execute(""" INSERT INTO hund_des_monats_votes (user_id, dog_id, monat) diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index 85eb624..4fbd03c 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -6,7 +6,7 @@ import smtplib import ssl from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart -from email.utils import formataddr +from email.utils import formataddr, formatdate from datetime import datetime from typing import List, Optional @@ -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=False) # UTC explizit, Container hat keine lokale TZ msg["Subject"] = subject msg["From"] = formataddr((acc["name"], acc["from"])) msg["To"] = to @@ -97,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: @@ -255,7 +267,7 @@ def send_support_mail(to: str, subject: str, body: str): def outreach_log_endpoint(user=Depends(require_admin)): with db() as conn: rows = conn.execute( - """SELECT ol.id, ol.recipient, ol.subject, ol.sent_at, + """SELECT ol.id, ol.recipient, ol.subject, ol.body, ol.sent_at, ol.from_account, u.name AS sent_by_name FROM outreach_log ol JOIN users u ON u.id = ol.sent_by diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index 83093d7..bf3c19c 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -694,11 +694,12 @@ async def list_zuchter_pending(user=Depends(get_current_user)): raise HTTPException(403, "Nur Moderatoren.") with db() as conn: rows = conn.execute( - """SELECT z.*, u.name AS user_name + """SELECT z.*, u.name AS user_name, m.name AS verified_by_name FROM wiki_zuchter z LEFT JOIN users u ON u.id = z.user_id - WHERE z.verified=0 - ORDER BY z.created_at ASC""", + LEFT JOIN users m ON m.id = z.verified_by + ORDER BY z.verified ASC, z.created_at ASC + LIMIT 200""", ).fetchall() return [dict(r) for r in rows] @@ -716,8 +717,10 @@ async def verify_zuchter(zuchter_id: int, user=Depends(get_current_user)): ).fetchone() if not row: raise HTTPException(404, "Züchter nicht gefunden.") + from datetime import datetime conn.execute( - "UPDATE wiki_zuchter SET verified=1 WHERE id=?", (zuchter_id,) + "UPDATE wiki_zuchter SET verified=1, verified_by=?, verified_at=? WHERE id=?", + (user["id"], datetime.utcnow().isoformat(), zuchter_id) ) result = conn.execute( "SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,) diff --git a/backend/scheduler.py b/backend/scheduler.py index d87ef3f..68a4c07 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -100,6 +100,14 @@ def start(): replace_existing=True, misfire_grace_time=1800, ) + # Täglich 12:00 — Moderation-Overdue-Check + _scheduler.add_job( + _job_moderation_overdue, + CronTrigger(hour=12, minute=0), + id="moderation_overdue", + replace_existing=True, + misfire_grace_time=1800, + ) # 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht _scheduler.add_job( _job_quarterly_report, @@ -116,8 +124,16 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # 1. des Monats 00:05 — Hund des Monats Sieger festlegen + _scheduler.add_job( + _job_hdm_winner, + CronTrigger(day=1, hour=0, minute=5), + id="hdm_winner", + replace_existing=True, + misfire_grace_time=3600, + ) _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, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -650,6 +666,115 @@ async def _job_ki_health_report(): # ------------------------------------------------------------------ +async def _job_moderation_overdue(): + """Sendet Alarm-Mail wenn Moderations-Einträge seit >24h offen sind.""" + import os + from mailer import send_email + + admin = os.getenv("ADMIN_EMAIL", "") + if not admin: + return + + SLA_H = 24 + threshold = f"datetime('now', '-{SLA_H} hours')" + + overdue = {} + try: + with db() as conn: + n = conn.execute(f"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing') AND created_at < {threshold}").fetchone()[0] + if n: overdue["Bewerbungen"] = n + n = conn.execute(f"SELECT COUNT(*) FROM users WHERE breeder_status='pending' AND created_at < {threshold}").fetchone()[0] + if n: overdue["Züchter-Anträge"] = n + n = conn.execute(f"SELECT COUNT(*) FROM forum_reports WHERE resolved=0 AND created_at < {threshold}").fetchone()[0] + if n: overdue["Forum-Meldungen"] = n + n = conn.execute(f"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending' AND created_at < {threshold}").fetchone()[0] + if n: overdue["Foto-Einreichungen"] = n + n = conn.execute(f"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending' AND created_at < {threshold}").fetchone()[0] + if n: overdue["POI-Korrekturen"] = n + n = conn.execute(f"SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0 AND created_at < {threshold}").fetchone()[0] + if n: overdue["Züchter-Einreichungen (Wiki)"] = n + except Exception as e: + logger.error(f"Moderation-Overdue-Check: DB-Fehler: {e}") + return + + if not overdue: + logger.info("Moderation-Overdue-Check: Alles im SLA.") + return + + now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M") + rows_html = "".join( + f'{label}' + f'{count}' + for label, count in overdue.items() + ) + html = f"""\ + + +
+
+
⚠️ Moderation überfällig
+
{now_str} · SLA: {SLA_H}h
+
+
+

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

+ + + + + + {rows_html} +
BereichAnzahl
+
+ + → Admin-Panel öffnen + +
+
+
+ Ban Yaro · banyaro.app +
+
""" + + plain = f"Ban Yaro — Moderation überfällig ({now_str})\n\nSeit >{SLA_H}h offen:\n" + \ + "\n".join(f" • {l}: {c}" for l, c in overdue.items()) + \ + "\n\nhttps://banyaro.app/app/admin" + + try: + await send_email(admin, f"⚠️ Ban Yaro — Moderation überfällig ({', '.join(overdue)})", html, plain) + logger.info(f"Moderation-Overdue-Mail gesendet: {overdue}") + except Exception as e: + logger.error(f"Moderation-Overdue-Mail fehlgeschlagen: {e}") + + +def _action_items_html(metrics: dict) -> str: + items = [ + ("jobs_pending", "Bewerbungen offen"), + ("breeder_pending", "Züchter-Anträge"), + ("reports_open", "Forum-Meldungen"), + ("fotos_pending", "Foto-Einreichungen"), + ("poi_edits_pending", "POI-Korrekturen"), + ] + open_items = [(label, metrics.get(key, 0)) for key, label in items if metrics.get(key, 0) > 0] + + if not open_items: + body = '✅ Alles erledigt — nichts offen' + else: + pills = "".join( + f'' + f'{label} {count}' + for label, count in open_items + ) + body = f'
⚠️ {len(open_items)} Punkt{"e" if len(open_items)!=1 else ""} brauchen deine Aufmerksamkeit
{pills}' + + link = '
→ Admin-Panel öffnen
' + return f'
' \ + f'
Heute zu erledigen
' \ + f'{body}{link}
' + + # JOB: Status-Report per Mail (täglich 06:00 Uhr) # ------------------------------------------------------------------ async def _job_status_report(): @@ -677,6 +802,7 @@ async def _job_status_report(): # Community metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + metrics["users_today"] = conn.execute("SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')").fetchone()[0] metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0] metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0] metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0] @@ -685,6 +811,28 @@ async def _job_status_report(): except Exception: metrics["lost_active"] = 0 + # Action Items + try: + metrics["jobs_pending"] = conn.execute("SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')").fetchone()[0] + except Exception: + metrics["jobs_pending"] = 0 + try: + metrics["breeder_pending"] = conn.execute("SELECT COUNT(*) FROM users WHERE breeder_status='pending'").fetchone()[0] + except Exception: + metrics["breeder_pending"] = 0 + try: + metrics["reports_open"] = conn.execute("SELECT COUNT(*) FROM forum_reports WHERE resolved=0").fetchone()[0] + except Exception: + metrics["reports_open"] = 0 + try: + metrics["fotos_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'").fetchone()[0] + except Exception: + metrics["fotos_pending"] = 0 + try: + metrics["poi_edits_pending"] = conn.execute("SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'").fetchone()[0] + except Exception: + metrics["poi_edits_pending"] = 0 + # Wiki-Interesse try: metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0] @@ -736,6 +884,9 @@ async def _job_status_report():
{now_str} Uhr
+ + {_action_items_html(metrics)} +
Scheduler-Jobs
@@ -749,14 +900,14 @@ async def _job_status_report():
Community
{"".join(f'
{v}
{k}
' for k,v in [ - ("Nutzer",metrics["users"]), + ("Nutzer gesamt",metrics["users"]), + ("Neue Nutzer heute",metrics["users_today"]), ("Hunde",metrics["dogs"]), ("Tagebuch-Einträge",metrics["diary_entries"]), ("Aktive Giftköder",metrics["poison_active"]), ("Vermisste Hunde",metrics["lost_active"]), ("'So einen hab ich'",metrics["interesse_hat"]), ("'Interessiert mich'",metrics["interesse_will"]), - ("Züchter (pending)",metrics["zuchter_pending"]), ])}
@@ -770,19 +921,28 @@ async def _job_status_report(): """ + action_open = [l for k,l in [ + ("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"), + ("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"), + ] if metrics.get(k,0) > 0] plain = f"""Ban Yaro Status-Report — {now_str} +=== HEUTE ZU ERLEDIGEN === +{"✅ Alles erledigt" if not action_open else "⚠️ " + ", ".join(f"{l} ({metrics[k]})" for k,l in [ + ("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"), + ("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"), +] if metrics.get(k,0) > 0)} + === Scheduler-Jobs === {job_rows_txt} === Community === -Nutzer: {metrics['users']} +Nutzer gesamt: {metrics['users']} (+{metrics['users_today']} heute) Hunde: {metrics['dogs']} Tagebuch-Einträge: {metrics['diary_entries']} Aktive Giftköder: {metrics['poison_active']} Vermisste Hunde: {metrics['lost_active']} 'So einen hab ich': {metrics['interesse_hat']} 'Interessiert mich': {metrics['interesse_will']} -Züchter (pending): {metrics['zuchter_pending']} """ try: @@ -958,3 +1118,57 @@ def _compute_milestone(today: date, bday: date, dog_name: str): return titel, text return None + + +# ------------------------------------------------------------------ +# JOB: Hund des Monats — Sieger des Vormonats festlegen +# ------------------------------------------------------------------ +async def _job_hdm_winner(): + """Läuft am 1. des Monats 00:05 und schreibt den Sieger des Vormonats.""" + today = datetime.now(tz=_TZ) + # Vormonat berechnen + first_this = today.replace(day=1) + last_month = (first_this - timedelta(days=1)).replace(day=1) + monat = last_month.strftime("%Y-%m") + + with db() as conn: + # Schon eingetragen? + existing = conn.execute( + "SELECT id FROM hund_des_monats_wins WHERE monat=?", (monat,) + ).fetchone() + if existing: + logger.info(f"HdM-Winner {monat}: bereits eingetragen, übersprungen.") + _log_job("hdm_winner", "ok", f"bereits vorhanden für {monat}") + return + + winner = conn.execute(""" + SELECT v.dog_id, d.name, d.user_id, COUNT(v.id) AS stimmen + FROM hund_des_monats_votes v + JOIN dogs d ON d.id = v.dog_id + WHERE v.monat = ? + GROUP BY v.dog_id + ORDER BY stimmen DESC + LIMIT 1 + """, (monat,)).fetchone() + + if not winner: + logger.info(f"HdM-Winner {monat}: keine Stimmen, kein Sieger.") + _log_job("hdm_winner", "ok", f"keine Stimmen für {monat}") + return + + conn.execute( + "INSERT OR IGNORE INTO hund_des_monats_wins (dog_id, monat, stimmen) VALUES (?, ?, ?)", + (winner["dog_id"], monat, winner["stimmen"]), + ) + + month_label = last_month.strftime("%B %Y") + send_push_to_user(winner["user_id"], { + "type": "hdm_winner", + "title": f"🏆 {winner['name']} ist Hund des Monats!", + "body": f"{winner['name']} hat den {month_label} gewonnen — herzlichen Glückwunsch!", + "data": {"page": "forum"}, + "tag": f"hdm-{monat}", + }) + + logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.") + _log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)") diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 073821a..3582760 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -435,7 +435,7 @@ .form-label { font-size: var(--text-sm); font-weight: var(--weight-medium); - color: var(--c-text-secondary); + color: var(--c-text); } /* iOS Safari: font-size < 16px triggert Auto-Zoom beim Fokus — muss alle Klassen überschreiben */ @@ -4179,6 +4179,19 @@ html.modal-open { text-overflow: ellipsis; max-width: 10rem; /* prevents single pill from being wider than ~160px on mobile */ } +.by-tab-text { + display: inline-block; + white-space: nowrap; + transition: transform 0.3s ease; +} +.by-tab-text.scrolling { + animation: forum-tab-scroll 1.8s ease-in-out 0.3s infinite alternate; + transition: none; +} +@keyframes forum-tab-scroll { + from { transform: translateX(0); } + to { transform: translateX(var(--tab-scroll-px, 0)); } +} /* Category badge (colored pill) */ .forum-category-badge { @@ -4200,6 +4213,59 @@ html.modal-open { .forum-category-badge--tauschboerse { background: #fce7f3; color: #9d174d; } /* Search */ +/* Hund des Monats — kompakte Forum-Kachel */ +.forum-hdm-tile { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: linear-gradient(135deg, var(--c-surface-2) 0%, var(--c-bg) 100%); + border: 1.5px solid var(--c-border-light); + border-radius: var(--radius-lg); + cursor: pointer; + margin-bottom: var(--space-3); + min-width: 0; + transition: border-color .15s, box-shadow .15s; +} +.forum-hdm-tile:hover { border-color: var(--c-primary); box-shadow: var(--shadow-sm); } +.forum-hdm-tile-trophy { font-size: 1.5rem; flex-shrink: 0; } +.forum-hdm-tile-body { + flex: 1; + min-width: 0; +} +.forum-hdm-tile-title { + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--c-text-muted); + text-transform: uppercase; + letter-spacing: .04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.forum-hdm-tile-winner { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.forum-hdm-tile-meta { + font-size: var(--text-xs); + color: var(--c-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.forum-hdm-tile-cta { + flex-shrink: 0; + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--c-primary); + white-space: nowrap; +} + .forum-search-wrap { position: relative; } @@ -4918,6 +4984,25 @@ html.modal-open { } /* Filter-Row */ +.movies-search-row { + position: relative; + padding: var(--space-3) 0 var(--space-1); +} +.movies-search-icon { + position: absolute; + left: var(--space-3); + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: var(--c-text-muted); + pointer-events: none; +} +.movies-search-input { + padding-left: calc(var(--space-3) + 16px + var(--space-2)) !important; + font-size: var(--text-sm); +} + .movies-filter-row { display: flex; gap: var(--space-2); @@ -5156,11 +5241,19 @@ html.modal-open { margin-bottom: var(--space-3); } +/* Kandidaten-Suche */ +.hdm-kandidaten-search { + margin-bottom: var(--space-3); +} + /* Vote-Grid */ .hdm-vote-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-3); + max-height: 340px; + overflow-y: auto; + padding-right: var(--space-1); } .hdm-vote-card { @@ -5972,6 +6065,21 @@ html.modal-open { cursor: pointer; } +/* Hund des Monats — Profil-Badge */ +.dp-hdm-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-3); + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); + color: #78350f; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + letter-spacing: .02em; + box-shadow: 0 1px 3px rgba(0,0,0,.15); +} + /* --- Foto-Editor Modal --- */ .photo-editor { display: flex; flex-direction: column; gap: var(--space-3); align-items: center; } .photo-editor-preview { diff --git a/backend/static/icons/founder.jpg b/backend/static/icons/founder.jpg new file mode 100644 index 0000000..5afbfe9 Binary files /dev/null and b/backend/static/icons/founder.jpg differ diff --git a/backend/static/index.html b/backend/static/index.html index 7b8b567..8356b59 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -90,7 +90,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index beb7069..55a80eb 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 = '583'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '597'; // ← 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/admin.js b/backend/static/js/pages/admin.js index fafba5b..4e89f32 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -48,6 +48,9 @@ window.Page_admin = (() => { // ------------------------------------------------------------------ function _render() { _container.innerHTML = ` + +
+
${TABS.map(t => ` @@ -73,9 +76,68 @@ window.Page_admin = (() => { }); }); + _renderActionItems(); _renderTab(); } + async function _renderActionItems() { + const el = _container.querySelector('#adm-action-items'); + if (!el) return; + let d; + try { d = await API.get('/admin/action-items'); } catch { return; } + + const items = [ + { key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' }, + { key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' }, + { key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' }, + { key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' }, + { key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' }, + ]; + + const open = items.filter(i => d[i.key] > 0); + const usersToday = d.users_today || 0; + + el.innerHTML = ` +
+ + ${UI.icon('check-square')} Zu erledigen + + ${open.length === 0 + ? ` + ${UI.icon('check-circle')} Alles erledigt + ` + : open.map(i => ` + `).join('') + } + + ${UI.icon('user-plus')} ${usersToday} neue Nutzer heute + +
`; + + el.querySelectorAll('[data-action-tab]').forEach(btn => { + btn.addEventListener('click', () => { + _tab = btn.dataset.actionTab; + _container.querySelectorAll('#adm-tabs .by-tab').forEach(b => + b.classList.toggle('active', b.dataset.tab === _tab) + ); + _renderTab(); + }); + }); + } + async function _renderTab() { const el = _container.querySelector('#adm-content'); if (!el) return; @@ -1398,6 +1460,43 @@ window.Page_admin = (() => { // ------------------------------------------------------------------ // TAB: MODERATION // ------------------------------------------------------------------ + function _ageLabel(createdAt) { + if (!createdAt) return ''; + const h = (Date.now() - new Date(createdAt + 'Z').getTime()) / 3600000; + const overdue = h >= 24; + const label = h < 1 ? '<1h' : h < 24 ? `${Math.floor(h)}h` : `${Math.floor(h/24)}d ${Math.floor(h%24)}h`; + return ` + ${overdue ? '⚠️ ' : ''}${label} + `; + } + + function _historySection(label, items, renderItem) { + const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`; + return ` +
+ + ${UI.icon('clock-countdown')} ${items.length} erledigte ${label} + + +
+ ${items.map(item => ` +
+ ${renderItem(item)} +
`).join('')} +
+
`; + } + async function _renderModeration(el) { el.innerHTML = `
@@ -1412,12 +1511,52 @@ window.Page_admin = (() => { async function _loadModeration(el) { el.innerHTML = `
Lade…
`; - const [zuchter, fotos] = await Promise.all([ + const [zuchter, fotos, reports, poiEdits] = await Promise.all([ API.get('/wiki/zuchter/pending').catch(() => []), API.get('/wiki/foto-submissions').catch(() => []), + API.get('/moderation/reports').catch(() => []), + API.get('/moderation/poi-edits').catch(() => []), ]); + const zuchterPending = zuchter.filter(z => !z.verified); + const zuchterDone = zuchter.filter(z => z.verified); + const fotosPending = fotos.filter(f => f.status === 'pending'); + const fotosDone = fotos.filter(f => f.status !== 'pending'); + const reportsPending = reports.filter(r => !r.resolved); + const reportsDone = reports.filter(r => r.resolved); + const poiPending = poiEdits.filter(e => e.status === 'pending'); + const poiDone = poiEdits.filter(e => e.status !== 'pending'); - let html = ''; + const modItems = [ + { label: 'Züchter-Einreichungen', count: zuchterPending.length, icon: 'certificate' }, + { label: 'Foto-Einreichungen', count: fotosPending.length, icon: 'image' }, + { label: 'Forum-Meldungen', count: reportsPending.length, icon: 'warning' }, + { label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' }, + ].filter(i => i.count > 0); + + let html = ` +
+ + ${UI.icon('check-square')} Zu erledigen + + ${modItems.length === 0 + ? ` + ${UI.icon('check-circle')} Alles erledigt + ` + : modItems.map(i => ` + + ${UI.icon(i.icon)} ${i.label} + ${i.count} + `).join('') + } +
`; // --- Züchter-Einreichungen --- html += `

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

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

Keine ausstehenden Einreichungen.

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

Keine ausstehenden Einreichungen.

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

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

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

Keine ausstehenden Foto-Einreichungen.

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

Keine ausstehenden Foto-Einreichungen.

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

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

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

Keine offenen Meldungen.

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

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

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

Keine ausstehenden POI-Korrekturen.

`; + } else { + html += `
+ + + + + + + + + + + + ${poiPending.map((e, i) => ` + + + + + + + + + `).join('')} + +
OrtFeldAltNeuVonAlter
${_esc(e.poi_name || `OSM #${e.osm_id}`)}${_esc(e.field)}${_esc(e.old_value || '—')}${_esc(e.new_value || '—')}${_esc(e.einreicher_name || '?')}${_ageLabel(e.created_at)} + + +
+
`; + } + // POI-History + if (poiDone.length) html += _historySection('POI-Korrekturen', poiDone, + e => `${_esc(e.poi_name||`OSM #${e.osm_id}`)} · + ${_esc(e.field)}: + ${_esc(e.old_value||'—')} → + ${_esc(e.new_value||'—')} · + ${e.status==='approved' ? `${UI.icon('check-circle')} freigegeben` : `${UI.icon('x-circle')} abgelehnt`} + ${e.mod_name ? ` von ${_esc(e.mod_name)}` : ''} · ${(e.resolved_at||'').slice(0,10)}`); + el.innerHTML = html; // Züchter freigeben @@ -1520,6 +1770,41 @@ window.Page_admin = (() => { await _loadModeration(el); }); }); + + // Forum-Meldung erledigen + el.querySelectorAll('.adm-mod-resolve').forEach(btn => { + btn.addEventListener('click', async () => { + btn.disabled = true; + try { + await API.patch(`/moderation/reports/${btn.dataset.rid}`, {}); + await _loadModeration(el); + } catch (e) { UI.toast.error(e.message); btn.disabled = false; } + }); + }); + + // POI-Korrektur freigeben + el.querySelectorAll('.adm-poi-approve').forEach(btn => { + btn.addEventListener('click', async () => { + btn.disabled = true; + try { + await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'approve' }); + UI.toast.success('Korrektur übernommen.'); + await _loadModeration(el); + } catch (e) { UI.toast.error(e.message); btn.disabled = false; } + }); + }); + + // POI-Korrektur ablehnen + el.querySelectorAll('.adm-poi-reject').forEach(btn => { + btn.addEventListener('click', async () => { + btn.disabled = true; + try { + await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'reject' }); + UI.toast.success('Korrektur abgelehnt.'); + await _loadModeration(el); + } catch (e) { UI.toast.error(e.message); btn.disabled = false; } + }); + }); } // ------------------------------------------------------------------ @@ -2134,8 +2419,10 @@ window.Page_admin = (() => { - ${log.map(l => ` - + ${log.map((l, i) => ` + ${accountBadge(l.from_account)} ${_esc(l.recipient)} ${_esc(l.subject)} @@ -2149,6 +2436,28 @@ window.Page_admin = (() => {
`; + // Log-Zeile: Mail-Inhalt anzeigen + el.querySelectorAll('tr[data-log-idx]').forEach(row => { + row.addEventListener('click', () => { + const l = log[Number(row.dataset.logIdx)]; + if (!l) return; + UI.modal.open({ + title: _esc(l.subject), + body: ` +
+ An: ${_esc(l.recipient)}  ·  + Von: ${_esc(l.from_account)}@banyaro.app  ·  + ${(l.sent_at||'').slice(0,16).replace('T',' ')} +
+
${_esc(l.body || '(kein Text gespeichert)')}
`, + footer: ``, + }); + }); + }); + // Vorlage in Compose laden function _loadTplIntoCompose(id) { const tpl = templates.find(t => t.id === id); diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 953232e..852237d 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -97,8 +97,19 @@ window.Page_dog_profile = (() => {

${_esc(dog.name)}

${dog.rasse - ? `

${_esc(dog.rasse)}

` - : `

`} + ? `

${_esc(dog.rasse)}

` + : `

`} + + ${(dog.hdm_wins?.length) ? ` +
+ ${dog.hdm_wins.map(m => { + const [y, mo] = m.split('-'); + const label = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }) + .format(new Date(+y, +mo - 1, 1)); + return `🏆 ${label}`; + }).join('')} +
+ ` : `
`}
${KATEGORIEN.map(k => ` + data-kat="${k.key}">${_esc(k.label)} `).join('')} + data-section="map">${UI.icon('users')} Mitgliederkarte
- +
+
+
@@ -127,6 +130,23 @@ window.Page_forum = (() => { const _tabCount = _tabsEl.querySelectorAll('.by-tab').length; _tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2)); + // Marquee-Scroll: nur Tabs animieren, bei denen Text wirklich abgeschnitten ist + _tabsEl.addEventListener('mouseenter', e => { + const btn = e.target.closest('.by-tab'); + const span = btn?.querySelector('.by-tab-text'); + if (!span) return; + const style = getComputedStyle(btn); + const padH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + const overflow = span.scrollWidth - (btn.clientWidth - padH); + if (overflow <= 2) return; + span.style.setProperty('--tab-scroll-px', `-${overflow}px`); + span.classList.add('scrolling'); + }, true); + _tabsEl.addEventListener('mouseleave', e => { + const span = e.target.closest('.by-tab')?.querySelector('.by-tab-text'); + if (span) span.classList.remove('scrolling'); + }, true); + // Tab-Klicks _tabsEl.addEventListener('click', e => { const btn = e.target.closest('[data-kat], [data-section]'); @@ -175,6 +195,177 @@ window.Page_forum = (() => { document.getElementById('forum-rules-btn').addEventListener('click', _showRules); } + // ---------------------------------------------------------- + // Hund des Monats — Kachel + Modal + // ---------------------------------------------------------- + async function _loadHdmCard() { + const card = document.getElementById('forum-hdm-card'); + if (!card) return; + try { + const data = await API.get('/movies/hund-des-monats'); + const [year, month] = data.monat.split('-'); + const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long' }) + .format(new Date(+year, +month - 1, 1)); + const top = data.top?.[0]; + const winnerLine = top + ? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}` + : 'Noch keine Stimmen'; + const metaLine = top + ? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}` + : 'Sei der Erste!'; + + card.innerHTML = ` +
+
🏆
+
+
Hund des Monats · ${_esc(monthName)}
+
${winnerLine}
+
${metaLine}
+
+
${UI.icon('arrow-right')}
+
`; + + document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data)); + } catch { + // Kachel bleibt leer bei Fehler + } + } + + async function _openHdmModal(data) { + try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ } + + const [year, month] = data.monat.split('-'); + const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }) + .format(new Date(+year, +month - 1, 1)); + + const topList = data.top?.length + ? data.top.slice(0, 5).map((dog, i) => { + const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][i]; + const av = dog.foto_url + ? `${_esc(dog.name)}` + : `${_esc(dog.name.charAt(0).toUpperCase())}`; + const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : ''; + return ` +
+ ${medal} +
${av}
+
+
${_esc(dog.name)}
+ ${dog.rasse ? `
${_esc(dog.rasse)}
` : ''} + ${vorname ? `
von ${vorname}
` : ''} +
+
${dog.stimmen} ${UI.icon('star')}
+
`; + }).join('') + : `

Noch keine Stimmen. Sei der Erste!

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

+ Anmelden + um abstimmen zu können. +

+
` + : `
+

Für welchen Hund möchtest du abstimmen?

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

Hund des Monats

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

Top 5 diesen Monat

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

Keine Hunde gefunden.

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

Kandidaten konnten nicht geladen werden.

`; + return; + } + _renderKandidaten(_kandidaten); + + document.getElementById('hdm-search')?.addEventListener('input', e => { + const q = e.target.value.trim().toLowerCase(); + _renderKandidaten(q + ? _kandidaten.filter(d => + (d.name || '').toLowerCase().includes(q) || + (d.rasse || '').toLowerCase().includes(q)) + : _kandidaten + ); + }); + } + // ---------------------------------------------------------- // Threads laden // ---------------------------------------------------------- diff --git a/backend/static/js/pages/movies.js b/backend/static/js/pages/movies.js index 552928d..a36cf73 100644 --- a/backend/static/js/pages/movies.js +++ b/backend/static/js/pages/movies.js @@ -15,6 +15,7 @@ window.Page_movies = (() => { let _filter = 'alle'; let _typ = 'alle'; // alle | film | serie | doku let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung + let _search = ''; // ---------------------------------------------------------- // INIT @@ -41,7 +42,6 @@ window.Page_movies = (() => {
-
`; @@ -66,7 +66,6 @@ window.Page_movies = (() => { if (_activeTab === 'filme') await _renderFilme(content); if (_activeTab === 'promis') _renderPromis(content); - if (_activeTab === 'hdm') await _renderHundDesMonats(content); } // ---------------------------------------------------------- @@ -86,6 +85,11 @@ window.Page_movies = (() => { content.innerHTML = `
+
+ + +
@@ -135,6 +139,11 @@ window.Page_movies = (() => { }); }); + content.querySelector('#movies-search')?.addEventListener('input', e => { + _search = e.target.value.trim().toLowerCase(); + _renderMovieGrid(content.querySelector('#movie-grid')); + }); + content.querySelector('#movies-sort')?.addEventListener('change', async e => { _sort = e.target.value; const grid = content.querySelector('#movie-grid'); @@ -153,6 +162,14 @@ window.Page_movies = (() => { if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund); if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund); if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0); + if (_search) { + list = list.filter(f => + (f.titel || '').toLowerCase().includes(_search) || + (f.hund_rasse || '').toLowerCase().includes(_search) || + (f.genre || '').toLowerCase().includes(_search) || + (f.beschreibung || '').toLowerCase().includes(_search) + ); + } const countEl = document.getElementById('movies-count'); if (countEl) countEl.textContent = `${list.length} Einträge`; diff --git a/backend/static/presse.html b/backend/static/presse.html new file mode 100644 index 0000000..2ea2e54 --- /dev/null +++ b/backend/static/presse.html @@ -0,0 +1,320 @@ + + + + + + Presse – Ban Yaro + + + + + + + +
+ Ban Yaro Icon +
Ban Yaro
+ ← Zur App +
+ +
+ +

Pressematerial

+

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

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

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

+

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

+ +

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

+ +

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

+ +

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

+ +

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

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

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

+
+
+ + +
+ +
+ +

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

+
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ René Degelmann mit Ban Yaro +
+

René Degelmann

+
Gründer & Entwickler, Ban Yaro
+

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

+
+
+

Foto zur redaktionellen Verwendung freigegeben — herunterladen ↓

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

René Degelmann

+

Ringstr. 26 · 85560 Ebersberg

+

Telefon: 0171 1209622

+

E-Mail: partner@banyaro.app

+

Web: banyaro.app

+
+
+ +
+ + + + + diff --git a/backend/static/sw.js b/backend/static/sw.js index e2dcd3f..bc46029 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-v583'; +const CACHE_VERSION = 'by-v597'; 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