- 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>
191 lines
7.8 KiB
Python
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>"
|
|
)
|