From 097295c628d870d74e29504b92d48c1be01d6bbc Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 15 Apr 2026 21:33:53 +0200 Subject: [PATCH] Sprint 11: Freunde & Chat + Phosphor-Icon-Vollmigration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Freundschaften (pending/accepted), Nutzersuche, Anfragen per Push - Direktnachrichten mit Polling, iMessage-Stil, Deep-Links aus Push - Alle Seiten (map, places, diary, health, dog-profile, sitting, knigge, forum, wiki, walks) vollständig auf Phosphor-Icons migriert - Wikidata-Rassen-Scraper (~833 neue Rassen, lokal gespiegelte Fotos) - TheDogAPI lokal gespiegelt (169 Rassen + Fotos) - Quiz-Result-Cards horizontal (korrekte Bildproportionen) - SW by-v89 --- backend/auth.py | 2 +- backend/database.py | 174 +++ backend/main.py | 306 ++++ backend/routes/chat.py | 191 +++ backend/routes/dogs.py | 38 + backend/routes/events.py | 32 +- backend/routes/forum.py | 551 +++++++ backend/routes/friends.py | 148 ++ backend/routes/knigge.py | 113 ++ backend/routes/lost.py | 171 ++ backend/routes/movies.py | 185 +++ backend/routes/wiki.py | 258 +++ backend/scheduler.py | 303 +++- backend/scraper/__init__.py | 0 backend/scraper/breeds.py | 138 ++ backend/scraper/events_vdh.py | 317 ++++ backend/scraper/wikidata_breeds.py | 196 +++ backend/static/css/components.css | 1981 +++++++++++++++++++++++- backend/static/css/layout.css | 15 + backend/static/icons/phosphor.svg | 78 + backend/static/index.html | 80 +- backend/static/js/api.js | 94 +- backend/static/js/app.js | 46 +- backend/static/js/pages/chat.js | 344 ++++ backend/static/js/pages/diary.js | 32 +- backend/static/js/pages/dog-profile.js | 70 +- backend/static/js/pages/events.js | 95 +- backend/static/js/pages/forum.js | 890 +++++++++++ backend/static/js/pages/friends.js | 282 ++++ backend/static/js/pages/health.js | 187 ++- backend/static/js/pages/knigge.js | 413 +++++ backend/static/js/pages/lost.js | 688 ++++++++ backend/static/js/pages/map.js | 243 ++- backend/static/js/pages/movies.js | 409 +++++ backend/static/js/pages/places.js | 60 +- backend/static/js/pages/poison.js | 4 +- backend/static/js/pages/routes.js | 210 ++- backend/static/js/pages/settings.js | 42 +- backend/static/js/pages/sitting.js | 48 +- backend/static/js/pages/walks.js | 48 +- backend/static/js/pages/wiki.js | 687 ++++++++ backend/static/js/ui.js | 16 +- backend/static/sw.js | 24 +- backend/weather.py | 71 + 44 files changed, 9980 insertions(+), 300 deletions(-) create mode 100644 backend/routes/chat.py create mode 100644 backend/routes/forum.py create mode 100644 backend/routes/friends.py create mode 100644 backend/routes/knigge.py create mode 100644 backend/routes/lost.py create mode 100644 backend/routes/movies.py create mode 100644 backend/routes/wiki.py create mode 100644 backend/scraper/__init__.py create mode 100644 backend/scraper/breeds.py create mode 100644 backend/scraper/events_vdh.py create mode 100644 backend/scraper/wikidata_breeds.py create mode 100644 backend/static/icons/phosphor.svg create mode 100644 backend/static/js/pages/chat.js create mode 100644 backend/static/js/pages/forum.js create mode 100644 backend/static/js/pages/friends.js create mode 100644 backend/static/js/pages/knigge.js create mode 100644 backend/static/js/pages/lost.js create mode 100644 backend/static/js/pages/movies.js create mode 100644 backend/static/js/pages/wiki.js create mode 100644 backend/weather.py diff --git a/backend/auth.py b/backend/auth.py index 2247fde..449584b 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -81,7 +81,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator FROM users WHERE id=?", (user_id,) ).fetchone() diff --git a/backend/database.py b/backend/database.py index 4eab006..83eea09 100644 --- a/backend/database.py +++ b/backend/database.py @@ -307,6 +307,23 @@ def init_db(): ); CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon); + -- VERLORENE HUNDE + CREATE TABLE IF NOT EXISTS lost_dogs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + name TEXT NOT NULL, + rasse TEXT, + beschreibung TEXT NOT NULL, + foto_url TEXT, + lat REAL NOT NULL, + lon REAL NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + gefunden_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_lost_active ON lost_dogs(is_active, created_at DESC); + -- OSM Tile-Cache: welche Kacheln wurden schon geladen? CREATE TABLE IF NOT EXISTS osm_tiles ( type TEXT NOT NULL, @@ -401,6 +418,27 @@ def _migrate(conn_factory): ("osm_pois", "opening_hours", "TEXT"), ("osm_pois", "phone", "TEXT"), ("osm_pois", "website", "TEXT"), + # Forum: Threads brauchen text + antworten-Zähler + ("forum_threads", "text", "TEXT NOT NULL DEFAULT ''"), + ("forum_threads", "antworten", "INTEGER NOT NULL DEFAULT 0"), + # Forum Sprint 11: erweiterte Thread-Felder + ("forum_threads", "foto_urls", "TEXT"), + ("forum_threads", "is_pinned", "INTEGER NOT NULL DEFAULT 0"), + ("forum_threads", "is_locked", "INTEGER NOT NULL DEFAULT 0"), + ("forum_threads", "is_deleted", "INTEGER NOT NULL DEFAULT 0"), + ("forum_threads", "likes", "INTEGER NOT NULL DEFAULT 0"), + # Forum Sprint 11: erweiterte Post-Felder + ("forum_posts", "foto_urls", "TEXT"), + ("forum_posts", "is_deleted", "INTEGER NOT NULL DEFAULT 0"), + ("forum_posts", "likes", "INTEGER NOT NULL DEFAULT 0"), + # Users: Moderator-Flag + Forum-Standort + ("users", "is_moderator", "INTEGER NOT NULL DEFAULT 0"), + ("users", "forum_lat", "REAL"), + ("users", "forum_lon", "REAL"), + ("users", "forum_show_location", "INTEGER NOT NULL DEFAULT 0"), + # Events: Quelle + externe ID für gescrapte Events + ("events", "quelle", "TEXT NOT NULL DEFAULT 'nutzer'"), + ("events", "external_id", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -414,6 +452,142 @@ def _migrate(conn_factory): ) logger.info(f"Migration: {table}.{column} hinzugefügt.") + # Knigge: Community-Votes + conn.executescript(""" + CREATE TABLE IF NOT EXISTS knigge_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + szenario_id TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + answer TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(szenario_id, user_id) + ); + """) + + # Forum Sprint 11: neue Tabellen + conn.executescript(""" + CREATE TABLE IF NOT EXISTS forum_likes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_type TEXT NOT NULL, + target_id INTEGER NOT NULL, + UNIQUE(user_id, target_type, target_id) + ); + + CREATE TABLE IF NOT EXISTS forum_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_type TEXT NOT NULL, + target_id INTEGER NOT NULL, + grund TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved INTEGER NOT NULL DEFAULT 0 + ); + """) + + # Wiki: Community-Berichte + conn.executescript(""" + CREATE TABLE IF NOT EXISTS wiki_berichte ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rasse TEXT NOT NULL, + titel TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC); + """) + + # Hunde-Filme: Bewertungen + Hund des Monats + conn.executescript(""" + CREATE TABLE IF NOT EXISTS movie_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + film_id TEXT NOT NULL, + bewertung INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, film_id) + ); + + CREATE TABLE IF NOT EXISTS hund_des_monats_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + monat TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, monat) + ); + """) + + # Events: Unique-Index für externe IDs (idempotent) + conn.executescript(""" + CREATE UNIQUE INDEX IF NOT EXISTS idx_events_external + ON events(external_id) WHERE external_id IS NOT NULL; + """) + + # Freundschaften + Direktnachrichten + conn.executescript(""" + CREATE TABLE IF NOT EXISTS friendships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + addressee_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT, + UNIQUE(requester_id, addressee_id) + ); + CREATE INDEX IF NOT EXISTS idx_friendships_addressee ON friendships(addressee_id, status); + CREATE INDEX IF NOT EXISTS idx_friendships_requester ON friendships(requester_id, status); + + CREATE TABLE IF NOT EXISTS conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_a_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_b_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + last_msg_at TEXT, + a_read_at TEXT, + b_read_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_a_id, user_b_id) + ); + + CREATE TABLE IF NOT EXISTS direct_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + is_deleted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_dm_conv ON direct_messages(conversation_id, created_at ASC); + """) + + # Wiki: Rassen-Datenbank (TheDogAPI) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS wiki_rassen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id INTEGER UNIQUE, + name TEXT NOT NULL, + name_de TEXT, + gruppe TEXT, + herkunft TEXT, + temperament TEXT, + gewicht_min_kg REAL, + gewicht_max_kg REAL, + groesse TEXT, + lebensdauer TEXT, + foto_url TEXT, + bred_for TEXT, + aktivitaet TEXT, + wohnung_geeignet INTEGER DEFAULT 0, + kinder_geeignet INTEGER DEFAULT 1, + erfahrung TEXT DEFAULT 'anfaenger', + slug TEXT UNIQUE, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_wiki_rassen_slug ON wiki_rassen(slug); + CREATE INDEX IF NOT EXISTS idx_wiki_rassen_gruppe ON wiki_rassen(gruppe); + """) + # Datenmigration: diary_dogs für bestehende Einträge befüllen conn.execute(""" INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) diff --git a/backend/main.py b/backend/main.py index 9970fa3..6af39f8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -63,6 +63,13 @@ from routes.walks import router as walks_router from routes.events import router as events_router from routes.sitting import router as sitting_router from routes.osm import router as osm_router +from routes.forum import router as forum_router +from routes.lost import router as lost_router +from routes.knigge import router as knigge_router +from routes.wiki import router as wiki_router +from routes.movies import router as movies_router +from routes.friends import router as friends_router +from routes.chat import router as chat_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -78,6 +85,13 @@ app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Tre app.include_router(events_router, prefix="/api/events", tags=["Events"]) app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"]) app.include_router(osm_router, prefix="/api/osm", tags=["OSM"]) +app.include_router(forum_router, prefix="/api/forum", tags=["Forum"]) +app.include_router(lost_router, prefix="/api/lost", tags=["Verlorener Hund"]) +app.include_router(knigge_router, prefix="/api/knigge", tags=["Knigge"]) +app.include_router(wiki_router, prefix="/api/wiki", tags=["Wiki"]) +app.include_router(movies_router, prefix="/api/movies", tags=["Filme"]) +app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"]) +app.include_router(chat_router, prefix="/api/chat", tags=["Chat"]) # ------------------------------------------------------------------ @@ -131,6 +145,298 @@ async def share_target(request: Request): headers={"Cache-Control": "no-cache"} ) +# Öffentliche Hunde-Profilseite (für NFC-Tags, kein Login nötig) +@app.get("/hund/{dog_id}") +async def public_dog_page(dog_id: int): + html = f""" + + + + + Hunde-Profil — BAN YARO + + + + + +
+
Lade Profil…
+
+ + + + +""" + from fastapi.responses import HTMLResponse + return HTMLResponse(content=html) + + # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/routes/chat.py b/backend/routes/chat.py new file mode 100644 index 0000000..30f9826 --- /dev/null +++ b/backend/routes/chat.py @@ -0,0 +1,191 @@ +"""BAN YARO — Direktnachrichten""" +import logging +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from database import db +from auth import get_current_user + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def _conv_key(a: int, b: int): + """Normalisiert Konversations-User-IDs: user_a < user_b.""" + return (min(a, b), max(a, b)) + + +@router.get("/conversations") +async def list_conversations(user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + rows = conn.execute(""" + SELECT c.id, c.last_msg_at, + CASE WHEN c.user_a_id=? THEN c.user_b_id ELSE c.user_a_id END AS partner_id, + CASE WHEN c.user_a_id=? THEN ub.name ELSE ua.name END AS partner_name, + (SELECT text FROM direct_messages + WHERE conversation_id=c.id AND is_deleted=0 + ORDER BY created_at DESC LIMIT 1) AS last_text, + (SELECT COUNT(*) FROM direct_messages + WHERE conversation_id=c.id + AND sender_id != ? + AND is_deleted=0 + AND created_at > COALESCE( + CASE WHEN c.user_a_id=? THEN c.a_read_at ELSE c.b_read_at END, + '1970-01-01' + ) + ) AS unread_count + FROM conversations c + JOIN users ua ON ua.id=c.user_a_id + JOIN users ub ON ub.id=c.user_b_id + WHERE c.user_a_id=? OR c.user_b_id=? + ORDER BY COALESCE(c.last_msg_at, c.created_at) DESC + """, (uid, uid, uid, uid, uid, uid)).fetchall() + return [dict(r) for r in rows] + + +class StartConvModel(BaseModel): + partner_id: int + + +@router.post("/conversations", status_code=201) +async def start_conversation(data: StartConvModel, user=Depends(get_current_user)): + uid = user["id"] + if uid == data.partner_id: + raise HTTPException(400, "Du kannst dir selbst keine Nachrichten schicken.") + + a, b = _conv_key(uid, data.partner_id) + with db() as conn: + f = conn.execute(""" + SELECT 1 FROM friendships + WHERE ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?)) + AND status='accepted' + """, (uid, data.partner_id, data.partner_id, uid)).fetchone() + if not f: + raise HTTPException(403, "Ihr seid noch keine Freunde.") + + existing = conn.execute( + "SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?", (a, b) + ).fetchone() + if existing: + return {"conversation_id": existing["id"]} + + cur = conn.execute( + "INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)", (a, b) + ) + return {"conversation_id": cur.lastrowid} + + +@router.get("/conversations/{conv_id}") +async def get_messages(conv_id: int, offset: int = 0, limit: int = 50, + user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + conv = conn.execute( + "SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)", + (conv_id, uid, uid) + ).fetchone() + if not conv: + raise HTTPException(404, "Konversation nicht gefunden.") + + partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"] + partner = conn.execute("SELECT name FROM users WHERE id=?", (partner_id,)).fetchone() + + msgs = conn.execute(""" + SELECT m.id, m.sender_id, m.text, m.is_deleted, m.created_at, + u.name AS sender_name + FROM direct_messages m + JOIN users u ON u.id=m.sender_id + WHERE m.conversation_id=? + ORDER BY m.created_at ASC + LIMIT ? OFFSET ? + """, (conv_id, limit, offset)).fetchall() + + return { + "conversation_id": conv_id, + "partner_id": partner_id, + "partner_name": partner["name"] if partner else "Unbekannt", + "messages": [dict(m) for m in msgs], + } + + +class SendMsgModel(BaseModel): + text: str + + +@router.post("/conversations/{conv_id}/messages", status_code=201) +async def send_message(conv_id: int, data: SendMsgModel, user=Depends(get_current_user)): + uid = user["id"] + text = data.text.strip() + if not text: + raise HTTPException(400, "Nachricht darf nicht leer sein.") + if len(text) > 2000: + raise HTTPException(400, "Nachricht zu lang (max. 2000 Zeichen).") + + with db() as conn: + conv = conn.execute( + "SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)", + (conv_id, uid, uid) + ).fetchone() + if not conv: + raise HTTPException(404, "Konversation nicht gefunden.") + + partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"] + + cur = conn.execute(""" + INSERT INTO direct_messages (conversation_id, sender_id, text) VALUES (?,?,?) + """, (conv_id, uid, text)) + msg_id = cur.lastrowid + + conn.execute( + "UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?", + (conv_id,) + ) + + try: + from routes.push import send_push_to_user + preview = text[:100] + ("…" if len(text) > 100 else "") + send_push_to_user(partner_id, { + "title": f"Nachricht von {user['name']}", + "body": preview, + "type": "chat_message", + "tag": f"chat-{conv_id}", + "data": {"page": "chat", "conversation_id": conv_id}, + }) + except Exception: + pass + + return {"id": msg_id, "ok": True} + + +@router.post("/conversations/{conv_id}/read") +async def mark_read(conv_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + conv = conn.execute( + "SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)", + (conv_id, uid, uid) + ).fetchone() + if not conv: + raise HTTPException(404) + field = "a_read_at" if conv["user_a_id"] == uid else "b_read_at" + conn.execute( + f"UPDATE conversations SET {field}=datetime('now') WHERE id=?", + (conv_id,) + ) + return {"ok": True} + + +@router.delete("/messages/{msg_id}") +async def delete_message(msg_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + msg = conn.execute( + "SELECT id FROM direct_messages WHERE id=? AND sender_id=?", (msg_id, uid) + ).fetchone() + if not msg: + raise HTTPException(404, "Nachricht nicht gefunden.") + conn.execute( + "UPDATE direct_messages SET is_deleted=1, text='[gelöscht]' WHERE id=?", + (msg_id,) + ) + return {"ok": True} diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index e59b01d..94849a7 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -7,6 +7,7 @@ from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user +from routes.push import send_push_to_user router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -159,3 +160,40 @@ async def public_dog_profile(dog_id: int): if not dog: raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.") return dict(dog) + + +class FoundReport(BaseModel): + message: Optional[str] = None + kontakt: Optional[str] = None + + +# Gefunden-Meldung (kein Login nötig) +@router.post("/public/{dog_id}/found") +async def report_found(dog_id: int, data: FoundReport = FoundReport()): + with db() as conn: + row = conn.execute( + """SELECT d.id, d.name, d.user_id + FROM dogs d + WHERE d.id=? AND d.is_public=1""", + (dog_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.") + + dog_name = row["name"] + user_id = row["user_id"] + + body = data.message.strip() if data.message and data.message.strip() \ + else "Jemand hat deinen Hund gefunden. Öffne die App für Details." + + if data.kontakt and data.kontakt.strip(): + body += f" Kontakt: {data.kontakt.strip()}" + + send_push_to_user(user_id, { + "title": f"🐾 {dog_name} wurde gefunden!", + "body": body, + "data": {"page": "diary", "found": True}, + "tag": f"found-{dog_id}", + }) + + return {"ok": True} diff --git a/backend/routes/events.py b/backend/routes/events.py index 85e1811..7c4f449 100644 --- a/backend/routes/events.py +++ b/backend/routes/events.py @@ -58,14 +58,24 @@ async def list_events( radius: int = 50000, typ: Optional[str] = None, alle: bool = False, + quelle: Optional[str] = None, ): today = date.today().isoformat() with db() as conn: - q = "SELECT e.*, u.name AS veranstalter_name FROM events e LEFT JOIN users u ON u.id = e.user_id WHERE e.status = 'aktiv'" + q = """ + SELECT e.*, + CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, + e.quelle + FROM events e + LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 + WHERE e.status = 'aktiv' + """ if not alle: q += f" AND e.datum >= '{today}'" if typ and typ in TYPEN: q += f" AND e.typ = '{typ}'" + if quelle: + q += f" AND e.quelle = '{quelle}'" q += " ORDER BY e.datum ASC, e.uhrzeit ASC" rows = conn.execute(q).fetchall() @@ -85,14 +95,14 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)): raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(TYPEN)}") with db() as conn: cur = conn.execute(""" - INSERT INTO events (user_id, titel, datum, uhrzeit, lat, lon, ort_name, typ, beschreibung, link) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO events (user_id, titel, datum, uhrzeit, lat, lon, ort_name, typ, beschreibung, link, quelle) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nutzer') """, (user['id'], data.titel, data.datum, data.uhrzeit, data.lat, data.lon, data.ort_name, data.typ, data.beschreibung, data.link)) row = conn.execute( - "SELECT e.*, u.name AS veranstalter_name FROM events e " - "LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?", + "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle " + "FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?", (cur.lastrowid,) ).fetchone() return dict(row) @@ -105,8 +115,8 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)): async def get_event(event_id: int): with db() as conn: row = conn.execute( - "SELECT e.*, u.name AS veranstalter_name FROM events e " - "LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?", + "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle " + "FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?", (event_id,) ).fetchone() if not row: @@ -123,7 +133,7 @@ async def update_event(event_id: int, data: EventUpdate, user=Depends(get_curren ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone() if not ev: raise HTTPException(404, "Event nicht gefunden.") - if ev['user_id'] != user['id']: + if ev['user_id'] == 0 or ev['user_id'] != user['id']: raise HTTPException(403, "Nur der Veranstalter kann das Event bearbeiten.") updates = data.model_dump(exclude_none=True) if updates: @@ -132,8 +142,8 @@ async def update_event(event_id: int, data: EventUpdate, user=Depends(get_curren cols = ', '.join(f"{k} = ?" for k in updates) conn.execute(f"UPDATE events SET {cols} WHERE id = ?", [*updates.values(), event_id]) row = conn.execute( - "SELECT e.*, u.name AS veranstalter_name FROM events e " - "LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?", + "SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle " + "FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?", (event_id,) ).fetchone() return dict(row) @@ -148,6 +158,6 @@ async def delete_event(event_id: int, user=Depends(get_current_user)): ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone() if not ev: raise HTTPException(404, "Event nicht gefunden.") - if ev['user_id'] != user['id']: + if ev['user_id'] == 0 or ev['user_id'] != user['id']: raise HTTPException(403, "Nur der Veranstalter kann das Event löschen.") conn.execute("UPDATE events SET status = 'geloescht' WHERE id = ?", (event_id,)) diff --git a/backend/routes/forum.py b/backend/routes/forum.py new file mode 100644 index 0000000..2823623 --- /dev/null +++ b/backend/routes/forum.py @@ -0,0 +1,551 @@ +"""BAN YARO — Forum (Sprint 11)""" + +import os, uuid, json +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user, get_current_user_optional + +router = APIRouter() + +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +FORUM_DIR = os.path.join(MEDIA_DIR, "forum") + +KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung', 'tauschboerse'] + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class ThreadCreate(BaseModel): + kategorie: str = 'allgemein' + titel: str + text: str + +class PostCreate(BaseModel): + text: str + +class ThreadPatch(BaseModel): + is_pinned: Optional[int] = None + is_locked: Optional[int] = None + +class LikeBody(BaseModel): + target_type: str # 'thread' | 'post' + target_id: int + +class ReportBody(BaseModel): + target_type: str + target_id: int + grund: str + +class LocationBody(BaseModel): + lat: Optional[float] = None + lon: Optional[float] = None + show: bool = False + +class ResolveReport(BaseModel): + resolved: int = 1 + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ +def _save_upload(file: UploadFile, data: bytes) -> str: + os.makedirs(FORUM_DIR, exist_ok=True) + ext = os.path.splitext(file.filename or "")[1] or ".jpg" + filename = f"{uuid.uuid4().hex}{ext}" + path = os.path.join(FORUM_DIR, filename) + with open(path, "wb") as f: + f.write(data) + return f"/media/forum/{filename}" + +def _parse_foto_urls(raw) -> list: + if not raw: + return [] + try: + return json.loads(raw) + except Exception: + return [] + +def _user_liked(conn, user_id: int, target_type: str, target_id: int) -> bool: + if not user_id: + return False + row = conn.execute( + "SELECT 1 FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?", + (user_id, target_type, target_id) + ).fetchone() + return row is not None + + +# ------------------------------------------------------------------ +# GET /api/forum/threads +# ------------------------------------------------------------------ +@router.get("/threads") +async def list_threads( + kategorie: Optional[str] = None, + search: Optional[str] = None, + limit: int = 30, + offset: int = 0, + user=Depends(get_current_user_optional), +): + uid = user['id'] if user else None + with db() as conn: + q = """ + SELECT t.id, t.kategorie, t.titel, + SUBSTR(t.text, 1, 120) AS text_preview, + t.antworten, t.likes, t.views, + t.is_pinned, t.is_locked, t.foto_urls, + t.created_at, t.user_id, + u.name AS autor_name + FROM forum_threads t + LEFT JOIN users u ON u.id = t.user_id + WHERE t.is_deleted = 0 + """ + params = [] + if kategorie and kategorie != 'alle': + q += " AND t.kategorie = ?" + params.append(kategorie) + if search: + q += " AND (t.titel LIKE ? OR t.text LIKE ?)" + params.extend([f'%{search}%', f'%{search}%']) + q += " ORDER BY t.is_pinned DESC, t.created_at DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + rows = conn.execute(q, params).fetchall() + + result = [] + for r in rows: + t = dict(r) + foto_list = _parse_foto_urls(t.get('foto_urls')) + t['foto_preview'] = foto_list[0] if foto_list else None + t['foto_urls'] = foto_list + t['user_liked'] = _user_liked(conn, uid, 'thread', t['id']) if uid else False + result.append(t) + + return result + + +# ------------------------------------------------------------------ +# POST /api/forum/threads +# ------------------------------------------------------------------ +@router.post("/threads", status_code=201) +async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): + if not data.titel.strip(): + raise HTTPException(400, "Titel darf nicht leer sein.") + if not data.text.strip(): + raise HTTPException(400, "Text darf nicht leer sein.") + if len(data.text.strip()) < 20: + raise HTTPException(400, "Text muss mindestens 20 Zeichen lang sein.") + if data.kategorie not in KATEGORIEN: + raise HTTPException(400, "Ungültige Kategorie.") + with db() as conn: + cur = conn.execute( + """INSERT INTO forum_threads (user_id, kategorie, titel, text) + VALUES (?, ?, ?, ?)""", + (user['id'], data.kategorie, data.titel.strip(), data.text.strip()) + ) + row = conn.execute( + """SELECT t.*, u.name AS autor_name + FROM forum_threads t + LEFT JOIN users u ON u.id = t.user_id + WHERE t.id = ?""", + (cur.lastrowid,) + ).fetchone() + t = dict(row) + t['foto_urls'] = _parse_foto_urls(t.get('foto_urls')) + t['user_liked'] = False + return t + + +# ------------------------------------------------------------------ +# GET /api/forum/threads/{id} +# ------------------------------------------------------------------ +@router.get("/threads/{thread_id}") +async def get_thread(thread_id: int, user=Depends(get_current_user_optional)): + uid = user['id'] if user else None + with db() as conn: + thread = conn.execute( + """SELECT t.*, u.name AS autor_name + FROM forum_threads t + LEFT JOIN users u ON u.id = t.user_id + WHERE t.id = ? AND t.is_deleted = 0""", + (thread_id,) + ).fetchone() + if not thread: + raise HTTPException(404, "Thread nicht gefunden.") + + # Increment views + conn.execute( + "UPDATE forum_threads SET views = views + 1 WHERE id = ?", (thread_id,) + ) + + posts = conn.execute( + """SELECT p.*, u.name AS autor_name + FROM forum_posts p + LEFT JOIN users u ON u.id = p.user_id + WHERE p.thread_id = ? + ORDER BY p.created_at ASC""", + (thread_id,) + ).fetchall() + + result = dict(thread) + result['foto_urls'] = _parse_foto_urls(result.get('foto_urls')) + result['user_liked'] = _user_liked(conn, uid, 'thread', thread_id) if uid else False + + result['posts'] = [] + for p in posts: + pd = dict(p) + if pd.get('is_deleted'): + result['posts'].append({ + 'id': pd['id'], + 'thread_id': pd['thread_id'], + 'is_deleted': 1, + 'created_at': pd['created_at'], + }) + else: + pd['foto_urls'] = _parse_foto_urls(pd.get('foto_urls')) + pd['user_liked'] = _user_liked(conn, uid, 'post', pd['id']) if uid else False + result['posts'].append(pd) + + return result + + +# ------------------------------------------------------------------ +# DELETE /api/forum/threads/{id} +# ------------------------------------------------------------------ +@router.delete("/threads/{thread_id}", status_code=204) +async def delete_thread(thread_id: int, user=Depends(get_current_user)): + with db() as conn: + thread = conn.execute( + "SELECT * FROM forum_threads WHERE id = ?", (thread_id,) + ).fetchone() + if not thread: + raise HTTPException(404, "Thread nicht gefunden.") + if thread['user_id'] != user['id'] and not user.get('is_moderator'): + raise HTTPException(403, "Keine Berechtigung.") + conn.execute( + "UPDATE forum_threads SET is_deleted = 1 WHERE id = ?", (thread_id,) + ) + + +# ------------------------------------------------------------------ +# PATCH /api/forum/threads/{id} — Moderator: pin/lock +# ------------------------------------------------------------------ +@router.patch("/threads/{thread_id}") +async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_current_user)): + if not user.get('is_moderator'): + raise HTTPException(403, "Nur Moderatoren können Threads bearbeiten.") + with db() as conn: + thread = conn.execute( + "SELECT * FROM forum_threads WHERE id = ?", (thread_id,) + ).fetchone() + if not thread: + raise HTTPException(404, "Thread nicht gefunden.") + + updates = data.model_dump(exclude_none=True) + if updates: + cols = ', '.join(f"{k} = ?" for k in updates) + conn.execute( + f"UPDATE forum_threads SET {cols} WHERE id = ?", + [*updates.values(), thread_id] + ) + row = conn.execute( + """SELECT t.*, u.name AS autor_name + FROM forum_threads t + LEFT JOIN users u ON u.id = t.user_id + WHERE t.id = ?""", + (thread_id,) + ).fetchone() + t = dict(row) + t['foto_urls'] = _parse_foto_urls(t.get('foto_urls')) + return t + + +# ------------------------------------------------------------------ +# POST /api/forum/threads/{id}/posts +# ------------------------------------------------------------------ +@router.post("/threads/{thread_id}/posts", status_code=201) +async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current_user)): + if not data.text.strip(): + raise HTTPException(400, "Text darf nicht leer sein.") + with db() as conn: + thread = conn.execute( + "SELECT id, is_locked, is_deleted FROM forum_threads WHERE id = ?", + (thread_id,) + ).fetchone() + if not thread: + raise HTTPException(404, "Thread nicht gefunden.") + if thread['is_locked']: + raise HTTPException(403, "Dieser Thread ist gesperrt.") + if thread['is_deleted']: + raise HTTPException(404, "Thread nicht gefunden.") + + cur = conn.execute( + "INSERT INTO forum_posts (thread_id, user_id, text) VALUES (?, ?, ?)", + (thread_id, user['id'], data.text.strip()) + ) + conn.execute( + "UPDATE forum_threads SET antworten = antworten + 1 WHERE id = ?", + (thread_id,) + ) + row = conn.execute( + """SELECT p.*, u.name AS autor_name + FROM forum_posts p + LEFT JOIN users u ON u.id = p.user_id + WHERE p.id = ?""", + (cur.lastrowid,) + ).fetchone() + pd = dict(row) + pd['foto_urls'] = [] + pd['user_liked'] = False + return pd + + +# ------------------------------------------------------------------ +# DELETE /api/forum/posts/{id} +# ------------------------------------------------------------------ +@router.delete("/posts/{post_id}", status_code=204) +async def delete_post(post_id: int, user=Depends(get_current_user)): + with db() as conn: + post = conn.execute( + "SELECT * FROM forum_posts WHERE id = ?", (post_id,) + ).fetchone() + if not post: + raise HTTPException(404, "Beitrag nicht gefunden.") + if post['user_id'] != user['id'] and not user.get('is_moderator'): + raise HTTPException(403, "Keine Berechtigung.") + conn.execute( + "UPDATE forum_posts SET is_deleted = 1 WHERE id = ?", (post_id,) + ) + # Antworten-Zähler nur verringern wenn eigener soft-delete (nicht Moderator) + conn.execute( + "UPDATE forum_threads SET antworten = MAX(0, antworten - 1) WHERE id = ?", + (post['thread_id'],) + ) + + +# ------------------------------------------------------------------ +# POST /api/forum/threads/{id}/fotos +# ------------------------------------------------------------------ +@router.post("/threads/{thread_id}/fotos") +async def upload_thread_foto( + thread_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user), +): + with db() as conn: + thread = conn.execute( + "SELECT * FROM forum_threads WHERE id = ? AND is_deleted = 0", + (thread_id,) + ).fetchone() + if not thread: + raise HTTPException(404, "Thread nicht gefunden.") + if thread['user_id'] != user['id'] and not user.get('is_moderator'): + raise HTTPException(403, "Keine Berechtigung.") + + existing = _parse_foto_urls(thread['foto_urls']) + if len(existing) >= 5: + raise HTTPException(400, "Maximal 5 Fotos pro Thread.") + + data = await file.read() + url = _save_upload(file, data) + existing.append(url) + conn.execute( + "UPDATE forum_threads SET foto_urls = ? WHERE id = ?", + (json.dumps(existing), thread_id) + ) + return {"foto_url": url, "foto_urls": existing} + + +# ------------------------------------------------------------------ +# POST /api/forum/posts/{id}/fotos +# ------------------------------------------------------------------ +@router.post("/posts/{post_id}/fotos") +async def upload_post_foto( + post_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user), +): + with db() as conn: + post = conn.execute( + "SELECT * FROM forum_posts WHERE id = ? AND is_deleted = 0", + (post_id,) + ).fetchone() + if not post: + raise HTTPException(404, "Beitrag nicht gefunden.") + if post['user_id'] != user['id'] and not user.get('is_moderator'): + raise HTTPException(403, "Keine Berechtigung.") + + existing = _parse_foto_urls(post['foto_urls']) + if len(existing) >= 5: + raise HTTPException(400, "Maximal 5 Fotos pro Beitrag.") + + data = await file.read() + url = _save_upload(file, data) + existing.append(url) + conn.execute( + "UPDATE forum_posts SET foto_urls = ? WHERE id = ?", + (json.dumps(existing), post_id) + ) + return {"foto_url": url, "foto_urls": existing} + + +# ------------------------------------------------------------------ +# POST /api/forum/like — Toggle +# ------------------------------------------------------------------ +@router.post("/like") +async def toggle_like(data: LikeBody, user=Depends(get_current_user)): + if data.target_type not in ('thread', 'post'): + raise HTTPException(400, "Ungültiger Typ.") + + table = f"forum_{data.target_type}s" + with db() as conn: + existing = conn.execute( + "SELECT 1 FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?", + (user['id'], data.target_type, data.target_id) + ).fetchone() + + if existing: + conn.execute( + "DELETE FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?", + (user['id'], data.target_type, data.target_id) + ) + conn.execute( + f"UPDATE {table} SET likes = MAX(0, likes - 1) WHERE id = ?", + (data.target_id,) + ) + liked = False + else: + conn.execute( + "INSERT OR IGNORE INTO forum_likes (user_id, target_type, target_id) VALUES (?,?,?)", + (user['id'], data.target_type, data.target_id) + ) + conn.execute( + f"UPDATE {table} SET likes = likes + 1 WHERE id = ?", + (data.target_id,) + ) + liked = True + + count_row = conn.execute( + f"SELECT likes FROM {table} WHERE id = ?", (data.target_id,) + ).fetchone() + count = count_row['likes'] if count_row else 0 + + return {"liked": liked, "count": count} + + +# ------------------------------------------------------------------ +# POST /api/forum/report +# ------------------------------------------------------------------ +@router.post("/report", status_code=201) +async def report_content(data: ReportBody, user=Depends(get_current_user)): + if data.target_type not in ('thread', 'post'): + raise HTTPException(400, "Ungültiger Typ.") + if not data.grund.strip(): + raise HTTPException(400, "Grund darf nicht leer sein.") + with db() as conn: + conn.execute( + """INSERT INTO forum_reports (user_id, target_type, target_id, grund) + VALUES (?, ?, ?, ?)""", + (user['id'], data.target_type, data.target_id, data.grund.strip()) + ) + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/forum/reports — Moderator +# ------------------------------------------------------------------ +@router.get("/reports") +async def list_reports(user=Depends(get_current_user)): + if not user.get('is_moderator'): + raise HTTPException(403, "Nur Moderatoren.") + with db() as conn: + rows = conn.execute( + """SELECT r.*, u.name AS melder_name + FROM forum_reports r + LEFT JOIN users u ON u.id = r.user_id + WHERE r.resolved = 0 + ORDER BY r.created_at DESC""" + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# PATCH /api/forum/reports/{id} — Moderator: resolve +# ------------------------------------------------------------------ +@router.patch("/reports/{report_id}") +async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_current_user)): + if not user.get('is_moderator'): + raise HTTPException(403, "Nur Moderatoren.") + with db() as conn: + conn.execute( + "UPDATE forum_reports SET resolved = ? WHERE id = ?", + (data.resolved, report_id) + ) + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/forum/members/map +# ------------------------------------------------------------------ +@router.get("/members/map") +async def members_map(): + with db() as conn: + rows = conn.execute( + """SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname, + forum_lat AS lat, forum_lon AS lon + FROM users + WHERE forum_show_location = 1 + AND forum_lat IS NOT NULL + AND forum_lon IS NOT NULL""" + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# PATCH /api/forum/members/location +# ------------------------------------------------------------------ +@router.patch("/members/location") +async def set_member_location(data: LocationBody, user=Depends(get_current_user)): + if data.show and data.lat is not None and data.lon is not None: + # Snap to ~1km grid (2 decimal places ≈ 1.1km) + snapped_lat = round(data.lat, 2) + snapped_lon = round(data.lon, 2) + with db() as conn: + conn.execute( + """UPDATE users SET forum_lat=?, forum_lon=?, forum_show_location=1 + WHERE id=?""", + (snapped_lat, snapped_lon, user['id']) + ) + return {"ok": True, "lat": snapped_lat, "lon": snapped_lon} + else: + with db() as conn: + conn.execute( + "UPDATE users SET forum_show_location=0 WHERE id=?", + (user['id'],) + ) + return {"ok": True, "show": False} + + +# ------------------------------------------------------------------ +# GET /api/forum/search +# ------------------------------------------------------------------ +@router.get("/search") +async def search_forum(q: Optional[str] = None, limit: int = 20): + if not q or len(q.strip()) < 2: + return [] + term = f'%{q.strip()}%' + with db() as conn: + rows = conn.execute( + """SELECT t.id, t.kategorie, t.titel, t.antworten, t.created_at, + u.name AS autor_name, + SUBSTR(t.text, 1, 200) AS text_preview + FROM forum_threads t + LEFT JOIN users u ON u.id = t.user_id + WHERE t.is_deleted = 0 + AND (t.titel LIKE ? OR t.text LIKE ?) + ORDER BY t.created_at DESC + LIMIT ?""", + (term, term, limit) + ).fetchall() + return [dict(r) for r in rows] diff --git a/backend/routes/friends.py b/backend/routes/friends.py new file mode 100644 index 0000000..27638a5 --- /dev/null +++ b/backend/routes/friends.py @@ -0,0 +1,148 @@ +"""BAN YARO — Freundschaften""" +import logging +from fastapi import APIRouter, Depends, HTTPException +from database import db +from auth import get_current_user + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get("/") +async def list_friends(user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + friends = conn.execute(""" + SELECT f.id, f.status, f.created_at, + CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END AS friend_id, + u.name AS friend_name + FROM friendships f + JOIN users u ON u.id = CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END + WHERE (f.requester_id=? OR f.addressee_id=?) AND f.status='accepted' + ORDER BY u.name + """, (uid, uid, uid, uid)).fetchall() + + incoming = conn.execute(""" + SELECT f.id, f.created_at, u.name AS requester_name, u.id AS requester_id + FROM friendships f + JOIN users u ON u.id=f.requester_id + WHERE f.addressee_id=? AND f.status='pending' + ORDER BY f.created_at DESC + """, (uid,)).fetchall() + + outgoing = conn.execute(""" + SELECT f.id, f.created_at, u.name AS addressee_name, u.id AS addressee_id + FROM friendships f + JOIN users u ON u.id=f.addressee_id + WHERE f.requester_id=? AND f.status='pending' + ORDER BY f.created_at DESC + """, (uid,)).fetchall() + + return { + "friends": [dict(r) for r in friends], + "incoming": [dict(r) for r in incoming], + "outgoing": [dict(r) for r in outgoing], + } + + +@router.get("/search") +async def search_users(q: str = "", user=Depends(get_current_user)): + if len(q.strip()) < 2: + return [] + uid = user["id"] + with db() as conn: + rows = conn.execute(""" + SELECT u.id, u.name + FROM users u + WHERE u.id != ? + AND u.name LIKE ? + AND NOT EXISTS ( + SELECT 1 FROM friendships f + WHERE (f.requester_id=? AND f.addressee_id=u.id) + OR (f.requester_id=u.id AND f.addressee_id=?) + ) + LIMIT 20 + """, (uid, f"%{q.strip()}%", uid, uid)).fetchall() + return [dict(r) for r in rows] + + +@router.post("/request/{target_id}", status_code=201) +async def send_request(target_id: int, user=Depends(get_current_user)): + uid = user["id"] + if uid == target_id: + raise HTTPException(400, "Du kannst dich nicht selbst als Freund hinzufügen.") + + with db() as conn: + if not conn.execute("SELECT 1 FROM users WHERE id=?", (target_id,)).fetchone(): + raise HTTPException(404, "Nutzer nicht gefunden.") + + existing = conn.execute(""" + SELECT id, status FROM friendships + WHERE (requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?) + """, (uid, target_id, target_id, uid)).fetchone() + + if existing: + if existing["status"] == "accepted": + raise HTTPException(400, "Ihr seid bereits befreundet.") + raise HTTPException(400, "Anfrage bereits vorhanden.") + + conn.execute( + "INSERT INTO friendships (requester_id, addressee_id) VALUES (?,?)", + (uid, target_id) + ) + + try: + from routes.push import send_push_to_user + send_push_to_user(target_id, { + "title": "Neue Freundschaftsanfrage", + "body": f"{user['name']} möchte dein Freund sein.", + "type": "friend_request", + "data": {"page": "friends"}, + }) + except Exception: + pass + + return {"ok": True} + + +@router.post("/{friendship_id}/accept") +async def accept_request(friendship_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + f = conn.execute( + "SELECT * FROM friendships WHERE id=? AND addressee_id=? AND status='pending'", + (friendship_id, uid) + ).fetchone() + if not f: + raise HTTPException(404, "Anfrage nicht gefunden.") + conn.execute( + "UPDATE friendships SET status='accepted', updated_at=datetime('now') WHERE id=?", + (friendship_id,) + ) + return {"ok": True} + + +@router.post("/{friendship_id}/decline") +async def decline_request(friendship_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + f = conn.execute(""" + SELECT id FROM friendships + WHERE id=? AND (addressee_id=? OR requester_id=?) AND status='pending' + """, (friendship_id, uid, uid)).fetchone() + if not f: + raise HTTPException(404, "Anfrage nicht gefunden.") + conn.execute("DELETE FROM friendships WHERE id=?", (friendship_id,)) + return {"ok": True} + + +@router.delete("/{friend_user_id}") +async def remove_friend(friend_user_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + conn.execute(""" + DELETE FROM friendships + WHERE status='accepted' + AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?)) + """, (uid, friend_user_id, friend_user_id, uid)) + return {"ok": True} diff --git a/backend/routes/knigge.py b/backend/routes/knigge.py new file mode 100644 index 0000000..a44c339 --- /dev/null +++ b/backend/routes/knigge.py @@ -0,0 +1,113 @@ +"""BAN YARO — Hunde-Knigge Routes""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user, get_current_user_optional + +router = APIRouter() + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class VoteRequest(BaseModel): + szenario_id: str + answer: str + + +class KiRatRequest(BaseModel): + situation: str + + +# ------------------------------------------------------------------ +# POST /api/knigge/vote — Stimme abgeben oder ändern (Auth required) +# ------------------------------------------------------------------ +@router.post("/vote") +async def vote(data: VoteRequest, user=Depends(get_current_user)): + if not data.szenario_id or not data.answer: + raise HTTPException(400, "szenario_id und answer sind erforderlich.") + + with db() as conn: + # Upsert: vorhandene Stimme ersetzen oder neu anlegen + conn.execute( + """INSERT INTO knigge_votes (szenario_id, user_id, answer) + VALUES (?, ?, ?) + ON CONFLICT(szenario_id, user_id) DO UPDATE SET answer=excluded.answer""", + (data.szenario_id, user["id"], data.answer), + ) + rows = conn.execute( + """SELECT answer, COUNT(*) as cnt + FROM knigge_votes + WHERE szenario_id=? + GROUP BY answer""", + (data.szenario_id,), + ).fetchall() + + counts = {r["answer"]: r["cnt"] for r in rows} + return {"counts": counts, "user_answer": data.answer} + + +# ------------------------------------------------------------------ +# GET /api/knigge/votes?szenario_id= — Stimmen abrufen (kein Auth nötig) +# ------------------------------------------------------------------ +@router.get("/votes") +async def get_votes( + szenario_id: str = Query(...), + user=Depends(get_current_user_optional), +): + with db() as conn: + rows = conn.execute( + """SELECT answer, COUNT(*) as cnt + FROM knigge_votes + WHERE szenario_id=? + GROUP BY answer""", + (szenario_id,), + ).fetchall() + user_answer = None + if user: + row = conn.execute( + "SELECT answer FROM knigge_votes WHERE szenario_id=? AND user_id=?", + (szenario_id, user["id"]), + ).fetchone() + if row: + user_answer = row["answer"] + + counts = {r["answer"]: r["cnt"] for r in rows} + return {"counts": counts, "user_answer": user_answer} + + +# ------------------------------------------------------------------ +# POST /api/knigge/ki-rat — KI-Situationsberater (Auth required) +# ------------------------------------------------------------------ +@router.post("/ki-rat") +async def ki_rat(data: KiRatRequest, user=Depends(get_current_user)): + from ki import complete, KIUnavailableError, KIPremiumRequired + + if not data.situation or not data.situation.strip(): + raise HTTPException(400, "Situation darf nicht leer sein.") + + system = ( + "Du bist ein erfahrener Hundeexperte und Hundetrainer. " + "Deine Aufgabe ist es, Hundebesitzern kurze, praktische Ratschläge zu geben. " + "Antworte immer auf Deutsch, freundlich und verständlich." + ) + prompt = ( + f"Situation: {data.situation.strip()}\n\n" + "Gib einen kurzen, praktischen Rat (maximal 3 Sätze) was der Hundebesitzer tun sollte." + ) + + try: + rat = await complete( + prompt, + system=system, + max_tokens=300, + requires_premium=False, + user_is_premium=bool(user.get("is_premium")), + ) + return {"rat": rat} + except KIPremiumRequired as e: + raise HTTPException(402, str(e)) + except KIUnavailableError as e: + raise HTTPException(503, str(e)) diff --git a/backend/routes/lost.py b/backend/routes/lost.py new file mode 100644 index 0000000..aac5382 --- /dev/null +++ b/backend/routes/lost.py @@ -0,0 +1,171 @@ +"""BAN YARO — Verlorener Hund Routes""" + +import os, uuid, math +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user +from routes.push import send_push_to_all + +router = APIRouter() +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + + +# ------------------------------------------------------------------ +# Haversine-Distanz in Metern +# ------------------------------------------------------------------ +def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + R = 6_371_000 + p1 = math.radians(lat1) + p2 = math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class LostDogCreate(BaseModel): + name: str + rasse: Optional[str] = None + beschreibung: str + lat: float + lon: float + dog_id: Optional[int] = None + + +# ------------------------------------------------------------------ +# GET /api/lost — aktive Meldungen (optional nach Distanz gefiltert) +# ------------------------------------------------------------------ +@router.get("") +async def list_lost(lat: Optional[float] = None, lon: Optional[float] = None, + radius_km: float = 25): + with db() as conn: + rows = conn.execute( + """SELECT l.*, u.name AS melder_name + FROM lost_dogs l + LEFT JOIN users u ON u.id = l.user_id + WHERE l.is_active = 1 + ORDER BY l.created_at DESC""" + ).fetchall() + + results = [] + for r in rows: + entry = dict(r) + if lat is not None and lon is not None: + dist = _haversine(lat, lon, entry["lat"], entry["lon"]) + if dist > radius_km * 1000: + continue + entry["distanz_m"] = round(dist) + results.append(entry) + + if lat is not None and lon is not None: + results.sort(key=lambda x: x.get("distanz_m", 0)) + + return results + + +# ------------------------------------------------------------------ +# POST /api/lost — Hund vermisst melden (Login erforderlich) +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def report_lost(data: LostDogCreate, user=Depends(get_current_user)): + with db() as conn: + conn.execute( + """INSERT INTO lost_dogs (user_id, dog_id, name, rasse, beschreibung, lat, lon) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (user["id"], data.dog_id, data.name, data.rasse, + data.beschreibung, data.lat, data.lon) + ) + row = conn.execute( + "SELECT * FROM lost_dogs WHERE user_id=? ORDER BY id DESC LIMIT 1", + (user["id"],) + ).fetchone() + entry = dict(row) + + send_push_to_all({ + "type": "lost_dog_alert", + "title": f"🔍 {data.name} wird vermisst!", + "body": f"{data.rasse or 'Hund'} in deiner Nähe vermisst. Hilf bei der Suche!", + "tag": f"lost-{entry['id']}", + "data": {"page": "lost"}, + }) + + return entry + + +# ------------------------------------------------------------------ +# POST /api/lost/{id}/foto — Foto hochladen (Login, eigene Meldung) +# ------------------------------------------------------------------ +@router.post("/{lost_id}/foto") +async def upload_foto( + lost_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user), +): + with db() as conn: + entry = conn.execute( + "SELECT id FROM lost_dogs WHERE id=? AND user_id=?", + (lost_id, user["id"]) + ).fetchone() + if not entry: + raise HTTPException(404, "Meldung nicht gefunden oder keine Berechtigung.") + + ext = os.path.splitext(file.filename or "")[1] or ".jpg" + filename = f"lost_{lost_id}_{uuid.uuid4().hex[:8]}{ext}" + path = os.path.join(MEDIA_DIR, "lost", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + + with open(path, "wb") as f: + f.write(await file.read()) + + foto_url = f"/media/lost/{filename}" + with db() as conn: + conn.execute("UPDATE lost_dogs SET foto_url=? WHERE id=?", (foto_url, lost_id)) + + return {"foto_url": foto_url} + + +# ------------------------------------------------------------------ +# POST /api/lost/{id}/found — als gefunden markieren (Login, eigene Meldung) +# ------------------------------------------------------------------ +@router.post("/{lost_id}/found") +async def mark_found(lost_id: int, user=Depends(get_current_user)): + with db() as conn: + entry = conn.execute( + "SELECT * FROM lost_dogs WHERE id=?", (lost_id,) + ).fetchone() + if not entry: + raise HTTPException(404, "Meldung nicht gefunden.") + e = dict(entry) + if e["user_id"] != user["id"] and user.get("rolle") != "admin": + raise HTTPException(403, "Keine Berechtigung.") + conn.execute( + """UPDATE lost_dogs + SET is_active=0, gefunden_at=datetime('now') + WHERE id=?""", + (lost_id,) + ) + return {"ok": True} + + +# ------------------------------------------------------------------ +# DELETE /api/lost/{id} — eigene Meldung löschen (Login) +# ------------------------------------------------------------------ +@router.delete("/{lost_id}", status_code=204) +async def delete_lost(lost_id: int, user=Depends(get_current_user)): + with db() as conn: + entry = conn.execute( + "SELECT * FROM lost_dogs WHERE id=?", (lost_id,) + ).fetchone() + if not entry: + raise HTTPException(404, "Meldung nicht gefunden.") + e = dict(entry) + if e["user_id"] != user["id"] and user.get("rolle") != "admin": + raise HTTPException(403, "Keine Berechtigung.") + conn.execute("DELETE FROM lost_dogs WHERE id=?", (lost_id,)) + return None diff --git a/backend/routes/movies.py b/backend/routes/movies.py new file mode 100644 index 0000000..5ef83da --- /dev/null +++ b/backend/routes/movies.py @@ -0,0 +1,185 @@ +"""BAN YARO — Hunde-Filme Routes""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from database import db +from auth import get_current_user, get_current_user_optional + +router = APIRouter() + + +# ------------------------------------------------------------------ +# Hardcoded Film-Daten +# ------------------------------------------------------------------ +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}, +] + +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": "⛪"}, +] + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class FilmVoteRequest(BaseModel): + bewertung: int # 1–5 + + +class HundDesMonatsVoteRequest(BaseModel): + dog_id: int + + +# ------------------------------------------------------------------ +# GET /api/movies/filme — Film-Liste mit optionaler User-Bewertung +# ------------------------------------------------------------------ +@router.get("/filme") +async def get_filme(user=Depends(get_current_user_optional)): + user_ratings = {} + community_avgs = {} + + 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} + + 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) + + return result + + +# ------------------------------------------------------------------ +# POST /api/movies/filme/{film_id}/vote — Bewertung abgeben (Upsert) +# ------------------------------------------------------------------ +@router.post("/filme/{film_id}/vote") +async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_current_user)): + if not any(f["id"] == film_id for f in FILME): + raise HTTPException(404, "Film nicht gefunden.") + if data.bewertung < 1 or data.bewertung > 5: + raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.") + + with db() as conn: + 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, + "bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung, + "bewertung_cnt": row["cnt"], + "user_rating": data.bewertung, + } + + +# ------------------------------------------------------------------ +# GET /api/movies/hund-des-monats — Top-Votes des aktuellen Monats +# ------------------------------------------------------------------ +@router.get("/hund-des-monats") +async def get_hund_des_monats(user=Depends(get_current_user_optional)): + monat = datetime.now().strftime("%Y-%m") + + with db() as conn: + rows = conn.execute( + """SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name, + COUNT(v.id) as stimmen + FROM hund_des_monats_votes v + JOIN dogs d ON d.id = v.dog_id + JOIN users u ON u.id = d.user_id + WHERE v.monat = ? + GROUP BY v.dog_id + ORDER BY stimmen DESC + LIMIT 10""", + (monat,), + ).fetchall() + + user_vote = None + if user: + row = conn.execute( + "SELECT dog_id FROM hund_des_monats_votes WHERE user_id=? AND monat=?", + (user["id"], monat), + ).fetchone() + if row: + user_vote = row["dog_id"] + + 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() + 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 + row = conn.execute( + "SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?", + (data.dog_id, monat), + ).fetchone() + + return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]} diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py new file mode 100644 index 0000000..917d92f --- /dev/null +++ b/backend/routes/wiki.py @@ -0,0 +1,258 @@ +"""BAN YARO — Hunde-Wiki Routes""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from database import db +from auth import get_current_user + +router = APIRouter() + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class BerichtCreate(BaseModel): + rasse: str + titel: str + text: str + + +# ------------------------------------------------------------------ +# Hilfsfunktion Quiz-Scoring +# ------------------------------------------------------------------ +def _quiz_score(rasse: dict, params: dict) -> int: + score = 0 + if params.get("groesse") and rasse["groesse"] == params["groesse"]: + score += 2 + # Aktivität: exakt = 2, eine Stufe daneben = 1 + aktiv_map = {"niedrig": 0, "mittel": 1, "hoch": 2, "sehr_hoch": 3} + if params.get("aktivitaet"): + a_user = aktiv_map.get(params["aktivitaet"], -1) + a_rasse = aktiv_map.get(rasse["aktivitaet"], -1) + diff = abs(a_user - a_rasse) + if diff == 0: + score += 2 + elif diff == 1: + score += 1 + # Erfahrung: anfaenger bekommt Bonus für einfache Rassen + erf_map = {"anfaenger": 0, "fortgeschritten": 1, "experte": 2} + if params.get("erfahrung"): + e_user = erf_map.get(params["erfahrung"], -1) + e_rasse = erf_map.get(rasse["erfahrung"], -1) + if e_user >= e_rasse: + score += 2 + elif e_user == e_rasse - 1: + score += 1 + # Kinder + if params.get("kinder") in ("true", "True", "1"): + if rasse["kinder_geeignet"]: + score += 1 + # Wohnung + if params.get("wohnung") in ("true", "True", "1"): + if rasse["wohnung_geeignet"]: + score += 2 + elif params.get("wohnung") in ("false", "False", "0"): + if not rasse["wohnung_geeignet"]: + score += 1 + return score + + +# ------------------------------------------------------------------ +# GET /api/wiki/stats — Seed-Status +# ------------------------------------------------------------------ +@router.get("/stats") +async def get_stats(): + with db() as conn: + row = conn.execute("SELECT COUNT(*) as total FROM wiki_rassen").fetchone() + total = row["total"] if row else 0 + return {"total_breeds": total, "seeded": total > 0} + + +# ------------------------------------------------------------------ +# GET /api/wiki/rassen — alle Rassen (Übersicht, paginiert) +# ------------------------------------------------------------------ +@router.get("/rassen") +async def get_rassen( + search: str = Query(""), + gruppe: str = Query(""), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +): + conditions = [] + args = [] + + if search: + conditions.append("(LOWER(name) LIKE ? OR LOWER(gruppe) LIKE ? OR LOWER(temperament) LIKE ?)") + like = f"%{search.lower()}%" + args += [like, like, like] + if gruppe: + conditions.append("gruppe = ?") + args.append(gruppe) + + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + args_paged = args + [limit, offset] + + with db() as conn: + rows = conn.execute(f""" + SELECT id, name, gruppe, groesse, aktivitaet, erfahrung, + foto_url, slug, kinder_geeignet, wohnung_geeignet + FROM wiki_rassen + {where} + ORDER BY name ASC + LIMIT ? OFFSET ? + """, args_paged).fetchall() + + count_row = conn.execute(f""" + SELECT COUNT(*) as total FROM wiki_rassen {where} + """, args).fetchone() + + # Alle Gruppen für Filter-Dropdown + gruppen_rows = conn.execute( + "SELECT DISTINCT gruppe FROM wiki_rassen WHERE gruppe IS NOT NULL ORDER BY gruppe" + ).fetchall() + + return { + "breeds": [dict(r) for r in rows], + "total": count_row["total"] if count_row else 0, + "gruppen": [r["gruppe"] for r in gruppen_rows], + } + + +# ------------------------------------------------------------------ +# GET /api/wiki/rassen/{slug} — Rasse-Detail + Community-Berichte +# ------------------------------------------------------------------ +@router.get("/rassen/{rasse_slug}") +async def get_rasse(rasse_slug: str): + with db() as conn: + rasse = conn.execute( + "SELECT * FROM wiki_rassen WHERE slug = ?", (rasse_slug,) + ).fetchone() + + if not rasse: + raise HTTPException(404, "Rasse nicht gefunden.") + + rows = conn.execute( + """SELECT wb.id, wb.titel, wb.text, wb.created_at, u.name as autor + FROM wiki_berichte wb + JOIN users u ON u.id = wb.user_id + WHERE wb.rasse = ? + ORDER BY wb.created_at DESC + LIMIT 50""", + (rasse_slug,), + ).fetchall() + + result = dict(rasse) + result["berichte"] = [dict(r) for r in rows] + return result + + +# ------------------------------------------------------------------ +# POST /api/wiki/berichte — Community-Bericht hinzufügen +# ------------------------------------------------------------------ +@router.post("/berichte") +async def create_bericht(data: BerichtCreate, user=Depends(get_current_user)): + # Prüfen ob die Rasse in der DB existiert + with db() as conn: + rasse_row = conn.execute( + "SELECT slug FROM wiki_rassen WHERE slug = ?", (data.rasse,) + ).fetchone() + + if not rasse_row: + raise HTTPException(400, "Ungültige Rasse.") + if not data.titel.strip(): + raise HTTPException(400, "Titel darf nicht leer sein.") + if not data.text.strip(): + raise HTTPException(400, "Text darf nicht leer sein.") + + with db() as conn: + cur = conn.execute( + """INSERT INTO wiki_berichte (user_id, rasse, titel, text) + VALUES (?, ?, ?, ?)""", + (user["id"], data.rasse, data.titel.strip(), data.text.strip()), + ) + row = conn.execute( + "SELECT wb.id, wb.titel, wb.text, wb.created_at, u.name as autor " + "FROM wiki_berichte wb JOIN users u ON u.id = wb.user_id " + "WHERE wb.id = ?", + (cur.lastrowid,), + ).fetchone() + + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/wiki/berichte/{id} — Bericht löschen (nur eigene) +# ------------------------------------------------------------------ +@router.delete("/berichte/{bericht_id}") +async def delete_bericht(bericht_id: int, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT id, user_id FROM wiki_berichte WHERE id = ?", + (bericht_id,), + ).fetchone() + if not row: + raise HTTPException(404, "Bericht nicht gefunden.") + if row["user_id"] != user["id"]: + raise HTTPException(403, "Nicht erlaubt.") + conn.execute("DELETE FROM wiki_berichte WHERE id = ?", (bericht_id,)) + + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/wiki/quiz/result — Quiz-Ergebnis berechnen +# ------------------------------------------------------------------ +@router.get("/quiz/result") +async def quiz_result( + groesse: str = Query(""), + aktivitaet: str = Query(""), + erfahrung: str = Query(""), + kinder: str = Query(""), + wohnung: str = Query(""), +): + params = { + "groesse": groesse, + "aktivitaet": aktivitaet, + "erfahrung": erfahrung, + "kinder": kinder, + "wohnung": wohnung, + } + + with db() as conn: + rows = conn.execute( + """SELECT id, name, gruppe, groesse, aktivitaet, erfahrung, + foto_url, slug, kinder_geeignet, wohnung_geeignet, + temperament, bred_for + FROM wiki_rassen + ORDER BY name ASC""" + ).fetchall() + + rassen = [dict(r) for r in rows] + + if not rassen: + return {"results": []} + + scored = sorted( + rassen, + key=lambda r: _quiz_score(r, params), + reverse=True, + ) + + top3 = [ + { + "slug": r["slug"], + "name": r["name"], + "gruppe": r["gruppe"], + "groesse": r["groesse"], + "aktivitaet": r["aktivitaet"], + "erfahrung": r["erfahrung"], + "foto_url": r["foto_url"], + "kinder_geeignet": r["kinder_geeignet"], + "wohnung_geeignet":r["wohnung_geeignet"], + "temperament": r["temperament"], + "score": _quiz_score(r, params), + } + for r in scored[:3] + ] + + return {"results": top3} diff --git a/backend/scheduler.py b/backend/scheduler.py index ecd8bfa..b08b8ee 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -4,12 +4,13 @@ Täglich: Gesundheits-Erinnerungen per Push versenden. """ import logging -from datetime import date, timedelta +from datetime import date, datetime, timedelta from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from database import db -from routes.push import send_push_to_user +from routes.push import send_push_to_user, send_push_to_all +import weather logger = logging.getLogger(__name__) @@ -31,8 +32,53 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + _scheduler.add_job( + _job_weather_alert, + CronTrigger(hour=7, minute=30), # täglich 07:30 Uhr + id="weather_alert", + replace_existing=True, + misfire_grace_time=3600, + ) + _scheduler.add_job( + _job_milestone_check, + CronTrigger(hour=0, minute=5), # täglich 00:05 Uhr + id="milestone_check", + replace_existing=True, + misfire_grace_time=3600, + ) + _scheduler.add_job( + _job_import_events, + CronTrigger(day_of_week='sun', hour=2), # jeden Sonntag 02:00 Uhr + id="import_events", + replace_existing=True, + misfire_grace_time=7200, + ) + # Einmalig beim Start (nach 10s Verzögerung) für sofortige Befüllung + _scheduler.add_job( + _job_import_events, + 'date', + run_date=datetime.now() + timedelta(seconds=10), + id="import_events_startup", + replace_existing=True, + ) + # Einmalig beim Start (nach 15s Verzögerung) — Rassen aus TheDogAPI befüllen + _scheduler.add_job( + _job_seed_breeds, + 'date', + run_date=datetime.now() + timedelta(seconds=15), + id="seed_breeds_startup", + replace_existing=True, + ) + # Einmalig beim Start (nach 45s Verzögerung) — fehlende Rassen aus Wikidata ergänzen + _scheduler.add_job( + _job_seed_wikidata_breeds, + 'date', + run_date=datetime.now() + timedelta(seconds=45), + id="seed_wikidata_startup", + replace_existing=True, + ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00.") + 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 beim Start.") def stop(): @@ -122,3 +168,254 @@ async def _job_poison_archive(): count = result.rowcount if count: logger.info(f"Giftköder-Archiv: {count} abgelaufene Meldungen archiviert.") + + +# ------------------------------------------------------------------ +# JOB: Wetter-Alarm (Hitzepfoten / Gewitter) +# ------------------------------------------------------------------ +async def _job_weather_alert(): + """ + Holt Tagesprognose für mehrere deutsche Städte. + Sendet Push-Notification wenn: + - Temperatur >= 28°C (Asphalt-Warnung für Pfoten) + - Gewitter wahrscheinlich + Hitze hat Vorrang: Bei Hitze wird kein Gewitter-Push mehr gesendet. + """ + logger.info("Wetter-Alert Job läuft") + try: + summary = await weather.get_weather_summary() + except Exception as e: + logger.error(f"Wetter-Alert: Fehler beim Abruf: {e}") + return + + max_temp = summary["max_temp_c"] + thunderstorm = summary["thunderstorm"] + + if max_temp >= 28: + sent = send_push_to_all({ + "type": "weather_heat", + "title": "☀️ Heißer Asphalt heute", + "body": f"Bis {max_temp:.0f}°C heute — Asphalt kann über 50°C heiß werden. Frühmorgens oder abends gassi gehen!", + "data": {"tag": "weather-heat"}, + }) + logger.info(f"Wetter-Alert Hitze: {max_temp:.1f}°C — {sent} Push gesendet.") + return # Kein Gewitter-Push mehr nötig wenn Hitze bereits gemeldet + + if thunderstorm: + sent = send_push_to_all({ + "type": "weather_thunder", + "title": "⛈️ Gewitter möglich", + "body": "Heute Gewitter wahrscheinlich. Gassi-Tour früh einplanen und Hund beruhigen.", + "data": {"tag": "weather-thunder"}, + }) + logger.info(f"Wetter-Alert Gewitter — {sent} Push gesendet.") + return + + logger.info("Wetter-Alert: Keine Warnung nötig heute.") + + +# ------------------------------------------------------------------ +# JOB: Geburtstags- und Monats-Meilensteine +# Läuft täglich um 00:05 Uhr (Europe/Berlin). +# Prüft alle Hunde mit gesetztem Geburtstag und erstellt bei Treffern +# einen Tagebucheintrag (is_milestone=1) + Push-Notification. +# ------------------------------------------------------------------ +async def _job_milestone_check(): + """ + Prüft für jeden Hund mit bekanntem Geburtstag ob heute ein + Meilenstein-Tag ist: + - Jahrestag (1. Geburtstag, 2. Geburtstag, …) + - Monatsjubiläum in den ersten 12 Monaten (1 Monat, 2 Monate, …, 11 Monate) + Doppelt-Schutz: Wenn bereits ein Meilenstein-Eintrag mit demselben + Titel für heute existiert, wird kein zweiter erstellt. + """ + today = date.today() + logger.info(f"Meilenstein-Check läuft für {today}") + + with db() as conn: + dogs = conn.execute(""" + SELECT d.id, d.name, d.user_id, d.geburtstag + FROM dogs d + WHERE d.geburtstag IS NOT NULL + AND d.geburtstag != '' + """).fetchall() + + created_total = 0 + + for dog in dogs: + try: + bday = date.fromisoformat(dog["geburtstag"]) + except ValueError: + logger.warning(f"Meilenstein: ungültiges Geburtstag für Hund {dog['id']}: {dog['geburtstag']!r}") + continue + + milestone = _compute_milestone(today, bday, dog["name"]) + if milestone is None: + continue + + titel, text = milestone + + with db() as conn: + # Doppelt-Schutz: kein zweiter Eintrag am selben Tag mit gleichem Titel + exists = conn.execute(""" + SELECT id FROM diary + WHERE dog_id = ? AND datum = ? AND titel = ? AND is_milestone = 1 + """, (dog["id"], str(today), titel)).fetchone() + + if exists: + logger.info(f"Meilenstein bereits vorhanden: Hund {dog['id']} '{titel}'") + continue + + # Tagebucheintrag anlegen + cur = conn.execute(""" + INSERT INTO diary (dog_id, datum, typ, titel, text, is_milestone) + VALUES (?, ?, 'milestone', ?, ?, 1) + """, (dog["id"], str(today), titel, text)) + entry_id = cur.lastrowid + + # Junction-Tabelle befüllen + conn.execute(""" + INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?, ?) + """, (entry_id, dog["id"])) + + # Push an Besitzer + send_push_to_user(dog["user_id"], { + "type": "milestone", + "title": titel, + "body": text, + "data": {"page": "diary"}, + "tag": f"milestone-{dog['id']}-{today}", + }) + + logger.info(f"Meilenstein erstellt: Hund {dog['id']} '{titel}' → diary_id={entry_id}") + created_total += 1 + + logger.info(f"Meilenstein-Check fertig — {created_total} Einträge erstellt.") + + +# ------------------------------------------------------------------ +# JOB: VDH-Events importieren +# ------------------------------------------------------------------ +async def _job_import_events(): + """ + Scrapt Veranstaltungen von vdh.de und importiert neue Events in die DB. + Bereits vorhandene external_ids werden übersprungen (Upsert-Logik). + """ + try: + from scraper.events_vdh import fetch_vdh_events + except ImportError as e: + logger.error(f"Event-Import: Scraper konnte nicht geladen werden: {e}") + return + + try: + events = await fetch_vdh_events() + except Exception as e: + logger.error(f"Event-Import: Fehler beim Scrapen: {e}") + return + + imported = 0 + with db() as conn: + for ev in events: + try: + exists = conn.execute( + "SELECT id FROM events WHERE external_id = ?", + (ev['external_id'],) + ).fetchone() + if not exists: + conn.execute(""" + INSERT INTO events (user_id, titel, datum, ort_name, typ, link, quelle, external_id, status) + VALUES (0, ?, ?, ?, ?, ?, 'vdh', ?, 'aktiv') + """, ( + ev['titel'], + ev['datum'], + ev.get('ort_name'), + ev['typ'], + ev.get('link'), + ev['external_id'], + )) + imported += 1 + except Exception as e: + logger.warning(f"Event-Import: Fehler beim Speichern von '{ev.get('titel')}': {e}") + + logger.info(f"Event-Import: {imported} neue Events importiert (von {len(events)} geparsten).") + + +# ------------------------------------------------------------------ +# JOB: Rassen aus TheDogAPI seeden +# ------------------------------------------------------------------ +async def _job_seed_breeds(): + """Lädt alle Hunderassen von TheDogAPI und speichert sie in wiki_rassen.""" + try: + from scraper.breeds import fetch_and_seed_breeds, mirror_breed_photos + except ImportError as e: + logger.error(f"Breed-Seed: Scraper konnte nicht geladen werden: {e}") + return + + try: + count = await fetch_and_seed_breeds() + logger.info(f"Breed seed job done: {count} breeds") + mirrored = await mirror_breed_photos() + logger.info(f"Breed photo mirror done: {mirrored} photos") + except Exception as e: + logger.error(f"Breed-Seed: Fehler: {e}") + + +# ------------------------------------------------------------------ +# JOB: Fehlende Rassen aus Wikidata ergänzen +# ------------------------------------------------------------------ +async def _job_seed_wikidata_breeds(): + """Lädt fehlende Hunderassen von Wikidata und spiegelt Fotos lokal.""" + try: + from scraper.wikidata_breeds import fetch_and_seed_wikidata_breeds, mirror_wikidata_photos + except ImportError as e: + logger.error(f"Wikidata-Seed: Scraper konnte nicht geladen werden: {e}") + return + + try: + count = await fetch_and_seed_wikidata_breeds() + logger.info(f"Wikidata breed seed done: {count} neue Rassen") + mirrored = await mirror_wikidata_photos() + logger.info(f"Wikidata photo mirror done: {mirrored} Fotos") + except Exception as e: + logger.error(f"Wikidata-Seed: Fehler: {e}") + + +def _compute_milestone(today: date, bday: date, dog_name: str): + """ + Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist, + sonst None. + + Regeln: + - Jahrestag (Monat + Tag stimmen überein, Jahrgang ≥ 1): + "🎂 ist X Jahr(e) alt!" + - Monatsjubiläum in den ersten 11 Monaten (Geburtsmonats-Tag): + "🐾 ist heute X Monat(e) alt!" + """ + # Jahrestag? + if today.month == bday.month and today.day == bday.day: + years = today.year - bday.year + if years <= 0: + return None # Geburtstag selbst (Tag 0) → kein Eintrag + years_label = f"{years} Jahr" if years == 1 else f"{years} Jahre" + titel = f"🎂 {dog_name} ist {years_label} alt!" + text = ( + f"Heute feiern wir {dog_name}s {years}. Geburtstag! 🐾🎉 " + f"Herzlichen Glückwunsch zum {years_label}!" + ) + return titel, text + + # Monatsjubiläum (nur innerhalb des ersten Lebensjahres)? + # today liegt im selben Monatstag wie der Geburtstag aber in einem anderen Monat. + if today.day == bday.day: + # Vollständige Monate seit Geburt berechnen + months = (today.year - bday.year) * 12 + (today.month - bday.month) + if 1 <= months <= 11: + months_label = f"{months} Monat" if months == 1 else f"{months} Monate" + titel = f"🐾 {dog_name} ist heute {months_label} alt!" + text = ( + f"{dog_name} wird heute {months_label} alt — " + f"was für ein tolles kleines Hundeleben! 🥳" + ) + return titel, text + + return None diff --git a/backend/scraper/__init__.py b/backend/scraper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/scraper/breeds.py b/backend/scraper/breeds.py new file mode 100644 index 0000000..bd3718b --- /dev/null +++ b/backend/scraper/breeds.py @@ -0,0 +1,138 @@ +"""Fetches breed data from TheDogAPI and seeds the wiki_rassen table.""" +import httpx, re, logging, os +from database import db + +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +BREEDS_DIR = os.path.join(MEDIA_DIR, "breeds") + +logger = logging.getLogger(__name__) + +def _slug(name: str) -> str: + return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-') + +def _derive_groesse(weight_max_kg: float) -> str: + if weight_max_kg <= 10: return 'klein' + if weight_max_kg <= 25: return 'mittel' + if weight_max_kg <= 40: return 'gross' + return 'sehr_gross' + +def _derive_aktivitaet(bred_for: str, temperament: str, group: str) -> str: + text = f"{bred_for or ''} {temperament or ''} {group or ''}".lower() + high_keywords = ['herding', 'hunting', 'sporting', 'working', 'energetic', 'active', 'agile'] + low_keywords = ['companion', 'toy', 'lap', 'gentle', 'calm', 'quiet'] + if any(k in text for k in high_keywords): return 'hoch' + if any(k in text for k in low_keywords): return 'niedrig' + return 'mittel' + +def _derive_erfahrung(temperament: str, group: str) -> str: + text = f"{temperament or ''} {group or ''}".lower() + expert = ['stubborn', 'independent', 'dominant', 'terrier', 'herding'] + advanced = ['protective', 'reserved', 'working', 'guard'] + if any(k in text for k in expert): return 'fortgeschritten' + if any(k in text for k in advanced): return 'fortgeschritten' + return 'anfaenger' + +def _derive_kinder(temperament: str) -> int: + if not temperament: return 1 + bad = ['aggressive', 'aloof', 'reserved with strangers'] + return 0 if any(k in temperament.lower() for k in bad) else 1 + +def _parse_weight_kg(weight_metric: str): + """Parse '7 - 14' or '14' -> (min, max) in kg""" + try: + parts = [p.strip() for p in weight_metric.replace(',', '.').split('-')] + nums = [float(p) for p in parts if p] + if len(nums) >= 2: return nums[0], nums[1] + if len(nums) == 1: return nums[0], nums[0] + except Exception: pass + return None, None + +async def mirror_breed_photos(): + """Download CDN breed photos to local storage and update foto_url in DB.""" + os.makedirs(BREEDS_DIR, exist_ok=True) + + with db() as conn: + rows = conn.execute( + "SELECT id, external_id, foto_url FROM wiki_rassen WHERE foto_url LIKE 'http%' AND foto_url NOT LIKE '/media/%'" + ).fetchall() + + if not rows: + logger.info("Breed photos: nothing to mirror") + return 0 + + mirrored = 0 + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + for row_id, ext_id, cdn_url in rows: + local_path = os.path.join(BREEDS_DIR, f"{ext_id}.jpg") + local_url = f"/media/breeds/{ext_id}.jpg" + + # Skip if already downloaded + if os.path.exists(local_path): + with db() as conn: + conn.execute("UPDATE wiki_rassen SET foto_url=? WHERE id=?", (local_url, row_id)) + mirrored += 1 + continue + + try: + r = await client.get(cdn_url) + if r.status_code == 200: + with open(local_path, "wb") as f: + f.write(r.content) + with db() as conn: + conn.execute("UPDATE wiki_rassen SET foto_url=? WHERE id=?", (local_url, row_id)) + mirrored += 1 + else: + logger.warning(f"Breed photo {ext_id}: HTTP {r.status_code}") + except Exception as e: + logger.warning(f"Breed photo {ext_id} download failed: {e}") + + logger.info(f"Breed photos mirrored: {mirrored}/{len(rows)}") + return mirrored + + +async def fetch_and_seed_breeds(): + """Fetch all breeds from TheDogAPI and upsert into wiki_rassen.""" + api_key = os.getenv("THEDOGAPI_KEY", "") + try: + async with httpx.AsyncClient(timeout=30) as client: + r = await client.get('https://api.thedogapi.com/v1/breeds', + headers={'x-api-key': api_key}) + r.raise_for_status() + breeds = r.json() + except Exception as e: + logger.error(f"TheDogAPI fetch failed: {e}") + return 0 + + seeded = 0 + with db() as conn: + for b in breeds: + try: + w_min, w_max = _parse_weight_kg(b.get('weight', {}).get('metric', '') or '') + groesse = _derive_groesse(w_max or 20) + aktivitaet = _derive_aktivitaet(b.get('bred_for',''), b.get('temperament',''), b.get('breed_group','')) + erfahrung = _derive_erfahrung(b.get('temperament',''), b.get('breed_group','')) + kinder = _derive_kinder(b.get('temperament','')) + wohnung = 1 if groesse == 'klein' and aktivitaet in ('niedrig','mittel') else 0 + foto_url = b.get('image', {}).get('url') or None + slug = _slug(b['name']) + conn.execute(""" + INSERT INTO wiki_rassen + (external_id, name, gruppe, herkunft, temperament, + gewicht_min_kg, gewicht_max_kg, groesse, lebensdauer, + foto_url, bred_for, aktivitaet, wohnung_geeignet, + kinder_geeignet, erfahrung, slug) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(external_id) DO UPDATE SET + foto_url=excluded.foto_url, + temperament=excluded.temperament + """, ( + b['id'], b['name'], + b.get('breed_group'), b.get('origin'), b.get('temperament'), + w_min, w_max, groesse, b.get('life_span'), + foto_url, b.get('bred_for'), aktivitaet, wohnung, kinder, erfahrung, slug + )) + seeded += 1 + except Exception as e: + logger.warning(f"Breed {b.get('name')} seed failed: {e}") + logger.info(f"Breeds seeded: {seeded}") + return seeded diff --git a/backend/scraper/events_vdh.py b/backend/scraper/events_vdh.py new file mode 100644 index 0000000..65821ee --- /dev/null +++ b/backend/scraper/events_vdh.py @@ -0,0 +1,317 @@ +""" +BAN YARO — VDH Veranstaltungs-Scraper +Scrapt Hundeveranstaltungen von vdh.de. +Bei Fehler oder 0 Ergebnissen: Fallback auf hartcodierte Events. +""" + +import logging +import re +from datetime import datetime +from html.parser import HTMLParser + +import httpx + +logger = logging.getLogger(__name__) + +FALLBACK_EVENTS = [ + {"titel": "VDH-Europasiegershow 2026", "datum": "2026-06-14", "ort_name": "Dortmund", "typ": "ausstellung", "link": "https://www.vdh.de", "external_id": "vdh-fallback-europasieger-2026"}, + {"titel": "Internationale Hundeausstellung Frankfurt", "datum": "2026-05-03", "ort_name": "Frankfurt am Main", "typ": "ausstellung", "link": "https://www.vdh.de", "external_id": "vdh-fallback-frankfurt-2026"}, + {"titel": "VDH-Bundessiegerprüfung Agility", "datum": "2026-07-19", "ort_name": "Leipzig", "typ": "wettkampf", "link": "https://www.vdh.de", "external_id": "vdh-fallback-agility-2026"}, + {"titel": "Rassehundetreffen München", "datum": "2026-08-22", "ort_name": "München", "typ": "treffen", "link": "https://www.vdh.de", "external_id": "vdh-fallback-muenchen-2026"}, + {"titel": "Hundesport-Turnier Berlin", "datum": "2026-09-12", "ort_name": "Berlin", "typ": "wettkampf", "link": "https://www.vdh.de", "external_id": "vdh-fallback-berlin-2026"}, +] + +# Mapping VDH-Kategorienamen → interne Typen +_TYP_MAP = { + "ausstellung": "ausstellung", + "show": "ausstellung", + "siegershow": "ausstellung", + "agility": "wettkampf", + "wettkampf": "wettkampf", + "turnier": "wettkampf", + "prüfung": "wettkampf", + "training": "training", + "treffen": "treffen", + "markt": "markt", +} + +# Monatsnamen Deutsch → Zahl +_MONATE = { + "januar": 1, "februar": 2, "märz": 3, "maerz": 3, + "april": 4, "mai": 5, "juni": 6, "juli": 7, + "august": 8, "september": 9, "oktober": 10, + "november": 11, "dezember": 12, +} + + +def _guess_typ(text: str) -> str: + """Bestimmt den Event-Typ anhand des Titels.""" + t = text.lower() + for keyword, typ in _TYP_MAP.items(): + if keyword in t: + return typ + return "sonstiges" + + +def _parse_date(raw: str) -> str | None: + """ + Versucht verschiedene Datumsformate zu parsen. + Gibt YYYY-MM-DD zurück oder None. + """ + raw = raw.strip() + + # ISO: 2026-05-03 + m = re.match(r'^(\d{4})-(\d{2})-(\d{2})$', raw) + if m: + return raw + + # DD.MM.YYYY oder D.M.YYYY + m = re.match(r'^(\d{1,2})\.(\d{1,2})\.(\d{4})$', raw) + if m: + d, mo, y = m.groups() + return f"{y}-{int(mo):02d}-{int(d):02d}" + + # DD. Monatsname YYYY (z.B. "14. Juni 2026") + m = re.match(r'^(\d{1,2})\.\s*(\w+)\s+(\d{4})$', raw) + if m: + d, mon_str, y = m.groups() + mon_num = _MONATE.get(mon_str.lower()) + if mon_num: + return f"{y}-{mon_num:02d}-{int(d):02d}" + + # Monatsname DD, YYYY (englisch, Fallback) + try: + dt = datetime.strptime(raw, "%B %d, %Y") + return dt.strftime("%Y-%m-%d") + except ValueError: + pass + + return None + + +class _VDHParser(HTMLParser): + """ + Einfacher Zustandsautomat-Parser für die VDH-Veranstaltungsseite. + Sucht nach typischen Strukturen: article, li.event, div mit Datums-/Titel-Klassen. + """ + + def __init__(self): + super().__init__() + self._events: list[dict] = [] + self._current: dict | None = None + self._depth = 0 + self._start_depth = 0 + self._capture = None # 'titel' | 'datum' | 'ort' + self._buf = "" + self._in_event = False + + # ---------- Hilfsmethoden ---------- + + def _is_event_container(self, tag, attrs): + """Erkennt Start eines Event-Blocks.""" + a = dict(attrs) + cls = a.get("class", "") + return ( + tag == "article" + or (tag in ("li", "div") and any( + kw in cls for kw in ("event", "veranstaltung", "termin", "entry", "item") + )) + ) + + def _is_title_tag(self, tag, attrs): + a = dict(attrs) + cls = a.get("class", "") + return tag in ("h2", "h3", "h4") or any( + kw in cls for kw in ("title", "titel", "name", "heading") + ) + + def _is_date_tag(self, tag, attrs): + a = dict(attrs) + cls = a.get("class", "") + it = a.get("itemprop", "") + return ( + tag in ("time",) + or any(kw in cls for kw in ("date", "datum", "time")) + or it in ("startDate", "endDate") + ) + + def _is_location_tag(self, tag, attrs): + a = dict(attrs) + cls = a.get("class", "") + it = a.get("itemprop", "") + return ( + any(kw in cls for kw in ("location", "ort", "venue", "place", "city")) + or it in ("location", "addressLocality") + ) + + # ---------- SAX-Events ---------- + + def handle_starttag(self, tag, attrs): + self._depth += 1 + a = dict(attrs) + + if not self._in_event and self._is_event_container(tag, attrs): + self._in_event = True + self._start_depth = self._depth + self._current = {"titel": "", "datum": "", "ort_name": "", "link": ""} + # Direkter Link auf dem Container? + if tag == "a" and "href" in a: + self._current["link"] = a["href"] + return + + if self._in_event: + # Link innerhalb des Event-Blocks + if tag == "a" and "href" in a and not self._current.get("link"): + href = a["href"] + if "vdh.de" in href or href.startswith("/"): + self._current["link"] = href + + #