feat: Kanban shows each order's artwork, filters quotes and searches orders and quotes
All checks were successful
Build and deploy / Validate source (push) Successful in 1m25s
Build and deploy / Integration suite on a real stack (push) Successful in 3m16s
Build and deploy / Secret scan and release gate (push) Successful in 13s
Build and deploy / Publish images (push) Successful in 1m29s

The customer's browser sends the small picture it already makes of each file
(at most 300 KB, WebP/JPEG/PNG read from the bytes, own uploads only); it goes
when the file's bytes go. Board cards, quote rows, the order panel and the
quote detail show it, with the layout drawing as a second view and as the
fallback for files without a picture.

Cotações gets a customer filter (e-mail, CNPJ, WhatsApp) and product, layout
and resolution-warning chips, with the page bar always shown. The board adds
Entrega, Retirada and stalled-order chips. The top search now covers orders in
any stage and unpaid quotes; CNPJ and phone match by digits only when nothing
but digits and punctuation was typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-10-01 11:46:33 -03:00
parent b54c635cfc
commit 352590e63a
15 changed files with 547 additions and 52 deletions

View File

@@ -1,6 +1,7 @@
"""Kanban: sign-in, the board, commercial review and card movement."""
import hashlib
import os
import re
import secrets
from datetime import datetime, timedelta, timezone
from typing import Literal
@@ -108,14 +109,36 @@ def board(user=Depends(operator)):
issues_total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL''').fetchone()['n']
backup = backup_status(c)
quotes = [quote_view(c, q) for q in pending + approved]
return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK,
'orders': orders, 'payment_issues_total': issues_total, 'tiny': tiny_status(), 'operator': user,
'providers': {'payment': payment.name, 'freight': freight_status(), 'backup': backup}, 'environment': ENVIRONMENT,
'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in pending + approved],
'quotes': quotes, 'thumbnails': thumbnails(c, orders, quotes),
'pending_total': pending_total, 'approved_total': approved_total}
def uploads_of(rows):
"""Every upload id an order (its snapshot) or a quote (its draft) refers to."""
ids = set()
for row in rows:
content = row.get('snapshot') or row.get('draft') or {}
for item in content.get('items') or []:
ids.update(str(uid) for uid in item.get('uploads') or [])
return ids
def thumbnails(c, *groups):
"""The uploads among these orders and quotes that have an artwork picture,
so the Kanban asks only for pictures that exist."""
ids = set().union(*(uploads_of(rows) for rows in groups))
if not ids:
return []
rows = c.execute('SELECT upload_id FROM dtf_local.upload_thumbnails WHERE upload_id=ANY(%s::uuid[])',
(list(ids),)).fetchall()
return [str(r['upload_id']) for r in rows]
def with_print_files(c, orders):
generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name
FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id
@@ -141,7 +164,8 @@ def finished_page(before_created_at: datetime | None = None, before_id: UUID | N
''' + ('AND (created_at,id)<(%s,%s) ' if paged else '') + '''
ORDER BY created_at DESC,id DESC LIMIT %s''',
((before_created_at, before_id) if paged else ()) + (limit + 1,)).fetchall()
return {'orders': with_print_files(c, rows[:limit]), 'has_more': len(rows) > limit}
orders = with_print_files(c, rows[:limit])
return {'orders': orders, 'has_more': len(rows) > limit, 'thumbnails': thumbnails(c, orders)}
@router.get('/api/operator/payment-events')
@@ -187,27 +211,97 @@ def events(provider: Literal['tiny','whatsapp'] | None = None,
total = c.execute(f'SELECT count(*) AS n FROM dtf_local.outbox {where}', params).fetchone()['n']
return {'events': rows, 'total': total}
MODES = {'textil': {'file', 'avulsa'}, 'uv': {'uvfile', 'uv'},
'folha': {'file', 'uvfile'}, 'avulsa': {'avulsa', 'uv'}}
def like(text):
"""A LIKE pattern that matches the text anywhere, its own % and _ literal."""
return '%' + re.sub(r'([\\%_])', r'\\\1', text) + '%'
def numeric(q):
"""The digits of a query made of digits and punctuation only ("#1042",
"11.222.333/0001-81", "(16) 99999-0000"); empty when it has letters."""
return '' if re.search(r'[^\W\d_]', q) else re.sub(r'\D', '', q)
def customer_match(column, q):
"""SQL and parameters matching a customer (e-mail, CNPJ or WhatsApp) in an
order snapshot or a quote draft. CNPJ and phone match by their digits, and
only when nothing but digits and punctuation was typed: the digits of an
e-mail address are not a phone number."""
text, digits = q.strip().lower(), numeric(q)
sql = f"lower({column}->'customer'->>'mail') LIKE %s"
params = [like(text)]
if len(digits) >= 3:
sql += (f" OR regexp_replace(coalesce({column}->'customer'->>'cnpj',''),'\\D','','g') LIKE %s"
f" OR regexp_replace(coalesce({column}->'customer'->>'zap',''),'\\D','','g') LIKE %s")
params += [like(digits), like(digits)]
return '(' + sql + ')', params
@router.get('/api/operator/quotes')
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
before_id: UUID | None = None, offset: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)):
"""Unpaid quotes, newest first: by cursor, or by page (offset) with a total."""
limit: int = Query(default=50, ge=1, le=100),
q: str | None = Query(default=None, max_length=100),
product: Literal['textil','uv'] | None = None, layout: Literal['folha','avulsa'] | None = None,
flag: Literal['ressalva'] | None = None, user=Depends(operator)):
"""Unpaid quotes, newest first: by cursor, or by page (offset) with a total;
filtered by customer, product, layout and an accepted resolution warning."""
if (before_created_at is None) != (before_id is None):
raise HTTPException(422, 'Both quote cursor fields are required')
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'
clauses = ['o.id IS NULL', 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL']
params = []
if q and q.strip():
sql, values = customer_match('q.draft', q)
clauses.append(sql); params += values
if product or layout:
modes = set.intersection(*(MODES[k] for k in (product, layout) if k))
clauses.append("EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i WHERE i->>'mode'=ANY(%s))")
params.append(sorted(modes))
if flag:
clauses.append("EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i WHERE i->>'quality_status'='warning')")
where = ' AND '.join(clauses)
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
skip = 0 if before_created_at else offset
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1, skip)
page = params + ([before_created_at, before_id] if before_created_at else []) + [limit+1, skip]
with db.connect() as c:
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND {approved_filter} {cursor}
ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', params).fetchall()
WHERE {where} {cursor}
ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', page).fetchall()
total = c.execute(f'''SELECT count(*) AS n FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND {approved_filter}''').fetchone()['n']
return {'quotes':[quote_view(c,row) for row in rows[:limit]],
'has_more':len(rows)>limit, 'total': total}
WHERE {where}''', params).fetchone()['n']
quotes = [quote_view(c,row) for row in rows[:limit]]
return {'quotes': quotes, 'has_more': len(rows)>limit, 'total': total,
'thumbnails': thumbnails(c, quotes)}
@router.get('/api/operator/search')
def search(q: str = Query(min_length=2, max_length=100), user=Depends(operator)):
"""One search over orders (any stage) and unpaid quotes: customer e-mail,
CNPJ or WhatsApp, the delivery recipient, or the order number."""
match, params = customer_match('snapshot', q)
digits = numeric(q)
sql = match + " OR lower(coalesce(snapshot->'destination'->>'recipient','')) LIKE %s"
params.append(like(q.strip().lower()))
if digits and len(digits) <= 9:
sql += ' OR number=%s'
params.append(int(digits))
quote_match, quote_params = customer_match('q.draft', q)
with db.connect() as c:
orders = c.execute(f'''SELECT * FROM dtf_local.orders WHERE {sql}
ORDER BY created_at DESC LIMIT 8''', params).fetchall()
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND {quote_match}
ORDER BY q.created_at DESC LIMIT 8''', quote_params).fetchall()
orders = with_print_files(c, orders)
quotes = [quote_view(c, row) for row in rows]
return {'orders': orders, 'quotes': quotes, 'thumbnails': thumbnails(c, orders, quotes)}
@router.post('/api/operator/quotes/{uid}/approve')
def approve(uid: UUID, body: Review, user=Depends(operator)):
@@ -381,6 +475,17 @@ def history(uid: UUID, user=Depends(operator)):
with db.connect() as c:
return c.execute('SELECT * FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (uid,)).fetchall()
@router.get('/api/operator/uploads/{uid}/thumbnail')
def thumbnail(uid: UUID, user=Depends(operator)):
"""The browser-made picture of an artwork. Cached by the operator's browser:
the board is redrawn on every refresh."""
with db.connect() as c:
row = c.execute('SELECT mime,data FROM dtf_local.upload_thumbnails WHERE upload_id=%s', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'No picture for this upload')
return Response(bytes(row['data']), media_type=row['mime'],
headers={'Cache-Control': 'private, max-age=86400', 'Content-Disposition': 'inline'})
@router.get('/api/operator/uploads/{uid}/download')
def download(uid: UUID, user=Depends(operator)):
with db.connect() as c:

View File

@@ -8,7 +8,8 @@ import os
from uuid import UUID, uuid4
from botocore.exceptions import ClientError
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from starlette.concurrency import run_in_threadpool
from ..core import db
from ..core.auth import audit, owner, rate_limit
@@ -94,6 +95,40 @@ def complete_upload(uid: UUID, session_id=Depends(owner)):
(UNPAID_HOLD, uid))
return {'id': uid, 'complete': True}
# The browser's small picture of the artwork, for the Kanban. Only an image, and
# only a small one: the type is read from the bytes, never from the header.
THUMBNAIL_BYTES = 300_000
def thumbnail_type(data):
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
return 'image/webp'
if data[:3] == b'\xff\xd8\xff':
return 'image/jpeg'
if data[:8] == b'\x89PNG\r\n\x1a\n':
return 'image/png'
return None
def store_thumbnail(uid, session_id, mime, data):
rate_limit('upload-thumbnail', str(session_id), 120, 900)
with db.connect() as c:
upload_row(c, uid, session_id)
c.execute('''INSERT INTO dtf_local.upload_thumbnails(upload_id,mime,data) VALUES(%s,%s,%s)
ON CONFLICT(upload_id) DO UPDATE SET mime=EXCLUDED.mime, data=EXCLUDED.data, created_at=now()''',
(uid, mime, data))
@router.put('/api/uploads/{uid}/thumbnail')
async def put_thumbnail(uid: UUID, request: Request, session_id=Depends(owner)):
if int(request.headers.get('content-length') or 0) > THUMBNAIL_BYTES:
raise HTTPException(413, 'Thumbnail too large')
data = await request.body()
if len(data) > THUMBNAIL_BYTES:
raise HTTPException(413, 'Thumbnail too large')
mime = thumbnail_type(data)
if not mime:
raise HTTPException(415, 'Thumbnail must be a WebP, JPEG or PNG image')
await run_in_threadpool(store_thumbnail, uid, session_id, mime, data)
return {'id': uid}
@router.delete('/api/uploads/{uid}')
def cancel_upload(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
@@ -102,5 +137,6 @@ def cancel_upload(uid: UUID, session_id=Depends(owner)):
raise HTTPException(409, 'Completed upload cannot be cancelled')
storage.discard(row['object_key'],row['multipart_id'],False)
c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s',(uid,))
c.execute('DELETE FROM dtf_local.upload_thumbnails WHERE upload_id=%s',(uid,))
audit('upload_cancelled', upload=str(uid))
return {'id':uid,'cancelled':True}

View File

@@ -37,7 +37,8 @@ async def safe_headers(request, call_next):
response = await call_next(request)
if response.status_code in (401,403,429) or response.status_code>=500:
audit('http_security_event', method=request.method, status=response.status_code, ip=client_ip(request))
response.headers['Cache-Control'] = 'no-store'
# Nothing is cached unless a route says so (the Kanban's artwork pictures).
response.headers.setdefault('Cache-Control', 'no-store')
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
return response

View File

@@ -205,3 +205,11 @@ CREATE INDEX IF NOT EXISTS payment_events_open_issues ON dtf_local.payment_event
-- Payment intents look up by quote (customer retry) and by provider id (webhook).
CREATE INDEX IF NOT EXISTS payment_intents_quote ON dtf_local.payment_intents(quote_id, created_at DESC);
-- A small picture of each artwork (about 480 px), made by the customer's
-- browser from the file it already read, so the Kanban shows what an order is
-- without opening the original. It goes when the file's bytes go.
CREATE TABLE IF NOT EXISTS dtf_local.upload_thumbnails (
upload_id uuid PRIMARY KEY REFERENCES dtf_local.uploads(id),
mime text NOT NULL, data bytea NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);

View File

@@ -35,6 +35,7 @@ def cleanup():
for row in rows:
storage.discard(row['object_key'],row['multipart_id'],row['complete'])
c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s', (row['id'],))
c.execute('DELETE FROM dtf_local.upload_thumbnails WHERE upload_id=%s', (row['id'],))
c.execute('DELETE FROM dtf_local.sessions WHERE expires_at<=now()')
c.execute('DELETE FROM dtf_local.operator_sessions WHERE expires_at<=now()')
c.execute("DELETE FROM dtf_local.login_attempts WHERE started_at<now()-interval '1 day'")