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", "datei_typ", "TEXT"),
("health", "tierarzt_id", "INTEGER"), ("health", "tierarzt_id", "INTEGER"),
# Tierärzte: Adresse aufgeteilt in Strasse/PLZ/Ort # Tierärzte: Adresse aufgeteilt in Strasse/PLZ/Ort
("tieraerzte", "strasse", "TEXT"), ("tieraerzte", "strasse", "TEXT"),
("tieraerzte", "plz", "TEXT"), ("tieraerzte", "plz", "TEXT"),
("tieraerzte", "ort", "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 # Gesundheit: Erinnerungsintervall für wiederkehrende Einträge
("health", "intervall_tage", "INTEGER"), ("health", "intervall_tage", "INTEGER"),
# Routen: neue Felder # Routen: neue Felder
@ -453,6 +457,7 @@ def _migrate(conn_factory):
("osm_pois", "opening_hours", "TEXT"), ("osm_pois", "opening_hours", "TEXT"),
("osm_pois", "phone", "TEXT"), ("osm_pois", "phone", "TEXT"),
("osm_pois", "website", "TEXT"), ("osm_pois", "website", "TEXT"),
("osm_pois", "user_edited", "INTEGER NOT NULL DEFAULT 0"),
# Forum: Threads brauchen text + antworten-Zähler # Forum: Threads brauchen text + antworten-Zähler
("forum_threads", "text", "TEXT NOT NULL DEFAULT ''"), ("forum_threads", "text", "TEXT NOT NULL DEFAULT ''"),
("forum_threads", "antworten", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "antworten", "INTEGER NOT NULL DEFAULT 0"),
@ -1157,3 +1162,51 @@ def _migrate(conn_factory):
ON notes(user_id, created_at DESC); ON notes(user_id, created_at DESC);
""") """)
logger.info("Migration: notes Tabelle bereit.") 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 httpx
import weather as weather_mod import weather as weather_mod
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif 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__) logger = logging.getLogger(__name__)
@ -19,6 +20,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel): class DiaryCreate(BaseModel):
datum: Optional[str] = None # ISO date, default heute 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" typ: str = "eintrag"
titel: Optional[str] = None titel: Optional[str] = None
text: Optional[str] = None text: Optional[str] = None
@ -288,14 +290,14 @@ async def create_diary(dog_id: int, data: DiaryCreate,
else: else:
all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn) 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( conn.execute(
"""INSERT INTO diary """INSERT INTO diary
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, location_name, is_milestone) (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, location_name, is_milestone, created_at)
VALUES (?, VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
COALESCE(?, date('now')), (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)
(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))
) )
entry = conn.execute( entry = conn.execute(
"SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1", "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 typing import Optional
from database import db from database import db
from auth import get_current_user, get_current_user_optional 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 routes.push import send_push_to_user
from media_utils import convert_media, extract_video_thumb from media_utils import convert_media, extract_video_thumb
@ -24,15 +25,17 @@ KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung',
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ThreadCreate(BaseModel): class ThreadCreate(BaseModel):
kategorie: str = 'allgemein' kategorie: str = 'allgemein'
titel: str titel: str
text: str text: str
thread_lat: Optional[float] = None thread_lat: Optional[float] = None
thread_lon: Optional[float] = None thread_lon: Optional[float] = None
thread_ort: Optional[str] = None thread_ort: Optional[str] = None
client_time: Optional[str] = None
class PostCreate(BaseModel): class PostCreate(BaseModel):
text: str text: str
client_time: Optional[str] = None
class ThreadPatch(BaseModel): class ThreadPatch(BaseModel):
is_pinned: Optional[int] = None 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: if data.kategorie not in KATEGORIEN:
raise HTTPException(400, "Ungültige Kategorie.") raise HTTPException(400, "Ungültige Kategorie.")
with db() as conn: with db() as conn:
ct = safe_client_time(data.client_time)
cur = conn.execute( cur = conn.execute(
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort) """INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user['id'], data.kategorie, data.titel.strip(), data.text.strip(), (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( row = conn.execute(
"""SELECT t.*, u.name AS autor_name """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']: if thread['is_deleted']:
raise HTTPException(404, "Thread nicht gefunden.") raise HTTPException(404, "Thread nicht gefunden.")
ct = safe_client_time(data.client_time)
cur = conn.execute( cur = conn.execute(
"INSERT INTO forum_posts (thread_id, user_id, text) VALUES (?, ?, ?)", "INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
(thread_id, user['id'], data.text.strip()) (thread_id, user['id'], data.text.strip(), ct)
) )
conn.execute( conn.execute(
"UPDATE forum_threads SET antworten = antworten + 1 WHERE id = ?", "UPDATE forum_threads SET antworten = antworten + 1 WHERE id = ?",

View file

@ -75,6 +75,18 @@ class HealthUpdate(BaseModel):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Hilfsfunktionen # 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): def _check_dog_owner(conn, dog_id: int, user_id: int):
dog = conn.execute( dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) "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,) (dog_id,)
).fetchone() ).fetchone()
media_map = _fetch_media_items(conn, [row["id"]]) media_map = _fetch_media_items(conn, [row["id"]])
if data.typ == 'gewicht':
_sync_gewicht(conn, dog_id)
return _entry_with_media(row, media_map) 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) conn.execute(f"UPDATE health SET {set_clause} WHERE id=?", values)
row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone() row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone()
media_map = _fetch_media_items(conn, [entry_id]) media_map = _fetch_media_items(conn, [entry_id])
if row["typ"] == 'gewicht':
_sync_gewicht(conn, dog_id)
return _entry_with_media(row, media_map) 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: with db() as conn:
_check_dog_owner(conn, dog_id, user["id"]) _check_dog_owner(conn, dog_id, user["id"])
entry = conn.execute( 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() ).fetchone()
if not entry: if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.") raise HTTPException(404, "Eintrag nicht gefunden.")
was_gewicht = entry["typ"] == 'gewicht'
conn.execute("DELETE FROM health WHERE id=?", (entry_id,)) conn.execute("DELETE FROM health WHERE id=?", (entry_id,))
if was_gewicht:
_sync_gewicht(conn, dog_id)
return None return None
@ -431,3 +450,18 @@ async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)):
raise HTTPException(402, str(e)) raise HTTPException(402, str(e))
except KIUnavailableError as e: except KIUnavailableError as e:
raise HTTPException(503, str(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 typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from timeutils import safe_client_time
from routes.push import send_push_to_all from routes.push import send_push_to_all
from media_utils import convert_media from media_utils import convert_media
@ -37,6 +38,7 @@ class LostDogCreate(BaseModel):
lat: float lat: float
lon: float lon: float
dog_id: Optional[int] = None 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) @router.post("", status_code=201)
async def report_lost(data: LostDogCreate, user=Depends(get_current_user)): async def report_lost(data: LostDogCreate, user=Depends(get_current_user)):
with db() as conn: with db() as conn:
ct = safe_client_time(data.client_time)
conn.execute( conn.execute(
"""INSERT INTO lost_dogs (user_id, dog_id, name, rasse, beschreibung, lat, lon) """INSERT INTO lost_dogs (user_id, dog_id, name, rasse, beschreibung, lat, lon, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user["id"], data.dog_id, data.name, data.rasse, (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( row = conn.execute(
"SELECT * FROM lost_dogs WHERE user_id=? ORDER BY id DESC LIMIT 1", "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: except Exception:
pass 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 { return {
"open_reports": open_reports, "open_reports": open_reports,
"pending_fotos": pending_fotos, "pending_fotos": pending_fotos,
"banned_users": banned_users, "banned_users": banned_users,
"pending_zuchter": pending_zuchter, "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", ""), reject_reason=data.get("reject_reason", ""),
) )
return await review_submission(foto_id, model, user) 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 typing import Optional, Any, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from timeutils import safe_client_time
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -21,6 +22,7 @@ class NoteCreate(BaseModel):
meta_json: Optional[Any] = None meta_json: Optional[Any] = None
location_name: Optional[str] = None location_name: Optional[str] = None
parent_label: Optional[str] = None parent_label: Optional[str] = None
client_time: Optional[str] = None
class NoteUpdate(BaseModel): 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.") raise HTTPException(400, "Notiz darf nicht leer sein.")
meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None 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: with db() as conn:
conn.execute( 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 ON CONFLICT(osm_id, type) DO UPDATE SET
lat=excluded.lat, lon=excluded.lon, lat=excluded.lat, lon=excluded.lon,
name=excluded.name, 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, phone=excluded.phone,
website=excluded.website, website=excluded.website,
cached_at=excluded.cached_at cached_at=excluded.cached_at
@ -372,3 +372,44 @@ async def analyze_region(
background_tasks.add_task(_warmup) background_tasks.add_task(_warmup)
return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())} 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 database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional
from routes.achievements import update_streak, check_and_award from routes.achievements import update_streak, check_and_award
from timeutils import safe_client_time
from media_utils import convert_media from media_utils import convert_media
from routes.push import send_push_to_user from routes.push import send_push_to_user
@ -52,6 +53,7 @@ class RouteCreate(BaseModel):
leine_empfohlen: Optional[bool] = None leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = False is_public: Optional[bool] = False
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
client_time: Optional[str] = None
class RouteUpdate(BaseModel): class RouteUpdate(BaseModel):
name: Optional[str] = None 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]) gps_json = json.dumps([p.model_dump() for p in data.gps_track])
is_valid = int(_check_speed(data.distanz_km, data.dauer_min)) is_valid = int(_check_speed(data.distanz_km, data.dauer_min))
ct = safe_client_time(data.client_time)
with db() as conn: with db() as conn:
cur = conn.execute(""" cur = conn.execute("""
INSERT INTO routes INSERT INTO routes
(user_id, name, beschreibung, gps_track, distanz_km, dauer_min, (user_id, name, beschreibung, gps_track, distanz_km, dauer_min,
schwierigkeit, untergrund, schatten, leine_empfohlen, is_public, schwierigkeit, untergrund, schatten, leine_empfohlen, is_public,
hunde_tauglichkeit, is_valid) hunde_tauglichkeit, is_valid, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
user['id'], data.name, data.beschreibung, gps_json, user['id'], data.name, data.beschreibung, gps_json,
data.distanz_km, data.dauer_min, data.schwierigkeit, data.untergrund, data.distanz_km, data.dauer_min, data.schwierigkeit, data.untergrund,
int(data.schatten) if data.schatten is not None else None, 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.leine_empfohlen) if data.leine_empfohlen is not None else None,
int(data.is_public) if data.is_public is not None else 1, 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() row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone()
update_streak(user['id'], conn) update_streak(user['id'], conn)

View file

@ -1,6 +1,7 @@
"""BAN YARO — Tierärzte Routes (user-level, nie löschen)""" """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 pydantic import BaseModel
from typing import Optional from typing import Optional
from database import db from database import db
@ -11,34 +12,60 @@ router = APIRouter()
class TierarztCreate(BaseModel): class TierarztCreate(BaseModel):
name: str name: str
strasse: Optional[str] = None strasse: Optional[str] = None
plz: Optional[str] = None plz: Optional[str] = None
ort: Optional[str] = None ort: Optional[str] = None
telefon: Optional[str] = None telefon: Optional[str] = None
notfall_telefon: Optional[str] = None notfall_telefon: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
website: Optional[str] = None website: Optional[str] = None
notizen: Optional[str] = None notizen: Optional[str] = None
ist_notfallpraxis: bool = False 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): class TierarztUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
strasse: Optional[str] = None strasse: Optional[str] = None
plz: Optional[str] = None plz: Optional[str] = None
ort: Optional[str] = None ort: Optional[str] = None
telefon: Optional[str] = None telefon: Optional[str] = None
notfall_telefon: Optional[str] = None notfall_telefon: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
website: Optional[str] = None website: Optional[str] = None
notizen: Optional[str] = None notizen: Optional[str] = None
ist_notfallpraxis: Optional[bool] = 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("") @router.get("")
async def list_tieraerzte(user=Depends(get_current_user)): 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: with db() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name", "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] 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) @router.post("", status_code=201)
async def create_tierarzt(data: TierarztCreate, user=Depends(get_current_user)): async def create_tierarzt(data: TierarztCreate, user=Depends(get_current_user)):
with db() as conn: with db() as conn:
conn.execute( conn.execute(
"""INSERT INTO tieraerzte """INSERT INTO tieraerzte
(user_id, name, strasse, plz, ort, telefon, notfall_telefon, (user_id, name, strasse, plz, ort, telefon, notfall_telefon,
email, website, notizen, ist_notfallpraxis) email, website, notizen, ist_notfallpraxis,
VALUES (?,?,?,?,?,?,?,?,?,?,?)""", opening_hours, lat, lon, osm_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(user["id"], data.name, data.strasse, data.plz, data.ort, (user["id"], data.name, data.strasse, data.plz, data.ort,
data.telefon, data.notfall_telefon, data.email, data.website, 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( row = conn.execute(
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY id DESC LIMIT 1", "SELECT * FROM tieraerzte WHERE user_id=? ORDER BY id DESC LIMIT 1",

View file

@ -100,6 +100,14 @@ def start():
replace_existing=True, replace_existing=True,
misfire_grace_time=1800, 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() _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).") 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]}") _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) # JOB: Status-Report per Mail (4× täglich)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -801,6 +879,7 @@ async def _job_status_report():
"seed_breeds_startup": "Rassen-Seed (TheDogAPI)", "seed_breeds_startup": "Rassen-Seed (TheDogAPI)",
"seed_wikidata_startup":"Rassen-Seed (Wikidata)", "seed_wikidata_startup":"Rassen-Seed (Wikidata)",
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)", "weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
"ki_health_report": "KI-Gesundheitsberichte",
} }
job_rows_html = "" job_rows_html = ""
job_rows_txt = "" job_rows_txt = ""

View file

@ -168,6 +168,7 @@ const API = (() => {
kiZusammenfassung(dogId) { kiZusammenfassung(dogId) {
return post(`/dogs/${dogId}/health/ki-zusammenfassung`); return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
}, },
kiBerichte(dogId) { return get(`/dogs/${dogId}/health/ki-berichte`); },
symptomCheck(dogId, symptoms) { symptomCheck(dogId, symptoms) {
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms }); return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
}, },
@ -180,9 +181,10 @@ const API = (() => {
// TIERÄRZTE // TIERÄRZTE
// ---------------------------------------------------------- // ----------------------------------------------------------
const tieraerzte = { const tieraerzte = {
list() { return get('/tieraerzte'); }, list() { return get('/tieraerzte'); },
create(data) { return post('/tieraerzte', data); }, create(data) { return post('/tieraerzte', data); },
update(id, d) { return patch(`/tieraerzte/${id}`, d); }, 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 // Öffentliche API
return { return {
get, post, put, patch, del, upload, get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison, auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push, places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes, friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
subscribeToPush, getLocation, subscribeToPush, getLocation, clientNow,
APIError, APIError,
}; };

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 = (() => { const App = (() => {

View file

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

View file

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

View file

@ -152,6 +152,7 @@ window.Page_health = (() => {
</button> </button>
</div> </div>
${transponderHtml} ${transponderHtml}
<div id="health-ki-berichte"></div>
<div id="health-reminders"></div> <div id="health-reminders"></div>
<div class="by-tabs" id="by-tabs"></div> <div class="by-tabs" id="by-tabs"></div>
<div id="by-tab-content"></div> <div id="by-tab-content"></div>
@ -166,6 +167,7 @@ window.Page_health = (() => {
await _loadAll(); await _loadAll();
_renderErinnerungen(); _renderErinnerungen();
_renderTab(); _renderTab();
_loadKiBerichte(dog.id);
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -1009,7 +1011,8 @@ window.Page_health = (() => {
if (praxis) { if (praxis) {
const adresse = [praxis.strasse, [praxis.plz, praxis.ort].filter(Boolean).join(' ')].filter(Boolean).join(', '); 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>` : ''; 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) { } else if (e.tierarzt_name) {
rows.push(['Tierarzt', _esc(e.tierarzt_name)]); rows.push(['Tierarzt', _esc(e.tierarzt_name)]);
@ -1561,6 +1564,11 @@ window.Page_health = (() => {
<div class="health-card-meta"> <div class="health-card-meta">
${[p.strasse, [p.plz, p.ort].filter(Boolean).join(' ')].filter(Boolean).map(_esc).join(', ')} ${[p.strasse, [p.plz, p.ort].filter(Boolean).join(' ')].filter(Boolean).map(_esc).join(', ')}
</div>` : ''} </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"> <div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
${p.telefon ? ` ${p.telefon ? `
<a href="tel:${_esc(p.telefon)}" class="btn btn-secondary btn-sm" <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" <input class="form-control" type="email" name="email"
value="${_esc(praxis?.email || '')}" placeholder="praxis@beispiel.de"> value="${_esc(praxis?.email || '')}" placeholder="praxis@beispiel.de">
</div> </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"> <div class="form-group">
<label class="form-label">Notizen</label> <label class="form-label">Notizen</label>
<textarea class="form-control" name="notizen" rows="2" <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>
<div class="form-group"> <div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer"> <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); 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 => { document.getElementById('praxis-form')?.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const btn = document.querySelector('[form="praxis-form"][type="submit"]') || e.target.querySelector('[type="submit"]'); 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, email: fd.email || null,
notizen: fd.notizen || null, notizen: fd.notizen || null,
ist_notfallpraxis: 'ist_notfallpraxis' in fd, ist_notfallpraxis: 'ist_notfallpraxis' in fd,
opening_hours: fd.opening_hours || null,
}; };
if (!payload.name) { UI.toast.warning('Bitte einen Namen eingeben.'); return; } 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 // KI-ZUSAMMENFASSUNG
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -1886,6 +2024,56 @@ window.Page_health = (() => {
.replace(/"/g, '&quot;'); .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) // NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

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

View file

@ -14,6 +14,7 @@ window.Page_moderation = (() => {
{ id: 'fotos', label: 'Fotos', icon: 'image' }, { id: 'fotos', label: 'Fotos', icon: 'image' },
{ id: 'user', label: 'User', icon: 'users' }, { id: 'user', label: 'User', icon: 'users' },
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' }, { 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 'fotos': await _renderFotos(el); break;
case 'user': await _renderUsers(el); break; case 'user': await _renderUsers(el); break;
case 'forum': await _renderForum(el); break; case 'forum': await _renderForum(el); break;
case 'poi-edits': await _renderPoiEdits(el); break;
} }
} catch (e) { } catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -106,6 +108,10 @@ window.Page_moderation = (() => {
'Züchter ausstehend', 'Züchter ausstehend',
s.pending_zuchter, s.pending_zuchter,
s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')} 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>
<div class="card" style="padding:var(--space-4);margin-top:var(--space-4)"> <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) { function _esc(s) {
if (!s) return ''; if (!s) return '';
return String(s) return String(s)

View file

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

View file

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