Feature: Ratings, Lightbox, Forum-Standort, Notifications, Routen-Recording, Chat-Picker

- Bewertungssystem (ratings.py): Sterne für Sitter/Walks/Places/Routen
- Admin: Server-Log-Viewer + OSM-Cache-Statistiken
- Chat: "Neue Nachricht"-Button mit Freundesliste-Picker
- Forum: 5 neue Kategorien, Standorteingabe (locationPicker), Absende-Toast, Lightbox
- Freunde: Aktivitäts-Filter (Chips), Freundschaftsanfrage → In-App-Notification
- Sitter: locationPicker statt manuelle Koordinateneingabe + ratingStars
- Tagebuch: Bilder-Lightbox im Detail-View, iOS-Modal-Header-Fix (90svh)
- Routen: Start/Stopp-Button wechselt Zustand, nutzt Page_map.isRecording()
- Benachrichtigungen: Delete-Button sichtbar, typ-basierte Navigation, Toast-Feedback
- OSM: globales Semaphore + 429-Retry-Logic; Scheduler: München-Umland, täglich
- SW by-v225, APP_VER 202
This commit is contained in:
rene 2026-04-19 09:40:35 +02:00
parent aa70a838f2
commit e56183b642
21 changed files with 648 additions and 175 deletions

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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]
# ------------------------------------------------------------------

View file

@ -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}")

View file

@ -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)
]

124
backend/routes/ratings.py Normal file
View file

@ -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)