Files
gogo-telefon/gogo/scheduling/slots.py
Senad Uka e855650f09 M1: backend core + proposal engine
- FastAPI skeleton, SQLAlchemy models (§13), Alembic initial migration
- SchedulingProvider interface with google_calendar (free/busy read-only),
  partner_api (Appendix B client) and mock implementations
- Proposal engine: create → provider-routed delivery → owner actions
  (resolve/confirm+SMS/reject) → expiry + reminders (§9)
- Signed single-use action links, .ics METHOD:REQUEST attachment
- Partner outcome webhook with HMAC verification + polling fallback
- SmsProvider (console) with Bosnian templates (§5.5), EmailProvider (console/SMTP)
- Fake partner API server in tests/ — Appendix B reference implementation
- 43 tests: slot math, proposal lifecycle, action links, partner contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:45:06 +02:00

78 lines
2.6 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.
"""Slot generation for Google Calendar tenants (§8.1).
working hours busy blocks, quantized to service duration (+ optional buffer),
respecting min_notice_hours and max_days_ahead. Partner tenants never hit this —
their software returns ready slots.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from gogo.domain import Slot
from gogo.hours import day_intervals, iter_days
QUANTIZE_MIN = 15 # slot starts snap to :00/:15/:30/:45
def subtract_busy(
interval: tuple[datetime, datetime], busy: list[tuple[datetime, datetime]]
) -> list[tuple[datetime, datetime]]:
"""Subtract busy blocks from one open interval → list of free intervals."""
free = [interval]
for b_start, b_end in sorted(busy):
next_free = []
for f_start, f_end in free:
if b_end <= f_start or b_start >= f_end:
next_free.append((f_start, f_end))
continue
if b_start > f_start:
next_free.append((f_start, b_start))
if b_end < f_end:
next_free.append((b_end, f_end))
free = next_free
return free
def compute_slots(
*,
working_hours: dict,
busy: list[tuple[datetime, datetime]],
duration_min: int,
buffer_min: int = 0,
date_from: date,
date_to: date,
tz: ZoneInfo,
now: datetime,
min_notice_hours: int = 2,
max_days_ahead: int = 14,
max_slots: int = 20,
) -> list[Slot]:
"""Generate offerable free slots, earliest first."""
earliest_start = now + timedelta(hours=min_notice_hours)
horizon = (now + timedelta(days=max_days_ahead)).date()
date_to = min(date_to, horizon)
total_min = duration_min + buffer_min
step = timedelta(minutes=total_min) # spec §8.1: quantized to service duration (+ buffer)
slots: list[Slot] = []
for day in iter_days(date_from, date_to):
for open_start, open_end in day_intervals(working_hours, day, tz):
for f_start, f_end in subtract_busy((open_start, open_end), busy):
cursor = _quantize_up(max(f_start, earliest_start.astimezone(tz)))
while cursor + timedelta(minutes=total_min) <= f_end:
slots.append(Slot(start=cursor, end=cursor + timedelta(minutes=duration_min)))
if len(slots) >= max_slots:
return slots
cursor += step
return slots
def _quantize_up(dt: datetime) -> datetime:
dt = dt.replace(second=0, microsecond=0)
rem = dt.minute % QUANTIZE_MIN
if rem:
dt += timedelta(minutes=QUANTIZE_MIN - rem)
return dt