feat: connect Tiny through its v3 API with OAuth
All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Tiny v3 replaces the v2 token adapter. An operator connects Tiny once from the Kanban; the callback is authorised by a single-use state, because Tiny's cross-site redirect does not carry the SameSite=Strict operator cookie. Tokens are kept in provider_tokens, the refresh token rotates under a row lock, and the worker keeps the connection alive while order creation is off. Orders find or create the customer's contact by CNPJ, then POST /pedidos with product ids from TINY_PRODUCT_TEXTIL_FOLHA, _TEXTIL_AVULSA, _UV_FOLHA and _UV_AVULSA and numeroOrdemCompra DTF-<number>; a retry searches the customer's recent orders for that number first. The product settings avoid a _FILE suffix, which the secrets loader reads as a secret file path. Production passes the application credentials through but keeps TINY_ADAPTER fake: Tiny has no sandbox, so creating real orders waits for a supervised test. compose.providers.yaml gives the local API and worker an internet route for provider testing; the default local stack still has none. Verified with the full CI integration sequence locally, including the new tiny_oauth_test against the real database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from ..core import db
|
||||
@@ -15,6 +16,7 @@ from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, o
|
||||
from ..core.models import Move, OperatorLogin, Resolution, Review
|
||||
from ..core.pricing import price
|
||||
from ..printjobs import queue as queue_print_files
|
||||
from .. import tiny
|
||||
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
|
||||
enqueue, freight, quote_view, storage, upload_row)
|
||||
from ..scanning import require_clean
|
||||
@@ -94,7 +96,7 @@ def board(user=Depends(operator)):
|
||||
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
|
||||
ORDER BY received_at LIMIT 100''').fetchall()
|
||||
return {'states': STATES, 'transitions': TRANSITIONS,
|
||||
'orders': orders, 'payment_issues': refused,
|
||||
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(),
|
||||
'finished_shown': len(finished), 'finished_total': finished_total,
|
||||
'quotes': [quote_view(c, q) for q in pending + approved],
|
||||
'pending_total': pending_total, 'approved_total': approved_total,
|
||||
@@ -207,6 +209,34 @@ def resolve_payment(uid: UUID, body: Resolution, user=Depends(operator)):
|
||||
audit('payment_issue_resolved', payment_event=str(uid), operator=user)
|
||||
return {'ok': True}
|
||||
|
||||
def tiny_status():
|
||||
if not tiny.configured():
|
||||
return {'configured': False}
|
||||
return {'configured': True, 'orders_enabled': os.environ.get('TINY_ADAPTER') == 'tiny',
|
||||
**tiny.TinyAuth().status()}
|
||||
|
||||
@router.post('/api/operator/tiny/connect')
|
||||
def tiny_connect(user=Depends(operator)):
|
||||
"""Start the one-time authorisation of this system in the client's Tiny."""
|
||||
if not tiny.configured():
|
||||
raise HTTPException(503, 'Tiny application is not configured')
|
||||
audit('tiny_connect_started', operator=user)
|
||||
return {'url': tiny.TinyAuth().authorize_url(user)}
|
||||
|
||||
@router.get('/api/operator/tiny/callback')
|
||||
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
|
||||
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
|
||||
single-use state an operator created is what authorises it."""
|
||||
if not tiny.configured():
|
||||
raise HTTPException(503, 'Tiny application is not configured')
|
||||
try:
|
||||
who = tiny.TinyAuth().complete(code, state)
|
||||
except tiny.TinyError:
|
||||
audit('tiny_connect_failed')
|
||||
return RedirectResponse('/?tiny=failed', status_code=303)
|
||||
audit('tiny_connected', operator=who)
|
||||
return RedirectResponse('/?tiny=connected', status_code=303)
|
||||
|
||||
@router.get('/api/operator/orders/{uid}/history')
|
||||
def history(uid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
|
||||
Reference in New Issue
Block a user