Teil 3: Terminvorschläge + KI-Limit-Bypass für Admins/Mods — SW by-v435, APP_VER 414

- timeutils: next_appointment_slot() parst OSM opening_hours, findet Slot
- GET /health/terminvorschlaege: fällige/überfällige Einträge (30-Tage-Horizont)
  Impfung/Tierarzt nutzen Praxis-Öffnungszeiten, Rest nächster Werktag 09:00
- Frontend: Terminvorschlags-Karten, bestätigbares Modal, legt Event an
- ki.py: Admins, Moderatoren, Media Manager bypassen CLOUD_WEEKLY_LIMIT
This commit is contained in:
rene 2026-04-26 17:08:18 +02:00
parent 570dcd4e93
commit c935d3fbd4
7 changed files with 300 additions and 9 deletions

View file

@ -1,15 +1,93 @@
"""Hilfsfunktionen für client-seitige Zeitstempel."""
"""Hilfsfunktionen für client-seitige Zeitstempel und Öffnungszeiten."""
import re
from datetime import datetime
from datetime import datetime, date, timedelta
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.
"""
"""Gibt client_time zurück falls valides ISO-Datetime, sonst UTC-Now."""
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")
# OSM-Wochentag-Kürzel → Python-weekday (Mo=0 … So=6)
_DAY = {'Mo': 0, 'Di': 1, 'Mi': 2, 'Do': 3, 'Fr': 4, 'Sa': 5, 'So': 6}
_DAY_EN = {'Mo': 0, 'Tu': 1, 'We': 2, 'Th': 3, 'Fr': 4, 'Sa': 5, 'Su': 6}
def _parse_oh_segments(oh: str) -> list[tuple[set[int], str, str]]:
"""Parst OSM-opening_hours in [(weekdays, open_time, close_time), …].
Unterstützt: "Mo-Fr 08:00-18:00; Sa 09:00-13:00", "24/7", "Mo,Mi 09:00-17:00"
"""
if not oh:
return []
oh = oh.strip()
if oh.lower() in ('24/7', '24/7 open'):
return [({0, 1, 2, 3, 4, 5, 6}, '09:00', '18:00')]
segments = []
for part in oh.split(';'):
part = part.strip()
m = re.match(
r'^([A-Za-z,\-]+)\s+(\d{2}:\d{2})-(\d{2}:\d{2})$', part
)
if not m:
continue
day_spec, open_t, close_t = m.group(1), m.group(2), m.group(3)
days: set[int] = set()
for chunk in day_spec.split(','):
chunk = chunk.strip()
if '-' in chunk:
parts = chunk.split('-')
if len(parts) == 2:
d1 = _DAY.get(parts[0], _DAY_EN.get(parts[0]))
d2 = _DAY.get(parts[1], _DAY_EN.get(parts[1]))
if d1 is not None and d2 is not None:
days.update(range(d1, d2 + 1))
else:
d = _DAY.get(chunk, _DAY_EN.get(chunk))
if d is not None:
days.add(d)
if days:
segments.append((days, open_t, close_t))
return segments
def next_appointment_slot(
opening_hours: str | None,
start_from: date | None = None,
prefer_morning: bool = True,
) -> tuple[str, str]:
"""Gibt (datum_str, uhrzeit_str) für den nächsten Slot zurück.
Sucht ab morgen (oder start_from) innerhalb der Öffnungszeiten.
Fallback: nächster Werktag 09:00 wenn keine Öffnungszeiten vorhanden.
Uhrzeit = Öffnungszeit + 1h (Puffer), min. 09:00, max. 11:00 (Vormittag).
"""
base = (start_from or date.today()) + timedelta(days=1)
segments = _parse_oh_segments(opening_hours or '')
if not segments:
# Fallback: nächster MoFr 09:00
d = base
for _ in range(14):
if d.weekday() < 5: # MoFr
return d.isoformat(), '09:00'
d += timedelta(days=1)
return base.isoformat(), '09:00'
for _ in range(14):
wd = base.weekday()
for days, open_t, close_t in segments:
if wd in days:
# Slot: Öffnungszeit + 1h, mindestens 09:00, höchstens 11:00
h, m = map(int, open_t.split(':'))
slot_h = max(9, h + 1)
slot_h = min(slot_h, 11)
return base.isoformat(), f'{slot_h:02d}:00'
base += timedelta(days=1)
return base.isoformat(), '09:00'