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

@@ -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}