Compare commits
4 Commits
cfcbe545f1
...
ui-v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
641dc6053b | ||
|
|
4c01e932c3 | ||
|
|
e3d5558198 | ||
|
|
c18b9e5b87 |
17
.env.example
17
.env.example
@@ -20,6 +20,23 @@ APP_ENV=local
|
||||
PAYMENT_ADAPTER=fake
|
||||
FREIGHT_ADAPTER=fake
|
||||
TINY_ADAPTER=fake
|
||||
# Sandbox only (see docs/LOCAL_SETUP.md, "Provider sandboxes"):
|
||||
# PAYMENT_ADAPTER=mercadopago
|
||||
# MP_ACCESS_TOKEN=TEST-...
|
||||
# MP_WEBHOOK_SECRET=...
|
||||
# MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
|
||||
# Tiny API v3: client ID/secret come from the "Aplicativo" created in Tiny
|
||||
# (Configurações > Geral > Aplicativos); the redirect URI registered there
|
||||
# must be exactly TINY_REDIRECT_URI. Product ids are the Tiny products each
|
||||
# Site product becomes. Tiny has no sandbox: orders created are real.
|
||||
# TINY_CLIENT_ID=...
|
||||
# TINY_CLIENT_SECRET=...
|
||||
# TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback
|
||||
# TINY_PRODUCT_TEXTIL_FOLHA=...
|
||||
# TINY_PRODUCT_TEXTIL_AVULSA=...
|
||||
# TINY_PRODUCT_UV_FOLHA=...
|
||||
# TINY_PRODUCT_UV_AVULSA=...
|
||||
# TINY_ADAPTER=tiny # only once connected and tested: creates real orders
|
||||
WHATSAPP_ADAPTER=fake
|
||||
STORAGE_ADAPTER=s3-local
|
||||
MOCK_FREIGHT_CENTS=1500
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
# one a developer exercises on localhost.
|
||||
- name: API and workflow regressions
|
||||
run: |
|
||||
for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test; do
|
||||
for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test print_file_test; do
|
||||
echo "--- $suite"
|
||||
$COMPOSE exec -T \
|
||||
-e SITE_BASE_URL=http://site \
|
||||
@@ -81,10 +81,18 @@ jobs:
|
||||
api python -m "tests.$suite"
|
||||
done
|
||||
|
||||
# Need Pillow and httpx, which only the application image has. The raster
|
||||
# check needs PyMuPDF as well and skips here; run it locally when changing
|
||||
# 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 -v
|
||||
|
||||
- name: Runtime and retention regressions
|
||||
run: |
|
||||
$COMPOSE exec -T api python -m tests.retention_test
|
||||
$COMPOSE exec -T api python -m tests.runtime_security_test
|
||||
$COMPOSE exec -T api python -m tests.tiny_oauth_test
|
||||
|
||||
# Run Chrome on the Compose network. It must resolve the same storage:9000
|
||||
# hostname used in presigned URLs, and absence of Chrome must fail CI.
|
||||
@@ -126,9 +134,9 @@ jobs:
|
||||
fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \
|
||||
--no-progress /src
|
||||
|
||||
# Keep push feedback advisory while the provider adapters are fake.
|
||||
# The manual release job enforces the source preflight unconditionally.
|
||||
# ENFORCE_PRODUCTION_PREFLIGHT can make push checks fail on blockers too.
|
||||
# Advisory while the provider adapters are fake. This is the only copy of
|
||||
# the gate: set ENFORCE_PRODUCTION_PREFLIGHT=true and a blocked preflight
|
||||
# fails this job, which stops images from being published.
|
||||
- name: Production source preflight
|
||||
run: |
|
||||
set +e
|
||||
@@ -145,10 +153,14 @@ jobs:
|
||||
fi
|
||||
echo "::warning::Source preflight reports blockers (advisory; set ENFORCE_PRODUCTION_PREFLIGHT=true to gate)."
|
||||
|
||||
# Every push to main that passes validation, the integration suite and the
|
||||
# scans publishes images. Production changes only when someone pulls and
|
||||
# redeploys the stack in Portainer; a manual run of this workflow also calls
|
||||
# the Portainer webhook when one is configured.
|
||||
publish-and-deploy:
|
||||
name: Publish images and notify Portainer
|
||||
name: Publish images
|
||||
needs: [validate, integration, scan]
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -158,12 +170,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Require production readiness
|
||||
env:
|
||||
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
|
||||
run: |
|
||||
python3 deploy/production_preflight.py --source-only
|
||||
test -n "$PORTAINER_WEBHOOK"
|
||||
- name: Sign in to the Gitea Container Registry
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
@@ -228,7 +234,12 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Trigger Portainer redeployment
|
||||
if: gitea.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
|
||||
run: |
|
||||
if [ -z "$PORTAINER_WEBHOOK" ]; then
|
||||
echo "No PORTAINER_WEBHOOK configured; redeploy the stack in Portainer."
|
||||
exit 0
|
||||
fi
|
||||
curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK"
|
||||
|
||||
@@ -17,9 +17,26 @@ def require_runtime():
|
||||
checkout until their audited implementations are added.
|
||||
"""
|
||||
environment = os.environ.get('APP_ENV', 'local')
|
||||
for name in ('PAYMENT', 'FREIGHT', 'TINY', 'WHATSAPP'):
|
||||
for name in ('FREIGHT', 'WHATSAPP'):
|
||||
if os.environ.get(f'{name}_ADAPTER') != 'fake':
|
||||
raise RuntimeError(f'{name} must use the currently supported fake adapter')
|
||||
tiny = os.environ.get('TINY_ADAPTER')
|
||||
if tiny == 'tiny':
|
||||
from .tiny import required_settings
|
||||
for name in required_settings():
|
||||
if not os.environ.get(name):
|
||||
raise RuntimeError(f'{name} is required for the Tiny adapter')
|
||||
elif tiny != 'fake':
|
||||
raise RuntimeError('TINY must use the fake or tiny adapter')
|
||||
# Mercado Pago is selectable only with its credentials present; it has not
|
||||
# yet passed the sandbox flows, so production preflight still blocks it.
|
||||
payment = os.environ.get('PAYMENT_ADAPTER')
|
||||
if payment == 'mercadopago':
|
||||
for name in ('MP_ACCESS_TOKEN', 'MP_WEBHOOK_SECRET'):
|
||||
if not os.environ.get(name):
|
||||
raise RuntimeError(f'{name} is required for the Mercado Pago adapter')
|
||||
elif payment != 'fake':
|
||||
raise RuntimeError('PAYMENT must use the fake or mercadopago adapter')
|
||||
if environment == 'local':
|
||||
if os.environ.get('STORAGE_ADAPTER') != 's3-local':
|
||||
raise RuntimeError('Local runtime requires local S3 storage')
|
||||
@@ -60,14 +77,14 @@ class PaymentEvent(NamedTuple):
|
||||
|
||||
|
||||
class PaymentAdapter(Protocol):
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict:
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
|
||||
"""Start a payment. Must be idempotent on quote_id: a retry after a
|
||||
timeout has to return the existing payment, never charge twice."""
|
||||
|
||||
def verify(self, headers: Mapping[str, str], body: bytes) -> bool:
|
||||
def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
|
||||
"""Whether this delivery genuinely came from the provider."""
|
||||
|
||||
def parse(self, body: bytes) -> PaymentEvent | None:
|
||||
def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
|
||||
"""Normalise a verified delivery, or None if it is not about a payment."""
|
||||
|
||||
|
||||
@@ -80,14 +97,16 @@ class FakePayment:
|
||||
the service changes.
|
||||
"""
|
||||
|
||||
name = 'fake'
|
||||
header = 'x-payment-signature'
|
||||
|
||||
def _secret(self) -> bytes | None:
|
||||
secret = os.environ.get('PAYMENT_WEBHOOK_SECRET', '')
|
||||
return secret.encode() if secret else None
|
||||
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict:
|
||||
return {'provider': 'fake', 'id': f'local-{quote_id}',
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
|
||||
kind = (method or {}).get('type', 'pix')
|
||||
return {'provider': 'fake', 'id': f'local-{quote_id}-{kind}',
|
||||
'status': 'pending', 'total_cents': total_cents}
|
||||
|
||||
def sign(self, body: bytes) -> str:
|
||||
@@ -96,7 +115,7 @@ class FakePayment:
|
||||
raise RuntimeError('PAYMENT_WEBHOOK_SECRET is not configured')
|
||||
return hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
|
||||
def verify(self, headers, body: bytes) -> bool:
|
||||
def verify(self, headers, body: bytes, query=None) -> bool:
|
||||
# No configured secret means nothing can be verified, so nothing is
|
||||
# accepted. A guessable default would let anyone forge an approval and
|
||||
# create an order that was never paid for.
|
||||
@@ -105,7 +124,7 @@ class FakePayment:
|
||||
supplied = headers.get(self.header) or headers.get(self.header.title()) or ''
|
||||
return hmac.compare_digest(supplied, self.sign(body))
|
||||
|
||||
def parse(self, body: bytes):
|
||||
def parse(self, body: bytes, query=None):
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
@@ -158,6 +177,8 @@ class ObjectStorage(Protocol):
|
||||
def complete(self, key: str, upload_id: str, parts: list): ...
|
||||
def size(self, key: str) -> int: ...
|
||||
def download(self, key: str, name: str) -> str: ...
|
||||
def fetch(self, key: str, path: str): ...
|
||||
def store(self, key: str, path: str, content_type: str): ...
|
||||
def health(self): ...
|
||||
def discard(self, key: str, upload_id: str, complete: bool): ...
|
||||
|
||||
@@ -206,6 +227,14 @@ class LocalS3Storage:
|
||||
def size(self, key):
|
||||
return self.client.head_object(Bucket=self.bucket, Key=key)['ContentLength']
|
||||
|
||||
def fetch(self, key, path):
|
||||
"""Copy a stored object to a local file (the worker's scratch space)."""
|
||||
self.client.download_file(self.bucket, key, path)
|
||||
|
||||
def store(self, key, path, content_type):
|
||||
"""Upload a file the service generated itself, such as a print file."""
|
||||
self.client.upload_file(path, self.bucket, key, ExtraArgs={'ContentType': content_type})
|
||||
|
||||
def download(self, key, name):
|
||||
from urllib.parse import quote
|
||||
return self.public.generate_presigned_url('get_object', Params={
|
||||
|
||||
@@ -9,7 +9,7 @@ from psycopg.types.json import Jsonb
|
||||
from ..core import db
|
||||
from ..artwork import submit_files
|
||||
from ..core.auth import (DUMMY_PASSWORD_HASH, audit, client_ip, new_session, owner,
|
||||
password_hash, password_matches, session_row, throttle, transfer_guest)
|
||||
login_failed, password_hash, password_matches, session_row, throttle, transfer_guest)
|
||||
from ..core.models import ArtworkSubmission, Login, Register
|
||||
from ..runtime import STATES, file_rows, owned_order, storage
|
||||
from ..scanning import require_clean
|
||||
@@ -26,6 +26,8 @@ def current(request):
|
||||
def register(body: Register, request: Request, response: Response):
|
||||
email = body.customer.mail.strip().lower()
|
||||
throttle(email, request)
|
||||
# Registration attempts keep counting against the email, as before.
|
||||
login_failed(email)
|
||||
previous = current(request)
|
||||
encoded = password_hash(body.password)
|
||||
identity = uuid4()
|
||||
@@ -53,6 +55,7 @@ def login(body: Login, request: Request, response: Response):
|
||||
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
|
||||
matches = password_matches(body.password, stored)
|
||||
if not account or not matches:
|
||||
login_failed(email)
|
||||
audit('customer_login_failed', ip=client_ip(request))
|
||||
raise HTTPException(401, 'Invalid email or password')
|
||||
previous = current(request)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Health, session bootstrap and freight quoting."""
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
|
||||
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, storage
|
||||
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, storage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -32,7 +34,9 @@ def session(request: Request, response: Response):
|
||||
with db.connect() as c:
|
||||
session_id = new_session(c, response)
|
||||
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
|
||||
'max_upload_bytes': upload_limit_bytes()}
|
||||
'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name,
|
||||
# Public by design: Mercado Pago's card form needs it in the browser.
|
||||
'payment_public_key': os.environ.get('MP_PUBLIC_KEY', '') if payment.name == 'mercadopago' else ''}
|
||||
|
||||
@router.post('/api/freight')
|
||||
def quote_freight(body: Freight):
|
||||
|
||||
@@ -7,13 +7,16 @@ from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from ..core import db
|
||||
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
|
||||
password_matches, throttle)
|
||||
from ..core.models import Move, OperatorLogin, Review
|
||||
login_failed, password_matches, throttle)
|
||||
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,
|
||||
enqueue, freight, quote_view, storage, upload_row)
|
||||
from ..scanning import require_clean
|
||||
@@ -32,6 +35,7 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
|
||||
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')
|
||||
token = secrets.token_urlsafe(32)
|
||||
@@ -80,8 +84,20 @@ def board(user=Depends(operator)):
|
||||
approved_total = c.execute('''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 q.approved IS NOT NULL''').fetchone()['n']
|
||||
orders = active + list(reversed(finished))
|
||||
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
|
||||
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
|
||||
for order in orders:
|
||||
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
|
||||
# A paid notification that did not become an order is money received
|
||||
# for nothing the factory will make. It stays on the board until a
|
||||
# person records what was done about it.
|
||||
refused = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome
|
||||
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': active + list(reversed(finished)),
|
||||
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(),
|
||||
'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,
|
||||
@@ -131,6 +147,7 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
'quality_acknowledged': original['quality_acknowledged']})
|
||||
quoted_freight = freight.quote(**draft['freight'])
|
||||
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
|
||||
'destination': draft.get('destination'),
|
||||
'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']}
|
||||
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved),user,uid))
|
||||
return approved
|
||||
@@ -166,6 +183,61 @@ def move(uid: UUID, body: Move, user=Depends(operator)):
|
||||
'customer_path': f'/portal.html?order={uid}'})
|
||||
return changed
|
||||
|
||||
@router.post('/api/operator/orders/{uid}/print-files')
|
||||
def regenerate(uid: UUID, user=Depends(operator)):
|
||||
"""Queue generation again for items that failed or went to manual preparation,
|
||||
and for orders paid before the generator existed."""
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT id,snapshot,state FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Order not found')
|
||||
if row['state'] not in ('rec','tra'):
|
||||
raise HTTPException(409, 'Print files are generated only before the order is queued')
|
||||
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1", (uid,)).fetchone():
|
||||
raise HTTPException(409, 'A customer correction replaced the original artwork; prepare the final file by hand')
|
||||
queue_print_files(c, uid, len(row['snapshot']['items']), only_missing=True)
|
||||
audit('print_file_requeued', order=str(uid), operator=user)
|
||||
return c.execute('SELECT * FROM dtf_local.print_files WHERE order_id=%s ORDER BY item_index', (uid,)).fetchall()
|
||||
|
||||
@router.post('/api/operator/payment-events/{uid}/resolve')
|
||||
def resolve_payment(uid: UUID, body: Resolution, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('''UPDATE dtf_local.payment_events SET resolved_at=now(), resolved_by=%s, resolution=%s
|
||||
WHERE id=%s AND (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') AND resolved_at IS NULL RETURNING id''',
|
||||
(user, body.note, uid)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'No open payment issue with this id')
|
||||
audit('payment_issue_resolved', payment_event=str(uid), operator=user)
|
||||
return {'ok': True}
|
||||
|
||||
def tiny_status():
|
||||
if not tiny.configured():
|
||||
return {'configured': False}
|
||||
return {'configured': True, 'orders_enabled': os.environ.get('TINY_ADAPTER') == 'tiny',
|
||||
**tiny.TinyAuth().status()}
|
||||
|
||||
@router.post('/api/operator/tiny/connect')
|
||||
def tiny_connect(user=Depends(operator)):
|
||||
"""Start the one-time authorisation of this system in the client's Tiny."""
|
||||
if not tiny.configured():
|
||||
raise HTTPException(503, 'Tiny application is not configured')
|
||||
audit('tiny_connect_started', operator=user)
|
||||
return {'url': tiny.TinyAuth().authorize_url(user)}
|
||||
|
||||
@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
|
||||
single-use state an operator created is what authorises it."""
|
||||
if not tiny.configured():
|
||||
raise HTTPException(503, 'Tiny application is not configured')
|
||||
try:
|
||||
who = tiny.TinyAuth().complete(code, state)
|
||||
except tiny.TinyError:
|
||||
audit('tiny_connect_failed')
|
||||
return RedirectResponse('/?tiny=failed', status_code=303)
|
||||
audit('tiny_connected', operator=who)
|
||||
return RedirectResponse('/?tiny=connected', status_code=303)
|
||||
|
||||
@router.get('/api/operator/orders/{uid}/history')
|
||||
def history(uid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
|
||||
@@ -5,11 +5,15 @@ signature is the only thing standing between this endpoint and an attacker
|
||||
creating orders. It is verified before the body is parsed, let alone acted on,
|
||||
and an unverified delivery is recorded and refused rather than retried.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import payments
|
||||
from ..core import db
|
||||
from ..core.auth import audit, client_ip, rate_limit
|
||||
from ..core.auth import audit, client_ip, owner, rate_limit
|
||||
from ..core.models import PaymentIntent
|
||||
from ..runtime import payment
|
||||
|
||||
router = APIRouter()
|
||||
@@ -24,11 +28,12 @@ async def webhook(request: Request):
|
||||
rate_limit('payment-webhook', client_ip(request), WEBHOOK_LIMIT, 900)
|
||||
body = await request.body()
|
||||
|
||||
if not payment.verify(request.headers, body):
|
||||
query = dict(request.query_params)
|
||||
if not payment.verify(request.headers, body, query):
|
||||
audit('payment_webhook_rejected', ip=client_ip(request), reason='signature')
|
||||
raise HTTPException(403, 'Invalid signature')
|
||||
|
||||
event = payment.parse(body)
|
||||
event = payment.parse(body, query)
|
||||
if event is None:
|
||||
# Verified, so genuinely from the provider, but not about a payment.
|
||||
# Acknowledge it: refusing would make the provider retry for ever.
|
||||
@@ -50,4 +55,43 @@ async def webhook(request: Request):
|
||||
|
||||
|
||||
def event_provider():
|
||||
return getattr(payment, 'name', payment.__class__.__name__.replace('Payment', '').lower() or 'fake')
|
||||
return payment.name
|
||||
|
||||
|
||||
@router.post('/api/payments/intent')
|
||||
def intent(body: PaymentIntent, session_id=Depends(owner)):
|
||||
"""Start paying an approved quote: a PIX code, or a card token from the
|
||||
provider's own form. Asking twice for the same method returns the same
|
||||
payment; a quote already paid returns 409."""
|
||||
rate_limit('payment-intent', str(session_id), 30, 900)
|
||||
with db.connect() as c:
|
||||
try:
|
||||
quote = payments.approved_quote(c, body.quote_id, session_id)
|
||||
except payments.PaymentRefused as refusal:
|
||||
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 a second charge: an approved payment is waiting for its
|
||||
# notification to become the order, and a card in review may still be.
|
||||
if c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s
|
||||
AND (status='approved' OR (method='card' AND status='pending'))''', (body.quote_id,)).fetchone():
|
||||
raise HTTPException(409, 'A payment for this quote is already approved or in review')
|
||||
if body.method.type == 'pix':
|
||||
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'
|
||||
AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id,)).fetchone()
|
||||
if existing:
|
||||
return existing['response']
|
||||
try:
|
||||
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
|
||||
quote['approved']['customer'], body.method.model_dump())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
except Exception:
|
||||
audit('payment_intent_failed', quote=str(body.quote_id))
|
||||
raise HTTPException(502, 'Payment provider unavailable; try again')
|
||||
c.execute('''INSERT INTO dtf_local.payment_intents(id,quote_id,provider,provider_payment_id,method,
|
||||
status,amount_cents,response) VALUES(%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(provider,provider_payment_id) DO NOTHING''',
|
||||
(uuid4(), body.quote_id, payment.name, created['id'], body.method.type,
|
||||
created['status'], quote['approved']['total_cents'], Jsonb(created)))
|
||||
return created
|
||||
|
||||
@@ -9,9 +9,29 @@ from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .printjobs import generated_identity
|
||||
from .runtime import upload_row
|
||||
from .scanning import require_clean
|
||||
|
||||
|
||||
def generated_owner(c, order, ref, kind):
|
||||
"""The owner to look a generated print file up under, or None if it is not one.
|
||||
|
||||
A generated file may be approved as the final for the item it was made
|
||||
from, and only while that item still has its original artwork: after a
|
||||
customer correction it reproduces a layout nobody wants printed.
|
||||
"""
|
||||
generated = c.execute('''SELECT item_index FROM dtf_local.print_files
|
||||
WHERE order_id=%s AND upload_id=%s AND status='ready' ''', (order['id'], ref.upload_id)).fetchone()
|
||||
if not generated:
|
||||
return None
|
||||
if kind != 'final' or generated['item_index'] != ref.item_index:
|
||||
raise HTTPException(422, 'A generated print file can only be the final file of its own item')
|
||||
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1",
|
||||
(order['id'],)).fetchone():
|
||||
raise HTTPException(409, 'A customer correction replaced the artwork this file was generated from')
|
||||
return generated_identity(order['id'])
|
||||
|
||||
def submit_files(c, order, body, identity, kind, actor):
|
||||
if order['version'] != body.version:
|
||||
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
|
||||
@@ -32,7 +52,7 @@ def submit_files(c, order, body, identity, kind, actor):
|
||||
if expiry <= datetime.now(timezone.utc):
|
||||
raise HTTPException(410, 'Order artwork retention has expired')
|
||||
for ref in body.files:
|
||||
upload = upload_row(c, ref.upload_id, identity, lock=True)
|
||||
upload = upload_row(c, ref.upload_id, generated_owner(c, order, ref, kind) or identity, lock=True)
|
||||
if not upload['complete']:
|
||||
raise HTTPException(409, 'Complete all uploads first')
|
||||
require_clean(upload)
|
||||
|
||||
@@ -94,10 +94,25 @@ def rate_limit(scope, identity, limit, seconds=900):
|
||||
audit('rate_limit', scope=scope)
|
||||
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
|
||||
|
||||
ACCOUNT_FAILURES = 10
|
||||
|
||||
def throttle(email, request):
|
||||
# Independent account and source buckets prevent bypass by rotating emails.
|
||||
# Every attempt counts against the source. Only failures count against the
|
||||
# account: guessing a password is what the account bucket stops, and
|
||||
# counting successful sign-ins too let ordinary use lock an operator out.
|
||||
rate_limit('auth-source', client_ip(request), 60)
|
||||
rate_limit('auth-account', email, 10)
|
||||
key = hashlib.sha256(('auth-account|'+email).encode()).hexdigest()
|
||||
with connect() as c:
|
||||
row = c.execute("""SELECT attempts FROM dtf_local.login_attempts
|
||||
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'})
|
||||
|
||||
def login_failed(email):
|
||||
"""Count a failed sign-in (or registration attempt) against the account."""
|
||||
rate_limit('auth-account', email, 1_000_000)
|
||||
|
||||
def operator(request: Request):
|
||||
token = request.cookies.get('dtf_operator', '')
|
||||
|
||||
@@ -44,6 +44,29 @@ class Freight(StrictModel):
|
||||
service: Literal['pickup','mock-standard'] = 'pickup'
|
||||
postal_code: str = Field(default='', max_length=8)
|
||||
|
||||
UF = Literal['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB',
|
||||
'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']
|
||||
|
||||
class Destination(StrictModel):
|
||||
"""Where a shipped order goes. A CEP alone quotes freight; it does not deliver."""
|
||||
recipient: str = Field(min_length=2, max_length=120)
|
||||
street: str = Field(min_length=2, max_length=160)
|
||||
number: str = Field(min_length=1, max_length=20)
|
||||
complement: str = Field(default='', max_length=80)
|
||||
district: str = Field(min_length=2, max_length=80)
|
||||
city: str = Field(min_length=2, max_length=80)
|
||||
state: UF
|
||||
postal_code: str = Field(pattern=r'^[0-9]{8}$')
|
||||
|
||||
@field_validator('recipient', 'street', 'number', 'complement', 'district', 'city', mode='before')
|
||||
@classmethod
|
||||
def trimmed(cls, value):
|
||||
if isinstance(value, str):
|
||||
value = ' '.join(value.split())
|
||||
if any(ord(ch) < 32 for ch in value):
|
||||
raise ValueError('Invalid characters')
|
||||
return value
|
||||
|
||||
class UploadStart(StrictModel):
|
||||
name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$')
|
||||
size: int = Field(gt=0, strict=True)
|
||||
@@ -136,6 +159,18 @@ class QuoteRequest(StrictModel):
|
||||
customer: Customer
|
||||
items: list[Item] = Field(min_length=1, max_length=30)
|
||||
freight: Freight
|
||||
destination: Destination | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def destination_matches_freight(self):
|
||||
if self.freight.service == 'pickup':
|
||||
if self.destination is not None:
|
||||
raise ValueError('Pickup orders do not take a delivery address')
|
||||
elif self.destination is None:
|
||||
raise ValueError('Delivery requires the full address')
|
||||
elif self.destination.postal_code != self.freight.postal_code:
|
||||
raise ValueError('The delivery address CEP must be the CEP freight was quoted for')
|
||||
return self
|
||||
|
||||
class Review(StrictModel):
|
||||
items: list[Item] = Field(min_length=1, max_length=30)
|
||||
@@ -143,11 +178,33 @@ class Review(StrictModel):
|
||||
class Pay(StrictModel):
|
||||
quote_id: UUID
|
||||
|
||||
class PaymentMethod(StrictModel):
|
||||
type: Literal['pix', 'card']
|
||||
# Card fields come from the provider's own form, which tokenises the card
|
||||
# in the browser; the number never reaches this server.
|
||||
token: str | None = Field(default=None, max_length=200)
|
||||
payment_method_id: str | None = Field(default=None, max_length=40, pattern=r'^[a-z_]+$')
|
||||
installments: int = Field(default=1, ge=1, le=12, strict=True)
|
||||
issuer_id: str | None = Field(default=None, max_length=40)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def card_needs_token(self):
|
||||
if self.type == 'card' and not (self.token and self.payment_method_id):
|
||||
raise ValueError('Card payment requires the provider token and method')
|
||||
return self
|
||||
|
||||
class PaymentIntent(StrictModel):
|
||||
quote_id: UUID
|
||||
method: PaymentMethod
|
||||
|
||||
class Move(StrictModel):
|
||||
state: Literal['rec','tra','fil','imp','cor','fin']
|
||||
version: int = Field(ge=0)
|
||||
reason: str = Field(default='', max_length=1000)
|
||||
|
||||
class Resolution(StrictModel):
|
||||
note: str = Field(min_length=3, max_length=1000)
|
||||
|
||||
class Register(StrictModel):
|
||||
customer: Customer
|
||||
password: str = Field(min_length=12, max_length=128)
|
||||
|
||||
164
app/mercadopago.py
Normal file
164
app/mercadopago.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Mercado Pago: payment creation, webhook verification and status lookup.
|
||||
|
||||
Written from the public API documentation and exercised only against a fake
|
||||
HTTP transport. It is not a verified integration until it has passed the
|
||||
sandbox flows in docs/PRODUCTION_INPUTS.md with the client's own account;
|
||||
until then the runtime refuses to select it without explicit credentials.
|
||||
|
||||
The notification is only a pointer. Its body says "payment 123 changed" and
|
||||
nothing about amount or status, so nothing in it is trusted beyond the id:
|
||||
the payment is fetched from the API with our access token, and that response
|
||||
is what the order service compares against the approved quote.
|
||||
|
||||
Signature (x-signature: "ts=<unix>,v1=<hex>"): HMAC-SHA256, keyed with the
|
||||
webhook secret from the integration panel, over the manifest
|
||||
"id:<data.id>;request-id:<x-request-id>;ts:<ts>;", where data.id comes from
|
||||
the notification URL's query string (lower-cased when alphanumeric). A part
|
||||
whose value is absent from the notification is left out of the manifest.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Mapping
|
||||
|
||||
import httpx
|
||||
|
||||
from .adapters import PaymentEvent
|
||||
|
||||
API = 'https://api.mercadopago.com'
|
||||
# How old a signed timestamp may be. Mercado Pago retries a failed delivery
|
||||
# every 15 minutes, re-signing each attempt, so a replayed old one is refused.
|
||||
MAX_SIGNATURE_AGE = 30 * 60
|
||||
STATUSES = {'approved': 'approved', 'pending': 'pending', 'in_process': 'pending',
|
||||
'authorized': 'pending', 'in_mediation': 'pending', 'rejected': 'rejected',
|
||||
'cancelled': 'cancelled', 'refunded': 'refunded', 'charged_back': 'refunded'}
|
||||
|
||||
|
||||
class MercadoPagoPayment:
|
||||
name = 'mercadopago'
|
||||
|
||||
def __init__(self, access_token=None, webhook_secret=None, notification_url=None,
|
||||
transport=None, clock=time.time):
|
||||
self.access_token = access_token or os.environ.get('MP_ACCESS_TOKEN', '')
|
||||
self.webhook_secret = (webhook_secret or os.environ.get('MP_WEBHOOK_SECRET', '')).encode()
|
||||
self.notification_url = notification_url or os.environ.get('MP_NOTIFICATION_URL', '')
|
||||
if not self.access_token or not self.webhook_secret:
|
||||
raise RuntimeError('Mercado Pago needs MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET')
|
||||
self.http = httpx.Client(base_url=API, transport=transport, timeout=15,
|
||||
headers={'Authorization': f'Bearer {self.access_token}'})
|
||||
self.clock = clock
|
||||
|
||||
# Payments ------------------------------------------------------------
|
||||
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
|
||||
"""Create a payment for an approved quote.
|
||||
|
||||
The quote id is the idempotency key, so a retry after a timeout returns
|
||||
the payment already created rather than charging again. `method` is
|
||||
{'type': 'pix'} or {'type': 'card', 'token', 'payment_method_id',
|
||||
'installments', 'issuer_id'} from Mercado Pago's card form: card data is
|
||||
tokenised in the customer's browser and never reaches this server.
|
||||
"""
|
||||
method = method or {'type': 'pix'}
|
||||
body = {'transaction_amount': float(Decimal(total_cents) / 100),
|
||||
'description': f'DTF - cotação {quote_id[:8]}',
|
||||
'external_reference': quote_id,
|
||||
'payer': {'email': customer['mail'],
|
||||
'identification': {'type': 'CNPJ', 'number': customer['cnpj']}}}
|
||||
if self.notification_url:
|
||||
body['notification_url'] = self.notification_url
|
||||
if method['type'] == 'pix':
|
||||
body['payment_method_id'] = 'pix'
|
||||
elif method['type'] == 'card':
|
||||
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
|
||||
installments=int(method.get('installments', 1)))
|
||||
if method.get('issuer_id'):
|
||||
body['issuer_id'] = method['issuer_id']
|
||||
else:
|
||||
raise ValueError('Unsupported payment method')
|
||||
# A PIX retry must return the same code. A card retry after a decline
|
||||
# is a new attempt with a new token, so the token is part of the key;
|
||||
# the intent route refuses new attempts once one is approved or in review.
|
||||
key = f'dtf-quote-{quote_id}-pix' if method['type'] == 'pix' else \
|
||||
f"dtf-quote-{quote_id}-card-{hashlib.sha256(method['token'].encode()).hexdigest()[:24]}"
|
||||
response = self.http.post('/v1/payments', json=body, headers={'X-Idempotency-Key': key})
|
||||
response.raise_for_status()
|
||||
payment = response.json()
|
||||
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}
|
||||
return {'provider': self.name, 'id': str(payment['id']),
|
||||
'status': STATUSES.get(payment.get('status'), 'pending'),
|
||||
'status_detail': payment.get('status_detail'),
|
||||
'total_cents': total_cents,
|
||||
'pix_qr_code': transaction.get('qr_code'),
|
||||
'pix_qr_code_base64': transaction.get('qr_code_base64'),
|
||||
'ticket_url': transaction.get('ticket_url')}
|
||||
|
||||
def lookup(self, payment_id: str) -> dict:
|
||||
response = self.http.get(f'/v1/payments/{payment_id}')
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# Webhooks ------------------------------------------------------------
|
||||
|
||||
def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
|
||||
signature = headers.get('x-signature') or ''
|
||||
parts = dict(part.strip().split('=', 1) for part in signature.split(',') if '=' in part)
|
||||
ts, supplied = parts.get('ts', ''), parts.get('v1', '')
|
||||
if not ts.isdigit() or not supplied:
|
||||
return False
|
||||
if abs(self.clock() - int(ts[:10])) > MAX_SIGNATURE_AGE:
|
||||
return False
|
||||
data_id = (query or {}).get('data.id', '')
|
||||
if data_id.isalnum():
|
||||
data_id = data_id.lower()
|
||||
manifest = ''
|
||||
if data_id:
|
||||
manifest += f'id:{data_id};'
|
||||
request_id = headers.get('x-request-id') or ''
|
||||
if request_id:
|
||||
manifest += f'request-id:{request_id};'
|
||||
manifest += f'ts:{ts};'
|
||||
expected = hmac.new(self.webhook_secret, manifest.encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, supplied.lower())
|
||||
|
||||
def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict) or data.get('type') != 'payment':
|
||||
return None
|
||||
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
|
||||
if not payment_id.isdigit():
|
||||
return None
|
||||
payment = self.lookup(payment_id)
|
||||
return event_from_payment(payment, notification_id=str(data.get('id', '')))
|
||||
|
||||
|
||||
def event_from_payment(payment: dict, notification_id: str = '') -> PaymentEvent:
|
||||
"""Normalise a payment fetched from the API. Amount is None unless it is BRL
|
||||
and a whole number of centavos, so a foreign or malformed amount is refused."""
|
||||
amount = None
|
||||
if payment.get('currency_id') == 'BRL':
|
||||
try:
|
||||
cents = Decimal(str(payment.get('transaction_amount'))) * 100
|
||||
if cents == cents.to_integral_value():
|
||||
amount = int(cents)
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
amount = None
|
||||
status = STATUSES.get(payment.get('status'), 'pending')
|
||||
# One event per payment state: a notification id alone would let the same
|
||||
# approval be applied twice under two notifications, and would collapse a
|
||||
# later refund into the earlier approval.
|
||||
event_id = f"{payment.get('id')}:{payment.get('status')}"
|
||||
return PaymentEvent(event_id=event_id, reference=str(payment.get('external_reference') or ''),
|
||||
status=status, amount_cents=amount,
|
||||
raw={'provider': 'mercadopago', 'payment_id': str(payment.get('id')),
|
||||
'status': payment.get('status'), 'status_detail': payment.get('status_detail'),
|
||||
'currency_id': payment.get('currency_id'),
|
||||
'transaction_amount': payment.get('transaction_amount'),
|
||||
'date_approved': payment.get('date_approved'),
|
||||
'notification_id': notification_id})
|
||||
@@ -15,6 +15,7 @@ from uuid import UUID, uuid4
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .core.auth import audit
|
||||
from .printjobs import queue as queue_print_files
|
||||
from .runtime import enqueue, upload_row
|
||||
from .scanning import require_clean
|
||||
|
||||
@@ -59,6 +60,7 @@ 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()
|
||||
queue_print_files(c, order['id'], len(approved['items']))
|
||||
for provider in ('tiny', 'whatsapp'):
|
||||
enqueue(c, f"{order['id']}:paid:{provider}", provider,
|
||||
{'order_id': str(order['id']), 'number': order['number'],
|
||||
@@ -77,7 +79,25 @@ def record(c, provider, event):
|
||||
|
||||
|
||||
def apply(c, event):
|
||||
"""Act on a payment notification. Returns the outcome recorded against it."""
|
||||
"""Act on a payment notification. Returns the outcome recorded against it.
|
||||
|
||||
Outcomes starting 'refused' (money arrived, no order) or 'attention' (an
|
||||
order exists but its payment was reversed) stay on the Kanban until an
|
||||
operator records a resolution.
|
||||
"""
|
||||
provider_id = event.raw.get('payment_id')
|
||||
if provider_id:
|
||||
c.execute('''UPDATE dtf_local.payment_intents SET status=%s, updated_at=now()
|
||||
WHERE provider_payment_id=%s''', (event.status, provider_id))
|
||||
if event.status in ('refunded', 'cancelled'):
|
||||
try:
|
||||
order = c.execute('SELECT number FROM dtf_local.orders WHERE quote_id=%s',
|
||||
(UUID(event.reference),)).fetchone()
|
||||
except (ValueError, AttributeError):
|
||||
order = None
|
||||
if order:
|
||||
audit('payment_reversed', order=order['number'], status=event.status)
|
||||
return f"attention: payment {event.status} for order {order['number']}"
|
||||
if event.status != 'approved':
|
||||
return f'ignored: {event.status}'
|
||||
|
||||
@@ -101,5 +121,5 @@ def apply(c, event):
|
||||
expected_cents=expected, paid_cents=event.amount_cents)
|
||||
return f'refused: paid {event.amount_cents} but quote total is {expected}'
|
||||
|
||||
order, created = create_order(c, quote, {'provider': 'webhook', **event.raw})
|
||||
order, created = create_order(c, quote, {'provider': event.raw.get('provider', 'webhook'), **event.raw})
|
||||
return f"order {order['number']}" + ('' if created else ' (already existed)')
|
||||
|
||||
488
app/printfile.py
Normal file
488
app/printfile.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""The print file: the reviewed layout, reproduced exactly, at the film's size.
|
||||
|
||||
The customer is quoted on a layout the Site computes: each copy of each artwork
|
||||
at a width, rotation, mirror and position on the film. Production spec v2 keeps
|
||||
that layout through the approved order. This module turns it into one PDF per
|
||||
order item, with a page exactly as wide as the film and as long as the layout,
|
||||
so the operator imports what the customer approved instead of rebuilding it.
|
||||
|
||||
Each source image is embedded once, at its original resolution, and every copy
|
||||
is a placement of it. Nothing is resampled: a 300 DPI artwork is still 300 DPI
|
||||
in the file, a JPEG keeps its original bytes, and transparency survives as a
|
||||
soft mask. The output is therefore about the size of the artwork, not of a
|
||||
57 cm x 20 m raster, and it never needs that raster in memory.
|
||||
|
||||
Raster sources are JPEG, PNG, WebP and TIFF. A single-page PDF is placed as a
|
||||
vector form, never rasterised. Anything else (PSD, AI, CDR, multi-page or
|
||||
protected PDFs), or a file whose proportions do not match the size it was
|
||||
quoted at, is refused with a reason, and the operator prepares that item by
|
||||
hand exactly as before.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import zlib
|
||||
from decimal import Decimal
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
PT_PER_CM = 72 / 2.54
|
||||
# Acrobat's page limit. Longer layouts scale user space with /UserUnit (PDF 1.6)
|
||||
# rather than cutting the film into pages a RIP might print with gaps.
|
||||
MAX_PAGE_PT = 14400
|
||||
# How far a file's proportions may drift from the quoted size before the item
|
||||
# is refused: rounding in the Site keeps real files well inside this.
|
||||
ASPECT_TOLERANCE = 0.01
|
||||
# The quote may round up (10 cm steps, 1 m minimum) but never down.
|
||||
HEIGHT_TOLERANCE_CM = Decimal('0.05')
|
||||
SUPPORTED_FORMATS = {'JPEG', 'PNG', 'WEBP', 'TIFF'}
|
||||
STRIP_ROWS = 256
|
||||
# Decoding holds the whole image in memory (about 4 bytes a pixel). 57 cm x 3 m
|
||||
# at 300 DPI is 239 Mpx; above this the item goes to the operator instead.
|
||||
MAX_DECODED_PIXELS = int(os.environ.get('PRINT_MAX_PIXELS', '250000000'))
|
||||
# Pillow's own bomb guard would refuse a genuine long sheet before we can
|
||||
# decide; the explicit limit above is the one that applies.
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
"""This item cannot be generated automatically; the reason is for the operator."""
|
||||
|
||||
|
||||
class Name(str):
|
||||
pass
|
||||
|
||||
|
||||
class Ref(int):
|
||||
pass
|
||||
|
||||
|
||||
def serialize(value):
|
||||
if isinstance(value, Ref):
|
||||
return b'%d 0 R' % value
|
||||
if isinstance(value, Name):
|
||||
return b'/' + value.encode('ascii')
|
||||
if isinstance(value, bool):
|
||||
return b'true' if value else b'false'
|
||||
if isinstance(value, int):
|
||||
return b'%d' % value
|
||||
if isinstance(value, (float, Decimal)):
|
||||
text = f'{float(value):.4f}'.rstrip('0').rstrip('.')
|
||||
return (text if text not in ('', '-0') else '0').encode('ascii')
|
||||
if isinstance(value, dict):
|
||||
return b'<<' + b''.join(b'/' + k.encode('ascii') + b' ' + serialize(v)
|
||||
for k, v in value.items()) + b'>>'
|
||||
if isinstance(value, (list, tuple)):
|
||||
return b'[' + b' '.join(serialize(v) for v in value) + b']'
|
||||
if isinstance(value, str):
|
||||
escaped = value.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
|
||||
return b'(' + escaped.encode('latin-1', 'replace') + b')'
|
||||
raise TypeError(f'Cannot serialize {type(value).__name__}')
|
||||
|
||||
|
||||
class PdfWriter:
|
||||
"""A sequential PDF writer: objects go straight to the file, streams included.
|
||||
|
||||
Stream lengths are indirect objects written after the data, so an image is
|
||||
compressed strip by strip into the output without being held in memory.
|
||||
"""
|
||||
|
||||
def __init__(self, fp):
|
||||
self.fp = fp
|
||||
self.offsets = {}
|
||||
self.next_id = 1
|
||||
self.write(b'%PDF-1.6\n%\xe2\xe3\xcf\xd3\n')
|
||||
|
||||
def write(self, data):
|
||||
self.fp.write(data)
|
||||
|
||||
def tell(self):
|
||||
return self.fp.tell()
|
||||
|
||||
def alloc(self):
|
||||
ref = Ref(self.next_id)
|
||||
self.next_id += 1
|
||||
return ref
|
||||
|
||||
def obj(self, value, ref=None):
|
||||
ref = ref or self.alloc()
|
||||
self.offsets[ref] = self.tell()
|
||||
self.write(b'%d 0 obj\n' % ref + serialize(value) + b'\nendobj\n')
|
||||
return ref
|
||||
|
||||
def stream(self, dictionary, chunks, ref=None):
|
||||
"""Write a stream from an iterable of already-encoded byte chunks."""
|
||||
ref = ref or self.alloc()
|
||||
length = self.alloc()
|
||||
self.offsets[ref] = self.tell()
|
||||
self.write(b'%d 0 obj\n' % ref + serialize({**dictionary, 'Length': length}) + b'\nstream\n')
|
||||
start = self.tell()
|
||||
for chunk in chunks:
|
||||
self.write(chunk)
|
||||
size = self.tell() - start
|
||||
self.write(b'\nendstream\nendobj\n')
|
||||
self.obj(size, length)
|
||||
return ref
|
||||
|
||||
def finish(self, root, info):
|
||||
xref = self.tell()
|
||||
count = self.next_id
|
||||
self.write(b'xref\n0 %d\n0000000000 65535 f \n' % count)
|
||||
for ref in range(1, count):
|
||||
self.write(b'%010d 00000 n \n' % self.offsets[ref])
|
||||
self.write(b'trailer\n' + serialize({'Size': count, 'Root': root, 'Info': info}) +
|
||||
b'\nstartxref\n%d\n%%%%EOF\n' % xref)
|
||||
|
||||
|
||||
def deflate(pieces):
|
||||
compressor = zlib.compressobj(6)
|
||||
for piece in pieces:
|
||||
out = compressor.compress(piece)
|
||||
if out:
|
||||
yield out
|
||||
yield compressor.flush()
|
||||
|
||||
|
||||
class SourceImage:
|
||||
"""One customer file, opened for reading pixels and measured as the browser sees it."""
|
||||
|
||||
def __init__(self, path, name):
|
||||
self.path = path
|
||||
self.name = name
|
||||
try:
|
||||
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
|
||||
except Exception as exc:
|
||||
raise Unsupported(f'"{name}" is not an image this generator can read') 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')
|
||||
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
|
||||
raise Unsupported(f'"{name}" is animated or has several frames')
|
||||
# Browsers draw a photo upright according to its EXIF orientation, and
|
||||
# the Site measured it that way, so the print must too.
|
||||
try:
|
||||
self.orientation = self.image.getexif().get(0x0112, 1)
|
||||
except Exception:
|
||||
self.orientation = 1
|
||||
width, height = self.image.size
|
||||
self.size = (height, width) if self.orientation in (5, 6, 7, 8) else (width, height)
|
||||
|
||||
def passthrough(self):
|
||||
"""Whether the original JPEG bytes can go into the PDF unchanged."""
|
||||
return (self.format == 'JPEG' and self.orientation == 1 and
|
||||
self.image.mode in ('L', 'RGB', 'CMYK'))
|
||||
|
||||
def embed(self, pdf):
|
||||
"""Write this image (and its alpha) as XObjects; returns the image reference."""
|
||||
if self.passthrough():
|
||||
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')
|
||||
return self._embed_pixels(pdf)
|
||||
|
||||
def _colorspace(self, pdf, mode):
|
||||
components = {'L': 1, 'RGB': 3, 'CMYK': 4}[mode]
|
||||
device = Name({'L': 'DeviceGray', 'RGB': 'DeviceRGB', 'CMYK': 'DeviceCMYK'}[mode])
|
||||
profile = self.image.info.get('icc_profile')
|
||||
if not profile:
|
||||
return device
|
||||
icc = pdf.stream({'N': components, 'Alternate': device, 'Filter': Name('FlateDecode')},
|
||||
deflate([profile]))
|
||||
return [Name('ICCBased'), icc]
|
||||
|
||||
def _embed_jpeg(self, pdf):
|
||||
image = self.image
|
||||
extra = {}
|
||||
if image.mode == 'CMYK' and 'adobe' in image.info:
|
||||
# Adobe writes CMYK JPEGs inverted; PDF readers expect the Decode flip.
|
||||
extra['Decode'] = [1, 0, 1, 0, 1, 0, 1, 0]
|
||||
|
||||
def chunks():
|
||||
with open(self.path, 'rb') as source:
|
||||
while block := source.read(1 << 20):
|
||||
yield block
|
||||
return pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
|
||||
'Width': image.width, 'Height': image.height,
|
||||
'ColorSpace': self._colorspace(pdf, image.mode),
|
||||
'BitsPerComponent': 8, 'Filter': Name('DCTDecode'), **extra},
|
||||
chunks())
|
||||
|
||||
def _upright(self):
|
||||
image = self.image
|
||||
image.load()
|
||||
if self.orientation != 1:
|
||||
image = ImageOps.exif_transpose(image)
|
||||
return image
|
||||
|
||||
def _embed_pixels(self, pdf):
|
||||
image = self._upright()
|
||||
mode = image.mode
|
||||
has_alpha = mode in ('RGBA', 'LA', 'PA', 'RGBa', 'La') or (
|
||||
mode == 'P' and 'transparency' in image.info) or (
|
||||
mode in ('L', 'RGB') and 'transparency' in image.info)
|
||||
if mode in ('I;16', 'I;16B', 'I;16L', 'I'):
|
||||
image = image.convert('I').point(lambda value: value * (1 / 257)).convert('L')
|
||||
mode = 'L'
|
||||
if mode == 'CMYK':
|
||||
color_mode = 'CMYK'
|
||||
elif mode in ('1', 'L', 'LA', 'La'):
|
||||
color_mode = 'L'
|
||||
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')
|
||||
if has_alpha:
|
||||
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
|
||||
width, height = image.size
|
||||
|
||||
def strips(convert):
|
||||
for top in range(0, height, STRIP_ROWS):
|
||||
yield convert(image.crop((0, top, width, min(height, top + STRIP_ROWS)))).tobytes()
|
||||
|
||||
smask = None
|
||||
if has_alpha:
|
||||
smask = pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
|
||||
'Width': width, 'Height': height,
|
||||
'ColorSpace': Name('DeviceGray'), 'BitsPerComponent': 8,
|
||||
'Filter': Name('FlateDecode')},
|
||||
deflate(strips(lambda strip: strip.getchannel('A'))))
|
||||
colorspace = self._colorspace(pdf, color_mode)
|
||||
dictionary = {'Type': Name('XObject'), 'Subtype': Name('Image'),
|
||||
'Width': width, 'Height': height, 'ColorSpace': colorspace,
|
||||
'BitsPerComponent': 8, 'Filter': Name('FlateDecode')}
|
||||
if smask:
|
||||
dictionary['SMask'] = smask
|
||||
return pdf.stream(dictionary, deflate(strips(lambda strip: strip.convert(color_mode))))
|
||||
|
||||
def close(self):
|
||||
self.image.close()
|
||||
|
||||
|
||||
def placement_matrix(placement, page_height_pt):
|
||||
"""Map the image's unit square onto its box on the film.
|
||||
|
||||
Matches the Site's canvas: the artwork is mirrored first, then turned
|
||||
clockwise about the centre of its box, and the turned image fills the box.
|
||||
"""
|
||||
x = float(placement['x_cm']) * PT_PER_CM
|
||||
top = page_height_pt - float(placement['y_cm']) * PT_PER_CM
|
||||
w = float(placement['width_cm']) * PT_PER_CM
|
||||
h = float(placement['length_cm']) * PT_PER_CM
|
||||
rotation = placement['rotation_degrees'] % 360
|
||||
mirrored = placement['mirrored']
|
||||
|
||||
def to_page(u, v):
|
||||
# Unit square (v up) -> image frame (s right, t down).
|
||||
s, t = u, 1 - v
|
||||
if mirrored:
|
||||
s = 1 - s
|
||||
s, t = {0: (s, t), 90: (1 - t, s), 180: (1 - s, 1 - t), 270: (t, 1 - s)}[rotation]
|
||||
return x + s * w, top - t * h
|
||||
|
||||
ox, oy = to_page(0, 0)
|
||||
ax, ay = to_page(1, 0)
|
||||
cx, cy = to_page(0, 1)
|
||||
return [ax - ox, ay - oy, cx - ox, cy - oy, ox, oy]
|
||||
|
||||
|
||||
class PdfPage:
|
||||
"""One page of a customer PDF, placed as a vector form: nothing is rasterised.
|
||||
|
||||
The box and orientation are the ones the Site measured with pdf.js: the
|
||||
CropBox (which pikepdf uses for the form's BBox), turned by the page's
|
||||
/Rotate, inherited or not.
|
||||
"""
|
||||
|
||||
def __init__(self, path, name):
|
||||
import pikepdf
|
||||
self.name = name
|
||||
try:
|
||||
self.pdf = pikepdf.open(path)
|
||||
except pikepdf.PasswordError as exc:
|
||||
raise Unsupported(f'"{name}" is password-protected') from exc
|
||||
except Exception as exc:
|
||||
raise Unsupported(f'"{name}" is not a PDF this generator can read') 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')
|
||||
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')
|
||||
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
|
||||
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')
|
||||
# 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)
|
||||
|
||||
def normalise(self):
|
||||
"""Matrix from the page's box, as displayed, onto the unit square."""
|
||||
a, d = 1 / self.w, 1 / self.h
|
||||
scale = [a, 0, 0, d, -self.x0 * a, -self.y0 * d]
|
||||
turn = {0: [1, 0, 0, 1, 0, 0], 90: [0, -1, 1, 0, 0, 1],
|
||||
180: [-1, 0, 0, -1, 1, 1], 270: [0, 1, -1, 0, 1, 0]}[self.rotation]
|
||||
return multiply(scale, turn)
|
||||
|
||||
def form(self, target):
|
||||
"""The page as a form XObject copied into the target document."""
|
||||
form = self.page.as_form_xobject(handle_transformations=False)
|
||||
group = self.page.obj.get('/Group')
|
||||
if group is not None and '/Group' not in form:
|
||||
form.Group = group
|
||||
return target.copy_foreign(form)
|
||||
|
||||
def close(self):
|
||||
self.pdf.close()
|
||||
|
||||
|
||||
def multiply(first, then):
|
||||
"""PDF matrices: a point transformed by `first`, then by `then`."""
|
||||
a1, b1, c1, d1, e1, f1 = first
|
||||
a2, b2, c2, d2, e2, f2 = then
|
||||
return [a1 * a2 + b1 * c2, a1 * b2 + b1 * d2, c1 * a2 + d1 * c2, c1 * b2 + d1 * d2,
|
||||
e1 * a2 + f1 * c2 + e2, e1 * b2 + f1 * d2 + f2]
|
||||
|
||||
|
||||
def open_source(path, name):
|
||||
with open(path, 'rb') as handle:
|
||||
head = handle.read(1024)
|
||||
if b'%PDF-' in head:
|
||||
return PdfPage(path, name)
|
||||
return SourceImage(path, name)
|
||||
|
||||
|
||||
def check_layout(item, sizes, vector=()):
|
||||
"""Refuse a layout the file cannot reproduce faithfully. Returns the lowest
|
||||
DPI of the raster sources (vector pages have none)."""
|
||||
production = item['production']
|
||||
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')
|
||||
lowest = None
|
||||
for placement in production['placements']:
|
||||
width_px, height_px = sizes[placement['source_index']]
|
||||
if placement['rotation_degrees'] % 180 == 90:
|
||||
width_px, height_px = height_px, width_px
|
||||
width_cm = float(placement['width_cm'])
|
||||
length_cm = float(placement['length_cm'])
|
||||
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')
|
||||
if placement['source_index'] in vector:
|
||||
continue
|
||||
dpi = width_px / (width_cm / 2.54)
|
||||
lowest = dpi if lowest is None else min(lowest, dpi)
|
||||
return lowest
|
||||
|
||||
|
||||
def render(item, files, out, title):
|
||||
"""Write the PDF for one approved order item.
|
||||
|
||||
`files` maps each source index to (local path, original name). Returns the
|
||||
evidence the Kanban shows: page size, what was billed, and the lowest DPI.
|
||||
|
||||
Raster sources are written by the streaming writer above. PDF sources are
|
||||
then added as vector forms by a second pass through pikepdf, so a sheet
|
||||
exported as PDF keeps its vectors, fonts and transparency.
|
||||
"""
|
||||
production = item['production']
|
||||
if production.get('version') != 2:
|
||||
raise Unsupported('item uses an obsolete production layout')
|
||||
sources = []
|
||||
try:
|
||||
for index in range(len(production['sources'])):
|
||||
path, name = files[index]
|
||||
sources.append(open_source(path, name))
|
||||
vector = {index for index, source in enumerate(sources) if isinstance(source, PdfPage)}
|
||||
lowest_dpi = check_layout(item, [source.size for source in sources], vector)
|
||||
|
||||
width_pt = float(production['film_width_cm']) * PT_PER_CM
|
||||
height_pt = float(production['height_cm']) * PT_PER_CM
|
||||
unit = max(1, math.ceil(max(width_pt, height_pt) / MAX_PAGE_PT))
|
||||
|
||||
first = tempfile.TemporaryFile() if vector else out
|
||||
try:
|
||||
write_rasters(production, sources, first, title, width_pt, height_pt, unit)
|
||||
if vector:
|
||||
first.seek(0)
|
||||
add_vector_pages(production, sources, first, out, height_pt)
|
||||
finally:
|
||||
if vector:
|
||||
first.close()
|
||||
finally:
|
||||
for source in sources:
|
||||
source.close()
|
||||
return {'film_width_cm': str(production['film_width_cm']),
|
||||
'height_cm': str(production['height_cm']),
|
||||
'billed_metres': str(item['billed_metres']),
|
||||
'placements': len(production['placements']),
|
||||
'sources': len(sources),
|
||||
'vector_sources': len(vector),
|
||||
'min_dpi': round(lowest_dpi) if lowest_dpi else None,
|
||||
'user_unit': unit}
|
||||
|
||||
|
||||
def write_rasters(production, sources, out, title, width_pt, height_pt, unit):
|
||||
pdf = PdfWriter(out)
|
||||
images = {index: source.embed(pdf) for index, source in enumerate(sources)
|
||||
if isinstance(source, SourceImage)}
|
||||
# The user-space scale is not wrapped in q/Q, so it also applies to the
|
||||
# vector placements appended by the second pass.
|
||||
commands = [b'%s 0 0 %s 0 0 cm\n' % (serialize(1 / unit), serialize(1 / unit))] if unit > 1 else []
|
||||
for placement in production['placements']:
|
||||
if placement['source_index'] not in images:
|
||||
continue
|
||||
matrix = placement_matrix(placement, height_pt)
|
||||
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
|
||||
b' cm /Im%d Do Q\n' % placement['source_index'])
|
||||
content = pdf.stream({'Filter': Name('FlateDecode')}, deflate(commands))
|
||||
pages = pdf.alloc()
|
||||
page_box = [0, 0, width_pt / unit, height_pt / unit]
|
||||
page = {'Type': Name('Page'), 'Parent': pages, 'MediaBox': page_box, 'TrimBox': page_box,
|
||||
'Resources': {'XObject': {f'Im{index}': ref for index, ref in images.items()}},
|
||||
'Contents': content}
|
||||
if unit > 1:
|
||||
page['UserUnit'] = unit
|
||||
page_ref = pdf.obj(page)
|
||||
pdf.obj({'Type': Name('Pages'), 'Kids': [page_ref], 'Count': 1}, pages)
|
||||
root = pdf.obj({'Type': Name('Catalog'), 'Pages': pages})
|
||||
info = pdf.obj({'Title': title, 'Producer': 'DTF System print-file generator'})
|
||||
pdf.finish(root, info)
|
||||
|
||||
|
||||
def add_vector_pages(production, sources, first, out, height_pt):
|
||||
import pikepdf
|
||||
with pikepdf.open(first) as document:
|
||||
page = document.pages[0]
|
||||
xobjects = page.obj.Resources.XObject
|
||||
for index, source in enumerate(sources):
|
||||
if isinstance(source, PdfPage):
|
||||
xobjects[f'/Pdf{index}'] = source.form(document)
|
||||
commands = []
|
||||
for placement in production['placements']:
|
||||
source = sources[placement['source_index']]
|
||||
if not isinstance(source, PdfPage):
|
||||
continue
|
||||
matrix = multiply(source.normalise(), placement_matrix(placement, height_pt))
|
||||
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
|
||||
b' cm /Pdf%d Do Q\n' % placement['source_index'])
|
||||
page.contents_add(pikepdf.Stream(document, b''.join(commands)), prepend=False)
|
||||
document.save(out, min_version='1.6')
|
||||
139
app/printjobs.py
Normal file
139
app/printjobs.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Generating print files in the background, one order item at a time.
|
||||
|
||||
A paid order queues one job per item. The worker claims a job, commits the
|
||||
claim, and renders outside any transaction, so a long render never holds a row
|
||||
lock or a database connection. A claim older than CLAIM_TIMEOUT is assumed to
|
||||
belong to a worker that died and is taken again.
|
||||
|
||||
The result is an ordinary upload row owned by an identity derived from the
|
||||
order, already marked clean: its only inputs are artwork that passed the
|
||||
malware scan, and the bytes are written here. The operator still decides
|
||||
whether it becomes the final file; generation never approves anything.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .core.auth import audit
|
||||
from .core.db import connect
|
||||
from .printfile import Unsupported, render
|
||||
|
||||
CLAIM_TIMEOUT = timedelta(minutes=15)
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def generated_identity(order_id):
|
||||
return uuid5(NAMESPACE_URL, f'dtf-print-file:{order_id}')
|
||||
|
||||
|
||||
def queue(c, order_id, items, only_missing=False):
|
||||
"""Queue (or re-queue) generation for every item of an order."""
|
||||
for index in range(items):
|
||||
if only_missing:
|
||||
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
|
||||
ON CONFLICT(order_id,item_index) DO UPDATE SET status='pending', attempts=0,
|
||||
claimed_at=NULL, finished_at=NULL, detail='{}'
|
||||
WHERE dtf_local.print_files.status IN ('failed','manual')''',
|
||||
(uuid4(), order_id, index))
|
||||
else:
|
||||
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
|
||||
ON CONFLICT(order_id,item_index) DO NOTHING''', (uuid4(), order_id, index))
|
||||
|
||||
|
||||
def claim(c):
|
||||
return c.execute('''SELECT p.*, o.snapshot, o.number FROM dtf_local.print_files p
|
||||
JOIN dtf_local.orders o ON o.id=p.order_id
|
||||
WHERE p.status='pending' OR (p.status='rendering' AND p.claimed_at < now()-%s)
|
||||
ORDER BY p.created_at FOR UPDATE OF p SKIP LOCKED LIMIT 1''', (CLAIM_TIMEOUT,)).fetchone()
|
||||
|
||||
|
||||
def render_one(storage):
|
||||
with connect() as c:
|
||||
job = claim(c)
|
||||
if not job:
|
||||
return False
|
||||
c.execute('''UPDATE dtf_local.print_files SET status='rendering', claimed_at=now(),
|
||||
attempts=attempts+1 WHERE id=%s''', (job['id'],))
|
||||
item = job['snapshot']['items'][job['item_index']]
|
||||
uploads = c.execute('''SELECT id,name,object_key,scan_state,purged_at,expires_at,
|
||||
(expires_at<=now()) AS expired FROM dtf_local.uploads WHERE id=ANY(%s)''',
|
||||
([UUID(u) for u in item['uploads']],)).fetchall()
|
||||
# Every generated file shares its order's artwork retention deadline.
|
||||
expiry = c.execute('SELECT min(created_at)+interval \'30 days\' AS e FROM dtf_local.uploads WHERE id=ANY(%s)',
|
||||
([UUID(u) for u in item['uploads']],)).fetchone()['e']
|
||||
by_id = {str(row['id']): row for row in uploads}
|
||||
try:
|
||||
result = produce(storage, job, item, by_id)
|
||||
except Unsupported as reason:
|
||||
finish(job, 'manual', {'reason': str(reason)})
|
||||
audit('print_file_manual', order=str(job['order_id']), item=job['item_index'])
|
||||
return True
|
||||
except Exception as exc:
|
||||
logging.exception('Print file generation failed')
|
||||
final = job['attempts'] + 1 >= MAX_ATTEMPTS
|
||||
finish(job, 'failed' if final else 'pending', {'error': type(exc).__name__})
|
||||
return True
|
||||
path, name, size, detail = result
|
||||
try:
|
||||
uid = uuid4()
|
||||
key = f'originals/{uid}'
|
||||
storage.store(key, path, 'application/pdf')
|
||||
finally:
|
||||
os.unlink(path)
|
||||
with connect() as c:
|
||||
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,complete,
|
||||
expires_at,scan_state,scan_reason,scanned_at)
|
||||
VALUES(%s,%s,%s,%s,%s,'',true,%s,'clean','generated from scanned artwork',now())''',
|
||||
(uid, generated_identity(job['order_id']), name, size, key, expiry))
|
||||
c.execute('''UPDATE dtf_local.print_files SET status='ready', upload_id=%s, detail=%s,
|
||||
finished_at=now() WHERE id=%s''', (uid, Jsonb(detail), job['id']))
|
||||
audit('print_file_ready', order=str(job['order_id']), item=job['item_index'])
|
||||
return True
|
||||
|
||||
|
||||
def produce(storage, job, item, uploads):
|
||||
"""Fetch the item's artwork and render it. Returns (pdf path, name, size, evidence)."""
|
||||
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')
|
||||
if row['purged_at'] or row['expired']:
|
||||
raise Unsupported('an original file has passed its retention period')
|
||||
number = job['number']
|
||||
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
|
||||
with tempfile.TemporaryDirectory(prefix='print-') as scratch:
|
||||
files = {}
|
||||
for index, upload_id in enumerate(item['uploads']):
|
||||
local = os.path.join(scratch, f'source-{index}')
|
||||
storage.fetch(uploads[upload_id]['object_key'], local)
|
||||
files[index] = (local, uploads[upload_id]['name'])
|
||||
handle, output = tempfile.mkstemp(prefix='print-', suffix='.pdf')
|
||||
try:
|
||||
with os.fdopen(handle, 'wb') as out:
|
||||
detail = render(item, files, out, f'Pedido {number} - item {job["item_index"] + 1}')
|
||||
except BaseException:
|
||||
os.unlink(output)
|
||||
raise
|
||||
return output, name, os.path.getsize(output), detail
|
||||
|
||||
|
||||
def finish(job, status, detail):
|
||||
with connect() as c:
|
||||
c.execute('''UPDATE dtf_local.print_files SET status=%s, detail=%s,
|
||||
finished_at=CASE WHEN %s='pending' THEN NULL ELSE now() END,
|
||||
claimed_at=NULL WHERE id=%s''', (status, Jsonb(detail), status, job['id']))
|
||||
|
||||
|
||||
def render_loop(storage):
|
||||
while True:
|
||||
try:
|
||||
if render_one(storage):
|
||||
continue
|
||||
except Exception:
|
||||
logging.exception('Print render worker tick failed')
|
||||
time.sleep(2)
|
||||
@@ -20,7 +20,11 @@ load_secret_files()
|
||||
require_runtime()
|
||||
|
||||
storage = LocalS3Storage()
|
||||
payment = FakePayment()
|
||||
if os.environ.get('PAYMENT_ADAPTER') == 'mercadopago':
|
||||
from .mercadopago import MercadoPagoPayment
|
||||
payment = MercadoPagoPayment()
|
||||
else:
|
||||
payment = FakePayment()
|
||||
freight = FakeFreight()
|
||||
ENVIRONMENT = os.environ.get('APP_ENV', 'local')
|
||||
PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost')
|
||||
|
||||
@@ -90,6 +90,42 @@ CREATE TABLE IF NOT EXISTS dtf_local.order_files (
|
||||
note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(order_id,upload_id,kind)
|
||||
);
|
||||
-- A payment started at the provider for an approved quote: which provider
|
||||
-- payment belongs to which quote, and its latest known status.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.payment_intents (
|
||||
id uuid PRIMARY KEY, quote_id uuid NOT NULL REFERENCES dtf_local.quotes(id),
|
||||
provider text NOT NULL, provider_payment_id text NOT NULL, method text NOT NULL,
|
||||
status text NOT NULL, amount_cents bigint NOT NULL, response jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(provider, provider_payment_id)
|
||||
);
|
||||
-- OAuth connections to providers (Tiny). One row per provider; the refresh
|
||||
-- token rotates on use, so it lives here, never in configuration.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
|
||||
provider text PRIMARY KEY, access_token text NOT NULL, refresh_token text NOT NULL,
|
||||
access_expires_at timestamptz NOT NULL, refresh_expires_at timestamptz,
|
||||
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Single-use states for an operator-started OAuth connection. They protect the
|
||||
-- callback, which arrives cross-site without the operator's cookie.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
|
||||
state text PRIMARY KEY, provider text NOT NULL, operator text NOT NULL,
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
-- The print file generated from each paid item's approved layout. One row per
|
||||
-- item: the worker claims it, renders, and records either the file or why the
|
||||
-- item has to be prepared by hand. Regenerating replaces the row's result.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.print_files (
|
||||
id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
|
||||
item_index integer NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending','rendering','ready','manual','failed')),
|
||||
upload_id uuid REFERENCES dtf_local.uploads(id), detail jsonb NOT NULL DEFAULT '{}',
|
||||
attempts integer NOT NULL DEFAULT 0, claimed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||
UNIQUE(order_id,item_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
|
||||
|
||||
@@ -131,3 +167,19 @@ CREATE INDEX IF NOT EXISTS security_events_created ON dtf_local.security_events(
|
||||
-- its own index, and it stays the size of the backlog.
|
||||
CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_events(received_at)
|
||||
WHERE processed_at IS NULL;
|
||||
|
||||
-- The render worker polls for unclaimed or abandoned jobs; the order view reads
|
||||
-- by order through the unique constraint.
|
||||
CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at)
|
||||
WHERE status IN ('pending','rendering');
|
||||
|
||||
-- The Kanban lists payment events a person must act on (money without an
|
||||
-- order, or a reversed payment on an existing order) until resolved.
|
||||
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz;
|
||||
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_by text;
|
||||
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolution text;
|
||||
CREATE INDEX IF NOT EXISTS payment_events_open_issues ON dtf_local.payment_events(received_at)
|
||||
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL;
|
||||
|
||||
-- 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);
|
||||
|
||||
276
app/tiny.py
Normal file
276
app/tiny.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Tiny/Olist ERP (API v3): create the sales order once a payment is approved.
|
||||
|
||||
Written from the public API documentation and exercised only against a fake
|
||||
HTTP transport. Tiny has no sandbox: the first real test creates a real order
|
||||
in the client's ERP, so test with a marked order and cancel it afterwards.
|
||||
|
||||
Authentication is OAuth2 on Tiny's Keycloak. The client creates an
|
||||
"Aplicativo" in Tiny (Configurações > Geral > Aplicativos), which yields a
|
||||
client ID and secret and registers our callback URL. An operator then clicks
|
||||
"Conectar Tiny" on the Kanban once; the tokens are kept in the database and
|
||||
the refresh token is rotated on every refresh, under a row lock so two
|
||||
workers never spend the same one.
|
||||
|
||||
Orders are created by contact and product id: the customer's contact is found
|
||||
by CNPJ or created, and each product mode maps to a product that must already
|
||||
exist in Tiny (PRODUCT_SETTINGS).
|
||||
|
||||
Idempotency: the outbox may deliver the same event more than once. Every order
|
||||
carries numeroOrdemCompra = "DTF-<order number>", and before creating one the
|
||||
customer's recent orders are searched for that number, so a second delivery
|
||||
finds the first order instead of creating another.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
API = 'https://api.tiny.com.br/public-api/v3'
|
||||
AUTH = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect'
|
||||
# Not TINY_PRODUCT_<MODE>: app/core/secrets.py reads any variable ending in
|
||||
# _FILE as a path to a secret file, and one product mode is called "file".
|
||||
PRODUCT_SETTINGS = {'file': 'TINY_PRODUCT_TEXTIL_FOLHA', 'avulsa': 'TINY_PRODUCT_TEXTIL_AVULSA',
|
||||
'uvfile': 'TINY_PRODUCT_UV_FOLHA', 'uv': 'TINY_PRODUCT_UV_AVULSA'}
|
||||
PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
|
||||
'avulsa': 'DTF Têxtil 57 cm · artes avulsas',
|
||||
'uvfile': 'DTF UV 28,5 cm · folha montada',
|
||||
'uv': 'DTF UV 28,5 cm · artes avulsas'}
|
||||
# How far back to look for an order a previous delivery may already have made.
|
||||
SEARCH_DAYS = 7
|
||||
STATE_MINUTES = 10
|
||||
# Brazil has had no daylight saving since 2019; the order date is the local day.
|
||||
BRASILIA = timezone(timedelta(hours=-3))
|
||||
|
||||
|
||||
def local_today():
|
||||
return datetime.now(BRASILIA).date()
|
||||
|
||||
|
||||
class TinyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TinyNotConnected(TinyError):
|
||||
"""No authorised connection yet: an operator must click "Conectar Tiny"."""
|
||||
|
||||
|
||||
def purchase_order(number):
|
||||
return f'DTF-{number}'
|
||||
|
||||
|
||||
def configured():
|
||||
"""Whether the OAuth application is configured (orders may still be fake)."""
|
||||
return all(os.environ.get(name) for name in ('TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'))
|
||||
|
||||
|
||||
def required_settings():
|
||||
return ['TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'] + list(PRODUCT_SETTINGS.values())
|
||||
|
||||
|
||||
# OAuth -------------------------------------------------------------------
|
||||
|
||||
class TinyAuth:
|
||||
"""The OAuth application and the stored connection."""
|
||||
|
||||
def __init__(self, connect=None, transport=None, clock=time.time):
|
||||
self.client_id = os.environ.get('TINY_CLIENT_ID', '')
|
||||
self.client_secret = os.environ.get('TINY_CLIENT_SECRET', '')
|
||||
self.redirect_uri = os.environ.get('TINY_REDIRECT_URI', '')
|
||||
if connect is None:
|
||||
from .core.db import connect
|
||||
self.connect = connect
|
||||
self.http = httpx.Client(timeout=20, transport=transport)
|
||||
self.clock = clock
|
||||
|
||||
def authorize_url(self, operator):
|
||||
"""Start a connection. The state is single-use, short-lived, and only an
|
||||
authenticated operator can create one, which is what protects the
|
||||
callback: Tiny's redirect back is cross-site, so the operator's
|
||||
SameSite=Strict cookie does not travel with it."""
|
||||
state = secrets.token_urlsafe(32)
|
||||
with self.connect() as c:
|
||||
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
|
||||
c.execute('''INSERT INTO dtf_local.oauth_states(state,provider,operator,expires_at)
|
||||
VALUES(%s,'tiny',%s,now()+%s)''', (state, operator, timedelta(minutes=STATE_MINUTES)))
|
||||
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri, 'scope': 'openid',
|
||||
'state': state})
|
||||
|
||||
def complete(self, code, state):
|
||||
"""Exchange the authorisation code; returns the operator who started it."""
|
||||
with self.connect() as c:
|
||||
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
|
||||
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
|
||||
if not row:
|
||||
raise TinyError('Unknown or expired authorisation state')
|
||||
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
|
||||
'redirect_uri': self.redirect_uri})
|
||||
self._store(c, tokens, row['operator'])
|
||||
return row['operator']
|
||||
|
||||
def status(self):
|
||||
with self.connect() as c:
|
||||
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at
|
||||
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
|
||||
if not row:
|
||||
return {'connected': False}
|
||||
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc)
|
||||
return {'connected': not expired, 'connected_by': row['connected_by'],
|
||||
'connected_at': row['connected_at'], 'expires_at': row['refresh_expires_at']}
|
||||
|
||||
def access_token(self):
|
||||
"""A valid access token, refreshing (and rotating) under a row lock."""
|
||||
with self.connect() as c:
|
||||
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
|
||||
FOR UPDATE''').fetchone()
|
||||
if not row:
|
||||
raise TinyNotConnected('Tiny is not connected; use "Conectar Tiny" on the Kanban')
|
||||
now = datetime.now(timezone.utc)
|
||||
if row['access_expires_at'] > now + timedelta(seconds=60):
|
||||
return row['access_token']
|
||||
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
|
||||
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
|
||||
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
|
||||
self._store(c, tokens, row['connected_by'], refreshed=True)
|
||||
return tokens['access_token']
|
||||
|
||||
def _token(self, form):
|
||||
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
|
||||
'client_secret': self.client_secret})
|
||||
if response.status_code == 400 and form['grant_type'] == 'refresh_token':
|
||||
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
|
||||
response.raise_for_status()
|
||||
tokens = response.json()
|
||||
if not tokens.get('access_token') or not tokens.get('refresh_token'):
|
||||
raise TinyError('Tiny token response is missing tokens')
|
||||
return tokens
|
||||
|
||||
def _store(self, c, tokens, operator, refreshed=False):
|
||||
now = datetime.now(timezone.utc)
|
||||
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
|
||||
refresh_in = tokens.get('refresh_expires_in')
|
||||
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
|
||||
c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token,
|
||||
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at)
|
||||
VALUES('tiny',%s,%s,%s,%s,%s,now(),now())
|
||||
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
|
||||
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
|
||||
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(),
|
||||
connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END,
|
||||
connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''',
|
||||
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
|
||||
operator, refreshed, refreshed))
|
||||
|
||||
|
||||
# Orders ------------------------------------------------------------------
|
||||
|
||||
def money(cents):
|
||||
return float(Decimal(cents) / 100)
|
||||
|
||||
|
||||
def contact_payload(order):
|
||||
customer = order['customer']
|
||||
destination = order.get('destination')
|
||||
contact = {'nome': (destination or {}).get('recipient') or customer['mail'],
|
||||
'tipoPessoa': 'J', 'cpfCnpj': customer['cnpj'], 'email': customer['mail'],
|
||||
'celular': customer['zap'], 'situacao': 'A'}
|
||||
if destination:
|
||||
contact['endereco'] = address(destination)
|
||||
return contact
|
||||
|
||||
|
||||
def address(destination):
|
||||
return {'endereco': destination['street'], 'numero': destination['number'],
|
||||
'complemento': destination.get('complement', ''), 'bairro': destination['district'],
|
||||
'municipio': destination['city'], 'cep': destination['postal_code'],
|
||||
'uf': destination['state'], 'pais': 'Brasil'}
|
||||
|
||||
|
||||
def order_payload(payload, contact_id, today=None):
|
||||
"""The v3 'pedido' for a paid order's approved snapshot."""
|
||||
order = payload['order']
|
||||
items = []
|
||||
for item in order['items']:
|
||||
setting = PRODUCT_SETTINGS[item['mode']]
|
||||
product = os.environ.get(setting, '')
|
||||
if not product.isdigit():
|
||||
raise TinyError(f'{setting} must be the Tiny product id')
|
||||
items.append({'produto': {'id': int(product)},
|
||||
'quantidade': float(Decimal(item['billed_metres'])),
|
||||
'valorUnitario': money(item['unit_cents']),
|
||||
'infoAdicional': PRODUCTS[item['mode']] + f" · nota {item['grade']}"})
|
||||
freight = order['freight']
|
||||
pickup = freight.get('service') == 'pickup'
|
||||
pedido = {'data': (today or local_today()).isoformat(),
|
||||
'idContato': contact_id,
|
||||
'numeroOrdemCompra': purchase_order(payload['number']),
|
||||
'itens': items,
|
||||
'valorFrete': money(freight['total_cents']),
|
||||
'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''),
|
||||
'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"}
|
||||
destination = order.get('destination')
|
||||
if destination:
|
||||
pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'],
|
||||
'nomeDestinatario': destination['recipient']}
|
||||
del pedido['enderecoEntrega']['numero']
|
||||
ecommerce = os.environ.get('TINY_ECOMMERCE_ID', '')
|
||||
if ecommerce.isdigit():
|
||||
pedido['ecommerce'] = {'id': int(ecommerce), 'numeroPedidoEcommerce': purchase_order(payload['number'])}
|
||||
return pedido
|
||||
|
||||
|
||||
class TinyOrders:
|
||||
def __init__(self, auth=None, transport=None, today=None):
|
||||
missing = [name for name in required_settings() if not os.environ.get(name)]
|
||||
if auth is None and missing:
|
||||
raise RuntimeError('Tiny needs ' + ', '.join(missing))
|
||||
self.auth = auth or TinyAuth()
|
||||
self.http = httpx.Client(base_url=API, transport=transport, timeout=30)
|
||||
self.today = today
|
||||
|
||||
def request(self, method, path, **kwargs):
|
||||
headers = {'Authorization': f'Bearer {self.auth.access_token()}'}
|
||||
response = self.http.request(method, path, headers=headers, **kwargs)
|
||||
if response.status_code == 429:
|
||||
raise TinyError('Tiny rate limit reached; the outbox will retry')
|
||||
if response.status_code >= 400:
|
||||
raise TinyError(f'{method} {path}: {response.status_code} {response.text[:300]}')
|
||||
return response.json() if response.content else {}
|
||||
|
||||
def contact(self, order):
|
||||
cnpj = order['customer']['cnpj']
|
||||
found = self.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5})
|
||||
for entry in found.get('itens') or []:
|
||||
if ''.join(ch for ch in str(entry.get('cpfCnpj') or '') if ch.isdigit()) == cnpj:
|
||||
return entry['id']
|
||||
return self.request('POST', '/contatos', json=contact_payload(order))['id']
|
||||
|
||||
def find(self, payload):
|
||||
"""An order a previous delivery already created, or None."""
|
||||
wanted = purchase_order(payload['number'])
|
||||
since = ((self.today or local_today()) - timedelta(days=SEARCH_DAYS)).isoformat()
|
||||
found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'],
|
||||
'dataInicial': since, 'limit': 100})
|
||||
for entry in found.get('itens') or []:
|
||||
detail = self.request('GET', f"/pedidos/{entry['id']}")
|
||||
if detail.get('numeroOrdemCompra') == wanted:
|
||||
return detail
|
||||
return None
|
||||
|
||||
def deliver(self, event_key, payload):
|
||||
if payload.get('event') != 'payment_approved':
|
||||
# Production progress is not written to Tiny; only the sale is.
|
||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable',
|
||||
'event': payload.get('event')}
|
||||
existing = self.find(payload)
|
||||
if existing:
|
||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
|
||||
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
|
||||
contact_id = self.contact(payload['order'])
|
||||
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today))
|
||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
|
||||
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Transactional outbox worker. Fake receipts persist; no messages leave the stack."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
@@ -8,15 +9,22 @@ from psycopg.types.json import Jsonb
|
||||
from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_runtime
|
||||
from .core.secrets import load as load_secret_files
|
||||
from .core.db import connect
|
||||
from .printjobs import render_loop
|
||||
from .scanning import ClamAV, scan_loop
|
||||
|
||||
load_secret_files()
|
||||
require_runtime()
|
||||
adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}
|
||||
# Not yet confirmed against the client's account; see app/tiny.py.
|
||||
if os.environ.get('TINY_ADAPTER') == 'tiny':
|
||||
from .tiny import TinyOrders
|
||||
adapters['tiny'] = TinyOrders()
|
||||
last_tick = 0.0
|
||||
last_cleanup = 0.0
|
||||
last_tiny_keepalive = 0.0
|
||||
storage = LocalS3Storage()
|
||||
scan_thread = None
|
||||
render_thread = None
|
||||
|
||||
def cleanup():
|
||||
"""Delete only expired object bytes; retain order/file metadata and history."""
|
||||
@@ -45,6 +53,27 @@ def tick():
|
||||
c.execute("UPDATE dtf_local.outbox SET attempts=attempts+1, last_error=%s, available_at=now() + %s * interval '1 second' WHERE id=%s", (str(exc),delay,job['id']))
|
||||
last_tick = time.monotonic()
|
||||
|
||||
def tiny_keepalive():
|
||||
"""Keep a connected Tiny authorised even while no orders are sent.
|
||||
|
||||
The refresh token expires unless it is used; access_token() refreshes (and
|
||||
rotates) only when the access token is about to expire, so asking every few
|
||||
minutes renews the connection roughly once per access-token lifetime.
|
||||
"""
|
||||
global last_tiny_keepalive
|
||||
if time.monotonic()-last_tiny_keepalive < 600:
|
||||
return
|
||||
last_tiny_keepalive = time.monotonic()
|
||||
from . import tiny
|
||||
if not tiny.configured():
|
||||
return
|
||||
try:
|
||||
tiny.TinyAuth().access_token()
|
||||
except tiny.TinyNotConnected:
|
||||
pass
|
||||
except Exception:
|
||||
logging.exception('Tiny connection refresh failed')
|
||||
|
||||
def loop():
|
||||
global last_cleanup
|
||||
while True:
|
||||
@@ -53,6 +82,7 @@ def loop():
|
||||
if time.monotonic()-last_cleanup > 60:
|
||||
cleanup()
|
||||
last_cleanup = time.monotonic()
|
||||
tiny_keepalive()
|
||||
except Exception:
|
||||
logging.exception('Local worker tick failed')
|
||||
time.sleep(1)
|
||||
@@ -64,16 +94,20 @@ class Health(BaseHTTPRequestHandler):
|
||||
scanner = bool(scan_thread and scan_thread.is_alive() and ClamAV().ping())
|
||||
except Exception:
|
||||
pass
|
||||
healthy = time.monotonic()-last_tick < 15 and scanner
|
||||
renderer = bool(render_thread and render_thread.is_alive())
|
||||
healthy = time.monotonic()-last_tick < 15 and scanner and renderer
|
||||
self.send_response(200 if self.path == '/health' and healthy else 503)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'worker': 'ok' if healthy else 'unavailable',
|
||||
'scanner': 'ok' if scanner else 'unavailable'}).encode())
|
||||
'scanner': 'ok' if scanner else 'unavailable',
|
||||
'print_files': 'ok' if renderer else 'unavailable'}).encode())
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
scan_thread = threading.Thread(target=scan_loop,args=(storage,),daemon=True)
|
||||
scan_thread.start()
|
||||
render_thread = threading.Thread(target=render_loop,args=(storage,),daemon=True)
|
||||
render_thread.start()
|
||||
threading.Thread(target=loop, daemon=True).start()
|
||||
HTTPServer(('0.0.0.0',8002),Health).serve_forever()
|
||||
|
||||
@@ -27,10 +27,25 @@ x-app: &app
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081}}
|
||||
COOKIE_SECURE: "false"
|
||||
PAYMENT_ADAPTER: fake
|
||||
# Sandbox testing only: set PAYMENT_ADAPTER=mercadopago with the MP_* test
|
||||
# credentials, or TINY_ADAPTER=tiny with a TINY_TOKEN, in .env. Never real
|
||||
# production credentials on a developer machine.
|
||||
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
|
||||
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-local-webhook-secret}
|
||||
MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
|
||||
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
|
||||
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
|
||||
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
|
||||
FREIGHT_ADAPTER: fake
|
||||
TINY_ADAPTER: fake
|
||||
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
|
||||
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
|
||||
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
|
||||
TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-}
|
||||
TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-}
|
||||
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
|
||||
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
|
||||
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
|
||||
TINY_ECOMMERCE_ID: ${TINY_ECOMMERCE_ID:-}
|
||||
WHATSAPP_ADAPTER: fake
|
||||
STORAGE_ADAPTER: s3-local
|
||||
MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500}
|
||||
@@ -66,11 +81,14 @@ services:
|
||||
retries: 30
|
||||
|
||||
storage:
|
||||
# quay.io, not Docker Hub: minio/minio there now answers anonymous pulls
|
||||
# with 401 authentication required, which breaks any runner that is not
|
||||
# logged in. Same image — identical image ID. Override MINIO_IMAGE to use
|
||||
# a mirror of your own.
|
||||
image: ${MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z}
|
||||
# MinIO stopped publishing public images: since September 2026 both
|
||||
# Docker Hub (minio/minio) and quay.io answer anonymous pulls with 401,
|
||||
# which breaks any machine or runner without a cached copy. Chainguard's
|
||||
# build still pulls anonymously, ships sh and mc (the healthcheck and
|
||||
# storage-init need both) and runs as a non-root user. Pinned by digest
|
||||
# because Chainguard's free tier only publishes :latest. Override
|
||||
# MINIO_IMAGE to use a mirror of your own.
|
||||
image: ${MINIO_IMAGE:-cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1}
|
||||
command: server /data --console-address :9001
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
|
||||
@@ -112,7 +130,7 @@ services:
|
||||
context: .
|
||||
dockerfile: infra/Dockerfile.storage-init
|
||||
args:
|
||||
MINIO_IMAGE: ${MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z}
|
||||
MINIO_IMAGE: ${MINIO_IMAGE:-cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1}
|
||||
entrypoint: [/bin/sh, /init.sh]
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
|
||||
@@ -175,6 +193,8 @@ services:
|
||||
dockerfile: infra/Dockerfile.web
|
||||
environment:
|
||||
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
|
||||
# Empty unless testing Mercado Pago's card form; see docs/LOCAL_SETUP.md.
|
||||
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
|
||||
ports:
|
||||
# Published ports are host-wide even bound to loopback, so on a shared
|
||||
# machine any of them can collide with something unrelated. CI overrides
|
||||
@@ -198,6 +218,7 @@ services:
|
||||
environment:
|
||||
WEB_INDEX: kanban.html
|
||||
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
|
||||
PAYMENT_CSP_SOURCES: ""
|
||||
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
|
||||
networks: [local, edge]
|
||||
depends_on:
|
||||
|
||||
16
compose.providers.yaml
Normal file
16
compose.providers.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Provider sandbox testing only: gives the API and worker a route to the
|
||||
# internet so they can reach Mercado Pago and Tiny. The default local stack
|
||||
# keeps them on an internal network with no external route, which is what
|
||||
# every other local run should keep doing.
|
||||
#
|
||||
# docker compose -f compose.local.yaml -f compose.providers.yaml up -d --wait
|
||||
#
|
||||
# Credentials go in .env (see .env.example); never production credentials.
|
||||
services:
|
||||
api:
|
||||
networks: [local, provider-egress]
|
||||
worker:
|
||||
networks: [local, provider-egress]
|
||||
|
||||
networks:
|
||||
provider-egress: {}
|
||||
@@ -27,7 +27,7 @@ server {
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
|
||||
|
||||
location = /health { access_log off; return 200 'ok'; }
|
||||
location /api/ {
|
||||
|
||||
@@ -29,6 +29,14 @@ OPERATOR_EMAIL=TBD
|
||||
PAYMENT_ADAPTER=TBD
|
||||
FREIGHT_ADAPTER=TBD
|
||||
TINY_ADAPTER=TBD
|
||||
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The
|
||||
# redirect URL registered there must equal TINY_REDIRECT_URI.
|
||||
TINY_CLIENT_ID=TBD
|
||||
TINY_REDIRECT_URI=https://<KANBAN_DOMAIN>/api/operator/tiny/callback
|
||||
TINY_PRODUCT_TEXTIL_FOLHA=TBD
|
||||
TINY_PRODUCT_TEXTIL_AVULSA=TBD
|
||||
TINY_PRODUCT_UV_FOLHA=TBD
|
||||
TINY_PRODUCT_UV_AVULSA=TBD
|
||||
WHATSAPP_ADAPTER=TBD
|
||||
STORAGE_QUOTA_BYTES=TBD
|
||||
OWNER_UPLOAD_QUOTA_BYTES=TBD
|
||||
|
||||
@@ -5,21 +5,24 @@ from .production_preflight import config_errors, source_errors
|
||||
|
||||
|
||||
class ReleaseWorkflowTests(unittest.TestCase):
|
||||
def test_main_push_cannot_publish_and_manual_release_is_gated(self):
|
||||
def test_only_checked_main_publishes_and_pushes_never_deploy(self):
|
||||
workflow = (Path(__file__).resolve().parents[1] /
|
||||
'.gitea/workflows/deploy.yml').read_text()
|
||||
release = workflow.split(' publish-and-deploy:\n', 1)[1]
|
||||
self.assertIn("if: gitea.event_name == 'workflow_dispatch' && "
|
||||
"gitea.ref == 'refs/heads/main'", release)
|
||||
preflight = release.index('python3 deploy/production_preflight.py --source-only')
|
||||
webhook = release.index('test -n "$PORTAINER_WEBHOOK"')
|
||||
checks, release = workflow.split(' publish-and-deploy:\n', 1)
|
||||
# Publishing waits for every check, and only ever happens from main.
|
||||
self.assertIn('needs: [validate, integration, scan]', release)
|
||||
self.assertIn("if: gitea.ref == 'refs/heads/main' && ", release)
|
||||
# The source preflight lives in the scan job and can be made blocking.
|
||||
self.assertIn('python3 deploy/production_preflight.py --source-only', checks)
|
||||
self.assertIn('ENFORCE_PRODUCTION_PREFLIGHT', checks)
|
||||
# Images are scanned before they are pushed, and a push to main only
|
||||
# publishes: redeployment is Portainer's (or a manual run's) decision.
|
||||
scan = release.index('- name: Image vulnerabilities')
|
||||
publish = release.index('- name: Publish validated images')
|
||||
redeploy = release.index('- name: Trigger Portainer redeployment')
|
||||
self.assertLess(preflight, scan)
|
||||
self.assertLess(webhook, scan)
|
||||
self.assertLess(scan, publish)
|
||||
self.assertLess(publish, redeploy)
|
||||
self.assertIn("if: gitea.event_name == 'workflow_dispatch'", release[redeploy:])
|
||||
|
||||
|
||||
def valid_config():
|
||||
|
||||
@@ -25,7 +25,18 @@ x-app-environment: &app-environment
|
||||
# when the provider is configured, never to a value anyone could guess.
|
||||
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
|
||||
FREIGHT_ADAPTER: fake
|
||||
# Order creation in Tiny stays off until it has been tested against the
|
||||
# client's account (Tiny has no sandbox). The application credentials can be
|
||||
# set now: they let an operator connect Tiny from the Kanban, and the worker
|
||||
# keeps that connection alive. Callback: https://<KANBAN_DOMAIN>/api/operator/tiny/callback
|
||||
TINY_ADAPTER: fake
|
||||
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
|
||||
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
|
||||
TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-}
|
||||
TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-}
|
||||
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
|
||||
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
|
||||
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
|
||||
WHATSAPP_ADAPTER: fake
|
||||
STORAGE_ADAPTER: s3-r2
|
||||
PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN}
|
||||
@@ -139,6 +150,9 @@ services:
|
||||
WEB_INDEX: index.html
|
||||
PUBLIC_HOST: ${SITE_DOMAIN:?set SITE_DOMAIN}
|
||||
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
|
||||
# Mercado Pago's card form loads from these origins; empty keeps the
|
||||
# Site at script-src 'self'. Set together with the Mercado Pago adapter.
|
||||
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
|
||||
networks: [backend]
|
||||
ports:
|
||||
- target: 8080
|
||||
@@ -161,6 +175,7 @@ services:
|
||||
WEB_INDEX: kanban.html
|
||||
PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
|
||||
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
|
||||
PAYMENT_CSP_SOURCES: ""
|
||||
networks: [backend]
|
||||
ports:
|
||||
- target: 8080
|
||||
|
||||
@@ -72,7 +72,9 @@ operator interfaces.
|
||||
the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**,
|
||||
select the manually prepared final files for every item, enter a review note,
|
||||
tick the confirmation and click **Aprovar arquivos finais**. Use the harmless
|
||||
fixture again only for this local test; no printable file is generated.
|
||||
fixture again only for this local test. The text fixture is not an image, so
|
||||
its print file shows **preparar à mão**; with a PNG or JPEG artwork the
|
||||
generated PDF is preselected instead (see "Print files").
|
||||
Continue through **Fila de impressão →
|
||||
Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can
|
||||
return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally.
|
||||
@@ -193,8 +195,10 @@ must resolve from the browser; keep `http://localhost:9000` for this stack.
|
||||
Parts use 15-minute presigned URLs and unfinished reservations expire after
|
||||
one hour. Clients can cancel a reservation through the upload DELETE endpoint.
|
||||
|
||||
Only fake integration adapters and `s3-local` storage are accepted. Startup fails
|
||||
if a production adapter/environment or nonlocal S3 endpoint is selected.
|
||||
Fake integration adapters and `s3-local` storage are the default. Mercado Pago
|
||||
and Tiny can be selected for sandbox testing only (see "Provider sandboxes");
|
||||
startup fails if their credentials are missing, if any other adapter is
|
||||
selected, or if a production environment or nonlocal S3 endpoint is used.
|
||||
The API, worker, and database run on an internal Docker network; web gateways
|
||||
and MinIO also join a network that permits loopback port publishing.
|
||||
|
||||
@@ -216,6 +220,113 @@ storage permits; otherwise reselect them. Saved quote IDs also survive reloads.
|
||||
Clearing cookies loses a guest session, while registered customers can sign in
|
||||
again. The operator can still inspect order records.
|
||||
|
||||
## Print files
|
||||
|
||||
Every paid order queues one print-file job per item. The worker renders a PDF
|
||||
exactly as wide as the film and as long as the approved layout, placing each
|
||||
copy at the position, rotation and mirror the customer reviewed. Each source
|
||||
image is embedded once at its original resolution (JPEG bytes unchanged, PNG
|
||||
transparency kept as a soft mask), so nothing is resampled. The Kanban card
|
||||
shows the status per item, the page size and the lowest DPI, and offers
|
||||
**Baixar PDF**. In **Arquivos de produção** the generated file is preselected
|
||||
as the final file; untick it to upload one by hand instead.
|
||||
|
||||
JPEG, PNG, WebP and TIFF are embedded as images. A single-page PDF is placed
|
||||
as a vector form (never rasterised), using the page's CropBox and `/Rotate`
|
||||
exactly as the Site measured it with pdf.js; the card then shows **PDF
|
||||
vetorial**. PSD, AI and CDR artwork, multi-page or password-protected PDFs, a
|
||||
file whose proportions do not match the quoted size, a layout longer than was
|
||||
billed, or an image above `PRINT_MAX_PIXELS` (250 Mpx by default) goes to
|
||||
**preparar à mão** with the reason, and the operator prepares it as before.
|
||||
**Gerar arquivos de impressão** retries those items and generates files for
|
||||
orders paid before the generator existed. After a customer correction the
|
||||
generated file is no longer offered: it reproduces the replaced artwork.
|
||||
|
||||
Layouts longer than about 5 m use the PDF `UserUnit` page scale instead of
|
||||
being split into pages. Confirm on the factory's FlexiPRINT that such a file
|
||||
imports at full length before relying on it for long orders.
|
||||
|
||||
```bash
|
||||
docker compose -f compose.local.yaml exec -T api python -m unittest tests.test_printfile -v
|
||||
docker compose -f compose.local.yaml exec -T api python -m tests.print_file_test
|
||||
```
|
||||
|
||||
With PyMuPDF installed locally (`pip install pymupdf`), the unit suite also
|
||||
draws each page and checks where every quadrant of the artwork lands.
|
||||
|
||||
## Provider sandboxes
|
||||
|
||||
`app/mercadopago.py` and `app/tiny.py` follow the providers' public API
|
||||
documentation and pass their unit suites against a fake transport. They are not
|
||||
verified integrations until they pass with the client's accounts.
|
||||
|
||||
The default local stack gives the API and worker no route to the internet, so
|
||||
provider testing adds `compose.providers.yaml`, which does:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.local.yaml -f compose.providers.yaml up -d --wait
|
||||
```
|
||||
|
||||
Put only **test** credentials in `.env`:
|
||||
|
||||
```bash
|
||||
PAYMENT_ADAPTER=mercadopago
|
||||
MP_ACCESS_TOKEN=TEST-...
|
||||
MP_WEBHOOK_SECRET=... # "Assinatura secreta" in the webhook settings
|
||||
MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
|
||||
```
|
||||
|
||||
### Tiny (API v3)
|
||||
|
||||
Tiny uses OAuth2. In the client's Tiny (Construa plan or above, with the
|
||||
"Gestão de Aplicativos" extension): **Configurações → Geral → Aplicativos →
|
||||
+ novo aplicativo**, with the redirect URL set to this system's callback. That
|
||||
gives a client ID and secret:
|
||||
|
||||
```bash
|
||||
TINY_CLIENT_ID=...
|
||||
TINY_CLIENT_SECRET=...
|
||||
TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback # exactly as registered in Tiny
|
||||
TINY_PRODUCT_TEXTIL_FOLHA=... # Tiny product id for each Site product
|
||||
TINY_PRODUCT_TEXTIL_AVULSA=...
|
||||
TINY_PRODUCT_UV_FOLHA=...
|
||||
TINY_PRODUCT_UV_AVULSA=...
|
||||
```
|
||||
|
||||
With the client ID and secret set, the Kanban header shows **Conectar Tiny**.
|
||||
Someone with a Tiny login approves access once; the tokens are stored in the
|
||||
database (the refresh token rotates on every use) and the worker keeps the
|
||||
connection alive. Only then set `TINY_ADAPTER=tiny`, which starts creating an
|
||||
order in Tiny for every paid order: find or create the customer's contact by
|
||||
CNPJ, then `POST /pedidos` with `numeroOrdemCompra = DTF-<order number>`. A
|
||||
retry searches the customer's recent orders for that number first, so it does
|
||||
not create a second one. **Tiny has no sandbox**: every test order is real, so
|
||||
agree the test with the client and cancel the test orders afterwards.
|
||||
|
||||
`tests.tiny_oauth_test` checks the connection flow against the real database
|
||||
with a fake token server; it saves and restores any existing connection.
|
||||
|
||||
With Mercado Pago selected, an approved quote shows **Pagar com PIX** on the
|
||||
Site instead of the local test button, and **Pagar com cartão** when
|
||||
`MP_PUBLIC_KEY` is set. The card form is Mercado Pago's Card Payment Brick: the
|
||||
card is typed into Mercado Pago's secure fields and only a one-time token
|
||||
reaches the API. It loads from Mercado Pago, so the Site's CSP must allow it:
|
||||
|
||||
```bash
|
||||
MP_PUBLIC_KEY=TEST-...
|
||||
PAYMENT_CSP_SOURCES=https://sdk.mercadopago.com https://*.mercadopago.com https://*.mlstatic.com https://*.mercadolibre.com
|
||||
```
|
||||
|
||||
`PAYMENT_CSP_SOURCES` is empty by default, which keeps the Site at
|
||||
`script-src 'self'`. Once a payment for a quote is approved, or a card payment
|
||||
is in review, the API refuses any further attempt for that quote. The order is created only by the signed
|
||||
notification, after the payment is fetched from the Mercado Pago API and its
|
||||
BRL amount matches the approved total. Mercado Pago must be able to reach the
|
||||
webhook, so a local run needs a public HTTPS tunnel to the Site port. A paid
|
||||
notification that cannot become an order, or a refund on an existing order,
|
||||
appears under **Pagamentos que precisam de atenção** on the Kanban until an
|
||||
operator records the resolution.
|
||||
|
||||
## Local backup and restore check
|
||||
|
||||
```bash
|
||||
|
||||
@@ -17,9 +17,11 @@ Cloudflare R2, so MinIO is not part of this stack.
|
||||
|
||||
The single workflow is `.gitea/workflows/deploy.yml`. Every push and pull request
|
||||
runs static validation, the integration suite against a real stack, and a secret
|
||||
scan. A push to `main` does not publish or deploy. A manual workflow run on
|
||||
`main` repeats those checks, builds and scans the images, then publishes both
|
||||
`latest` and the full commit SHA and calls Portainer.
|
||||
scan. When all of them pass on a push to `main`, the images are built, scanned
|
||||
and published as both `latest` and the full commit SHA. Nothing is deployed:
|
||||
production changes when someone pulls and redeploys the stack in Portainer. A
|
||||
manual workflow run on `main` does the same and also calls the Portainer
|
||||
webhook, if `PORTAINER_WEBHOOK` is configured.
|
||||
|
||||
What actually gates a deployment:
|
||||
|
||||
@@ -29,16 +31,18 @@ What actually gates a deployment:
|
||||
| Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) | yes |
|
||||
| Browser suites | yes; Chrome runs in the Compose test container |
|
||||
| Trivy secret scan (HIGH/CRITICAL) | yes |
|
||||
| Source preflight (`deploy/production_preflight.py --source-only`) | yes for manual release; advisory on pushes unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true` |
|
||||
| Source preflight (`deploy/production_preflight.py --source-only`) | advisory unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true`, which blocks publishing |
|
||||
| Trivy image vulnerabilities, CRITICAL | yes |
|
||||
| Trivy image vulnerabilities, HIGH | no — reported before publication |
|
||||
| Configured Portainer webhook | yes for manual release |
|
||||
| Configured Portainer webhook | no — called on manual runs when present; otherwise redeploy in Portainer |
|
||||
|
||||
The source preflight refuses a release while the payment and messaging adapters
|
||||
are fake. It remains advisory on push checks so development can continue, but a
|
||||
manual release is blocked until those adapters are replaced. Set the repository
|
||||
variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` when all pushes should also
|
||||
fail on those blockers. Before a manual release, run the full configuration
|
||||
The source preflight reports while the payment and messaging adapters are
|
||||
fake. From 2026-09-23 it was enforced on every manual release, and since the
|
||||
adapters are still fake no release could succeed: production kept running
|
||||
older images while `main` moved on. It is now advisory everywhere. Set the
|
||||
repository variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` once the real
|
||||
adapters are in place, and a blocked preflight then stops images from being
|
||||
published. Before a manual release, run the full configuration
|
||||
preflight below against the actual Portainer values; CI checks source only.
|
||||
|
||||
CRITICAL image findings block. Both images carry none: the bases are pinned by
|
||||
|
||||
105
docs/ROADMAP.md
105
docs/ROADMAP.md
@@ -7,7 +7,18 @@
|
||||
> Update the **Current step** line and the item status every time something moves.
|
||||
> Add new findings at the bottom of the relevant block rather than rewriting history.
|
||||
|
||||
**Current step (2026-09-23, Week 2):** Payment safety fixes 2.13 and 2.14 and
|
||||
**Current step (2026-09-24, Week 2):** Client inputs for Mercado Pago and
|
||||
freight were requested on 2026-09-24; Tiny access is already with the client.
|
||||
Built without them: server-side print-file generation (1.4), the delivery
|
||||
address (3.8), the Kanban's payment-issue and print-file views (1.5), and
|
||||
Mercado Pago and Tiny adapters written from the public API documentation and
|
||||
tested against fake transports (1.1, 1.3). None of the provider work is a
|
||||
verified integration. The complete CI integration sequence passed locally on
|
||||
2026-09-24 (all API suites including the new `print_file_test`, adapter and
|
||||
generator unit suites, retention, runtime security, and both browser suites),
|
||||
run with Docker Engine in WSL against a fresh build; pending a Gitea runner run.
|
||||
|
||||
**Previous step (2026-09-23):** Payment safety fixes 2.13 and 2.14 and
|
||||
the local order-correctness work in 3.6/3.9 have passed integration checks.
|
||||
Production specification v2 now records each copy's film coordinates and is
|
||||
kept through the approved order; 4.6 now pages pending and approved unpaid
|
||||
@@ -211,14 +222,71 @@ From the report already sent. These are dated promises, not backlog.
|
||||
A refused paid event must be visible for operator resolution rather than silently
|
||||
treated as finished. See 2.14 and 3.7. Requires sandbox access, webhook
|
||||
administration, event mapping and an approved refund policy.
|
||||
**Groundwork (2026-09-24):** `app/mercadopago.py` creates PIX or card-token
|
||||
payments with the quote as idempotency key, verifies `x-signature` as
|
||||
documented (HMAC-SHA256 over `id;request-id;ts`, 30-minute replay window),
|
||||
and treats the notification as a pointer: the payment is fetched from the
|
||||
API and only a BRL amount in whole centavos is compared. `payment_intents`
|
||||
binds each provider payment to its quote; `/api/payments/intent` starts a
|
||||
PIX and the Site shows its QR code. Refused paid events and refunds on
|
||||
existing orders now stay on the Kanban until an operator records a
|
||||
resolution. Unit-tested against a fake transport only.
|
||||
**Card form (2026-09-24):** Mercado Pago's Card Payment Brick on the Site,
|
||||
shown when `MP_PUBLIC_KEY` is set; the card becomes a one-time token in
|
||||
Mercado Pago's secure fields. Each card attempt has its own idempotency key
|
||||
(a decline can be retried with another card) and the intent route refuses
|
||||
any new attempt once a payment is approved or a card is in review, so a
|
||||
quote cannot be charged twice. The Site CSP gains the Mercado Pago origins
|
||||
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
|
||||
against a real public key; sandbox run and refund policy remain.
|
||||
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
|
||||
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
|
||||
packaging weight/dimensions per length, subsidy policy.
|
||||
- `[ ]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
|
||||
- `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
|
||||
Confirm endpoints, tag behaviour and rate limits first.
|
||||
- `[ ]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
|
||||
**Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's
|
||||
Keycloak. An operator starts the connection from the Kanban; the callback is
|
||||
authorised by a single-use state (the operator cookie is SameSite=Strict and
|
||||
does not survive Tiny's cross-site redirect). Tokens live in
|
||||
`provider_tokens`; the refresh token rotates under a row lock and the worker
|
||||
keeps the connection alive. Orders: contact found by CNPJ or created, then
|
||||
`POST /pedidos` with product ids from `TINY_PRODUCT_TEXTIL_FOLHA` / `_TEXTIL_AVULSA` / `_UV_FOLHA`
|
||||
/ `_UV_AVULSA` (not `_<MODE>`: a name ending in `_FILE` is read as a secret
|
||||
file path by `app/core/secrets.py`) and
|
||||
`numeroOrdemCompra = DTF-<number>`; a retry searches the customer's last
|
||||
seven days of orders for that number first. Production passes the app
|
||||
credentials through but keeps `TINY_ADAPTER: fake`. Tested against fake
|
||||
transports (`tests.test_tiny`) and, for OAuth, the real database
|
||||
(`tests.tiny_oauth_test`). Tiny has no sandbox: the first real test creates
|
||||
real orders. Still to confirm on the client's account: plan (Construa+),
|
||||
product ids, token lifetimes, whether pickup needs a transportador, and
|
||||
rate limits.
|
||||
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
|
||||
must survive checkout before an output engine can reproduce the approved job).
|
||||
- `[ ]` 1.5 — Main Kanban production states consolidated.
|
||||
**Built (2026-09-24):** each paid item gets a PDF the width of the film and
|
||||
the length of the approved layout, with every copy at its reviewed position,
|
||||
rotation and mirror (`app/printfile.py`, rendered by the worker from
|
||||
`app/printjobs.py`). Sources are embedded once at original resolution; JPEG
|
||||
bytes pass through, PNG alpha becomes a soft mask, EXIF orientation is
|
||||
honoured. A file whose proportions differ from the quote, a layout longer than
|
||||
billed, or PDF/PSD/AI/CDR artwork goes to hand preparation with the reason.
|
||||
The operator approves the generated PDF as the final file through the
|
||||
existing review. Unit tests include a raster check of every rotation and
|
||||
mirror, and `tests.print_file_test` passes on the running stack (generate,
|
||||
download, approve as final, queue; hand-preparation routing and retry).
|
||||
**Still open:** a FlexiPRINT import of real
|
||||
generated files (including one longer than 5 m, which uses `UserUnit`).
|
||||
**PDF artwork (2026-09-24):** a single-page PDF source is placed as a vector
|
||||
form through pikepdf (MPL-2.0; PyMuPDF was rejected for its AGPL licence),
|
||||
using the CropBox and inherited `/Rotate` the Site measured with pdf.js.
|
||||
Raster tests cover crop, page rotation, placement rotation and mirroring,
|
||||
and were shown to fail when the rotation or crop handling is broken.
|
||||
Multi-page and protected PDFs go to hand preparation.
|
||||
- `[~]` 1.5 — Main Kanban production states consolidated. The six states and
|
||||
their transitions are unchanged; cards now show the delivery address, the
|
||||
print-file status per item, and a panel lists payments that need a person
|
||||
(money without an order, refunds after an order) until resolved. Confirm
|
||||
with the operation that these are the main states before closing.
|
||||
- `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18.
|
||||
|
||||
`[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)
|
||||
@@ -497,12 +565,20 @@ overpayment and provider success followed by database failure. Record refused
|
||||
paid events for resolution. Decide who reconciles them and when production must
|
||||
stop or refund. Implement with 1.1 after the checkout/refund policy is approved.
|
||||
|
||||
### `[ ]` 3.8 — Collect a deliverable destination before charging freight
|
||||
### `[~]` 3.8 — Collect a deliverable destination before charging freight
|
||||
|
||||
The quote has a shipping service and CEP but no recipient, street, number,
|
||||
city/state or delivery snapshot. Add and validate these fields with 1.2, then
|
||||
bind the chosen service and final freight amount to the payment intent.
|
||||
|
||||
**Local progress 2026-09-24:** the Site collects recipient, street, number,
|
||||
complement, district, city and UF for delivery; the API requires them for any
|
||||
non-pickup quote, requires the CEP to be the one freight was quoted for, and
|
||||
refuses an address on pickup. The address is part of the reviewed quote, the
|
||||
order snapshot, the Kanban card and the Tiny payload. Changing it after a quote
|
||||
invalidates that quote in the browser like any other cart change. Binding the
|
||||
chosen freight service to the payment intent waits on 1.2.
|
||||
|
||||
### `[~]` 3.9 — Make artwork quality and geometry evidence explicit
|
||||
|
||||
Reject or route for review when PDF page count/geometry, image decoding or DPI
|
||||
@@ -622,11 +698,26 @@ print-file evidence still need correction before this item can close.
|
||||
data, a production API image import, local security status, and a local
|
||||
backup/restore of the database plus 78 clean objects. Production offsite
|
||||
recovery and signature freshness remain separate open items.
|
||||
- `[x]` 5.15 — MinIO stopped publishing public images: by 2026-09-24 both
|
||||
Docker Hub and quay.io answered anonymous pulls with 401, so a runner or
|
||||
machine without a cached image could not start the stack. The local/CI
|
||||
storage and storage-init now use Chainguard's MinIO build (ships `sh` and
|
||||
`mc`, non-root), pinned by digest. Verified with a fresh local build and the
|
||||
full integration sequence. Production uses R2 and is unaffected.
|
||||
- `[x]` 5.16 — The operator login limit (10 per account per 15 minutes) counted
|
||||
successful logins too, so ordinary use could lock an operator out, and one
|
||||
extra test login made CI's final browser sign-in fail with 429. Now every
|
||||
attempt counts against the source address and only failures count against
|
||||
the account; registration still counts every attempt. The security suite's
|
||||
lockout check (ten failures, then 429) is unchanged and passes.
|
||||
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
|
||||
and object backups, a consistent snapshot boundary, Swarm data placement and
|
||||
a restore rehearsal that opens every required live order file.
|
||||
- `[ ]` 5.14 — Promote and verify one immutable release. Normal `main` pushes
|
||||
now run checks only; manual dispatch requires source preflight and a configured
|
||||
- `[ ]` 5.14 — Promote and verify one immutable release. **2026-09-24:** green
|
||||
pushes to `main` now publish images and Portainer's pull-and-redeploy is the
|
||||
release gate; the source preflight is advisory unless enforced by variable,
|
||||
because enforcing it while the adapters are fake made every release fail.
|
||||
Previously: normal `main` pushes ran checks only; manual dispatch required source preflight and a configured
|
||||
webhook, and scans images before publishing. Still make the full preflight
|
||||
validate the active stack, deploy the tested immutable image references, test
|
||||
clean install and upgrade, check application readiness after Portainer
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Provisioning script and policies baked in, for the same reason as the scanner.
|
||||
ARG MINIO_IMAGE=quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
ARG MINIO_IMAGE=cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1
|
||||
FROM ${MINIO_IMAGE}
|
||||
COPY infra/storage-init.sh /init.sh
|
||||
COPY infra/storage-policy.json /policy.json
|
||||
|
||||
@@ -9,7 +9,7 @@ server {
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
|
||||
location = /health { access_log off; return 200 'ok'; }
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=100 nodelay;
|
||||
|
||||
@@ -21,7 +21,7 @@ anyio==4.15.1 \
|
||||
boto3==1.38.23 \
|
||||
--hash=sha256:70ab8364f1f6f0a7e0eaf97f62fbdacf9c1e4cc1de330faf1c146ef9ab01e7d0 \
|
||||
--hash=sha256:bcf73aca469add09e165b8793be18e7578db8d2604d82505ab13dc2495bad982
|
||||
# via -r local/requirements.txt
|
||||
# via -r infra/requirements.txt
|
||||
botocore==1.38.46 \
|
||||
--hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \
|
||||
--hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b
|
||||
@@ -41,7 +41,7 @@ click==8.5.0 \
|
||||
fastapi==0.141.1 \
|
||||
--hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
|
||||
--hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
|
||||
# via -r local/requirements.txt
|
||||
# via -r infra/requirements.txt
|
||||
h11==0.16.0 \
|
||||
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
|
||||
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
|
||||
@@ -55,7 +55,7 @@ httpcore==1.0.9 \
|
||||
httpx==0.28.1 \
|
||||
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
|
||||
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
|
||||
# via -r local/requirements.txt
|
||||
# via -r infra/requirements.txt
|
||||
idna==3.19 \
|
||||
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
|
||||
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
|
||||
@@ -68,10 +68,328 @@ jmespath==1.1.0 \
|
||||
# via
|
||||
# boto3
|
||||
# botocore
|
||||
lxml==6.1.3 \
|
||||
--hash=sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4 \
|
||||
--hash=sha256:05f5bce9af14fd1506997594bd81cee6d9c6b58ea80a39c058327aa6371ed9e9 \
|
||||
--hash=sha256:0794e04ba343852c6d78e996c58ef4b8e579b4ecc72f8df0d4058bf843b4c96e \
|
||||
--hash=sha256:0ab2467e405e748d93495fb5568e74044802b8d3ff2b2a1607c3f78c6e982de5 \
|
||||
--hash=sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe \
|
||||
--hash=sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc \
|
||||
--hash=sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748 \
|
||||
--hash=sha256:0f17d83c48ee9dfd96abae3ac3e2108c76d2fc86ce96355e37b8da9f7f4ecc08 \
|
||||
--hash=sha256:0feebef8d0521188d0157f758356072e840173aa61ca45b8b3f87959ac283dd5 \
|
||||
--hash=sha256:13a620a3fcc20023f9e6ed5c383e00e826f1c2d5db554df2f67240760f9118e8 \
|
||||
--hash=sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741 \
|
||||
--hash=sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87 \
|
||||
--hash=sha256:16148acd77ed1d8836a56db883af2f5eed720f9723088110b16a0d08582130a6 \
|
||||
--hash=sha256:170773d8a3cdc76259065523ddd978c44f9806e28605f08812e8f86783e44ac6 \
|
||||
--hash=sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633 \
|
||||
--hash=sha256:18a4db52b5a7b53a3540b0b0f4123319334621ee8083d496de314d0bf06ff59a \
|
||||
--hash=sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d \
|
||||
--hash=sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa \
|
||||
--hash=sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e \
|
||||
--hash=sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70 \
|
||||
--hash=sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867 \
|
||||
--hash=sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f \
|
||||
--hash=sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12 \
|
||||
--hash=sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156 \
|
||||
--hash=sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6 \
|
||||
--hash=sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5 \
|
||||
--hash=sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75 \
|
||||
--hash=sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48 \
|
||||
--hash=sha256:23c366231259cd75ad06495174701afb3fcb36a92917fa47de2d1f1bd9d95739 \
|
||||
--hash=sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37 \
|
||||
--hash=sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626 \
|
||||
--hash=sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015 \
|
||||
--hash=sha256:2b9b1325ca1c2a9a2dbb6eb913ae563313f2082ae60b03210f7e83ee80712274 \
|
||||
--hash=sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165 \
|
||||
--hash=sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e \
|
||||
--hash=sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79 \
|
||||
--hash=sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d \
|
||||
--hash=sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d \
|
||||
--hash=sha256:302f72413251c03f671e063c9414bed5dc8c927069e5abb69245521e51a4e81b \
|
||||
--hash=sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026 \
|
||||
--hash=sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad \
|
||||
--hash=sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11 \
|
||||
--hash=sha256:3847e71a78cbbc1aff955dbbbaf2fff12153f611d3162c5beaa3395636cbc2f9 \
|
||||
--hash=sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385 \
|
||||
--hash=sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7 \
|
||||
--hash=sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd \
|
||||
--hash=sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f \
|
||||
--hash=sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c \
|
||||
--hash=sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a \
|
||||
--hash=sha256:40bcbd9f94166ffe925811e730607385cec959f42fb1bb7dad83748680465221 \
|
||||
--hash=sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167 \
|
||||
--hash=sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a \
|
||||
--hash=sha256:41e2d428110b408e963b6fb18f9bbf1f5c027b56bd4b498d54556476c0aeb1c3 \
|
||||
--hash=sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054 \
|
||||
--hash=sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245 \
|
||||
--hash=sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21 \
|
||||
--hash=sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6 \
|
||||
--hash=sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e \
|
||||
--hash=sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13 \
|
||||
--hash=sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b \
|
||||
--hash=sha256:4b061064b4a2fe8598a466d723d43dbcd5a610a5d5cfe02fb6226f5c17349f75 \
|
||||
--hash=sha256:4e11e885e0704be185867fcf71b904d8f65d7d6877bc121f69870b0d0479ba7b \
|
||||
--hash=sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d \
|
||||
--hash=sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0 \
|
||||
--hash=sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69 \
|
||||
--hash=sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414 \
|
||||
--hash=sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d \
|
||||
--hash=sha256:5929d9df5e7e3379183be0e21f7d559618a5b61cb63280df6164019242e337ed \
|
||||
--hash=sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f \
|
||||
--hash=sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf \
|
||||
--hash=sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2 \
|
||||
--hash=sha256:5d12669a2c419b0e8dc423d23dea24bb82f6f9cb829f32e04674b0ba40322a7c \
|
||||
--hash=sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2 \
|
||||
--hash=sha256:61116cec57ed69aebc70f37a545eec095339bb829efbdabcfb97c51e9536e158 \
|
||||
--hash=sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d \
|
||||
--hash=sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d \
|
||||
--hash=sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c \
|
||||
--hash=sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861 \
|
||||
--hash=sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd \
|
||||
--hash=sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0 \
|
||||
--hash=sha256:6ba4fe5bfbef6811a8e49b3719cde373ad399006c0c1ac184b7297116ecbba5d \
|
||||
--hash=sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5 \
|
||||
--hash=sha256:6e1eb8a4cbffd5553680ad96be6680e364710656eced73d1dc90ec489df599a3 \
|
||||
--hash=sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0 \
|
||||
--hash=sha256:71532ebf30be0048a45559b4fab15333fbaaf9042f658e878d918ecd0cf09805 \
|
||||
--hash=sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a \
|
||||
--hash=sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8 \
|
||||
--hash=sha256:773062aec2f2e56b2b22d37054123f0de8a22a4688a0c3376c3fe42685f975cf \
|
||||
--hash=sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559 \
|
||||
--hash=sha256:7b2bb7d703bed7ac893bf7f40d97b5d9279d35d2ce460624ca28929eab0d5a3d \
|
||||
--hash=sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c \
|
||||
--hash=sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a \
|
||||
--hash=sha256:7dd624c1eaa629ad44b59a1a0145fdf2d67895592dce94c9358b938b3d075e65 \
|
||||
--hash=sha256:7f75b9b9fec2a9c6b18095c81865580e795b1441c429e42d22fcc82a77f40039 \
|
||||
--hash=sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92 \
|
||||
--hash=sha256:8499d464de86fab0f102313cce32a9bed9ab1f06ec813cf025cb790964fbb765 \
|
||||
--hash=sha256:869dfcd4d381cb0ea87085cc4f011b9171b494ef21e76ad8665f6d5e2d1dc8a1 \
|
||||
--hash=sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0 \
|
||||
--hash=sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1 \
|
||||
--hash=sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2 \
|
||||
--hash=sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758 \
|
||||
--hash=sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473 \
|
||||
--hash=sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310 \
|
||||
--hash=sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c \
|
||||
--hash=sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4 \
|
||||
--hash=sha256:92d96586376fb79a33474797186bf993250152ee5c32650b67db78d54b92e6f3 \
|
||||
--hash=sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17 \
|
||||
--hash=sha256:97acecb11cbc411473f15b8d780df06d7a9f3a2aad9aca78364f56640c8fb70e \
|
||||
--hash=sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9 \
|
||||
--hash=sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48 \
|
||||
--hash=sha256:9e67324961ac9bbe616cce5100514d2e34d88665aeb07071e8b16eac55d06d94 \
|
||||
--hash=sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a \
|
||||
--hash=sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2 \
|
||||
--hash=sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55 \
|
||||
--hash=sha256:a2e3f70673a1d5b82f38255f777d26cd855bf2092b1436c4867464a7892f9238 \
|
||||
--hash=sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e \
|
||||
--hash=sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56 \
|
||||
--hash=sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0 \
|
||||
--hash=sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0 \
|
||||
--hash=sha256:aa9fd1ee2a5dacfc41039ed49ffeeacfa75bafbd255b69f3b578e11897a0e623 \
|
||||
--hash=sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e \
|
||||
--hash=sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1 \
|
||||
--hash=sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a \
|
||||
--hash=sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c \
|
||||
--hash=sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed \
|
||||
--hash=sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6 \
|
||||
--hash=sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4 \
|
||||
--hash=sha256:b50343241eb69fd85f7791cf8bcc7b1c4729826b7d59ba2f6b27db29638fa745 \
|
||||
--hash=sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae \
|
||||
--hash=sha256:be5346653c0b0e34be96869ff9dbeba23860156f89a2896a64c64fb419260cb6 \
|
||||
--hash=sha256:c00e26288784460885fe76e4d4b293573e0f791f52e6d60e27b42edf005922eb \
|
||||
--hash=sha256:c1b50797ac246bb2942a04b6c0f69af0667aba7cf7535f39bbb1b3208fd5d128 \
|
||||
--hash=sha256:c34ca1dc41bd86d9ff830d5bdf4e4a752bba6c54f7d2707027ce0eabd36084c9 \
|
||||
--hash=sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5 \
|
||||
--hash=sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9 \
|
||||
--hash=sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415 \
|
||||
--hash=sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8 \
|
||||
--hash=sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11 \
|
||||
--hash=sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8 \
|
||||
--hash=sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2 \
|
||||
--hash=sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a \
|
||||
--hash=sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300 \
|
||||
--hash=sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0 \
|
||||
--hash=sha256:cc669256d28736f7f3a149df5c380c50ace2692ba3e62203d10656fade4a2145 \
|
||||
--hash=sha256:ce1f220114959941170e22b8ad44279f6dee2dcef7591814d01ae805dc058889 \
|
||||
--hash=sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9 \
|
||||
--hash=sha256:d077f21f4b16f0471353883748f126f62038760397c107bb9fad2ca94dc0dfb7 \
|
||||
--hash=sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559 \
|
||||
--hash=sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962 \
|
||||
--hash=sha256:d44442effeb8781f392340c5dc8c6716fba41dbeacb82fd4c0f09026fb5ff682 \
|
||||
--hash=sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e \
|
||||
--hash=sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb \
|
||||
--hash=sha256:d9a0d12846d6ce434fb3857918eef4315ec9b4769deb020c75828798614bfcfd \
|
||||
--hash=sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc \
|
||||
--hash=sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8 \
|
||||
--hash=sha256:da85db328e507da922d586c3c7416ec360ec22e9cd9e0700691afacde0c81f53 \
|
||||
--hash=sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e \
|
||||
--hash=sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed \
|
||||
--hash=sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d \
|
||||
--hash=sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32 \
|
||||
--hash=sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477 \
|
||||
--hash=sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023 \
|
||||
--hash=sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887 \
|
||||
--hash=sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41 \
|
||||
--hash=sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6 \
|
||||
--hash=sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376 \
|
||||
--hash=sha256:ec295280f4b37769256da025acf5890370355ac589c27e89caae0b5e9eedc702 \
|
||||
--hash=sha256:f6449672f9c93316deb5e2839e18931f468670e44d5bd9b1301a5a9655d45c07 \
|
||||
--hash=sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5 \
|
||||
--hash=sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2 \
|
||||
--hash=sha256:f8b9c8ceebae6387d0dc77f7f4dbbfbfc962dba2efbfe6877486075a480726b4 \
|
||||
--hash=sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011 \
|
||||
--hash=sha256:fbfb70ba01355251faf6b293171df49f73a88a1b6494db109ffea85442574458 \
|
||||
--hash=sha256:fe91993149523aa59941b9e3c90e2eb45f57ad014697aef6c8b13339a59c019e \
|
||||
--hash=sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0 \
|
||||
--hash=sha256:ff88a92cafde90888511242d1c54afcc1a8adbb6dc0a88fa7f87e29e92400d4a
|
||||
# via pikepdf
|
||||
packaging==26.3 \
|
||||
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
|
||||
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
||||
# via pikepdf
|
||||
pikepdf==10.13.0.post1 \
|
||||
--hash=sha256:01f80ca046d984752cf6f08093debc193884bee91c01c104aa0236767711a20f \
|
||||
--hash=sha256:092a9bf15739e931ecab15ec3baee5d9629dad90ea4e42b779f6b439d2d1e462 \
|
||||
--hash=sha256:0efb4faed9cbb59c1486f668af326096dac1ff0ca058eb48ec485742a9a655af \
|
||||
--hash=sha256:1f76fcbe5d86f2ae6f231ba542cd04793d4c89e75bd5f62526b7412926ff2100 \
|
||||
--hash=sha256:20c76343128ec41d5be58337b23c6f9f620fa7b8256a2d942460a48c07bbfe7b \
|
||||
--hash=sha256:2c6e83f8a1828ec79cdec4df8cc07209eaf10ed7e4f5a90a7356b254bacc07d5 \
|
||||
--hash=sha256:2db9a18074ba112e7c517e8c21dfd8894cc13b8a37ccf49a5841192170fb68eb \
|
||||
--hash=sha256:365b94f2be7e2857c6cb5445b56dc52dc7417ba9f06c8a4282d9f521cb2d0fb8 \
|
||||
--hash=sha256:3e18d5a009bbe5f3ab18f916fb9e28f0c5b0d920736e3a85cc10c627ec633596 \
|
||||
--hash=sha256:414f42c83e5e6029870de1a988625dafc95781ebff10e15d82caeb5e69a83c9c \
|
||||
--hash=sha256:43d70f244a4a1120a11cfe2227f8c03249fac6a7e3789a3116173102a3b9fe37 \
|
||||
--hash=sha256:4b73f926ebae81f04bf14527af330bd00bb268be767e0f189f7c4c3e4ad7ae0a \
|
||||
--hash=sha256:4bb5fe2090d246ad4b325d17f33a186f60f6763bba3d4ac3c4b863c8890e913a \
|
||||
--hash=sha256:51fae4a4a3c6549aa4c405896ff7010f3e43e0c4f407c0bcee071ef13d271202 \
|
||||
--hash=sha256:544f1be1b1e5630a79cd182a8663c439504099eb9eae0d342a50173d9825bdf3 \
|
||||
--hash=sha256:55e53b4d8a4b1700f686f76e3a68411e421e962a4c8b1b90d00aab3f3e494a55 \
|
||||
--hash=sha256:571efcd1d54e0dd817973c76c253feb6fb758c93bb0c16a893cc68f2a178d404 \
|
||||
--hash=sha256:6b038cd5bcbb6c1952bcc271695eaf24c4199d72e45606ee5e467f837760820e \
|
||||
--hash=sha256:7ba09ef5a5f26e38ee558d2a08223fee08a5ef1868962ae2d8590d4de3c8c92f \
|
||||
--hash=sha256:87141ada970386ff6640db54f0bda734d3bde7960d3ba04a76768b48e25028ca \
|
||||
--hash=sha256:8cb976331cb8b03ec3465e06d9e7a3eadbadb7e622be888e70f918ac732a105e \
|
||||
--hash=sha256:8fb8f82dc43056a4b4f891e78ee1db4e3ced75ba3e87b836f8e28c8771228928 \
|
||||
--hash=sha256:90f17cb174db88e08075c5bfa3c619df00dd84abbad34bfa1edf84863f0b01a7 \
|
||||
--hash=sha256:94e04ed92fe42b8bcebf8c40462868dca4eb92581dcc2be1f0c4b8ee23347386 \
|
||||
--hash=sha256:9613505f5b22203465d4224a3fd8cf69876ce8278442c6478ac6849f54724a30 \
|
||||
--hash=sha256:98a7305e330f797da02b543d3ad57a134c4a14c6ec6f8d86d91aa9dd130c425b \
|
||||
--hash=sha256:996a714a47cc725e3c48fe3901691d3353f3c2eeb9e0571aa2b16164abc40ff9 \
|
||||
--hash=sha256:99f6afccd6119233e7133bd4c2ade48461de3ddd4269cc28a4f90cdf7c1372f5 \
|
||||
--hash=sha256:a3e9104db5ea5b5a7c5fde417147900966a687de39ef91a5ea103fd4b773aee1 \
|
||||
--hash=sha256:b63577c44fedf7ed6b971076f7c7ed0ff95a8bac627ac93b79e8b542214861a7 \
|
||||
--hash=sha256:b69b89577b50617248185b6ad73cda0e4f15c57da11f7da8cc4032bf0d7d9ebe \
|
||||
--hash=sha256:b7b0cbb135de32ec3f41651a08ab294e3c18ae9fec32516a48d27e64a53a47f0 \
|
||||
--hash=sha256:b948e11f7dd3710f939194f00b4d75b0df87a30d53b31414128768ac25da77d8 \
|
||||
--hash=sha256:c0b5127df90b0164dd846bddd0ed542326b2f69bd11f627afe48762cf16c2904 \
|
||||
--hash=sha256:d3b58ccb30b93ba400e6a6a83315b4830eea49f6f19e18d78241fe6b1c49fec2 \
|
||||
--hash=sha256:e19bab4320e4e8771b7816f7319ba37530fd28a40372840b75cab394ebf868e4 \
|
||||
--hash=sha256:ef4ed47d40aa44deb063feb4a88e8bcf1c8fa0183ce526dc4295f7cbdb1292f8 \
|
||||
--hash=sha256:f0eb89f06cad9231b9db54d81a22592b03b63924824a6b850febd2b75daa6546 \
|
||||
--hash=sha256:f2463f650efab46905b9e279f5c776faf96c65bb45c1acf9f2de0e8a6eec5fb7 \
|
||||
--hash=sha256:f3dedd02795626f17ee42d5c02ec4ec94e28aa47046ef430d4478454a8fbd07f \
|
||||
--hash=sha256:f515a31c76cce043bbb7b781e77a343a4e26fa8f520ba337d30ddea0f7a0ce50 \
|
||||
--hash=sha256:f7962a75cf22d0d683b49ab19b8966e94dc7014d9aa6806f3c0d2b37fb9ae607 \
|
||||
--hash=sha256:fb05fb7b42d85754219b55111aa61beff4e48da56069e971de1e5aca380c0ac1
|
||||
# via -r infra/requirements.txt
|
||||
pillow==12.3.0 \
|
||||
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
|
||||
--hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
|
||||
--hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
|
||||
--hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
|
||||
--hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
|
||||
--hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
|
||||
--hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
|
||||
--hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
|
||||
--hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
|
||||
--hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
|
||||
--hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
|
||||
--hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
|
||||
--hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
|
||||
--hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
|
||||
--hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
|
||||
--hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
|
||||
--hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
|
||||
--hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
|
||||
--hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
|
||||
--hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
|
||||
--hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
|
||||
--hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
|
||||
--hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
|
||||
--hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
|
||||
--hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
|
||||
--hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
|
||||
--hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
|
||||
--hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
|
||||
--hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
|
||||
--hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
|
||||
--hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
|
||||
--hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
|
||||
--hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
|
||||
--hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
|
||||
--hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
|
||||
--hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
|
||||
--hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
|
||||
--hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
|
||||
--hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
|
||||
--hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
|
||||
--hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
|
||||
--hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
|
||||
--hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
|
||||
--hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
|
||||
--hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
|
||||
--hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
|
||||
--hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
|
||||
--hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
|
||||
--hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
|
||||
--hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
|
||||
--hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
|
||||
--hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
|
||||
--hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
|
||||
--hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
|
||||
--hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
|
||||
--hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
|
||||
--hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
|
||||
--hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
|
||||
--hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
|
||||
--hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
|
||||
--hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
|
||||
--hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
|
||||
--hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
|
||||
--hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
|
||||
--hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
|
||||
--hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
|
||||
--hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
|
||||
--hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
|
||||
--hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
|
||||
--hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
|
||||
--hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
|
||||
--hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
|
||||
--hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
|
||||
--hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
|
||||
--hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
|
||||
--hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
|
||||
--hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
|
||||
--hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
|
||||
--hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
|
||||
--hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
|
||||
--hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
|
||||
--hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
|
||||
--hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
|
||||
--hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
|
||||
--hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
|
||||
--hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
|
||||
--hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
|
||||
# via
|
||||
# -r infra/requirements.txt
|
||||
# pikepdf
|
||||
psycopg==3.2.9 \
|
||||
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
|
||||
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
|
||||
# via -r local/requirements.txt
|
||||
# via -r infra/requirements.txt
|
||||
psycopg-binary==3.2.9 \
|
||||
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
|
||||
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
|
||||
@@ -281,7 +599,7 @@ starlette==1.6.0 \
|
||||
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
|
||||
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
|
||||
# via
|
||||
# -r local/requirements.txt
|
||||
# -r infra/requirements.txt
|
||||
# fastapi
|
||||
typing-extensions==4.16.0 \
|
||||
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
|
||||
@@ -307,6 +625,4 @@ urllib3==2.7.0 \
|
||||
uvicorn==0.34.2 \
|
||||
--hash=sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328 \
|
||||
--hash=sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403
|
||||
# via -r local/requirements.txt
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# via -r infra/requirements.txt
|
||||
|
||||
@@ -4,3 +4,5 @@ uvicorn==0.34.2
|
||||
psycopg[binary]==3.2.9
|
||||
boto3==1.38.23
|
||||
httpx==0.28.1
|
||||
pillow==12.3.0
|
||||
pikepdf==10.13.0.post1
|
||||
|
||||
@@ -58,6 +58,20 @@ def run():
|
||||
assert not customer.call('/quotes/' + quote_id)['order'], 'unsigned delivery created an order'
|
||||
print('PASS: unsigned and tampered deliveries are refused and create nothing')
|
||||
|
||||
# Starting a PIX twice returns the same one. A card in review blocks every
|
||||
# further attempt, so one quote can never be charged twice.
|
||||
pix = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
|
||||
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == pix['id']
|
||||
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'card'}}, expected=422)
|
||||
card = {'type': 'card', 'token': 'tok-1', 'payment_method_id': 'visa', 'installments': 1}
|
||||
customer.call('/payments/intent', {'quote_id': quote_id, 'method': card})
|
||||
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {**card, 'token': 'tok-2'}}, expected=409)
|
||||
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=409)
|
||||
stranger = Client()
|
||||
stranger.call('/session')
|
||||
stranger.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=404)
|
||||
print('PASS: payment start is idempotent for PIX and refuses a second charge')
|
||||
|
||||
# An approved payment for the wrong amount must not become an order.
|
||||
deliver({'event_id': 'short-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': total - 100})
|
||||
|
||||
158
tests/print_file_test.py
Normal file
158
tests/print_file_test.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Print files and payment issues, against a running stack.
|
||||
|
||||
A paid order must get a print file generated from its approved layout, which
|
||||
the operator can download and approve as the final file without re-uploading
|
||||
anything. Artwork the generator cannot read goes to hand preparation with a
|
||||
reason, and a paid notification that did not become an order stays on the
|
||||
Kanban until someone records what was done.
|
||||
|
||||
Run inside the API container (it needs Pillow): python -m tests.print_file_test
|
||||
"""
|
||||
import io
|
||||
import re
|
||||
import time
|
||||
from urllib.request import urlopen
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from tests.payment_test import deliver
|
||||
from tests.smoke_test import Client, upload_bytes
|
||||
|
||||
PT_PER_CM = 72 / 2.54
|
||||
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
|
||||
|
||||
|
||||
def artwork(kind='PNG'):
|
||||
image = Image.new('RGBA', (600, 300), (0, 0, 0, 0))
|
||||
for x in range(40, 560):
|
||||
for y in range(40, 260):
|
||||
image.putpixel((x, y), (220, 30, 60, 255))
|
||||
out = io.BytesIO()
|
||||
if kind == 'PDF':
|
||||
image.convert('RGB').save(out, 'PDF', resolution=72)
|
||||
else:
|
||||
image.save(out, kind)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def loose_item(uid, copies=2):
|
||||
"""Two copies of a 20 x 10 cm artwork side by side on 57 cm film."""
|
||||
placements = [{'source_index': 0, 'copy_index': i, 'x_cm': 20 * i, 'y_cm': 0,
|
||||
'width_cm': 20, 'length_cm': 10, 'rotation_degrees': 0, 'mirrored': False}
|
||||
for i in range(copies)]
|
||||
return {'mode': 'avulsa', 'metres': '0.1', 'grade': 90, 'uploads': [uid],
|
||||
'production': {'version': 2, 'film_width_cm': 57, 'height_cm': 10,
|
||||
'sources': [{'upload_id': uid, 'kind': 'artwork', 'width_cm': 20,
|
||||
'length_cm': 10, 'copies': copies, 'rotation_degrees': 0,
|
||||
'mirrored': False, 'measurement': 'file'}],
|
||||
'placements': placements},
|
||||
'quality_status': 'ok', 'quality_acknowledged': False}
|
||||
|
||||
|
||||
def approved_quote(client, item):
|
||||
quote = client.call('/quotes', {'request_key': str(uuid4()), 'customer': CUSTOMER,
|
||||
'items': [item], 'freight': {'service': 'pickup'}})
|
||||
approved = client.call('/operator/quotes/' + quote['id'] + '/approve', {'items': [item]}, operator=True)
|
||||
return quote['id'], approved['total_cents']
|
||||
|
||||
|
||||
def paid_order(client, item):
|
||||
quote_id, _ = approved_quote(client, item)
|
||||
return client.call('/orders/dev-paid', {'quote_id': quote_id})
|
||||
|
||||
|
||||
def wait_print(client, oid, wanted):
|
||||
deadline = time.monotonic() + 90
|
||||
while time.monotonic() < deadline:
|
||||
order = next(o for o in client.call('/operator/board', operator=True)['orders'] if o['id'] == oid)
|
||||
rows = order['print_files']
|
||||
if rows and rows[0]['status'] in ('ready', 'manual', 'failed'):
|
||||
assert rows[0]['status'] == wanted, rows
|
||||
return order, rows[0]
|
||||
time.sleep(1)
|
||||
raise AssertionError('Print file was not generated in time')
|
||||
|
||||
|
||||
def run():
|
||||
client = Client()
|
||||
client.call('/session')
|
||||
|
||||
uid = upload_bytes(client, artwork(), name='LOCAL-PRINT-TEST.png')
|
||||
order = paid_order(client, loose_item(uid))
|
||||
order, row = wait_print(client, order['id'], 'ready')
|
||||
assert row['detail']['placements'] == 2 and row['detail']['min_dpi'] == round(600 / (20 / 2.54))
|
||||
link = client.call('/operator/uploads/' + str(row['upload_id']) + '/download', operator=True)
|
||||
with urlopen(link['url'], timeout=30) as response:
|
||||
pdf = response.read()
|
||||
assert pdf.startswith(b'%PDF-') and pdf.rstrip().endswith(b'%%EOF')
|
||||
width, height = map(float, re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf).groups())
|
||||
assert abs(width - 57 * PT_PER_CM) < 0.01 and abs(height - 10 * PT_PER_CM) < 0.01, (width, height)
|
||||
print('PASS: paid order generated a 57 x 10 cm print file with both copies')
|
||||
|
||||
# The operator approves the generated file as the final one, with no upload,
|
||||
# and the order can then enter the print queue.
|
||||
oid = order['id']
|
||||
result = client.call('/operator/orders/' + oid + '/final-files',
|
||||
{'version': order['version'], 'files': [{'item_index': 0, 'upload_id': str(row['upload_id'])}],
|
||||
'note': 'Generated print file checked'}, operator=True)
|
||||
version = result['version']
|
||||
for state in ('tra', 'fil'):
|
||||
client.call('/operator/orders/' + oid + '/move', {'state': state, 'version': version}, operator=True)
|
||||
version += 1
|
||||
# Once queued for printing, the final set can no longer change.
|
||||
client.call('/operator/orders/' + oid + '/final-files',
|
||||
{'version': version, 'files': [{'item_index': 0, 'upload_id': str(row['upload_id'])}],
|
||||
'note': 'again'}, operator=True, expected=409)
|
||||
print('PASS: generated file approved as final without re-uploading; order queued for printing')
|
||||
|
||||
# A single-page PDF is placed as a vector form, not rasterised.
|
||||
pdf_upload = upload_bytes(client, artwork('PDF'), name='LOCAL-PRINT-TEST.pdf')
|
||||
pdf_order = paid_order(client, loose_item(pdf_upload))
|
||||
_, pdf_row = wait_print(client, pdf_order['id'], 'ready')
|
||||
assert pdf_row['detail']['vector_sources'] == 1 and pdf_row['detail']['min_dpi'] is None, pdf_row
|
||||
link = client.call('/operator/uploads/' + str(pdf_row['upload_id']) + '/download', operator=True)
|
||||
with urlopen(link['url'], timeout=30) as response:
|
||||
generated = response.read()
|
||||
assert b'/Subtype /Form' in generated or b'/Subtype/Form' in generated
|
||||
print('PASS: PDF artwork generated as a vector print file')
|
||||
|
||||
# A customer cannot see or reuse another order's generated file.
|
||||
other = Client()
|
||||
other.call('/session')
|
||||
other.call('/uploads/' + str(row['upload_id']), expected=404)
|
||||
|
||||
# Artwork the generator cannot read goes to hand preparation, with a reason.
|
||||
manual = upload_bytes(client, b'LOCAL PRINT TEST - NOT AN IMAGE', name='LOCAL-PRINT-TEST.cdr')
|
||||
order = paid_order(client, loose_item(manual, copies=1))
|
||||
order, row = wait_print(client, order['id'], 'manual')
|
||||
assert 'not an image' in row['detail']['reason'], row
|
||||
rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True)
|
||||
assert rows[0]['status'] == 'pending'
|
||||
wait_print(client, order['id'], 'manual')
|
||||
print('PASS: unreadable artwork is routed to hand preparation and can be retried')
|
||||
|
||||
# A signed, paid notification for the wrong amount does not become an order;
|
||||
# it waits on the Kanban until an operator records the resolution.
|
||||
# Same client, so the same operator session: operator logins share a
|
||||
# 10-per-15-minutes account limit with every other suite in the run.
|
||||
quote_id, total = approved_quote(client, loose_item(uid, copies=1))
|
||||
event_id = 'print-test-underpaid-' + uuid4().hex
|
||||
outcome = deliver({'event_id': event_id, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': total - 1})
|
||||
assert outcome['outcome'].startswith('refused'), outcome
|
||||
issues = client.call('/operator/board', operator=True)['payment_issues']
|
||||
issue = next(i for i in issues if i['event_id'] == event_id)
|
||||
client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'},
|
||||
operator=True, expected=422)
|
||||
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
|
||||
{'note': 'Local test: refunded the underpayment'}, operator=True)
|
||||
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
|
||||
{'note': 'Local test: second resolution'}, operator=True, expected=404)
|
||||
issues = client.call('/operator/board', operator=True)['payment_issues']
|
||||
assert not any(i['event_id'] == event_id for i in issues)
|
||||
print('PASS: refused paid notification is listed until an operator resolves it')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@@ -129,8 +129,16 @@ def run():
|
||||
|
||||
items=[item_spec(m,'2.75',90,uid) for m in ('file','avulsa','uvfile','uv')]
|
||||
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
|
||||
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}}
|
||||
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'},
|
||||
'destination':{'recipient':'Local Smoke Ltda','street':'Rua de Teste','number':'100',
|
||||
'district':'Centro','city':'Franca','state':'SP','postal_code':'14400000'}}
|
||||
client.call('/quotes',{**draft,'total_cents':1},expected=422)
|
||||
# Freight quoted by CEP alone cannot ship: the address is required, must be
|
||||
# the quoted CEP, and a pickup order takes none.
|
||||
client.call('/quotes',{**draft,'destination':None},expected=422)
|
||||
client.call('/quotes',{**draft,'destination':{**draft['destination'],'postal_code':'01001000'}},expected=422)
|
||||
client.call('/quotes',{**draft,'destination':{**draft['destination'],'state':'XX'}},expected=422)
|
||||
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=422)
|
||||
client.call('/quotes',{**draft,'items':[{k:v for k,v in items[0].items() if k!='production'}]},expected=422)
|
||||
outside={**items[0],'production':{**items[0]['production'],
|
||||
'placements':[{**items[0]['production']['placements'][0],'x_cm':1}]}}
|
||||
@@ -139,7 +147,7 @@ def run():
|
||||
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
|
||||
quote=client.call('/quotes',draft)
|
||||
assert client.call('/quotes',draft)['id']==quote['id']
|
||||
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=409)
|
||||
client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409)
|
||||
qid=quote['id']
|
||||
other.call('/quotes/'+qid,expected=404)
|
||||
client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
|
||||
@@ -152,6 +160,7 @@ def run():
|
||||
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
|
||||
assert approved['items'][0]['total_cents']==2189
|
||||
assert approved['total_cents']==2189+6972+19572+23492+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
|
||||
assert approved['destination']=={**draft['destination'],'complement':''}
|
||||
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
|
||||
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
|
||||
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
|
||||
|
||||
122
tests/test_mercadopago.py
Normal file
122
tests/test_mercadopago.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""The Mercado Pago adapter against a fake HTTP transport.
|
||||
|
||||
This proves the adapter follows the documented contract. It does not prove the
|
||||
integration: that needs the sandbox flows with the client's own account.
|
||||
Runs where httpx is installed (the API image, or a local virtualenv).
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import httpx
|
||||
|
||||
from app.mercadopago import MercadoPagoPayment, event_from_payment
|
||||
|
||||
SECRET = 'test-webhook-secret'
|
||||
NOW = 1_790_000_000
|
||||
|
||||
|
||||
def signature(data_id, request_id, ts, secret=SECRET):
|
||||
manifest = ''
|
||||
if data_id:
|
||||
manifest += f'id:{data_id};'
|
||||
if request_id:
|
||||
manifest += f'request-id:{request_id};'
|
||||
manifest += f'ts:{ts};'
|
||||
return f'ts={ts},v1=' + hmac.new(secret.encode(), manifest.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
class MercadoPagoTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.requests = []
|
||||
self.payments = {}
|
||||
|
||||
def handler(request):
|
||||
self.requests.append(request)
|
||||
if request.method == 'GET':
|
||||
payment_id = request.url.path.rsplit('/', 1)[-1]
|
||||
return httpx.Response(200, json=self.payments[payment_id])
|
||||
body = json.loads(request.content)
|
||||
payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer',
|
||||
'point_of_interaction': {'transaction_data': {
|
||||
'qr_code': '000201PIX', 'qr_code_base64': 'aW1n', 'ticket_url': 'https://mp/t'}},
|
||||
**{k: body[k] for k in ('transaction_amount', 'external_reference')}}
|
||||
return httpx.Response(201, json=payment)
|
||||
|
||||
self.mp = MercadoPagoPayment('TEST-token', SECRET, 'https://dtf.example/api/payments/webhook',
|
||||
transport=httpx.MockTransport(handler), clock=lambda: NOW)
|
||||
|
||||
def test_signature_follows_the_documented_manifest(self):
|
||||
headers = {'x-signature': signature('123456', 'req-1', NOW), 'x-request-id': 'req-1'}
|
||||
self.assertTrue(self.mp.verify(headers, b'{}', {'data.id': '123456'}))
|
||||
# Any change to the signed values breaks it.
|
||||
self.assertFalse(self.mp.verify(headers, b'{}', {'data.id': '123457'}))
|
||||
self.assertFalse(self.mp.verify({**headers, 'x-request-id': 'req-2'}, b'{}', {'data.id': '123456'}))
|
||||
self.assertFalse(self.mp.verify({'x-signature': signature('123456', 'req-1', NOW, 'other'),
|
||||
'x-request-id': 'req-1'}, b'{}', {'data.id': '123456'}))
|
||||
self.assertFalse(self.mp.verify({}, b'{}', {'data.id': '123456'}))
|
||||
|
||||
def test_absent_values_are_left_out_and_alphanumeric_ids_lowercased(self):
|
||||
self.assertTrue(self.mp.verify({'x-signature': signature('abc123', None, NOW)}, b'{}',
|
||||
{'data.id': 'ABC123'}))
|
||||
|
||||
def test_old_signatures_are_refused(self):
|
||||
old = NOW - 3600
|
||||
self.assertFalse(self.mp.verify({'x-signature': signature('1', 'r', old), 'x-request-id': 'r'},
|
||||
b'{}', {'data.id': '1'}))
|
||||
|
||||
def test_notification_is_only_a_pointer(self):
|
||||
# The body claims nothing about amount or status; the API is asked.
|
||||
self.payments['999'] = {'id': 999, 'status': 'approved', 'currency_id': 'BRL',
|
||||
'transaction_amount': 123.45, 'external_reference': 'quote-1'}
|
||||
body = json.dumps({'id': 42, 'type': 'payment', 'action': 'payment.updated',
|
||||
'data': {'id': '999'}}).encode()
|
||||
event = self.mp.parse(body, {'data.id': '999', 'type': 'payment'})
|
||||
self.assertEqual((event.status, event.amount_cents, event.reference), ('approved', 12345, 'quote-1'))
|
||||
self.assertEqual(event.event_id, '999:approved')
|
||||
self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token')
|
||||
self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode()))
|
||||
|
||||
def test_amounts_outside_brl_centavos_are_not_trusted(self):
|
||||
for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
|
||||
{'currency_id': 'BRL', 'transaction_amount': 10.001},
|
||||
{'currency_id': 'BRL', 'transaction_amount': None}):
|
||||
self.assertIsNone(event_from_payment({'id': 1, 'status': 'approved', **payment}).amount_cents)
|
||||
self.assertEqual(event_from_payment({'id': 1, 'status': 'approved', 'currency_id': 'BRL',
|
||||
'transaction_amount': 0.1}).amount_cents, 10)
|
||||
|
||||
def test_statuses_map_to_the_service_vocabulary(self):
|
||||
for provider, ours in (('in_process', 'pending'), ('charged_back', 'refunded'),
|
||||
('cancelled', 'cancelled'), ('rejected', 'rejected')):
|
||||
self.assertEqual(event_from_payment({'id': 1, 'status': provider}).status, ours)
|
||||
|
||||
def test_pix_payment_is_idempotent_on_the_quote(self):
|
||||
created = self.mp.create('11111111-2222-3333-4444-555555555555', 12345,
|
||||
{'mail': 'a@example.test', 'cnpj': '11222333000181'})
|
||||
request = self.requests[-1]
|
||||
body = json.loads(request.content)
|
||||
self.assertEqual(request.headers['x-idempotency-key'],
|
||||
'dtf-quote-11111111-2222-3333-4444-555555555555-pix')
|
||||
self.assertEqual((body['payment_method_id'], body['transaction_amount']), ('pix', 123.45))
|
||||
self.assertEqual(body['external_reference'], '11111111-2222-3333-4444-555555555555')
|
||||
self.assertEqual(body['notification_url'], 'https://dtf.example/api/payments/webhook')
|
||||
self.assertEqual((created['pix_qr_code'], created['status']), ('000201PIX', 'pending'))
|
||||
|
||||
def test_card_payment_uses_the_browser_token_only(self):
|
||||
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
|
||||
{'type': 'card', 'token': 'tok_abc', 'payment_method_id': 'visa', 'installments': 3})
|
||||
request = self.requests[-1]
|
||||
body = json.loads(request.content)
|
||||
self.assertEqual((body['token'], body['payment_method_id'], body['installments']), ('tok_abc', 'visa', 3))
|
||||
self.assertNotIn('card_number', json.dumps(body))
|
||||
# A new card attempt after a decline must not collide with the first.
|
||||
first_key = request.headers['x-idempotency-key']
|
||||
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
|
||||
{'type': 'card', 'token': 'tok_def', 'payment_method_id': 'visa'})
|
||||
self.assertNotEqual(self.requests[-1].headers['x-idempotency-key'], first_key)
|
||||
self.assertTrue(first_key.startswith('dtf-quote-q-card-'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
297
tests/test_printfile.py
Normal file
297
tests/test_printfile.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""The print file must reproduce the reviewed layout: size, place, turn and mirror.
|
||||
|
||||
Runs where Pillow is installed (the API image, or a local virtualenv). The
|
||||
raster checks additionally need PyMuPDF to draw the page, and skip without it;
|
||||
the structural checks do not.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
import zlib
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.printfile import PT_PER_CM, Unsupported, placement_matrix, render
|
||||
|
||||
try:
|
||||
import fitz # PyMuPDF, a development-only rasteriser
|
||||
except ImportError:
|
||||
fitz = None
|
||||
|
||||
RED, GREEN, BLUE, YELLOW = (255, 0, 0), (0, 160, 0), (0, 0, 255), (255, 220, 0)
|
||||
|
||||
|
||||
def quadrants(width=80, height=40, alpha=False):
|
||||
"""Top-left red, top-right green, bottom-left blue, bottom-right yellow."""
|
||||
image = Image.new('RGBA' if alpha else 'RGB', (width, height))
|
||||
for x in range(width):
|
||||
for y in range(height):
|
||||
left, top = x < width // 2, y < height // 2
|
||||
color = RED if left and top else GREEN if top else BLUE if left else YELLOW
|
||||
image.putpixel((x, y), color + ((255,) if alpha else ()))
|
||||
return image
|
||||
|
||||
|
||||
def item(placements, sources, height_cm, billed='1.0', film=57):
|
||||
return {'mode': 'avulsa', 'billed_metres': billed,
|
||||
'production': {'version': 2, 'film_width_cm': film, 'height_cm': height_cm,
|
||||
'sources': sources, 'placements': placements}}
|
||||
|
||||
|
||||
def source(width_cm, length_cm, copies=1, rotation=0, mirrored=False):
|
||||
return {'upload_id': '00000000-0000-0000-0000-000000000000', 'kind': 'artwork',
|
||||
'width_cm': width_cm, 'length_cm': length_cm, 'copies': copies,
|
||||
'rotation_degrees': rotation, 'mirrored': mirrored, 'measurement': 'file'}
|
||||
|
||||
|
||||
def placement(x, y, width, length, rotation=0, mirrored=False, index=0, copy=0):
|
||||
return {'source_index': index, 'copy_index': copy, 'x_cm': x, 'y_cm': y,
|
||||
'width_cm': width, 'length_cm': length,
|
||||
'rotation_degrees': rotation, 'mirrored': mirrored}
|
||||
|
||||
|
||||
class PrintFileTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.dir.cleanup)
|
||||
|
||||
def save(self, image, name, **options):
|
||||
path = os.path.join(self.dir.name, name)
|
||||
image.save(path, **options)
|
||||
return path
|
||||
|
||||
def render(self, spec, paths):
|
||||
out = io.BytesIO()
|
||||
detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test')
|
||||
return out.getvalue(), detail
|
||||
|
||||
def test_page_is_the_film_and_the_layout_length(self):
|
||||
path = self.save(quadrants(), 'a.png')
|
||||
pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)],
|
||||
[source(20, 10, copies=2)], 10), [path])
|
||||
box = re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf)
|
||||
self.assertAlmostEqual(float(box.group(1)), 57 * PT_PER_CM, places=2)
|
||||
self.assertAlmostEqual(float(box.group(2)), 10 * PT_PER_CM, places=2)
|
||||
# One embedded image, drawn once per copy.
|
||||
self.assertEqual(pdf.count(b'/Subtype /Image'), 1)
|
||||
content = self.content(pdf)
|
||||
self.assertEqual(content.count(b' Do '), 2)
|
||||
self.assertEqual(detail['placements'], 2)
|
||||
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
|
||||
self.assertTrue(pdf.startswith(b'%PDF-1.6') and pdf.rstrip().endswith(b'%%EOF'))
|
||||
|
||||
def content(self, pdf):
|
||||
# The page content is the last Flate stream before the page object.
|
||||
streams = re.findall(rb'/Filter /FlateDecode/Length \d+ 0 R>>\nstream\n(.*?)\nendstream', pdf, re.S)
|
||||
return zlib.decompress(streams[-1])
|
||||
|
||||
def test_jpeg_bytes_are_embedded_unchanged(self):
|
||||
path = self.save(quadrants(), 'a.jpg', quality=90)
|
||||
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
|
||||
with open(path, 'rb') as original:
|
||||
self.assertIn(original.read(), pdf)
|
||||
self.assertIn(b'/DCTDecode', pdf)
|
||||
|
||||
def test_transparency_survives_as_a_soft_mask(self):
|
||||
image = quadrants(alpha=True)
|
||||
image.putpixel((0, 0), (0, 0, 0, 0))
|
||||
path = self.save(image, 'a.png')
|
||||
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
|
||||
self.assertIn(b'/SMask', pdf)
|
||||
self.assertEqual(pdf.count(b'/Subtype /Image'), 2)
|
||||
|
||||
def test_exif_orientation_is_honoured_like_a_browser(self):
|
||||
# Stored landscape, tagged "rotate 90": browsers show it portrait.
|
||||
image = quadrants(80, 40)
|
||||
exif = Image.Exif()
|
||||
exif[0x0112] = 6
|
||||
path = self.save(image, 'a.jpg', exif=exif)
|
||||
pdf, _ = self.render(item([placement(0, 0, 10, 20)], [source(10, 20)], 20), [path])
|
||||
self.assertNotIn(b'/DCTDecode', pdf)
|
||||
with self.assertRaises(Unsupported):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
|
||||
|
||||
def test_refuses_what_it_cannot_reproduce(self):
|
||||
png = self.save(quadrants(), 'a.png')
|
||||
with self.assertRaisesRegex(Unsupported, 'proportions'):
|
||||
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
|
||||
with self.assertRaisesRegex(Unsupported, 'billed'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png])
|
||||
fake_pdf = os.path.join(self.dir.name, 'art.pdf')
|
||||
with open(fake_pdf, 'wb') as handle:
|
||||
handle.write(b'%PDF-1.4\n%%EOF\n')
|
||||
with self.assertRaisesRegex(Unsupported, 'not a PDF'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
|
||||
cdr = os.path.join(self.dir.name, 'art.cdr')
|
||||
with open(cdr, 'wb') as handle:
|
||||
handle.write(b'not artwork')
|
||||
with self.assertRaisesRegex(Unsupported, 'not an image'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr])
|
||||
|
||||
def test_long_layouts_scale_user_space_instead_of_splitting(self):
|
||||
# 6 m is longer than a PDF page may be (about 5.08 m).
|
||||
path = self.save(quadrants(40, 800), 'a.png')
|
||||
pdf, detail = self.render(item([placement(0, 0, 30, 600)], [source(30, 600)], 600,
|
||||
billed='6.0'), [path])
|
||||
self.assertEqual(detail['user_unit'], 2)
|
||||
self.assertIn(b'/UserUnit 2', pdf)
|
||||
|
||||
def test_matrix_maps_corners_like_the_canvas(self):
|
||||
# Box 20 x 10 cm at the page's top-left; which image corner lands where.
|
||||
page = 10 * PT_PER_CM
|
||||
|
||||
def corner(matrix, u, v):
|
||||
a, b, c, d, e, f = matrix
|
||||
x, y = a * u + c * v + e, b * u + d * v + f
|
||||
return round(x / PT_PER_CM, 6), round((page - y) / PT_PER_CM, 6)
|
||||
|
||||
# Image top-left is unit (0, 1).
|
||||
cases = {(0, False): (0, 0), (90, False): (20, 0), (180, False): (20, 10),
|
||||
(270, False): (0, 10), (0, True): (20, 0), (90, True): (20, 10)}
|
||||
for (rotation, mirrored), expected in cases.items():
|
||||
matrix = placement_matrix(placement(0, 0, 20, 10, rotation, mirrored), page)
|
||||
self.assertEqual(corner(matrix, 0, 1), expected, (rotation, mirrored))
|
||||
|
||||
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
|
||||
def test_drawn_page_matches_the_layout(self):
|
||||
# Each case: the colour expected in the box's TL, TR, BL, BR quadrant.
|
||||
cases = {
|
||||
(0, False): (RED, GREEN, BLUE, YELLOW),
|
||||
(90, False): (BLUE, RED, YELLOW, GREEN),
|
||||
(180, False): (YELLOW, BLUE, GREEN, RED),
|
||||
(0, True): (GREEN, RED, YELLOW, BLUE),
|
||||
(90, True): (YELLOW, GREEN, BLUE, RED),
|
||||
}
|
||||
image = quadrants(80, 40)
|
||||
for (rotation, mirrored), expected in cases.items():
|
||||
turned = rotation % 180 == 90
|
||||
width, length = (10, 20) if turned else (20, 10)
|
||||
path = self.save(image, f'q{rotation}{mirrored}.png')
|
||||
# Sources are described after the customer's turn, like the box.
|
||||
spec = item([placement(12, 5, width, length, rotation, mirrored)],
|
||||
[source(width, length, rotation=rotation % 180, mirrored=mirrored)], 30)
|
||||
pdf, _ = self.render(spec, [path])
|
||||
page = fitz.open(stream=pdf, filetype='pdf')[0]
|
||||
dpi = 40
|
||||
pixmap = page.get_pixmap(dpi=dpi, alpha=False)
|
||||
|
||||
def sample(x_cm, y_cm):
|
||||
px = int(x_cm / 2.54 * dpi)
|
||||
py = int(y_cm / 2.54 * dpi)
|
||||
return pixmap.pixel(px, py)
|
||||
|
||||
got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25),
|
||||
sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75))
|
||||
for actual, wanted in zip(got, expected):
|
||||
self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)),
|
||||
f'rotation {rotation} mirrored {mirrored}: {got} != {expected}')
|
||||
# Outside the box the film stays empty.
|
||||
self.assertEqual(sample(2, 2), (255, 255, 255))
|
||||
|
||||
|
||||
class PdfSourceTests(unittest.TestCase):
|
||||
"""A customer PDF is placed as a vector form, sized and turned like an image."""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.dir.cleanup)
|
||||
|
||||
def pdf(self, name, image=None, rotate=None, crop=None, pages=1, password=None):
|
||||
import pikepdf
|
||||
path = os.path.join(self.dir.name, name)
|
||||
image = image or quadrants(80, 40)
|
||||
image.save(path, 'PDF', resolution=72, save_all=pages > 1,
|
||||
append_images=[image] * (pages - 1))
|
||||
if rotate is not None or crop or password:
|
||||
with pikepdf.open(path, allow_overwriting_input=True) as document:
|
||||
if rotate is not None:
|
||||
document.Root.Pages.Rotate = rotate # inherited, not on the page
|
||||
if crop:
|
||||
document.pages[0].obj.CropBox = pikepdf.Array(crop)
|
||||
encryption = pikepdf.Encryption(owner=password, user=password) if password else None
|
||||
document.save(path, encryption=encryption or False)
|
||||
return path
|
||||
|
||||
def render(self, spec, paths):
|
||||
out = io.BytesIO()
|
||||
detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test')
|
||||
return out.getvalue(), detail
|
||||
|
||||
def test_pdf_page_is_a_vector_form_placed_per_copy(self):
|
||||
path = self.pdf('sheet.pdf')
|
||||
pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)],
|
||||
[source(20, 10, copies=2)], 10), [path])
|
||||
self.assertTrue(pdf.startswith(b'%PDF-1.6'))
|
||||
self.assertEqual(detail['vector_sources'], 1)
|
||||
self.assertIsNone(detail['min_dpi'])
|
||||
import pikepdf
|
||||
with pikepdf.open(io.BytesIO(pdf)) as document:
|
||||
page = document.pages[0]
|
||||
forms = [x for x in page.Resources.XObject.values() if x.Subtype == '/Form']
|
||||
self.assertEqual(len(forms), 1)
|
||||
self.assertAlmostEqual(float(page.mediabox[2]), 57 * PT_PER_CM, places=2)
|
||||
content = b''.join(s.read_bytes() for s in page.obj.Contents) if isinstance(page.obj.Contents, pikepdf.Array) else page.obj.Contents.read_bytes()
|
||||
self.assertEqual(content.count(b'/Pdf0 Do'), 2)
|
||||
|
||||
def test_mixed_raster_and_pdf_sources(self):
|
||||
png = os.path.join(self.dir.name, 'a.png')
|
||||
quadrants(80, 40).save(png)
|
||||
pdf_path = self.pdf('b.pdf')
|
||||
spec = item([placement(0, 0, 20, 10), placement(0, 0, 20, 10, index=1)],
|
||||
[source(20, 10), source(20, 10)], 10)
|
||||
spec['production']['placements'][1]['x_cm'] = 25
|
||||
pdf, detail = self.render(spec, [png, pdf_path])
|
||||
self.assertEqual((detail['sources'], detail['vector_sources']), (2, 1))
|
||||
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
|
||||
|
||||
def test_refuses_pdfs_it_cannot_place(self):
|
||||
with self.assertRaisesRegex(Unsupported, '2 pages'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('two.pdf', pages=2)])
|
||||
with self.assertRaisesRegex(Unsupported, 'password'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10),
|
||||
[self.pdf('locked.pdf', password='secret')])
|
||||
with self.assertRaisesRegex(Unsupported, 'proportions'):
|
||||
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [self.pdf('square.pdf')])
|
||||
|
||||
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
|
||||
def test_drawn_pdf_matches_what_the_site_measured(self):
|
||||
framed = Image.new('RGB', (100, 60), (255, 255, 255))
|
||||
framed.paste(quadrants(80, 40), (10, 10))
|
||||
cases = {
|
||||
# (page /Rotate, placement rotation, mirrored) -> TL, TR, BL, BR as drawn
|
||||
'plain': (self.pdf('plain.pdf'), 0, False, (RED, GREEN, BLUE, YELLOW)),
|
||||
# CropBox trims the white frame, exactly as pdf.js shows the page.
|
||||
'cropped': (self.pdf('crop.pdf', image=framed, crop=[10, 10, 90, 50]), 0, False,
|
||||
(RED, GREEN, BLUE, YELLOW)),
|
||||
# An inherited /Rotate 90 is displayed turned clockwise.
|
||||
'rotated page': (self.pdf('rot.pdf', rotate=90), 0, False, (BLUE, RED, YELLOW, GREEN)),
|
||||
'placement turn': (self.pdf('turn.pdf'), 90, False, (BLUE, RED, YELLOW, GREEN)),
|
||||
'mirrored': (self.pdf('mirror.pdf'), 0, True, (GREEN, RED, YELLOW, BLUE)),
|
||||
}
|
||||
for label, (path, turn, mirrored, expected) in cases.items():
|
||||
displayed_portrait = label == 'rotated page'
|
||||
portrait = displayed_portrait != (turn == 90)
|
||||
width, length = (10, 20) if portrait else (20, 10)
|
||||
src = (10, 20) if displayed_portrait else (20, 10)
|
||||
spec = item([placement(12, 5, width, length, turn, mirrored)],
|
||||
[source(*src, rotation=0, mirrored=mirrored)], 30)
|
||||
pdf, _ = self.render(spec, [path])
|
||||
page = fitz.open(stream=pdf, filetype='pdf')[0]
|
||||
dpi = 40
|
||||
pixmap = page.get_pixmap(dpi=dpi, alpha=False)
|
||||
|
||||
def sample(x_cm, y_cm):
|
||||
return pixmap.pixel(int(x_cm / 2.54 * dpi), int(y_cm / 2.54 * dpi))
|
||||
|
||||
got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25),
|
||||
sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75))
|
||||
for actual, wanted in zip(got, expected):
|
||||
self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)),
|
||||
f'{label}: {got} != {expected}')
|
||||
self.assertEqual(sample(2, 2), (255, 255, 255), label)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
116
tests/test_tiny.py
Normal file
116
tests/test_tiny.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""The Tiny v3 adapter against a fake HTTP transport: payload and idempotency.
|
||||
|
||||
This proves the documented contract only; the client's account must still
|
||||
confirm products, contacts and order fields. Runs where httpx is installed.
|
||||
The OAuth connection against the real database is tests/tiny_oauth_test.py.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from datetime import date
|
||||
from unittest import mock
|
||||
|
||||
import httpx
|
||||
|
||||
from app.tiny import TinyError, TinyOrders, contact_payload, order_payload
|
||||
|
||||
PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event': 'payment_approved',
|
||||
'order': {'customer': {'cnpj': '11222333000181', 'zap': '16999999999', 'mail': 'loja@example.test'},
|
||||
'items': [{'mode': 'avulsa', 'grade': 90, 'billed_metres': '2.8', 'unit_cents': 2490}],
|
||||
'freight': {'service': 'mock-standard', 'total_cents': 1500},
|
||||
'destination': {'recipient': 'Loja Teste', 'street': 'Rua A', 'number': '10',
|
||||
'complement': '', 'district': 'Centro', 'city': 'Franca',
|
||||
'state': 'SP', 'postal_code': '14400000'},
|
||||
'total_cents': 8472}}
|
||||
PRODUCTS = {'TINY_PRODUCT_TEXTIL_FOLHA': '101', 'TINY_PRODUCT_TEXTIL_AVULSA': '102',
|
||||
'TINY_PRODUCT_UV_FOLHA': '103', 'TINY_PRODUCT_UV_AVULSA': '104'}
|
||||
|
||||
|
||||
class FakeAuth:
|
||||
def access_token(self):
|
||||
return 'access-1'
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PRODUCTS)
|
||||
class TinyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.contacts = []
|
||||
self.orders = []
|
||||
self.calls = []
|
||||
|
||||
def handler(request):
|
||||
path = request.url.path.removeprefix('/public-api/v3')
|
||||
self.calls.append((request.method, path))
|
||||
assert request.headers['authorization'] == 'Bearer access-1'
|
||||
if request.method == 'GET' and path == '/contatos':
|
||||
cnpj = request.url.params['cpfCnpj']
|
||||
return httpx.Response(200, json={'itens': [c for c in self.contacts if c['cpfCnpj'] == cnpj]})
|
||||
if request.method == 'POST' and path == '/contatos':
|
||||
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
|
||||
self.contacts.append(contact)
|
||||
return httpx.Response(200, json={'id': contact['id']})
|
||||
if request.method == 'GET' and path == '/pedidos':
|
||||
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
|
||||
if request.method == 'GET' and path.startswith('/pedidos/'):
|
||||
wanted = int(path.rsplit('/', 1)[-1])
|
||||
return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted))
|
||||
if request.method == 'POST' and path == '/pedidos':
|
||||
order = {**json.loads(request.content), 'id': 9000 + len(self.orders),
|
||||
'numeroPedido': str(100 + len(self.orders))}
|
||||
self.orders.append(order)
|
||||
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
|
||||
return httpx.Response(404)
|
||||
|
||||
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
|
||||
today=date(2026, 9, 24))
|
||||
|
||||
def test_payload_carries_contact_products_address_and_freight(self):
|
||||
pedido = order_payload(PAID, 777, date(2026, 9, 24))
|
||||
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
|
||||
(777, 'DTF-42', '2026-09-24'))
|
||||
item = pedido['itens'][0]
|
||||
self.assertEqual((item['produto'], item['quantidade'], item['valorUnitario']),
|
||||
({'id': 102}, 2.8, 24.9))
|
||||
self.assertEqual(pedido['valorFrete'], 15.0)
|
||||
self.assertEqual((pedido['enderecoEntrega']['cep'], pedido['enderecoEntrega']['enderecoNro'],
|
||||
pedido['enderecoEntrega']['nomeDestinatario']), ('14400000', '10', 'Loja Teste'))
|
||||
contact = contact_payload(PAID['order'])
|
||||
self.assertEqual((contact['tipoPessoa'], contact['cpfCnpj'], contact['endereco']['uf']),
|
||||
('J', '11222333000181', 'SP'))
|
||||
pickup = order_payload({**PAID, 'order': {**PAID['order'], 'destination': None,
|
||||
'freight': {'service': 'pickup', 'total_cents': 0}}}, 1)
|
||||
self.assertNotIn('enderecoEntrega', pickup)
|
||||
self.assertIn('retirada', pickup['observacoes'])
|
||||
|
||||
def test_second_delivery_finds_the_first_order(self):
|
||||
first = self.tiny.deliver('k1', PAID)
|
||||
second = self.tiny.deliver('k1', PAID)
|
||||
self.assertEqual((first['status'], second['status']), ('created', 'already-created'))
|
||||
self.assertEqual(first['tiny_id'], second['tiny_id'])
|
||||
self.assertEqual(self.calls.count(('POST', '/pedidos')), 1)
|
||||
self.assertEqual(self.calls.count(('POST', '/contatos')), 1)
|
||||
|
||||
def test_existing_contact_is_reused(self):
|
||||
self.contacts.append({'id': 321, 'cpfCnpj': '11222333000181'})
|
||||
self.tiny.deliver('k1', PAID)
|
||||
self.assertNotIn(('POST', '/contatos'), self.calls)
|
||||
self.assertEqual(self.orders[0]['idContato'], 321)
|
||||
|
||||
def test_production_events_are_not_sent(self):
|
||||
self.assertEqual(self.tiny.deliver('k2', {**PAID, 'event': 'ready'})['status'], 'not-applicable')
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_missing_product_mapping_fails_rather_than_guessing(self):
|
||||
with mock.patch.dict(os.environ, {'TINY_PRODUCT_TEXTIL_AVULSA': ''}):
|
||||
with self.assertRaises(TinyError):
|
||||
self.tiny.deliver('k3', PAID)
|
||||
self.assertNotIn(('POST', '/pedidos'), self.calls)
|
||||
|
||||
def test_rate_limit_is_a_retryable_failure(self):
|
||||
tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429)))
|
||||
with self.assertRaisesRegex(TinyError, 'rate limit'):
|
||||
tiny.deliver('k4', PAID)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
105
tests/tiny_oauth_test.py
Normal file
105
tests/tiny_oauth_test.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""The Tiny OAuth connection against the real database, with a fake token server.
|
||||
|
||||
Run inside the API container: python -m tests.tiny_oauth_test
|
||||
It saves any existing Tiny connection first and restores it afterwards.
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.db import connect
|
||||
from app.tiny import TinyAuth, TinyError, TinyNotConnected
|
||||
|
||||
os.environ.update(TINY_CLIENT_ID='test-client', TINY_CLIENT_SECRET='test-secret',
|
||||
TINY_REDIRECT_URI='http://localhost:8081/api/operator/tiny/callback')
|
||||
|
||||
|
||||
def run():
|
||||
with connect() as c:
|
||||
saved = c.execute("SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone()
|
||||
c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'")
|
||||
try:
|
||||
exercise()
|
||||
finally:
|
||||
with connect() as c:
|
||||
c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'")
|
||||
if saved:
|
||||
columns = ','.join(saved)
|
||||
c.execute(f'INSERT INTO dtf_local.provider_tokens({columns}) VALUES({",".join(["%s"] * len(saved))})',
|
||||
tuple(saved.values()))
|
||||
|
||||
|
||||
def exercise():
|
||||
issued = []
|
||||
|
||||
def token_server(request):
|
||||
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
||||
assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
|
||||
if form['grant_type'] == 'authorization_code':
|
||||
assert form['code'] == 'good-code' and form['redirect_uri'].endswith('/tiny/callback')
|
||||
elif form['grant_type'] == 'refresh_token':
|
||||
if form['refresh_token'] != issued[-1]:
|
||||
return httpx.Response(400, json={'error': 'invalid_grant'})
|
||||
n = len(issued) + 1
|
||||
issued.append(f'refresh-{n}')
|
||||
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400})
|
||||
|
||||
auth = TinyAuth(transport=httpx.MockTransport(token_server))
|
||||
assert auth.status() == {'connected': False}
|
||||
try:
|
||||
auth.access_token()
|
||||
raise AssertionError('an unconnected Tiny must not yield a token')
|
||||
except TinyNotConnected:
|
||||
pass
|
||||
|
||||
url = urlparse(auth.authorize_url('operator@example.test'))
|
||||
query = {k: v[0] for k, v in parse_qs(url.query).items()}
|
||||
assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client'
|
||||
assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
|
||||
try:
|
||||
auth.complete('good-code', 'forged-state')
|
||||
raise AssertionError('a state no operator created must be refused')
|
||||
except TinyError:
|
||||
pass
|
||||
assert auth.complete('good-code', query['state']) == 'operator@example.test'
|
||||
try:
|
||||
auth.complete('good-code', query['state'])
|
||||
raise AssertionError('a state must be single-use')
|
||||
except TinyError:
|
||||
pass
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['connected_by'] == 'operator@example.test'
|
||||
assert auth.access_token() == 'access-1'
|
||||
print('PASS: operator-started state is required, single-use, and stores the connection')
|
||||
|
||||
# An access token about to expire is refreshed, and the refresh token rotates.
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
|
||||
assert auth.access_token() == 'access-2'
|
||||
with connect() as c:
|
||||
row = c.execute("SELECT refresh_token,connected_by FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone()
|
||||
assert row == {'refresh_token': 'refresh-2', 'connected_by': 'operator@example.test'}
|
||||
assert auth.access_token() == 'access-2'
|
||||
print('PASS: expiring access token refreshed once, refresh token rotated and stored')
|
||||
|
||||
# A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
|
||||
with connect() as c:
|
||||
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
|
||||
WHERE provider='tiny'""")
|
||||
try:
|
||||
auth.access_token()
|
||||
raise AssertionError('a refused refresh must report the connection as lost')
|
||||
except TinyNotConnected:
|
||||
pass
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
|
||||
(datetime.now(timezone.utc) - timedelta(seconds=1),))
|
||||
assert not auth.status()['connected']
|
||||
print('PASS: revoked or expired connections report that Tiny must be connected again')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@@ -35,6 +35,9 @@
|
||||
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;}
|
||||
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
|
||||
$('cepIn').value=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]||'';
|
||||
$('atual').style.display='none';pintaEntrega();
|
||||
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
|
||||
}else{
|
||||
|
||||
117
web/checkout.js
117
web/checkout.js
@@ -73,6 +73,7 @@
|
||||
if (busy) return;
|
||||
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
|
||||
if (!clienteOk() || !entrega.cotado) return;
|
||||
if (!enderecoOk()) return message('Preencha o endereço de entrega.');
|
||||
if (!cartPodeEnviar()) return message('Revise a qualidade e confirme a ressalva de cada item antes de enviar o pedido.');
|
||||
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
if (!cart.length) return message('Adicione um item ao pedido.');
|
||||
@@ -95,6 +96,8 @@
|
||||
quality_status:item.qualityStatus,quality_acknowledged:item.qualityAcknowledged});
|
||||
}
|
||||
const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}};
|
||||
const destination = destinoApi();
|
||||
if (destination) content.destination = destination;
|
||||
if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.');
|
||||
const serialized = JSON.stringify(content);
|
||||
if (!requestKey || serialized !== requestBody) {
|
||||
@@ -127,6 +130,23 @@
|
||||
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
|
||||
} else if (quote.status==='approved') {
|
||||
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
|
||||
if ((await ready).payment_provider === 'mercadopago') {
|
||||
button('Pagar com PIX',async event=>{
|
||||
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
|
||||
event.target.disabled=true;
|
||||
try { showPix(await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}})); }
|
||||
catch(error) { message(error.message); event.target.disabled=false; }
|
||||
});
|
||||
if ((await ready).payment_public_key) {
|
||||
button('Pagar com cartão',async event=>{
|
||||
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
|
||||
event.target.disabled=true;
|
||||
try { await showCard(quote.approved.total_cents); }
|
||||
catch(error) { message(error.message); event.target.disabled=false; }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ((await ready).environment !== 'local') {
|
||||
message('Cotação revisada. O pagamento online ainda não está disponível.');
|
||||
return;
|
||||
@@ -156,6 +176,103 @@
|
||||
button('Limpar referência e tentar de novo',clearDraft);
|
||||
}
|
||||
}
|
||||
// PIX: the provider's QR code and copy-and-paste code. The order is created
|
||||
// by the provider's notification, not by this page, so the page only waits.
|
||||
let pixTimer=null;
|
||||
function showPix(intent) {
|
||||
actions.replaceChildren();
|
||||
if (!intent.pix_qr_code) { message('Não foi possível gerar o PIX. Tente de novo em instantes.'); return; }
|
||||
message('Pague o PIX de '+rs(intent.total_cents/100)+'. O pedido entra na produção assim que o pagamento for confirmado.');
|
||||
if (intent.pix_qr_code_base64) {
|
||||
const img=document.createElement('img');
|
||||
img.src='data:image/png;base64,'+intent.pix_qr_code_base64;
|
||||
img.alt='QR code do PIX';img.width=220;img.height=220;img.style.display='block';
|
||||
actions.append(img);
|
||||
}
|
||||
const code=document.createElement('input');
|
||||
code.readOnly=true;code.value=intent.pix_qr_code;code.style.cssText='width:100%;margin-top:8px;padding:8px';
|
||||
actions.append(code);
|
||||
button('Copiar código PIX',async()=>{ try{ await navigator.clipboard.writeText(intent.pix_qr_code); message('Código copiado.'); }catch(_){ code.select(); } });
|
||||
clearInterval(pixTimer);
|
||||
pixTimer=setInterval(async()=>{
|
||||
try {
|
||||
const quote=await api('/quotes/'+draftId);
|
||||
if (quote.status==='paid') {
|
||||
clearInterval(pixTimer);
|
||||
pedido=[]; itemAtual=null; limpaPaineis();
|
||||
await window.dtfClearCart?.();
|
||||
await refresh();
|
||||
}
|
||||
} catch(_) {}
|
||||
},5000);
|
||||
}
|
||||
// Card: Mercado Pago's own form (Card Payment Brick). The card is typed into
|
||||
// Mercado Pago's secure fields and becomes a one-time token; the number never
|
||||
// reaches this page's code or our server. As with PIX, the order is created
|
||||
// by the provider's notification, so the page only waits for it.
|
||||
let sdkLoading=null;
|
||||
function loadMercadoPago() {
|
||||
if (window.MercadoPago) return Promise.resolve();
|
||||
sdkLoading = sdkLoading || new Promise((resolve,reject)=>{
|
||||
const script=document.createElement('script');
|
||||
script.src='https://sdk.mercadopago.com/js/v2';
|
||||
script.onload=resolve;
|
||||
script.onerror=()=>{sdkLoading=null;reject(new Error('Não foi possível carregar o formulário do Mercado Pago.'));};
|
||||
document.head.append(script);
|
||||
});
|
||||
return sdkLoading;
|
||||
}
|
||||
let cardBrick=null;
|
||||
async function showCard(totalCents) {
|
||||
await loadMercadoPago();
|
||||
const session=await ready;
|
||||
actions.replaceChildren();
|
||||
const holder=document.createElement('div');
|
||||
holder.id='cardPaymentBrick';
|
||||
holder.style.cssText='max-width:520px;margin-top:8px';
|
||||
actions.append(holder);
|
||||
message('Pagamento com cartão: '+rs(totalCents/100)+'.');
|
||||
if (cardBrick) { try { await cardBrick.unmount(); } catch(_) {} }
|
||||
const mp=new window.MercadoPago(session.payment_public_key,{locale:'pt-BR'});
|
||||
cardBrick=await mp.bricks().create('cardPayment','cardPaymentBrick',{
|
||||
initialization:{amount:totalCents/100, payer:{email:cliente.mail}},
|
||||
customization:{paymentMethods:{maxInstallments:12}},
|
||||
callbacks:{
|
||||
onReady:()=>{},
|
||||
onError:error=>{ console.error(error); message('Erro no formulário do cartão. Confira os dados e tente de novo.'); },
|
||||
onSubmit:async data=>{
|
||||
const result=await api('/payments/intent',{quote_id:draftId,method:{
|
||||
type:'card', token:data.token, payment_method_id:data.payment_method_id,
|
||||
installments:Number(data.installments)||1,
|
||||
issuer_id:data.issuer_id==null?null:String(data.issuer_id)}});
|
||||
if (result.status==='approved' || result.status==='pending') {
|
||||
message(result.status==='approved'
|
||||
? 'Pagamento aprovado. Seu pedido entra na produção em instantes.'
|
||||
: 'Pagamento em análise pelo Mercado Pago. Avisamos assim que for confirmado.');
|
||||
waitForOrder();
|
||||
} else {
|
||||
message('Pagamento recusado pelo Mercado Pago ('+(result.status_detail||result.status)+'). '+
|
||||
'Confira os dados ou use outro cartão.');
|
||||
throw new Error('rejected');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function waitForOrder() {
|
||||
clearInterval(pixTimer);
|
||||
pixTimer=setInterval(async()=>{
|
||||
try {
|
||||
const quote=await api('/quotes/'+draftId);
|
||||
if (quote.status==='paid') {
|
||||
clearInterval(pixTimer);
|
||||
pedido=[]; itemAtual=null; limpaPaineis();
|
||||
await window.dtfClearCart?.();
|
||||
await refresh();
|
||||
}
|
||||
} catch(_) {}
|
||||
},5000);
|
||||
}
|
||||
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
|
||||
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){
|
||||
if(draftId!==quoteFromPortal){quotedCart=null;localStorage.removeItem('dtf-quote-cart');}
|
||||
|
||||
@@ -644,6 +644,8 @@ h1 em{font-style:normal;color:var(--laranja)}
|
||||
font-family:"Inter",sans-serif;font-weight:600;font-size:12.5px}
|
||||
.cepL button:hover{border-color:var(--laranja);color:var(--laranja)}
|
||||
.cep p{font-size:11.5px;color:var(--fraco);margin-top:9px;line-height:1.5}
|
||||
.cep .endereco{margin-top:12px}
|
||||
.cep .err{color:var(--vermelho)}
|
||||
.avisoE:empty{display:none}
|
||||
.avisoE{font-size:11.5px;color:#1F5C3A;background:#F1FBF5;border:1px solid #BFE6CE;
|
||||
border-radius:8px;padding:10px 12px;line-height:1.5;margin-top:9px}
|
||||
@@ -1229,6 +1231,23 @@ footer a:hover{color:var(--laranja2)}
|
||||
<button id="bCep">Calcular</button>
|
||||
</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>
|
||||
<div class="cp larga"><label>Rua / avenida</label>
|
||||
<input id="eRua" maxlength="160" autocomplete="address-line1"></div>
|
||||
<div class="cp"><label>Número</label>
|
||||
<input id="eNum" maxlength="20" placeholder="123 ou S/N"></div>
|
||||
<div class="cp"><label>Complemento (opcional)</label>
|
||||
<input id="eComp" maxlength="80" autocomplete="address-line2"></div>
|
||||
<div class="cp"><label>Bairro</label>
|
||||
<input id="eBairro" maxlength="80"></div>
|
||||
<div class="cp"><label>Cidade</label>
|
||||
<input id="eCidade" maxlength="80" autocomplete="address-level2"></div>
|
||||
<div class="cp"><label>UF</label>
|
||||
<input id="eUf" maxlength="2" autocomplete="address-level1" placeholder="SP"></div>
|
||||
</div>
|
||||
<p class="err" id="eEnd"></p>
|
||||
</div>
|
||||
<p class="avisoE" id="avisoE"></p>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,12 @@ button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padd
|
||||
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px}
|
||||
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px}
|
||||
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
|
||||
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
||||
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}.print{margin-top:8px;padding-top:8px;border-top:1px solid var(--linha)}.print button{margin-left:6px}#tiny{font-size:12px;color:var(--fraco);margin-right:8px}#tiny button{margin-left:6px}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
||||
</style></head><body>
|
||||
<header><h1>Kanban DTF</h1><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
|
||||
<header><h1>Kanban DTF</h1><span id="tiny"></span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
|
||||
<div class="aviso">Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
|
||||
<form id="login"><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
||||
<p id="status" role="status"></p>
|
||||
<section id="reviews"></section><div id="kan"></div>
|
||||
<section id="payments"></section><section id="reviews"></section><div id="kan"></div>
|
||||
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
|
||||
<script src="/upload.js"></script><script src="/kanban.js?v=operator-email-1"></script></body></html>
|
||||
<script src="/upload.js"></script><script src="/kanban.js?v=pdf-print-1"></script></body></html>
|
||||
|
||||
@@ -40,6 +40,53 @@ function productionLines(container,item){
|
||||
' cm · giro '+source.rotation_degrees+'°'+(source.mirrored?' · espelhada':'')+
|
||||
' · medida '+source.measurement+' · arquivo '+source.upload_id.slice(0,8),'meta')));
|
||||
}
|
||||
const PRINT_STATUS={pending:'na fila para gerar',rendering:'gerando…',ready:'pronto',
|
||||
manual:'preparar à mão',failed:'falhou ao gerar'};
|
||||
function download(uid){return async()=>{
|
||||
const result=await api('/uploads/'+uid+'/download');
|
||||
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
|
||||
};}
|
||||
function printFiles(card,order){
|
||||
const rows=order.print_files||[];
|
||||
const box=node('div',undefined,'print');
|
||||
box.append(node('b','Arquivo de impressão'));
|
||||
order.snapshot.items.forEach((item,index)=>{
|
||||
const row=rows.find(r=>r.item_index===index);
|
||||
const line=node('p','Item '+(index+1)+' · '+(row?PRINT_STATUS[row.status]:'não gerado'),'meta');
|
||||
if(row?.status==='ready'){
|
||||
line.textContent+=' · '+row.detail.film_width_cm+' × '+row.detail.height_cm+' cm'+
|
||||
(row.detail.min_dpi?' · menor resolução '+row.detail.min_dpi+' DPI':'')+
|
||||
(row.detail.vector_sources?' · PDF vetorial':'');
|
||||
line.append(action('Baixar PDF',download(row.upload_id)));
|
||||
}
|
||||
if(row?.status==='manual')line.append(node('span',' · '+row.detail.reason));
|
||||
box.append(line);
|
||||
});
|
||||
const retry=order.snapshot.items.some((_,index)=>{const row=rows.find(r=>r.item_index===index);
|
||||
return !row||row.status==='manual'||row.status==='failed';});
|
||||
if(retry&&['rec','tra'].includes(order.state))
|
||||
box.append(action('Gerar arquivos de impressão',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();}));
|
||||
card.append(box);
|
||||
}
|
||||
function paymentIssues(){
|
||||
$('payments').replaceChildren();
|
||||
if(!board.payment_issues?.length)return;
|
||||
$('payments').append(node('h2','Pagamentos que precisam de atenção · '+board.payment_issues.length));
|
||||
$('payments').append(node('p','Um pagamento chegou sem virar pedido (valor diferente, cotação vencida) ou foi '+
|
||||
'estornado depois do pedido. Confira no painel do provedor, resolva com o cliente e registre o que foi feito.','meta'));
|
||||
for(const issue of board.payment_issues){
|
||||
const card=node('article',undefined,'review');
|
||||
card.append(node('b',issue.provider+' · evento '+issue.event_id),
|
||||
node('p',(issue.amount_cents==null?'valor não informado':money(issue.amount_cents))+' · cotação '+
|
||||
(issue.reference||'—')+' · '+new Date(issue.received_at).toLocaleString('pt-BR'),'meta'),
|
||||
node('p',issue.outcome));
|
||||
card.append(action('Registrar resolução',async()=>{
|
||||
const note=prompt('O que foi feito? (ex.: estornado no Mercado Pago em 25/09)');if(!note)return;
|
||||
await api('/payment-events/'+issue.id+'/resolve',{note});await load();
|
||||
}));
|
||||
$('payments').append(card);
|
||||
}
|
||||
}
|
||||
async function load(){
|
||||
try{
|
||||
board=await api('/board');
|
||||
@@ -47,7 +94,7 @@ async function load(){
|
||||
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length,
|
||||
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length};
|
||||
$('login').hidden=true;
|
||||
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString();
|
||||
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString()+tinyNotice;
|
||||
render();
|
||||
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
|
||||
}
|
||||
@@ -62,7 +109,25 @@ async function loadMoreQuotes(kind){
|
||||
moreQuotes[kind]=page.has_more;
|
||||
render();
|
||||
}
|
||||
// The one-time authorisation of this system in the client's Tiny. Shown only
|
||||
// when the Tiny application is configured on the server.
|
||||
function tinyStatus(){
|
||||
const box=$('tiny');box.replaceChildren();
|
||||
const tiny=board.tiny||{};
|
||||
if(!tiny.configured)return;
|
||||
box.append(node('span',tiny.connected
|
||||
? 'Tiny conectado'+(tiny.orders_enabled?'':' · envio de pedidos desligado')
|
||||
: 'Tiny não conectado'));
|
||||
box.append(action(tiny.connected?'Reconectar Tiny':'Conectar Tiny',async()=>{
|
||||
const result=await api('/tiny/connect',{});location.href=result.url;
|
||||
}));
|
||||
}
|
||||
const tinyResult=new URLSearchParams(location.search).get('tiny');
|
||||
const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):'';
|
||||
if(tinyResult)history.replaceState(null,'',location.pathname);
|
||||
function render(){
|
||||
tinyStatus();
|
||||
paymentIssues();
|
||||
$('reviews').replaceChildren();
|
||||
if(board.pending_total || board.approved_total)
|
||||
$('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+
|
||||
@@ -107,11 +172,15 @@ function render(){
|
||||
column.append(node('h2',title+' · '+count));
|
||||
for(const order of orders){
|
||||
const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id;
|
||||
card.append(node('b','#'+order.number+' · Pago local'),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
|
||||
card.append(node('b','#'+order.number+' · '+(order.payment?.provider==='fake'?'Pago local':'Pago')),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
|
||||
const to=order.snapshot.destination;
|
||||
card.append(node('p',to?'Entrega: '+to.recipient+' · '+to.street+', '+to.number+(to.complement?' '+to.complement:'')+
|
||||
' · '+to.district+' · '+to.city+'/'+to.state+' · CEP '+to.postal_code:'Retirada em Franca','meta'));
|
||||
for(const item of order.snapshot.items){
|
||||
card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
|
||||
productionLines(card,item);
|
||||
}
|
||||
printFiles(card,order);
|
||||
const actions=node('div',undefined,'actions');files(actions,order.snapshot.items);
|
||||
const artwork=node('div');
|
||||
actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork)));
|
||||
@@ -140,22 +209,39 @@ async function artworkPanel(order,container){
|
||||
for(const file of revisions){
|
||||
const row=node('p',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':file.active?'atual':'substituído'),'meta');
|
||||
row.append(node('p',file.note));
|
||||
if(!file.expired)row.append(action('Baixar '+file.name,async()=>{const result=await api('/uploads/'+file.upload_id+'/download');const link=node('a');link.href=result.url;link.download=result.name;link.referrerPolicy='no-referrer';link.click();}));
|
||||
if(!file.expired)row.append(action('Baixar '+file.name,download(file.upload_id)));
|
||||
container.append(row);
|
||||
}
|
||||
if(!['rec','tra','cor'].includes(order.state))return;
|
||||
const form=node('form');
|
||||
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.'));
|
||||
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';label.append(input);form.append(label);return input;});
|
||||
// A generated print file reproduces the approved layout; offer it first, and
|
||||
// keep the upload for items that need hand preparation. Not after a
|
||||
// correction: the file was made from artwork the customer has replaced.
|
||||
const corrected=revisions.some(file=>file.kind==='correction');
|
||||
const inputs=order.snapshot.items.map((item,index)=>{
|
||||
const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';
|
||||
const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';
|
||||
const generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready');
|
||||
if(generated){
|
||||
const use=node('input');use.type='checkbox';use.checked=true;input.required=false;input.hidden=true;
|
||||
use.onchange=()=>{input.hidden=use.checked;input.required=!use.checked;};
|
||||
const choice=node('label',' Usar o arquivo gerado ('+generated.name+')');choice.prepend(use);choice.style.display='block';
|
||||
label.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
|
||||
}
|
||||
label.append(input);form.append(label);return input;});
|
||||
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note);
|
||||
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label);
|
||||
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit);
|
||||
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
|
||||
const refs=[];
|
||||
for(const [index,input] of inputs.entries())for(const file of input.files){
|
||||
for(const [index,input] of inputs.entries()){
|
||||
const generated=input.generated?.();
|
||||
if(generated){refs.push({item_index:index,upload_id:generated});continue;}
|
||||
for(const file of input.files){
|
||||
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text});
|
||||
refs.push({item_index:index,upload_id:uid});
|
||||
}
|
||||
}}
|
||||
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
|
||||
await load();
|
||||
}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
|
||||
|
||||
@@ -53,7 +53,7 @@ function pintaPedido(){
|
||||
$('bPagar').textContent = (pedido.length + (itemAtual?1:0))>1
|
||||
? 'Pagar os '+(pedido.length+(itemAtual?1:0))+' itens de uma vez'
|
||||
: 'Ir para o pagamento';
|
||||
$('bPagar').disabled = !cartPodeEnviar() || !entrega.cotado || !clienteOk();
|
||||
$('bPagar').disabled = !cartPodeEnviar() || !entrega.cotado || !clienteOk() || !enderecoOk();
|
||||
if(pedido.length || itemAtual) $('carr').classList.add('on');
|
||||
if($('cartLink')) $('cartLink').textContent='Carrinho ('+n+')';
|
||||
window.dispatchEvent(new Event('dtf-cart-changed'));
|
||||
|
||||
@@ -75,7 +75,7 @@ let pedido=[]; // itens já fechados · o pagamento
|
||||
// A escolha de entrega vale para o pedido inteiro, não por item. No Tiny, retirada
|
||||
// grava forma_envio X, transportador DropStar e frete por conta do remetente — igual
|
||||
// ao que a expedição já recebe hoje, sem criar processo novo.
|
||||
let entrega={tipo:'retira', cep:'', valor:0, cotado:true};
|
||||
let entrega={tipo:'retira', cep:'', valor:0, cotado:true, end:{}};
|
||||
let cliente={cnpj:'', zap:'', mail:''};
|
||||
let caminho='auto'; // o que o cliente diz que vai mandar
|
||||
|
||||
@@ -93,6 +93,19 @@ function cnpjOk(v){
|
||||
const zapOk=v=>{ const d=(v||'').replace(/\D/g,''); return d.length===10||d.length===11; };
|
||||
const mailOk=v=>/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test((v||'').trim());
|
||||
const clienteOk=()=>cnpjOk(cliente.cnpj)&&zapOk(cliente.zap)&&mailOk(cliente.mail);
|
||||
// Frete cotado pelo CEP não entrega nada sozinho: a transportadora e o Tiny
|
||||
// precisam do endereço completo antes do pagamento.
|
||||
const UFS='AC AL AP AM BA CE DF ES GO MA MT MS MG PA PB PR PE PI RJ RN RS RO RR SC SP SE TO'.split(' ');
|
||||
const enderecoOk=()=>{
|
||||
if(entrega.tipo!=='frete') return true;
|
||||
const e=entrega.end||{}, t=v=>(v||'').trim();
|
||||
return t(e.nome).length>=2 && t(e.rua).length>=2 && t(e.num).length>=1 &&
|
||||
t(e.bairro).length>=2 && t(e.cidade).length>=2 && UFS.includes(t(e.uf).toUpperCase());
|
||||
};
|
||||
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};
|
||||
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);
|
||||
|
||||
@@ -87,7 +87,9 @@ function pintaEntrega(){
|
||||
? 'DTF é impresso depois que você paga. Avisamos no WhatsApp quando estiver '+
|
||||
'<b>pronto para retirar</b> — não venha antes do aviso.'
|
||||
: '';
|
||||
$('bPagar').disabled = !entrega.cotado || !clienteOk() || !cartPodeEnviar();
|
||||
$('bPagar').disabled = !entrega.cotado || !clienteOk() || !enderecoOk() || !cartPodeEnviar();
|
||||
$('eEnd').textContent = entrega.tipo==='frete' && entrega.cotado && !enderecoOk()
|
||||
? 'Preencha o endereço de entrega para seguir.' : '';
|
||||
pintaPedido();
|
||||
}
|
||||
|
||||
@@ -144,6 +146,14 @@ $('cepIn').addEventListener('input',e=>{
|
||||
e.target.value = v.length>5 ? v.slice(0,5)+'-'+v.slice(5) : v;
|
||||
entrega.cep=v; entrega.cotado=false; entrega.valor=0; pintaEntrega();
|
||||
});
|
||||
for(const [id,chave] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
|
||||
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']]){
|
||||
$(id).addEventListener('input',e=>{
|
||||
if(chave==='uf') e.target.value=e.target.value.replace(/[^a-z]/gi,'').toUpperCase();
|
||||
entrega.end={...(entrega.end||{}),[chave]:e.target.value};
|
||||
pintaEntrega();
|
||||
});
|
||||
}
|
||||
$('bCep').addEventListener('click',async()=>{
|
||||
if(entrega.cep.length!==8){ $('cepMsg').innerHTML='CEP incompleto.'; return; }
|
||||
if(window.dtfFreight){ await window.dtfFreight(); return; }
|
||||
|
||||
Reference in New Issue
Block a user