Files
gogo-telefon/gogo/jobs.py
Senad Uka e855650f09 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>
2026-07-11 09:45:06 +02:00

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