Files
gogo-telefon/gogo/proposals/tokens.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

45 lines
1.3 KiB
Python

"""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