- 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>
80 lines
2.3 KiB
Python
80 lines
2.3 KiB
Python
"""Working-hours helpers.
|
||
|
||
Working hours are stored per tenant as:
|
||
{"mon": [["09:00", "13:00"], ["14:00", "18:00"]], ..., "sun": []}
|
||
Multiple intervals per day model lunch breaks. Missing/empty day = closed.
|
||
All computations happen in the tenant's timezone.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime, time, timedelta
|
||
from zoneinfo import ZoneInfo
|
||
|
||
WEEKDAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||
|
||
# Bosnian day names (Latin) for UI / agent context
|
||
DAY_NAMES_BS = {
|
||
"mon": "ponedjeljak",
|
||
"tue": "utorak",
|
||
"wed": "srijeda",
|
||
"thu": "četvrtak",
|
||
"fri": "petak",
|
||
"sat": "subota",
|
||
"sun": "nedjelja",
|
||
}
|
||
|
||
DEFAULT_WORKING_HOURS: dict[str, list[list[str]]] = {
|
||
"mon": [["09:00", "18:00"]],
|
||
"tue": [["09:00", "18:00"]],
|
||
"wed": [["09:00", "18:00"]],
|
||
"thu": [["09:00", "18:00"]],
|
||
"fri": [["09:00", "18:00"]],
|
||
"sat": [["09:00", "14:00"]],
|
||
"sun": [],
|
||
}
|
||
|
||
|
||
def _parse_hhmm(s: str) -> time:
|
||
h, m = s.split(":")
|
||
return time(int(h), int(m))
|
||
|
||
|
||
def day_intervals(
|
||
working_hours: dict, day: date, tz: ZoneInfo
|
||
) -> list[tuple[datetime, datetime]]:
|
||
"""Open intervals for a calendar day as tz-aware datetimes."""
|
||
key = WEEKDAY_KEYS[day.weekday()]
|
||
out = []
|
||
for start_s, end_s in working_hours.get(key, []):
|
||
start = datetime.combine(day, _parse_hhmm(start_s), tzinfo=tz)
|
||
end = datetime.combine(day, _parse_hhmm(end_s), tzinfo=tz)
|
||
if end > start:
|
||
out.append((start, end))
|
||
return out
|
||
|
||
|
||
def is_open_at(working_hours: dict, at: datetime, tz: ZoneInfo) -> bool:
|
||
at = at.astimezone(tz)
|
||
return any(s <= at < e for s, e in day_intervals(working_hours, at.date(), tz))
|
||
|
||
|
||
def hours_summary_bs(working_hours: dict) -> str:
|
||
"""Human-readable Bosnian working-hours summary for prompts/emails."""
|
||
parts = []
|
||
for key in WEEKDAY_KEYS:
|
||
intervals = working_hours.get(key, [])
|
||
if not intervals:
|
||
parts.append(f"{DAY_NAMES_BS[key]}: zatvoreno")
|
||
else:
|
||
spans = ", ".join(f"{a}–{b}" for a, b in intervals)
|
||
parts.append(f"{DAY_NAMES_BS[key]}: {spans}")
|
||
return "; ".join(parts)
|
||
|
||
|
||
def iter_days(start: date, end: date):
|
||
d = start
|
||
while d <= end:
|
||
yield d
|
||
d += timedelta(days=1)
|