diff --git a/backend/database.py b/backend/database.py index 95c77f8..193d41d 100644 --- a/backend/database.py +++ b/backend/database.py @@ -482,6 +482,10 @@ def _migrate(conn_factory): ("sitters", "anz_bewertungen", "INTEGER DEFAULT 0"), # Tagebuch-Medien: Cover-Bild markieren ("diary_media", "is_cover", "INTEGER NOT NULL DEFAULT 0"), + # Forum-Threads: optionaler Standort + ("forum_threads", "thread_lat", "REAL"), + ("forum_threads", "thread_lon", "REAL"), + ("forum_threads", "thread_ort", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/main.py b/backend/main.py index cdaa64f..b4a3b71 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ BAN YARO — FastAPI Hauptanwendung import os import logging +from collections import deque from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse @@ -13,10 +14,25 @@ from database import init_db import ki import scheduler as sched +# In-Memory Log-Buffer (letzte 500 Zeilen) +log_buffer: deque = deque(maxlen=500) + +class _BufferHandler(logging.Handler): + _fmt = logging.Formatter() + + def emit(self, record): + log_buffer.append({ + 't': self._fmt.formatTime(record, '%H:%M:%S'), + 'l': record.levelname, + 'm': record.getMessage(), + 'n': record.name, + }) + logging.basicConfig( level = logging.INFO, format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) +logging.getLogger().addHandler(_BufferHandler()) logger = logging.getLogger(__name__) @@ -78,6 +94,7 @@ from routes.sharing import dog_router as sharing_dog_router, share_router as from routes.widget import router as widget_router from routes.notifications import router as notifications_router from routes.services import router as services_router +from routes.ratings import router as ratings_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -109,6 +126,7 @@ app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen" app.include_router(widget_router, prefix="/api/widget", tags=["Widget"]) app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(services_router, prefix="/api/services", tags=["Services"]) +app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"]) # ------------------------------------------------------------------ diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 2ddfa21..7f5b3c6 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -116,6 +116,14 @@ async def stats(user=Depends(require_mod)): media_count = media_diary + media_health routes_total = conn.execute("SELECT COUNT(*) FROM routes").fetchone()[0] events_total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0] + osm_total = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0] + osm_tiles = conn.execute("SELECT COUNT(*) FROM osm_tiles").fetchone()[0] + osm_by_type = { + row[0]: row[1] + for row in conn.execute( + "SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC" + ).fetchall() + } return { "users_total": users_total, @@ -131,6 +139,9 @@ async def stats(user=Depends(require_mod)): "media_count": media_count, "routes_total": routes_total, "events_total": events_total, + "osm_total": osm_total, + "osm_tiles": osm_tiles, + "osm_by_type": osm_by_type, } @@ -438,6 +449,18 @@ async def system_info(user=Depends(require_admin)): } +# ------------------------------------------------------------------ +# GET /api/admin/logs +# ------------------------------------------------------------------ +@router.get("/logs") +async def get_logs(lines: int = 200, level: str = "", user=Depends(require_admin)): + from main import log_buffer + entries = list(log_buffer) + if level: + entries = [e for e in entries if e['l'] == level.upper()] + return entries[-lines:] + + # ------------------------------------------------------------------ # GET /api/admin/audit # ------------------------------------------------------------------ diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 76aeb1c..d75cf28 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -15,16 +15,20 @@ 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'] +KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung', + 'spaziergang', 'ausflug', 'training', 'ernaehrung', 'probleme', 'tauschboerse'] # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class ThreadCreate(BaseModel): - kategorie: str = 'allgemein' - titel: str - text: str + kategorie: str = 'allgemein' + titel: str + text: str + thread_lat: Optional[float] = None + thread_lon: Optional[float] = None + thread_ort: Optional[str] = None class PostCreate(BaseModel): text: str @@ -143,9 +147,10 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): 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()) + """INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (user['id'], data.kategorie, data.titel.strip(), data.text.strip(), + data.thread_lat, data.thread_lon, data.thread_ort) ) row = conn.execute( """SELECT t.*, u.name AS autor_name @@ -523,7 +528,8 @@ async def members_map(): AND forum_lat IS NOT NULL AND forum_lon IS NOT NULL""" ).fetchall() - return [dict(r) for r in rows] + return [{'vorname': r['vorname'] or '?', 'lat': round(r['lat'], 2), 'lon': round(r['lon'], 2)} + for r in rows] # ------------------------------------------------------------------ diff --git a/backend/routes/friends.py b/backend/routes/friends.py index 634be79..724c2b4 100644 --- a/backend/routes/friends.py +++ b/backend/routes/friends.py @@ -96,7 +96,7 @@ async def search_users(q: str = "", user=Depends(get_current_user)): FROM dogs d WHERE d.user_id=u.id AND d.is_public=1) AS dogs_json FROM users u WHERE u.id != ? - AND u.name LIKE ? + AND norm(u.name) LIKE norm(?) AND NOT EXISTS ( SELECT 1 FROM friendships f WHERE (f.requester_id=? AND f.addressee_id=u.id) @@ -142,6 +142,19 @@ async def send_request(target_id: int, user=Depends(get_current_user)): (uid, target_id) ) + # In-App Benachrichtigung + Badge + try: + with db() as conn: + conn.execute( + """INSERT INTO notifications (user_id, type, title, body, data) + VALUES (?, 'friend_request', 'Neue Freundschaftsanfrage', ?, ?)""", + (target_id, + f"{user['name']} möchte dein Freund sein.", + '{"page":"friends"}') + ) + except Exception: + pass + try: from routes.push import send_push_to_user send_push_to_user(target_id, { @@ -223,7 +236,7 @@ async def get_activity(user=Depends(get_current_user)): JOIN users u ON u.id = d.user_id WHERE d.user_id IN ({ph}) ORDER BY dg.created_at DESC - LIMIT 30 + LIMIT 15 """, friend_ids).fetchall() # Gesundheitseinträge der Freunde (nur Typ + Datum, kein Inhalt) @@ -241,7 +254,7 @@ async def get_activity(user=Depends(get_current_user)): JOIN users u ON u.id = d.user_id WHERE d.user_id IN ({ph}) ORDER BY h.created_at DESC - LIMIT 30 + LIMIT 15 """, friend_ids).fetchall() # Gassi-Treffen der Freunde @@ -259,7 +272,7 @@ async def get_activity(user=Depends(get_current_user)): JOIN users u ON u.id = w.user_id WHERE w.user_id IN ({ph}) ORDER BY w.created_at DESC - LIMIT 30 + LIMIT 15 """, friend_ids).fetchall() # Neue Hunde (angelegt in den letzten 30 Tagen) @@ -277,7 +290,7 @@ async def get_activity(user=Depends(get_current_user)): WHERE d.user_id IN ({ph}) AND d.created_at >= datetime('now', '-30 days') ORDER BY d.created_at DESC - LIMIT 30 + LIMIT 15 """, friend_ids).fetchall() _ICON = { @@ -309,7 +322,7 @@ async def get_activity(user=Depends(get_current_user)): # Zusammenführen und nach created_at absteigend sortieren, max. 30 items.sort(key=lambda x: x["created_at"] or "", reverse=True) - return items[:30] + return items[:50] @router.delete("/{friend_user_id}") diff --git a/backend/routes/osm.py b/backend/routes/osm.py index 51c1f17..d462313 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -20,6 +20,9 @@ CACHE_ZOOM = 12 CACHE_DAYS = 14 OVERPASS_URL = 'https://overpass-api.de/api/interpreter' +# Globales Limit: max 2 gleichzeitige Overpass-Anfragen (Prewarm + User geteilt) +_overpass_sem = asyncio.Semaphore(2) + OSM_QUERIES = { 'waste_basket': '[out:json][timeout:20];node["amenity"="waste_basket"]({bbox});out;', 'dog_park': '[out:json][timeout:25];(way["leisure"="dog_park"]({bbox});node["leisure"="dog_park"]({bbox});way["leisure"="park"]["dog"="yes"]({bbox});node["leisure"="park"]["dog"="yes"]({bbox}););out center;', @@ -61,10 +64,17 @@ def _covering_tiles(south, west, north, east, zoom): # Overpass-Fetch + Cache # ------------------------------------------------------------------ async def _fetch_overpass(query): - async with httpx.AsyncClient(timeout=40) as client: - r = await client.post(OVERPASS_URL, data={'data': query}) - r.raise_for_status() - return r.json().get('elements', []) + for attempt in range(3): + async with _overpass_sem: + async with httpx.AsyncClient(timeout=40) as client: + r = await client.post(OVERPASS_URL, data={'data': query}) + if r.status_code != 429: + r.raise_for_status() + return r.json().get('elements', []) + logger.warning(f"Overpass 429 (Versuch {attempt + 1}/3)") + # Semaphore freigeben, dann warten + await asyncio.sleep(45 * (attempt + 1)) + raise Exception("Overpass 429 nach 3 Versuchen") def _stale_tiles(poi_type, tiles): stale = [] @@ -143,11 +153,7 @@ async def get_pois( stale = _stale_tiles(type, tiles) if stale and not fast: - sem = asyncio.Semaphore(3) - async def _limited(x, y): - async with sem: - await _fetch_and_store_tile(type, x, y) - await asyncio.gather(*[_limited(x, y) for (x, y) in stale]) + await asyncio.gather(*[_fetch_and_store_tile(type, x, y) for (x, y) in stale]) fetched_fresh = True with db() as conn: @@ -313,12 +319,8 @@ async def analyze_region( tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM) async def _warmup(): - sem = asyncio.Semaphore(3) - async def _limited(poi_type, x, y): - async with sem: - await _fetch_and_store_tile(poi_type, x, y) tasks = [ - _limited(pt, x, y) + _fetch_and_store_tile(pt, x, y) for pt in OSM_QUERIES for (x, y) in _stale_tiles(pt, tiles) ] diff --git a/backend/routes/ratings.py b/backend/routes/ratings.py new file mode 100644 index 0000000..dba63f5 --- /dev/null +++ b/backend/routes/ratings.py @@ -0,0 +1,124 @@ +"""BAN YARO — Bewertungssystem (Ratings)""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() + +VALID_TYPES = {'walk', 'sitting', 'place', 'route'} + +# Tabelle → bewertung + anz_bewertungen aktualisieren +TABLE_MAP = { + 'walk': 'walks', + 'sitting': 'sitters', + 'place': 'places', + 'route': 'routes', +} + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class RatingCreate(BaseModel): + target_type: str + target_id: int + stars: int + kommentar: Optional[str] = None + + +# ------------------------------------------------------------------ +# POST /api/ratings — Bewertung abgeben oder aktualisieren +# ------------------------------------------------------------------ +@router.post("", status_code=200) +async def upsert_rating(data: RatingCreate, user=Depends(get_current_user)): + if data.target_type not in VALID_TYPES: + raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(VALID_TYPES)}") + if not (1 <= data.stars <= 5): + raise HTTPException(400, "Sterne müssen zwischen 1 und 5 liegen.") + if data.kommentar and len(data.kommentar) > 200: + raise HTTPException(400, "Kommentar darf maximal 200 Zeichen lang sein.") + + table = TABLE_MAP[data.target_type] + kommentar = data.kommentar.strip() if data.kommentar else None + + with db() as conn: + # Prüfen ob Zielobjekt existiert + row = conn.execute(f"SELECT id FROM {table} WHERE id=?", (data.target_id,)).fetchone() + if not row: + raise HTTPException(404, "Objekt nicht gefunden.") + + # Upsert + conn.execute(""" + INSERT INTO ratings (user_id, target_type, target_id, stars, kommentar) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id, target_type, target_id) + DO UPDATE SET stars=excluded.stars, kommentar=excluded.kommentar, created_at=datetime('now') + """, (user['id'], data.target_type, data.target_id, data.stars, kommentar)) + + # Durchschnitt berechnen und Zieltabelle aktualisieren + agg = conn.execute(""" + SELECT AVG(CAST(stars AS REAL)) AS avg_stars, COUNT(*) AS cnt + FROM ratings + WHERE target_type=? AND target_id=? + """, (data.target_type, data.target_id)).fetchone() + + conn.execute( + f"UPDATE {table} SET bewertung=?, anz_bewertungen=? WHERE id=?", + (round(agg['avg_stars'], 2), agg['cnt'], data.target_id) + ) + + return {"bewertung": round(agg['avg_stars'], 2), "anz_bewertungen": agg['cnt']} + + +# ------------------------------------------------------------------ +# GET /api/ratings/{type}/{id} — Bewertungen für ein Objekt laden +# WICHTIG: Feste Route vor {param} in main.py registrieren +# ------------------------------------------------------------------ +@router.get("/{target_type}/{target_id}") +async def get_ratings(target_type: str, target_id: int): + if target_type not in VALID_TYPES: + raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(VALID_TYPES)}") + + with db() as conn: + rows = conn.execute(""" + SELECT r.id, r.stars, r.kommentar, r.created_at, + u.name AS user_name + FROM ratings r + JOIN users u ON u.id = r.user_id + WHERE r.target_type=? AND r.target_id=? + ORDER BY r.created_at DESC + """, (target_type, target_id)).fetchall() + + agg = conn.execute(""" + SELECT AVG(CAST(stars AS REAL)) AS avg_stars, COUNT(*) AS cnt + FROM ratings WHERE target_type=? AND target_id=? + """, (target_type, target_id)).fetchone() + + return { + "bewertung": round(agg['avg_stars'], 2) if agg['avg_stars'] else 0, + "anz_bewertungen": agg['cnt'], + "ratings": [dict(r) for r in rows], + } + + +# ------------------------------------------------------------------ +# GET /api/ratings/me/{type}/{id} — Eigene Bewertung für ein Objekt +# WICHTIG: Diese Route muss VOR /{target_type}/{target_id} stehen! +# ------------------------------------------------------------------ +@router.get("/me/{target_type}/{target_id}") +async def get_my_rating(target_type: str, target_id: int, user=Depends(get_current_user)): + if target_type not in VALID_TYPES: + raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(VALID_TYPES)}") + + with db() as conn: + row = conn.execute(""" + SELECT stars, kommentar FROM ratings + WHERE user_id=? AND target_type=? AND target_id=? + """, (user['id'], target_type, target_id)).fetchone() + + if not row: + return {"stars": None, "kommentar": None} + return dict(row) diff --git a/backend/scheduler.py b/backend/scheduler.py index a404fc0..a476ce9 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -58,7 +58,7 @@ def start(): ) _scheduler.add_job( _job_prewarm_cities, - CronTrigger(day_of_week='sun', hour=1), # jeden Sonntag 01:00 Uhr + CronTrigger(hour=2, minute=0), # täglich 02:00 Uhr id="prewarm_cities", replace_existing=True, misfire_grace_time=7200, @@ -71,14 +71,6 @@ def start(): id="import_events_startup", replace_existing=True, ) - # Einmalig beim Start (nach 90s) — OSM-Tiles für Großstädte vorwärmen - _scheduler.add_job( - _job_prewarm_cities, - 'date', - run_date=datetime.now(tz=_TZ) + timedelta(seconds=90), - id="prewarm_cities_startup", - replace_existing=True, - ) # Einmalig beim Start (nach 15s Verzögerung) — Rassen aus TheDogAPI befüllen _scheduler.add_job( _job_seed_breeds, @@ -482,6 +474,15 @@ _CITIES_DE = [ (52.2763, 8.0479, "Osnabrück"), (53.8755, 10.7000, "Lübeck-Ost"), (51.9333, 6.8667, "Borken"), + # München Umland + (48.0734, 11.9661, "Ebersberg"), + (47.9947, 11.6612, "Holzkirchen"), + (48.0628, 11.6574, "Ottobrunn"), + (48.2456, 11.3712, "Dachau"), + (48.1667, 11.7833, "Vaterstetten"), + (48.2667, 11.6667, "Garching"), + (48.0667, 11.4667, "Gauting"), + (47.9833, 11.3000, "Starnberg"), ] async def _job_prewarm_cities(): @@ -493,7 +494,7 @@ async def _job_prewarm_cities(): REPORT_INTERVAL = 5 * 3600 # alle 5 Stunden logger.info("City-Prewarm Job startet…") - sem = asyncio.Semaphore(2) + sem = asyncio.Semaphore(1) total_fetched = 0 cities_done = 0 start_time = time.monotonic() @@ -504,7 +505,7 @@ async def _job_prewarm_cities(): async with sem: await _fetch_and_store_tile(poi_type, x, y) total_fetched += 1 - await asyncio.sleep(1.5) + await asyncio.sleep(5) async def _send_progress(subject_prefix, cities_done, total_cities, eta_str=""): if not ADMIN: diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 610cd46..58fcff0 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -695,6 +695,7 @@ html.modal-open { width: 100%; max-width: 480px; max-height: 90vh; + max-height: 90svh; overflow: hidden; /* Modal selbst scrollt NICHT */ display: flex; flex-direction: column; @@ -1163,7 +1164,7 @@ html.modal-open { border-radius: 50%; border: none; background: rgba(0,0,0,.50); - color: rgba(255,255,255,.55); + color: #9ca3af; font-size: 14px; cursor: pointer; display: flex; @@ -2050,6 +2051,9 @@ html.modal-open { white-space: nowrap; flex-shrink: 0; } +.rk-rec-btn--active { + animation: rec-pulse 1.2s ease-in-out infinite; +} .rk-filters { display: flex; flex-direction: column; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 4605477..f65326f 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '187'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '202'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { @@ -357,12 +357,14 @@ const App = (() => { title: 'Was möchtest du hinzufügen?', body: `
@@ -386,6 +388,8 @@ const App = (() => { if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); } if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); } if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); } + if (action === 'chat') { navigate('chat'); setTimeout(() => pages['chat'].module?._showNewMessagePicker?.(), 400); } + if (action === 'forum') { navigate('forum'); setTimeout(() => pages['forum'].module?.openNew?.(), 400); } }, 350); }, { once: true }); } diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 4a15929..df55f65 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -104,6 +104,20 @@ window.Page_admin = (() => { ${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')} ${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')} ${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')} + ${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)')} + ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)')} + + +
OSM-Cache nach Typ
+${UI.escape(entry.text)}
` + ? `${UI.escape(_cleanText(entry.text))}
` : ''} ${tags.length - ? `- Noch keine Aktivitäten. Füge Freunde hinzu! -
-+ Keine Einträge in dieser Kategorie. +
+Keine Benachrichtigungen