- 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>
111 lines
4.0 KiB
Python
111 lines
4.0 KiB
Python
"""Background jobs: proposal expiry/reminders, partner polling fallback, retention.
|
|
|
|
APScheduler (in-process) — no extra infra needed for MVP scale (§4).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from sqlalchemy import select
|
|
|
|
from gogo.db import get_sessionmaker
|
|
from gogo.domain import BookingStatus, Slot
|
|
from gogo.models import BookingRequest, Tenant
|
|
from gogo.proposals import engine as proposal_engine
|
|
from gogo.scheduling.base import get_provider
|
|
|
|
log = logging.getLogger("gogo.jobs")
|
|
|
|
|
|
async def run_expiry_job() -> None:
|
|
async with get_sessionmaker()() as session:
|
|
expired = await proposal_engine.process_expirations(session)
|
|
await session.commit()
|
|
if expired:
|
|
log.info("expired %d proposals", expired)
|
|
|
|
|
|
async def run_partner_polling_job() -> None:
|
|
"""Polling fallback (§B.3) for partner tenants that cannot call webhooks."""
|
|
async with get_sessionmaker()() as session:
|
|
pending = (
|
|
(
|
|
await session.execute(
|
|
select(BookingRequest).where(
|
|
BookingRequest.status == BookingStatus.pending.value,
|
|
BookingRequest.partner_request_id.is_not(None),
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
for req in pending:
|
|
tenant = (
|
|
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
|
|
).scalar_one()
|
|
if tenant.scheduling_provider != "partner_api":
|
|
continue
|
|
provider = await get_provider(session, tenant)
|
|
if not provider.config.get("polling_fallback"):
|
|
continue
|
|
try:
|
|
data = await provider.poll_status(req.partner_request_id)
|
|
except Exception: # noqa: BLE001
|
|
log.exception("polling failed for request %s", req.id)
|
|
continue
|
|
status = data.get("status")
|
|
if status == "confirmed" and data.get("confirmed_slot"):
|
|
slot = Slot.model_validate(data["confirmed_slot"])
|
|
await proposal_engine.confirm_request(session, tenant, req, slot, recheck=False)
|
|
elif status == "resolved":
|
|
await proposal_engine.resolve_request(session, tenant, req)
|
|
elif status == "rejected":
|
|
await proposal_engine.reject_request(session, tenant, req)
|
|
await session.commit()
|
|
|
|
|
|
async def run_retention_job() -> None:
|
|
"""Delete call audio older than retention window; keep transcripts (§5.4)."""
|
|
import os
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from gogo.config import get_settings
|
|
from gogo.models import Call
|
|
|
|
cutoff = datetime.now(UTC) - timedelta(days=get_settings().audio_retention_days)
|
|
async with get_sessionmaker()() as session:
|
|
rows = (
|
|
(
|
|
await session.execute(
|
|
select(Call).where(
|
|
Call.recording_path.is_not(None), Call.started_at < cutoff
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
for call in rows:
|
|
path = call.recording_path
|
|
if path and os.path.exists(path):
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
log.exception("failed to delete recording %s", path)
|
|
continue
|
|
call.recording_path = None
|
|
await session.commit()
|
|
if rows:
|
|
log.info("retention: cleared %d recordings", len(rows))
|
|
|
|
|
|
def build_scheduler() -> AsyncIOScheduler:
|
|
scheduler = AsyncIOScheduler()
|
|
scheduler.add_job(run_expiry_job, "interval", minutes=5, id="proposal_expiry")
|
|
scheduler.add_job(run_partner_polling_job, "interval", minutes=5, id="partner_polling")
|
|
scheduler.add_job(run_retention_job, "cron", hour=4, minute=0, id="retention")
|
|
return scheduler
|