Files
smb-online/backoffice/app/availability.py
T
mivanchenko 2f6e0c1459 Availability engine + booking API: create/cancel/reschedule (#16)
Adds the plumbing that makes "can a customer actually get booked" true
end to end at the API layer, on top of #15's schema/tenancy layer.

- resource_hours table + min_notice_minutes/max_advance_days/buffer_minutes
  on resources -- config #15 didn't include but #16 depends on.
- availability.py: pure slot-generation function, correct across a
  Europe/Berlin DST transition (tested both directions).
- booking_api.py: JSON blueprint for slot listing, booking creation
  (auto_confirm -> confirmed/pending), and signed-JWT cancel/reschedule,
  registered into app.py.
- booking_db.py gains resource-hours CRUD, a tenant-scoped busy-bookings
  query for buffer/slot validation, and a read-only client lookup.

A true concurrent-threads test (not just sequential requests) surfaced a
real gap: Postgres can raise DeadlockDetected instead of ExclusionViolation
when two overlapping inserts race the exclusion constraint directly, which
went uncaught and would have 500'd instead of giving the clean 4xx the
ticket requires -- now caught alongside ExclusionViolation.

Also fixed: reschedule used the request's raw UTC offset to pick the
business day instead of the client's own timezone (could pick the wrong
day's hours/bookings near local midnight); the cancel/reschedule JWT no
longer falls back to reusing CRM_API_TOKEN as its signing secret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 14:52:33 +02:00

56 lines
2.4 KiB
Python

"""Slot generation for the booking module (#16).
Pure functions only -- no I/O. Callers (booking_api.py) fetch a resource's
hours/existing bookings via booking_db.py and pass them in here, which keeps
this module trivially unit-testable against hand-computed expectations,
including across a DST transition.
"""
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
def generate_slots(hours_by_weekday, duration_minutes, date_from, date_to,
tz_name, now, min_notice_minutes=60, max_advance_days=30,
buffer_minutes=0, busy=()):
"""Return a sorted list of tz-aware UTC datetimes, one per bookable slot
start, for [date_from, date_to] inclusive.
hours_by_weekday: {0..6: (opens_at time, closes_at time)}, 0=Monday,
matching date.weekday(); a missing weekday means closed that day.
tz_name: IANA zone the hours are local to (e.g. "Europe/Berlin").
now: tz-aware datetime "now" is measured from, for min-notice/max-advance.
busy: iterable of (start, end) tz-aware datetimes already booked on this
resource -- a candidate slot within buffer_minutes of one is dropped.
"""
tz = ZoneInfo(tz_name)
duration = timedelta(minutes=duration_minutes)
buffer_td = timedelta(minutes=buffer_minutes)
earliest = now + timedelta(minutes=min_notice_minutes)
latest = now + timedelta(days=max_advance_days)
busy_utc = [(s.astimezone(timezone.utc), e.astimezone(timezone.utc)) for s, e in busy]
slots = []
day = date_from
while day <= date_to:
hours = hours_by_weekday.get(day.weekday())
if hours:
opens_at, closes_at = hours
cursor = datetime.combine(day, opens_at, tzinfo=tz)
close = datetime.combine(day, closes_at, tzinfo=tz)
while cursor + duration <= close:
start_utc = cursor.astimezone(timezone.utc)
end_utc = (cursor + duration).astimezone(timezone.utc)
if (earliest <= start_utc <= latest
and not _conflicts(start_utc, end_utc, busy_utc, buffer_td)):
slots.append(start_utc)
cursor += duration
day += timedelta(days=1)
return slots
def _conflicts(start, end, busy_utc, buffer_td):
for b_start, b_end in busy_utc:
if start < b_end + buffer_td and end + buffer_td > b_start:
return True
return False