Sprint 15: Zeitzone-Fix, Gewichts-Sync, Öffnungszeiten, KI-Bericht, POI-Moderation — SW by-v432, APP_VER 411

- client_time: Browser-Lokalzeit bei allen Creates mitschicken (Tagebuch, Notizen,
  Forum, Verlorener Hund, Routen) — kein UTC-Versatz mehr bei Einträgen
- Gewicht-Sync: health typ=gewicht schreibt dogs.gewicht_kg, einmalige Migration
- Praxen: opening_hours + lat/lon/osm_id in tieraerzte-Tabelle, OSM-Nearby-Lookup,
  Öffnungszeiten in Karte und Detailansicht
- KI-Gesundheitsbericht: alle 2 Wochen automatisch, ki_health_reports-Tabelle,
  Frontend-Banner mit Archiv (letzten 5 Berichte)
- POI-Korrekturen: User schlägt Öffnungszeiten-Änderung vor, Moderatoren-Tab
  genehmigt/lehnt ab, user_edited-Flag schützt vor Overpass-Überschreibung
- timeutils.py: safe_client_time() zentral für alle Routen
This commit is contained in:
rene 2026-04-26 15:38:50 +02:00
parent 679dbdd862
commit 06bd8525ed
21 changed files with 724 additions and 75 deletions

View file

