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:
parent
aa70a838f2
commit
e56183b642
21 changed files with 648 additions and 175 deletions
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
124
backend/routes/ratings.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue