From e3d555819884ae543a78ff403d3ca2d423dfdb63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 24 Sep 2026 12:46:09 -0300 Subject: [PATCH] feat: connect Tiny through its v3 API with OAuth 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-; 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 --- .env.example | 15 +- .gitea/workflows/deploy.yml | 1 + app/adapters.py | 6 +- app/api/operator.py | 32 +++- app/schema.sql | 14 ++ app/tiny.py | 298 +++++++++++++++++++++++++++-------- app/worker.py | 23 +++ compose.local.yaml | 10 +- compose.providers.yaml | 16 ++ deploy/portainer.env.example | 8 + docker-compose.yml | 11 ++ docs/LOCAL_SETUP.md | 43 ++++- docs/ROADMAP.md | 24 ++- tests/test_tiny.py | 113 ++++++++----- tests/tiny_oauth_test.py | 105 ++++++++++++ web/kanban.html | 6 +- web/kanban.js | 19 ++- 17 files changed, 613 insertions(+), 131 deletions(-) create mode 100644 compose.providers.yaml create mode 100644 tests/tiny_oauth_test.py diff --git a/.env.example b/.env.example index c49f89a..851579a 100644 --- a/.env.example +++ b/.env.example @@ -25,9 +25,18 @@ TINY_ADAPTER=fake # MP_ACCESS_TOKEN=TEST-... # MP_WEBHOOK_SECRET=... # MP_NOTIFICATION_URL=https:///api/payments/webhook -# TINY_ADAPTER=tiny -# TINY_TOKEN=... -# TINY_TAG=Site DTF +# Tiny API v3: client ID/secret come from the "Aplicativo" created in Tiny +# (Configurações > Geral > Aplicativos); the redirect URI registered there +# must be exactly TINY_REDIRECT_URI. Product ids are the Tiny products each +# Site product becomes. Tiny has no sandbox: orders created are real. +# TINY_CLIENT_ID=... +# TINY_CLIENT_SECRET=... +# TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback +# TINY_PRODUCT_TEXTIL_FOLHA=... +# TINY_PRODUCT_TEXTIL_AVULSA=... +# TINY_PRODUCT_UV_FOLHA=... +# TINY_PRODUCT_UV_AVULSA=... +# TINY_ADAPTER=tiny # only once connected and tested: creates real orders WHATSAPP_ADAPTER=fake STORAGE_ADAPTER=s3-local MOCK_FREIGHT_CENTS=1500 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 4103d46..0bf98c2 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -92,6 +92,7 @@ jobs: run: | $COMPOSE exec -T api python -m tests.retention_test $COMPOSE exec -T api python -m tests.runtime_security_test + $COMPOSE exec -T api python -m tests.tiny_oauth_test # Run Chrome on the Compose network. It must resolve the same storage:9000 # hostname used in presigned URLs, and absence of Chrome must fail CI. diff --git a/app/adapters.py b/app/adapters.py index cbb982e..6b29aab 100644 --- a/app/adapters.py +++ b/app/adapters.py @@ -22,8 +22,10 @@ def require_runtime(): raise RuntimeError(f'{name} must use the currently supported fake adapter') tiny = os.environ.get('TINY_ADAPTER') if tiny == 'tiny': - if not os.environ.get('TINY_TOKEN'): - raise RuntimeError('TINY_TOKEN is required for the Tiny adapter') + from .tiny import required_settings + for name in required_settings(): + if not os.environ.get(name): + raise RuntimeError(f'{name} is required for the Tiny adapter') elif tiny != 'fake': raise RuntimeError('TINY must use the fake or tiny adapter') # Mercado Pago is selectable only with its credentials present; it has not diff --git a/app/api/operator.py b/app/api/operator.py index 8c8beda..d1d664a 100644 --- a/app/api/operator.py +++ b/app/api/operator.py @@ -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: diff --git a/app/schema.sql b/app/schema.sql index 3d2fbb8..d08835a 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -99,6 +99,20 @@ CREATE TABLE IF NOT EXISTS dtf_local.payment_intents ( created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(provider, provider_payment_id) ); +-- OAuth connections to providers (Tiny). One row per provider; the refresh +-- token rotates on use, so it lives here, never in configuration. +CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens ( + provider text PRIMARY KEY, access_token text NOT NULL, refresh_token text NOT NULL, + access_expires_at timestamptz NOT NULL, refresh_expires_at timestamptz, + connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +-- Single-use states for an operator-started OAuth connection. They protect the +-- callback, which arrives cross-site without the operator's cookie. +CREATE TABLE IF NOT EXISTS dtf_local.oauth_states ( + state text PRIMARY KEY, provider text NOT NULL, operator text NOT NULL, + expires_at timestamptz NOT NULL +); -- The print file generated from each paid item's approved layout. One row per -- item: the worker claims it, renders, and records either the file or why the -- item has to be prepared by hand. Regenerating replaces the row's result. diff --git a/app/tiny.py b/app/tiny.py index 28dcbcb..1547c86 100644 --- a/app/tiny.py +++ b/app/tiny.py @@ -1,104 +1,264 @@ -"""Tiny/Olist (API 2.0): create the sales order once a payment is approved. +"""Tiny/Olist ERP (API v3): create the sales order once a payment is approved. Written from the public API documentation and exercised only against a fake -HTTP transport. Endpoints, product codes, tags and rate limits still have to -be confirmed against the client's account before this is a real integration. +HTTP transport. Tiny has no sandbox: the first real test creates a real order +in the client's ERP, so test with a marked order and cancel it afterwards. -Idempotency: the outbox may deliver the same event more than once (a timeout -after Tiny accepted it, a worker restart). Every order is created with -numero_pedido_ecommerce = "DTF-", and Tiny is searched for that -number first, so a second delivery finds the first order instead of creating -another one. +Authentication is OAuth2 on Tiny's Keycloak. The client creates an +"Aplicativo" in Tiny (Configurações > Geral > Aplicativos), which yields a +client ID and secret and registers our callback URL. An operator then clicks +"Conectar Tiny" on the Kanban once; the tokens are kept in the database and +the refresh token is rotated on every refresh, under a row lock so two +workers never spend the same one. -Tiny answers HTTP 200 for most failures and reports them in retorno.status, -so the body decides success. Anything unexpected raises, and the outbox -retries with backoff; nothing is marked delivered unless Tiny confirmed it. +Orders are created by contact and product id: the customer's contact is found +by CNPJ or created, and each product mode maps to a product that must already +exist in Tiny (PRODUCT_SETTINGS). + +Idempotency: the outbox may deliver the same event more than once. Every order +carries numeroOrdemCompra = "DTF-", and before creating one the +customer's recent orders are searched for that number, so a second delivery +finds the first order instead of creating another. """ -import json import os +import secrets +import time +from datetime import datetime, timedelta, timezone from decimal import Decimal +from urllib.parse import urlencode import httpx -API = 'https://api.tiny.com.br/api2' +API = 'https://api.tiny.com.br/public-api/v3' +AUTH = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect' +# Not TINY_PRODUCT_: app/core/secrets.py reads any variable ending in +# _FILE as a path to a secret file, and one product mode is called "file". +PRODUCT_SETTINGS = {'file': 'TINY_PRODUCT_TEXTIL_FOLHA', 'avulsa': 'TINY_PRODUCT_TEXTIL_AVULSA', + 'uvfile': 'TINY_PRODUCT_UV_FOLHA', 'uv': 'TINY_PRODUCT_UV_AVULSA'} PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada', 'avulsa': 'DTF Têxtil 57 cm · artes avulsas', 'uvfile': 'DTF UV 28,5 cm · folha montada', 'uv': 'DTF UV 28,5 cm · artes avulsas'} +# How far back to look for an order a previous delivery may already have made. +SEARCH_DAYS = 7 +STATE_MINUTES = 10 +# Brazil has had no daylight saving since 2019; the order date is the local day. +BRASILIA = timezone(timedelta(hours=-3)) + + +def local_today(): + return datetime.now(BRASILIA).date() class TinyError(Exception): pass -def ecommerce_number(number): +class TinyNotConnected(TinyError): + """No authorised connection yet: an operator must click "Conectar Tiny".""" + + +def purchase_order(number): return f'DTF-{number}' +def configured(): + """Whether the OAuth application is configured (orders may still be fake).""" + return all(os.environ.get(name) for name in ('TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI')) + + +def required_settings(): + return ['TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'] + list(PRODUCT_SETTINGS.values()) + + +# OAuth ------------------------------------------------------------------- + +class TinyAuth: + """The OAuth application and the stored connection.""" + + def __init__(self, connect=None, transport=None, clock=time.time): + self.client_id = os.environ.get('TINY_CLIENT_ID', '') + self.client_secret = os.environ.get('TINY_CLIENT_SECRET', '') + self.redirect_uri = os.environ.get('TINY_REDIRECT_URI', '') + if connect is None: + from .core.db import connect + self.connect = connect + self.http = httpx.Client(timeout=20, transport=transport) + self.clock = clock + + def authorize_url(self, operator): + """Start a connection. The state is single-use, short-lived, and only an + authenticated operator can create one, which is what protects the + callback: Tiny's redirect back is cross-site, so the operator's + SameSite=Strict cookie does not travel with it.""" + state = secrets.token_urlsafe(32) + with self.connect() as c: + c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_atnow() RETURNING operator''', (state,)).fetchone() + if not row: + raise TinyError('Unknown or expired authorisation state') + tokens = self._token({'grant_type': 'authorization_code', 'code': code, + 'redirect_uri': self.redirect_uri}) + self._store(c, tokens, row['operator']) + return row['operator'] + + def status(self): + with self.connect() as c: + row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at + FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone() + if not row: + return {'connected': False} + expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc) + return {'connected': not expired, 'connected_by': row['connected_by'], + 'connected_at': row['connected_at'], 'expires_at': row['refresh_expires_at']} + + def access_token(self): + """A valid access token, refreshing (and rotating) under a row lock.""" + with self.connect() as c: + row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny' + FOR UPDATE''').fetchone() + if not row: + raise TinyNotConnected('Tiny is not connected; use "Conectar Tiny" on the Kanban') + now = datetime.now(timezone.utc) + if row['access_expires_at'] > now + timedelta(seconds=60): + return row['access_token'] + if row['refresh_expires_at'] and row['refresh_expires_at'] <= now: + raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban') + tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']}) + self._store(c, tokens, row['connected_by'], refreshed=True) + return tokens['access_token'] + + def _token(self, form): + response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id, + 'client_secret': self.client_secret}) + if response.status_code == 400 and form['grant_type'] == 'refresh_token': + raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban') + response.raise_for_status() + tokens = response.json() + if not tokens.get('access_token') or not tokens.get('refresh_token'): + raise TinyError('Tiny token response is missing tokens') + return tokens + + def _store(self, c, tokens, operator, refreshed=False): + now = datetime.now(timezone.utc) + access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300))) + refresh_in = tokens.get('refresh_expires_in') + refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None + c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token, + access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at) + VALUES('tiny',%s,%s,%s,%s,%s,now(),now()) + ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token, + refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at, + refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(), + connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END, + connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''', + (tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires, + operator, refreshed, refreshed)) + + +# Orders ------------------------------------------------------------------ + def money(cents): - return f'{Decimal(cents) / 100:.2f}' + return float(Decimal(cents) / 100) -def order_payload(payload): - """The Tiny 'pedido' for a paid order's approved snapshot.""" - order = payload['order'] +def contact_payload(order): customer = order['customer'] destination = order.get('destination') - client = {'nome': (destination or {}).get('recipient') or customer['mail'], - 'tipo_pessoa': 'J', 'cpf_cnpj': customer['cnpj'], - 'fone': customer['zap'], 'email': customer['mail']} + contact = {'nome': (destination or {}).get('recipient') or customer['mail'], + 'tipoPessoa': 'J', 'cpfCnpj': customer['cnpj'], 'email': customer['mail'], + 'celular': customer['zap'], 'situacao': 'A'} if destination: - client.update(endereco=destination['street'], numero=destination['number'], - complemento=destination.get('complement', ''), bairro=destination['district'], - cep=destination['postal_code'], cidade=destination['city'], uf=destination['state']) + contact['endereco'] = address(destination) + return contact + + +def address(destination): + return {'endereco': destination['street'], 'numero': destination['number'], + 'complemento': destination.get('complement', ''), 'bairro': destination['district'], + 'municipio': destination['city'], 'cep': destination['postal_code'], + 'uf': destination['state'], 'pais': 'Brasil'} + + +def order_payload(payload, contact_id, today=None): + """The v3 'pedido' for a paid order's approved snapshot.""" + order = payload['order'] items = [] for item in order['items']: - code = os.environ.get(f"TINY_SKU_{item['mode'].upper()}", '') - entry = {'descricao': PRODUCTS[item['mode']] + f" · nota {item['grade']}", - 'unidade': 'M', 'quantidade': item['billed_metres'], - 'valor_unitario': money(item['unit_cents'])} - if code: - entry['codigo'] = code - items.append({'item': entry}) + setting = PRODUCT_SETTINGS[item['mode']] + product = os.environ.get(setting, '') + if not product.isdigit(): + raise TinyError(f'{setting} must be the Tiny product id') + items.append({'produto': {'id': int(product)}, + 'quantidade': float(Decimal(item['billed_metres'])), + 'valorUnitario': money(item['unit_cents']), + 'infoAdicional': PRODUCTS[item['mode']] + f" · nota {item['grade']}"}) freight = order['freight'] - pedido = {'numero_pedido_ecommerce': ecommerce_number(payload['number']), - 'cliente': client, 'itens': items, - 'valor_frete': money(freight['total_cents']), - 'obs': f"Pedido DTF #{payload['number']} · pago · {payload['order_id']}"} - if freight.get('service') == 'pickup': - # What dispatch already receives for pickups today (see web/site-config.js). - pedido.update(forma_envio='X', nome_transportador='DropStar', frete_por_conta='R') - tag = os.environ.get('TINY_TAG', '') - if tag: - pedido['marcadores'] = [{'marcador': {'descricao': tag}}] - return {'pedido': pedido} + pickup = freight.get('service') == 'pickup' + pedido = {'data': (today or local_today()).isoformat(), + 'idContato': contact_id, + 'numeroOrdemCompra': purchase_order(payload['number']), + 'itens': items, + 'valorFrete': money(freight['total_cents']), + 'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''), + 'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"} + destination = order.get('destination') + if destination: + pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'], + 'nomeDestinatario': destination['recipient']} + del pedido['enderecoEntrega']['numero'] + ecommerce = os.environ.get('TINY_ECOMMERCE_ID', '') + if ecommerce.isdigit(): + pedido['ecommerce'] = {'id': int(ecommerce), 'numeroPedidoEcommerce': purchase_order(payload['number'])} + return pedido class TinyOrders: - def __init__(self, token=None, transport=None): - self.token = token or os.environ.get('TINY_TOKEN', '') - if not self.token: - raise RuntimeError('Tiny needs TINY_TOKEN') + def __init__(self, auth=None, transport=None, today=None): + missing = [name for name in required_settings() if not os.environ.get(name)] + if auth is None and missing: + raise RuntimeError('Tiny needs ' + ', '.join(missing)) + self.auth = auth or TinyAuth() self.http = httpx.Client(base_url=API, transport=transport, timeout=30) + self.today = today - def call(self, method, **params): - response = self.http.post(f'/{method}.php', data={'token': self.token, 'formato': 'json', **params}) - response.raise_for_status() - body = response.json().get('retorno', {}) - if body.get('status') != 'OK': - errors = body.get('erros') or body.get('registros') or [] - # "No records" is how the search reports an empty result. - if str(body.get('codigo_erro')) == '20': - return {'pedidos': []} - raise TinyError(f"{method}: {body.get('codigo_erro')} {json.dumps(errors, ensure_ascii=False)[:300]}") - return body + def request(self, method, path, **kwargs): + headers = {'Authorization': f'Bearer {self.auth.access_token()}'} + response = self.http.request(method, path, headers=headers, **kwargs) + if response.status_code == 429: + raise TinyError('Tiny rate limit reached; the outbox will retry') + if response.status_code >= 400: + raise TinyError(f'{method} {path}: {response.status_code} {response.text[:300]}') + return response.json() if response.content else {} - def find(self, number): - found = self.call('pedidos.pesquisa', numeroEcommerce=ecommerce_number(number)) - for entry in found.get('pedidos') or []: - pedido = entry.get('pedido', entry) - if str(pedido.get('numero_ecommerce')) == ecommerce_number(number): - return pedido + def contact(self, order): + cnpj = order['customer']['cnpj'] + found = self.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5}) + for entry in found.get('itens') or []: + if ''.join(ch for ch in str(entry.get('cpfCnpj') or '') if ch.isdigit()) == cnpj: + return entry['id'] + return self.request('POST', '/contatos', json=contact_payload(order))['id'] + + def find(self, payload): + """An order a previous delivery already created, or None.""" + wanted = purchase_order(payload['number']) + since = ((self.today or local_today()) - timedelta(days=SEARCH_DAYS)).isoformat() + found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'], + 'dataInicial': since, 'limit': 100}) + for entry in found.get('itens') or []: + detail = self.request('GET', f"/pedidos/{entry['id']}") + if detail.get('numeroOrdemCompra') == wanted: + return detail return None def deliver(self, event_key, payload): @@ -106,13 +266,11 @@ class TinyOrders: # Production progress is not written to Tiny; only the sale is. return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable', 'event': payload.get('event')} - existing = self.find(payload['number']) + existing = self.find(payload) if existing: return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created', - 'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numero'))} - created = self.call('pedido.incluir', pedido=json.dumps(order_payload(payload), ensure_ascii=False)) - record = (created.get('registros') or [{}])[0].get('registro', {}) - if record.get('status') != 'OK': - raise TinyError(f"pedido.incluir: {json.dumps(record, ensure_ascii=False)[:300]}") + 'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))} + contact_id = self.contact(payload['order']) + created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today)) return {'provider': 'tiny', 'event_key': event_key, 'status': 'created', - 'tiny_id': str(record.get('id')), 'tiny_number': str(record.get('numero'))} + 'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))} diff --git a/app/worker.py b/app/worker.py index b0a346e..c7dc39f 100644 --- a/app/worker.py +++ b/app/worker.py @@ -21,6 +21,7 @@ if os.environ.get('TINY_ADAPTER') == 'tiny': adapters['tiny'] = TinyOrders() last_tick = 0.0 last_cleanup = 0.0 +last_tiny_keepalive = 0.0 storage = LocalS3Storage() scan_thread = None render_thread = None @@ -52,6 +53,27 @@ def tick(): c.execute("UPDATE dtf_local.outbox SET attempts=attempts+1, last_error=%s, available_at=now() + %s * interval '1 second' WHERE id=%s", (str(exc),delay,job['id'])) last_tick = time.monotonic() +def tiny_keepalive(): + """Keep a connected Tiny authorised even while no orders are sent. + + The refresh token expires unless it is used; access_token() refreshes (and + rotates) only when the access token is about to expire, so asking every few + minutes renews the connection roughly once per access-token lifetime. + """ + global last_tiny_keepalive + if time.monotonic()-last_tiny_keepalive < 600: + return + last_tiny_keepalive = time.monotonic() + from . import tiny + if not tiny.configured(): + return + try: + tiny.TinyAuth().access_token() + except tiny.TinyNotConnected: + pass + except Exception: + logging.exception('Tiny connection refresh failed') + def loop(): global last_cleanup while True: @@ -60,6 +82,7 @@ def loop(): if time.monotonic()-last_cleanup > 60: cleanup() last_cleanup = time.monotonic() + tiny_keepalive() except Exception: logging.exception('Local worker tick failed') time.sleep(1) diff --git a/compose.local.yaml b/compose.local.yaml index 2546655..22766b6 100644 --- a/compose.local.yaml +++ b/compose.local.yaml @@ -37,8 +37,14 @@ x-app: &app MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-} FREIGHT_ADAPTER: fake TINY_ADAPTER: ${TINY_ADAPTER:-fake} - TINY_TOKEN: ${TINY_TOKEN:-} - TINY_TAG: ${TINY_TAG:-} + TINY_CLIENT_ID: ${TINY_CLIENT_ID:-} + TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-} + TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-} + TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-} + TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-} + TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-} + TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-} + TINY_ECOMMERCE_ID: ${TINY_ECOMMERCE_ID:-} WHATSAPP_ADAPTER: fake STORAGE_ADAPTER: s3-local MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500} diff --git a/compose.providers.yaml b/compose.providers.yaml new file mode 100644 index 0000000..58bcdfc --- /dev/null +++ b/compose.providers.yaml @@ -0,0 +1,16 @@ +# Provider sandbox testing only: gives the API and worker a route to the +# internet so they can reach Mercado Pago and Tiny. The default local stack +# keeps them on an internal network with no external route, which is what +# every other local run should keep doing. +# +# docker compose -f compose.local.yaml -f compose.providers.yaml up -d --wait +# +# Credentials go in .env (see .env.example); never production credentials. +services: + api: + networks: [local, provider-egress] + worker: + networks: [local, provider-egress] + +networks: + provider-egress: {} diff --git a/deploy/portainer.env.example b/deploy/portainer.env.example index fb20fe4..4d10408 100644 --- a/deploy/portainer.env.example +++ b/deploy/portainer.env.example @@ -29,6 +29,14 @@ OPERATOR_EMAIL=TBD PAYMENT_ADAPTER=TBD FREIGHT_ADAPTER=TBD TINY_ADAPTER=TBD +# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The +# redirect URL registered there must equal TINY_REDIRECT_URI. +TINY_CLIENT_ID=TBD +TINY_REDIRECT_URI=https:///api/operator/tiny/callback +TINY_PRODUCT_TEXTIL_FOLHA=TBD +TINY_PRODUCT_TEXTIL_AVULSA=TBD +TINY_PRODUCT_UV_FOLHA=TBD +TINY_PRODUCT_UV_AVULSA=TBD WHATSAPP_ADAPTER=TBD STORAGE_QUOTA_BYTES=TBD OWNER_UPLOAD_QUOTA_BYTES=TBD diff --git a/docker-compose.yml b/docker-compose.yml index 6b19478..0def095 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,18 @@ x-app-environment: &app-environment # when the provider is configured, never to a value anyone could guess. PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-} FREIGHT_ADAPTER: fake + # Order creation in Tiny stays off until it has been tested against the + # client's account (Tiny has no sandbox). The application credentials can be + # set now: they let an operator connect Tiny from the Kanban, and the worker + # keeps that connection alive. Callback: https:///api/operator/tiny/callback TINY_ADAPTER: fake + TINY_CLIENT_ID: ${TINY_CLIENT_ID:-} + TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-} + TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-} + TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-} + TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-} + TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-} + TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-} WHATSAPP_ADAPTER: fake STORAGE_ADAPTER: s3-r2 PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN} diff --git a/docs/LOCAL_SETUP.md b/docs/LOCAL_SETUP.md index 62a1dc3..de59dfd 100644 --- a/docs/LOCAL_SETUP.md +++ b/docs/LOCAL_SETUP.md @@ -255,7 +255,15 @@ draws each page and checks where every quadrant of the artwork lands. `app/mercadopago.py` and `app/tiny.py` follow the providers' public API documentation and pass their unit suites against a fake transport. They are not -verified integrations until they pass with the client's sandbox accounts. +verified integrations until they pass with the client's accounts. + +The default local stack gives the API and worker no route to the internet, so +provider testing adds `compose.providers.yaml`, which does: + +```bash +docker compose -f compose.local.yaml -f compose.providers.yaml up -d --wait +``` + Put only **test** credentials in `.env`: ```bash @@ -263,11 +271,38 @@ PAYMENT_ADAPTER=mercadopago MP_ACCESS_TOKEN=TEST-... MP_WEBHOOK_SECRET=... # "Assinatura secreta" in the webhook settings MP_NOTIFICATION_URL=https:///api/payments/webhook -TINY_ADAPTER=tiny -TINY_TOKEN=... -TINY_TAG=Site DTF # optional marker on created orders ``` +### Tiny (API v3) + +Tiny uses OAuth2. In the client's Tiny (Construa plan or above, with the +"Gestão de Aplicativos" extension): **Configurações → Geral → Aplicativos → ++ novo aplicativo**, with the redirect URL set to this system's callback. That +gives a client ID and secret: + +```bash +TINY_CLIENT_ID=... +TINY_CLIENT_SECRET=... +TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback # exactly as registered in Tiny +TINY_PRODUCT_TEXTIL_FOLHA=... # Tiny product id for each Site product +TINY_PRODUCT_TEXTIL_AVULSA=... +TINY_PRODUCT_UV_FOLHA=... +TINY_PRODUCT_UV_AVULSA=... +``` + +With the client ID and secret set, the Kanban header shows **Conectar Tiny**. +Someone with a Tiny login approves access once; the tokens are stored in the +database (the refresh token rotates on every use) and the worker keeps the +connection alive. Only then set `TINY_ADAPTER=tiny`, which starts creating an +order in Tiny for every paid order: find or create the customer's contact by +CNPJ, then `POST /pedidos` with `numeroOrdemCompra = DTF-`. A +retry searches the customer's recent orders for that number first, so it does +not create a second one. **Tiny has no sandbox**: every test order is real, so +agree the test with the client and cancel the test orders afterwards. + +`tests.tiny_oauth_test` checks the connection flow against the real database +with a fake token server; it saves and restores any existing connection. + With Mercado Pago selected, an approved quote shows **Pagar com PIX** on the Site instead of the local test button. The order is created only by the signed notification, after the payment is fetched from the Mercado Pago API and its diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2d5a829..5989d54 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -237,13 +237,23 @@ From the report already sent. These are dated promises, not backlog. packaging weight/dimensions per length, subsidy policy. - `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability. Confirm endpoints, tag behaviour and rate limits first. - **Groundwork (2026-09-24):** `app/tiny.py` (API 2.0) maps the approved - snapshot to `pedido.incluir` with `numero_pedido_ecommerce = DTF-`, - searches for that number before creating, and treats Tiny's in-body errors - as failures so the outbox retries. Pickup keeps the existing `forma_envio X` - / DropStar convention. Selected with `TINY_ADAPTER=tiny`; tested against a - fake transport only. Product codes (`TINY_SKU_`), the tag, API version - (2.0 vs 3.0) and rate limits must be confirmed on the client's account. + **Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's + Keycloak. An operator starts the connection from the Kanban; the callback is + authorised by a single-use state (the operator cookie is SameSite=Strict and + does not survive Tiny's cross-site redirect). Tokens live in + `provider_tokens`; the refresh token rotates under a row lock and the worker + keeps the connection alive. Orders: contact found by CNPJ or created, then + `POST /pedidos` with product ids from `TINY_PRODUCT_TEXTIL_FOLHA` / `_TEXTIL_AVULSA` / `_UV_FOLHA` + / `_UV_AVULSA` (not `_`: a name ending in `_FILE` is read as a secret + file path by `app/core/secrets.py`) and + `numeroOrdemCompra = DTF-`; a retry searches the customer's last + seven days of orders for that number first. Production passes the app + credentials through but keeps `TINY_ADAPTER: fake`. Tested against fake + transports (`tests.test_tiny`) and, for OAuth, the real database + (`tests.tiny_oauth_test`). Tiny has no sandbox: the first real test creates + real orders. Still to confirm on the client's account: plan (Construa+), + product ids, token lifetimes, whether pickup needs a transportador, and + rate limits. - `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions must survive checkout before an output engine can reproduce the approved job). **Built (2026-09-24):** each paid item gets a PDF the width of the film and diff --git a/tests/test_tiny.py b/tests/test_tiny.py index 068332b..9e5b55d 100644 --- a/tests/test_tiny.py +++ b/tests/test_tiny.py @@ -1,15 +1,18 @@ -"""The Tiny adapter against a fake HTTP transport: payload and idempotency. +"""The Tiny v3 adapter against a fake HTTP transport: payload and idempotency. This proves the documented contract only; the client's account must still -confirm endpoints, product codes and tags. Runs where httpx is installed. +confirm products, contacts and order fields. Runs where httpx is installed. +The OAuth connection against the real database is tests/tiny_oauth_test.py. """ import json +import os import unittest -from urllib.parse import parse_qs +from datetime import date +from unittest import mock import httpx -from app.tiny import TinyError, TinyOrders, order_payload +from app.tiny import TinyError, TinyOrders, contact_payload, order_payload PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event': 'payment_approved', 'order': {'customer': {'cnpj': '11222333000181', 'zap': '16999999999', 'mail': 'loja@example.test'}, @@ -19,60 +22,94 @@ PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event 'complement': '', 'district': 'Centro', 'city': 'Franca', 'state': 'SP', 'postal_code': '14400000'}, 'total_cents': 8472}} +PRODUCTS = {'TINY_PRODUCT_TEXTIL_FOLHA': '101', 'TINY_PRODUCT_TEXTIL_AVULSA': '102', + 'TINY_PRODUCT_UV_FOLHA': '103', 'TINY_PRODUCT_UV_AVULSA': '104'} +class FakeAuth: + def access_token(self): + return 'access-1' + + +@mock.patch.dict(os.environ, PRODUCTS) class TinyTests(unittest.TestCase): def setUp(self): - self.orders = {} + self.contacts = [] + self.orders = [] self.calls = [] def handler(request): - form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()} - method = request.url.path.rsplit('/', 1)[-1].removesuffix('.php') - self.calls.append((method, form)) - if method == 'pedidos.pesquisa': - found = self.orders.get(form['numeroEcommerce']) - if not found: - return httpx.Response(200, json={'retorno': {'status': 'Erro', 'codigo_erro': 20}}) - return httpx.Response(200, json={'retorno': {'status': 'OK', 'pedidos': [{'pedido': found}]}}) - pedido = json.loads(form['pedido'])['pedido'] - record = {'id': 9000 + len(self.orders), 'numero': 100 + len(self.orders), 'status': 'OK'} - self.orders[pedido['numero_pedido_ecommerce']] = {**record, 'numero_ecommerce': pedido['numero_pedido_ecommerce']} - return httpx.Response(200, json={'retorno': {'status': 'OK', 'registros': [{'registro': {'sequencia': 1, **record}}]}}) + path = request.url.path.removeprefix('/public-api/v3') + self.calls.append((request.method, path)) + assert request.headers['authorization'] == 'Bearer access-1' + if request.method == 'GET' and path == '/contatos': + cnpj = request.url.params['cpfCnpj'] + return httpx.Response(200, json={'itens': [c for c in self.contacts if c['cpfCnpj'] == cnpj]}) + if request.method == 'POST' and path == '/contatos': + contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)} + self.contacts.append(contact) + return httpx.Response(200, json={'id': contact['id']}) + if request.method == 'GET' and path == '/pedidos': + return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]}) + if request.method == 'GET' and path.startswith('/pedidos/'): + wanted = int(path.rsplit('/', 1)[-1]) + return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted)) + if request.method == 'POST' and path == '/pedidos': + order = {**json.loads(request.content), 'id': 9000 + len(self.orders), + 'numeroPedido': str(100 + len(self.orders))} + self.orders.append(order) + return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']}) + return httpx.Response(404) - self.tiny = TinyOrders('test-token', transport=httpx.MockTransport(handler)) + self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler), + today=date(2026, 9, 24)) - def test_payload_carries_customer_address_items_and_freight(self): - pedido = order_payload(PAID)['pedido'] - self.assertEqual(pedido['numero_pedido_ecommerce'], 'DTF-42') - self.assertEqual((pedido['cliente']['cpf_cnpj'], pedido['cliente']['cep'], pedido['cliente']['uf']), - ('11222333000181', '14400000', 'SP')) - item = pedido['itens'][0]['item'] - self.assertEqual((item['quantidade'], item['valor_unitario'], item['unidade']), ('2.8', '24.90', 'M')) - self.assertEqual(pedido['valor_frete'], '15.00') + def test_payload_carries_contact_products_address_and_freight(self): + pedido = order_payload(PAID, 777, date(2026, 9, 24)) + self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']), + (777, 'DTF-42', '2026-09-24')) + item = pedido['itens'][0] + self.assertEqual((item['produto'], item['quantidade'], item['valorUnitario']), + ({'id': 102}, 2.8, 24.9)) + self.assertEqual(pedido['valorFrete'], 15.0) + self.assertEqual((pedido['enderecoEntrega']['cep'], pedido['enderecoEntrega']['enderecoNro'], + pedido['enderecoEntrega']['nomeDestinatario']), ('14400000', '10', 'Loja Teste')) + contact = contact_payload(PAID['order']) + self.assertEqual((contact['tipoPessoa'], contact['cpfCnpj'], contact['endereco']['uf']), + ('J', '11222333000181', 'SP')) pickup = order_payload({**PAID, 'order': {**PAID['order'], 'destination': None, - 'freight': {'service': 'pickup', 'total_cents': 0}}})['pedido'] - self.assertEqual((pickup['forma_envio'], pickup['frete_por_conta']), ('X', 'R')) - self.assertNotIn('endereco', pickup['cliente']) + 'freight': {'service': 'pickup', 'total_cents': 0}}}, 1) + self.assertNotIn('enderecoEntrega', pickup) + self.assertIn('retirada', pickup['observacoes']) def test_second_delivery_finds_the_first_order(self): first = self.tiny.deliver('k1', PAID) second = self.tiny.deliver('k1', PAID) - self.assertEqual(first['status'], 'created') - self.assertEqual(second['status'], 'already-created') + self.assertEqual((first['status'], second['status']), ('created', 'already-created')) self.assertEqual(first['tiny_id'], second['tiny_id']) - self.assertEqual([m for m, _ in self.calls].count('pedido.incluir'), 1) - self.assertEqual(self.calls[0][1]['token'], 'test-token') + self.assertEqual(self.calls.count(('POST', '/pedidos')), 1) + self.assertEqual(self.calls.count(('POST', '/contatos')), 1) + + def test_existing_contact_is_reused(self): + self.contacts.append({'id': 321, 'cpfCnpj': '11222333000181'}) + self.tiny.deliver('k1', PAID) + self.assertNotIn(('POST', '/contatos'), self.calls) + self.assertEqual(self.orders[0]['idContato'], 321) def test_production_events_are_not_sent(self): self.assertEqual(self.tiny.deliver('k2', {**PAID, 'event': 'ready'})['status'], 'not-applicable') self.assertEqual(self.calls, []) - def test_errors_reported_in_the_body_are_failures(self): - tiny = TinyOrders('t', transport=httpx.MockTransport(lambda r: httpx.Response( - 200, json={'retorno': {'status': 'Erro', 'codigo_erro': 6, 'erros': [{'erro': 'API Bloqueada'}]}}))) - with self.assertRaises(TinyError): - tiny.deliver('k3', PAID) + def test_missing_product_mapping_fails_rather_than_guessing(self): + with mock.patch.dict(os.environ, {'TINY_PRODUCT_TEXTIL_AVULSA': ''}): + with self.assertRaises(TinyError): + self.tiny.deliver('k3', PAID) + self.assertNotIn(('POST', '/pedidos'), self.calls) + + def test_rate_limit_is_a_retryable_failure(self): + tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429))) + with self.assertRaisesRegex(TinyError, 'rate limit'): + tiny.deliver('k4', PAID) if __name__ == '__main__': diff --git a/tests/tiny_oauth_test.py b/tests/tiny_oauth_test.py new file mode 100644 index 0000000..a89dd57 --- /dev/null +++ b/tests/tiny_oauth_test.py @@ -0,0 +1,105 @@ +"""The Tiny OAuth connection against the real database, with a fake token server. + +Run inside the API container: python -m tests.tiny_oauth_test +It saves any existing Tiny connection first and restores it afterwards. +""" +import os +from datetime import datetime, timedelta, timezone +from urllib.parse import parse_qs, urlparse + +import httpx + +from app.core.db import connect +from app.tiny import TinyAuth, TinyError, TinyNotConnected + +os.environ.update(TINY_CLIENT_ID='test-client', TINY_CLIENT_SECRET='test-secret', + TINY_REDIRECT_URI='http://localhost:8081/api/operator/tiny/callback') + + +def run(): + with connect() as c: + saved = c.execute("SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone() + c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'") + try: + exercise() + finally: + with connect() as c: + c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'") + if saved: + columns = ','.join(saved) + c.execute(f'INSERT INTO dtf_local.provider_tokens({columns}) VALUES({",".join(["%s"] * len(saved))})', + tuple(saved.values())) + + +def exercise(): + issued = [] + + def token_server(request): + form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()} + assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret') + if form['grant_type'] == 'authorization_code': + assert form['code'] == 'good-code' and form['redirect_uri'].endswith('/tiny/callback') + elif form['grant_type'] == 'refresh_token': + if form['refresh_token'] != issued[-1]: + return httpx.Response(400, json={'error': 'invalid_grant'}) + n = len(issued) + 1 + issued.append(f'refresh-{n}') + return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400, + 'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400}) + + auth = TinyAuth(transport=httpx.MockTransport(token_server)) + assert auth.status() == {'connected': False} + try: + auth.access_token() + raise AssertionError('an unconnected Tiny must not yield a token') + except TinyNotConnected: + pass + + url = urlparse(auth.authorize_url('operator@example.test')) + query = {k: v[0] for k, v in parse_qs(url.query).items()} + assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client' + assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30 + try: + auth.complete('good-code', 'forged-state') + raise AssertionError('a state no operator created must be refused') + except TinyError: + pass + assert auth.complete('good-code', query['state']) == 'operator@example.test' + try: + auth.complete('good-code', query['state']) + raise AssertionError('a state must be single-use') + except TinyError: + pass + status = auth.status() + assert status['connected'] and status['connected_by'] == 'operator@example.test' + assert auth.access_token() == 'access-1' + print('PASS: operator-started state is required, single-use, and stores the connection') + + # An access token about to expire is refreshed, and the refresh token rotates. + with connect() as c: + c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'") + assert auth.access_token() == 'access-2' + with connect() as c: + row = c.execute("SELECT refresh_token,connected_by FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone() + assert row == {'refresh_token': 'refresh-2', 'connected_by': 'operator@example.test'} + assert auth.access_token() == 'access-2' + print('PASS: expiring access token refreshed once, refresh token rotated and stored') + + # A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop. + with connect() as c: + c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked' + WHERE provider='tiny'""") + try: + auth.access_token() + raise AssertionError('a refused refresh must report the connection as lost') + except TinyNotConnected: + pass + with connect() as c: + c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'", + (datetime.now(timezone.utc) - timedelta(seconds=1),)) + assert not auth.status()['connected'] + print('PASS: revoked or expired connections report that Tiny must be connected again') + + +if __name__ == '__main__': + run() diff --git a/web/kanban.html b/web/kanban.html index d82c80d..4dc2830 100644 --- a/web/kanban.html +++ b/web/kanban.html @@ -10,12 +10,12 @@ button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padd button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px} label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px} .col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)} -.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}.print{margin-top:8px;padding-top:8px;border-top:1px solid var(--linha)}.print button{margin-left:6px}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)} +.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}.print{margin-top:8px;padding-top:8px;border-top:1px solid var(--linha)}.print button{margin-left:6px}#tiny{font-size:12px;color:var(--fraco);margin-right:8px}#tiny button{margin-left:6px}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)} -

