Files
gogo-telefon/gogo/api/actions.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

191 lines
7.8 KiB
Python

"""Owner action links from proposal emails (§9.1-9.2): /a/{token}.
Signed, single-use, no login required, idempotent (§15). All owner-facing
copy is Bosnian. Reject shows a minimal form to edit the client SMS before
sending; confirm re-checks free/busy and warns if the slot has been taken.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Form
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.db import get_session
from gogo.domain import BookingStatus, Slot
from gogo.i18n import fmt_slot
from gogo.models import ActionToken, BookingRequest, Tenant, utcnow
from gogo.proposals import engine
from gogo.proposals.tokens import unsign
from gogo.sms.templates import render_sms
router = APIRouter()
_PAGE = """<!doctype html><html lang="bs"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Gogo Telefon</title>
<style>
body{{font-family:system-ui,sans-serif;background:#f4f4f5;margin:0;padding:24px;
display:flex;justify-content:center}}
.card{{background:#fff;border-radius:12px;padding:32px;max-width:480px;width:100%;
box-shadow:0 1px 4px rgba(0,0,0,.08)}}
h1{{font-size:20px;margin-top:0}} p{{line-height:1.5}}
.ok{{color:#16a34a}} .warn{{color:#d97706}} .err{{color:#dc2626}}
textarea{{width:100%;min-height:90px;font:inherit;padding:8px;box-sizing:border-box}}
button,a.btn{{background:#2563eb;color:#fff;border:0;border-radius:8px;padding:12px 20px;
font-size:15px;cursor:pointer;text-decoration:none;display:inline-block;margin-top:8px}}
button.green{{background:#16a34a}} button.red{{background:#dc2626}}
.muted{{color:#71717a;font-size:13px;margin-top:24px}}
</style></head><body><div class="card">{body}
<p class="muted">Gogo Telefon — virtuelni asistent</p></div></body></html>"""
def page(body: str, status_code: int = 200) -> HTMLResponse:
return HTMLResponse(_PAGE.format(body=body), status_code=status_code)
STATUS_LABEL = {
BookingStatus.pending.value: "na čekanju",
BookingStatus.resolved_by_owner.value: "riješen — klijent kontaktiran",
BookingStatus.confirmed.value: "potvrđen",
BookingStatus.rejected.value: "odbijen",
BookingStatus.expired.value: "istekao",
}
async def _load(token: str, session: AsyncSession):
raw = unsign(token)
if raw is None:
return None, None, None, page(
"<h1 class='err'>Nevažeći link</h1><p>Link je oštećen ili nije ispravan.</p>", 400
)
at = (
await session.execute(select(ActionToken).where(ActionToken.token == raw))
).scalar_one_or_none()
if at is None:
return None, None, None, page(
"<h1 class='err'>Nepoznat link</h1><p>Ovaj link više ne postoji.</p>", 404
)
req = (
await session.execute(select(BookingRequest).where(BookingRequest.id == at.request_id))
).scalar_one()
tenant = (
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
).scalar_one()
return at, req, tenant, None
def _already_done(req: BookingRequest) -> HTMLResponse:
label = STATUS_LABEL.get(req.status, req.status)
return page(
f"<h1 class='ok'>Zahtjev je već obrađen</h1>"
f"<p>Status zahtjeva klijenta <b>{req.client_name}</b>: <b>{label}</b>.</p>"
"<p>Nije potrebna dodatna akcija.</p>"
)
@router.get("/a/{token}", response_class=HTMLResponse)
async def action_get(token: str, session: AsyncSession = Depends(get_session)):
at, req, tenant, err = await _load(token, session)
if err:
return err
if req.status != BookingStatus.pending.value:
return _already_done(req)
if at.action == "resolve":
await engine.resolve_request(session, tenant, req)
at.used_at = utcnow()
await session.commit()
return page(
"<h1 class='ok'>✓ Označeno kao riješeno</h1>"
f"<p>Zahtjev klijenta <b>{req.client_name}</b> je zatvoren. "
"Klijentu <b>nije</b> poslan SMS — dogovorili ste se direktno.</p>"
"<p>Ne zaboravite upisati termin u svoj kalendar (možete iskoristiti "
"priloženi .ics iz emaila).</p>"
)
if at.action == "confirm":
slots = [Slot.model_validate(s) for s in (req.slots or [])]
idx = at.slot_index or 0
if idx >= len(slots):
return page("<h1 class='err'>Greška</h1><p>Traženi termin ne postoji.</p>", 400)
slot = slots[idx]
ok = await engine.confirm_request(session, tenant, req, slot, recheck=True)
if not ok:
# slot taken since — warn, offer force (§9.2)
return page(
"<h1 class='warn'>⚠ Termin je u međuvremenu zauzet</h1>"
f"<p>U kalendaru više nije slobodno: <b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
"<p>Možete svejedno potvrditi (npr. ako ste sami upisali ovaj termin "
"u kalendar), ili se javiti klijentu direktno.</p>"
f"<form method='post' action='/a/{token}/force-confirm'>"
"<button class='green'>Svejedno potvrdi i pošalji SMS</button></form>"
)
at.used_at = utcnow()
await session.commit()
return page(
"<h1 class='ok'>✓ Termin potvrđen</h1>"
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s potvrdom za "
f"<b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
"<p>Ne zaboravite upisati termin u svoj kalendar.</p>"
)
if at.action == "reject":
default_sms = render_sms(tenant, "rejection")
return page(
"<h1>Odbij zahtjev</h1>"
f"<p>Klijent <b>{req.client_name}</b> ({req.client_phone}) će dobiti ovu poruku — "
"možete je izmijeniti prije slanja:</p>"
f"<form method='post' action='/a/{token}/reject'>"
f"<textarea name='sms_body'>{default_sms}</textarea>"
"<button class='red'>Pošalji i odbij zahtjev</button></form>"
)
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
@router.post("/a/{token}/reject", response_class=HTMLResponse)
async def action_reject(
token: str, sms_body: str = Form(""), session: AsyncSession = Depends(get_session)
):
at, req, tenant, err = await _load(token, session)
if err:
return err
if at.action != "reject":
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
if req.status != BookingStatus.pending.value:
return _already_done(req)
await engine.reject_request(session, tenant, req, custom_sms=sms_body.strip() or None)
at.used_at = utcnow()
await session.commit()
return page(
"<h1 class='ok'>Zahtjev odbijen</h1>"
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s obavještenjem.</p>"
)
@router.post("/a/{token}/force-confirm", response_class=HTMLResponse)
async def action_force_confirm(token: str, session: AsyncSession = Depends(get_session)):
at, req, tenant, err = await _load(token, session)
if err:
return err
if at.action != "confirm":
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
if req.status != BookingStatus.pending.value:
return _already_done(req)
slots = [Slot.model_validate(s) for s in (req.slots or [])]
idx = at.slot_index or 0
if idx >= len(slots):
return page("<h1 class='err'>Greška</h1><p>Traženi termin ne postoji.</p>", 400)
slot = slots[idx]
await engine.confirm_request(session, tenant, req, slot, recheck=False)
at.used_at = utcnow()
await session.commit()
return page(
"<h1 class='ok'>✓ Termin potvrđen</h1>"
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s potvrdom za "
f"<b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
)