"""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 = """
Gogo Telefon — virtuelni asistent
Link je oštećen ili nije ispravan.
", 400 ) at = ( await session.execute(select(ActionToken).where(ActionToken.token == raw)) ).scalar_one_or_none() if at is None: return None, None, None, page( "Ovaj link više ne postoji.
", 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"Status zahtjeva klijenta {req.client_name}: {label}.
" "Nije potrebna dodatna akcija.
" ) @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( "Zahtjev klijenta {req.client_name} je zatvoren. " "Klijentu nije poslan SMS — dogovorili ste se direktno.
" "Ne zaboravite upisati termin u svoj kalendar (možete iskoristiti " "priloženi .ics iz emaila).
" ) 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("Traženi termin ne postoji.
", 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( "U kalendaru više nije slobodno: {fmt_slot(slot.start, tenant.timezone)}.
" "Možete svejedno potvrditi (npr. ako ste sami upisali ovaj termin " "u kalendar), ili se javiti klijentu direktno.
" f"" ) at.used_at = utcnow() await session.commit() return page( "Klijentu {req.client_name} je poslan SMS s potvrdom za " f"{fmt_slot(slot.start, tenant.timezone)}.
" "Ne zaboravite upisati termin u svoj kalendar.
" ) if at.action == "reject": default_sms = render_sms(tenant, "rejection") return page( "Klijent {req.client_name} ({req.client_phone}) će dobiti ovu poruku — " "možete je izmijeniti prije slanja:
" f"" ) return page("Klijentu {req.client_name} je poslan SMS s obavještenjem.
" ) @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("Traženi termin ne postoji.
", 400) slot = slots[idx] await engine.confirm_request(session, tenant, req, slot, recheck=False) at.used_at = utcnow() await session.commit() return page( "Klijentu {req.client_name} je poslan SMS s potvrdom za " f"{fmt_slot(slot.start, tenant.timezone)}.
" )