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>
This commit is contained in:
2026-07-11 09:45:06 +02:00
commit e855650f09
48 changed files with 4941 additions and 0 deletions

77
gogo/scheduling/slots.py Normal file
View File

@@ -0,0 +1,77 @@
"""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