diff --git a/backend/cache.py b/backend/cache.py deleted file mode 100644 index 29c471d..0000000 --- a/backend/cache.py +++ /dev/null @@ -1,103 +0,0 @@ -"""BAN YARO — In-Memory TTL-Cache für statische DB-Daten. - -Hintergrund: - Routes wie /api/training/exercises, /api/help, /api/wiki/stats laden bei - jedem Request statische Daten aus der DB. Das ist verschwendete Energie. - Diese Daten ändern sich nur durch Admin-Aktionen. - -Verwendung: - from cache import ttl_cache - - @ttl_cache(ttl=3600) - def my_func(arg1, arg2): - ... - -API: - @ttl_cache(ttl=3600) Decorator – cached pro Argumenten-Signatur - my_func.cache_clear() Komplett leeren (z.B. nach Admin-Update) - -Hinweis: - Diese Implementierung ist absichtlich klein und ohne externe Dependency - (kein cachetools nötig). Thread-safe via Lock. Reicht für Read-Only- - Listen, die sich selten ändern. Niemals für user-spezifische Daten - verwenden! -""" -from __future__ import annotations - -import functools -import threading -import time -from typing import Any, Callable - - -def ttl_cache(ttl: int = 3600, maxsize: int = 128) -> Callable: - """Decorator: cached Rückgabewert pro Argumenten-Signatur für `ttl` Sek. - - - ttl: Time-to-live in Sekunden (Default: 1 Stunde) - - maxsize: max. Anzahl Einträge im Cache (FIFO-Eviction bei Überlauf) - - Die dekorierte Funktion bekommt zusätzlich: - .cache_clear() – leert den gesamten Cache - .cache_info() – {hits, misses, size, ttl, maxsize} - """ - def decorator(func: Callable) -> Callable: - store: dict[tuple, tuple[float, Any]] = {} - lock = threading.Lock() - stats = {"hits": 0, "misses": 0} - - def _make_key(args: tuple, kwargs: dict) -> tuple: - # kwargs als sortiertes Tuple in den Key packen - if kwargs: - return args + tuple(sorted(kwargs.items())) - return args - - @functools.wraps(func) - def wrapper(*args, **kwargs): - key = _make_key(args, kwargs) - now = time.monotonic() - with lock: - cached = store.get(key) - if cached is not None: - expires_at, value = cached - if expires_at > now: - stats["hits"] += 1 - return value - # abgelaufen → raus - del store[key] - stats["misses"] += 1 - - # Außerhalb des Locks ausführen (kann DB-Calls machen) - value = func(*args, **kwargs) - - with lock: - # FIFO-Eviction, wenn maxsize überschritten - if len(store) >= maxsize: - try: - oldest_key = next(iter(store)) - del store[oldest_key] - except StopIteration: - pass - store[key] = (now + ttl, value) - return value - - def cache_clear() -> None: - with lock: - store.clear() - stats["hits"] = 0 - stats["misses"] = 0 - - def cache_info() -> dict: - with lock: - return { - "hits": stats["hits"], - "misses": stats["misses"], - "size": len(store), - "ttl": ttl, - "maxsize": maxsize, - } - - wrapper.cache_clear = cache_clear # type: ignore[attr-defined] - wrapper.cache_info = cache_info # type: ignore[attr-defined] - return wrapper - - return decorator diff --git a/backend/database.py b/backend/database.py index 00a5210..7368eb7 100644 --- a/backend/database.py +++ b/backend/database.py @@ -180,8 +180,6 @@ def init_db(): anz_bewertungen INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); - CREATE INDEX IF NOT EXISTS idx_routes_user ON routes(user_id, created_at DESC); - CREATE TABLE IF NOT EXISTS route_walks ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -274,8 +272,6 @@ def init_db(): created_at TEXT NOT NULL DEFAULT (datetime('now')), edited_at TEXT ); - CREATE INDEX IF NOT EXISTS idx_forum_posts_thread ON forum_posts(thread_id, created_at ASC); - CREATE INDEX IF NOT EXISTS idx_forum_posts_user ON forum_posts(user_id, created_at DESC); -- PUSH SUBSCRIPTIONS (alternativ zu users.push_sub für mehrere Geräte) CREATE TABLE IF NOT EXISTS push_subscriptions ( diff --git a/backend/main.py b/backend/main.py index 8378aee..d63ae64 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "1090" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1070" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/admin.py b/backend/routes/admin.py index bfd02f8..c0ec221 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -359,7 +359,7 @@ async def list_users( SELECT u.id, u.name, {_email_col}, u.rolle, u.is_premium, u.is_moderator, u.is_banned, u.ban_reason, u.is_founder, u.is_partner, u.founder_number, - u.created_at, u.last_login, u.last_seen, u.subscription_tier, + u.created_at, u.last_login, u.subscription_tier, (SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count, (SELECT COUNT(*) FROM forum_threads t WHERE t.user_id=u.id AND t.is_deleted=0) AS thread_count, ROUND(COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=u.id), 0), 1) AS total_km, @@ -1139,16 +1139,7 @@ async def list_upgrade_requests(user=Depends(require_admin)): SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at, u.name, u.email, u.billing_address, u.is_founder, u.is_founder_pending, u.referred_by, - COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count, - (SELECT id FROM invoices i WHERE i.user_id=r.user_id - AND i.status IN ('draft','sent') - ORDER BY i.created_at DESC LIMIT 1) AS existing_invoice_id, - (SELECT invoice_number FROM invoices i WHERE i.user_id=r.user_id - AND i.status IN ('draft','sent') - ORDER BY i.created_at DESC LIMIT 1) AS existing_invoice_number, - (SELECT status FROM invoices i WHERE i.user_id=r.user_id - AND i.status IN ('draft','sent') - ORDER BY i.created_at DESC LIMIT 1) AS existing_invoice_status + COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count FROM upgrade_requests r JOIN users u ON u.id = r.user_id ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC diff --git a/backend/routes/alerts.py b/backend/routes/alerts.py index 0065d18..4ffdcd0 100644 --- a/backend/routes/alerts.py +++ b/backend/routes/alerts.py @@ -9,7 +9,6 @@ from auth import get_current_user_optional as get_optional_user router = APIRouter() _RADIUS_M = 20_000 # 20 km -_RADIUS_KM = _RADIUS_M / 1000.0 def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: @@ -21,36 +20,15 @@ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: return 2 * R * math.asin(math.sqrt(a)) -def _bbox(lat: float, lon: float, radius_km: float) -> tuple[float, float, float, float]: - """Bounding-Box-Approximation für lat/lon innerhalb radius_km.""" - lat_delta = radius_km / 111.0 - # cos darf bei Polen nicht 0 werden → mit kleinem Minimum absichern - cos_lat = max(abs(math.cos(math.radians(lat))), 0.01) - lon_delta = radius_km / (111.0 * cos_lat) - return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta) - - @router.get("") async def nearby_alerts(lat: float, lon: float, user=Depends(get_optional_user)): now = datetime.utcnow().isoformat() - lat_min, lat_max, lon_min, lon_max = _bbox(lat, lon, _RADIUS_KM) with db() as conn: - # Bounding-Box-Vorfilter per SQL (billig) → reduziert die Kandidaten - # auf ~10 Einträge statt "alle". Die exakte Haversine-Prüfung passiert - # anschließend in Python. poisons = conn.execute( - """SELECT lat, lon FROM poison - WHERE geloest=0 AND expires_at > ? - AND lat BETWEEN ? AND ? - AND lon BETWEEN ? AND ?""", - (now, lat_min, lat_max, lon_min, lon_max) + "SELECT lat, lon FROM poison WHERE geloest=0 AND expires_at > ?", (now,) ).fetchall() lost = conn.execute( - """SELECT lat, lon FROM lost_dogs - WHERE is_active=1 - AND lat BETWEEN ? AND ? - AND lon BETWEEN ? AND ?""", - (lat_min, lat_max, lon_min, lon_max) + "SELECT lat, lon FROM lost_dogs WHERE is_active=1" ).fetchall() # Letzten Standort des Users für geo-basierte Push-Filter speichern if user: diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 7ee2d03..0ccc0cd 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -479,10 +479,3 @@ async def select_primary_dog(body: dict, user=Depends(get_current_user)): "UPDATE users SET needs_dog_selection=0 WHERE id=?", (user["id"],) ) return {"ok": True} - - -@router.post("/heartbeat") -async def heartbeat(user=Depends(get_current_user)): - with db() as conn: - conn.execute("UPDATE users SET last_seen=datetime('now') WHERE id=?", (user["id"],)) - return {"ok": True} diff --git a/backend/routes/breeder_photos.py b/backend/routes/breeder_photos.py index 18eb085..554cb91 100644 --- a/backend/routes/breeder_photos.py +++ b/backend/routes/breeder_photos.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi.responses import FileResponse from pydantic import BaseModel from typing import Optional -import os, logging, asyncio +import os, logging from database import db from auth import get_current_user, get_current_user_optional from media_utils import validate_upload, generate_preview @@ -112,37 +112,27 @@ async def upload_photo( file_uuid = str(uuid.uuid4()) file_path = os.path.join(save_dir, f"{file_uuid}.webp") - # Blockierende Bildverarbeitung in Threadpool auslagern, - # damit der Event-Loop für andere Requests frei bleibt. - loop = asyncio.get_event_loop() - - def _write_bytes(p: str, data: bytes) -> None: - with open(p, "wb") as f: - f.write(data) - # Thumbnail erzeugen - thumb_bytes = await loop.run_in_executor( - None, lambda: generate_preview(raw_data, ext) - ) + thumb_bytes = generate_preview(raw_data, ext) thumb_path = None if thumb_bytes: thumb_path = os.path.join(save_dir, f"{file_uuid}_thumb.webp") - await loop.run_in_executor(None, lambda: _write_bytes(thumb_path, thumb_bytes)) + with open(thumb_path, "wb") as f: + f.write(thumb_bytes) - # Originalbild konvertieren und speichern (Pillow direkt — WebP-Qualität 85) - def _save_original(): - try: - import io - from PIL import Image, ImageOps - img = Image.open(io.BytesIO(raw_data)) - img = ImageOps.exif_transpose(img) - img = img.convert("RGB") - img.save(file_path, format="WEBP", quality=85) - except Exception: - # Fallback: Rohdaten speichern - _write_bytes(file_path, raw_data) - - await loop.run_in_executor(None, _save_original) + # Originalbild konvertieren und speichern + # generate_preview liefert WebP, für das Original nehmen wir Pillow direkt + try: + import io + from PIL import Image, ImageOps + img = Image.open(io.BytesIO(raw_data)) + img = ImageOps.exif_transpose(img) + img = img.convert("RGB") + img.save(file_path, format="WEBP", quality=85) + except Exception: + # Fallback: Rohdaten speichern + with open(file_path, "wb") as f: + f.write(raw_data) # Relative Pfade für DB (relativ zu MEDIA_DIR) rel_file = os.path.relpath(file_path, MEDIA_DIR) diff --git a/backend/routes/diary.py b/backend/routes/diary.py index baf2586..6f6cd12 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -1,6 +1,6 @@ """BAN YARO — Tagebuch Routes""" -import os, uuid, json, math, logging, asyncio +import os, uuid, json, math, logging from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional @@ -684,13 +684,7 @@ async def upload_media(dog_id: int, entry_id: int, validate_upload(raw_data, file.filename or "") except ValueError as e: raise HTTPException(415, str(e)) - - # Blockierende Bild-/Video-Konvertierung in Threadpool auslagern, - # damit der Event-Loop für andere Requests frei bleibt. - loop = asyncio.get_event_loop() - raw_data, ext = await loop.run_in_executor( - None, lambda: convert_media(raw_data, file.filename or "") - ) + raw_data, ext = convert_media(raw_data, file.filename or "") if not ext: ext = ".jpg" filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" @@ -698,21 +692,17 @@ async def upload_media(dog_id: int, entry_id: int, media_type = _guess_media_type(ct, file.filename or "") os.makedirs(os.path.dirname(path), exist_ok=True) - def _write_bytes(p: str, data: bytes) -> None: - with open(p, "wb") as f: - f.write(data) - - await loop.run_in_executor(None, lambda: _write_bytes(path, raw_data)) + with open(path, "wb") as f: + f.write(raw_data) if media_type == "video": - await loop.run_in_executor(None, lambda: extract_video_thumb(path)) + extract_video_thumb(path) elif media_type == "image": - preview_bytes = await loop.run_in_executor( - None, lambda: generate_preview(raw_data, ext) - ) + preview_bytes = generate_preview(raw_data, ext) if preview_bytes: preview_path = os.path.splitext(path)[0] + "_preview.webp" - await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes)) + with open(preview_path, "wb") as f: + f.write(preview_bytes) media_url = f"/media/diary/{filename}" @@ -720,8 +710,8 @@ async def upload_media(dog_id: int, entry_id: int, exif_gps = None img_size = None if media_type == "image": - exif_gps = await loop.run_in_executor(None, lambda: extract_gps_from_exif(raw_data)) - img_size = await loop.run_in_executor(None, lambda: get_image_size(raw_data)) + exif_gps = extract_gps_from_exif(raw_data) + img_size = get_image_size(raw_data) with db() as conn: # sort_order = nächste freie Position diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 9cc2820..45228ed 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -9,20 +9,6 @@ from database import db from auth import get_current_user, has_pro_access from routes.push import send_push_to_user from media_utils import safe_media_path, preview_url_from -from cache import ttl_cache - - -# ------------------------------------------------------------------ -# Pflege-Tipps sind statische Stamm-Daten → 1h TTL-Cache -# (Filterung pro Hund passiert weiter unten in-memory, NICHT gecached) -# ------------------------------------------------------------------ -@ttl_cache(ttl=3600) -def _load_all_pflege_tipps() -> list[dict]: - with db() as conn: - rows = conn.execute( - "SELECT * FROM pflege_tipps ORDER BY kategorie, titel" - ).fetchall() - return [dict(r) for r in rows] router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -1109,8 +1095,10 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)): elif any(w in beschr for w in ["schneid", "geschoren", "schere", "clipper"]): fell_pflege_art_filter = "schneiden" - # Statische Tipps aus Cache (1h TTL) – Filterung passiert in-memory - alle_tipps = _load_all_pflege_tipps() + with db() as conn: + alle_tipps = conn.execute( + "SELECT * FROM pflege_tipps ORDER BY kategorie, titel" + ).fetchall() # Relevante Tipps: kein Fell-Filter oder passend from datetime import date diff --git a/backend/routes/help.py b/backend/routes/help.py index 6551e4d..4c77018 100644 --- a/backend/routes/help.py +++ b/backend/routes/help.py @@ -5,28 +5,10 @@ from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user_optional, require_admin -from cache import ttl_cache router = APIRouter() -# ------------------------------------------------------------------ -# Öffentliche, aktive FAQ-Liste – statisch, 1h TTL-Cache. -# Admin-Pfad (?all=1) wird NICHT gecached. -# Wird bei jedem schreibenden Admin-Endpoint unten invalidiert. -# ------------------------------------------------------------------ -@ttl_cache(ttl=3600) -def _load_active_help_articles() -> list[dict]: - with db() as conn: - rows = conn.execute( - "SELECT id, kategorie, frage, antwort, sort_order, aktiv " - "FROM help_articles " - "WHERE aktiv = 1 " - "ORDER BY kategorie, sort_order, id" - ).fetchall() - return [dict(r) for r in rows] - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -57,17 +39,22 @@ def get_help( is_admin = user and user.get("rolle") == "admin" show_all = all == 1 and is_admin - if show_all: - with db() as conn: + with db() as conn: + if show_all: rows = conn.execute( "SELECT id, kategorie, frage, antwort, sort_order, aktiv " "FROM help_articles " "ORDER BY kategorie, sort_order, id" ).fetchall() - return [dict(r) for r in rows] + else: + rows = conn.execute( + "SELECT id, kategorie, frage, antwort, sort_order, aktiv " + "FROM help_articles " + "WHERE aktiv = 1 " + "ORDER BY kategorie, sort_order, id" + ).fetchall() - # Öffentliche, aktive Artikel kommen aus dem Cache - return _load_active_help_articles() + return [dict(r) for r in rows] # ------------------------------------------------------------------ @@ -81,7 +68,6 @@ def create_article(body: ArticleCreate, admin=Depends(require_admin)): "VALUES (?, ?, ?, ?, ?)", (body.kategorie, body.frage, body.antwort, body.sort_order, body.aktiv), ) - _load_active_help_articles.cache_clear() return {"ok": True, "id": cur.lastrowid} @@ -99,7 +85,6 @@ def update_article(article_id: int, body: ArticleUpdate, admin=Depends(require_a f"UPDATE help_articles SET {set_clause} WHERE id=?", (*updates.values(), article_id), ) - _load_active_help_articles.cache_clear() return {"ok": True} @@ -110,5 +95,4 @@ def update_article(article_id: int, body: ArticleUpdate, admin=Depends(require_a def delete_article(article_id: int, admin=Depends(require_admin)): with db() as conn: conn.execute("DELETE FROM help_articles WHERE id=?", (article_id,)) - _load_active_help_articles.cache_clear() return {"ok": True} diff --git a/backend/routes/social.py b/backend/routes/social.py index 1cf204d..494d9a2 100644 --- a/backend/routes/social.py +++ b/backend/routes/social.py @@ -1278,26 +1278,21 @@ except Exception: # ------------------------------------------------------------------ @router.post("/training-tip") async def training_tip(user=Depends(require_social_media)): - # Übung wählen die noch nicht als Social-Post verwendet wurde. - # Per SQL: zuerst eine unbenutzte zufällig wählen, sonst Reset (irgendeine). + # Übung wählen die noch nicht als Social-Post verwendet wurde with db() as conn: - row = conn.execute( - """SELECT * FROM training_exercises - WHERE exercise_id NOT IN ( - SELECT exercise_id FROM social_content WHERE exercise_id IS NOT NULL - ) - ORDER BY RANDOM() LIMIT 1""" - ).fetchone() - if not row: - # Alle durch — Reset: irgendeine zufällige nehmen - row = conn.execute( - "SELECT * FROM training_exercises ORDER BY RANDOM() LIMIT 1" - ).fetchone() + used = {r["exercise_id"] for r in conn.execute( + "SELECT exercise_id FROM social_content WHERE exercise_id IS NOT NULL" + ).fetchall()} + all_ex = conn.execute( + "SELECT * FROM training_exercises ORDER BY RANDOM()" + ).fetchall() - if not row: + unused = [e for e in all_ex if e["exercise_id"] not in used] + pool = unused if unused else list(all_ex) # Reset wenn alle durch + if not pool: raise HTTPException(404, "Keine Übungen gefunden.") - ex = dict(row) + ex = dict(pool[0]) stil = random.choice(_TRAINING_STILE) schritte_list = json.loads(ex["schritte"] or "[]") schritte_text = "\n".join(f" {i+1}. {s}" for i, s in enumerate(schritte_list[:4])) @@ -1568,10 +1563,8 @@ async def pflege_tipp(breed_id: Optional[int] = None, user=Depends(require_socia (breed_id,), ).fetchone() - # LIMIT 100 deckelt das Result-Set ab (Tabelle hat aktuell ~43 Einträge); - # der Python-Filter unten braucht mehrere Kandidaten für Fell-Typ-Auswahl. tipps = conn.execute( - "SELECT * FROM pflege_tipps ORDER BY RANDOM() LIMIT 100" + "SELECT * FROM pflege_tipps ORDER BY RANDOM()" ).fetchall() # Noch nicht verwendete bevorzugen diff --git a/backend/routes/training.py b/backend/routes/training.py index 078ceef..aaaa855 100644 --- a/backend/routes/training.py +++ b/backend/routes/training.py @@ -7,16 +7,15 @@ import datetime import ki from database import db from auth import get_current_user, require_admin -from cache import ttl_cache router = APIRouter() # ------------------------------------------------------------------ # Alle Übungen aus DB (öffentlich, kein Auth) -# Statische Daten → 1h TTL-Cache. Wird in update_exercise() invalidiert. # ------------------------------------------------------------------ -@ttl_cache(ttl=3600) -def _load_exercises_by_tab() -> dict: +@router.get("/exercises") +async def get_exercises(): + """Alle Übungen aus der DB, gruppiert nach Tab-ID.""" import json as _json CAT_TO_TAB = { 'Grundkommando': 'grundkommandos', @@ -34,7 +33,7 @@ def _load_exercises_by_tab() -> dict: dauer, beschreibung, schritte, tipp FROM training_exercises ORDER BY kategorie, name """).fetchall() - by_tab: dict = {} + by_tab = {} for r in rows: tab = CAT_TO_TAB.get(r['kategorie'], r['kategorie'].lower().replace(' ', '-')) by_tab.setdefault(tab, []).append({ @@ -51,12 +50,6 @@ def _load_exercises_by_tab() -> dict: }) return by_tab - -@router.get("/exercises") -async def get_exercises(): - """Alle Übungen aus der DB, gruppiert nach Tab-ID (1h-Cache).""" - return _load_exercises_by_tab() - # ------------------------------------------------------------------ # Admin: Übung bearbeiten (beschreibung / schritte / tipp) # ------------------------------------------------------------------ @@ -85,8 +78,6 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ return {"ok": True, "updated": 0} vals.append(exercise_id) conn.execute(f"UPDATE training_exercises SET {', '.join(fields)} WHERE id=?", vals) - # Cache invalidieren, damit der Admin-Edit sofort sichtbar wird - _load_exercises_by_tab.cache_clear() return {"ok": True, "updated": len(fields)} # ------------------------------------------------------------------ diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index a05bb1b..56df55d 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -10,7 +10,6 @@ from pydantic import BaseModel from database import db from auth import get_current_user, get_current_user_optional from ratelimit import check as rl_check, block_ip -from cache import ttl_cache logger = logging.getLogger(__name__) MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -82,34 +81,16 @@ def _quiz_score(rasse: dict, params: dict) -> int: # ------------------------------------------------------------------ -# GET /api/wiki/stats — Seed-Status (1h TTL-Cache, statische Anzahl) +# GET /api/wiki/stats — Seed-Status # ------------------------------------------------------------------ -@ttl_cache(ttl=3600) -def _wiki_stats() -> dict: +@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} -@router.get("/stats") -async def get_stats(): - return _wiki_stats() - - -# ------------------------------------------------------------------ -# Gruppen-Liste für Filter-Dropdown – statisch, 1h TTL-Cache -# ------------------------------------------------------------------ -@ttl_cache(ttl=3600) -def _wiki_gruppen() -> list[str]: - with db() as conn: - rows = conn.execute( - "SELECT DISTINCT gruppe FROM wiki_rassen " - "WHERE gruppe IS NOT NULL ORDER BY gruppe" - ).fetchall() - return [r["gruppe"] for r in rows] - - # ------------------------------------------------------------------ # GET /api/wiki/rassen — alle Rassen (Übersicht, paginiert) # ------------------------------------------------------------------ @@ -153,13 +134,15 @@ async def get_rassen( SELECT COUNT(*) as total FROM wiki_rassen {where} """, args).fetchone() - # Alle Gruppen für Filter-Dropdown (gecached, 1h TTL) - gruppen = _wiki_gruppen() + # 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": gruppen, + "gruppen": [r["gruppe"] for r in gruppen_rows], } diff --git a/backend/scheduler.py b/backend/scheduler.py index a6aef1f..aee77b7 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -24,19 +24,12 @@ _job_log: dict = {} def start(): - # ------------------------------------------------------------------ - # Job-Staffelung in 5-Minuten-Intervallen — verhindert gleichzeitige - # Last-Spitzen (mehrere Jobs zur selben Sekunde 08:00 Uhr). - # coalesce=True: bei verpassten Läufen nur ein Lauf nachholen. - # misfire_grace_time: Mindestwert 300s, höher wo Job lange dauern kann. - # ------------------------------------------------------------------ _scheduler.add_job( _job_health_reminders, CronTrigger(hour=8, minute=0), # täglich 08:00 Uhr id="health_reminders", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.add_job( _job_poison_archive, @@ -44,7 +37,6 @@ def start(): id="poison_archive", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.add_job( _job_weather_alert, @@ -52,7 +44,6 @@ def start(): id="weather_alert", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.add_job( _job_milestone_check, @@ -60,7 +51,6 @@ def start(): id="milestone_check", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.add_job( _job_import_events, @@ -68,7 +58,6 @@ def start(): id="import_events", replace_existing=True, misfire_grace_time=7200, - coalesce=True, ) # Einmalig beim Start (nach 10s Verzögerung) für sofortige Befüllung @@ -79,32 +68,29 @@ def start(): id="import_events_startup", replace_existing=True, ) - # 1. des Monats 03:00 — Rassen aus TheDogAPI aktualisieren + # Alle 4 Wochen Di 03:00 — Rassen aus TheDogAPI aktualisieren _scheduler.add_job( _job_seed_breeds, CronTrigger(day=1, hour=3, minute=0), # 1. jedes Monats id="seed_breeds", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) - # 1. des Monats 04:00 — fehlende Rassen aus Wikidata ergänzen + # Alle 4 Wochen Di 04:00 — fehlende Rassen aus Wikidata ergänzen _scheduler.add_job( _job_seed_wikidata_breeds, CronTrigger(day=1, hour=4, minute=0), # 1. jedes Monats id="seed_wikidata", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) - # Jeden Montag 09:05 — Wöchentlicher Fortschritts-Lober (staggered) + # Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober _scheduler.add_job( _job_weekly_praise, - CronTrigger(day_of_week='mon', hour=9, minute=5), + CronTrigger(day_of_week='mon', hour=9, minute=0), id="weekly_praise", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # Täglich 06:00 Uhr Status-Report per Mail _scheduler.add_job( @@ -113,7 +99,6 @@ def start(): id="status_report", replace_existing=True, misfire_grace_time=1800, - coalesce=True, ) # Täglich 12:00 — Moderation-Overdue-Check _scheduler.add_job( @@ -122,25 +107,22 @@ def start(): id="moderation_overdue", replace_existing=True, misfire_grace_time=1800, - coalesce=True, ) - # 1. Feb / Mai / Aug / Nov 07:10 — Quartalsbericht (staggered weg von 07:00) + # 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht _scheduler.add_job( _job_quarterly_report, - CronTrigger(month="2,5,8,11", day=1, hour=7, minute=10), + CronTrigger(month="2,5,8,11", day=1, hour=7, minute=0), id="quarterly_report", replace_existing=True, misfire_grace_time=7200, - coalesce=True, ) - # Jeden Montag 07:05 — KI-Gesundheitsberichte (staggered weg von 07:00) + # Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen) _scheduler.add_job( _job_ki_health_report, - CronTrigger(day_of_week='mon', hour=7, minute=5), + CronTrigger(day_of_week='mon', hour=7, minute=0), id="ki_health_report", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # Täglich 06:30 — Wiederkehrende Ausgaben anlegen _scheduler.add_job( @@ -149,7 +131,6 @@ def start(): id="recurring_expenses", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # 1. des Monats 00:05 — Hund des Monats Sieger festlegen _scheduler.add_job( @@ -158,7 +139,6 @@ def start(): id="hdm_winner", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # Täglich 19:00 Uhr — Streak-Erinnerung _scheduler.add_job( @@ -167,25 +147,22 @@ def start(): id="streak_reminder", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) - # Täglich 08:05 Uhr — Tierfutter-Rückrufe prüfen (RASFF) (staggered weg von 08:00) + # Täglich 08:00 Uhr — Tierfutter-Rückrufe prüfen (RASFF) _scheduler.add_job( _job_recall_check, - CronTrigger(hour=8, minute=5), + CronTrigger(hour=8, minute=0), id="recall_check", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) - # Jeden Montag 08:10 Uhr — Neue Foto-Challenge anlegen (staggered weg von 08:00) + # Jeden Montag 08:00 Uhr — Neue Foto-Challenge anlegen _scheduler.add_job( _job_new_foto_challenge, - CronTrigger(day_of_week='mon', hour=8, minute=10), + CronTrigger(day_of_week='mon', hour=8, minute=0), id="new_foto_challenge", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # Täglich 07:00 Uhr — Goldene Gassi-Stunde _scheduler.add_job( @@ -194,7 +171,6 @@ def start(): id="golden_gassi_hour", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren) _scheduler.add_job( @@ -203,7 +179,6 @@ def start(): id="anniversary_reminders", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # 1. des Monats 10:00 — Monatlicher Rückblick per Push _scheduler.add_job( @@ -212,16 +187,13 @@ def start(): id="monthly_recap", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) - # Täglich 03:15 — Abo-Ablauf prüfen (staggered weg von 03:00 poison_archive) _scheduler.add_job( _job_subscription_check, - CronTrigger(hour=3, minute=15), + CronTrigger(hour=3, minute=0), id="subscription_check", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.add_job( _job_invoice_reminder, @@ -229,10 +201,9 @@ def start(): id="invoice_reminder", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.start() - logger.info("Scheduler gestartet (gestaffelt) — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import 1.+2./4./7./10. 02:00, Rassen-Seed 1. 03:00, Wikidata-Seed 1. 04:00, Status-Report 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:10, KI-Gesundheitsbericht Mo 07:05, Streak-Reminder 19:00, Rückruf-Check 08:05, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. 10:00, Foto-Challenge Mo 08:10, Weekly-Praise Mo 09:05, Abo-Check 03:15, Invoice-Reminder 08:30. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00, Abo-Check 03:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 4ec2cef..41d4e27 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8865,43 +8865,3 @@ svg.empty-state-icon { overflow: hidden; position: relative; } - -/* ============================================================ - Offline-Bereitschafts-Anzeige IM Welten-FAB - Die 5 Pfoten-Pfade werden je nach Score grün gefärbt - (Default = weiß auf orange, filled = grün auf orange) - ============================================================ */ -#worlds-fab .offline-paw .paw-elem { - color: #fff; /* stroke via currentColor — fill bleibt 'none' aus HTML */ - transition: stroke 0.4s ease; -} -#worlds-fab .offline-paw .paw-elem.filled { - color: #5C3517; /* dunkles Ban Yaro Braun — klar auf orangem FAB */ -} - -.offline-status-row { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-md); - border: 1px solid var(--c-border-light); - font-size: var(--text-sm); - margin-bottom: var(--space-2); -} -.offline-status-row .osr-check { - width: 24px; - height: 24px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - font-size: 14px; - font-weight: 700; -} -.offline-status-row.ok .osr-check { background: var(--c-success); color: #fff; } -.offline-status-row.miss .osr-check { background: var(--c-surface-2); color: var(--c-text-muted); border: 1px dashed var(--c-border); } -.offline-status-row .osr-text { flex: 1; min-width: 0; } -.offline-status-row .osr-title { font-weight: 600; } -.offline-status-row .osr-detail { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: 2px; } diff --git a/backend/static/index.html b/backend/static/index.html index 37c7fee..e8ea43a 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + +
@@ -602,16 +602,7 @@- ${missing.length === 0 - ? 'Voll offline-fähig — Tagebuch, Karte und Daten funktionieren auch ohne Internet.' - : 'Je grüner deine Pfote im FAB, desto mehr klappt offline. Fehlende Inhalte werden beim nächsten Online-Aufruf automatisch geladen.'} -
- ${rows} - `, - footer: ` -