- 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>
252 lines
8.6 KiB
Python
252 lines
8.6 KiB
Python
"""Proposal engine (§9): create booking requests, route delivery through the
|
|
tenant's scheduling provider, drive state transitions and client SMS.
|
|
|
|
States: pending → resolved_by_owner | confirmed | rejected | expired
|
|
The same transition functions are used by email action links, the dashboard,
|
|
and the partner webhook — semantics are identical everywhere (§B.3).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from gogo.config import get_settings
|
|
from gogo.domain import BookingRequestData, BookingStatus, DeliveryResult, Slot
|
|
from gogo.i18n import fmt_date, fmt_slot, fmt_time
|
|
from gogo.models import BookingRequest, Service, Tenant, utcnow
|
|
from gogo.scheduling.base import get_provider
|
|
from gogo.sms import send_sms
|
|
from gogo.sms.templates import render_sms
|
|
|
|
log = logging.getLogger("gogo.proposals")
|
|
|
|
|
|
class TransitionError(Exception):
|
|
"""Invalid state transition (e.g. confirming an already-rejected request)."""
|
|
|
|
|
|
async def create_booking_request(
|
|
session: AsyncSession,
|
|
tenant: Tenant,
|
|
*,
|
|
source: str,
|
|
client_name: str,
|
|
client_phone: str,
|
|
service_id: str | None = None,
|
|
service_name_raw: str = "",
|
|
slots: list[Slot] | None = None,
|
|
time_preference_text: str = "",
|
|
home_visit: bool = False,
|
|
address: str | None = None,
|
|
summary: str = "",
|
|
call_id: uuid.UUID | None = None,
|
|
chat_session_id: uuid.UUID | None = None,
|
|
) -> tuple[BookingRequest, DeliveryResult]:
|
|
"""Create a pending request and deliver it via the tenant's provider."""
|
|
slots = (slots or [])[:3] # hard cap: at most 3 offered slots (§6.2)
|
|
now = utcnow()
|
|
req = BookingRequest(
|
|
tenant_id=tenant.id,
|
|
source=source,
|
|
client_name=client_name.strip(),
|
|
client_phone=client_phone.strip(),
|
|
service_id=uuid.UUID(service_id) if service_id else None,
|
|
service_name_raw=service_name_raw,
|
|
slots=[s.model_dump(mode="json") for s in slots],
|
|
time_preference_text=time_preference_text,
|
|
home_visit=home_visit,
|
|
address=address,
|
|
summary=summary,
|
|
call_id=call_id,
|
|
chat_session_id=chat_session_id,
|
|
status=BookingStatus.pending.value,
|
|
expires_at=now + timedelta(hours=tenant.proposal_ttl_hours or 24),
|
|
)
|
|
session.add(req)
|
|
await session.flush() # assign req.id
|
|
|
|
data = BookingRequestData(
|
|
gogo_request_id=str(req.id),
|
|
tenant_id=str(tenant.id),
|
|
created_at=now,
|
|
source=source,
|
|
client_name=req.client_name,
|
|
client_phone=req.client_phone,
|
|
service_id=service_id,
|
|
service_name_raw=service_name_raw,
|
|
requested_slots=slots,
|
|
time_preference_text=time_preference_text,
|
|
home_visit=home_visit,
|
|
address=address,
|
|
summary=summary,
|
|
transcript_url=f"{get_settings().base_url}/t/{req.id}",
|
|
)
|
|
provider = await get_provider(session, tenant)
|
|
result = await provider.deliver_request(data)
|
|
if not result.ok:
|
|
log.error("delivery failed for request %s: %s", req.id, result.detail)
|
|
|
|
# Optional "request received" SMS to the client (§9.1)
|
|
if tenant.sms_request_received and req.client_phone:
|
|
await send_sms(
|
|
session,
|
|
tenant.id,
|
|
req.client_phone,
|
|
render_sms(tenant, "request_received"),
|
|
kind="received",
|
|
)
|
|
return req, result
|
|
|
|
|
|
# -- transitions -------------------------------------------------------------
|
|
|
|
|
|
async def resolve_request(
|
|
session: AsyncSession, tenant: Tenant, req: BookingRequest, note: str = ""
|
|
) -> None:
|
|
"""Owner contacted the client directly — primary flow. Gogo sends NO SMS (§9.2)."""
|
|
_require_pending(req, allow_same=BookingStatus.resolved_by_owner)
|
|
if req.status != BookingStatus.pending.value:
|
|
return # idempotent replay
|
|
req.status = BookingStatus.resolved_by_owner.value
|
|
req.resolved_at = utcnow()
|
|
req.resolution_note = note
|
|
|
|
|
|
async def confirm_request(
|
|
session: AsyncSession,
|
|
tenant: Tenant,
|
|
req: BookingRequest,
|
|
slot: Slot,
|
|
*,
|
|
recheck: bool = True,
|
|
) -> bool:
|
|
"""Owner confirms a slot → confirmation SMS to client.
|
|
|
|
Returns False (no transition) if recheck finds the slot busy — the caller
|
|
should show a warning and let the owner decide (force with recheck=False).
|
|
"""
|
|
_require_pending(req, allow_same=BookingStatus.confirmed)
|
|
if req.status == BookingStatus.confirmed.value:
|
|
return True # idempotent replay
|
|
|
|
if recheck:
|
|
provider = await get_provider(session, tenant)
|
|
checker = getattr(provider, "is_slot_free", None)
|
|
if checker is not None:
|
|
try:
|
|
free = await checker(
|
|
str(req.service_id) if req.service_id else None, slot
|
|
)
|
|
except Exception: # noqa: BLE001 — recheck is best-effort
|
|
log.exception("free/busy recheck failed for %s", req.id)
|
|
free = True
|
|
if not free:
|
|
return False
|
|
|
|
req.status = BookingStatus.confirmed.value
|
|
req.confirmed_slot = slot.model_dump(mode="json")
|
|
req.resolved_at = utcnow()
|
|
|
|
service_name = await _service_name(session, req)
|
|
day_name = fmt_slot(slot.start, tenant.timezone).split(",")[0]
|
|
body = render_sms(
|
|
tenant,
|
|
"confirmation",
|
|
usluga=service_name,
|
|
dan=day_name,
|
|
datum=fmt_date(slot.start, tenant.timezone),
|
|
vrijeme=fmt_time(slot.start, tenant.timezone),
|
|
)
|
|
if req.client_phone:
|
|
await send_sms(session, tenant.id, req.client_phone, body, kind="confirmation")
|
|
return True
|
|
|
|
|
|
async def reject_request(
|
|
session: AsyncSession, tenant: Tenant, req: BookingRequest, custom_sms: str | None = None
|
|
) -> None:
|
|
"""Reject → rejection SMS to client (template, editable before send §9.2)."""
|
|
_require_pending(req, allow_same=BookingStatus.rejected)
|
|
if req.status == BookingStatus.rejected.value:
|
|
return # idempotent replay
|
|
req.status = BookingStatus.rejected.value
|
|
req.resolved_at = utcnow()
|
|
body = custom_sms or render_sms(tenant, "rejection")
|
|
if req.client_phone:
|
|
await send_sms(session, tenant.id, req.client_phone, body, kind="rejection")
|
|
|
|
|
|
async def expire_request(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> None:
|
|
"""TTL passed with no owner action → apology SMS to client (§9.2)."""
|
|
if req.status != BookingStatus.pending.value:
|
|
return
|
|
req.status = BookingStatus.expired.value
|
|
req.resolved_at = utcnow()
|
|
if req.client_phone:
|
|
await send_sms(
|
|
session, tenant.id, req.client_phone, render_sms(tenant, "expiry"), kind="expiry"
|
|
)
|
|
|
|
|
|
def _require_pending(req: BookingRequest, allow_same: BookingStatus) -> None:
|
|
if req.status not in (BookingStatus.pending.value, allow_same.value):
|
|
raise TransitionError(
|
|
f"request {req.id} is {req.status}, cannot transition to {allow_same.value}"
|
|
)
|
|
|
|
|
|
async def _service_name(session: AsyncSession, req: BookingRequest) -> str:
|
|
if req.service_id:
|
|
service = (
|
|
await session.execute(select(Service).where(Service.id == req.service_id))
|
|
).scalar_one_or_none()
|
|
if service:
|
|
return service.name
|
|
return req.service_name_raw or "termin"
|
|
|
|
|
|
# -- background jobs ---------------------------------------------------------
|
|
|
|
|
|
async def process_expirations(session: AsyncSession) -> int:
|
|
"""Expire overdue pending requests; send owner reminders at 50% TTL. Returns count expired."""
|
|
from gogo.proposals.email import send_reminder_email
|
|
|
|
now = datetime.now(UTC)
|
|
pending = (
|
|
(
|
|
await session.execute(
|
|
select(BookingRequest).where(
|
|
BookingRequest.status == BookingStatus.pending.value
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
expired = 0
|
|
for req in pending:
|
|
tenant = (
|
|
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
|
|
).scalar_one()
|
|
expires_at = req.expires_at
|
|
if expires_at is None:
|
|
continue
|
|
if expires_at.tzinfo is None:
|
|
expires_at = expires_at.replace(tzinfo=UTC)
|
|
if expires_at <= now:
|
|
await expire_request(session, tenant, req)
|
|
expired += 1
|
|
else:
|
|
ttl = timedelta(hours=tenant.proposal_ttl_hours or 24)
|
|
if not req.reminder_sent and expires_at - now <= ttl / 2:
|
|
await send_reminder_email(session, tenant, req)
|
|
req.reminder_sent = True
|
|
return expired
|