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
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:
@@ -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': ''}
|
||||
|
||||
@@ -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$'):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')''',
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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'])):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
19
app/tiny.py
19
app/tiny.py
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user