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
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:
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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'")
|
||||
|
||||
Reference in New Issue
Block a user