- 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>
82 lines
3.0 KiB
Python
82 lines
3.0 KiB
Python
"""Mock scheduling provider — in-memory, for tests and demos (§16 M1).
|
|
|
|
Availability: generated from tenant working hours with configurable busy blocks
|
|
(class-level, settable by tests/demo seeder). Delivery: owner email, same as
|
|
google_calendar.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import date, datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
|
|
from gogo.hours import DEFAULT_WORKING_HOURS
|
|
from gogo.models import BookingRequest, Service, Tenant, utcnow
|
|
from gogo.scheduling.base import register_provider
|
|
from gogo.scheduling.slots import compute_slots
|
|
|
|
|
|
@register_provider("mock")
|
|
class MockProvider:
|
|
# tenant_id(str) -> list[(start, end)] busy blocks; tests populate this
|
|
busy_blocks: dict[str, list[tuple[datetime, datetime]]] = {}
|
|
# test hook: freeze "now"
|
|
now_override: datetime | None = None
|
|
|
|
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
|
|
self.session = session
|
|
self.tenant = tenant
|
|
self.config = config
|
|
|
|
async def get_services(self) -> list[ServiceInfo] | None:
|
|
return None # no external catalog
|
|
|
|
async def get_availability(
|
|
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
|
|
) -> list[Slot]:
|
|
service = (
|
|
await self.session.execute(
|
|
select(Service).where(Service.id == uuid.UUID(service_id))
|
|
)
|
|
).scalar_one_or_none()
|
|
duration = service.duration_min if service else 30
|
|
buffer_min = service.buffer_min if service else 0
|
|
tz = ZoneInfo(self.tenant.timezone)
|
|
return compute_slots(
|
|
working_hours=self.tenant.working_hours or DEFAULT_WORKING_HOURS,
|
|
busy=self.busy_blocks.get(str(self.tenant.id), []),
|
|
duration_min=duration,
|
|
buffer_min=buffer_min,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
tz=tz,
|
|
now=self.now_override or utcnow(),
|
|
min_notice_hours=self.tenant.min_notice_hours,
|
|
max_days_ahead=self.tenant.max_days_ahead,
|
|
)
|
|
|
|
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
|
|
if booking_request.dry_run:
|
|
return DeliveryResult(ok=True, detail="dry-run ok (mock)")
|
|
from gogo.proposals.email import send_proposal_email
|
|
|
|
req = (
|
|
await self.session.execute(
|
|
select(BookingRequest).where(
|
|
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
|
|
)
|
|
)
|
|
).scalar_one()
|
|
service = None
|
|
if req.service_id:
|
|
service = (
|
|
await self.session.execute(select(Service).where(Service.id == req.service_id))
|
|
).scalar_one_or_none()
|
|
await send_proposal_email(self.session, self.tenant, req, service)
|
|
return DeliveryResult(ok=True, detail="email sent (mock)")
|