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>
This commit is contained in:
0
gogo/proposals/__init__.py
Normal file
0
gogo/proposals/__init__.py
Normal file
217
gogo/proposals/email.py
Normal file
217
gogo/proposals/email.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Proposal email to the owner: subject, body, action links, .ics (§9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.domain import Slot
|
||||
from gogo.email import EmailMessage, send_email
|
||||
from gogo.i18n import fmt_slot
|
||||
from gogo.models import BookingRequest, EmailLog, Service, Tenant
|
||||
from gogo.proposals.ics import build_ics
|
||||
from gogo.proposals.tokens import action_url, create_action_token
|
||||
|
||||
|
||||
def _slots_from_request(req: BookingRequest) -> list[Slot]:
|
||||
return [Slot.model_validate(s) for s in (req.slots or [])]
|
||||
|
||||
|
||||
async def send_proposal_email(
|
||||
session: AsyncSession, tenant: Tenant, req: BookingRequest, service: Service | None
|
||||
) -> None:
|
||||
recipients = list(tenant.notify_emails or [])
|
||||
if not recipients:
|
||||
return
|
||||
|
||||
slots = _slots_from_request(req)
|
||||
service_name = service.name if service else (req.service_name_raw or "termin")
|
||||
days = ", ".join(sorted({fmt_slot(s.start, tenant.timezone).split(",")[0] for s in slots}))
|
||||
subject = f"Novi zahtjev za termin — {service_name} — {req.client_name}"
|
||||
if days:
|
||||
subject += f" ({days})"
|
||||
|
||||
resolve_url = action_url(await create_action_token(session, req.id, "resolve"))
|
||||
reject_url = action_url(await create_action_token(session, req.id, "reject"))
|
||||
confirm_urls = [
|
||||
(
|
||||
fmt_slot(s.start, tenant.timezone),
|
||||
action_url(await create_action_token(session, req.id, "confirm", slot_index=i)),
|
||||
)
|
||||
for i, s in enumerate(slots)
|
||||
]
|
||||
|
||||
transcript_url = f"{get_settings().base_url}/t/{req.id}"
|
||||
|
||||
lines = [
|
||||
f"Novi zahtjev za termin — {tenant.name}",
|
||||
"",
|
||||
f"Klijent: {req.client_name}",
|
||||
f"Telefon: {req.client_phone}",
|
||||
f"Usluga: {service_name}",
|
||||
]
|
||||
if req.home_visit:
|
||||
lines.append(f"Dolazak na adresu: DA — {req.address or 'adresa nije navedena'}")
|
||||
if slots:
|
||||
lines.append("Traženi termini (po redoslijedu želje):")
|
||||
lines += [f" {i + 1}. {fmt_slot(s.start, tenant.timezone)}" for i, s in enumerate(slots)]
|
||||
if req.time_preference_text and not slots:
|
||||
lines.append(f"Željeno vrijeme: {req.time_preference_text}")
|
||||
if req.summary:
|
||||
lines += ["", f"Sažetak razgovora: {req.summary}"]
|
||||
lines += [
|
||||
"",
|
||||
f"Cijeli razgovor: {transcript_url}",
|
||||
"",
|
||||
"─" * 40,
|
||||
f"RIJEŠENO — kontaktirao/la sam klijenta:\n {resolve_url}",
|
||||
]
|
||||
for label, url in confirm_urls:
|
||||
lines.append(f"POTVRDI {label} + pošalji SMS klijentu:\n {url}")
|
||||
lines.append(f"ODBIJ zahtjev:\n {reject_url}")
|
||||
|
||||
html = _render_html(
|
||||
tenant, req, service_name, slots, resolve_url, confirm_urls, reject_url, transcript_url
|
||||
)
|
||||
|
||||
attachments = []
|
||||
if slots:
|
||||
first = slots[0]
|
||||
ics = build_ics(
|
||||
uid=str(req.id),
|
||||
start=first.start,
|
||||
end=first.end,
|
||||
summary=f"{service_name} — {req.client_name} (zahtjev)",
|
||||
description=(
|
||||
f"Zahtjev putem Gogo Telefona.\nKlijent: {req.client_name}, {req.client_phone}\n"
|
||||
f"{req.summary}"
|
||||
),
|
||||
)
|
||||
attachments.append(("termin.ics", "text/calendar", ics))
|
||||
|
||||
await send_email(EmailMessage(recipients, subject, "\n".join(lines), html, attachments))
|
||||
session.add(
|
||||
EmailLog(
|
||||
tenant_id=tenant.id,
|
||||
to_addr=", ".join(recipients),
|
||||
subject=subject,
|
||||
kind="proposal",
|
||||
status="sent",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _render_html(
|
||||
tenant: Tenant,
|
||||
req: BookingRequest,
|
||||
service_name: str,
|
||||
slots: list[Slot],
|
||||
resolve_url: str,
|
||||
confirm_urls: list[tuple[str, str]],
|
||||
reject_url: str,
|
||||
transcript_url: str,
|
||||
) -> str:
|
||||
def esc(s: str) -> str:
|
||||
return (
|
||||
str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
)
|
||||
|
||||
slot_rows = "".join(
|
||||
f"<li>{esc(fmt_slot(s.start, tenant.timezone))}</li>" for s in slots
|
||||
)
|
||||
confirm_buttons = "".join(
|
||||
f'<p><a href="{url}" style="background:#2563eb;color:#fff;padding:10px 16px;'
|
||||
f'border-radius:6px;text-decoration:none;display:inline-block">'
|
||||
f"Potvrdi {esc(label)} + SMS</a></p>"
|
||||
for label, url in confirm_urls
|
||||
)
|
||||
home = (
|
||||
f"<p><b>Dolazak na adresu:</b> DA — {esc(req.address or 'adresa nije navedena')}</p>"
|
||||
if req.home_visit
|
||||
else ""
|
||||
)
|
||||
pref = (
|
||||
f"<p><b>Željeno vrijeme:</b> {esc(req.time_preference_text)}</p>"
|
||||
if req.time_preference_text and not slots
|
||||
else ""
|
||||
)
|
||||
summary = f"<p><b>Sažetak:</b> {esc(req.summary)}</p>" if req.summary else ""
|
||||
return f"""
|
||||
<div style="font-family:sans-serif;max-width:560px">
|
||||
<h2 style="margin-bottom:4px">Novi zahtjev za termin</h2>
|
||||
<p style="color:#666;margin-top:0">{esc(tenant.name)}</p>
|
||||
<p><b>Klijent:</b> {esc(req.client_name)}<br>
|
||||
<b>Telefon:</b> {esc(req.client_phone)}<br>
|
||||
<b>Usluga:</b> {esc(service_name)}</p>
|
||||
{home}
|
||||
{"<p><b>Traženi termini:</b></p><ol>" + slot_rows + "</ol>" if slots else ""}
|
||||
{pref}
|
||||
{summary}
|
||||
<p><a href="{transcript_url}">Cijeli razgovor →</a></p>
|
||||
<hr>
|
||||
<p><a href="{resolve_url}" style="background:#16a34a;color:#fff;padding:12px 20px;
|
||||
border-radius:6px;text-decoration:none;display:inline-block;font-weight:bold">
|
||||
✓ Riješeno — kontaktirao/la sam klijenta</a></p>
|
||||
{confirm_buttons}
|
||||
<p><a href="{reject_url}" style="color:#dc2626">Odbij zahtjev</a></p>
|
||||
<p style="color:#999;font-size:12px">Gogo Telefon — virtuelni asistent salona.
|
||||
U prilogu je .ics za prvi traženi termin (možete ga pomjeriti prije spremanja u kalendar).</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
async def send_reminder_email(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> None:
|
||||
"""Reminder at 50% of proposal TTL (§9.2)."""
|
||||
recipients = list(tenant.notify_emails or [])
|
||||
if not recipients:
|
||||
return
|
||||
subject = f"Podsjetnik: neodgovoren zahtjev — {req.client_name}"
|
||||
ttl = tenant.proposal_ttl_hours or 24
|
||||
body = (
|
||||
f"Zahtjev klijenta {req.client_name} ({req.client_phone}) čeka odgovor.\n"
|
||||
f"Ako ne odgovorite u roku od {ttl // 2}h, zahtjev ističe i klijent dobija "
|
||||
f"SMS s molbom da nazove ponovo.\n\n"
|
||||
f"Pregled: {get_settings().base_url}/t/{req.id}"
|
||||
)
|
||||
await send_email(EmailMessage(recipients, subject, body))
|
||||
session.add(
|
||||
EmailLog(
|
||||
tenant_id=tenant.id,
|
||||
to_addr=", ".join(recipients),
|
||||
subject=subject,
|
||||
kind="proposal_reminder",
|
||||
status="sent",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def send_usage_warning_email(
|
||||
session: AsyncSession, tenant: Tenant, used_minutes: int, pct: int
|
||||
) -> None:
|
||||
recipients = list(tenant.notify_emails or [])
|
||||
if not recipients:
|
||||
return
|
||||
subject = f"Gogo Telefon: iskorišteno {pct}% minuta ovaj mjesec"
|
||||
body = (
|
||||
f"Salon {tenant.name} je iskoristio {used_minutes} od {tenant.included_minutes} "
|
||||
f"uključenih minuta virtualnog asistenta ovaj mjesec.\n"
|
||||
"Asistent nastavlja odgovarati na pozive. Za veći paket javite se Gogo podršci."
|
||||
)
|
||||
await send_email(EmailMessage(recipients, subject, body))
|
||||
session.add(
|
||||
EmailLog(
|
||||
tenant_id=tenant.id,
|
||||
to_addr=", ".join(recipients),
|
||||
subject=subject,
|
||||
kind="usage_warning",
|
||||
status="sent",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
from datetime import UTC
|
||||
|
||||
return datetime.now(UTC)
|
||||
251
gogo/proposals/engine.py
Normal file
251
gogo/proposals/engine.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""Proposal engine (§9): create booking requests, route delivery through the
|
||||
tenant's scheduling provider, drive state transitions and client SMS.
|
||||
|
||||
States: pending → resolved_by_owner | confirmed | rejected | expired
|
||||
The same transition functions are used by email action links, the dashboard,
|
||||
and the partner webhook — semantics are identical everywhere (§B.3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.domain import BookingRequestData, BookingStatus, DeliveryResult, Slot
|
||||
from gogo.i18n import fmt_date, fmt_slot, fmt_time
|
||||
from gogo.models import BookingRequest, Service, Tenant, utcnow
|
||||
from gogo.scheduling.base import get_provider
|
||||
from gogo.sms import send_sms
|
||||
from gogo.sms.templates import render_sms
|
||||
|
||||
log = logging.getLogger("gogo.proposals")
|
||||
|
||||
|
||||
class TransitionError(Exception):
|
||||
"""Invalid state transition (e.g. confirming an already-rejected request)."""
|
||||
|
||||
|
||||
async def create_booking_request(
|
||||
session: AsyncSession,
|
||||
tenant: Tenant,
|
||||
*,
|
||||
source: str,
|
||||
client_name: str,
|
||||
client_phone: str,
|
||||
service_id: str | None = None,
|
||||
service_name_raw: str = "",
|
||||
slots: list[Slot] | None = None,
|
||||
time_preference_text: str = "",
|
||||
home_visit: bool = False,
|
||||
address: str | None = None,
|
||||
summary: str = "",
|
||||
call_id: uuid.UUID | None = None,
|
||||
chat_session_id: uuid.UUID | None = None,
|
||||
) -> tuple[BookingRequest, DeliveryResult]:
|
||||
"""Create a pending request and deliver it via the tenant's provider."""
|
||||
slots = (slots or [])[:3] # hard cap: at most 3 offered slots (§6.2)
|
||||
now = utcnow()
|
||||
req = BookingRequest(
|
||||
tenant_id=tenant.id,
|
||||
source=source,
|
||||
client_name=client_name.strip(),
|
||||
client_phone=client_phone.strip(),
|
||||
service_id=uuid.UUID(service_id) if service_id else None,
|
||||
service_name_raw=service_name_raw,
|
||||
slots=[s.model_dump(mode="json") for s in slots],
|
||||
time_preference_text=time_preference_text,
|
||||
home_visit=home_visit,
|
||||
address=address,
|
||||
summary=summary,
|
||||
call_id=call_id,
|
||||
chat_session_id=chat_session_id,
|
||||
status=BookingStatus.pending.value,
|
||||
expires_at=now + timedelta(hours=tenant.proposal_ttl_hours or 24),
|
||||
)
|
||||
session.add(req)
|
||||
await session.flush() # assign req.id
|
||||
|
||||
data = BookingRequestData(
|
||||
gogo_request_id=str(req.id),
|
||||
tenant_id=str(tenant.id),
|
||||
created_at=now,
|
||||
source=source,
|
||||
client_name=req.client_name,
|
||||
client_phone=req.client_phone,
|
||||
service_id=service_id,
|
||||
service_name_raw=service_name_raw,
|
||||
requested_slots=slots,
|
||||
time_preference_text=time_preference_text,
|
||||
home_visit=home_visit,
|
||||
address=address,
|
||||
summary=summary,
|
||||
transcript_url=f"{get_settings().base_url}/t/{req.id}",
|
||||
)
|
||||
provider = await get_provider(session, tenant)
|
||||
result = await provider.deliver_request(data)
|
||||
if not result.ok:
|
||||
log.error("delivery failed for request %s: %s", req.id, result.detail)
|
||||
|
||||
# Optional "request received" SMS to the client (§9.1)
|
||||
if tenant.sms_request_received and req.client_phone:
|
||||
await send_sms(
|
||||
session,
|
||||
tenant.id,
|
||||
req.client_phone,
|
||||
render_sms(tenant, "request_received"),
|
||||
kind="received",
|
||||
)
|
||||
return req, result
|
||||
|
||||
|
||||
# -- transitions -------------------------------------------------------------
|
||||
|
||||
|
||||
async def resolve_request(
|
||||
session: AsyncSession, tenant: Tenant, req: BookingRequest, note: str = ""
|
||||
) -> None:
|
||||
"""Owner contacted the client directly — primary flow. Gogo sends NO SMS (§9.2)."""
|
||||
_require_pending(req, allow_same=BookingStatus.resolved_by_owner)
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return # idempotent replay
|
||||
req.status = BookingStatus.resolved_by_owner.value
|
||||
req.resolved_at = utcnow()
|
||||
req.resolution_note = note
|
||||
|
||||
|
||||
async def confirm_request(
|
||||
session: AsyncSession,
|
||||
tenant: Tenant,
|
||||
req: BookingRequest,
|
||||
slot: Slot,
|
||||
*,
|
||||
recheck: bool = True,
|
||||
) -> bool:
|
||||
"""Owner confirms a slot → confirmation SMS to client.
|
||||
|
||||
Returns False (no transition) if recheck finds the slot busy — the caller
|
||||
should show a warning and let the owner decide (force with recheck=False).
|
||||
"""
|
||||
_require_pending(req, allow_same=BookingStatus.confirmed)
|
||||
if req.status == BookingStatus.confirmed.value:
|
||||
return True # idempotent replay
|
||||
|
||||
if recheck:
|
||||
provider = await get_provider(session, tenant)
|
||||
checker = getattr(provider, "is_slot_free", None)
|
||||
if checker is not None:
|
||||
try:
|
||||
free = await checker(
|
||||
str(req.service_id) if req.service_id else None, slot
|
||||
)
|
||||
except Exception: # noqa: BLE001 — recheck is best-effort
|
||||
log.exception("free/busy recheck failed for %s", req.id)
|
||||
free = True
|
||||
if not free:
|
||||
return False
|
||||
|
||||
req.status = BookingStatus.confirmed.value
|
||||
req.confirmed_slot = slot.model_dump(mode="json")
|
||||
req.resolved_at = utcnow()
|
||||
|
||||
service_name = await _service_name(session, req)
|
||||
day_name = fmt_slot(slot.start, tenant.timezone).split(",")[0]
|
||||
body = render_sms(
|
||||
tenant,
|
||||
"confirmation",
|
||||
usluga=service_name,
|
||||
dan=day_name,
|
||||
datum=fmt_date(slot.start, tenant.timezone),
|
||||
vrijeme=fmt_time(slot.start, tenant.timezone),
|
||||
)
|
||||
if req.client_phone:
|
||||
await send_sms(session, tenant.id, req.client_phone, body, kind="confirmation")
|
||||
return True
|
||||
|
||||
|
||||
async def reject_request(
|
||||
session: AsyncSession, tenant: Tenant, req: BookingRequest, custom_sms: str | None = None
|
||||
) -> None:
|
||||
"""Reject → rejection SMS to client (template, editable before send §9.2)."""
|
||||
_require_pending(req, allow_same=BookingStatus.rejected)
|
||||
if req.status == BookingStatus.rejected.value:
|
||||
return # idempotent replay
|
||||
req.status = BookingStatus.rejected.value
|
||||
req.resolved_at = utcnow()
|
||||
body = custom_sms or render_sms(tenant, "rejection")
|
||||
if req.client_phone:
|
||||
await send_sms(session, tenant.id, req.client_phone, body, kind="rejection")
|
||||
|
||||
|
||||
async def expire_request(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> None:
|
||||
"""TTL passed with no owner action → apology SMS to client (§9.2)."""
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return
|
||||
req.status = BookingStatus.expired.value
|
||||
req.resolved_at = utcnow()
|
||||
if req.client_phone:
|
||||
await send_sms(
|
||||
session, tenant.id, req.client_phone, render_sms(tenant, "expiry"), kind="expiry"
|
||||
)
|
||||
|
||||
|
||||
def _require_pending(req: BookingRequest, allow_same: BookingStatus) -> None:
|
||||
if req.status not in (BookingStatus.pending.value, allow_same.value):
|
||||
raise TransitionError(
|
||||
f"request {req.id} is {req.status}, cannot transition to {allow_same.value}"
|
||||
)
|
||||
|
||||
|
||||
async def _service_name(session: AsyncSession, req: BookingRequest) -> str:
|
||||
if req.service_id:
|
||||
service = (
|
||||
await session.execute(select(Service).where(Service.id == req.service_id))
|
||||
).scalar_one_or_none()
|
||||
if service:
|
||||
return service.name
|
||||
return req.service_name_raw or "termin"
|
||||
|
||||
|
||||
# -- background jobs ---------------------------------------------------------
|
||||
|
||||
|
||||
async def process_expirations(session: AsyncSession) -> int:
|
||||
"""Expire overdue pending requests; send owner reminders at 50% TTL. Returns count expired."""
|
||||
from gogo.proposals.email import send_reminder_email
|
||||
|
||||
now = datetime.now(UTC)
|
||||
pending = (
|
||||
(
|
||||
await session.execute(
|
||||
select(BookingRequest).where(
|
||||
BookingRequest.status == BookingStatus.pending.value
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
expired = 0
|
||||
for req in pending:
|
||||
tenant = (
|
||||
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
|
||||
).scalar_one()
|
||||
expires_at = req.expires_at
|
||||
if expires_at is None:
|
||||
continue
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
if expires_at <= now:
|
||||
await expire_request(session, tenant, req)
|
||||
expired += 1
|
||||
else:
|
||||
ttl = timedelta(hours=tenant.proposal_ttl_hours or 24)
|
||||
if not req.reminder_sent and expires_at - now <= ttl / 2:
|
||||
await send_reminder_email(session, tenant, req)
|
||||
req.reminder_sent = True
|
||||
return expired
|
||||
42
gogo/proposals/ics.py
Normal file
42
gogo/proposals/ics.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Generate the .ics attachment (METHOD:REQUEST) for the first-choice slot (§9.1).
|
||||
|
||||
The .ics is a convenience so Gmail offers "Add to calendar" — the owner can move
|
||||
and rearrange the event before saving. Gogo never writes to the owner's calendar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from icalendar import Calendar, Event, vCalAddress, vText
|
||||
|
||||
from gogo.config import get_settings
|
||||
|
||||
|
||||
def build_ics(
|
||||
*,
|
||||
uid: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
summary: str,
|
||||
description: str,
|
||||
organizer_email: str = "noreply@gogotelefon.ba",
|
||||
) -> bytes:
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//Gogo Telefon//gogotelefon.ba//BS")
|
||||
cal.add("version", "2.0")
|
||||
cal.add("method", "REQUEST")
|
||||
|
||||
ev = Event()
|
||||
ev.add("uid", f"{uid}@gogotelefon.ba")
|
||||
ev.add("dtstart", start)
|
||||
ev.add("dtend", end)
|
||||
ev.add("summary", summary)
|
||||
ev.add("description", description)
|
||||
ev.add("status", "TENTATIVE")
|
||||
organizer = vCalAddress(f"MAILTO:{organizer_email}")
|
||||
organizer.params["cn"] = vText("Gogo Telefon")
|
||||
ev["organizer"] = organizer
|
||||
ev.add("url", get_settings().base_url)
|
||||
cal.add_component(ev)
|
||||
return cal.to_ical()
|
||||
44
gogo/proposals/tokens.py
Normal file
44
gogo/proposals/tokens.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Single-use signed action tokens for proposal email links (§9.1).
|
||||
|
||||
Tokens are random, stored in DB (single-use enforced there) and the URL carries
|
||||
an itsdangerous signature so guessing/forging is infeasible even if the DB row
|
||||
leaked. Action links must be idempotent (§15): a used 'resolve' link re-visited
|
||||
shows "already resolved", it never errors or double-fires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from itsdangerous import BadSignature, URLSafeSerializer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.models import ActionToken
|
||||
|
||||
|
||||
def _serializer() -> URLSafeSerializer:
|
||||
return URLSafeSerializer(get_settings().secret_key, salt="proposal-action")
|
||||
|
||||
|
||||
async def create_action_token(
|
||||
session: AsyncSession, request_id: uuid.UUID, action: str, slot_index: int | None = None
|
||||
) -> str:
|
||||
"""Create a DB-backed token and return the signed URL-safe token string."""
|
||||
raw = secrets.token_urlsafe(24)[:40]
|
||||
session.add(
|
||||
ActionToken(token=raw, request_id=request_id, action=action, slot_index=slot_index)
|
||||
)
|
||||
return _serializer().dumps(raw)
|
||||
|
||||
|
||||
def action_url(signed: str) -> str:
|
||||
return f"{get_settings().base_url}/a/{signed}"
|
||||
|
||||
|
||||
def unsign(signed: str) -> str | None:
|
||||
try:
|
||||
return _serializer().loads(signed)
|
||||
except BadSignature:
|
||||
return None
|
||||
Reference in New Issue
Block a user