Kanban DTF

+

Kanban DTF

Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.

Eventos locais de integração
- + diff --git a/web/kanban.js b/web/kanban.js index d3c12cb..b08a545 100644 --- a/web/kanban.js +++ b/web/kanban.js @@ -93,7 +93,7 @@ async function load(){ moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length, approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length}; $('login').hidden=true; - $('status').textContent='Atualizado às '+new Date().toLocaleTimeString(); + $('status').textContent='Atualizado às '+new Date().toLocaleTimeString()+tinyNotice; render(); }catch(e){$('status').textContent=e.message;$('login').hidden=false;} } @@ -108,7 +108,24 @@ async function loadMoreQuotes(kind){ moreQuotes[kind]=page.has_more; render(); } +// The one-time authorisation of this system in the client's Tiny. Shown only +// when the Tiny application is configured on the server. +function tinyStatus(){ + const box=$('tiny');box.replaceChildren(); + const tiny=board.tiny||{}; + if(!tiny.configured)return; + box.append(node('span',tiny.connected + ? 'Tiny conectado'+(tiny.orders_enabled?'':' · envio de pedidos desligado') + : 'Tiny não conectado')); + box.append(action(tiny.connected?'Reconectar Tiny':'Conectar Tiny',async()=>{ + const result=await api('/tiny/connect',{});location.href=result.url; + })); +} +const tinyResult=new URLSearchParams(location.search).get('tiny'); +const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):''; +if(tinyResult)history.replaceState(null,'',location.pathname); function render(){ + tinyStatus(); paymentIssues(); $('reviews').replaceChildren(); if(board.pending_total || board.approved_total)