Compare commits

...

5 Commits

Author SHA1 Message Date
Cauê Faleiros
37715ef223 feat: the server checks the grade against the files before approving
All checks were successful
Build and deploy / Validate source (push) Successful in 1m23s
Build and deploy / Integration suite on a real stack (push) Successful in 3m13s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m25s
The price depends on the grade, which the browser worked out and the API
took on trust. Before a cart is approved at checkout the API now recomputes
it from the uploaded files by the Site's own rules: the pixel size in a
PNG, JPG or WebP header across the printed width (rotation included), and
the area-weighted DPI of the images placed in a PDF of up to 150 MB, 300
for vectors. Sheets take the worst grade, artworks the average. A claim more
than 2 points above the file's grade, or a discount on a file the server
cannot grade, waits for an operator, with the reason on the Kanban.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:24:37 -03:00
Cauê Faleiros
b7e2ea12d0 feat: accept large JPG and WebP artworks instead of refusing them
Above 150 MB the browser cannot read JPG or WebP in parts, so a large
artwork was refused. It is now measured from its header, graded and priced
with the discount like any other, and placed on the sheet as a full box,
which is exact for JPG. The card says there is no preview and that the team
checks the art before printing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:14:04 -03:00
Cauê Faleiros
933bd30cbd feat: an unpaid cart's files are kept 2 days, a paid order's 30
Files are uploaded before payment so the price and the security check use
the file itself, but an abandoned cart kept them for 30 days. Now a finished
upload is held 2 days, a quote waiting for review 7, an approved quote 2 more
to be paid, and the paid order keeps its originals for 30 days from upload.
A payment never starts for files that are gone; one under way holds them a
day. Files attached to an order take the order's window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:09:02 -03:00
Cauê Faleiros
eb254da501 feat: tell the customer how long the upload takes before it starts
When files are picked, the product page shows the range the upload takes
between a slow (10 Mbps) and a fast (150 Mbps) connection, and asks to keep
the page open. The cart shows the same range until the real speed is
measured, then the measured time left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:09:02 -03:00
Cauê Faleiros
8786a33c8d fix: the cart keeps only its items, never the customer's details
The browser put the CNPJ, WhatsApp, e-mail, CEP and address back after a
reload without telling the page, which then showed them but could not pay
until they were typed again. The fields are now emptied on load and marked
autocomplete="off", and the saved cart holds only the items: the details and
address are typed again after a reload or a closed tab. The payment page
already takes them from the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:23:18 -03:00
26 changed files with 462 additions and 48 deletions

View File

@@ -86,7 +86,7 @@ jobs:
# the generator's geometry. The provider suites use a fake transport: they
# prove the documented contract, not the integration.
- name: Print-file geometry and provider adapters
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog tests.test_quote_review tests.test_large_files -v
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog tests.test_quote_review tests.test_large_files tests.test_grade_check -v
- name: Runtime and retention regressions
run: |

View File

@@ -85,6 +85,16 @@ def intent(body: PaymentIntent, session_id=Depends(owner)):
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
raise HTTPException(409, 'Quote is already paid')
# Never charge for files that are gone: an unpaid cart's files are
# removed after a while. A payment under way keeps them a day longer,
# time enough for the provider's notice to become the order.
uploads = payments.quote_uploads(quote['approved'])
live = c.execute('''SELECT count(*) AS n FROM dtf_local.uploads WHERE id=ANY(%s)
AND purged_at IS NULL AND expires_at>now()''', (uploads,)).fetchone()['n']
if live != len(set(uploads)):
raise HTTPException(410, 'Os arquivos deste pedido expiraram porque ele não foi pago a tempo. '
'Monte o pedido de novo para pagar.')
payments.hold_uploads(c, uploads, '1 day')
# Never a second charge: an approved payment is waiting for its
# notification to become the order, and a card in review may still be.
# A card waiting for the bank's confirmation (3-D Secure) blocks only

View File

@@ -7,12 +7,14 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
from psycopg.types.json import Jsonb
from .. import payments
from ..core import db
from ..core.auth import owner
from ..core.models import QuoteRequest
from ..core.pricing import price
from .. import quote_review
from ..runtime import freight, quote_view, require_delivery_available, upload_row
from ..grade_check import Files, mismatch
from ..runtime import freight, quote_view, require_delivery_available, storage, upload_row
from ..scanning import require_clean
router = APIRouter()
@@ -35,21 +37,30 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
except ValueError as exc:
raise HTTPException(422, str(exc))
with db.connect() as c:
rows = {}
for item in body.items:
for uid in item.uploads:
row = upload_row(c, uid, session_id)
if not row['complete']:
raise HTTPException(409, 'Complete every upload before requesting a quote')
require_clean(row)
rows[str(uid)] = row
# The grade sets the price and came from the browser: before a cart is
# approved at checkout, the server works it out from the files.
note = mismatch(Files(storage), draft['items'], rows) if reason is None else None
reason = reason or note
uid = uuid4()
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
(uid, session_id, body.request_key, digest, Jsonb(draft)))
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft,review_note) VALUES(%s,%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
(uid, session_id, body.request_key, digest, Jsonb(draft), note))
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s FOR UPDATE', (session_id, body.request_key)).fetchone()
if row['request_hash'] != digest:
raise HTTPException(409, 'Request key already used for a different cart')
if row['id'] == uid and reason is None:
quote_review.approve(c, row, body.items, quote_review.AUTO)
return {'id': row['id'], 'status': 'approved'}
if row['id'] == uid:
# An operator reviews it first; the files wait for that review.
payments.hold_uploads(c, payments.quote_uploads(draft), payments.REVIEW_HOLD)
return {'id': row['id'], 'status': 'approved' if row['approved'] else 'pending_review'}
@router.get('/api/quotes/{uid}')

