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:
parent
679dbdd862
commit
06bd8525ed
21 changed files with 724 additions and 75 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = ?",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
→ "Mo–Fr 08:00–18:00 · Sa 09:00–13: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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue