Perf: 9 Performance-Fixes — SW by-v1072

Backend:
- DB: 3 neue Indizes (forum_posts thread+user, routes user) — Forum/Routen-Queries
- Caching: cache.py (TTL-Cache ohne neue Dependency) für 5 statische Listen
  (training_exercises, pflege_tipps, wiki_stats, wiki_gruppen, help_articles)
- diary.py + breeder_photos.py: Bildverarbeitung (ffmpeg/PIL/EXIF) per
  run_in_executor → blockiert Event-Loop nicht mehr
- scheduler.py: 11 kollidierende Jobs auf 5-Min-Intervalle gestaggert, coalesce=True
- social.py: ORDER BY RANDOM() ohne LIMIT in 2 Stellen gefixt
- alerts.py: Haversine-Loop bekommt SQL-Bounding-Box-Vorfilter

Frontend:
- sw.js: Tile-Cache mit LRU-Eviction (max 500 Einträge)
- admin.js: Event-Listener-Leak — Tab-Klicks per Delegation statt N Listener
- api.js: compressImage() Helper — Client-seitiges Resize auf max 2000px
  (HEIC/Videos/<500KB unverändert), integriert in 8 Upload-Stellen
  (diary, dog-profile×2, walks, poison, lost, health×2)

Bump APP_VER 1071 → 1072 (sw.js, app.js, main.py, index.html)
This commit is contained in:
rene 2026-05-26 06:30:36 +02:00
parent 3abf974d29
commit c03884cb81
23 changed files with 461 additions and 120 deletions

View file

@ -9,6 +9,7 @@ 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:
@ -20,15 +21,36 @@ 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 > ?", (now,)
"""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)
).fetchall()
lost = conn.execute(
"SELECT lat, lon FROM lost_dogs WHERE is_active=1"
"""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)
).fetchall()
# Letzten Standort des Users für geo-basierte Push-Filter speichern
if user:

View file

@ -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
import os, logging, asyncio
from database import db
from auth import get_current_user, get_current_user_optional
from media_utils import validate_upload, generate_preview
@ -112,27 +112,37 @@ 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 = generate_preview(raw_data, ext)
thumb_bytes = await loop.run_in_executor(
None, lambda: generate_preview(raw_data, ext)
)
thumb_path = None
if thumb_bytes:
thumb_path = os.path.join(save_dir, f"{file_uuid}_thumb.webp")
with open(thumb_path, "wb") as f:
f.write(thumb_bytes)
await loop.run_in_executor(None, lambda: _write_bytes(thumb_path, thumb_bytes))
# 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)
# 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)
# Relative Pfade für DB (relativ zu MEDIA_DIR)
rel_file = os.path.relpath(file_path, MEDIA_DIR)

View file

@ -1,6 +1,6 @@
"""BAN YARO — Tagebuch Routes"""
import os, uuid, json, math, logging
import os, uuid, json, math, logging, asyncio
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
@ -684,7 +684,13 @@ 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))
raw_data, ext = convert_media(raw_data, file.filename or "")
# 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 "")
)
if not ext:
ext = ".jpg"
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
@ -692,17 +698,21 @@ 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)
with open(path, "wb") as f:
f.write(raw_data)
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))
if media_type == "video":
extract_video_thumb(path)
await loop.run_in_executor(None, lambda: extract_video_thumb(path))
elif media_type == "image":
preview_bytes = generate_preview(raw_data, ext)
preview_bytes = await loop.run_in_executor(
None, lambda: generate_preview(raw_data, ext)
)
if preview_bytes:
preview_path = os.path.splitext(path)[0] + "_preview.webp"
with open(preview_path, "wb") as f:
f.write(preview_bytes)
await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes))
media_url = f"/media/diary/{filename}"
@ -710,8 +720,8 @@ async def upload_media(dog_id: int, entry_id: int,
exif_gps = None
img_size = None
if media_type == "image":
exif_gps = extract_gps_from_exif(raw_data)
img_size = get_image_size(raw_data)
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))
with db() as conn:
# sort_order = nächste freie Position