View File

@@ -14,6 +14,7 @@ from ..core import db
from ..core.auth import audit, owner, rate_limit
from ..core.limits import upload_limit_bytes
from ..core.models import UploadStart
from ..payments import UNPAID_HOLD
from ..runtime import PART_BYTES, storage, upload_row
router = APIRouter()
@@ -87,7 +88,10 @@ def complete_upload(uid: UUID, session_id=Depends(owner)):
existing_size = storage.size(row['object_key'])
if existing_size != row['size']:
raise HTTPException(409, 'Stored size differs from declared size')
c.execute("UPDATE dtf_local.uploads SET complete=true,expires_at=now()+interval '30 days' WHERE id=%s", (uid,))
# Held while the cart is unpaid: an abandoned cart's files go after
# UNPAID_HOLD; a paid order keeps them for its 30 days (app/payments.py).
c.execute('UPDATE dtf_local.uploads SET complete=true,expires_at=now()+%s::interval WHERE id=%s',
(UNPAID_HOLD, uid))
return {'id': uid, 'complete': True}
@router.delete('/api/uploads/{uid}')

View File

@@ -67,7 +67,8 @@ def submit_files(c, order, body, identity, kind, actor):
for ref in body.files:
c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)',
(uuid4(),order['id'],ref.upload_id,ref.item_index,kind,body.note,actor))
c.execute('UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,%s) WHERE id=%s', (expiry,ref.upload_id))
# The order's window, whatever the upload's own hold was.
c.execute('UPDATE dtf_local.uploads SET expires_at=%s WHERE id=%s', (expiry,ref.upload_id))
c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],))
if kind == 'final':
# Artwork approval, not commercial quote approval, starts original cleanup.

189
app/grade_check.py Normal file
View File

@@ -0,0 +1,189 @@
"""The grade (the resolution discount) recomputed from the uploaded files.
The Site grades the art in the customer's browser and sends the grade with the
cart, and the price depends on it. A customer who edits the page could claim a
better grade, so before a cart is approved at checkout the server reads the
files it already has and works the grade out again, by the Site's own rules:
- a finished sheet: its pixel width over the sheet width is its DPI, and the
item's grade comes from the worst sheet (web/site-quality.js);
- artworks: each one's DPI along the width it is printed at, rotation
included; the item's grade is the average of the artworks' grades;
- a PDF: the area-weighted DPI of the images drawn on its page, 300 when it
is only vectors (web/site-pdf.js, dpiDasImagens and resumoDpi).
Images are measured from their first bytes, never decoded. A PDF is opened
only up to PDF_MAX_BYTES, the size the Site itself analyses. A grade that
cannot be recomputed is None; the caller decides what that means.
"""
import math
import os
import struct
import tempfile
DPI_IDEAL = 300
# The Site analyses PDFs up to 150 MB (GRANDE_BYTES); above that it does not
# grade them, so neither does this.
PDF_MAX_BYTES = 150 * 1048576
HEAD_BYTES = 1048576
# Rounding in the browser and here can differ by a point on the same file.
TOLERANCE = 2
def js_round(value):
"""Math.round, which rounds halves up; Python's round() does not."""
return math.floor(value + 0.5)
def grade_of(dpi):
return max(6, min(100, js_round(dpi / DPI_IDEAL * 100)))
def image_size(head):
"""(width, height) in pixels from a PNG, JPEG or WebP's first bytes."""
if head[:8] == b'\x89PNG\r\n\x1a\n' and head[12:16] == b'IHDR':
return struct.unpack('>II', head[16:24])
if head[:2] == b'\xff\xd8':
i = 2
while i + 9 < len(head):
if head[i] != 0xFF:
i += 1
continue
marker = head[i + 1]
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7 or marker == 0xFF:
i += 1 if marker == 0xFF else 2
continue
length = struct.unpack('>H', head[i + 2:i + 4])[0]
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
h, w = struct.unpack('>HH', head[i + 5:i + 9])
return w, h
i += 2 + length
return None
if head[:4] == b'RIFF' and head[8:12] == b'WEBP':
chunk = head[12:16]
if chunk == b'VP8X':
return (int.from_bytes(head[24:27], 'little') + 1, int.from_bytes(head[27:30], 'little') + 1)
if chunk == b'VP8L' and head[20] == 0x2F:
bits = int.from_bytes(head[21:25], 'little')
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
if chunk == b'VP8 ' and head[23:26] == b'\x9d\x01\x2a':
w, h = struct.unpack('<HH', head[26:30])
return w & 0x3FFF, h & 0x3FFF
return None
def _multiply(a, b):
return [a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1],
a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3],
a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5]]
def pdf_dpi(path):
"""The page's area-weighted image DPI, 300 for vectors only, None if the
file is not a one-page PDF this can read."""
import pikepdf
try:
with pikepdf.open(path) as pdf:
if len(pdf.pages) != 1:
return None
page = pdf.pages[0]
unit = float(page.obj.get('/UserUnit', 1))
found = []
def walk(resources, instructions, ctm, depth):
stack = []
xobjects = resources.get('/XObject', {}) if resources is not None else {}
for operands, operator in instructions:
op = str(operator)
if op == 'q':
stack.append(ctm)
elif op == 'Q':
ctm = stack.pop() if stack else [1, 0, 0, 1, 0, 0]
elif op == 'cm':
ctm = _multiply(ctm, [float(v) for v in operands])
elif op == 'Do' and operands:
xobject = xobjects.get(str(operands[0]))
if xobject is None:
continue
subtype = xobject.get('/Subtype')
if subtype == '/Image':
width_pt = math.hypot(ctm[0], ctm[1])
if width_pt >= 1:
found.append((int(xobject.get('/Width', 0)) / (width_pt * unit / 72),
abs(ctm[0] * ctm[3] - ctm[1] * ctm[2])))
elif subtype == '/Form' and depth < 8:
matrix = [float(v) for v in xobject.get('/Matrix', [1, 0, 0, 1, 0, 0])]
walk(xobject.get('/Resources', resources),
pikepdf.parse_content_stream(xobject), _multiply(ctm, matrix), depth + 1)
walk(page.obj.get('/Resources'), pikepdf.parse_content_stream(page), [1, 0, 0, 1, 0, 0], 0)
except Exception:
return None
found = [(dpi, area) for dpi, area in found if dpi > 0]
if not found:
return DPI_IDEAL
total = sum(area for _, area in found) or 1
return js_round(sum(dpi * area for dpi, area in found) / total)
class Files:
"""The uploaded files, read from storage as little as needed."""
def __init__(self, storage):
self.storage = storage
def head(self, row):
return self.storage.client.get_object(Bucket=self.storage.bucket, Key=row['object_key'],
Range=f'bytes=0-{HEAD_BYTES - 1}')['Body'].read()
def pdf_dpi(self, row):
if row['size'] > PDF_MAX_BYTES:
return None
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
self.storage.client.download_fileobj(self.storage.bucket, row['object_key'], handle)
handle.flush()
return pdf_dpi(handle.name)
def source_dpi(files, row, source):
"""One source's DPI across the width it is printed at, or None."""
name = row['name'].lower()
width_in = float(source['width_cm']) / 2.54
if name.endswith('.pdf'):
return files.pdf_dpi(row) if source['kind'] == 'sheet' else None
if not name.endswith(('.png', '.jpg', '.jpeg', '.webp')):
return None
size = image_size(files.head(row))
if not size or not all(size):
return None
across = size[1] if source['rotation_degrees'] in (90, 270) else size[0]
return across / width_in
def item_grade(files, item, rows):
"""The grade the Site gives this item, recomputed; None if it cannot be."""
sources = item['production']['sources']
dpis = []
for source in sources:
row = rows.get(str(source['upload_id']))
dpi = source_dpi(files, row, source) if row else None
if dpi is None:
return None
dpis.append(dpi)
if all(source['kind'] == 'sheet' for source in sources):
# A sheet's DPI is shown and compared as a whole number.
return grade_of(min(js_round(d) for d in dpis))
return js_round(sum(grade_of(d) for d in dpis) / len(dpis))
def mismatch(files, items, rows):
"""Why a cart's grades cannot be approved at checkout, or None."""
for item in items:
if item['grade'] == 0:
continue # full price: nothing to verify
verified = item_grade(files, item, rows)
if verified is None:
return 'Nota não conferida no servidor'
if item['grade'] > verified + TOLERANCE:
return f"Nota {item['grade']} maior que a do arquivo ({verified})"
return None

