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:
79
gogo/hours.py
Normal file
79
gogo/hours.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user