@ -440,9 +440,13 @@ def _migrate(conn_factory):
("health", "datei_typ", "TEXT"),
("health", "tierarzt_id", "INTEGER"),
# Tierärzte: Adresse aufgeteilt in Strasse/PLZ/Ort
("tieraerzte", "strasse", "TEXT"),
("tieraerzte", "plz", "TEXT"),
("tieraerzte", "ort", "TEXT"),
("tieraerzte", "strasse", "TEXT"),
("tieraerzte", "plz", "TEXT"),
("tieraerzte", "ort", "TEXT"),
("tieraerzte", "opening_hours", "TEXT"),
("tieraerzte", "lat", "REAL"),
("tieraerzte", "lon", "REAL"),
("tieraerzte", "osm_id", "TEXT"),
# Gesundheit: Erinnerungsintervall für wiederkehrende Einträge
("health", "intervall_tage", "INTEGER"),
# Routen: neue Felder
@ -453,6 +457,7 @@ def _migrate(conn_factory):
("osm_pois", "opening_hours", "TEXT"),
("osm_pois", "phone", "TEXT"),
("osm_pois", "website", "TEXT"),
("osm_pois", "user_edited", "INTEGER NOT NULL DEFAULT 0"),
# Forum: Threads brauchen text + antworten-Zähler
("forum_threads", "text", "TEXT NOT NULL DEFAULT ''"),
("forum_threads", "antworten", "INTEGER NOT NULL DEFAULT 0"),
@ -1157,3 +1162,51 @@ def _migrate(conn_factory):
ON notes(user_id, created_at DESC);
""")
logger.info("Migration: notes Tabelle bereit.")
conn.execute("""
CREATE TABLE IF NOT EXISTS ki_health_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bericht TEXT NOT NULL,
erstellt_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ki_health_reports_dog
ON ki_health_reports(dog_id, erstellt_at DESC)
""")
logger.info("Migration: ki_health_reports Tabelle bereit.")
conn.execute("""
CREATE TABLE IF NOT EXISTS osm_poi_edits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
osm_id TEXT NOT NULL,
poi_name TEXT NOT NULL,
field TEXT NOT NULL DEFAULT 'opening_hours',
old_value TEXT,
new_value TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
mod_id INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
resolved_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_osm_poi_edits_status
ON osm_poi_edits(status, created_at DESC);
""")
logger.info("Migration: osm_poi_edits Tabelle bereit.")
# Einmalige Datenmigration: dogs.gewicht_kg mit aktuellem Gesundheits-Gewicht synchronisieren
conn.execute("""
UPDATE dogs SET gewicht_kg = (
SELECT wert FROM health
WHERE health.dog_id = dogs.id AND health.typ = 'gewicht' AND health.wert IS NOT NULL
ORDER BY health.datum DESC, health.id DESC LIMIT 1
)
WHERE EXISTS (
SELECT 1 FROM health
WHERE health.dog_id = dogs.id AND health.typ = 'gewicht' AND health.wert IS NOT NULL
)
""")
logger.info("Migration: dogs.gewicht_kg aus health synchronisiert.")

View file

@ -10,6 +10,7 @@ import ki as KI
import httpx
import weather as weather_mod
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif
from timeutils import safe_client_time
logger = logging.getLogger(__name__)
@ -19,6 +20,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel):
datum: Optional[str] = None # ISO date, default heute
client_time: Optional[str] = None # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS)
typ: str = "eintrag"
titel: Optional[str] = None
text: Optional[str] = None
@ -288,14 +290,14 @@ async def create_diary(dog_id: int, data: DiaryCreate,
else:
all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn)
ct = safe_client_time(data.client_time)
datum = data.datum or ct[:10]
conn.execute(
"""INSERT INTO diary
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, location_name, is_milestone)
VALUES (?,
COALESCE(?, date('now')),
?,?,?,?,?,?,?,?)""",
(dog_id, data.datum, data.typ, data.titel, data.text,
json.dumps(tags), data.gps_lat, data.gps_lon, data.location_name, int(data.is_milestone))
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, location_name, is_milestone, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(dog_id, datum, data.typ, data.titel, data.text,
json.dumps(tags), data.gps_lat, data.gps_lon, data.location_name, int(data.is_milestone), ct)
)
entry = conn.execute(
"SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1",

View file

@ -6,6 +6,7 @@ from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
from timeutils import safe_client_time
from routes.push import send_push_to_user
from media_utils import convert_media, extract_video_thumb
@ -24,15 +25,17 @@ KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung',
# Schemas
# ------------------------------------------------------------------
class ThreadCreate(BaseModel):
kategorie: str = 'allgemein'
titel: str
text: str
thread_lat: Optional[float] = None
thread_lon: Optional[float] = None
thread_ort: Optional[str] = None
kategorie: str = 'allgemein'
titel: str
text: str
thread_lat: Optional[float] = None
thread_lon: Optional[float] = None
thread_ort: Optional[str] = None
client_time: Optional[str] = None
class PostCreate(BaseModel):
text: str
text: str
client_time: Optional[str] = None
class ThreadPatch(BaseModel):
is_pinned: Optional[int] = None
@ -165,11 +168,12 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, "Ungültige Kategorie.")
with db() as conn:
ct = safe_client_time(data.client_time)
cur = conn.execute(
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user['id'], data.kategorie, data.titel.strip(), data.text.strip(),
data.thread_lat, data.thread_lon, data.thread_ort)
data.thread_lat, data.thread_lon, data.thread_ort, ct)
)
row = conn.execute(
"""SELECT t.*, u.name AS autor_name
@ -307,9 +311,10 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
if thread['is_deleted']:
raise HTTPException(404, "Thread nicht gefunden.")
ct = safe_client_time(data.client_time)
cur = conn.execute(
"INSERT INTO forum_posts (thread_id, user_id, text) VALUES (?, ?, ?)",
(thread_id, user['id'], data.text.strip())
"INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
(thread_id, user['id'], data.text.strip(), ct)
)
conn.execute(
"UPDATE forum_threads SET antworten = antworten + 1 WHERE id = ?",

View file

@ -75,6 +75,18 @@ class HealthUpdate(BaseModel):
# ------------------------------------------------------------------
# Hilfsfunktionen
# ------------------------------------------------------------------
def _sync_gewicht(conn, dog_id: int):
"""Aktualisiert dogs.gewicht_kg auf den neuesten Gewichtseintrag (nach datum)."""
conn.execute(
"""UPDATE dogs SET gewicht_kg = (
SELECT wert FROM health
WHERE dog_id=? AND typ='gewicht' AND wert IS NOT NULL
ORDER BY datum DESC, id DESC LIMIT 1
) WHERE id=?""",
(dog_id, dog_id)
)
def _check_dog_owner(conn, dog_id: int, user_id: int):
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
@ -160,6 +172,8 @@ async def create_health(dog_id: int, data: HealthCreate,
(dog_id,)
).fetchone()
media_map = _fetch_media_items(conn, [row["id"]])
if data.typ == 'gewicht':
_sync_gewicht(conn, dog_id)
return _entry_with_media(row, media_map)
@ -186,6 +200,8 @@ async def update_health(dog_id: int, entry_id: int, data: HealthUpdate,
conn.execute(f"UPDATE health SET {set_clause} WHERE id=?", values)
row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone()
media_map = _fetch_media_items(conn, [entry_id])
if row["typ"] == 'gewicht':
_sync_gewicht(conn, dog_id)
return _entry_with_media(row, media_map)
@ -197,11 +213,14 @@ async def delete_health(dog_id: int, entry_id: int, user=Depends(get_current_use
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
entry = conn.execute(
"SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
"SELECT id, typ FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
was_gewicht = entry["typ"] == 'gewicht'
conn.execute("DELETE FROM health WHERE id=?", (entry_id,))
if was_gewicht:
_sync_gewicht(conn, dog_id)
return None
@ -431,3 +450,18 @@ async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)):
raise HTTPException(402, str(e))
except KIUnavailableError as e:
raise HTTPException(503, str(e))
# ------------------------------------------------------------------
# GET /api/dogs/{dog_id}/health/ki-berichte
# ------------------------------------------------------------------
@router.get("/{dog_id}/health/ki-berichte")
async def list_ki_berichte(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
_check_dog_owner(conn, dog_id, user["id"])
rows = conn.execute(
"""SELECT id, bericht, erstellt_at FROM ki_health_reports
WHERE dog_id=? ORDER BY erstellt_at DESC LIMIT 5""",
(dog_id,)
).fetchall()
return [dict(r) for r in rows]

View file

@ -7,6 +7,7 @@ from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from timeutils import safe_client_time
from routes.push import send_push_to_all
from media_utils import convert_media
@ -37,6 +38,7 @@ class LostDogCreate(BaseModel):
lat: float
lon: float
dog_id: Optional[int] = None
client_time: Optional[str] = None
# ------------------------------------------------------------------
@ -76,11 +78,12 @@ async def list_lost(lat: Optional[float] = None, lon: Optional[float] = None,
@router.post("", status_code=201)
async def report_lost(data: LostDogCreate, user=Depends(get_current_user)):
with db() as conn:
ct = safe_client_time(data.client_time)
conn.execute(
"""INSERT INTO lost_dogs (user_id, dog_id, name, rasse, beschreibung, lat, lon)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
"""INSERT INTO lost_dogs (user_id, dog_id, name, rasse, beschreibung, lat, lon, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user["id"], data.dog_id, data.name, data.rasse,
data.beschreibung, data.lat, data.lon)
data.beschreibung, data.lat, data.lon, ct)
)
row = conn.execute(
"SELECT * FROM lost_dogs WHERE user_id=? ORDER BY id DESC LIMIT 1",

View file

@ -45,11 +45,20 @@ async def mod_stats(user=Depends(require_moderator)):
except Exception:
pass
pending_poi_edits = 0
try:
pending_poi_edits = conn.execute(
"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'"
).fetchone()[0]
except Exception:
pass
return {
"open_reports": open_reports,
"pending_fotos": pending_fotos,
"banned_users": banned_users,
"pending_zuchter": pending_zuchter,
"open_reports": open_reports,
"pending_fotos": pending_fotos,
"banned_users": banned_users,
"pending_zuchter": pending_zuchter,
"pending_poi_edits": pending_poi_edits,
}
@ -207,3 +216,56 @@ async def mod_foto_action(foto_id: int, data: dict, user=Depends(require_moderat
reject_reason=data.get("reject_reason", ""),
)
return await review_submission(foto_id, model, user)
# ------------------------------------------------------------------
# GET /api/moderation/poi-edits — ausstehende POI-Korrekturen
# ------------------------------------------------------------------
@router.get("/poi-edits")
async def mod_poi_edits(user=Depends(require_moderator)):
with db() as conn:
rows = conn.execute("""
SELECT e.id, e.osm_id, e.poi_name, e.field,
e.old_value, e.new_value, e.status,
e.created_at, e.resolved_at,
u.name AS einreicher_name
FROM osm_poi_edits e
JOIN users u ON u.id = e.user_id
ORDER BY e.status ASC, e.created_at DESC
LIMIT 100
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# PATCH /api/moderation/poi-edits/{id} — approve / reject
# ------------------------------------------------------------------
@router.patch("/poi-edits/{edit_id}")
async def mod_poi_edit_action(edit_id: int, data: dict,
user=Depends(require_moderator)):
action = data.get("action")
if action not in ("approve", "reject"):
raise HTTPException(400, "action muss 'approve' oder 'reject' sein.")
with db() as conn:
edit = conn.execute(
"SELECT * FROM osm_poi_edits WHERE id=?", (edit_id,)
).fetchone()
if not edit:
raise HTTPException(404, "Korrektur nicht gefunden.")
if edit["status"] != "pending":
raise HTTPException(409, "Korrektur wurde bereits bearbeitet.")
if action == "approve":
conn.execute(
f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?",
(edit["new_value"], edit["osm_id"])
)
conn.execute(
"""UPDATE osm_poi_edits SET status=?, mod_id=?, resolved_at=datetime('now')
WHERE id=?""",
(action + "d", user["id"], edit_id)
)
return {"status": action + "d"}

View file

@ -8,6 +8,7 @@ from pydantic import BaseModel
from typing import Optional, Any, List
from database import db
from auth import get_current_user
from timeutils import safe_client_time
router = APIRouter()
logger = logging.getLogger(__name__)
@ -21,6 +22,7 @@ class NoteCreate(BaseModel):
meta_json: Optional[Any] = None
location_name: Optional[str] = None
parent_label: Optional[str] = None
client_time: Optional[str] = None
class NoteUpdate(BaseModel):
@ -194,7 +196,7 @@ async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
raise HTTPException(400, "Notiz darf nicht leer sein.")
meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
now = safe_client_time(data.client_time)
with db() as conn:
conn.execute(

View file

@ -156,7 +156,7 @@ async def _fetch_and_store_tile(poi_type, x, y):
ON CONFLICT(osm_id, type) DO UPDATE SET
lat=excluded.lat, lon=excluded.lon,
name=excluded.name,
opening_hours=excluded.opening_hours,
opening_hours=CASE WHEN user_edited=1 THEN opening_hours ELSE excluded.opening_hours END,
phone=excluded.phone,
website=excluded.website,
cached_at=excluded.cached_at
@ -372,3 +372,44 @@ async def analyze_region(
background_tasks.add_task(_warmup)
return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())}
# ------------------------------------------------------------------
# POST /pois/{osm_id}/edit — Nutzer schlägt Korrektur vor
# ------------------------------------------------------------------
class PoiEditCreate(BaseModel):
poi_name: str
field: str = 'opening_hours'
new_value: str
@router.post('/pois/{osm_id}/edit', status_code=201)
async def submit_poi_edit(osm_id: str, data: PoiEditCreate,
user=Depends(get_current_user)):
if data.field not in ('opening_hours',):
raise HTTPException(400, "Nur 'opening_hours' kann korrigiert werden.")
if not data.new_value.strip():
raise HTTPException(400, "Neuer Wert darf nicht leer sein.")
with db() as conn:
poi = conn.execute(
"SELECT name, opening_hours FROM osm_pois WHERE osm_id=?", (osm_id,)
).fetchone()
if not poi:
raise HTTPException(404, "POI nicht gefunden.")
existing = conn.execute(
"""SELECT id FROM osm_poi_edits
WHERE osm_id=? AND field=? AND status='pending' AND user_id=?""",
(osm_id, data.field, user["id"])
).fetchone()
if existing:
raise HTTPException(409, "Du hast bereits eine ausstehende Korrektur für diesen POI.")
conn.execute(
"""INSERT INTO osm_poi_edits (osm_id, poi_name, field, old_value, new_value, user_id)
VALUES (?, ?, ?, ?, ?, ?)""",
(osm_id, data.poi_name or poi["name"], data.field,
poi[data.field], data.new_value.strip(), user["id"])
)
return {"status": "pending", "message": "Korrektur wurde zur Prüfung eingereicht."}

View file

@ -8,6 +8,7 @@ from typing import Optional, List
from database import db
from auth import get_current_user, get_current_user_optional
from routes.achievements import update_streak, check_and_award
from timeutils import safe_client_time
from media_utils import convert_media
from routes.push import send_push_to_user
@ -52,6 +53,7 @@ class RouteCreate(BaseModel):
leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = False
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
client_time: Optional[str] = None
class RouteUpdate(BaseModel):
name: Optional[str] = None
@ -146,20 +148,21 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)):
gps_json = json.dumps([p.model_dump() for p in data.gps_track])
is_valid = int(_check_speed(data.distanz_km, data.dauer_min))
ct = safe_client_time(data.client_time)
with db() as conn:
cur = conn.execute("""
INSERT INTO routes
(user_id, name, beschreibung, gps_track, distanz_km, dauer_min,
schwierigkeit, untergrund, schatten, leine_empfohlen, is_public,
hunde_tauglichkeit, is_valid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
hunde_tauglichkeit, is_valid, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user['id'], data.name, data.beschreibung, gps_json,
data.distanz_km, data.dauer_min, data.schwierigkeit, data.untergrund,
int(data.schatten) if data.schatten is not None else None,
int(data.leine_empfohlen) if data.leine_empfohlen is not None else None,
int(data.is_public) if data.is_public is not None else 1,
data.hunde_tauglichkeit, is_valid,
data.hunde_tauglichkeit, is_valid, ct,
))
row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone()
update_streak(user['id'], conn)

View file

@ -1,6 +1,7 @@
"""BAN YARO — Tierärzte Routes (user-level, nie löschen)"""
from fastapi import APIRouter, Depends, HTTPException
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from database import db
@ -11,34 +12,60 @@ router = APIRouter()
class TierarztCreate(BaseModel):
name: str
strasse: Optional[str] = None
plz: Optional[str] = None
ort: Optional[str] = None
telefon: Optional[str] = None
notfall_telefon: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
notizen: Optional[str] = None
ist_notfallpraxis: bool = False
strasse: Optional[str] = None
plz: Optional[str] = None
ort: Optional[str] = None
telefon: Optional[str] = None
notfall_telefon: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
notizen: Optional[str] = None
ist_notfallpraxis: bool = False
opening_hours: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
osm_id: Optional[str] = None
class TierarztUpdate(BaseModel):
name: Optional[str] = None
strasse: Optional[str] = None
plz: Optional[str] = None
ort: Optional[str] = None
telefon: Optional[str] = None
notfall_telefon: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
notizen: Optional[str] = None
name: Optional[str] = None
strasse: Optional[str] = None
plz: Optional[str] = None
ort: Optional[str] = None
telefon: Optional[str] = None
notfall_telefon: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
notizen: Optional[str] = None
ist_notfallpraxis: Optional[bool] = None
aktiv: Optional[bool] = None # False = inaktiv (Umzug etc.)
aktiv: Optional[bool] = None
opening_hours: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
osm_id: Optional[str] = None
def _fmt_opening_hours(raw: str | None) -> str | None:
"""Wandelt OSM-opening_hours-String in lesbares Deutsch um.
Beispiel: "Mo-Fr 08:00-18:00; Sa 09:00-13:00"
"MoFr 08:0018:00 · Sa 09:0013:00"
"""
if not raw:
return None
if raw.strip().lower() == "24/7":
return "24/7 geöffnet"
result = raw.replace(" - ", "").replace("-", "", 1)
result = "; ".join(
part.strip().replace(";", "").replace(",", " · ")
for part in raw.split(";")
)
return result
@router.get("")
async def list_tieraerzte(user=Depends(get_current_user)):
"""Alle Tierärzte des Users — aktive zuerst, dann inaktive (für Historienansicht)."""
"""Alle Tierärzte des Users — aktive zuerst, dann inaktive."""
with db() as conn:
rows = conn.execute(
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name",
@ -47,17 +74,64 @@ async def list_tieraerzte(user=Depends(get_current_user)):
return [dict(r) for r in rows]
@router.get("/osm-nearby")
async def osm_nearby(
lat: float = Query(...),
lon: float = Query(...),
radius_km: float = Query(5.0),
user=Depends(get_current_user)
):
"""Findet Tierarzt-POIs aus dem OSM-Cache in der Nähe (max. 10 Treffer)."""
with db() as conn:
rows = conn.execute(
"""SELECT osm_id, name, lat, lon, opening_hours, phone, website
FROM osm_pois
WHERE type = 'tierarzt'
AND lat BETWEEN ? AND ?
AND lon BETWEEN ? AND ?""",
(lat - radius_km / 111.0, lat + radius_km / 111.0,
lon - radius_km / 111.0, lon + radius_km / 111.0)
).fetchall()
def _dist(r):
dlat = (r["lat"] - lat) * math.pi / 180
dlon = (r["lon"] - lon) * math.pi / 180
a = math.sin(dlat/2)**2 + math.cos(lat*math.pi/180) * math.cos(r["lat"]*math.pi/180) * math.sin(dlon/2)**2
return 6371 * 2 * math.asin(math.sqrt(a))
results = []
for r in rows:
d = _dist(r)
if d <= radius_km:
results.append({
"osm_id": r["osm_id"],
"name": r["name"],
"lat": r["lat"],
"lon": r["lon"],
"opening_hours": r["opening_hours"],
"opening_hours_fmt": _fmt_opening_hours(r["opening_hours"]),
"phone": r["phone"],
"website": r["website"],
"distanz_km": round(d, 2),
})
results.sort(key=lambda x: x["distanz_km"])
return results[:10]
@router.post("", status_code=201)
async def create_tierarzt(data: TierarztCreate, user=Depends(get_current_user)):
with db() as conn:
conn.execute(
"""INSERT INTO tieraerzte
(user_id, name, strasse, plz, ort, telefon, notfall_telefon,
email, website, notizen, ist_notfallpraxis)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
email, website, notizen, ist_notfallpraxis,
opening_hours, lat, lon, osm_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(user["id"], data.name, data.strasse, data.plz, data.ort,
data.telefon, data.notfall_telefon, data.email, data.website,
data.notizen, int(data.ist_notfallpraxis))
data.notizen, int(data.ist_notfallpraxis),
data.opening_hours, data.lat, data.lon, data.osm_id)
)
row = conn.execute(
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY id DESC LIMIT 1",

View file

@ -100,6 +100,14 @@ def start():
replace_existing=True,
misfire_grace_time=1800,
)
# Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen)
_scheduler.add_job(
_job_ki_health_report,
CronTrigger(day_of_week='mon', hour=7, minute=0),
id="ki_health_report",
replace_existing=True,
misfire_grace_time=3600,
)
_scheduler.start()
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 beim Start. OSM-Cache: on-demand (kein Prewarm).")
@ -745,6 +753,76 @@ async def _job_weekly_praise():
_log_job("weekly_praise", "ok", f"{generated} Lob-Texte f\u00fcr KW {d[1]}")
# ------------------------------------------------------------------
# JOB: KI-Gesundheitsberichte (alle 2 Wochen, jeden Montag 07:00)
# ------------------------------------------------------------------
async def _job_ki_health_report():
"""
Erstellt für jeden Hund, der seit mehr als 13 Tagen keinen KI-Gesundheitsbericht
hat (oder noch keinen hatte), einen neuen Bericht via ki.health_summary() und
schickt eine Push-Notification an den Besitzer. Maximal 20 Hunde pro Lauf.
"""
import ki as KI
with db() as conn:
dogs = conn.execute("""
SELECT d.id AS dog_id, d.name, d.rasse, d.geburtstag, d.gewicht_kg, d.user_id
FROM dogs d
WHERE d.id NOT IN (
SELECT dog_id FROM ki_health_reports
WHERE erstellt_at >= datetime('now', '-13 days')
)
ORDER BY d.id
LIMIT 20
""").fetchall()
dogs = [dict(d) for d in dogs]
if not dogs:
logger.info("KI-Gesundheitsbericht: Keine fälligen Hunde.")
_log_job("ki_health_report", "ok", "0 Berichte erstellt")
return
count = 0
for dog in dogs:
try:
with db() as conn:
health_rows = conn.execute(
"SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
(dog["dog_id"],)
).fetchall()
health_data = [dict(r) for r in health_rows]
dog_info = {
"name": dog["name"],
"rasse": dog.get("rasse"),
"geburtstag": dog.get("geburtstag"),
"gewicht_kg": dog.get("gewicht_kg"),
}
bericht = await KI.health_summary(health_data=health_data, dog_info=dog_info)
with db() as conn:
conn.execute(
"INSERT INTO ki_health_reports (dog_id, user_id, bericht) VALUES (?, ?, ?)",
(dog["dog_id"], dog["user_id"], bericht)
)
send_push_to_user(dog["user_id"], {
"type": "ki_health_report",
"title": f"Gesundheitsbericht für {dog['name']}",
"body": "Dein KI-Assistent hat einen neuen Bericht erstellt.",
"data": {"page": "health"},
})
count += 1
logger.info(f"KI-Gesundheitsbericht: Bericht für Hund {dog['dog_id']} ({dog['name']}) erstellt.")
except Exception as e:
logger.error(f"KI-Gesundheitsbericht: Fehler für Hund {dog['dog_id']} ({dog['name']}): {e}")
logger.info(f"KI-Gesundheitsbericht Job fertig — {count}/{len(dogs)} Berichte erstellt.")
_log_job("ki_health_report", "ok", f"{count} Berichte erstellt")
# ------------------------------------------------------------------
# JOB: Status-Report per Mail (4× täglich)
# ------------------------------------------------------------------
@ -801,6 +879,7 @@ async def _job_status_report():
"seed_breeds_startup": "Rassen-Seed (TheDogAPI)",
"seed_wikidata_startup":"Rassen-Seed (Wikidata)",
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
"ki_health_report": "KI-Gesundheitsberichte",
}
job_rows_html = ""
job_rows_txt = ""

View file

@ -168,6 +168,7 @@ const API = (() => {
kiZusammenfassung(dogId) {
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
},
kiBerichte(dogId) { return get(`/dogs/${dogId}/health/ki-berichte`); },
symptomCheck(dogId, symptoms) {
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
},
@ -180,9 +181,10 @@ const API = (() => {
// TIERÄRZTE
// ----------------------------------------------------------
const tieraerzte = {
list() { return get('/tieraerzte'); },
create(data) { return post('/tieraerzte', data); },
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
list() { return get('/tieraerzte'); },
create(data) { return post('/tieraerzte', data); },
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
};
// ----------------------------------------------------------
@ -598,13 +600,18 @@ const API = (() => {
}
}
// Lokale Gerätezeit als ISO-String ("2026-04-26T12:00:00") für server-seitige created_at
function clientNow() {
return new Date().toLocaleString('sv').replace(' ', 'T');
}
// Öffentliche API
return {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
subscribeToPush, getLocation,
subscribeToPush, getLocation, clientNow,
APIError,
};

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '408'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '411'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {

View file

@ -1732,6 +1732,7 @@ window.Page_diary = (() => {
gps_lat: _locLat,
gps_lon: _locLon,
location_name: _locName,
client_time: API.clientNow(),
};
async function _uploadNewFiles(entryId) {
@ -2012,7 +2013,7 @@ window.Page_diary = (() => {
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName };
const payload = { text, parent_label: parentLabel, location_name: locationName, client_time: API.clientNow() };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {

View file

@ -531,7 +531,7 @@ window.Page_forum = (() => {
if (!text) { UI.toast.warning('Bitte Text eingeben.'); return; }
await UI.asyncButton(btn, async () => {
const post = await API.forum.addPost(thread.id, { text });
const post = await API.forum.addPost(thread.id, { text, client_time: API.clientNow() });
// Foto hochladen falls vorhanden
const files = Array.from(document.getElementById('forum-reply-file')?.files || []);
@ -900,12 +900,13 @@ window.Page_forum = (() => {
const loc = _picker ? _picker.getValue() : { lat: null, lon: null, name: null };
const created = await API.forum.create({
kategorie: fd.kategorie,
titel: (fd.titel || '').trim(),
text: (fd.text || '').trim(),
thread_lat: loc.lat ?? null,
thread_lon: loc.lon ?? null,
thread_ort: loc.name ?? null,
kategorie: fd.kategorie,
titel: (fd.titel || '').trim(),
text: (fd.text || '').trim(),
thread_lat: loc.lat ?? null,
thread_lon: loc.lon ?? null,
thread_ort: loc.name ?? null,
client_time: API.clientNow(),
});
// Fotos hochladen

View file

@ -152,6 +152,7 @@ window.Page_health = (() => {
</button>
</div>
${transponderHtml}
<div id="health-ki-berichte"></div>
<div id="health-reminders"></div>
<div class="by-tabs" id="by-tabs"></div>
<div id="by-tab-content"></div>
@ -166,6 +167,7 @@ window.Page_health = (() => {
await _loadAll();
_renderErinnerungen();
_renderTab();
_loadKiBerichte(dog.id);
}
// ----------------------------------------------------------
@ -1009,7 +1011,8 @@ window.Page_health = (() => {
if (praxis) {
const adresse = [praxis.strasse, [praxis.plz, praxis.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ');
const tel = praxis.telefon ? ` · <a href="tel:${_esc(praxis.telefon)}">${_esc(praxis.telefon)}</a>` : '';
rows.push(['Praxis', `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}`]);
const oh = praxis.opening_hours ? `<br><small style="color:var(--c-text-secondary)">🕐 ${_esc(_fmtOeffnungszeiten(praxis.opening_hours))}</small>` : '';
rows.push(['Praxis', `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}${oh}`]);
}
} else if (e.tierarzt_name) {
rows.push(['Tierarzt', _esc(e.tierarzt_name)]);
@ -1561,6 +1564,11 @@ window.Page_health = (() => {
<div class="health-card-meta">
${[p.strasse, [p.plz, p.ort].filter(Boolean).join(' ')].filter(Boolean).map(_esc).join(', ')}
</div>` : ''}
${p.opening_hours ? `
<div class="health-card-meta" style="margin-top:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="font-size:0.9em"><use href="/icons/phosphor.svg#clock"></use></svg>
${_esc(_fmtOeffnungszeiten(p.opening_hours))}
</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
${p.telefon ? `
<a href="tel:${_esc(p.telefon)}" class="btn btn-secondary btn-sm"
@ -1642,10 +1650,24 @@ window.Page_health = (() => {
<input class="form-control" type="email" name="email"
value="${_esc(praxis?.email || '')}" placeholder="praxis@beispiel.de">
</div>
<div class="form-group">
<label class="form-label">
Öffnungszeiten
<button type="button" id="praxis-osm-lookup" class="btn btn-secondary btn-sm"
style="margin-left:var(--space-2);font-size:var(--text-xs)">
📍 Aus Karte laden
</button>
</label>
<input class="form-control" type="text" name="opening_hours"
id="praxis-opening-hours"
value="${_esc(praxis?.opening_hours || '')}"
placeholder="z.B. MoFr 08:0018:00 · Sa 09:0013:00">
<div id="praxis-osm-results" style="display:none;margin-top:var(--space-2)"></div>
</div>
<div class="form-group">
<label class="form-label">Notizen</label>
<textarea class="form-control" name="notizen" rows="2"
placeholder="Öffnungszeiten, Besonderheiten…">${_esc(praxis?.notizen || '')}</textarea>
placeholder="Besonderheiten, interne Hinweise…">${_esc(praxis?.notizen || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
@ -1670,6 +1692,68 @@ window.Page_health = (() => {
document.getElementById('praxis-cancel')?.addEventListener('click', UI.modal.close);
// OSM-Lookup: Tierarztpraxen in der Nähe suchen und Öffnungszeiten übernehmen
document.getElementById('praxis-osm-lookup')?.addEventListener('click', async btn => {
const lookupBtn = document.getElementById('praxis-osm-lookup');
const resultsEl = document.getElementById('praxis-osm-results');
lookupBtn.disabled = true;
lookupBtn.textContent = 'Suche…';
try {
const pos = await API.getLocation();
const hits = await API.tieraerzte.osmNearby(pos.lat, pos.lon);
if (!hits.length) {
resultsEl.style.display = 'block';
resultsEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">Keine Praxen in der Nähe im OSM-Cache gefunden.</p>';
} else {
resultsEl.style.display = 'block';
resultsEl.innerHTML = hits.map(h => `
<div class="health-card" style="margin-bottom:var(--space-2)">
<div style="cursor:pointer;flex:1"
data-osm-id="${_esc(h.osm_id)}"
data-name="${_esc(h.name)}"
data-oh="${_esc(h.opening_hours || '')}"
data-phone="${_esc(h.phone || '')}"
data-action="pick-osm">
<div style="font-weight:600">${_esc(h.name)}</div>
${h.opening_hours_fmt ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(h.opening_hours_fmt)}</div>` : '<div style="font-size:var(--text-sm);color:var(--c-text-muted)">Öffnungszeiten unbekannt</div>'}
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${h.distanz_km} km entfernt</div>
</div>
<button class="btn btn-secondary btn-sm" style="flex-shrink:0;align-self:flex-start"
data-action="korrigieren"
data-osm-id="${_esc(h.osm_id)}"
data-poi-name="${_esc(h.name)}"
data-current-oh="${_esc(h.opening_hours || '')}">
</button>
</div>
`).join('');
resultsEl.querySelectorAll('[data-action="pick-osm"]').forEach(el => {
el.addEventListener('click', () => {
const nameInput = document.querySelector('[name="name"]');
const ohInput = document.getElementById('praxis-opening-hours');
const telInput = document.querySelector('[name="telefon"]');
if (nameInput && !nameInput.value) nameInput.value = el.dataset.name;
if (ohInput) ohInput.value = el.dataset.oh;
if (telInput && !telInput.value) telInput.value = el.dataset.phone;
resultsEl.style.display = 'none';
UI.toast.success('Daten übernommen.');
});
});
resultsEl.querySelectorAll('[data-action="korrigieren"]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
_showPoiKorrekturModal(btn.dataset.osmId, btn.dataset.poiName, btn.dataset.currentOh);
});
});
}
} catch (err) {
UI.toast.warning('Standort nicht verfügbar oder kein OSM-Cache in der Nähe.');
} finally {
lookupBtn.disabled = false;
lookupBtn.textContent = '📍 Aus Karte laden';
}
});
document.getElementById('praxis-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="praxis-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
@ -1686,6 +1770,7 @@ window.Page_health = (() => {
email: fd.email || null,
notizen: fd.notizen || null,
ist_notfallpraxis: 'ist_notfallpraxis' in fd,
opening_hours: fd.opening_hours || null,
};
if (!payload.name) { UI.toast.warning('Bitte einen Namen eingeben.'); return; }
@ -1850,6 +1935,59 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// KI-GESUNDHEITSBERICHTE (gespeicherte automatische Berichte)
// ----------------------------------------------------------
async function _loadKiBerichte(dogId) {
const el = _container.querySelector('#health-ki-berichte');
if (!el) return;
try {
const berichte = await API.health.kiBerichte(dogId);
if (!berichte || berichte.length === 0) return;
const neuester = berichte[0];
const datum = neuester.erstellt_at
? new Date(neuester.erstellt_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '';
const preview = neuester.bericht.length > 180
? _esc(neuester.bericht.slice(0, 180)) + '&hellip;'
: _esc(neuester.bericht);
el.innerHTML = `
<div class="health-ki-bericht-banner" style="
background:var(--c-surface-2,#f7f2eb);
border:1px solid var(--c-border,#e2d9ce);
border-radius:var(--radius-md,10px);
padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-3);
cursor:pointer;
">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0"><use href="/icons/phosphor.svg#star"></use></svg>
<strong style="font-size:var(--text-sm)">KI-Gesundheitsbericht</strong>
${datum ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted);margin-left:auto">${datum}</span>` : ''}
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-muted);line-height:1.5">${preview}</div>
${berichte.length > 1 ? `<div style="font-size:var(--text-xs);color:var(--c-accent,#c4843a);margin-top:var(--space-1)">${berichte.length} Berichte gespeichert — zum Öffnen tippen</div>` : ''}
</div>`;
el.querySelector('.health-ki-bericht-banner').addEventListener('click', () => {
const listeHtml = berichte.map((b, i) => {
const d = b.erstellt_at
? new Date(b.erstellt_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '';
return `<div style="${i > 0 ? 'border-top:1px solid var(--c-border);padding-top:var(--space-3);margin-top:var(--space-3)' : ''}">
${d ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1)">${d}</div>` : ''}
<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(b.bericht)}</div>
</div>`;
}).join('');
UI.modal.open({
title: `${UI.icon('star')} KI-Gesundheitsberichte`,
body: listeHtml,
});
});
} catch (_) {
// Silently ignore — Berichte sind optional
}
}
// ----------------------------------------------------------
// KI-ZUSAMMENFASSUNG
// ----------------------------------------------------------
@ -1886,6 +2024,56 @@ window.Page_health = (() => {
.replace(/"/g, '&quot;');
}
function _showPoiKorrekturModal(osmId, poiName, currentOh) {
UI.modal.open({
title: 'Öffnungszeiten korrigieren',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
Korrektur für <strong>${_esc(poiName)}</strong>.<br>
Dein Vorschlag wird von einem Moderator geprüft und dann für alle übernommen.
</p>
<form id="poi-korrektur-form">
<div class="form-group">
<label class="form-label">Aktuelle Angabe</label>
<input class="form-control" type="text" value="${_esc(currentOh)}" disabled
style="color:var(--c-text-muted)">
</div>
<div class="form-group">
<label class="form-label">Korrekte Öffnungszeiten *</label>
<input class="form-control" type="text" name="new_value" required
placeholder="z.B. MoFr 08:0018:00 · Sa 09:0013:00"
value="${_esc(currentOh)}">
</div>
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="poi-kor-cancel">Abbrechen</button>
<button type="submit" form="poi-korrektur-form" class="btn btn-primary flex-1">Einreichen</button>
`,
});
document.getElementById('poi-kor-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('poi-korrektur-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="poi-korrektur-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
await API.post(`/osm/pois/${encodeURIComponent(osmId)}/edit`, {
poi_name: poiName,
field: 'opening_hours',
new_value: fd.new_value.trim(),
});
UI.modal.close();
UI.toast.success('Danke! Dein Vorschlag wird geprüft.');
});
});
}
function _fmtOeffnungszeiten(raw) {
if (!raw) return '';
if (raw.trim().toLowerCase() === '24/7') return '24/7 geöffnet';
return raw.split(';').map(s => s.trim()).filter(Boolean).join(' · ');
}
// ----------------------------------------------------------
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------

View file

@ -629,6 +629,7 @@ window.Page_lost = (() => {
lat : parseFloat(fd.lat),
lon : parseFloat(fd.lon),
dog_id : fd.dog_id ? parseInt(fd.dog_id) : null,
client_time : API.clientNow(),
};
const created = await API.lost.report(payload);

View file

@ -14,6 +14,7 @@ window.Page_moderation = (() => {
{ id: 'fotos', label: 'Fotos', icon: 'image' },
{ id: 'user', label: 'User', icon: 'users' },
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' },
{ id: 'poi-edits', label: 'POI-Edits', icon: 'clock' },
];
// ------------------------------------------------------------------
@ -77,6 +78,7 @@ window.Page_moderation = (() => {
case 'fotos': await _renderFotos(el); break;
case 'user': await _renderUsers(el); break;
case 'forum': await _renderForum(el); break;
case 'poi-edits': await _renderPoiEdits(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -106,6 +108,10 @@ window.Page_moderation = (() => {
'Züchter ausstehend',
s.pending_zuchter,
s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
${_statCard('clock',
'POI-Korrekturen',
s.pending_poi_edits ?? 0,
(s.pending_poi_edits ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
</div>
<div class="card" style="padding:var(--space-4);margin-top:var(--space-4)">
@ -456,6 +462,76 @@ window.Page_moderation = (() => {
`;
}
// ------------------------------------------------------------------
// TAB: POI-KORREKTUREN
// ------------------------------------------------------------------
async function _renderPoiEdits(el) {
const edits = await API.get('/moderation/poi-edits');
if (!edits.length) {
el.innerHTML = _emptyState('check-circle', 'Alles erledigt', 'Keine ausstehenden POI-Korrekturen.');
return;
}
const STATUS_LABEL = { pending: 'Ausstehend', approved: 'Genehmigt', rejected: 'Abgelehnt' };
const STATUS_COLOR = { pending: 'var(--c-warning)', approved: 'var(--c-success,#22c55e)', rejected: 'var(--c-danger)' };
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${edits.map(e => `
<div class="card" style="padding:var(--space-4)" data-edit-id="${e.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-2);flex-wrap:wrap">
<div>
<div style="font-weight:600">${_esc(e.poi_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
OSM-ID: ${_esc(e.osm_id)} · Feld: ${_esc(e.field)} · von ${_esc(e.einreicher_name)}
· ${new Date(e.created_at).toLocaleDateString('de-DE')}
</div>
</div>
<span style="font-size:var(--text-xs);font-weight:600;color:${STATUS_COLOR[e.status] || 'inherit'}">
${STATUS_LABEL[e.status] || e.status}
</span>
</div>
<div style="margin-top:var(--space-3);display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Aktuell</div>
<div style="font-size:var(--text-sm)">${_esc(e.old_value) || '<em style="color:var(--c-text-muted)">leer</em>'}</div>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Vorschlag</div>
<div style="font-size:var(--text-sm);font-weight:600">${_esc(e.new_value)}</div>
</div>
</div>
${e.status === 'pending' ? `
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button class="btn btn-primary btn-sm flex-1" data-action="approve" data-id="${e.id}">
Übernehmen
</button>
<button class="btn btn-secondary btn-sm flex-1" data-action="reject" data-id="${e.id}">
Ablehnen
</button>
</div>` : ''}
</div>
`).join('')}
</div>
`;
el.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id);
const action = btn.dataset.action;
btn.disabled = true;
try {
await API.patch(`/moderation/poi-edits/${id}`, { action });
UI.toast.success(action === 'approve' ? 'Übernommen!' : 'Abgelehnt.');
await _renderPoiEdits(el);
} catch (err) {
UI.toast.error(err.message || 'Fehler.');
btn.disabled = false;
}
});
});
}
function _esc(s) {
if (!s) return '';
return String(s)

View file

@ -630,6 +630,7 @@ window.Page_routes = (() => {
leine_empfohlen: 'leine_empfohlen' in fd,
is_public: 'is_public' in fd,
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
client_time: API.clientNow(),
});
UI.modal.close();
UI.toast.success(`Route „${saved.name}" gespeichert!`);
@ -2428,6 +2429,7 @@ window.Page_routes = (() => {
leine_empfohlen: document.getElementById('ri-leine')?.checked,
is_public: document.getElementById('ri-public')?.checked,
hunde_tauglichkeit: _selPaw,
client_time: API.clientNow(),
});
UI.modal.close();
UI.toast.success('Route importiert! 🥾');
@ -2551,7 +2553,7 @@ window.Page_routes = (() => {
ovl.querySelector('#rk-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#rk-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, payload);

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v427';
const CACHE_VERSION = 'by-v432';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

15
backend/timeutils.py Normal file
View file

@ -0,0 +1,15 @@
"""Hilfsfunktionen für client-seitige Zeitstempel."""
import re
from datetime import datetime
def safe_client_time(client_time: str | None) -> str:
"""Gibt client_time zurück falls valides ISO-Datetime, sonst UTC-Now.
Schützt gegen Injection: nur YYYY-MM-DD HH:MM[:SS] erlaubt.
"""
if client_time and re.match(
r'^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$', client_time
):
return client_time.replace('T', ' ')[:19]
return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")