View File

@@ -49,6 +49,26 @@ def is_test(order):
return (order.get('payment') or {}).get('provider') == TEST_PROVIDER
# How long a file is kept while nothing has been paid for it. A cart's files
# are uploaded before payment, so the price and the security check are done on
# the file itself; a cart that is never paid must not keep them for 30 days.
UNPAID_HOLD = '2 days'
# A quote waiting for an operator's review keeps its files this long.
REVIEW_HOLD = '7 days'
# Once paid, the order keeps its originals for this long from the upload.
ORDER_RETENTION = '30 days'
def quote_uploads(quote_or_draft):
return [uid for item in quote_or_draft['items'] for uid in item['uploads']]
def hold_uploads(c, upload_ids, interval):
"""Keep these files at least `interval` from now; never shortens a hold."""
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, now()+%s::interval)
WHERE id=ANY(%s) AND purged_at IS NULL''', (interval, [str(u) for u in upload_ids]))
def create_order(c, quote, payment):
"""Create the order for a reviewed quote, or return the one already there.
@@ -68,6 +88,9 @@ def create_order(c, quote, payment):
order = c.execute(
'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
(uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone()
# Paid: the files are kept for the order's retention, counted from upload.
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, created_at+%s::interval)
WHERE id=ANY(%s) AND purged_at IS NULL''', (ORDER_RETENTION, quote_uploads(approved)))
queue_print_files(c, order['id'], len(approved['items']))
if is_test(order):
return order, True

View File

