banyaro/backend/timeutils.py
rene c935d3fbd4 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
2026-04-26 17:08:18 +02:00

93 lines
3.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 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'