feat: redesign the Kanban and keep test wording out of production
All checks were successful
Build and deploy / Validate source (push) Successful in 11s
Build and deploy / Integration suite on a real stack (push) Successful in 3m3s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m51s

Kanban: tabs for production, quote review and payments/integrations; compact
cards with products, metres, print-file status, delivery and time in stage;
an order panel with stage progress, one main action, a correction reason in
place, items with a preview drawn from the approved layout, final-file
approval and the history as a timeline. Quote review gets a list and a pane;
payment issues resolve in place; integrations show their real state, Tiny's
connection with a read-only "Testar conexão", and a readable send log. The
previous Kanban is kept in git tag ui-v1 and is no longer served.

Production wording: the customer portal no longer says it is a local test
environment outside the local stack; the checkout no longer tells customers
to use the Kanban or shows internal stage codes; sign-in, session, quota and
print-file messages are Portuguese and never say "local". A simulated freight
price is refused outside the local stack until a real freight provider
exists, so production only offers pickup.

The browser suite drives the new tabs and panel and still checks the whole
upload, quote, payment and production journey. Full CI sequence passes locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-24 15:28:04 -03:00
parent 641dc6053b
commit 99a558ddd9
22 changed files with 861 additions and 297 deletions

View File

@@ -146,6 +146,8 @@ class FreightAdapter(Protocol):
def quote(self, service: str, postal_code: str) -> dict: ...
class FakeFreight:
name = 'fake'
def quote(self, service: str, postal_code: str) -> dict:
if service == 'pickup':
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}

View File

@@ -57,7 +57,7 @@ def login(body: Login, request: Request, response: Response):
if not account or not matches:
login_failed(email)
audit('customer_login_failed', ip=client_ip(request))
raise HTTPException(401, 'Invalid email or password')
raise HTTPException(401, 'E-mail ou senha inválidos.')
previous = current(request)
with db.connect() as c:
if not stored.startswith('scrypt-v2$'):

View File

@@ -7,7 +7,8 @@ from ..core import db
from ..core.auth import client_ip, owner, new_session, rate_limit
from ..core.limits import upload_limit_bytes
from ..core.models import Freight
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, storage
from ..runtime import (ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment,
require_delivery_available, storage)
router = APIRouter()
@@ -40,6 +41,7 @@ def session(request: Request, response: Response):
@router.post('/api/freight')
def quote_freight(body: Freight):
require_delivery_available(body.service)
try:
return freight.quote(body.service, body.postal_code)
except ValueError as exc:

View File

@@ -17,7 +17,7 @@ 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,
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
enqueue, freight, quote_view, storage, upload_row)
from ..scanning import require_clean
@@ -30,14 +30,14 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
with db.connect() as c:
account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone():
raise HTTPException(503, 'No Kanban operator account is configured')
raise HTTPException(503, 'Nenhuma conta de operador configurada.')
# Comparable password work whether or not the account exists or is active.
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not account['active'] or not matches:
login_failed('operator:'+email)
audit('operator_login_failed', ip=client_ip(request), operator=email)
raise HTTPException(401, 'Invalid operator login')
raise HTTPException(401, 'E-mail ou senha inválidos.')
token = secrets.token_urlsafe(32)
with db.connect() as c:
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
@@ -97,7 +97,8 @@ 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, 'tiny': tiny_status(),
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(), 'operator': user,
'providers': {'payment': payment.name}, 'environment': ENVIRONMENT,
'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,
@@ -224,6 +225,18 @@ def tiny_connect(user=Depends(operator)):
audit('tiny_connect_started', operator=user)
return {'url': tiny.TinyAuth().authorize_url(user)}
@router.post('/api/operator/tiny/test')
def tiny_test(user=Depends(operator)):
"""Read one order and one contact from Tiny. Creates nothing."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
try:
results = tiny.check()
except tiny.TinyNotConnected as exc:
raise HTTPException(409, str(exc))
audit('tiny_tested', operator=user, ok=all(v == 'ok' for v in results.values()))
return {'ok': all(v == 'ok' for v in results.values()), 'results': results}
@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

View File

@@ -10,7 +10,7 @@ from psycopg.types.json import Jsonb
from ..core import db
from ..core.auth import owner
from ..core.models import QuoteRequest
from ..runtime import freight, quote_view, upload_row
from ..runtime import freight, quote_view, require_delivery_available, upload_row
from ..scanning import require_clean
router = APIRouter()
@@ -20,6 +20,7 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
for item in body.items:
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
raise HTTPException(422, 'Quoted metres do not match the submitted layout height')
require_delivery_available(body.freight.service)
draft = body.model_dump(mode='json', exclude={'request_key'})
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
try:

View File

@@ -37,7 +37,7 @@ def begin_upload(body: UploadStart, session_id=Depends(owner)):
usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
audit('upload_quota_rejected')
raise HTTPException(429, 'Local storage quota or pending upload limit reached')
raise HTTPException(429, 'Limite de armazenamento ou de envios pendentes atingido. Tente de novo mais tarde.')
multipart = storage.begin(key)
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at)
VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''',

View File

@@ -45,7 +45,7 @@ def session_row(request):
try:
sid = UUID(request.cookies.get('dtf_session', ''))
except ValueError:
raise HTTPException(401, 'Start a local session first')
raise HTTPException(401, 'Sessão não iniciada. Recarregue a página.')
with connect() as c:
row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone()
if not row:
@@ -92,7 +92,7 @@ def rate_limit(scope, identity, limit, seconds=900):
RETURNING attempts""", (key,seconds,seconds)).fetchone()
if row['attempts'] > limit:
audit('rate_limit', scope=scope)
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After':str(seconds)})
ACCOUNT_FAILURES = 10
@@ -108,7 +108,7 @@ def throttle(email, request):
WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone()
if row and row['attempts'] >= ACCOUNT_FAILURES:
audit('rate_limit', scope='auth-account')
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After': '900'})
raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After': '900'})
def login_failed(email):
"""Count a failed sign-in (or registration attempt) against the account."""
@@ -117,10 +117,10 @@ def login_failed(email):
def operator(request: Request):
token = request.cookies.get('dtf_operator', '')
if not token or len(token)>128:
raise HTTPException(401, 'Sign in to the local Kanban')
raise HTTPException(401, 'Entre no Kanban.')
digest = hashlib.sha256(token.encode()).hexdigest()
with connect() as c:
row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone()
if not row:
raise HTTPException(401, 'Operator session expired')
raise HTTPException(401, 'Sua sessão expirou. Entre de novo.')
return row['username']