@@ -6,11 +6,11 @@ moment it was created. The Site is a shop: a customer who can price the order
should be able to pay for it straight away, at any hour, so only orders a
person must look at before charging wait for the Kanban (review_reason).
Known limit: the grade (and so the discount) and the layout are still worked
out in the customer's browser (roadmap 3.2, 3.9). The API checks the layout's
geometry and that an unanalysed item carries no discount, but a customer who
edits the page can claim a better grade. Operators see every order's grade
and artwork at Arte recebida.
The grade (and so the discount) is worked out in the customer's browser and
checked against the files before an automatic approval (app/grade_check.py);
a grade the files do not support waits for review. The layout is still the
browser's (roadmap 3.9): the API checks its geometry. Operators see every
order's grade and artwork at Arte recebida.
"""
import os
from decimal import Decimal
@@ -18,6 +18,7 @@ from decimal import Decimal
from fastapi import HTTPException
from psycopg.types.json import Jsonb
from . import payments
from .core.pricing import price
from .runtime import freight, upload_row
from .scanning import require_clean
@@ -83,4 +84,6 @@ def approve(c, row, items, reviewer):
'total_cents': sum(i['total_cents'] for i in priced) + quoted_freight['total_cents']}
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s',
(Jsonb(approved), reviewer, row['id']))
# Approved and payable now: the files wait for the payment, not for ever.
payments.hold_uploads(c, payments.quote_uploads(approved), payments.UNPAID_HOLD)
return approved

View File

@@ -85,7 +85,7 @@ def quote_view(c, row):
'draft': row['draft'], 'approved': row['approved'],
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
'auto_approved': row.get('reviewed_by') == 'auto',
'review_reason': None if row['approved'] else review_reason(row['draft']),
'review_reason': None if row['approved'] else (row.get('review_note') or review_reason(row['draft'])),
'payment': attempt,
'order': order}

View File

@@ -142,6 +142,10 @@ CREATE TABLE IF NOT EXISTS dtf_local.backups (
);
CREATE INDEX IF NOT EXISTS backups_finished ON dtf_local.backups(finished_at DESC);
-- Why a quote waits for review, when the reason came from its files (the grade
-- the server recomputed) and cannot be worked out again from the draft alone.
ALTER TABLE dtf_local.quotes ADD COLUMN IF NOT EXISTS review_note text;
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
-- Indexes follow the queries the application actually issues. Only these; every

View File

@@ -627,6 +627,13 @@ the site already did.
generator, with the browser as preview only. This is the single largest gap between
what was promised in the meeting and what exists.
**2026-09-30:** the grade (the resolution discount) is no longer taken on trust.
Before an automatic approval the API recomputes it from the uploaded files by the
Site's rules (`app/grade_check.py`): image headers for PNG/JPG/WebP, the placed
images of a PDF up to 150 MB. A claim more than 2 points above the file's grade,
or a discount on a file the server cannot grade, waits for review with the reason
on the Kanban. The metres (the packing) are still the browser's.
### `[~]` 3.3 — The 5 GB problem is unsolved `(F19)`
Transport accepts 5 GiB; `SCAN_MAX_BYTES` / ClamAV `StreamMaxLength` release only

View File

@@ -170,6 +170,17 @@ try{
await evaluate(`folhas=[];pintaFolha()`);
await evaluate(`pickTipo('avulsa')`);
assert.equal(await evaluate('modo'),'avulsa');
// A large JPG artwork cannot be read in parts: it is measured from its
// header and placed as a full box, with the discount, instead of refused.
await evaluate(`(()=>{const h=new Uint8Array(20);h.set([0xFF,0xD8,0xFF,0xC0,0,17,8,0x09,0x3A,0x09,0x3A,3]);
const f=new File([h],'arte-grande.jpg');Object.defineProperty(f,'size',{value:400*1048576});sel([f]);})()`);
await waitFor(()=>evaluate(`artes.length===1 && artes[0].semPrevia===true`),'large JPG artwork measured from its header');
await fill('[data-cm]',20);
await waitFor(()=>evaluate(`!!itemAtual?.production?.sources?.length`),'large JPG artwork packed');
assert.deepEqual(await evaluate(`({dpi:Math.round(dpiDe(artes[0])),src:!!artes[0].src,
measurement:itemAtual.production.sources[0].measurement,note:$('lista').textContent.includes('Sem prévia')})`),
{dpi:300,src:false,measurement:'file',note:true});
await evaluate(`artes=[];pintaArtes()`);
await evaluate('sendImage()');
await waitFor(()=>evaluate(`artes.length===1 && !!artes[0].src`),'JPG artwork model');
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});

View File

@@ -93,6 +93,12 @@ try{
assert.deepEqual(await site.eval(`({items:pedido.length,empty:!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens'),undo:!$('desfazer').hidden})`),{items:0,empty:true,undo:true});
await site.click('#desfazerBtn');
assert.equal(await site.eval('pedido.length===1 && pedido[0].total===21.89 && $("desfazer").hidden'),true);
// The items come back after a reload; the customer's details do not.
assert.deepEqual(await site.eval(`['fCnpj','fZap','fMail'].map(id=>$(id).value)`),['','','']);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),true);
await site.fill('#fCnpj','11222333000181');
await site.fill('#fZap','11999999999');
await site.fill('#fMail','local-browser@example.test');
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
assert.equal(await site.eval('pedido[0].total'),21.89);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);

View File

@@ -155,6 +155,30 @@ def run():
assert queued == 0 and jobs == 1, (queued, jobs)
print('PASS: an operator test order reaches the board and the print queue, never Tiny or WhatsApp')
# Files are uploaded before payment. An unpaid cart keeps them briefly; a
# paid order keeps them for its 30 days; a payment never starts for files
# that are gone.
def files_of(qid):
with db.connect() as c:
q = c.execute('SELECT approved,draft FROM dtf_local.quotes WHERE id=%s', (qid,)).fetchone()
return [u for item in (q['approved'] or q['draft'])['items'] for u in item['uploads']]
def days(ids, since='now()'):
with db.connect() as c:
return [float(r['d']) for r in c.execute(
f'SELECT extract(epoch FROM expires_at-{since})/86400 AS d FROM dtf_local.uploads WHERE id=ANY(%s)',
(ids,)).fetchall()]
fresh = upload_bytes(customer, b'UNPAID HOLD TEST')
assert all(1.99 < d <= 2.0 for d in days([fresh])), days([fresh])
assert all(abs(d - 30) < 0.01 for d in days(files_of(quote_id), 'created_at')), days(files_of(quote_id), 'created_at')
late, late_quote, _ = reviewed_quote()
assert all(d > 1.99 for d in days(files_of(late_quote))), days(files_of(late_quote))
with db.connect() as c:
c.execute("UPDATE dtf_local.uploads SET expires_at=now()-interval '1 second' WHERE id=ANY(%s)",
(files_of(late_quote),))
late.call('/payments/intent', {'quote_id': late_quote, 'method': {'type': 'pix'}}, expected=410)
print('PASS: unpaid files are held 2 days, paid ones 30 days, and expired files are never charged for')
if __name__ == '__main__':
run()

View File

@@ -175,7 +175,11 @@ def run():
assert not client.call('/quotes/'+qid)['auto_approved']
# A cart the Site priced is approved at once and can be paid straight away,
# at the server's own prices; no operator can then change it.
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]}
# The server works the grade out from the file: this PNG is 6059 px across
# 57 cm, 270 DPI, which is grade 90.
png=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR'+(6059).to_bytes(4,'big')+(2000).to_bytes(4,'big')+b'\x08\x06\x00\x00\x00'+b'\x00'*4
graded=upload_bytes(client,png,name='arte-270dpi.png')
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,graded)]}
auto=client.call('/quotes',small)
assert auto['status']=='approved'
seen=client.call('/quotes/'+auto['id'])
@@ -188,6 +192,12 @@ def run():
held=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[claimed]})
assert held['status']=='pending_review'
assert client.call('/quotes/'+held['id'])['review_reason']=='Nota informada sem análise da arte'
# A better grade than the file supports, or one the server cannot check, waits too.
inflated=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',100,graded)]})
assert inflated['status']=='pending_review'
assert client.call('/quotes/'+inflated['id'])['review_reason']=='Nota 100 maior que a do arquivo (90)'
unchecked=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]})
assert client.call('/quotes/'+unchecked['id'])['review_reason']=='Nota não conferida no servidor'
print('PASS: priced carts are approved at checkout; large or inconsistent ones wait for review')
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)

86
tests/test_grade_check.py Normal file
View File

@@ -0,0 +1,86 @@
"""The server's grade against real image and PDF files.
Runs where Pillow and pikepdf are installed (the API image).
"""
import io
import tempfile
import unittest
import pikepdf
from PIL import Image
from app import grade_check
class FakeFiles:
def __init__(self, blobs):
self.blobs = blobs
def head(self, row):
return self.blobs[row['name']][:grade_check.HEAD_BYTES]
def pdf_dpi(self, row):
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
handle.write(self.blobs[row['name']])
handle.flush()
return grade_check.pdf_dpi(handle.name)
def encoded(fmt, size, **options):
out = io.BytesIO()
Image.new('RGBA' if fmt in ('PNG', 'WEBP') else 'RGB', size, (200, 30, 30)).save(out, fmt, **options)
return out.getvalue()
def item(kind, grade, sources):
return {'grade': grade, 'production': {'sources': [
{'upload_id': name, 'kind': kind, 'width_cm': width, 'rotation_degrees': rotation}
for name, width, rotation in sources]}}
class GradeCheckTests(unittest.TestCase):
def test_image_sizes_from_the_first_bytes(self):
for fmt, options in (('PNG', {}), ('JPEG', {'quality': 80}), ('JPEG', {'progressive': True}),
('WEBP', {'lossless': True}), ('WEBP', {'quality': 80})):
self.assertEqual(grade_check.image_size(encoded(fmt, (1234, 567), **options)), (1234, 567), (fmt, options))
# A JPEG with a large metadata block before the frame header.
exif = Image.Exif()
exif[0x010E] = 'x' * 60000
self.assertEqual(grade_check.image_size(encoded('JPEG', (800, 600), exif=exif)), (800, 600))
self.assertIsNone(grade_check.image_size(b'not an image at all'))
def test_pdf_dpi_is_the_images_area_weighted(self):
out = io.BytesIO()
Image.new('RGB', (1500, 750), 'white').save(out, 'PDF', resolution=150)
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
handle.write(out.getvalue()); handle.flush()
self.assertEqual(grade_check.pdf_dpi(handle.name), 150)
vector = pikepdf.new()
vector.add_blank_page(page_size=(1615, 850))
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
vector.save(handle.name)
self.assertEqual(grade_check.pdf_dpi(handle.name), 300)
def test_grades_follow_the_site(self):
# 57 cm is 22.44 in: 6059 px is 270 DPI, grade 90; 3366 px is 150 DPI, grade 50.
files = FakeFiles({'a.png': encoded('PNG', (6059, 100)), 'b.jpg': encoded('JPEG', (3366, 100)),
'tall.png': encoded('PNG', (100, 2362)), 'x.cdr': b'CDR'})
rows = {n: {'name': n, 'size': 1} for n in files.blobs}
# A sheet takes the worst sheet's grade.
self.assertEqual(grade_check.item_grade(files, item('sheet', 0, [('a.png', 57, 0)]), rows), 90)
self.assertEqual(grade_check.item_grade(files, item('sheet', 0, [('a.png', 57, 0), ('b.jpg', 57, 0)]), rows), 50)
# Artworks average; a rotated one is measured along its height.
self.assertEqual(grade_check.item_grade(files, item('artwork', 0, [('a.png', 57, 0), ('b.jpg', 57, 0)]), rows), 70)
self.assertEqual(grade_check.item_grade(files, item('artwork', 0, [('tall.png', 20, 90)]), rows), 100)
self.assertIsNone(grade_check.item_grade(files, item('sheet', 0, [('x.cdr', 57, 0)]), rows))
# What the cart claims is checked, with a point or two of rounding.
claim = lambda g, src: grade_check.mismatch(files, [item('sheet', g, src)], rows)
self.assertIsNone(claim(90, [('a.png', 57, 0)]))
self.assertIsNone(claim(92, [('a.png', 57, 0)]))
self.assertEqual(claim(100, [('a.png', 57, 0)]), 'Nota 100 maior que a do arquivo (90)')
self.assertEqual(claim(40, [('x.cdr', 57, 0)]), 'Nota não conferida no servidor')
self.assertIsNone(claim(0, [('x.cdr', 57, 0)]))
if __name__ == '__main__':
unittest.main()

View File

@@ -1,5 +1,11 @@
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h. */
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h.
Only the items are kept: the customer's details and address are typed again
after a reload or a closed tab, so nothing personal stays in the browser. */
(() => {
// A browser may put back what was typed before the reload without telling
// the page, which then shows values it does not know and cannot pay with.
const CAMPOS=['fCnpj','fZap','fMail','cepIn','eNome','eRua','eNum','eComp','eBairro','eCidade','eUf'];
for(const id of CAMPOS)if($(id))$(id).value='';
let database,scope,timer,restoring=true;
let signedOut=false;
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];});
@@ -11,7 +17,7 @@
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
try{
if(!items.length){await transaction('readwrite',store=>store.delete(scope));return;}
await transaction('readwrite',store=>store.put({items,customer:{...cliente},delivery:{...entrega},expires:Date.now()+86400000},scope));
await transaction('readwrite',store=>store.put({items,expires:Date.now()+86400000},scope));
notice.textContent='Carrinho salvo neste navegador por 24 horas. Você pode remover itens e adicionar outros após recarregar.';
}catch(error){notice.textContent='Não foi possível salvar o carrinho neste navegador. Mantenha esta página aberta até enviar o pedido.';}
}
@@ -30,16 +36,8 @@
for(const key of records){const value=await transaction('readonly',store=>store.get(key));if(value.expires<Date.now())await transaction('readwrite',store=>store.delete(key));}
const saved=await transaction('readonly',store=>store.get(scope));
if(saved && !pedido.length && !itemAtual){
pedido=saved.items;cliente=saved.customer;entrega=saved.delivery;
// Freight must be quoted again; restored browser values are never final.
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;entrega.dias=null;}
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
$('cepIn').value=fmtCep(entrega.cep);
entrega.end=entrega.end||{};
for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key]||'';
pedido=saved.items;
$('atual').style.display='none';pintaEntrega();
if(entrega.tipo==='frete' && entrega.cep.length===8 && window.dtfFreight) window.dtfFreight();
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
}else{
const account=await window.dtfApi('/account/me');

View File

@@ -152,8 +152,10 @@
if (!e) return '';
if (e.fase==='pronto') return 'Arquivos enviados ✓';
if (e.fase==='verificando') return 'Arquivos enviados · verificando a segurança…';
return 'Enviando seus arquivos: '+e.pct+'% · '+mb(e.sent)+' de '+mb(e.total)+
(e.restante!=null ? ' · '+tempo(e.restante) : '');
// Until the speed is measured, the range from the file size.
return 'Enviando seus arquivos: '+e.pct+'% · '+mb(e.sent)+' de '+mb(e.total)+' · '+
(e.restante!=null ? tempo(e.restante) : 'estimativa '+faixaEnvio(e.total-e.sent))+
'. Mantenha a página aberta.';
}
function pintaEnvio() {
const e=estadoEnvio(), texto=textoEnvio(e);

View File

@@ -917,6 +917,7 @@ footer a:hover{color:var(--laranja2)}
<div class="trocouCam" id="avisoTipo" style="display:none"></div>
<div class="recusa" id="recusa" style="display:none"></div>
<div id="lista"></div>
<p class="avisoEnvio" id="avisoEnvio" role="note" hidden></p>
<div class="zona" id="zona" tabindex="0" role="button">
<div class="ico">↑</div>
<b id="zTit">Arraste aqui</b><span id="zSub"></span>
@@ -1097,13 +1098,13 @@ footer a:hover{color:var(--laranja2)}
e é por ele que suas artes ficam guardadas para o próximo pedido.</p>
<div class="campos">
<div class="cp"><label>CNPJ</label>
<input id="fCnpj" inputmode="numeric" maxlength="18" placeholder="00.000.000/0000-00">
<input id="fCnpj" autocomplete="off" inputmode="numeric" maxlength="18" placeholder="00.000.000/0000-00">
<span class="err" id="eCnpj"></span></div>
<div class="cp"><label>WhatsApp</label>
<input id="fZap" inputmode="numeric" maxlength="15" placeholder="(16) 99999-9999">
<input id="fZap" autocomplete="off" inputmode="numeric" maxlength="15" placeholder="(16) 99999-9999">
<span class="err" id="eZap"></span></div>
<div class="cp larga"><label>E-mail para a nota</label>
<input id="fMail" type="email" placeholder="voce@empresa.com.br">
<input id="fMail" autocomplete="off" type="email" placeholder="voce@empresa.com.br">
<span class="err" id="eMail"></span></div>
</div>
</div>
@@ -1131,24 +1132,24 @@ footer a:hover{color:var(--laranja2)}
<div class="cep" id="cep">
<label>CEP de entrega</label>
<div class="cepL">
<input id="cepIn" inputmode="numeric" maxlength="9" placeholder="00000-000" autocomplete="postal-code">
<input id="cepIn" autocomplete="off" inputmode="numeric" maxlength="9" placeholder="00000-000">
</div>
<p id="cepMsg"></p>
<div class="campos endereco" id="endereco">
<div class="cp larga"><label>Quem recebe</label>
<input id="eNome" maxlength="120" autocomplete="name" placeholder="Nome ou empresa"></div>
<input id="eNome" autocomplete="off" maxlength="120" placeholder="Nome ou empresa"></div>
<div class="cp larga"><label>Rua / avenida</label>
<input id="eRua" maxlength="160" autocomplete="address-line1"></div>
<input id="eRua" autocomplete="off" maxlength="160"></div>
<div class="cp"><label>Número</label>
<input id="eNum" maxlength="20" placeholder="123 ou S/N"></div>
<input id="eNum" autocomplete="off" maxlength="20" placeholder="123 ou S/N"></div>
<div class="cp"><label>Complemento (opcional)</label>
<input id="eComp" maxlength="80" autocomplete="address-line2"></div>
<input id="eComp" autocomplete="off" maxlength="80"></div>
<div class="cp"><label>Bairro</label>
<input id="eBairro" maxlength="80"></div>
<input id="eBairro" autocomplete="off" maxlength="80"></div>
<div class="cp"><label>Cidade</label>
<input id="eCidade" maxlength="80" autocomplete="address-level2"></div>
<input id="eCidade" autocomplete="off" maxlength="80"></div>
<div class="cp"><label>UF</label>
<input id="eUf" maxlength="2" autocomplete="address-level1" placeholder="SP"></div>
<input id="eUf" autocomplete="off" maxlength="2" placeholder="SP"></div>
</div>
<p class="dicaEnd" id="eEnd"></p>
</div>

View File

@@ -91,7 +91,7 @@ function carrinho(){
measurement:x.med?'file':'customer'}
: {kind:'artwork',width_cm:x.cm,length_cm:+(x.cm*propDe(x)).toFixed(4),
copies:x.q,rotation_degrees:x.giro||0,mirrored:!!x.esp,
measurement:x.src?'file':'customer'});
measurement:(x.src||x.semPrevia)?'file':'customer'});
let layout;
if(ehFolha()){
let y=0;

View File

@@ -96,6 +96,25 @@ const destinoApi=()=>entrega.tipo!=='frete' ? null : {
recipient:entrega.end.nome.trim(), street:entrega.end.rua.trim(), number:entrega.end.num.trim(),
complement:(entrega.end.comp||'').trim(), district:entrega.end.bairro.trim(),
city:entrega.end.cidade.trim(), state:entrega.end.uf.trim().toUpperCase(), postal_code:entrega.cep};
// Tempo de envio antes de medir a internet de quem compra: uma faixa entre um
// upload lento (10 Mbps) e um rápido (150 Mbps). O arquivo vai direto para o
// armazenamento, então é o upload do cliente que manda.
const ENVIO_LENTO=10e6/8, ENVIO_RAPIDO=150e6/8, ENVIO_AVISA_S=120;
const duracao=s=>{
const min=Math.ceil(s/60);
return s<60 ? 'menos de 1 min' : min<60 ? min+' min' : Math.floor(min/60)+' h'+(min%60? ' '+min%60+' min' : '');
};
const faixaEnvio=bytes=>'de '+duracao(bytes/ENVIO_RAPIDO)+' a '+duracao(bytes/ENVIO_LENTO);
function pintaAvisoEnvio(){
const el=$('avisoEnvio'); if(!el) return;
const total=(ehFolha()?folhas:artes).reduce((t,x)=>t+(x.f?.size||0),0);
el.hidden = total/ENVIO_LENTO < ENVIO_AVISA_S;
if(el.hidden) return;
el.innerHTML='<b>O envio '+((ehFolha()?folhas:artes).length>1?'destes arquivos':'deste arquivo')+
' ('+fmt(total)+') leva '+faixaEnvio(total)+'</b>, conforme a velocidade de upload da sua internet. '+
'Ele começa quando você coloca o item no carrinho e continua enquanto você preenche os dados. '+
'<b>Mantenha a página aberta até terminar.</b>';
}
const larguraFilme=()=>MODOS[modo].larg;
// cobrança proporcional: arredonda para cima em 10 cm · 2,75 m vira 2,80 m
const cobrar=m=>Math.max(MINIMO_M, Math.ceil(m*10)/10);

View File

@@ -337,7 +337,7 @@ function previaAoVivo(){
tMont=setTimeout(()=>{
// o produto pode ter sido fechado nesses 140 ms (item foi para o carrinho)
if(!modo) return;
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && (a.src||a.semPrevia));
const W=Math.round(300*ZOOMS[zoomI]);
$('vArea').classList.toggle('ampliado', zoomI>0);
desenhaMontagem($('vArea'), prontas, 0, W, $('vUso'), r=>{

View File

@@ -406,6 +406,7 @@ function detalhesDaFolha(a,i,aberto){
}
function pintaFolha(){
invalidaItemAtual();
pintaAvisoEnvio();
const L=larguraFilme();
pintaTipoEnvio(); // trava o seletor enquanto houver folha
$('zona').classList.toggle('compacta', folhas.length>0);
@@ -528,6 +529,7 @@ function pintaProgressoArte(a){
}
function pintaArtes(){
invalidaItemAtual();
pintaAvisoEnvio();
$('zona').classList.toggle('compacta', artes.length>0);
// The same card as a finished sheet: thumbnail, size, a DPI chip, then the
// width (with the usual sizes), copies, rotation and mirroring.
@@ -539,13 +541,11 @@ function pintaArtes(){
if(a.lendo!=null){
barra='<div class="prog"><i style="width:'+a.lendo+'%"></i></div>'+
'<span class="progT">arquivo grande · gerando a prévia… '+a.lendo+'%</span>';
}else if(a.decodeError==='grande'){
aviso='<p class="avisoF"><b>Arquivo grande em JPG ou WebP.</b> Exporte em PNG para montar a folha.</p>';
}else if(a.decodeError){
aviso='<p class="avisoF"><b>Não conseguimos ler esta imagem.</b> Exporte novamente em PNG ou JPG.</p>';
}else if(a.cm>larguraFilme()){
aviso='<p class="avisoF"><b>Largura maior que o filme.</b> O filme aqui tem '+n1(larguraFilme())+' cm.</p>';
}else if(a.cm>0 && a.src){
}else if(a.cm>0 && (a.src||a.semPrevia)){
const dpi=Math.round(dpiDe(a));
medida='<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>';
chips='<ul class="chips"><li class="chip '+(dpi>=DPI_AVISA?'ok':dpi>=DPI_RECUSA?'av':'er')+'">'+dpi+' DPI</li>'+
@@ -562,8 +562,11 @@ function pintaArtes(){
n1(sug.cm)+' cm.<button data-usar="'+i+'" data-cm="'+sug.alvo+'">Usar '+n1(sug.alvo)+' cm</button></div>'
: '';
}
if(a.semPrevia && !a.decodeError)
aviso+='<p class="repTxt"><b>Sem prévia para '+escapeHTML(extDe(a.f.name))+' deste tamanho.</b> '+
'A arte entra na folha pela medida, e a equipe confere antes de imprimir. Em PNG, a prévia aparece.</p>';
const mini = a.miniSrc ? '<img src="'+escapeHTML(a.miniSrc)+'" alt="">' : '<span>'+escapeHTML(extDe(a.f.name))+'</span>';
const sub = a.cm>0 && a.src ? (a.q>1? a.q+' × ' : '')+'<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>' : '';
const sub = a.cm>0 && (a.src||a.semPrevia) ? (a.q>1? a.q+' × ' : '')+'<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>' : '';
return '<div class="rep art2'+(a===recemChegada?' nova':'')+'" data-arte="'+i+'">'+
'<div class="repTopo"><div class="miniF">'+mini+'</div><div class="repInfo">'+
'<div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+(a===recemChegada?'<em class="novaT">nova</em>':'')+
@@ -620,7 +623,7 @@ function pintaArtes(){
previaAoVivo();
}
function resumoArtes(){
const r=$('rA'), prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
const r=$('rA'), prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && (a.src||a.semPrevia));
if(!prontas.length){ r.style.display='none'; return; }
const m=montagemCm/100; // a mesma folha que aparece ao lado
const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round(dpiDe(a)/300*100))));

View File

@@ -64,9 +64,7 @@ function avaliar(){
}
if(!artes.length) return {pronto:false, falta:'Arraste suas artes para começar.'};
if(artes.some(a=>a.lendo!=null)) return {pronto:false, falta:'Gerando a prévia das artes grandes…'};
if(artes.some(a=>a.decodeError==='grande'))
return {pronto:false, falta:'Uma arte grande está em JPG ou WebP. Exporte-a em PNG para montar a folha.'};
if(artes.some(a=>a.decodeError || !a.src || !(a.px>0) || !(a.py>0)))
if(artes.some(a=>a.decodeError || !(a.src||a.semPrevia) || !(a.px>0) || !(a.py>0)))
return {pronto:false, falta:'Não conseguimos ler uma das imagens. Exporte-a novamente em PNG ou JPG antes de continuar.'};
if(artes.some(a=>!Number.isFinite(a.cm) || a.cm>larguraFilme()))
return {pronto:false, falta:'A largura da arte precisa caber no filme de '+

View File

@@ -128,7 +128,9 @@ function medir(a){
return dimensoesImagem(a.f).then(async d=>{
if(!d || !(d.w>0) || !(d.h>0)){ a.decodeError=true; return a; }
a.px=d.w; a.py=d.h; a.prop=d.h/d.w; a.grande=true;
if(!/\.png$/i.test(a.f.name)){ a.decodeError='grande'; return a; }
// JPG and WebP cannot be read in parts: the art is placed by its
// measurements, as a full box, and the team checks it before printing.
if(!/\.png$/i.test(a.f.name)){ a.semPrevia=true; return a; }
a.lendo=0; pintaArtes();
let mostrado=0;
const cv=await previaPngGrande(a.f, 1200, p=>{

View File

@@ -332,6 +332,8 @@ section+section{border-top:0}
/* The payment pages: paying on the left, the order summary on the right */
html:is([data-rota="pagamento"],[data-rota="pix"]) .checkout{max-width:1040px;margin-top:8px}
html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:32px;align-items:start}
.avisoEnvio{margin:10px 0 0;padding:10px 12px;border-radius:8px;background:var(--aviso-fundo);color:#5A3A00;font-size:13px;line-height:1.5}
.avisoEnvio[hidden]{display:none}
.envioArq{font-size:13px;color:var(--texto2);margin-top:14px;line-height:1.5}
.envioArq.ok{color:var(--verde)}
.envioArq.erro{color:#B42318}