View file

@ -9,6 +9,20 @@ 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")
@ -1095,10 +1109,8 @@ 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"
with db() as conn:
alle_tipps = conn.execute(
"SELECT * FROM pflege_tipps ORDER BY kategorie, titel"
).fetchall()
# Statische Tipps aus Cache (1h TTL) Filterung passiert in-memory
alle_tipps = _load_all_pflege_tipps()
# Relevante Tipps: kein Fell-Filter oder passend
from datetime import date

View file

@ -5,10 +5,28 @@ 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
# ------------------------------------------------------------------
@ -39,22 +57,17 @@ def get_help(
is_admin = user and user.get("rolle") == "admin"
show_all = all == 1 and is_admin
with db() as conn:
if show_all:
if show_all:
with db() as conn:
rows = conn.execute(
"SELECT id, kategorie, frage, antwort, sort_order, aktiv "
"FROM help_articles "
"ORDER BY kategorie, sort_order, id"
).fetchall()
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()
return [dict(r) for r in rows]
return [dict(r) for r in rows]
# Öffentliche, aktive Artikel kommen aus dem Cache
return _load_active_help_articles()
# ------------------------------------------------------------------
@ -68,6 +81,7 @@ 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}
@ -85,6 +99,7 @@ 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}
@ -95,4 +110,5 @@ 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}

View file

@ -1278,21 +1278,26 @@ 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
# Übung wählen die noch nicht als Social-Post verwendet wurde.
# Per SQL: zuerst eine unbenutzte zufällig wählen, sonst Reset (irgendeine).
with db() as conn:
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()
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()
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:
if not row:
raise HTTPException(404, "Keine Übungen gefunden.")
ex = dict(pool[0])
ex = dict(row)
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]))
@ -1563,8 +1568,10 @@ 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()"
"SELECT * FROM pflege_tipps ORDER BY RANDOM() LIMIT 100"
).fetchall()
# Noch nicht verwendete bevorzugen

View file

@ -7,15 +7,16 @@ 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.
# ------------------------------------------------------------------
@router.get("/exercises")
async def get_exercises():
"""Alle Übungen aus der DB, gruppiert nach Tab-ID."""
@ttl_cache(ttl=3600)
def _load_exercises_by_tab() -> dict:
import json as _json
CAT_TO_TAB = {
'Grundkommando': 'grundkommandos',
@ -33,7 +34,7 @@ async def get_exercises():
dauer, beschreibung, schritte, tipp
FROM training_exercises ORDER BY kategorie, name
""").fetchall()
by_tab = {}
by_tab: dict = {}
for r in rows:
tab = CAT_TO_TAB.get(r['kategorie'], r['kategorie'].lower().replace(' ', '-'))
by_tab.setdefault(tab, []).append({
@ -50,6 +51,12 @@ async def get_exercises():
})
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)
# ------------------------------------------------------------------
@ -78,6 +85,8 @@ 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)}
# ------------------------------------------------------------------

View file

@ -10,6 +10,7 @@ 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")
@ -81,16 +82,34 @@ def _quiz_score(rasse: dict, params: dict) -> int:
# ------------------------------------------------------------------
# GET /api/wiki/stats — Seed-Status
# GET /api/wiki/stats — Seed-Status (1h TTL-Cache, statische Anzahl)
# ------------------------------------------------------------------
@router.get("/stats")
async def get_stats():
@ttl_cache(ttl=3600)
def _wiki_stats() -> dict:
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)
# ------------------------------------------------------------------
@ -134,15 +153,13 @@ async def get_rassen(
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()
# Alle Gruppen für Filter-Dropdown (gecached, 1h TTL)
gruppen = _wiki_gruppen()
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],
"gruppen": gruppen,
}