- 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
93 lines
3.3 KiB
Python
93 lines
3.3 KiB
Python
"""Hilfsfunktionen für client-seitige Zeitstempel und Öffnungszeiten."""
|
||
import re
|
||
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."""
|
||
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 Mo–Fr 09:00
|
||
d = base
|
||
for _ in range(14):
|
||
if d.weekday() < 5: # Mo–Fr
|
||
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'
|