View File

@@ -46,7 +46,8 @@ Image.MAX_IMAGE_PIXELS = None
class Unsupported(Exception):
"""This item cannot be generated automatically; the reason is for the operator."""
"""This item cannot be generated automatically. The reason is shown to the
operator on the Kanban, so it is written in Portuguese."""
class Name(str):
@@ -153,14 +154,14 @@ class SourceImage:
self.image = Image.open(path)
self.format = self.image.format
except Image.DecompressionBombError as exc:
raise Unsupported(f'"{name}" is too large to generate automatically') from exc
raise Unsupported(f'"{name}" é grande demais para gerar automaticamente') from exc
except Exception as exc:
raise Unsupported(f'"{name}" is not an image this generator can read') from exc
raise Unsupported(f'"{name}" não é uma imagem que o gerador consiga ler') from exc
if self.format not in SUPPORTED_FORMATS:
raise Unsupported(f'"{name}" is {self.format or "an unknown format"}; '
'only JPEG, PNG, WebP and TIFF are generated automatically')
raise Unsupported(f'"{name}" está em {self.format or "formato desconhecido"}; '
'só JPEG, PNG, WebP, TIFF e PDF são gerados automaticamente')
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
raise Unsupported(f'"{name}" is animated or has several frames')
raise Unsupported(f'"{name}" é animado ou tem vários quadros')
# Browsers draw a photo upright according to its EXIF orientation, and
# the Site measured it that way, so the print must too.
try:
@@ -181,8 +182,8 @@ class SourceImage:
return self._embed_jpeg(pdf)
width, height = self.image.size
if width * height > MAX_DECODED_PIXELS:
raise Unsupported(f'"{self.name}" has {width} x {height} px, more than the '
'generator decodes; prepare this item by hand')
raise Unsupported(f'"{self.name}" tem {width} × {height} px, mais do que o '
'gerador processa; prepare este item à mão')
return self._embed_pixels(pdf)
def _colorspace(self, pdf, mode):
@@ -235,7 +236,7 @@ class SourceImage:
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
color_mode = 'RGB'
else:
raise Unsupported(f'"{self.name}" uses the {mode} colour mode, which is not generated automatically')
raise Unsupported(f'"{self.name}" usa o modo de cor {mode}, que não é gerado automaticamente')
if has_alpha:
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
width, height = image.size
@@ -304,29 +305,29 @@ class PdfPage:
try:
self.pdf = pikepdf.open(path)
except pikepdf.PasswordError as exc:
raise Unsupported(f'"{name}" is password-protected') from exc
raise Unsupported(f'"{name}" está protegido por senha') from exc
except Exception as exc:
raise Unsupported(f'"{name}" is not a PDF this generator can read') from exc
raise Unsupported(f'"{name}" não é um PDF que o gerador consiga ler') from exc
try:
pages = len(self.pdf.pages)
if pages != 1:
raise Unsupported(f'"{name}" has {pages} pages; only single-page PDFs are generated automatically')
raise Unsupported(f'"{name}" tem {pages} páginas; só PDFs de uma página são gerados automaticamente')
self.page = self.pdf.pages[0]
self.rotation = int(self.page.rotation) % 360
if self.rotation not in (0, 90, 180, 270):
raise Unsupported(f'"{name}" has an unsupported page rotation')
raise Unsupported(f'"{name}" tem uma rotação de página não suportada')
box = [float(v) for v in self.page.cropbox]
except Unsupported:
self.pdf.close()
raise
except Exception as exc:
self.pdf.close()
raise Unsupported(f'"{name}" has a page this generator cannot read') from exc
raise Unsupported(f'"{name}" tem uma página que o gerador não consegue ler') from exc
self.x0, self.y0 = min(box[0], box[2]), min(box[1], box[3])
self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1])
if self.w <= 0 or self.h <= 0:
self.pdf.close()
raise Unsupported(f'"{name}" has an empty page')
raise Unsupported(f'"{name}" tem uma página vazia')
# As displayed, which is what the proportions are checked against.
self.size = (self.h, self.w) if self.rotation in (90, 270) else (self.w, self.h)
@@ -373,7 +374,7 @@ def check_layout(item, sizes, vector=()):
height = Decimal(str(production['height_cm']))
billed = Decimal(str(item['billed_metres'])) * 100
if height > billed + HEIGHT_TOLERANCE_CM:
raise Unsupported(f'layout is {height} cm long but only {billed} cm were billed')
raise Unsupported(f'a montagem tem {height} cm, mas só {billed} cm foram cobrados')
lowest = None
for placement in production['placements']:
width_px, height_px = sizes[placement['source_index']]
@@ -384,8 +385,8 @@ def check_layout(item, sizes, vector=()):
drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
if drift > ASPECT_TOLERANCE:
source = production['sources'][placement['source_index']]
raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match '
f'the quoted {source["width_cm"]} x {source["length_cm"]} cm')
raise Unsupported(f'o arquivo {placement["source_index"] + 1} tem proporções diferentes '
f'das cotadas ({source["width_cm"]} × {source["length_cm"]} cm)')
if placement['source_index'] in vector:
continue
dpi = width_px / (width_cm / 2.54)
@@ -405,7 +406,7 @@ def render(item, files, out, title):
"""
production = item['production']
if production.get('version') != 2:
raise Unsupported('item uses an obsolete production layout')
raise Unsupported('o item usa uma montagem antiga')
sources = []
try:
for index in range(len(production['sources'])):

View File

@@ -101,9 +101,9 @@ def produce(storage, job, item, uploads):
for upload_id in item['uploads']:
row = uploads.get(upload_id)
if not row or row['scan_state'] != 'clean':
raise Unsupported('an original file is missing or not cleared by the malware scan')
raise Unsupported('um arquivo original está ausente ou não foi liberado pelo antivírus')
if row['purged_at'] or row['expired']:
raise Unsupported('an original file has passed its retention period')
raise Unsupported('um arquivo original passou do prazo de guarda')
number = job['number']
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
with tempfile.TemporaryDirectory(prefix='print-') as scratch:

View File

@@ -48,6 +48,13 @@ TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
def require_delivery_available(service):
"""Outside the local stack, a simulated freight price must never reach a
customer: until a real freight provider exists, only pickup is offered."""
if service != 'pickup' and ENVIRONMENT != 'local' and getattr(freight, 'name', '') == 'fake':
raise HTTPException(503, 'A entrega ainda não está disponível. Escolha a retirada em Franca.')
def upload_row(c, upload_id, session_id, lock=False):
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND owner=%s' +
(' FOR UPDATE' if lock else ''), (upload_id, session_id)).fetchone()

View File

@@ -223,6 +223,25 @@ def order_payload(payload, contact_id, today=None):
return pedido
def check(auth=None, transport=None):
"""Read-only proof that the connection and permissions work: one order and
one contact listed, nothing created. Raises TinyNotConnected when there is
no usable connection; otherwise reports each read separately."""
orders = TinyOrders(auth=auth or TinyAuth(), transport=transport)
results = {}
for name, path in (('pedidos', '/pedidos'), ('contatos', '/contatos')):
try:
orders.request('GET', path, params={'limit': 1})
results[name] = 'ok'
except TinyNotConnected:
raise
except TinyError as exc:
results[name] = str(exc)[:200]
except httpx.HTTPError:
results[name] = 'sem resposta do Tiny'
return results
class TinyOrders:
def __init__(self, auth=None, transport=None, today=None):
missing = [name for name in required_settings() if not os.environ.get(name)]