Compare commits
82 Commits
7386469404
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef95dcc327 | ||
|
|
0b86fe79fc | ||
|
|
24ee4e9eab | ||
|
|
223854e392 | ||
|
|
0b55ebce7f | ||
|
|
bee7f8f245 | ||
|
|
c5db1375de | ||
|
|
3d8bca3af7 | ||
|
|
f3fc733352 | ||
|
|
4ce44feb70 | ||
|
|
01b94da36d | ||
|
|
25fa890e92 | ||
|
|
4386fc0737 | ||
|
|
70a76feb23 | ||
|
|
20982a945f | ||
|
|
37715ef223 | ||
|
|
b7e2ea12d0 | ||
|
|
933bd30cbd | ||
|
|
eb254da501 | ||
|
|
8786a33c8d | ||
|
|
20403c5132 | ||
|
|
1b57313496 | ||
|
|
a02711ef2b | ||
|
|
8dddf0fa25 | ||
|
|
f4aacb5776 | ||
|
|
64e47ee481 | ||
|
|
2b313a1cc8 | ||
|
|
7116ebe50a | ||
|
|
9e69c48d2d | ||
|
|
64c1260a9f | ||
|
|
b5bb03cbb4 | ||
|
|
2495edf188 | ||
|
|
f5fed013ab | ||
|
|
593ddf82c8 | ||
|
|
8b5aafa299 | ||
|
|
e47f88a6d6 | ||
|
|
5f2be7ea20 | ||
|
|
4437232d27 | ||
|
|
8bc57195e8 | ||
|
|
0a575a3be7 | ||
|
|
15abd589a8 | ||
|
|
32c060e1b0 | ||
|
|
6f5d183365 | ||
|
|
2215da8d5e | ||
|
|
88e65290e1 | ||
|
|
690b15ece1 | ||
|
|
e20c8673bb | ||
|
|
4de2151e9a | ||
|
|
641aafc87d | ||
|
|
875a7ef9c7 | ||
|
|
c436936fed | ||
|
|
0ed6de4bd5 | ||
|
|
eae3dad306 | ||
|
|
7b6675d4d4 | ||
|
|
4df74221d2 | ||
|
|
43043c361c | ||
|
|
536510b148 | ||
|
|
a1877bdf0a | ||
|
|
275ebf72c4 | ||
|
|
9bea187ea4 | ||
|
|
bd56170248 | ||
|
|
60b7336b37 | ||
|
|
04e4cbc953 | ||
|
|
aec5b1d054 | ||
|
|
8b42e684ca | ||
|
|
e768dcb489 | ||
|
|
122c645f72 | ||
|
|
988b252f9d | ||
|
|
56021d8451 | ||
|
|
b6a90411f1 | ||
|
|
cbbbdb351a | ||
|
|
b81ff8d03d | ||
|
|
177882e77b | ||
|
|
2e03b0362b | ||
|
|
99a558ddd9 | ||
|
|
641dc6053b | ||
|
|
4c01e932c3 | ||
|
|
e3d5558198 | ||
|
|
c18b9e5b87 | ||
|
|
cfcbe545f1 | ||
|
|
24013458c9 | ||
|
|
ccc25a2d5d |
22
.env.example
@@ -20,6 +20,28 @@ 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
|
||||
# Jadlog price quotes go in jadlog.env, not here (see app/jadlog_probe.py):
|
||||
# JADLOG_TOKEN=...
|
||||
# JADLOG_CNPJ=... # the "Usuário" Jadlog issued, the CNPJ that contracts freight
|
||||
# JADLOG_CONTA=... # conta corrente
|
||||
# JADLOG_CONTRATO= # only if Jadlog issued a contract number
|
||||
WHATSAPP_ADAPTER=fake
|
||||
STORAGE_ADAPTER=s3-local
|
||||
MOCK_FREIGHT_CENTS=1500
|
||||
|
||||
@@ -48,6 +48,9 @@ jobs:
|
||||
# by whoever follows them. The suites run inside the network, so it has to
|
||||
# be the service name, not a published port on the host.
|
||||
S3_PUBLIC_ENDPOINT: http://storage:9000
|
||||
PUBLIC_ORIGIN: http://site
|
||||
ALLOWED_HOSTS: localhost,127.0.0.1,site,kanban
|
||||
ALLOWED_ORIGINS: http://site,http://kanban,http://localhost:28080,http://localhost:28081
|
||||
COMPOSE: docker compose -f compose.local.yaml
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -70,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; 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 \
|
||||
@@ -78,46 +81,33 @@ 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 tests.test_jadlog tests.test_quote_review tests.test_large_files tests.test_grade_check -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
|
||||
$COMPOSE exec -T backup python -m tests.backup_test
|
||||
|
||||
# These need a real Chrome. They are the only coverage for the artwork
|
||||
# editor and the full customer journey, so install google-chrome-stable
|
||||
# (or set CHROME_BIN) on the runner to make them gate deployments. The
|
||||
# suites above stay hard gates either way.
|
||||
# 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.
|
||||
- name: Browser regressions
|
||||
run: |
|
||||
for candidate in "$CHROME_BIN" /usr/bin/google-chrome-stable \
|
||||
/usr/bin/google-chrome /usr/bin/chromium /usr/bin/chromium-browser; do
|
||||
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
|
||||
export CHROME_BIN="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ ! -x "${CHROME_BIN:-}" ]; then
|
||||
echo "::warning::No Chrome on this runner; browser regressions were NOT run."
|
||||
echo "Install google-chrome-stable or set CHROME_BIN to gate on them."
|
||||
exit 0
|
||||
fi
|
||||
# Chrome runs here, in the runner container, and reaches the stack only
|
||||
# through ports published on the host. When the runner is itself a
|
||||
# container those are in another namespace, so check before running
|
||||
# rather than failing with a bare connection error. See ROADMAP 5.10.
|
||||
if ! wget -q -T 5 -O /dev/null "http://localhost:${SITE_PORT}/health"; then
|
||||
echo "::warning::Stack not reachable from the runner; browser regressions were NOT run."
|
||||
exit 0
|
||||
fi
|
||||
echo "Using $CHROME_BIN"
|
||||
node tests/artwork_browser_test.mjs
|
||||
node tests/browser_test.mjs
|
||||
$COMPOSE build browser-tests
|
||||
$COMPOSE run --rm --no-deps browser-tests sh -ec \
|
||||
'node tests/artwork_browser_test.mjs && node tests/browser_test.mjs'
|
||||
|
||||
- name: Diagnostics on failure
|
||||
if: failure()
|
||||
run: |
|
||||
$COMPOSE ps || true
|
||||
$COMPOSE logs --tail 200 api worker site kanban || true
|
||||
$COMPOSE logs --tail 200 api worker backup site kanban || true
|
||||
|
||||
- name: Tear down
|
||||
if: always()
|
||||
@@ -145,13 +135,9 @@ jobs:
|
||||
fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \
|
||||
--no-progress /src
|
||||
|
||||
# docs/PORTAINER.md described this as blocking publication. It never ran at
|
||||
# all, and turning it on unconditionally would block every deploy: the
|
||||
# source preflight refuses a release while the payment and messaging
|
||||
# adapters are fake, which is the deliberate state the stack runs in
|
||||
# today. So its verdict is always printed, and enforcement is opt-in.
|
||||
# Set the repository variable ENFORCE_PRODUCTION_PREFLIGHT to "true" once
|
||||
# real adapters land, and this becomes the gate the documentation claims.
|
||||
# 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
|
||||
@@ -168,10 +154,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 == 'push' && 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:
|
||||
@@ -190,7 +180,7 @@ jobs:
|
||||
test -n "$REGISTRY_TOKEN"
|
||||
echo "$REGISTRY_TOKEN" | docker login gitea.blyzer.com.br \
|
||||
--username "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build and publish API
|
||||
- name: Build API
|
||||
run: |
|
||||
image="gitea.blyzer.com.br/blyzer/dtf-api"
|
||||
# The Dockerfiles pin digests themselves; these variables let a base be
|
||||
@@ -201,9 +191,7 @@ jobs:
|
||||
docker build --file deploy/Dockerfile.api "$@" \
|
||||
--build-arg VCS_REF="${{ gitea.sha }}" \
|
||||
--tag "$image:latest" --tag "$image:${{ gitea.sha }}" .
|
||||
docker push "$image:latest"
|
||||
docker push "$image:${{ gitea.sha }}"
|
||||
- name: Build and publish web
|
||||
- name: Build web
|
||||
run: |
|
||||
image="gitea.blyzer.com.br/blyzer/dtf-web"
|
||||
set --
|
||||
@@ -212,8 +200,6 @@ jobs:
|
||||
docker build --file deploy/Dockerfile.web "$@" \
|
||||
--build-arg VCS_REF="${{ gitea.sha }}" \
|
||||
--tag "$image:latest" --tag "$image:${{ gitea.sha }}" .
|
||||
docker push "$image:latest"
|
||||
docker push "$image:${{ gitea.sha }}"
|
||||
# CRITICAL blocks, HIGH is reported. Both images carry zero CRITICAL after
|
||||
# the base pinning and OS upgrades, so this gate holds the line already
|
||||
# reached. The remaining HIGH findings have no upstream fix, so failing on
|
||||
@@ -221,31 +207,63 @@ jobs:
|
||||
- name: Image vulnerabilities
|
||||
run: |
|
||||
image="${TRIVY_IMAGE:-aquasec/trivy:0.58.1}"
|
||||
failed=0
|
||||
# One database download for the four scans, kept in a volume between
|
||||
# runs and retried: a failed download from the mirror used to fail
|
||||
# the gate as if a CRITICAL vulnerability had been found.
|
||||
trivy() { docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v dtf-trivy-cache:/root/.cache/trivy "$image" "$@"; }
|
||||
for attempt in 1 2 3; do
|
||||
trivy image --download-db-only --no-progress && break
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "::error::The vulnerability database could not be downloaded; the release images were not scanned."
|
||||
exit 1
|
||||
fi
|
||||
echo "Database download failed (attempt $attempt); retrying in 30 s."
|
||||
sleep 30
|
||||
done
|
||||
# Findings exit 5; any other failure means the scan did not run.
|
||||
found=0; broken=0
|
||||
for target in \
|
||||
"gitea.blyzer.com.br/blyzer/dtf-api:${{ gitea.sha }}" \
|
||||
"gitea.blyzer.com.br/blyzer/dtf-web:${{ gitea.sha }}"; do
|
||||
echo "--- $target (HIGH, reported)"
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \
|
||||
image --scanners vuln --severity HIGH --no-progress \
|
||||
trivy image --skip-db-update --image-src docker --scanners vuln --severity HIGH --no-progress \
|
||||
--format table --exit-code 0 "$target" ||
|
||||
echo "::warning::Could not scan $target for HIGH findings"
|
||||
echo "--- $target (CRITICAL, blocking)"
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \
|
||||
image --scanners vuln --severity CRITICAL --no-progress \
|
||||
--format table --exit-code 1 "$target" || failed=1
|
||||
set +e
|
||||
trivy image --skip-db-update --image-src docker --scanners vuln --severity CRITICAL --no-progress \
|
||||
--format table --exit-code 5 "$target"
|
||||
verdict=$?
|
||||
set -e
|
||||
if [ "$verdict" -eq 5 ]; then found=1
|
||||
elif [ "$verdict" -ne 0 ]; then broken=1; echo "::error::The CRITICAL scan of $target did not run (exit $verdict)."
|
||||
fi
|
||||
done
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "::error::A CRITICAL vulnerability was found in a published image."
|
||||
if [ "$found" -ne 0 ]; then
|
||||
echo "::error::A CRITICAL vulnerability was found in a release image."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$broken" -ne 0 ]; then
|
||||
echo "::error::A release image could not be scanned; nothing is published unscanned."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish validated images
|
||||
run: |
|
||||
for name in dtf-api dtf-web; do
|
||||
image="gitea.blyzer.com.br/blyzer/$name"
|
||||
docker push "$image:${{ gitea.sha }}"
|
||||
docker push "$image:latest"
|
||||
done
|
||||
|
||||
- name: Trigger Portainer redeployment
|
||||
if: gitea.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
|
||||
run: |
|
||||
if [ -z "$PORTAINER_WEBHOOK" ]; then
|
||||
echo "PORTAINER_WEBHOOK is not configured; images were published but deployment was skipped."
|
||||
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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Sistema DTF 24h — Altus Group
|
||||
|
||||
> **Active local milestone (2026-09-11):** Read [docs/CONTEXT.md](docs/CONTEXT.md) first.
|
||||
> Start with `docker compose up --build`, then open the [Site](http://localhost:8080)
|
||||
> **Active local milestone (2026-09-23):** Read [docs/CONTEXT.md](docs/CONTEXT.md) first.
|
||||
> Start with `docker compose -f compose.local.yaml up --build`, then open the [Site](http://localhost:8080)
|
||||
> and [Kanban](http://localhost:8081). Optional configuration: copy `.env.example`
|
||||
> to `.env`. Follow [docs/LOCAL_SETUP.md](docs/LOCAL_SETUP.md) for the complete test flow,
|
||||
> local login, health checks, and troubleshooting. See
|
||||
|
||||
131
app/adapters.py
@@ -1,6 +1,9 @@
|
||||
"""Local-only composition root. No production provider implementations/imports."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from typing import Protocol
|
||||
from typing import Mapping, NamedTuple, Protocol
|
||||
from urllib.parse import urlparse
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
@@ -14,9 +17,33 @@ def require_runtime():
|
||||
checkout until their audited implementations are added.
|
||||
"""
|
||||
environment = os.environ.get('APP_ENV', 'local')
|
||||
for name in ('PAYMENT', 'FREIGHT', 'TINY', 'WHATSAPP'):
|
||||
if os.environ.get(f'{name}_ADAPTER') != 'fake':
|
||||
raise RuntimeError(f'{name} must use the currently supported fake adapter')
|
||||
if os.environ.get('WHATSAPP_ADAPTER') != 'fake':
|
||||
raise RuntimeError('WHATSAPP must use the currently supported fake adapter')
|
||||
freight = os.environ.get('FREIGHT_ADAPTER')
|
||||
if freight == 'jadlog':
|
||||
from .jadlog import required_settings as jadlog_settings
|
||||
for name in jadlog_settings():
|
||||
if not os.environ.get(name):
|
||||
raise RuntimeError(f'{name} is required for the Jadlog adapter')
|
||||
elif freight != 'fake':
|
||||
raise RuntimeError('FREIGHT must use the fake or jadlog 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')
|
||||
@@ -41,19 +68,99 @@ def require_runtime():
|
||||
# Compatibility alias for local-only callers outside the active runtime.
|
||||
require_local = require_runtime
|
||||
|
||||
class PaymentEvent(NamedTuple):
|
||||
"""One provider notification, normalised.
|
||||
|
||||
`event_id` identifies the delivery and makes it idempotent. `reference` is
|
||||
our quote id, echoed back by the provider. `amount_cents` is what the
|
||||
provider says was actually paid, which the service compares against the
|
||||
approved total before it will create an order.
|
||||
"""
|
||||
event_id: str
|
||||
reference: str
|
||||
status: str # 'approved' | 'rejected' | 'pending' | 'refunded'
|
||||
amount_cents: int | None
|
||||
raw: dict
|
||||
|
||||
|
||||
class PaymentAdapter(Protocol):
|
||||
def pay(self, quote_id: str, total_cents: int) -> 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, query: Mapping[str, str] | None = None) -> bool:
|
||||
"""Whether this delivery genuinely came from the provider."""
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
class FakePayment:
|
||||
"""Local stand-in with a real signature scheme, so the webhook path is
|
||||
exercised end to end rather than waiting for a provider account.
|
||||
|
||||
Signs the body with HMAC-SHA256 under PAYMENT_WEBHOOK_SECRET. A real adapter
|
||||
replaces verify() and parse() with the provider's own scheme; nothing else in
|
||||
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, method: dict | None = None) -> dict:
|
||||
kind = (method or {}).get('type', 'pix')
|
||||
if kind == 'pix':
|
||||
from datetime import datetime, timedelta, timezone
|
||||
expires = (datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat(timespec='milliseconds')
|
||||
return {'provider': 'fake', 'id': f"local-{quote_id}-pix-{(method or {}).get('attempt', 1)}",
|
||||
'status': 'pending', 'total_cents': total_cents, 'expires_at': expires}
|
||||
return {'provider': 'fake', 'id': f'local-{quote_id}-{kind}',
|
||||
'status': 'pending', 'total_cents': total_cents}
|
||||
|
||||
def sign(self, body: bytes) -> str:
|
||||
secret = self._secret()
|
||||
if secret is None:
|
||||
raise RuntimeError('PAYMENT_WEBHOOK_SECRET is not configured')
|
||||
return hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
|
||||
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.
|
||||
if self._secret() is None:
|
||||
return False
|
||||
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, query=None):
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict) or 'event_id' not in data:
|
||||
return None
|
||||
return PaymentEvent(event_id=str(data['event_id']),
|
||||
reference=str(data.get('reference', '')),
|
||||
status=str(data.get('status', 'pending')),
|
||||
amount_cents=data.get('amount_cents'),
|
||||
raw=data)
|
||||
|
||||
# The local development checkout still needs a direct "it is paid" path.
|
||||
def pay(self, quote_id: str, total_cents: int) -> dict:
|
||||
return {'provider': 'fake', 'id': f'local-{quote_id}',
|
||||
'status': 'paid', 'total_cents': total_cents}
|
||||
|
||||
class FreightAdapter(Protocol):
|
||||
def quote(self, service: str, postal_code: str) -> dict: ...
|
||||
def quote(self, service: str, postal_code: str, metres=None, declared_cents: int = 0) -> dict: ...
|
||||
|
||||
class FakeFreight:
|
||||
def quote(self, service: str, postal_code: str) -> dict:
|
||||
name = 'fake'
|
||||
|
||||
def quote(self, service: str, postal_code: str, metres=None, declared_cents: int = 0) -> dict:
|
||||
if service == 'pickup':
|
||||
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}
|
||||
if service != 'mock-standard' or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit():
|
||||
@@ -84,6 +191,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): ...
|
||||
|
||||
@@ -132,6 +241,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={
|
||||
|
||||
@@ -12,7 +12,7 @@ from ..artwork import submit_files
|
||||
from ..core.auth import operator
|
||||
from ..core.models import ArtworkSubmission, UploadStart
|
||||
from ..runtime import file_rows, operator_identity
|
||||
from .uploads import begin_upload, complete_upload, part_url, upload_status
|
||||
from .uploads import begin_upload, cancel_upload, complete_upload, part_url, upload_status
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -36,6 +36,10 @@ def final_part(uid: UUID, number: int, user=Depends(operator)):
|
||||
def final_complete(uid: UUID, user=Depends(operator)):
|
||||
return complete_upload(uid,session_id=operator_identity(user))
|
||||
|
||||
@router.delete('/api/operator/uploads/{uid}')
|
||||
def final_cancel(uid: UUID, user=Depends(operator)):
|
||||
return cancel_upload(uid,session_id=operator_identity(user))
|
||||
|
||||
@router.get('/api/operator/orders/{oid}/files')
|
||||
def operator_files(oid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from psycopg.errors import UniqueViolation
|
||||
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)
|
||||
from ..core.models import ArtworkSubmission, Login, Register
|
||||
login_failed, password_hash, password_matches, session_row, throttle, transfer_guest)
|
||||
from ..core.models import ArtworkSubmission, EmailChange, Login, PasswordChange, ProfileUpdate, Register
|
||||
from ..runtime import STATES, file_rows, owned_order, storage
|
||||
from ..scanning import require_clean
|
||||
|
||||
@@ -26,6 +28,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,8 +57,9 @@ 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')
|
||||
raise HTTPException(401, 'E-mail ou senha inválidos.')
|
||||
previous = current(request)
|
||||
with db.connect() as c:
|
||||
if not stored.startswith('scrypt-v2$'):
|
||||
@@ -83,21 +88,124 @@ def me(identity=Depends(owner)):
|
||||
row = c.execute('SELECT profile FROM dtf_local.accounts WHERE id=%s', (identity,)).fetchone()
|
||||
return {'customer': row['profile'] if row else None}
|
||||
|
||||
@router.get('/api/customer/orders')
|
||||
def orders(identity=Depends(owner)):
|
||||
def account_row(c, identity, lock=False):
|
||||
row = c.execute('SELECT * FROM dtf_local.accounts WHERE id=%s' + (' FOR UPDATE' if lock else ''), (identity,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(401, 'Entre na sua conta.')
|
||||
return row
|
||||
|
||||
@router.post('/api/account/profile')
|
||||
def update_profile(body: ProfileUpdate, identity=Depends(owner)):
|
||||
"""WhatsApp and the saved delivery address. The CNPJ is the account's and
|
||||
does not change; the e-mail is the login and changes on its own route."""
|
||||
with db.connect() as c:
|
||||
rows = c.execute('SELECT id,number,state,version,snapshot,created_at,updated_at FROM dtf_local.orders WHERE owner=%s ORDER BY created_at DESC', (identity,)).fetchall()
|
||||
quotes = c.execute('''SELECT q.id,q.approved,q.approved_at,q.created_at FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE q.owner=%s AND o.id IS NULL ORDER BY q.created_at DESC''', (identity,)).fetchall()
|
||||
return {'orders': rows, 'quotes': quotes, 'states': STATES}
|
||||
row = account_row(c, identity, lock=True)
|
||||
profile = {**row['profile'], 'zap': body.zap,
|
||||
'address': body.address.model_dump() if body.address else None}
|
||||
c.execute('UPDATE dtf_local.accounts SET profile=%s WHERE id=%s', (Jsonb(profile), identity))
|
||||
audit('account_profile_updated', account=str(identity))
|
||||
return {'customer': profile}
|
||||
|
||||
@router.post('/api/account/email')
|
||||
def change_email(body: EmailChange, request: Request, identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = account_row(c, identity)
|
||||
throttle(row['email'], request)
|
||||
if not password_matches(body.password, row['password_hash']):
|
||||
login_failed(row['email'])
|
||||
raise HTTPException(401, 'Senha incorreta.')
|
||||
profile = {**row['profile'], 'mail': body.email}
|
||||
try:
|
||||
with db.connect() as c:
|
||||
c.execute('UPDATE dtf_local.accounts SET email=%s, profile=%s WHERE id=%s', (body.email, Jsonb(profile), identity))
|
||||
except UniqueViolation:
|
||||
raise HTTPException(409, 'Este e-mail já é de outra conta.')
|
||||
audit('account_email_changed', account=str(identity))
|
||||
return {'customer': profile}
|
||||
|
||||
@router.post('/api/account/password')
|
||||
def change_password(body: PasswordChange, request: Request, identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = account_row(c, identity)
|
||||
throttle(row['email'], request)
|
||||
if not password_matches(body.current, row['password_hash']):
|
||||
login_failed(row['email'])
|
||||
raise HTTPException(401, 'Senha atual incorreta.')
|
||||
session = current(request)
|
||||
with db.connect() as c:
|
||||
c.execute('UPDATE dtf_local.accounts SET password_hash=%s WHERE id=%s', (password_hash(body.new), identity))
|
||||
# Other devices signed in with the old password are signed out.
|
||||
c.execute('DELETE FROM dtf_local.sessions WHERE owner=%s AND id<>%s', (identity, session['id']))
|
||||
audit('account_password_changed', account=str(identity))
|
||||
return {'ok': True}
|
||||
|
||||
# The order list's groups, as the customer filters them.
|
||||
GROUPS = {'pay': 'Aguardando pagamento', 'prod': 'Em produção', 'cor': 'Correção', 'fin': 'Finalizados'}
|
||||
ENTRIES = '''WITH live_quote AS (
|
||||
-- The cart waiting for payment: the newest unpaid quote whose files still
|
||||
-- exist. An older one was replaced when the cart changed, and one whose
|
||||
-- files are gone can no longer be paid.
|
||||
SELECT q.id, q.created_at, q.approved FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE q.owner=%(me)s AND o.id IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(i->'uploads') u(uid)
|
||||
JOIN dtf_local.uploads up ON up.id=u.uid::uuid
|
||||
WHERE up.purged_at IS NOT NULL OR up.expires_at<=now())
|
||||
ORDER BY q.created_at DESC LIMIT 1
|
||||
), entries AS (
|
||||
SELECT 'order' AS kind, o.id, o.number, o.state,
|
||||
CASE WHEN o.state='fin' THEN 'fin' WHEN o.state='cor' THEN 'cor' ELSE 'prod' END AS grp,
|
||||
(o.snapshot->>'total_cents')::bigint AS total_cents, o.created_at, o.snapshot->'items' AS items
|
||||
FROM dtf_local.orders o WHERE o.owner=%(me)s
|
||||
UNION ALL
|
||||
SELECT 'quote', q.id, NULL, CASE WHEN q.approved IS NULL THEN 'review' ELSE 'pay' END, 'pay',
|
||||
(q.approved->>'total_cents')::bigint, q.created_at, COALESCE(q.approved->'items','[]'::jsonb)
|
||||
FROM live_quote q
|
||||
)'''
|
||||
|
||||
@router.get('/api/customer/orders')
|
||||
def orders(identity=Depends(owner), status: Literal['all','pay','prod','cor','fin'] = 'all',
|
||||
number: int | None = Query(None, ge=1), days: int = Query(0, ge=0, le=3650),
|
||||
page: int = Query(1, ge=1, le=10000), size: int = Query(10, ge=1, le=50)):
|
||||
"""The customer's orders and the cart waiting for payment, newest first,
|
||||
filtered and a page at a time, with how many there are in each group."""
|
||||
params = {'me': identity, 'grp': None if status == 'all' else status, 'number': number,
|
||||
'since': datetime.now(timezone.utc) - timedelta(days=days) if days else None,
|
||||
'size': size, 'offset': (page - 1) * size}
|
||||
where = '''WHERE (%(grp)s::text IS NULL OR grp=%(grp)s) AND (%(number)s::int IS NULL OR number=%(number)s)
|
||||
AND (%(since)s::timestamptz IS NULL OR created_at>=%(since)s)'''
|
||||
with db.connect() as c:
|
||||
counts = {r['grp']: r['n'] for r in c.execute(ENTRIES + ' SELECT grp, count(*) AS n FROM entries GROUP BY grp', params).fetchall()}
|
||||
total = c.execute(ENTRIES + ' SELECT count(*) AS n FROM entries ' + where, params).fetchone()['n']
|
||||
rows = c.execute(ENTRIES + ' SELECT * FROM entries ' + where +
|
||||
' ORDER BY created_at DESC LIMIT %(size)s OFFSET %(offset)s', params).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
entry = {'kind': r['kind'], 'id': r['id'], 'number': r['number'], 'state': r['state'],
|
||||
'group': r['grp'], 'total_cents': r['total_cents'], 'created_at': r['created_at'],
|
||||
'items': [{'mode': i['mode'], 'billed_metres': i.get('billed_metres')} for i in r['items']]}
|
||||
if r['kind'] == 'quote':
|
||||
# A PIX code still open is paid on its own page.
|
||||
pix = c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'
|
||||
AND status='pending' AND COALESCE((response->>'expires_at')::timestamptz > now(), false)''',
|
||||
(r['id'],)).fetchone()
|
||||
entry['pay_with'] = 'pix' if pix else 'page'
|
||||
items.append(entry)
|
||||
return {'items': items, 'total': total, 'page': page, 'size': size,
|
||||
'counts': {g: counts.get(g, 0) for g in GROUPS}, 'groups': GROUPS, 'states': STATES}
|
||||
|
||||
@router.get('/api/customer/orders/{oid}')
|
||||
def detail(oid: UUID, identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = owned_order(c, oid, identity)
|
||||
history = c.execute('SELECT from_state,to_state,reason,created_at FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (oid,)).fetchall()
|
||||
# A move back undoes an operator's mistake and its reason is internal;
|
||||
# only a correction's reason is written for the customer.
|
||||
history = c.execute('''SELECT from_state,to_state,CASE WHEN to_state='cor' THEN reason ELSE '' END AS reason,
|
||||
created_at FROM dtf_local.movements WHERE order_id=%s AND NOT back ORDER BY id''', (oid,)).fetchall()
|
||||
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
|
||||
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}
|
||||
'created_at': row['created_at'], 'snapshot': row['snapshot'], 'history': history,
|
||||
'files': file_rows(c,oid)}
|
||||
|
||||
@router.post('/api/customer/orders/{oid}/corrections')
|
||||
def correction(oid: UUID, body: ArtworkSubmission, identity=Depends(owner)):
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"""Health, session bootstrap and freight quoting."""
|
||||
"""Health, session bootstrap, freight quoting and the address of a CEP."""
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
|
||||
from ..core import db
|
||||
from ..core.auth import client_ip, owner, new_session, rate_limit
|
||||
from ..core.models import Freight
|
||||
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, storage
|
||||
from ..core.limits import upload_limit_bytes
|
||||
from ..core.models import FreightEstimate
|
||||
from ..runtime import (ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment,
|
||||
require_delivery_available, storage)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -33,11 +37,36 @@ 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': int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))}
|
||||
'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 '',
|
||||
# The delivery service the cart quotes, or none: pickup only.
|
||||
'freight_service': 'jadlog' if freight.name == 'jadlog' else 'mock-standard' if ENVIRONMENT == 'local' else None}
|
||||
|
||||
@router.post('/api/freight')
|
||||
def quote_freight(body: Freight):
|
||||
def quote_freight(body: FreightEstimate):
|
||||
require_delivery_available(body.service)
|
||||
try:
|
||||
return freight.quote(body.service, body.postal_code)
|
||||
return freight.quote(body.service, body.postal_code, body.metres, body.declared_cents)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
|
||||
# The address of a CEP, so the cart fills it in. Looked up here rather than in
|
||||
# the browser, which keeps the Site's CSP to its own origin.
|
||||
VIACEP = 'https://viacep.com.br/ws/{}/json/'
|
||||
CEP_LIMIT = 120
|
||||
|
||||
@router.get('/api/cep/{cep}')
|
||||
def cep_address(cep: str, request: Request):
|
||||
if not re.fullmatch(r'[0-9]{8}', cep):
|
||||
raise HTTPException(422, 'CEP must have eight digits')
|
||||
rate_limit('cep-lookup', client_ip(request), CEP_LIMIT, 900)
|
||||
try:
|
||||
response = httpx.get(VIACEP.format(cep), timeout=5)
|
||||
data = response.json() if response.status_code == 200 else {}
|
||||
except (httpx.HTTPError, ValueError):
|
||||
raise HTTPException(503, 'Consulta de CEP indisponível')
|
||||
if not data or data.get('erro'):
|
||||
raise HTTPException(404, 'CEP não encontrado')
|
||||
return {'street': data.get('logradouro') or '', 'district': data.get('bairro') or '',
|
||||
'city': data.get('localidade') or '', 'state': data.get('uf') or ''}
|
||||
|
||||
@@ -3,18 +3,21 @@ import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from psycopg.types.json import Jsonb
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
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
|
||||
from ..core.pricing import price
|
||||
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
|
||||
enqueue, freight, quote_view, storage, upload_row)
|
||||
login_failed, password_matches, throttle)
|
||||
from ..core.models import Move, OperatorLogin, Resolution, Review
|
||||
from ..printjobs import queue as queue_print_files
|
||||
from .. import payments, quote_review
|
||||
from .. import tiny
|
||||
from ..runtime import (BACK, BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
|
||||
enqueue, freight, quote_view, storage)
|
||||
from ..scanning import require_clean
|
||||
|
||||
router = APIRouter()
|
||||
@@ -26,13 +29,14 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
|
||||
with db.connect() as c:
|
||||
account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
|
||||
if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone():
|
||||
raise HTTPException(503, 'No Kanban operator account is configured')
|
||||
raise HTTPException(503, 'Nenhuma conta de operador configurada.')
|
||||
# Comparable password work whether or not the account exists or is active.
|
||||
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')
|
||||
raise HTTPException(401, 'E-mail ou senha inválidos.')
|
||||
token = secrets.token_urlsafe(32)
|
||||
with db.connect() as c:
|
||||
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
|
||||
@@ -55,6 +59,24 @@ def operator_logout(request: Request, response: Response):
|
||||
audit('operator_logout')
|
||||
return {'ok': True}
|
||||
|
||||
def freight_status():
|
||||
"""What delivery the Site offers, and the package rule it prices with."""
|
||||
if freight.name != 'jadlog':
|
||||
return {'provider': freight.name}
|
||||
return {'provider': 'jadlog', 'base_kg': str(freight.base_kg), 'per_metre_kg': str(freight.per_metre_kg),
|
||||
'production_days': freight.production_days}
|
||||
|
||||
|
||||
def backup_status(c):
|
||||
"""The latest database backup and the latest run, which may have failed."""
|
||||
ok = c.execute('''SELECT finished_at,bytes FROM dtf_local.backups WHERE status='ok'
|
||||
ORDER BY finished_at DESC LIMIT 1''').fetchone()
|
||||
last = c.execute('SELECT finished_at,status,detail FROM dtf_local.backups ORDER BY finished_at DESC LIMIT 1').fetchone()
|
||||
return {'last_ok': ok['finished_at'].isoformat() if ok else None, 'bytes': ok['bytes'] if ok else None,
|
||||
'failed': last['detail'] if last and last['status'] == 'failed' else None,
|
||||
'failed_at': last['finished_at'].isoformat() if last and last['status'] == 'failed' else None}
|
||||
|
||||
|
||||
@router.get('/api/operator/board')
|
||||
def board(user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
@@ -65,14 +87,127 @@ def board(user=Depends(operator)):
|
||||
finished = c.execute("SELECT * FROM dtf_local.orders WHERE state='fin' ORDER BY created_at DESC LIMIT %s",
|
||||
(BOARD_FINISHED_LIMIT,)).fetchall()
|
||||
finished_total = c.execute("SELECT count(*) AS n FROM dtf_local.orders WHERE state='fin'").fetchone()['n']
|
||||
quotes = c.execute('''SELECT q.* FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE o.id IS NULL
|
||||
ORDER BY q.created_at LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall()
|
||||
return {'states': STATES, 'transitions': TRANSITIONS,
|
||||
'orders': active + list(reversed(finished)),
|
||||
pending = c.execute('''SELECT q.* FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND q.approved IS NULL
|
||||
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall()
|
||||
approved = c.execute('''SELECT q.* FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND q.approved IS NOT NULL
|
||||
ORDER BY q.created_at DESC,q.id DESC LIMIT 20''').fetchall()
|
||||
pending_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 NULL''').fetchone()['n']
|
||||
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 = with_print_files(c, active + list(reversed(finished)))
|
||||
# A paid notification that did not become an order is money received
|
||||
# for nothing the factory will make. The board carries the count; the
|
||||
# Pagamentos tab pages through them until someone records a resolution.
|
||||
issues_total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
|
||||
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL''').fetchone()['n']
|
||||
backup = backup_status(c)
|
||||
return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK,
|
||||
'orders': orders, 'payment_issues_total': issues_total, 'tiny': tiny_status(), 'operator': user,
|
||||
'providers': {'payment': payment.name, 'freight': freight_status(), 'backup': backup}, 'environment': ENVIRONMENT,
|
||||
'finished_shown': len(finished), 'finished_total': finished_total,
|
||||
'quotes': [quote_view(c, q) for q in quotes],
|
||||
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
|
||||
'quotes': [quote_view(c, q) for q in pending + approved],
|
||||
'pending_total': pending_total, 'approved_total': approved_total}
|
||||
|
||||
|
||||
def with_print_files(c, orders):
|
||||
generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name
|
||||
FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id
|
||||
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']]
|
||||
return orders
|
||||
|
||||
|
||||
def cursor_pair(first, second):
|
||||
if (first is None) != (second is None):
|
||||
raise HTTPException(422, 'Both cursor fields are required')
|
||||
return first is not None
|
||||
|
||||
|
||||
@router.get('/api/operator/orders/finished')
|
||||
def finished_page(before_created_at: datetime | None = None, before_id: UUID | None = None,
|
||||
limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)):
|
||||
"""Older finished orders, newest first, beyond the board's recent window."""
|
||||
paged = cursor_pair(before_created_at, before_id)
|
||||
with db.connect() as c:
|
||||
rows = c.execute('''SELECT * FROM dtf_local.orders WHERE state='fin'
|
||||
''' + ('AND (created_at,id)<(%s,%s) ' if paged else '') + '''
|
||||
ORDER BY created_at DESC,id DESC LIMIT %s''',
|
||||
((before_created_at, before_id) if paged else ()) + (limit + 1,)).fetchall()
|
||||
return {'orders': with_print_files(c, rows[:limit]), 'has_more': len(rows) > limit}
|
||||
|
||||
|
||||
@router.get('/api/operator/payment-events')
|
||||
def payment_events(state: Literal['open','resolved','all'] = 'open',
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
|
||||
"""Payments that needed a person, newest first: open ones to act on, resolved ones as history."""
|
||||
where = {'open': 'AND resolved_at IS NULL', 'resolved': 'AND resolved_at IS NOT NULL', 'all': ''}[state]
|
||||
with db.connect() as c:
|
||||
rows = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome,
|
||||
resolved_at,resolved_by,resolution
|
||||
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') ''' + where + '''
|
||||
ORDER BY received_at DESC,id DESC LIMIT %s OFFSET %s''', (limit, offset)).fetchall()
|
||||
total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
|
||||
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') ''' + where).fetchone()['n']
|
||||
return {'issues': rows, 'total': total}
|
||||
|
||||
|
||||
@router.get('/api/operator/events')
|
||||
def events(provider: Literal['tiny','whatsapp'] | None = None,
|
||||
status: Literal['delivered','queued','failing'] | None = None,
|
||||
event: Literal['payment_approved','production_started','correction_needed','ready'] | None = None,
|
||||
order: int | None = Query(default=None, ge=1), offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
|
||||
"""The integration send log, newest first, filtered, by page with a total."""
|
||||
clauses, params = [], []
|
||||
if provider:
|
||||
clauses.append('provider=%s'); params.append(provider)
|
||||
if status == 'delivered':
|
||||
clauses.append('delivered_at IS NOT NULL')
|
||||
elif status == 'queued':
|
||||
clauses.append('delivered_at IS NULL AND last_error IS NULL')
|
||||
elif status == 'failing':
|
||||
clauses.append('delivered_at IS NULL AND last_error IS NOT NULL')
|
||||
if event:
|
||||
clauses.append("payload->>'event'=%s"); params.append(event)
|
||||
if order:
|
||||
clauses.append("payload->>'number'=%s"); params.append(str(order))
|
||||
where = ('WHERE ' + ' AND '.join(clauses)) if clauses else ''
|
||||
with db.connect() as c:
|
||||
rows = c.execute(f'SELECT * FROM dtf_local.outbox {where} ORDER BY id DESC LIMIT %s OFFSET %s',
|
||||
(*params, limit, offset)).fetchall()
|
||||
total = c.execute(f'SELECT count(*) AS n FROM dtf_local.outbox {where}', params).fetchone()['n']
|
||||
return {'events': rows, 'total': total}
|
||||
|
||||
@router.get('/api/operator/quotes')
|
||||
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
|
||||
before_id: UUID | None = None, offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)):
|
||||
"""Unpaid quotes, newest first: by cursor, or by page (offset) with a total."""
|
||||
if (before_created_at is None) != (before_id is None):
|
||||
raise HTTPException(422, 'Both quote cursor fields are required')
|
||||
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'
|
||||
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
|
||||
skip = 0 if before_created_at else offset
|
||||
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1, skip)
|
||||
with db.connect() as c:
|
||||
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND {approved_filter} {cursor}
|
||||
ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', params).fetchall()
|
||||
total = c.execute(f'''SELECT count(*) AS n FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND {approved_filter}''').fetchone()['n']
|
||||
return {'quotes':[quote_view(c,row) for row in rows[:limit]],
|
||||
'has_more':len(rows)>limit, 'total': total}
|
||||
|
||||
@router.post('/api/operator/quotes/{uid}/approve')
|
||||
def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
@@ -80,23 +215,25 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Quote not found')
|
||||
if row['approved']:
|
||||
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
|
||||
draft = row['draft']
|
||||
if len(body.items) != len(draft['items']):
|
||||
raise HTTPException(422, 'Review must cover every item')
|
||||
items = []
|
||||
for item, original in zip(body.items, draft['items']):
|
||||
if item.mode != original['mode'] or list(map(str,item.uploads)) != original['uploads']:
|
||||
raise HTTPException(422, 'Product mode and attached files cannot change during review')
|
||||
for upload_id in item.uploads:
|
||||
require_clean(upload_row(c, upload_id, row['owner']))
|
||||
items.append({**price(item.mode, str(item.metres), item.grade), 'uploads': original['uploads']})
|
||||
quoted_freight = freight.quote(**draft['freight'])
|
||||
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
|
||||
'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
|
||||
return quote_review.approve(c, row, body.items, user)
|
||||
|
||||
@router.post('/api/operator/quotes/{uid}/test-order')
|
||||
def test_order(uid: UUID, user=Depends(operator)):
|
||||
"""An order for an approved quote without payment, to try the Kanban,
|
||||
files and print flow in production. Marked TEST on the board, never sent
|
||||
to Tiny or WhatsApp, and audited."""
|
||||
with db.connect() as c:
|
||||
try:
|
||||
quote = payments.approved_quote(c, uid)
|
||||
except payments.PaymentRefused as refusal:
|
||||
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
|
||||
receipt = {'provider': payments.TEST_PROVIDER, 'id': f'teste-{uid}', 'status': 'paid',
|
||||
'total_cents': quote['approved']['total_cents'], 'operator': user}
|
||||
order, created = payments.create_order(c, quote, receipt)
|
||||
if created:
|
||||
audit('test_order_created', order=str(order['id']), operator=user)
|
||||
return order
|
||||
|
||||
|
||||
@router.post('/api/operator/orders/{uid}/move')
|
||||
def move(uid: UUID, body: Move, user=Depends(operator)):
|
||||
@@ -105,30 +242,140 @@ def move(uid: UUID, body: Move, user=Depends(operator)):
|
||||
if not row:
|
||||
raise HTTPException(404, 'Order not found')
|
||||
if body.version != row['version']:
|
||||
raise HTTPException(409, 'Order changed; refresh the board')
|
||||
raise HTTPException(409, 'O pedido mudou. Clique em Atualizar.')
|
||||
if body.state == row['state']:
|
||||
return row
|
||||
if body.state not in TRANSITIONS[row['state']]:
|
||||
raise HTTPException(409, 'Move is not allowed from this state')
|
||||
back = BACK.get(row['state']) == body.state
|
||||
if body.state not in TRANSITIONS[row['state']] and not back:
|
||||
raise HTTPException(409, f"Não dá para ir de {STATES[row['state']]} para {STATES[body.state]}.")
|
||||
if body.state == 'cor' and not body.reason.strip():
|
||||
raise HTTPException(422, 'Correction requires a reason')
|
||||
raise HTTPException(422, 'Informe o motivo da correção.')
|
||||
if back and not body.reason.strip():
|
||||
raise HTTPException(422, 'Informe por que o pedido está voltando de etapa.')
|
||||
if body.state in ('fil','imp'):
|
||||
coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall()
|
||||
if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))):
|
||||
raise HTTPException(409, 'Approve a complete final-file set for every item before queueing')
|
||||
raise HTTPException(409, 'Aprove os arquivos finais de todos os itens antes de colocar na fila.')
|
||||
if body.state == 'cor':
|
||||
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,))
|
||||
c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason) VALUES(%s,%s,%s,%s,%s)',
|
||||
(uid,row['state'],body.state,user,body.reason))
|
||||
c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason,back) VALUES(%s,%s,%s,%s,%s,%s)',
|
||||
(uid,row['state'],body.state,user,body.reason,back))
|
||||
changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone()
|
||||
events = {'imp':'production_started','cor':'correction_needed','fin':'ready'}
|
||||
if body.state in events:
|
||||
for provider in ('tiny','whatsapp'):
|
||||
enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider,
|
||||
{'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
|
||||
'customer_path': f'/portal.html?order={uid}'})
|
||||
if body.state in events and not back and not payments.is_test(row):
|
||||
# "Production started" and "ready" reach the customer once per order,
|
||||
# even if a mistaken move is undone and made again. Each correction
|
||||
# is a new request, so it keeps one message per movement.
|
||||
once = body.state in ('imp','fin')
|
||||
event = {'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
|
||||
'customer_path': f'/portal.html?order={uid}'}
|
||||
# Tiny needs the order (customer, pickup or delivery) and, once the
|
||||
# sale reached it, its id, to set the situação without a search.
|
||||
sale = c.execute("SELECT receipt FROM dtf_local.outbox WHERE event_key=%s AND delivered_at IS NOT NULL",
|
||||
(f'{uid}:paid:tiny',)).fetchone()
|
||||
tiny = {**event, 'order': row['snapshot'], 'tiny_id': ((sale or {}).get('receipt') or {}).get('tiny_id')}
|
||||
for provider, payload in (('tiny', tiny), ('whatsapp', event)):
|
||||
key = f'{uid}:{events[body.state]}:{provider}' if once else f'{uid}:{changed["version"]}:{provider}'
|
||||
enqueue(c, key, provider, payload)
|
||||
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.post('/api/operator/tiny/test')
|
||||
def tiny_test(user=Depends(operator)):
|
||||
"""Read one order and one contact from Tiny. Creates nothing."""
|
||||
if not tiny.configured():
|
||||
raise HTTPException(503, 'Tiny application is not configured')
|
||||
try:
|
||||
results = tiny.check()
|
||||
except tiny.TinyNotConnected as exc:
|
||||
raise HTTPException(409, str(exc))
|
||||
audit('tiny_tested', operator=user, ok=all(v == 'ok' for v in results.values()))
|
||||
return {'ok': all(v == 'ok' for v in results.values()), 'results': results}
|
||||
|
||||
@router.post('/api/operator/mercadopago/check')
|
||||
def mercadopago_check(user=Depends(operator)):
|
||||
"""The Mercado Pago account behind the configured token; reads only."""
|
||||
if payment.name != 'mercadopago':
|
||||
raise HTTPException(409, 'Mercado Pago não está configurado')
|
||||
from ..mercadopago_probe import check
|
||||
audit('mercadopago_check', operator=user)
|
||||
result = check(payment.access_token)
|
||||
# Whether Mercado Pago's notifications reach this server and pass the
|
||||
# signature: every accepted one is recorded, a refused one is audited.
|
||||
with db.connect() as c:
|
||||
received = c.execute('''SELECT count(*) AS n, max(received_at) AS last FROM dtf_local.payment_events
|
||||
WHERE provider='mercadopago' AND received_at > now()-interval '24 hours' ''').fetchone()
|
||||
last = c.execute('''SELECT received_at,status,outcome FROM dtf_local.payment_events
|
||||
WHERE provider='mercadopago' ORDER BY received_at DESC LIMIT 1''').fetchone()
|
||||
refused = c.execute('''SELECT count(*) AS n FROM dtf_local.security_events
|
||||
WHERE event='payment_webhook_rejected' AND created_at > now()-interval '24 hours' ''').fetchone()
|
||||
result['webhooks'] = {'accepted_24h': received['n'], 'refused_24h': refused['n'], 'last': last}
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/api/operator/tiny/callback')
|
||||
def tiny_callback(code: str = Query('', max_length=4096), state: str = Query('', max_length=128),
|
||||
error: 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. Tiny reports a
|
||||
refusal (the operator declined, or offline access is not allowed for this
|
||||
application) with `error` instead of a code."""
|
||||
if not tiny.configured():
|
||||
raise HTTPException(503, 'Tiny application is not configured')
|
||||
try:
|
||||
if error == 'invalid_scope':
|
||||
retry = tiny.TinyAuth().without_offline(state)
|
||||
audit('tiny_offline_refused')
|
||||
return RedirectResponse(retry, status_code=303)
|
||||
if error or not code:
|
||||
raise tiny.TinyError(f'Tiny returned {error or "no code"}')
|
||||
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:
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
"""Paid orders. Local development payment only; no provider is wired yet."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
"""Local development checkout.
|
||||
|
||||
The real path is the provider webhook. This exists so the local stack can reach
|
||||
a paid order without a provider account, and it goes through the same service so
|
||||
the two cannot drift apart.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import payments
|
||||
from ..core import db
|
||||
from ..core.auth import owner
|
||||
from ..core.models import Pay
|
||||
from ..runtime import ENVIRONMENT, enqueue, payment, upload_row
|
||||
from ..scanning import require_clean
|
||||
from ..runtime import ENVIRONMENT, payment
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/api/orders/dev-paid')
|
||||
def dev_paid(body: Pay, session_id=Depends(owner)):
|
||||
if ENVIRONMENT != 'local':
|
||||
raise HTTPException(503, 'Checkout is not configured yet')
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s FOR UPDATE', (body.quote_id,session_id)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Quote not found')
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone()
|
||||
if existing:
|
||||
return existing
|
||||
if not row['approved']:
|
||||
raise HTTPException(409, 'An operator must verify length and grade first')
|
||||
if row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24):
|
||||
raise HTTPException(409, 'Quote expired; request a new quote')
|
||||
approved = row['approved']
|
||||
for item in approved['items']:
|
||||
for upload_id in item['uploads']:
|
||||
require_clean(upload_row(c, UUID(upload_id), session_id))
|
||||
paid = payment.pay(str(body.quote_id), approved['total_cents'])
|
||||
result = c.execute('INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
||||
(uuid4(),body.quote_id,session_id,Jsonb(approved),Jsonb(paid))).fetchone()
|
||||
for provider in ('tiny','whatsapp'):
|
||||
enqueue(c, f"{result['id']}:paid:{provider}", provider,
|
||||
{'order_id': str(result['id']), 'number': result['number'], 'event': 'payment_approved', 'order': approved})
|
||||
return result
|
||||
try:
|
||||
quote = payments.approved_quote(c, body.quote_id, session_id)
|
||||
except payments.PaymentRefused as refusal:
|
||||
# The quote may already be paid; that is not a refusal.
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s AND owner=%s',
|
||||
(body.quote_id, session_id)).fetchone()
|
||||
if existing:
|
||||
return existing
|
||||
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
|
||||
# The charge happens inside the transaction that persists the order, so a
|
||||
# failure to record it cannot leave a customer charged without an order.
|
||||
receipt = payment.pay(str(body.quote_id), quote['approved']['total_cents'])
|
||||
order, _ = payments.create_order(c, quote, receipt)
|
||||
return order
|
||||
|
||||
147
app/api/payments.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""The provider's callback.
|
||||
|
||||
Unauthenticated by necessity — a payment provider has no session — so the
|
||||
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 uuid import uuid4
|
||||
|
||||
import httpx
|
||||
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, owner, rate_limit
|
||||
from ..core.models import PaymentIntent
|
||||
from ..runtime import payment
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Generous: a provider legitimately retries, and a signature check is cheap.
|
||||
# This exists so an unsigned flood cannot keep the database busy.
|
||||
WEBHOOK_LIMIT = 600
|
||||
# How long a card waiting for the bank's confirmation holds off a new attempt.
|
||||
CHALLENGE_MINUTES = 10
|
||||
|
||||
|
||||
@router.post('/api/payments/webhook')
|
||||
async def webhook(request: Request):
|
||||
rate_limit('payment-webhook', client_ip(request), WEBHOOK_LIMIT, 900)
|
||||
body = await request.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, 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.
|
||||
return {'status': 'ignored'}
|
||||
|
||||
with db.connect() as c:
|
||||
stored = payments.record(c, event_provider(), event)
|
||||
if stored is None:
|
||||
# Already delivered. Acknowledge without acting again.
|
||||
return {'status': 'duplicate'}
|
||||
outcome = payments.apply(c, event)
|
||||
c.execute('UPDATE dtf_local.payment_events SET processed_at=now(), outcome=%s WHERE id=%s',
|
||||
(outcome, stored['id']))
|
||||
|
||||
# audit()'s own first parameter is named `event`, so the id goes under another key.
|
||||
audit('payment_webhook_applied', payment_event=event.event_id,
|
||||
status=event.status, outcome=outcome)
|
||||
return {'status': 'applied', 'outcome': outcome}
|
||||
|
||||
|
||||
def event_provider():
|
||||
return payment.name
|
||||
|
||||
|
||||
def provider_reason(response):
|
||||
"""A short, loggable reason from a refused provider call."""
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return f'HTTP {response.status_code}'
|
||||
causes = '; '.join(f"{c.get('code')}: {c.get('description')}" for c in data.get('cause') or []
|
||||
if isinstance(c, dict))
|
||||
return (causes or data.get('message') or data.get('error') or f'HTTP {response.status_code}')[:300]
|
||||
|
||||
|
||||
@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 charge for files that are gone: an unpaid cart's files are
|
||||
# removed after a while. A payment under way keeps them a day longer,
|
||||
# time enough for the provider's notice to become the order.
|
||||
uploads = payments.quote_uploads(quote['approved'])
|
||||
live = c.execute('''SELECT count(*) AS n FROM dtf_local.uploads WHERE id=ANY(%s)
|
||||
AND purged_at IS NULL AND expires_at>now()''', (uploads,)).fetchone()['n']
|
||||
if live != len(set(uploads)):
|
||||
raise HTTPException(410, 'Os arquivos deste pedido expiraram porque ele não foi pago a tempo. '
|
||||
'Monte o pedido de novo para pagar.')
|
||||
payments.hold_uploads(c, uploads, '1 day')
|
||||
# Never a second charge: an approved payment is waiting for its
|
||||
# notification to become the order, and a card in review may still be.
|
||||
# A card waiting for the bank's confirmation (3-D Secure) blocks only
|
||||
# for CHALLENGE_MINUTES: a customer who gave up on it must still be able
|
||||
# to pay, and an unanswered challenge is not charged.
|
||||
if c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s
|
||||
AND (status='approved' OR (method='card' AND status='pending'
|
||||
AND NOT (jsonb_typeof(response->'challenge')='object'
|
||||
AND created_at < now() - make_interval(mins => %s))))''',
|
||||
(body.quote_id, CHALLENGE_MINUTES)).fetchone():
|
||||
raise HTTPException(409, 'A payment for this quote is already approved or in review')
|
||||
method = body.method.model_dump()
|
||||
if body.method.type == 'pix':
|
||||
# One open PIX per quote: the same code until it expires, and a new
|
||||
# one only after that, when the old code can no longer be paid.
|
||||
# Serialised per quote, so two clicks never open two codes.
|
||||
c.execute('SELECT pg_advisory_xact_lock(hashtext(%s))', ('pix:'+str(body.quote_id),))
|
||||
existing = c.execute('''SELECT *, COALESCE((response->>'expires_at')::timestamptz <= now(), false) AS expired
|
||||
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 and not existing['expired']:
|
||||
return existing['response']
|
||||
if existing:
|
||||
c.execute("UPDATE dtf_local.payment_intents SET status='expired', updated_at=now() WHERE id=%s",
|
||||
(existing['id'],))
|
||||
method['attempt'] = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_intents
|
||||
WHERE quote_id=%s AND method='pix' ''', (body.quote_id,)).fetchone()['n'] + 1
|
||||
try:
|
||||
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
|
||||
quote['approved']['customer'], method)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
except httpx.HTTPStatusError as exc:
|
||||
# Mercado Pago's own reason (status, message and cause codes) goes to
|
||||
# the log; it never contains card data, only what was refused.
|
||||
reason = provider_reason(exc.response)
|
||||
audit('payment_intent_refused', quote=str(body.quote_id), method=body.method.type,
|
||||
status=exc.response.status_code, reason=reason)
|
||||
if exc.response.status_code < 500:
|
||||
raise HTTPException(422, f'O Mercado Pago recusou o pagamento: {reason}')
|
||||
raise HTTPException(502, 'Payment provider unavailable; try again')
|
||||
except Exception as exc:
|
||||
audit('payment_intent_failed', quote=str(body.quote_id), error=type(exc).__name__)
|
||||
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
|
||||
@@ -1,41 +1,67 @@
|
||||
"""Quotes: the customer's cart, and the operator-reviewed version of it."""
|
||||
"""Quotes: the customer's cart, approved at once when it can be (app/quote_review.py)."""
|
||||
import hashlib
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import payments
|
||||
from ..core import db
|
||||
from ..core.auth import owner
|
||||
from ..core.models import QuoteRequest
|
||||
from ..runtime import freight, quote_view, upload_row
|
||||
from ..core.pricing import price
|
||||
from .. import quote_review
|
||||
from ..grade_check import Files, mismatch
|
||||
from ..runtime import freight, quote_view, require_delivery_available, storage, upload_row
|
||||
from ..scanning import require_clean
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post('/api/quotes')
|
||||
def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
||||
for item in body.items:
|
||||
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
|
||||
raise HTTPException(422, 'Quoted metres do not match the submitted layout height')
|
||||
require_delivery_available(body.freight.service)
|
||||
draft = body.model_dump(mode='json', exclude={'request_key'})
|
||||
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
|
||||
try:
|
||||
freight.quote(body.freight.service, body.freight.postal_code)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
reason = quote_review.review_reason(draft)
|
||||
if reason is not None:
|
||||
# Waits for review: check the delivery now, priced at approval.
|
||||
try:
|
||||
priced = [price(item.mode, str(item.metres), item.grade) for item in body.items]
|
||||
freight.quote(body.freight.service, body.freight.postal_code,
|
||||
sum(Decimal(i['billed_metres']) for i in priced), sum(i['total_cents'] for i in priced))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
with db.connect() as c:
|
||||
rows = {}
|
||||
for item in body.items:
|
||||
for uid in item.uploads:
|
||||
row = upload_row(c, uid, session_id)
|
||||
if not row['complete']:
|
||||
raise HTTPException(409, 'Complete every upload before requesting a quote')
|
||||
require_clean(row)
|
||||
rows[str(uid)] = row
|
||||
# The grade sets the price and came from the browser: before a cart is
|
||||
# approved at checkout, the server works it out from the files.
|
||||
note = mismatch(Files(storage), draft['items'], rows) if reason is None else None
|
||||
reason = reason or note
|
||||
uid = uuid4()
|
||||
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
|
||||
(uid, session_id, body.request_key, digest, Jsonb(draft)))
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s', (session_id, body.request_key)).fetchone()
|
||||
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft,review_note) VALUES(%s,%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
|
||||
(uid, session_id, body.request_key, digest, Jsonb(draft), note))
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s FOR UPDATE', (session_id, body.request_key)).fetchone()
|
||||
if row['request_hash'] != digest:
|
||||
raise HTTPException(409, 'Request key already used for a different cart')
|
||||
return {'id': row['id'], 'status': 'pending_review'}
|
||||
if row['id'] == uid and reason is None:
|
||||
quote_review.approve(c, row, body.items, quote_review.AUTO)
|
||||
return {'id': row['id'], 'status': 'approved'}
|
||||
if row['id'] == uid:
|
||||
# An operator reviews it first; the files wait for that review.
|
||||
payments.hold_uploads(c, payments.quote_uploads(draft), payments.REVIEW_HOLD)
|
||||
return {'id': row['id'], 'status': 'approved' if row['approved'] else 'pending_review'}
|
||||
|
||||
@router.get('/api/quotes/{uid}')
|
||||
def get_quote(uid: UUID, session_id=Depends(owner)):
|
||||
|
||||
@@ -12,15 +12,17 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..core import db
|
||||
from ..core.auth import audit, owner, rate_limit
|
||||
from ..core.limits import upload_limit_bytes
|
||||
from ..core.models import UploadStart
|
||||
from ..payments import UNPAID_HOLD
|
||||
from ..runtime import PART_BYTES, storage, upload_row
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post('/api/uploads')
|
||||
def begin_upload(body: UploadStart, session_id=Depends(owner)):
|
||||
if body.size > int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120')):
|
||||
raise HTTPException(413, 'File exceeds the upload limit')
|
||||
if body.size > upload_limit_bytes():
|
||||
raise HTTPException(413, 'File exceeds the malware scan limit; select a smaller file')
|
||||
uid = uuid4()
|
||||
key = f'originals/{uid}'
|
||||
rate_limit('upload-start', str(session_id), 60, 900)
|
||||
@@ -30,14 +32,16 @@ def begin_upload(body: UploadStart, session_id=Depends(owner)):
|
||||
usage = c.execute('''SELECT COALESCE(sum(size),0) AS total,
|
||||
COALESCE(sum(size) FILTER(WHERE owner=%s),0) AS owned,
|
||||
count(*) FILTER(WHERE owner=%s AND NOT complete) AS pending
|
||||
FROM dtf_local.uploads WHERE purged_at IS NULL''', (session_id,session_id)).fetchone()
|
||||
FROM dtf_local.uploads WHERE purged_at IS NULL''',
|
||||
(session_id,session_id)).fetchone()
|
||||
if (usage['total']+body.size > int(os.environ.get('STORAGE_QUOTA_BYTES','53687091200')) or
|
||||
usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
|
||||
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
|
||||
audit('upload_quota_rejected')
|
||||
raise HTTPException(429, 'Local storage quota or pending upload limit reached')
|
||||
raise HTTPException(429, 'Limite de armazenamento ou de envios pendentes atingido. Tente de novo mais tarde.')
|
||||
multipart = storage.begin(key)
|
||||
c.execute('INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id) VALUES(%s,%s,%s,%s,%s,%s)',
|
||||
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''',
|
||||
(uid, session_id, body.name, body.size, key, multipart))
|
||||
return {'id': uid, 'part_bytes': PART_BYTES}
|
||||
|
||||
@@ -54,8 +58,11 @@ def upload_status(uid: UUID, session_id=Depends(owner)):
|
||||
def part_url(uid: UUID, part: int, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = upload_row(c, uid, session_id)
|
||||
if row['complete'] or not 1 <= part <= math.ceil(row['size'] / PART_BYTES):
|
||||
raise HTTPException(409, 'Invalid part or completed upload')
|
||||
if row['complete'] or not 1 <= part <= math.ceil(row['size'] / PART_BYTES):
|
||||
raise HTTPException(409, 'Invalid part or completed upload')
|
||||
# The reservation lease is an hour; a multi-GB upload on a slow line
|
||||
# takes longer, so each part it asks for keeps it alive.
|
||||
c.execute("UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at,now()+interval '1 hour') WHERE id=%s", (uid,))
|
||||
size = min(PART_BYTES, row['size']-(part-1)*PART_BYTES)
|
||||
return {'url': storage.part_url(row['object_key'], row['multipart_id'], part, size)}
|
||||
|
||||
@@ -81,5 +88,19 @@ def complete_upload(uid: UUID, session_id=Depends(owner)):
|
||||
existing_size = storage.size(row['object_key'])
|
||||
if existing_size != row['size']:
|
||||
raise HTTPException(409, 'Stored size differs from declared size')
|
||||
c.execute('UPDATE dtf_local.uploads SET complete=true WHERE id=%s', (uid,))
|
||||
# Held while the cart is unpaid: an abandoned cart's files go after
|
||||
# UNPAID_HOLD; a paid order keeps them for its 30 days (app/payments.py).
|
||||
c.execute('UPDATE dtf_local.uploads SET complete=true,expires_at=now()+%s::interval WHERE id=%s',
|
||||
(UNPAID_HOLD, uid))
|
||||
return {'id': uid, 'complete': True}
|
||||
|
||||
@router.delete('/api/uploads/{uid}')
|
||||
def cancel_upload(uid: UUID, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row=upload_row(c,uid,session_id,lock=True)
|
||||
if row['complete']:
|
||||
raise HTTPException(409, 'Completed upload cannot be cancelled')
|
||||
storage.discard(row['object_key'],row['multipart_id'],False)
|
||||
c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s',(uid,))
|
||||
audit('upload_cancelled', upload=str(uid))
|
||||
return {'id':uid,'cancelled':True}
|
||||
|
||||
@@ -13,7 +13,7 @@ from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from .core import db
|
||||
from .core.auth import audit, client_ip
|
||||
from .runtime import ALLOWED_HOSTS, ALLOWED_ORIGINS, storage
|
||||
from .api import artwork, customer, health, operator, orders, quotes, uploads
|
||||
from .api import artwork, customer, health, operator, orders, payments, quotes, uploads
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -44,5 +44,5 @@ async def safe_headers(request, call_next):
|
||||
|
||||
|
||||
# Order is not significant: no two routers declare the same path.
|
||||
for module in (health, uploads, quotes, orders, operator, customer, artwork):
|
||||
for module in (health, uploads, quotes, orders, payments, operator, customer, artwork):
|
||||
app.include_router(module.router)
|
||||
|
||||
@@ -12,6 +12,26 @@ from fastapi import HTTPException
|
||||
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')
|
||||
# A large sheet's print file is its original, owned by the customer.
|
||||
return c.execute('SELECT owner FROM dtf_local.uploads WHERE id=%s', (ref.upload_id,)).fetchone()['owner']
|
||||
|
||||
def submit_files(c, order, body, identity, kind, actor):
|
||||
if order['version'] != body.version:
|
||||
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
|
||||
@@ -32,19 +52,28 @@ 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)
|
||||
if c.execute('SELECT id FROM dtf_local.order_files WHERE upload_id=%s', (ref.upload_id,)).fetchone():
|
||||
raise HTTPException(409, 'File is already attached. Upload a new revision.')
|
||||
c.execute('UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind=%s', (order['id'],kind))
|
||||
# A new customer correction supersedes every final prepared from earlier
|
||||
# artwork, including finals uploaded while this order was in correction.
|
||||
if kind == 'correction':
|
||||
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind IN ('correction','final')", (order['id'],))
|
||||
else:
|
||||
c.execute('UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind=%s', (order['id'],kind))
|
||||
for ref in body.files:
|
||||
c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)',
|
||||
(uuid4(),order['id'],ref.upload_id,ref.item_index,kind,body.note,actor))
|
||||
c.execute('UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,%s) WHERE id=%s', (expiry,ref.upload_id))
|
||||
# The order's window, whatever the upload's own hold was.
|
||||
c.execute('UPDATE dtf_local.uploads SET expires_at=%s WHERE id=%s', (expiry,ref.upload_id))
|
||||
c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],))
|
||||
if kind == 'final':
|
||||
# Artwork approval, not commercial quote approval, starts original cleanup.
|
||||
c.execute("UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,now()+interval '7 days') WHERE id=ANY(%s)", (original_ids,))
|
||||
# An original approved as its own print file is kept as the final.
|
||||
finals = [ref.upload_id for ref in body.files]
|
||||
c.execute("UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,now()+interval '7 days') WHERE id=ANY(%s) AND NOT id=ANY(%s)",
|
||||
(original_ids, finals))
|
||||
return {'ok': True, 'version': order['version']+1, 'expires_at': expiry}
|
||||
|
||||
@@ -45,7 +45,7 @@ def session_row(request):
|
||||
try:
|
||||
sid = UUID(request.cookies.get('dtf_session', ''))
|
||||
except ValueError:
|
||||
raise HTTPException(401, 'Start a local session first')
|
||||
raise HTTPException(401, 'Sessão não iniciada. Recarregue a página.')
|
||||
with connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone()
|
||||
if not row:
|
||||
@@ -92,20 +92,35 @@ def rate_limit(scope, identity, limit, seconds=900):
|
||||
RETURNING attempts""", (key,seconds,seconds)).fetchone()
|
||||
if row['attempts'] > limit:
|
||||
audit('rate_limit', scope=scope)
|
||||
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
|
||||
raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', 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, 'Muitas tentativas. Tente de novo mais tarde.', 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', '')
|
||||
if not token or len(token)>128:
|
||||
raise HTTPException(401, 'Sign in to the local Kanban')
|
||||
raise HTTPException(401, 'Entre no Kanban.')
|
||||
digest = hashlib.sha256(token.encode()).hexdigest()
|
||||
with connect() as c:
|
||||
row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(401, 'Operator session expired')
|
||||
raise HTTPException(401, 'Sua sessão expirou. Entre de novo.')
|
||||
return row['username']
|
||||
|
||||
13
app/core/limits.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Limits shared by upload admission and the malware scanner."""
|
||||
import os
|
||||
|
||||
CLAMAV_STREAM_MAX_BYTES = 2000 * 1024 * 1024 # infra/clamd.conf StreamMaxLength
|
||||
|
||||
|
||||
def scan_limit_bytes():
|
||||
"""The largest file ClamAV scans; above it the format check releases it."""
|
||||
return min(CLAMAV_STREAM_MAX_BYTES, int(os.environ.get('SCAN_MAX_BYTES', str(CLAMAV_STREAM_MAX_BYTES))))
|
||||
|
||||
|
||||
def upload_limit_bytes():
|
||||
return int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))
|
||||
@@ -2,7 +2,7 @@ import re
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
|
||||
@@ -41,9 +41,38 @@ class Customer(StrictModel):
|
||||
return value.strip()
|
||||
|
||||
class Freight(StrictModel):
|
||||
service: Literal['pickup','mock-standard'] = 'pickup'
|
||||
service: Literal['pickup','mock-standard','jadlog'] = 'pickup'
|
||||
postal_code: str = Field(default='', max_length=8)
|
||||
|
||||
class FreightEstimate(Freight):
|
||||
"""The cart's estimate. The charged freight is quoted again by the server
|
||||
from the approved items, never from these numbers."""
|
||||
metres: Decimal | None = Field(default=None, gt=0, le=12000)
|
||||
declared_cents: int = Field(default=0, ge=0, le=100_000_000, strict=True)
|
||||
|
||||
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)
|
||||
@@ -56,17 +85,98 @@ class UploadStart(StrictModel):
|
||||
raise ValueError('Unsupported artwork file extension')
|
||||
return value
|
||||
|
||||
class ProductionSource(StrictModel):
|
||||
upload_id: UUID
|
||||
kind: Literal['sheet', 'artwork']
|
||||
width_cm: Decimal = Field(gt=0, le=57)
|
||||
length_cm: Decimal = Field(gt=0, le=6000)
|
||||
copies: int = Field(ge=1, le=200, strict=True)
|
||||
rotation_degrees: Literal[0, 90] = 0
|
||||
mirrored: bool = False
|
||||
measurement: Literal['file', 'customer']
|
||||
|
||||
class ProductionPlacement(StrictModel):
|
||||
source_index: int = Field(ge=0, le=19, strict=True)
|
||||
copy_index: int = Field(ge=0, le=199, strict=True)
|
||||
x_cm: Decimal = Field(ge=0, le=57)
|
||||
y_cm: Decimal = Field(ge=0, le=1200000)
|
||||
width_cm: Decimal = Field(gt=0, le=57)
|
||||
length_cm: Decimal = Field(gt=0, le=6000)
|
||||
rotation_degrees: Literal[0, 90, 180, 270]
|
||||
mirrored: bool
|
||||
|
||||
class ProductionSpec(StrictModel):
|
||||
version: Literal[2]
|
||||
film_width_cm: Decimal
|
||||
height_cm: Decimal = Field(gt=0, le=1200000)
|
||||
sources: list[ProductionSource] = Field(min_length=1, max_length=20)
|
||||
placements: list[ProductionPlacement] = Field(min_length=1, max_length=4000)
|
||||
|
||||
class Item(StrictModel):
|
||||
mode: Literal['file','avulsa','uvfile','uv']
|
||||
metres: Decimal = Field(gt=0, le=12000)
|
||||
grade: int = Field(ge=0, le=100, strict=True)
|
||||
uploads: list[UUID] = Field(min_length=1, max_length=20)
|
||||
production: ProductionSpec
|
||||
quality_status: Literal['ok', 'warning', 'unverified']
|
||||
quality_acknowledged: bool
|
||||
|
||||
@model_validator(mode='after')
|
||||
def production_matches_uploads(self):
|
||||
if [source.upload_id for source in self.production.sources] != self.uploads:
|
||||
raise ValueError('Production sources must match uploaded files in order')
|
||||
is_sheet = self.mode in ('file', 'uvfile')
|
||||
film_width = Decimal('28.5') if self.mode in ('uvfile', 'uv') else Decimal('57')
|
||||
if self.production.film_width_cm != film_width:
|
||||
raise ValueError('Production film width does not match the product')
|
||||
for source in self.production.sources:
|
||||
if (source.kind == 'sheet') != is_sheet or source.width_cm > film_width:
|
||||
raise ValueError('Production source does not fit the selected product')
|
||||
if is_sheet and (source.rotation_degrees or source.mirrored):
|
||||
raise ValueError('Finished sheets cannot be rotated or mirrored by the layout')
|
||||
expected={(index,copy) for index,source in enumerate(self.production.sources)
|
||||
for copy in range(source.copies)}
|
||||
placed=set()
|
||||
tolerance=Decimal('0.02')
|
||||
for placement in self.production.placements:
|
||||
key=(placement.source_index,placement.copy_index)
|
||||
if key not in expected or key in placed:
|
||||
raise ValueError('Production placement has a missing or duplicate source copy')
|
||||
placed.add(key)
|
||||
source=self.production.sources[placement.source_index]
|
||||
auto_rotation=(placement.rotation_degrees-source.rotation_degrees)%360
|
||||
if auto_rotation not in (0,90) or placement.mirrored != source.mirrored:
|
||||
raise ValueError('Production placement changes the source transform')
|
||||
width,length=(source.width_cm,source.length_cm) if auto_rotation==0 else (source.length_cm,source.width_cm)
|
||||
if abs(placement.width_cm-width)>tolerance or abs(placement.length_cm-length)>tolerance:
|
||||
raise ValueError('Production placement changes the source size')
|
||||
if placement.x_cm+placement.width_cm>film_width+tolerance or placement.y_cm+placement.length_cm>self.production.height_cm+tolerance:
|
||||
raise ValueError('Production placement is outside the film')
|
||||
if is_sheet and (placement.x_cm or auto_rotation):
|
||||
raise ValueError('Finished sheets must retain their original orientation')
|
||||
if placed != expected:
|
||||
raise ValueError('Production layout does not cover every source copy')
|
||||
if self.quality_status == 'warning' and not self.quality_acknowledged:
|
||||
raise ValueError('Resolution warning must be acknowledged')
|
||||
return self
|
||||
|
||||
class QuoteRequest(StrictModel):
|
||||
request_key: UUID
|
||||
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)
|
||||
@@ -74,15 +184,70 @@ 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)
|
||||
# The cardholder's document from the card form: for a card, the payer is
|
||||
# the cardholder, not necessarily the company on the invoice.
|
||||
payer_document_type: Literal['CPF', 'CNPJ'] | None = None
|
||||
payer_document: str | None = Field(default=None, pattern=r'^[0-9]{11,14}$')
|
||||
|
||||
@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)
|
||||
|
||||
class ProfileUpdate(StrictModel):
|
||||
"""What a customer may change about their account; the CNPJ is not among it."""
|
||||
zap: str
|
||||
address: Destination | None = None
|
||||
|
||||
@field_validator('zap')
|
||||
@classmethod
|
||||
def phone_valid(cls, value):
|
||||
digits = re.sub(r'\D', '', value)
|
||||
if len(digits) not in (10,11) or not digits.isascii():
|
||||
raise ValueError('Invalid phone')
|
||||
return digits
|
||||
|
||||
class EmailChange(StrictModel):
|
||||
email: str = Field(max_length=254)
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def email_valid(cls, value):
|
||||
value = value.strip().lower()
|
||||
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value):
|
||||
raise ValueError('Invalid email')
|
||||
return value
|
||||
|
||||
class PasswordChange(StrictModel):
|
||||
current: str = Field(min_length=1, max_length=128)
|
||||
new: str = Field(min_length=12, max_length=128)
|
||||
|
||||
class Login(StrictModel):
|
||||
email: str = Field(min_length=3, max_length=254)
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
189
app/grade_check.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""The grade (the resolution discount) recomputed from the uploaded files.
|
||||
|
||||
The Site grades the art in the customer's browser and sends the grade with the
|
||||
cart, and the price depends on it. A customer who edits the page could claim a
|
||||
better grade, so before a cart is approved at checkout the server reads the
|
||||
files it already has and works the grade out again, by the Site's own rules:
|
||||
|
||||
- a finished sheet: its pixel width over the sheet width is its DPI, and the
|
||||
item's grade comes from the worst sheet (web/site-quality.js);
|
||||
- artworks: each one's DPI along the width it is printed at, rotation
|
||||
included; the item's grade is the average of the artworks' grades;
|
||||
- a PDF: the area-weighted DPI of the images drawn on its page, 300 when it
|
||||
is only vectors (web/site-pdf.js, dpiDasImagens and resumoDpi).
|
||||
|
||||
Images are measured from their first bytes, never decoded. A PDF is opened
|
||||
only up to PDF_MAX_BYTES, the size the Site itself analyses. A grade that
|
||||
cannot be recomputed is None; the caller decides what that means.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
|
||||
DPI_IDEAL = 300
|
||||
# The Site analyses PDFs up to 150 MB (GRANDE_BYTES); above that it does not
|
||||
# grade them, so neither does this.
|
||||
PDF_MAX_BYTES = 150 * 1048576
|
||||
HEAD_BYTES = 1048576
|
||||
# Rounding in the browser and here can differ by a point on the same file.
|
||||
TOLERANCE = 2
|
||||
|
||||
|
||||
def js_round(value):
|
||||
"""Math.round, which rounds halves up; Python's round() does not."""
|
||||
return math.floor(value + 0.5)
|
||||
|
||||
|
||||
def grade_of(dpi):
|
||||
return max(6, min(100, js_round(dpi / DPI_IDEAL * 100)))
|
||||
|
||||
|
||||
def image_size(head):
|
||||
"""(width, height) in pixels from a PNG, JPEG or WebP's first bytes."""
|
||||
if head[:8] == b'\x89PNG\r\n\x1a\n' and head[12:16] == b'IHDR':
|
||||
return struct.unpack('>II', head[16:24])
|
||||
if head[:2] == b'\xff\xd8':
|
||||
i = 2
|
||||
while i + 9 < len(head):
|
||||
if head[i] != 0xFF:
|
||||
i += 1
|
||||
continue
|
||||
marker = head[i + 1]
|
||||
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7 or marker == 0xFF:
|
||||
i += 1 if marker == 0xFF else 2
|
||||
continue
|
||||
length = struct.unpack('>H', head[i + 2:i + 4])[0]
|
||||
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
|
||||
h, w = struct.unpack('>HH', head[i + 5:i + 9])
|
||||
return w, h
|
||||
i += 2 + length
|
||||
return None
|
||||
if head[:4] == b'RIFF' and head[8:12] == b'WEBP':
|
||||
chunk = head[12:16]
|
||||
if chunk == b'VP8X':
|
||||
return (int.from_bytes(head[24:27], 'little') + 1, int.from_bytes(head[27:30], 'little') + 1)
|
||||
if chunk == b'VP8L' and head[20] == 0x2F:
|
||||
bits = int.from_bytes(head[21:25], 'little')
|
||||
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||
if chunk == b'VP8 ' and head[23:26] == b'\x9d\x01\x2a':
|
||||
w, h = struct.unpack('<HH', head[26:30])
|
||||
return w & 0x3FFF, h & 0x3FFF
|
||||
return None
|
||||
|
||||
|
||||
def _multiply(a, b):
|
||||
return [a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1],
|
||||
a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3],
|
||||
a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5]]
|
||||
|
||||
|
||||
def pdf_dpi(path):
|
||||
"""The page's area-weighted image DPI, 300 for vectors only, None if the
|
||||
file is not a one-page PDF this can read."""
|
||||
import pikepdf
|
||||
try:
|
||||
with pikepdf.open(path) as pdf:
|
||||
if len(pdf.pages) != 1:
|
||||
return None
|
||||
page = pdf.pages[0]
|
||||
unit = float(page.obj.get('/UserUnit', 1))
|
||||
found = []
|
||||
|
||||
def walk(resources, instructions, ctm, depth):
|
||||
stack = []
|
||||
xobjects = resources.get('/XObject', {}) if resources is not None else {}
|
||||
for operands, operator in instructions:
|
||||
op = str(operator)
|
||||
if op == 'q':
|
||||
stack.append(ctm)
|
||||
elif op == 'Q':
|
||||
ctm = stack.pop() if stack else [1, 0, 0, 1, 0, 0]
|
||||
elif op == 'cm':
|
||||
ctm = _multiply(ctm, [float(v) for v in operands])
|
||||
elif op == 'Do' and operands:
|
||||
xobject = xobjects.get(str(operands[0]))
|
||||
if xobject is None:
|
||||
continue
|
||||
subtype = xobject.get('/Subtype')
|
||||
if subtype == '/Image':
|
||||
width_pt = math.hypot(ctm[0], ctm[1])
|
||||
if width_pt >= 1:
|
||||
found.append((int(xobject.get('/Width', 0)) / (width_pt * unit / 72),
|
||||
abs(ctm[0] * ctm[3] - ctm[1] * ctm[2])))
|
||||
elif subtype == '/Form' and depth < 8:
|
||||
matrix = [float(v) for v in xobject.get('/Matrix', [1, 0, 0, 1, 0, 0])]
|
||||
walk(xobject.get('/Resources', resources),
|
||||
pikepdf.parse_content_stream(xobject), _multiply(ctm, matrix), depth + 1)
|
||||
|
||||
walk(page.obj.get('/Resources'), pikepdf.parse_content_stream(page), [1, 0, 0, 1, 0, 0], 0)
|
||||
except Exception:
|
||||
return None
|
||||
found = [(dpi, area) for dpi, area in found if dpi > 0]
|
||||
if not found:
|
||||
return DPI_IDEAL
|
||||
total = sum(area for _, area in found) or 1
|
||||
return js_round(sum(dpi * area for dpi, area in found) / total)
|
||||
|
||||
|
||||
class Files:
|
||||
"""The uploaded files, read from storage as little as needed."""
|
||||
|
||||
def __init__(self, storage):
|
||||
self.storage = storage
|
||||
|
||||
def head(self, row):
|
||||
return self.storage.client.get_object(Bucket=self.storage.bucket, Key=row['object_key'],
|
||||
Range=f'bytes=0-{HEAD_BYTES - 1}')['Body'].read()
|
||||
|
||||
def pdf_dpi(self, row):
|
||||
if row['size'] > PDF_MAX_BYTES:
|
||||
return None
|
||||
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
|
||||
self.storage.client.download_fileobj(self.storage.bucket, row['object_key'], handle)
|
||||
handle.flush()
|
||||
return pdf_dpi(handle.name)
|
||||
|
||||
|
||||
def source_dpi(files, row, source):
|
||||
"""One source's DPI across the width it is printed at, or None."""
|
||||
name = row['name'].lower()
|
||||
width_in = float(source['width_cm']) / 2.54
|
||||
if name.endswith('.pdf'):
|
||||
return files.pdf_dpi(row) if source['kind'] == 'sheet' else None
|
||||
if not name.endswith(('.png', '.jpg', '.jpeg', '.webp')):
|
||||
return None
|
||||
size = image_size(files.head(row))
|
||||
if not size or not all(size):
|
||||
return None
|
||||
across = size[1] if source['rotation_degrees'] in (90, 270) else size[0]
|
||||
return across / width_in
|
||||
|
||||
|
||||
def item_grade(files, item, rows):
|
||||
"""The grade the Site gives this item, recomputed; None if it cannot be."""
|
||||
sources = item['production']['sources']
|
||||
dpis = []
|
||||
for source in sources:
|
||||
row = rows.get(str(source['upload_id']))
|
||||
dpi = source_dpi(files, row, source) if row else None
|
||||
if dpi is None:
|
||||
return None
|
||||
dpis.append(dpi)
|
||||
if all(source['kind'] == 'sheet' for source in sources):
|
||||
# A sheet's DPI is shown and compared as a whole number.
|
||||
return grade_of(min(js_round(d) for d in dpis))
|
||||
return js_round(sum(grade_of(d) for d in dpis) / len(dpis))
|
||||
|
||||
|
||||
def mismatch(files, items, rows):
|
||||
"""Why a cart's grades cannot be approved at checkout, or None."""
|
||||
for item in items:
|
||||
if item['grade'] == 0:
|
||||
continue # full price: nothing to verify
|
||||
verified = item_grade(files, item, rows)
|
||||
if verified is None:
|
||||
return 'Nota não conferida no servidor'
|
||||
if item['grade'] > verified + TOLERANCE:
|
||||
return f"Nota {item['grade']} maior que a do arquivo ({verified})"
|
||||
return None
|
||||
119
app/jadlog.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Jadlog freight quotes (Embarcador API, "Simulador de Frete").
|
||||
|
||||
Written from Jadlog's API manual v2.3 (August 2025) and exercised only
|
||||
against a fake HTTP transport until app.jadlog_probe has run on the client's
|
||||
account. A quote is read-only: it creates no shipment and costs nothing, so
|
||||
unlike Tiny it can be tried on the real account as often as needed.
|
||||
|
||||
The client's contract: origin CEP 14402-310, service .PACKAGE (modalidade 3),
|
||||
home delivery. Jadlog prices by weight in kg and expects the larger of the
|
||||
real and the cubed weight. The package weight comes from the client (a base
|
||||
plus a weight per billed metre) and has no default: FREIGHT_ADAPTER=jadlog
|
||||
refuses to start without it, so a guessed weight never prices a customer's
|
||||
freight.
|
||||
"""
|
||||
import os
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
import httpx
|
||||
|
||||
QUOTE_URL = 'https://www.jadlog.com.br/embarcador/api/frete/valor'
|
||||
PACKAGE = 3
|
||||
ORIGIN = '14402310'
|
||||
SERVICE = 'jadlog'
|
||||
WEIGHT_SETTINGS = ('JADLOG_PESO_BASE_KG', 'JADLOG_PESO_POR_METRO_KG')
|
||||
|
||||
|
||||
class JadlogError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def digits(value):
|
||||
return ''.join(ch for ch in str(value or '') if ch.isdigit())
|
||||
|
||||
|
||||
class JadlogQuotes:
|
||||
def __init__(self, token=None, cnpj=None, conta=None, contrato=None, origin=None,
|
||||
modalidade=None, transport=None):
|
||||
self.token = (token or os.environ.get('JADLOG_TOKEN', '')).strip()
|
||||
self.cnpj = digits(cnpj or os.environ.get('JADLOG_CNPJ'))
|
||||
# Sent as configured: Jadlog issues it as 000000-0 and documents six
|
||||
# characters, so which form it accepts is only known on the account.
|
||||
self.conta = (conta if conta is not None else os.environ.get('JADLOG_CONTA', '')).strip()
|
||||
# Only when Jadlog issued one; the manual says to send null otherwise.
|
||||
self.contrato = (contrato if contrato is not None else os.environ.get('JADLOG_CONTRATO', '')).strip() or None
|
||||
self.origin = digits(origin or os.environ.get('JADLOG_ORIGEM_CEP') or ORIGIN)
|
||||
self.modalidade = int(modalidade or os.environ.get('JADLOG_MODALIDADE') or PACKAGE)
|
||||
if not self.token or len(self.cnpj) != 14:
|
||||
raise RuntimeError('Jadlog needs JADLOG_TOKEN and a 14-digit JADLOG_CNPJ')
|
||||
self.http = httpx.Client(timeout=20, transport=transport)
|
||||
|
||||
def item(self, postal_code, weight_kg, declared_cents):
|
||||
return {'cepori': self.origin, 'cepdes': digits(postal_code), 'frap': 'N',
|
||||
'peso': float(weight_kg), 'cnpj': self.cnpj, 'conta': self.conta,
|
||||
'contrato': self.contrato, 'modalidade': self.modalidade,
|
||||
'tpentrega': 'D', 'tpseguro': 'N',
|
||||
'vldeclarado': declared_cents / 100, 'vlcoleta': 0}
|
||||
|
||||
def quote(self, postal_code, weight_kg, declared_cents):
|
||||
"""Price in centavos and delivery days for one package to one CEP."""
|
||||
response = self.http.post(QUOTE_URL, json={'frete': [self.item(postal_code, weight_kg, declared_cents)]},
|
||||
headers={'Authorization': self.token})
|
||||
if response.status_code == 401:
|
||||
raise JadlogError('Jadlog refused the token (HTTP 401)')
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
raise JadlogError(f'HTTP {response.status_code}: the response is not JSON') from None
|
||||
items = data.get('frete') or []
|
||||
# The manual names the group "erro" in its tables and "error" in its
|
||||
# examples, at the top level and per item.
|
||||
problem = data.get('error') or data.get('erro')
|
||||
if not problem and items:
|
||||
problem = items[0].get('erro') or items[0].get('error')
|
||||
if problem:
|
||||
raise JadlogError(problem.get('descricao') or str(problem) if isinstance(problem, dict) else str(problem))
|
||||
if response.status_code >= 400 or not items or items[0].get('vltotal') is None:
|
||||
raise JadlogError(f'HTTP {response.status_code}: no freight value in the response')
|
||||
total = Decimal(str(items[0]['vltotal']))
|
||||
return {'total_cents': int((total * 100).quantize(Decimal('1'), ROUND_HALF_UP)),
|
||||
'days': items[0].get('prazo'), 'raw': items[0]}
|
||||
|
||||
|
||||
def required_settings():
|
||||
return ('JADLOG_TOKEN', 'JADLOG_CNPJ') + WEIGHT_SETTINGS
|
||||
|
||||
|
||||
class JadlogFreight:
|
||||
"""The Site's delivery option: Jadlog's price for the order's package."""
|
||||
name = 'jadlog'
|
||||
|
||||
def __init__(self, quotes=None):
|
||||
self.quotes = quotes or JadlogQuotes()
|
||||
self.base_kg = Decimal(os.environ['JADLOG_PESO_BASE_KG'])
|
||||
self.per_metre_kg = Decimal(os.environ['JADLOG_PESO_POR_METRO_KG'])
|
||||
# Jadlog's time counts from collection; the order is printed first.
|
||||
self.production_days = int(os.environ.get('FREIGHT_PRODUCTION_DAYS') or 0)
|
||||
if self.base_kg < 0 or self.per_metre_kg <= 0 or self.production_days < 0:
|
||||
raise RuntimeError('Jadlog package weight and production days must be positive')
|
||||
|
||||
def weight_kg(self, metres):
|
||||
return (self.base_kg + self.per_metre_kg * Decimal(str(metres))).quantize(Decimal('0.001'))
|
||||
|
||||
def quote(self, service, postal_code, metres=None, declared_cents=0):
|
||||
if service == 'pickup':
|
||||
return {'provider': self.name, 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}
|
||||
if service != SERVICE or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit():
|
||||
raise ValueError('Select pickup or Jadlog delivery with an eight-digit CEP')
|
||||
if metres is None or Decimal(str(metres)) <= 0:
|
||||
raise ValueError('The order length is required to quote freight')
|
||||
weight = self.weight_kg(metres)
|
||||
try:
|
||||
result = self.quotes.quote(postal_code, weight, int(declared_cents))
|
||||
except (JadlogError, httpx.HTTPError) as error:
|
||||
raise ValueError(f'Não foi possível cotar o frete na Jadlog agora: {error}') from None
|
||||
days = result['days']
|
||||
return {'provider': self.name, 'service': SERVICE, 'postal_code': postal_code,
|
||||
'total_cents': result['total_cents'], 'weight_kg': str(weight),
|
||||
'days': None if days is None else int(days) + self.production_days,
|
||||
'description': 'Jadlog .PACKAGE'}
|
||||
80
app/jadlog_probe.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Read-only price check against the client's real Jadlog account, run by hand.
|
||||
A quote creates no shipment and costs nothing.
|
||||
|
||||
python -m app.jadlog_probe [--cep 01310100 ...] [--peso 0.5 ...] [--valor 100]
|
||||
|
||||
Without --cep and --peso it prices a few test weights to a few regions. It
|
||||
answers whether the token, CNPJ and account are accepted, whether a contract
|
||||
number is required (Jadlog names it in the error), and what the contract
|
||||
prices and delivery times are. The first failure stops the run: an account
|
||||
problem would repeat on every line.
|
||||
|
||||
In production, from the worker's console (Portainer > Containers > worker >
|
||||
Console), with JADLOG_TOKEN, JADLOG_CNPJ and JADLOG_CONTA in the stack:
|
||||
|
||||
python -m app.jadlog_probe
|
||||
|
||||
Locally, with the credentials in jadlog.env (ignored by git):
|
||||
|
||||
docker compose -f compose.local.yaml -f compose.providers.yaml run --rm --no-deps --build \\
|
||||
--env-from-file jadlog.env worker python -m app.jadlog_probe
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
|
||||
from .core.secrets import load as load_secret_files
|
||||
from .jadlog import JadlogError, JadlogQuotes
|
||||
|
||||
DESTINATIONS = {'14402310': 'Franca (origem)', '01310100': 'São Paulo', '20040002': 'Rio de Janeiro',
|
||||
'80010000': 'Curitiba', '50030230': 'Recife', '69005010': 'Manaus'}
|
||||
WEIGHTS = ['0.3', '0.5', '1', '2', '5']
|
||||
|
||||
|
||||
def money(cents):
|
||||
return f'R$ {cents / 100:.2f}'.replace('.', ',')
|
||||
|
||||
|
||||
def run(quotes, ceps, weights, declared_cents, out):
|
||||
header = quotes.token
|
||||
for weight in weights:
|
||||
for cep in ceps:
|
||||
try:
|
||||
result = quotes.quote(cep, Decimal(weight), declared_cents)
|
||||
except JadlogError as error:
|
||||
# The manual shows the header as the bare token; some accounts
|
||||
# are issued one that expects the Bearer scheme.
|
||||
if '401' in str(error) and not header.lower().startswith('bearer '):
|
||||
quotes.token = 'Bearer ' + header
|
||||
header = quotes.token
|
||||
try:
|
||||
result = quotes.quote(cep, Decimal(weight), declared_cents)
|
||||
except JadlogError as retry:
|
||||
out(f'ERRO {cep} {weight} kg: {retry} (also with "Bearer ")')
|
||||
return 1
|
||||
out('Note: the token only works with the "Bearer " prefix.')
|
||||
else:
|
||||
out(f'ERRO {cep} {weight} kg: {error}')
|
||||
return 1
|
||||
days = result['days']
|
||||
out(f"{weight} kg\t{cep}\t{DESTINATIONS.get(cep, '')}\t{money(result['total_cents'])}\t"
|
||||
f"{days if days is not None else '?'} dia(s)")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(prog='python -m app.jadlog_probe')
|
||||
parser.add_argument('--cep', action='append', help='destination CEP (repeatable)')
|
||||
parser.add_argument('--peso', action='append', help='weight in kg (repeatable)')
|
||||
parser.add_argument('--valor', default='100', help='declared value in reais (default 100)')
|
||||
args = parser.parse_args(argv)
|
||||
load_secret_files()
|
||||
quotes = JadlogQuotes()
|
||||
declared = int(Decimal(args.valor) * 100)
|
||||
print(f'Origem {quotes.origin}, modalidade {quotes.modalidade}, contrato {quotes.contrato or "-"}, '
|
||||
f'valor declarado {money(declared)}')
|
||||
return run(quotes, args.cep or list(DESTINATIONS), args.peso or WEIGHTS, declared, print)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
196
app/mercadopago.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""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 datetime import datetime, timedelta, timezone
|
||||
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'}
|
||||
# A PIX code stops working after this; Mercado Pago then cancels the payment.
|
||||
PIX_MINUTES = 30
|
||||
BRASILIA = timezone(timedelta(hours=-3))
|
||||
|
||||
|
||||
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
|
||||
expires_at = None
|
||||
if method['type'] == 'pix':
|
||||
body['payment_method_id'] = 'pix'
|
||||
expires_at = (datetime.now(BRASILIA) + timedelta(minutes=PIX_MINUTES)).isoformat(timespec='milliseconds')
|
||||
body['date_of_expiration'] = expires_at
|
||||
elif method['type'] == 'card':
|
||||
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
|
||||
installments=int(method.get('installments', 1)))
|
||||
# 3-D Secure (the bank's confirmation) only for debit, which needs
|
||||
# it; asking it of every card was refused as a rule (10113).
|
||||
if method['payment_method_id'].startswith('deb'):
|
||||
body['three_d_secure_mode'] = 'optional'
|
||||
if method.get('payer_document_type') and method.get('payer_document'):
|
||||
body['payer']['identification'] = {'type': method['payer_document_type'],
|
||||
'number': method['payer_document']}
|
||||
# No issuer_id: Mercado Pago takes the issuer from the card number.
|
||||
# The form's suggestion was refused for its own test cards
|
||||
# (10111, "the issuer does not have the BIN configured").
|
||||
else:
|
||||
raise ValueError('Unsupported payment method')
|
||||
# A PIX retry must return the same code; a new one, after the last
|
||||
# expired, is the next attempt. 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-{int(method.get('attempt', 1))}" 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 {}
|
||||
# A payment waiting for 3-D Secure carries the bank's challenge page,
|
||||
# which the Site shows in a frame by posting `creq` to that address.
|
||||
three_ds = payment.get('three_ds_info') or {}
|
||||
challenge = ({'url': three_ds['external_resource_url'], 'creq': three_ds['creq']}
|
||||
if payment.get('status_detail') == 'pending_challenge'
|
||||
and three_ds.get('external_resource_url') and three_ds.get('creq') else None)
|
||||
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'),
|
||||
'expires_at': payment.get('date_of_expiration') or expires_at,
|
||||
'challenge': challenge}
|
||||
|
||||
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
|
||||
try:
|
||||
payment = self.lookup(payment_id)
|
||||
except httpx.HTTPStatusError as error:
|
||||
# Signed by Mercado Pago but about no payment of ours, such as the
|
||||
# panel's "Simular notificação". Anything else is raised so that a
|
||||
# real notification is retried.
|
||||
if error.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
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})
|
||||
65
app/mercadopago_probe.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Read-only look at the Mercado Pago account behind MP_ACCESS_TOKEN.
|
||||
|
||||
python -m app.mercadopago_probe [--bin 503143] [--valor 50]
|
||||
|
||||
Run from the api container's console (Portainer > Containers > api >
|
||||
Console), or with "Verificar conta" on the Kanban's Integrations tab. It creates nothing and charges nothing: it asks Mercado Pago which
|
||||
account the token belongs to, which card methods the account accepts, and how
|
||||
it classifies a card number's first digits (type, issuer, instalments). That
|
||||
is what payment errors such as 10111 (issuer) and 10113 (method excluded by a
|
||||
rule) depend on.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
from .core.secrets import load as load_secret_files
|
||||
|
||||
API = 'https://api.mercadopago.com'
|
||||
|
||||
|
||||
def check(token, bin_='548083', amount='50', transport=None):
|
||||
"""The account, its card methods and how a card's first digits are read."""
|
||||
http = httpx.Client(base_url=API, timeout=15, transport=transport,
|
||||
headers={'Authorization': f'Bearer {token}'})
|
||||
result = {'token': 'test' if token.startswith('TEST-') else 'production'}
|
||||
me = http.get('/users/me')
|
||||
if me.status_code == 200:
|
||||
user = me.json()
|
||||
tags = user.get('tags') or []
|
||||
result['account'] = {'id': user.get('id'), 'nickname': user.get('nickname'),
|
||||
'site': user.get('site_id'), 'test_user': 'test_user' in tags, 'tags': tags}
|
||||
else:
|
||||
result['account'] = {'error': f'HTTP {me.status_code}'}
|
||||
methods = http.get('/v1/payment_methods')
|
||||
result['cards'] = ([{'id': m.get('id'), 'type': m.get('payment_type_id'), 'status': m.get('status')}
|
||||
for m in methods.json() if m.get('payment_type_id') in ('credit_card', 'debit_card')]
|
||||
if methods.status_code == 200 else {'error': f'HTTP {methods.status_code}'})
|
||||
options = http.get('/v1/payment_methods/installments', params={'bin': bin_, 'amount': amount})
|
||||
result['bin'] = ([{'method': o.get('payment_method_id'), 'type': o.get('payment_type_id'),
|
||||
'issuer': (o.get('issuer') or {}).get('name'),
|
||||
'installments': [c.get('installments') for c in o.get('payer_costs') or []]}
|
||||
for o in options.json()]
|
||||
if options.status_code == 200 else {'error': f'HTTP {options.status_code} {options.text[:200]}'})
|
||||
return result
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(prog='python -m app.mercadopago_probe')
|
||||
parser.add_argument('--bin', default='548083', help="the card's first six digits")
|
||||
parser.add_argument('--valor', default='50', help='amount in reais for the instalment lookup')
|
||||
args = parser.parse_args(argv)
|
||||
load_secret_files()
|
||||
token = os.environ.get('MP_ACCESS_TOKEN', '')
|
||||
if not token:
|
||||
print('MP_ACCESS_TOKEN is not set in this container.')
|
||||
return 1
|
||||
import json
|
||||
print(json.dumps(check(token, args.bin, args.valor), indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
158
app/payments.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Turning a payment into an order, once.
|
||||
|
||||
A provider may deliver the same notification several times, out of order, or
|
||||
long after the fact. None of that may produce a second order, a second charge,
|
||||
or a second WhatsApp message. Every delivery is recorded under the provider's
|
||||
own event id and applied inside one transaction, so a duplicate is a no-op and a
|
||||
crash mid-way is retried rather than half-applied.
|
||||
|
||||
Order creation lives here rather than in a route because two paths reach it: the
|
||||
webhook, and the local development checkout. They must agree.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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
|
||||
|
||||
QUOTE_VALID_HOURS = 24
|
||||
|
||||
|
||||
class PaymentRefused(Exception):
|
||||
"""The payment cannot become an order, with a reason worth recording."""
|
||||
|
||||
|
||||
def approved_quote(c, quote_id, owner=None):
|
||||
"""The reviewed quote behind a payment, or a refusal explaining why not."""
|
||||
sql = 'SELECT * FROM dtf_local.quotes WHERE id=%s' + (' AND owner=%s' if owner else '')
|
||||
row = c.execute(sql + ' FOR UPDATE', (quote_id, owner) if owner else (quote_id,)).fetchone()
|
||||
if not row:
|
||||
raise PaymentRefused('quote not found')
|
||||
if not row['approved']:
|
||||
raise PaymentRefused('quote was never reviewed')
|
||||
if any(item.get('production', {}).get('version') != 2 for item in row['approved']['items']):
|
||||
raise PaymentRefused('quote uses an obsolete production layout; request a new quote')
|
||||
if row['approved_at'] < datetime.now(timezone.utc) - timedelta(hours=QUOTE_VALID_HOURS):
|
||||
raise PaymentRefused('quote expired before payment')
|
||||
return row
|
||||
|
||||
|
||||
TEST_PROVIDER = 'teste'
|
||||
|
||||
|
||||
def is_test(order):
|
||||
"""An operator's test order: it goes through production and notifies no one."""
|
||||
return (order.get('payment') or {}).get('provider') == TEST_PROVIDER
|
||||
|
||||
|
||||
# How long a file is kept while nothing has been paid for it. A cart's files
|
||||
# are uploaded before payment, so the price and the security check are done on
|
||||
# the file itself; a cart that is never paid must not keep them for 30 days.
|
||||
UNPAID_HOLD = '2 days'
|
||||
# A quote waiting for an operator's review keeps its files this long.
|
||||
REVIEW_HOLD = '7 days'
|
||||
# Once paid, the order keeps its originals for this long from the upload.
|
||||
ORDER_RETENTION = '30 days'
|
||||
|
||||
|
||||
def quote_uploads(quote_or_draft):
|
||||
return [uid for item in quote_or_draft['items'] for uid in item['uploads']]
|
||||
|
||||
|
||||
def hold_uploads(c, upload_ids, interval):
|
||||
"""Keep these files at least `interval` from now; never shortens a hold."""
|
||||
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, now()+%s::interval)
|
||||
WHERE id=ANY(%s) AND purged_at IS NULL''', (interval, [str(u) for u in upload_ids]))
|
||||
|
||||
|
||||
def create_order(c, quote, payment):
|
||||
"""Create the order for a reviewed quote, or return the one already there.
|
||||
|
||||
Returns (order, created). The caller decides what to do about a duplicate;
|
||||
the important part is that asking twice cannot produce two orders, because
|
||||
orders.quote_id is unique and this runs inside the caller's transaction.
|
||||
"""
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (quote['id'],)).fetchone()
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
approved = quote['approved']
|
||||
for item in approved['items']:
|
||||
for upload_id in item['uploads']:
|
||||
require_clean(upload_row(c, UUID(upload_id), quote['owner']))
|
||||
|
||||
order = c.execute(
|
||||
'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
||||
(uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone()
|
||||
# Paid: the files are kept for the order's retention, counted from upload.
|
||||
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, created_at+%s::interval)
|
||||
WHERE id=ANY(%s) AND purged_at IS NULL''', (ORDER_RETENTION, quote_uploads(approved)))
|
||||
queue_print_files(c, order['id'], len(approved['items']))
|
||||
if is_test(order):
|
||||
return order, True
|
||||
for provider in ('tiny', 'whatsapp'):
|
||||
enqueue(c, f"{order['id']}:paid:{provider}", provider,
|
||||
{'order_id': str(order['id']), 'number': order['number'],
|
||||
'event': 'payment_approved', 'order': approved})
|
||||
return order, True
|
||||
|
||||
|
||||
def record(c, provider, event):
|
||||
"""Store a delivery. Returns None if this exact event was already seen."""
|
||||
inserted = c.execute(
|
||||
'''INSERT INTO dtf_local.payment_events(id,provider,event_id,reference,status,amount_cents,payload)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(provider,event_id) DO NOTHING RETURNING *''',
|
||||
(uuid4(), provider, event.event_id, event.reference, event.status,
|
||||
event.amount_cents, Jsonb(event.raw))).fetchone()
|
||||
return inserted
|
||||
|
||||
|
||||
def apply(c, event):
|
||||
"""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}'
|
||||
|
||||
try:
|
||||
quote_id = UUID(event.reference)
|
||||
except (ValueError, AttributeError):
|
||||
return 'refused: reference is not a quote id'
|
||||
|
||||
try:
|
||||
quote = approved_quote(c, quote_id)
|
||||
except PaymentRefused as refusal:
|
||||
return f'refused: {refusal}'
|
||||
|
||||
# The provider is the authority on what was paid, and the reviewed quote is
|
||||
# the authority on what was owed. If they disagree, no order is created:
|
||||
# underpayment would ship artwork that was not paid for, and overpayment
|
||||
# means something is wrong that a person should look at.
|
||||
expected = quote['approved']['total_cents']
|
||||
if type(event.amount_cents) is not int or event.amount_cents != expected:
|
||||
audit('payment_amount_mismatch', quote=str(quote_id),
|
||||
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': event.raw.get('provider', 'webhook'), **event.raw})
|
||||
return f"order {order['number']}" + ('' if created else ' (already existed)')
|
||||
489
app/printfile.py
Normal file
@@ -0,0 +1,489 @@
|
||||
"""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 shown to the
|
||||
operator on the Kanban, so it is written in Portuguese."""
|
||||
|
||||
|
||||
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}" é grande demais para gerar automaticamente') from exc
|
||||
except Exception as exc:
|
||||
raise Unsupported(f'"{name}" não é uma imagem que o gerador consiga ler') from exc
|
||||
if self.format not in SUPPORTED_FORMATS:
|
||||
raise Unsupported(f'"{name}" está em {self.format or "formato desconhecido"}; '
|
||||
'só JPEG, PNG, WebP, TIFF e PDF são gerados automaticamente')
|
||||
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
|
||||
raise Unsupported(f'"{name}" é animado ou tem vários quadros')
|
||||
# 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}" tem {width} × {height} px, mais do que o '
|
||||
'gerador processa; prepare este item à mão')
|
||||
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}" usa o modo de cor {mode}, que não é gerado automaticamente')
|
||||
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}" está protegido por senha') from exc
|
||||
except Exception as exc:
|
||||
raise Unsupported(f'"{name}" não é um PDF que o gerador consiga ler') from exc
|
||||
try:
|
||||
pages = len(self.pdf.pages)
|
||||
if pages != 1:
|
||||
raise Unsupported(f'"{name}" tem {pages} páginas; só PDFs de uma página são gerados automaticamente')
|
||||
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}" tem uma rotação de página não suportada')
|
||||
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}" tem uma página que o gerador não consegue ler') 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}" tem uma página vazia')
|
||||
# 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'a montagem tem {height} cm, mas só {billed} cm foram cobrados')
|
||||
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'o arquivo {placement["source_index"] + 1} tem proporções diferentes '
|
||||
f'das cotadas ({source["width_cm"]} × {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('o item usa uma montagem antiga')
|
||||
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')
|
||||
180
app/printjobs.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""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.
|
||||
|
||||
Sheets of several GB are the normal order, and decoding one would take more
|
||||
memory than the worker has. A source above LARGE_SOURCE_BYTES is never
|
||||
opened: a finished sheet placed whole on the film is already its own print
|
||||
file, so the original becomes the print file; any other layout is prepared
|
||||
by hand from the original.
|
||||
"""
|
||||
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
|
||||
LARGE_SOURCE_BYTES = int(os.environ.get('PRINT_DECODE_MAX_BYTES', str(300 * 1024 * 1024)))
|
||||
# Formats the operator can import as they are.
|
||||
PRINTABLE_ORIGINAL = ('.png', '.jpg', '.jpeg', '.tif', '.tiff', '.pdf')
|
||||
|
||||
|
||||
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,size,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}
|
||||
if any(row['size'] > LARGE_SOURCE_BYTES for row in uploads):
|
||||
original = whole_sheet(item, by_id)
|
||||
if original:
|
||||
with connect() as c:
|
||||
c.execute('''UPDATE dtf_local.print_files SET status='ready', upload_id=%s, detail=%s,
|
||||
finished_at=now(), claimed_at=NULL WHERE id=%s''',
|
||||
(original['id'], Jsonb({'source': 'original', 'name': original['name']}), job['id']))
|
||||
audit('print_file_original', order=str(job['order_id']), item=job['item_index'])
|
||||
else:
|
||||
finish(job, 'manual', {'reason': f'arquivo acima de {LARGE_SOURCE_BYTES // 1048576} MB: '
|
||||
'monte a folha a partir do original'})
|
||||
audit('print_file_manual', order=str(job['order_id']), item=job['item_index'])
|
||||
return True
|
||||
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 whole_sheet(item, uploads):
|
||||
"""The original, when the item is one finished sheet placed whole, once,
|
||||
unrotated and unmirrored, across the film: then it is the print file."""
|
||||
spec = item.get('production') or {}
|
||||
sources, placements = spec.get('sources') or [], spec.get('placements') or []
|
||||
if len(item['uploads']) != 1 or len(sources) != 1 or len(placements) != 1:
|
||||
return None
|
||||
source, place = sources[0], placements[0]
|
||||
row = uploads.get(item['uploads'][0])
|
||||
if (not row or row['scan_state'] != 'clean' or row['purged_at'] or row['expired']
|
||||
or source.get('kind') != 'sheet' or int(source.get('copies', 1)) != 1
|
||||
or not row['name'].lower().endswith(PRINTABLE_ORIGINAL)):
|
||||
return None
|
||||
if (float(place['x_cm']) != 0 or float(place['y_cm']) != 0 or int(place['rotation_degrees']) != 0
|
||||
or place['mirrored'] or abs(float(source['width_cm']) - float(spec['film_width_cm'])) > 0.5):
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
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('um arquivo original está ausente ou não foi liberado pelo antivírus')
|
||||
if row['purged_at'] or row['expired']:
|
||||
raise Unsupported('um arquivo original passou do prazo de guarda')
|
||||
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)
|
||||
89
app/quote_review.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Quote approval: automatic at checkout, by an operator for the exceptions.
|
||||
|
||||
A quote the customer can pay is priced here from the server's own ladders,
|
||||
whether an operator approved it on the Kanban or the Site approved it the
|
||||
moment it was created. The Site is a shop: a customer who can price the order
|
||||
should be able to pay for it straight away, at any hour, so only orders a
|
||||
person must look at before charging wait for the Kanban (review_reason).
|
||||
|
||||
The grade (and so the discount) is worked out in the customer's browser and
|
||||
checked against the files before an automatic approval (app/grade_check.py);
|
||||
a grade the files do not support waits for review. The layout is still the
|
||||
browser's (roadmap 3.9): the API checks its geometry. Operators see every
|
||||
order's grade and artwork at Arte recebida.
|
||||
"""
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from . import payments
|
||||
from .core.pricing import price
|
||||
from .runtime import freight, upload_row
|
||||
from .scanning import require_clean
|
||||
|
||||
AUTO = 'auto'
|
||||
|
||||
|
||||
def auto_approve_enabled():
|
||||
return os.environ.get('QUOTE_AUTO_APPROVE', 'true').lower() == 'true'
|
||||
|
||||
|
||||
def max_auto_metres():
|
||||
return Decimal(os.environ.get('QUOTE_AUTO_MAX_METRES', '50'))
|
||||
|
||||
|
||||
def review_reason(draft):
|
||||
"""Why this quote needs a person before it can be paid, or None."""
|
||||
if not auto_approve_enabled():
|
||||
return 'Aprovação automática desligada'
|
||||
total = sum(Decimal(str(item['metres'])) for item in draft['items'])
|
||||
if total > max_auto_metres():
|
||||
return f'Pedido acima de {max_auto_metres():g} m'
|
||||
for item in draft['items']:
|
||||
if item.get('production', {}).get('version') != 2:
|
||||
return 'Montagem antiga'
|
||||
# The Site grades only the art it could analyse; anything else is
|
||||
# priced at the full rate. A discount on it did not come from the Site.
|
||||
if item['quality_status'] == 'unverified' and item['grade'] != 0:
|
||||
return 'Nota informada sem análise da arte'
|
||||
return None
|
||||
|
||||
|
||||
def approve(c, row, items, reviewer):
|
||||
"""Price the reviewed items and bind them to the quote; returns the approval.
|
||||
`row` must be locked by the caller."""
|
||||
draft = row['draft']
|
||||
if row['approved']:
|
||||
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
|
||||
if any(item.get('production', {}).get('version') != 2 for item in draft['items']):
|
||||
raise HTTPException(409, 'Quote uses an obsolete production layout; customer must request a new quote')
|
||||
if len(items) != len(draft['items']):
|
||||
raise HTTPException(422, 'Review must cover every item')
|
||||
priced = []
|
||||
for item, original in zip(items, draft['items']):
|
||||
if item.mode != original['mode'] or list(map(str, item.uploads)) != original['uploads']:
|
||||
raise HTTPException(422, 'Product mode and attached files cannot change during review')
|
||||
if item.production.model_dump(mode='json') != original['production'] or item.quality_status != original['quality_status'] or item.quality_acknowledged != original['quality_acknowledged']:
|
||||
raise HTTPException(422, 'Production instructions and customer acknowledgement cannot change during commercial review')
|
||||
for upload_id in item.uploads:
|
||||
require_clean(upload_row(c, upload_id, row['owner']))
|
||||
priced.append({**price(item.mode, str(item.metres), item.grade),
|
||||
'uploads': original['uploads'], 'production': original['production'],
|
||||
'quality_status': original['quality_status'],
|
||||
'quality_acknowledged': original['quality_acknowledged']})
|
||||
metres = sum(Decimal(i['billed_metres']) for i in priced)
|
||||
try:
|
||||
quoted_freight = freight.quote(draft['freight']['service'], draft['freight'].get('postal_code', ''),
|
||||
metres, sum(i['total_cents'] for i in priced))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
approved = {'customer': draft['customer'], 'items': priced, 'freight': quoted_freight,
|
||||
'destination': draft.get('destination'),
|
||||
'total_cents': sum(i['total_cents'] for i in priced) + quoted_freight['total_cents']}
|
||||
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s',
|
||||
(Jsonb(approved), reviewer, row['id']))
|
||||
# Approved and payable now: the files wait for the payment, not for ever.
|
||||
payments.hold_uploads(c, payments.quote_uploads(approved), payments.UNPAID_HOLD)
|
||||
return approved
|
||||
@@ -20,8 +20,16 @@ load_secret_files()
|
||||
require_runtime()
|
||||
|
||||
storage = LocalS3Storage()
|
||||
payment = FakePayment()
|
||||
freight = FakeFreight()
|
||||
if os.environ.get('PAYMENT_ADAPTER') == 'mercadopago':
|
||||
from .mercadopago import MercadoPagoPayment
|
||||
payment = MercadoPagoPayment()
|
||||
else:
|
||||
payment = FakePayment()
|
||||
if os.environ.get('FREIGHT_ADAPTER') == 'jadlog':
|
||||
from .jadlog import JadlogFreight
|
||||
freight = JadlogFreight()
|
||||
else:
|
||||
freight = FakeFreight()
|
||||
ENVIRONMENT = os.environ.get('APP_ENV', 'local')
|
||||
PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost')
|
||||
ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host]
|
||||
@@ -42,6 +50,16 @@ STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impress
|
||||
'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
|
||||
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
|
||||
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
|
||||
# Undoing a move made by mistake: one stage back, with an internal reason. It
|
||||
# is not a correction: the customer is not told and approved finals stay.
|
||||
BACK = {'tra': 'rec', 'fil': 'tra', 'imp': 'fil', 'fin': 'imp'}
|
||||
|
||||
|
||||
def require_delivery_available(service):
|
||||
"""Outside the local stack, a simulated freight price must never reach a
|
||||
customer: until a real freight provider exists, only pickup is offered."""
|
||||
if service != 'pickup' and ENVIRONMENT != 'local' and getattr(freight, 'name', '') == 'fake':
|
||||
raise HTTPException(503, 'A entrega ainda não está disponível. Escolha a retirada em Franca.')
|
||||
|
||||
|
||||
def upload_row(c, upload_id, session_id, lock=False):
|
||||
@@ -56,10 +74,19 @@ def upload_row(c, upload_id, session_id, lock=False):
|
||||
|
||||
|
||||
def quote_view(c, row):
|
||||
from .quote_review import review_reason # it imports this module
|
||||
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
|
||||
# The latest payment attempt, so the payment page can tell a refused card
|
||||
# (the notification updates it) from one still waiting.
|
||||
attempt = c.execute('''SELECT method,status,response->>'status_detail' AS status_detail
|
||||
FROM dtf_local.payment_intents WHERE quote_id=%s ORDER BY created_at DESC LIMIT 1''', (row['id'],)).fetchone()
|
||||
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
|
||||
return {'id': row['id'], 'draft': row['draft'], 'approved': row['approved'],
|
||||
return {'id': row['id'], 'created_at': row['created_at'],
|
||||
'draft': row['draft'], 'approved': row['approved'],
|
||||
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
|
||||
'auto_approved': row.get('reviewed_by') == 'auto',
|
||||
'review_reason': None if row['approved'] else (row.get('review_note') or review_reason(row['draft'])),
|
||||
'payment': attempt,
|
||||
'order': order}
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
"""Local ClamAV boundary. Unknown/error/over-limit results NEVER release artwork."""
|
||||
import os
|
||||
"""Releasing artwork: ClamAV up to its size limit, a format check above it.
|
||||
|
||||
Unknown or error results NEVER release artwork. ClamAV scans files up to
|
||||
scan_limit_bytes() (2 GB). Sheets of several GB are the normal order and
|
||||
ClamAV cannot take them, so a larger file is released only if its first bytes
|
||||
are those of the format its name claims (a PNG that really is a PNG, not a
|
||||
program renamed .png). That is the check the client chose for large files; it
|
||||
does not look for malware inside a valid file.
|
||||
"""
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from fastapi import HTTPException
|
||||
from .core.auth import audit
|
||||
from .core.db import connect
|
||||
from .core.limits import scan_limit_bytes
|
||||
|
||||
def require_clean(row):
|
||||
if not row['complete'] or row['scan_state'] != 'clean':
|
||||
@@ -30,10 +39,9 @@ class ClamAV:
|
||||
return self.command(b'VERSION').decode('utf-8','replace')
|
||||
|
||||
def scan(self, stream, size):
|
||||
if size > min(134217728, int(os.environ.get('SCAN_MAX_BYTES','134217728'))):
|
||||
return 'rejected', 'File exceeds the malware scan limit'
|
||||
with socket.create_connection(('scanner',3310),timeout=10) as sock:
|
||||
sock.settimeout(150)
|
||||
# A 2 GB file takes minutes to stream and scan.
|
||||
sock.settimeout(900)
|
||||
sock.sendall(b'zINSTREAM\0')
|
||||
sent=0
|
||||
for chunk in stream.iter_chunks(chunk_size=65536):
|
||||
@@ -52,6 +60,37 @@ class ClamAV:
|
||||
if result.endswith(b' FOUND'):return 'rejected','Malware or unsafe scan condition detected'
|
||||
return 'error','Scanner could not verify this file'
|
||||
|
||||
# What each accepted extension must start with. AI files are PDF or PostScript;
|
||||
# CDR and WebP are RIFF containers with their own form type.
|
||||
TIFF = (b'II*\x00', b'MM\x00*', b'II+\x00', b'MM\x00+')
|
||||
SIGNATURES = {
|
||||
'png': (b'\x89PNG\r\n\x1a\n',),
|
||||
'jpg': (b'\xff\xd8\xff',), 'jpeg': (b'\xff\xd8\xff',),
|
||||
'tif': TIFF, 'tiff': TIFF,
|
||||
'pdf': (b'%PDF-',), 'ai': (b'%PDF-', b'%!PS'),
|
||||
'psd': (b'8BPS',), 'psb': (b'8BPS',),
|
||||
}
|
||||
RIFF_FORMS = {'cdr': re.compile(rb'^RIFF....CDR', re.S), 'webp': re.compile(rb'^RIFF....WEBP', re.S)}
|
||||
HEAD_BYTES = 64
|
||||
|
||||
|
||||
def format_matches(name, head):
|
||||
"""Whether a file's first bytes are those of the format its name claims."""
|
||||
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
|
||||
if ext in RIFF_FORMS:
|
||||
return bool(RIFF_FORMS[ext].match(head))
|
||||
return any(head.startswith(sig) for sig in SIGNATURES.get(ext, ()))
|
||||
|
||||
|
||||
def check_large(storage, row):
|
||||
"""Release decision for a file above the antivirus limit."""
|
||||
head = storage.client.get_object(Bucket=storage.bucket, Key=row['object_key'],
|
||||
Range=f'bytes=0-{HEAD_BYTES - 1}')['Body'].read()
|
||||
if format_matches(row['name'], head):
|
||||
return 'clean', 'Acima do limite do antivírus; formato do arquivo conferido'
|
||||
return 'rejected', 'O conteúdo do arquivo não corresponde ao formato do nome'
|
||||
|
||||
|
||||
def scan_one(storage, scanner=None):
|
||||
scanner=scanner or ClamAV()
|
||||
with connect() as c:
|
||||
@@ -60,9 +99,12 @@ def scan_one(storage, scanner=None):
|
||||
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1''').fetchone()
|
||||
if not row:return False
|
||||
try:
|
||||
stream=storage.client.get_object(Bucket=storage.bucket,Key=row['object_key'])['Body']
|
||||
try:state,reason=scanner.scan(stream,row['size'])
|
||||
finally:stream.close()
|
||||
if row['size'] > scan_limit_bytes():
|
||||
state,reason=check_large(storage,row)
|
||||
else:
|
||||
stream=storage.client.get_object(Bucket=storage.bucket,Key=row['object_key'])['Body']
|
||||
try:state,reason=scanner.scan(stream,row['size'])
|
||||
finally:stream.close()
|
||||
except Exception:
|
||||
state,reason='error','Malware scanner unavailable; file remains blocked'
|
||||
c.execute("""UPDATE dtf_local.uploads SET scan_state=%s,scan_reason=%s,scanned_at=now(),
|
||||
|
||||
@@ -64,6 +64,13 @@ CREATE TABLE IF NOT EXISTS dtf_local.operators (
|
||||
password_hash text NOT NULL, active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), last_login_at timestamptz
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.payment_events (
|
||||
id uuid PRIMARY KEY, provider text NOT NULL, event_id text NOT NULL,
|
||||
reference text, status text NOT NULL, amount_cents bigint,
|
||||
payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now(),
|
||||
processed_at timestamptz, outcome text,
|
||||
UNIQUE(provider, event_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.security_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
event text NOT NULL, details jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
@@ -83,6 +90,61 @@ 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()
|
||||
);
|
||||
-- Renewal health, cleared by the next successful renewal or connection.
|
||||
-- refused_at: the provider rejected the refresh token, so only a new
|
||||
-- connection helps. offline: the grant is not bound to a login session.
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_failed_at timestamptz;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_error text;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refused_at timestamptz;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS offline boolean NOT NULL DEFAULT false;
|
||||
-- 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)
|
||||
);
|
||||
|
||||
-- Each run of the off-server database backup (ops/db_backup.py), which the
|
||||
-- Kanban shows so a backup that stopped working does not go unnoticed.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.backups (
|
||||
id uuid PRIMARY KEY, started_at timestamptz NOT NULL, finished_at timestamptz NOT NULL,
|
||||
status text NOT NULL CHECK(status IN ('ok','failed')), object_key text, bytes bigint, detail text
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS backups_finished ON dtf_local.backups(finished_at DESC);
|
||||
|
||||
-- Why a quote waits for review, when the reason came from its files (the grade
|
||||
-- the server recomputed) and cannot be worked out again from the draft alone.
|
||||
ALTER TABLE dtf_local.quotes ADD COLUMN IF NOT EXISTS review_note text;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
|
||||
|
||||
@@ -118,3 +180,28 @@ CREATE INDEX IF NOT EXISTS operator_sessions_username ON dtf_local.operator_sess
|
||||
|
||||
-- security_status reads recent events; the worker prunes old ones by age.
|
||||
CREATE INDEX IF NOT EXISTS security_events_created ON dtf_local.security_events(created_at);
|
||||
|
||||
-- The webhook looks an event up by provider and id on every delivery, and the
|
||||
-- unique constraint already indexes that pair. Only the unprocessed sweep needs
|
||||
-- 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');
|
||||
|
||||
-- A movement that undid an earlier one (a mistaken move), shown as such.
|
||||
ALTER TABLE dtf_local.movements ADD COLUMN IF NOT EXISTS back boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- 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);
|
||||
|
||||
444
app/tiny.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""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.
|
||||
|
||||
Customer notices: the client already sends WhatsApp messages from Tiny's
|
||||
order situação (Tiny webhook -> their middleware -> n8n). With
|
||||
TINY_STATUS_UPDATES on, a paid order is set to "Aprovada" and a finished
|
||||
pickup order to "Pronto para envio", so those notices reach Site customers
|
||||
from the same number and templates; the system's own WhatsApp sender stays
|
||||
off. Pickup orders carry the client's "retirar pessoalmente" forma de envio.
|
||||
"""
|
||||
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
|
||||
# A "ready" event can come after corrections; it normally knows the Tiny id.
|
||||
READY_SEARCH_DAYS = 45
|
||||
# Tiny v3 order situações (PUT /pedidos/{id}/situacao).
|
||||
ABERTA, APROVADA, PRONTO_ENVIO, ENVIADA, ENTREGUE, CANCELADA, NAO_ENTREGUE = 0, 3, 7, 5, 6, 2, 9
|
||||
SITUACOES = {0: 'Aberta', 3: 'Aprovada', 4: 'Preparando envio', 1: 'Faturada', 7: 'Pronto para envio',
|
||||
5: 'Enviada', 6: 'Entregue', 2: 'Cancelada', 8: 'Dados incompletos', 9: 'Não entregue'}
|
||||
# The client's customer notices (Tiny webhook -> middleware -> n8n -> WhatsApp)
|
||||
# react to these situações. Off until go-live: while n8n still sends the
|
||||
# designer message for DTFIMP products on "Aprovado", setting it would reach
|
||||
# Site customers.
|
||||
STATUS_UPDATES_SETTING = 'TINY_STATUS_UPDATES'
|
||||
# The id of the client's custom "retirar pessoalmente" forma de envio (v2 code
|
||||
# X), which the pickup notice is keyed on. Find it with the console tool.
|
||||
PICKUP_SETTING = 'TINY_FORMA_ENVIO_RETIRADA'
|
||||
STATE_MINUTES = 10
|
||||
# Renewal runs about every four hours against a one-day refresh token, so
|
||||
# less than this left means renewals have been failing for hours.
|
||||
EXPIRY_WARNING = timedelta(hours=12)
|
||||
# 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())
|
||||
|
||||
|
||||
def status_updates():
|
||||
return os.environ.get(STATUS_UPDATES_SETTING, '').lower() == 'true'
|
||||
|
||||
|
||||
# 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, offline=True):
|
||||
"""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.
|
||||
|
||||
offline_access asks for a grant that is not bound to a login session,
|
||||
so the connection lasts as long as it keeps being renewed. Tiny's
|
||||
documented refresh token otherwise lasts one day."""
|
||||
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 offline_access' if offline else 'openid',
|
||||
'state': state})
|
||||
|
||||
def _claim(self, c, state):
|
||||
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')
|
||||
return row['operator']
|
||||
|
||||
def complete(self, code, state):
|
||||
"""Exchange the authorisation code; returns the operator who started it."""
|
||||
with self.connect() as c:
|
||||
operator = self._claim(c, state)
|
||||
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
|
||||
'redirect_uri': self.redirect_uri})
|
||||
self._store(c, tokens, operator)
|
||||
return operator
|
||||
|
||||
def without_offline(self, state):
|
||||
"""Tiny refused the offline_access scope for this application: spend the
|
||||
operator's state and start again with a session-bound grant."""
|
||||
with self.connect() as c:
|
||||
operator = self._claim(c, state)
|
||||
return self.authorize_url(operator, offline=False)
|
||||
|
||||
def status(self):
|
||||
with self.connect() as c:
|
||||
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at,updated_at,offline,
|
||||
refresh_failed_at,refused_at FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
|
||||
if not row:
|
||||
return {'connected': False}
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = row['refresh_expires_at']
|
||||
expired = bool(expires and expires <= now)
|
||||
if row['refused_at']:
|
||||
problem = 'refused'
|
||||
elif expired:
|
||||
problem = 'expired'
|
||||
elif row['refresh_failed_at']:
|
||||
problem = 'renewal-failing'
|
||||
elif expires and expires - now < EXPIRY_WARNING:
|
||||
problem = 'expiring'
|
||||
else:
|
||||
problem = None
|
||||
return {'connected': not expired and not row['refused_at'], 'problem': problem,
|
||||
'connected_by': row['connected_by'], 'connected_at': row['connected_at'],
|
||||
'renewed_at': row['updated_at'], 'expires_at': expires, 'offline': row['offline'],
|
||||
'failed_at': row['refresh_failed_at']}
|
||||
|
||||
def access_token(self):
|
||||
"""A valid access token, refreshing (and rotating) under a row lock.
|
||||
|
||||
A failed renewal is recorded after the lock is released, so the Kanban
|
||||
can show it. A refused refresh token is never tried again: only a new
|
||||
connection helps, and retrying it would only repeat the refusal."""
|
||||
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['refused_at']:
|
||||
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
|
||||
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
|
||||
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
|
||||
try:
|
||||
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
|
||||
except (TinyError, httpx.HTTPError, ValueError) as exc:
|
||||
failure = exc
|
||||
else:
|
||||
self._store(c, tokens, row['connected_by'], refreshed=True)
|
||||
return tokens['access_token']
|
||||
# Only against the token that failed: another worker may have renewed since.
|
||||
with self.connect() as c:
|
||||
c.execute('''UPDATE dtf_local.provider_tokens SET refresh_failed_at=now(), refresh_error=%s,
|
||||
refused_at=CASE WHEN %s THEN now() ELSE refused_at END
|
||||
WHERE provider='tiny' AND refresh_token=%s''',
|
||||
(str(failure)[:300] or type(failure).__name__, isinstance(failure, TinyNotConnected),
|
||||
row['refresh_token']))
|
||||
raise failure
|
||||
|
||||
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)))
|
||||
# An offline grant reports refresh_expires_in 0: no fixed end.
|
||||
refresh_in = tokens.get('refresh_expires_in')
|
||||
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
|
||||
offline = 'offline_access' in str(tokens.get('scope') or '').split()
|
||||
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,offline)
|
||||
VALUES('tiny',%s,%s,%s,%s,%s,now(),now(),%s)
|
||||
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(), offline=EXCLUDED.offline,
|
||||
refresh_failed_at=NULL, refresh_error=NULL, refused_at=NULL,
|
||||
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, offline, 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']}"}
|
||||
pickup_method = os.environ.get(PICKUP_SETTING, '')
|
||||
if pickup and pickup_method.isdigit():
|
||||
pedido['transportador'] = {'formaEnvio': {'id': int(pickup_method)}}
|
||||
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
|
||||
|
||||
|
||||
def configured_product(orders, mode):
|
||||
"""The Tiny product a mode's setting points at, or a reason it cannot be used."""
|
||||
setting = PRODUCT_SETTINGS[mode]
|
||||
value = os.environ.get(setting, '')
|
||||
if not value.isdigit():
|
||||
return None, f'{setting} sem id'
|
||||
found = orders.request('GET', f'/produtos/{value}')
|
||||
if found.get('situacao') != 'A':
|
||||
return found, f"produto {value} não está ativo no Tiny ({found.get('situacao')})"
|
||||
return found, 'ok'
|
||||
|
||||
|
||||
def configured_pickup(orders):
|
||||
"""The forma de envio the pickup setting points at, or why it cannot be used."""
|
||||
value = os.environ.get(PICKUP_SETTING, '')
|
||||
if not value.isdigit():
|
||||
return None, f'{PICKUP_SETTING} sem id'
|
||||
return orders.request('GET', f'/formas-envio/{value}'), 'ok'
|
||||
|
||||
|
||||
def check(auth=None, transport=None, orders=None):
|
||||
"""Read-only proof that the connection and permissions work: one order and
|
||||
one contact listed, each configured product found active and the pickup
|
||||
forma de envio found, nothing created. Raises TinyNotConnected when there is no usable connection;
|
||||
otherwise reports each read separately."""
|
||||
orders = orders or TinyOrders(auth=auth or TinyAuth(), transport=transport)
|
||||
|
||||
def listed(path):
|
||||
orders.request('GET', path, params={'limit': 1})
|
||||
return 'ok'
|
||||
|
||||
reads = [('pedidos', lambda: listed('/pedidos')), ('contatos', lambda: listed('/contatos'))]
|
||||
reads += [(PRODUCTS[mode], lambda mode=mode: configured_product(orders, mode)[1]) for mode in PRODUCT_SETTINGS]
|
||||
reads.append(('forma de envio de retirada', lambda: configured_pickup(orders)[1]))
|
||||
results = {}
|
||||
for name, read in reads:
|
||||
try:
|
||||
results[name] = read()
|
||||
except TinyNotConnected:
|
||||
raise
|
||||
except TinyError as exc:
|
||||
results[name] = str(exc)[:200]
|
||||
except httpx.HTTPError:
|
||||
results[name] = 'sem resposta do Tiny'
|
||||
return results
|
||||
|
||||
|
||||
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, days=SEARCH_DAYS):
|
||||
"""An order a previous delivery already created, or None."""
|
||||
wanted = purchase_order(payload['number'])
|
||||
since = ((self.today or local_today()) - timedelta(days=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 set_situacao(self, tiny_id, situacao):
|
||||
self.request('PUT', f'/pedidos/{tiny_id}/situacao', json={'situacao': situacao})
|
||||
|
||||
def deliver(self, event_key, payload):
|
||||
event = payload.get('event')
|
||||
if event == 'payment_approved':
|
||||
return self.sale(event_key, payload)
|
||||
if event == 'ready' and status_updates():
|
||||
return self.ready(event_key, payload)
|
||||
# Other production events have no Tiny situação and are not written.
|
||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable', 'event': event}
|
||||
|
||||
def sale(self, event_key, payload):
|
||||
"""Create the order once; with status updates on, approve it once.
|
||||
|
||||
An order is created "Aberta" and then set to "Aprovada", so a retry
|
||||
after a failure between the two finds it still open and finishes the
|
||||
job, and an order someone has already moved on is left alone."""
|
||||
existing = self.find(payload)
|
||||
if existing:
|
||||
receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
|
||||
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
|
||||
tiny_id, situacao = existing.get('id'), existing.get('situacao')
|
||||
else:
|
||||
contact_id = self.contact(payload['order'])
|
||||
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today))
|
||||
receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
|
||||
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))}
|
||||
tiny_id, situacao = created.get('id'), ABERTA
|
||||
if status_updates() and situacao == ABERTA:
|
||||
self.set_situacao(tiny_id, APROVADA)
|
||||
receipt['situacao'] = SITUACOES[APROVADA]
|
||||
return receipt
|
||||
|
||||
def ready(self, event_key, payload):
|
||||
"""A pickup order became ready: "Pronto para envio", which the client's
|
||||
notices turn into the pickup message. Shipped orders get "Enviada"
|
||||
once freight exists (1.2)."""
|
||||
base = {'provider': 'tiny', 'event_key': event_key}
|
||||
order = payload.get('order') or {}
|
||||
if (order.get('freight') or {}).get('service') != 'pickup':
|
||||
return {**base, 'status': 'not-applicable', 'event': 'ready'}
|
||||
tiny_id = payload.get('tiny_id')
|
||||
detail = self.request('GET', f'/pedidos/{tiny_id}') if tiny_id else self.find(payload, READY_SEARCH_DAYS)
|
||||
if not detail:
|
||||
raise TinyError(f"{purchase_order(payload['number'])} is not in Tiny yet; the outbox will retry")
|
||||
base.update(tiny_id=str(detail.get('id')), tiny_number=str(detail.get('numeroPedido')))
|
||||
situacao = detail.get('situacao')
|
||||
if situacao in (PRONTO_ENVIO, ENVIADA, ENTREGUE, CANCELADA, NAO_ENTREGUE):
|
||||
return {**base, 'status': 'status-unchanged', 'situacao': SITUACOES.get(situacao, str(situacao))}
|
||||
self.set_situacao(detail['id'], PRONTO_ENVIO)
|
||||
return {**base, 'status': 'status-updated', 'situacao': SITUACOES[PRONTO_ENVIO]}
|
||||
153
app/tiny_probe.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Supervised checks against the client's real Tiny, run by hand from a
|
||||
container console (docker exec, or Portainer > Containers > worker > Console).
|
||||
Tiny has no sandbox, so this is how the adapter is proven on the real account.
|
||||
|
||||
python -m app.tiny_probe produtos [termo] list active products (read-only)
|
||||
python -m app.tiny_probe formas-envio [termo] list formas de envio, to find pickup (read-only)
|
||||
python -m app.tiny_probe conferir connection, the four product ids and pickup (read-only)
|
||||
python -m app.tiny_probe pedido --cnpj ... --email ... --celular ... [--modo file]
|
||||
show the test order; --confirmar creates it
|
||||
|
||||
The test order goes through TinyOrders.deliver, exactly as the worker sends a
|
||||
paid order. The duplicate guard is then proven read-only first (the order must
|
||||
be found by its purchase-order number) and only then by a second delivery,
|
||||
which must return the existing order. If the search cannot find the order, the
|
||||
second delivery is not attempted: it would create a duplicate. Cancel the test
|
||||
order in Olist afterwards.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from .core.pricing import TIERS
|
||||
from .core.secrets import load as load_secret_files
|
||||
from . import tiny
|
||||
|
||||
FIND_ATTEMPTS = 4
|
||||
FIND_WAIT_SECONDS = 5
|
||||
|
||||
|
||||
def test_order(cnpj, email, celular, mode, now=None):
|
||||
number = 'TESTE-' + (now or datetime.now(tiny.BRASILIA)).strftime('%Y%m%d%H%M')
|
||||
unit = TIERS[mode][0][1]
|
||||
return {'order_id': 'teste-integracao', 'number': number, 'event': 'payment_approved',
|
||||
'order': {'customer': {'cnpj': cnpj, 'mail': email, 'zap': celular},
|
||||
'items': [{'mode': mode, 'grade': 100, 'billed_metres': '1', 'unit_cents': unit}],
|
||||
'freight': {'service': 'pickup', 'total_cents': 0},
|
||||
'destination': None, 'total_cents': unit}}
|
||||
|
||||
|
||||
def produtos(orders, termo, out):
|
||||
params = {'situacao': 'A', 'limit': 100}
|
||||
if termo:
|
||||
params['nome'] = termo
|
||||
found = orders.request('GET', '/produtos', params=params).get('itens') or []
|
||||
for item in found:
|
||||
preco = (item.get('precos') or {}).get('preco')
|
||||
out(f"{item['id']}\t{item.get('sku') or '-'}\t{item.get('descricao')}\tR$ {preco}")
|
||||
out(f'{len(found)} produto(s) ativo(s)' + (f' com "{termo}"' if termo else '') + '.')
|
||||
|
||||
|
||||
def formas_envio(orders, termo, out):
|
||||
params = {'limit': 100}
|
||||
if termo:
|
||||
params['nome'] = termo
|
||||
found = orders.request('GET', '/formas-envio', params=params).get('itens') or []
|
||||
for item in found:
|
||||
out(f"{item.get('id')}\t{item.get('tipo')}\t{item.get('nome')}")
|
||||
out(f'{len(found)} forma(s) de envio' + (f' com "{termo}"' if termo else '') +
|
||||
'. A retirada é a do tipo 6 (Customizado) que a equipe usa para retirar pessoalmente.')
|
||||
|
||||
|
||||
def conferir(orders, out):
|
||||
ok = True
|
||||
for mode, setting in tiny.PRODUCT_SETTINGS.items():
|
||||
product, result = tiny.configured_product(orders, mode)
|
||||
ok &= result == 'ok'
|
||||
described = f"{product.get('sku') or '-'} · {product.get('descricao')}" if product else ''
|
||||
out(f'{setting} ({tiny.PRODUCTS[mode]}): {result} {described}'.rstrip())
|
||||
method, result = tiny.configured_pickup(orders)
|
||||
ok &= result == 'ok'
|
||||
out(f"{tiny.PICKUP_SETTING} (retirada): {result} {method.get('nome') if method else ''}".rstrip())
|
||||
out(f"{tiny.STATUS_UPDATES_SETTING}: {'ligado' if tiny.status_updates() else 'desligado'}")
|
||||
for name, result in tiny.check(orders=orders).items():
|
||||
if name in ('pedidos', 'contatos'):
|
||||
ok &= result == 'ok'
|
||||
out(f'{name}: {result}')
|
||||
out('Tudo conferido.' if ok else 'Há pendências acima.')
|
||||
return ok
|
||||
|
||||
|
||||
def pedido(orders, payload, confirm, out, wait=time.sleep):
|
||||
cnpj = payload['order']['customer']['cnpj']
|
||||
contacts = orders.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5}).get('itens') or []
|
||||
known = [c for c in contacts if ''.join(ch for ch in str(c.get('cpfCnpj') or '') if ch.isdigit()) == cnpj]
|
||||
out(f"Contato {cnpj}: " + (f"já existe no Tiny (id {known[0]['id']})" if known
|
||||
else 'não existe; será criado com o e-mail como nome'))
|
||||
out('Pedido que será enviado:')
|
||||
out(json.dumps(tiny.order_payload(payload, known[0]['id'] if known else 0), ensure_ascii=False, indent=2))
|
||||
if not confirm:
|
||||
out('Nada foi criado. Repita com --confirmar para criar este pedido no Tiny.')
|
||||
return None
|
||||
first = orders.deliver('teste-integracao', payload)
|
||||
out(f"1º envio: {first['status']} · Tiny id {first['tiny_id']} · nº {first['tiny_number']}")
|
||||
for attempt in range(FIND_ATTEMPTS):
|
||||
found = orders.find(payload)
|
||||
if found:
|
||||
break
|
||||
if attempt < FIND_ATTEMPTS - 1:
|
||||
wait(FIND_WAIT_SECONDS)
|
||||
else:
|
||||
out(f'FALHA: a busca por {tiny.purchase_order(payload["number"])} não encontrou o pedido. '
|
||||
'Um reenvio criaria um duplicado; o 2º envio não foi feito. Cancele o pedido no Olist.')
|
||||
return False
|
||||
second = orders.deliver('teste-integracao', payload)
|
||||
out(f"2º envio: {second['status']} · Tiny id {second['tiny_id']}")
|
||||
passed = second['status'] == 'already-created' and second['tiny_id'] == first['tiny_id']
|
||||
out(('OK: o reenvio encontrou o mesmo pedido.' if passed else 'FALHA: o reenvio não devolveu o mesmo pedido.')
|
||||
+ f" Cancele o pedido nº {first['tiny_number']} no Olist.")
|
||||
return passed
|
||||
|
||||
|
||||
def main(argv=None, orders=None, out=print):
|
||||
parser = argparse.ArgumentParser(prog='python -m app.tiny_probe')
|
||||
commands = parser.add_subparsers(dest='command', required=True)
|
||||
listing = commands.add_parser('produtos')
|
||||
listing.add_argument('termo', nargs='?', default='')
|
||||
methods = commands.add_parser('formas-envio')
|
||||
methods.add_argument('termo', nargs='?', default='')
|
||||
commands.add_parser('conferir')
|
||||
order = commands.add_parser('pedido')
|
||||
order.add_argument('--cnpj', required=True)
|
||||
order.add_argument('--email', required=True)
|
||||
order.add_argument('--celular', required=True)
|
||||
order.add_argument('--modo', choices=sorted(tiny.PRODUCT_SETTINGS), default='file')
|
||||
order.add_argument('--confirmar', action='store_true')
|
||||
args = parser.parse_args(argv)
|
||||
if orders is None:
|
||||
load_secret_files()
|
||||
orders = tiny.TinyOrders(auth=tiny.TinyAuth())
|
||||
try:
|
||||
if args.command == 'produtos':
|
||||
produtos(orders, args.termo, out)
|
||||
return 0
|
||||
if args.command == 'formas-envio':
|
||||
formas_envio(orders, args.termo, out)
|
||||
return 0
|
||||
if args.command == 'conferir':
|
||||
return 0 if conferir(orders, out) else 1
|
||||
cnpj = ''.join(ch for ch in args.cnpj if ch.isdigit())
|
||||
if len(cnpj) != 14:
|
||||
out('Informe um CNPJ com 14 dígitos.')
|
||||
return 2
|
||||
payload = test_order(cnpj, args.email, args.celular, args.modo)
|
||||
return 0 if pedido(orders, payload, args.confirmar, out) is not False else 1
|
||||
except tiny.TinyError as exc:
|
||||
out(f'Tiny: {exc}')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -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()
|
||||
|
||||
@@ -23,13 +23,39 @@ x-app: &app
|
||||
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only}
|
||||
# The browser reaches the API through the Site gateway, so the published
|
||||
# Site/Kanban origins must be accepted or every write is rejected 403.
|
||||
PUBLIC_ORIGIN: http://localhost:${SITE_PORT:-8080}
|
||||
ALLOWED_HOSTS: localhost,127.0.0.1
|
||||
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}
|
||||
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:-http://localhost:${SITE_PORT:-8080}}
|
||||
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
|
||||
FREIGHT_ADAPTER: fake
|
||||
TINY_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: ${FREIGHT_ADAPTER:-fake}
|
||||
JADLOG_TOKEN: ${JADLOG_TOKEN:-}
|
||||
JADLOG_CNPJ: ${JADLOG_CNPJ:-}
|
||||
JADLOG_CONTA: ${JADLOG_CONTA:-}
|
||||
JADLOG_CONTRATO: ${JADLOG_CONTRATO:-}
|
||||
JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-}
|
||||
JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-}
|
||||
FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0}
|
||||
# Carts the Site priced are approved at checkout; larger ones wait for review.
|
||||
QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true}
|
||||
QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50}
|
||||
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}
|
||||
@@ -38,7 +64,7 @@ x-app: &app
|
||||
STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:-53687091200}
|
||||
OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:-10737418240}
|
||||
MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10}
|
||||
SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728}
|
||||
SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-2097152000}
|
||||
networks: [local]
|
||||
init: true
|
||||
security_opt: [no-new-privileges:true]
|
||||
@@ -65,11 +91,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}
|
||||
@@ -111,7 +140,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}
|
||||
@@ -119,6 +148,9 @@ services:
|
||||
S3_APP_USER: ${S3_APP_USER:-dtf_app}
|
||||
S3_APP_PASSWORD: ${S3_APP_PASSWORD:-local-app-storage-only}
|
||||
S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork}
|
||||
S3_BACKUP_BUCKET: dtf-local-backups
|
||||
S3_BACKUP_USER: dtf_backup
|
||||
S3_BACKUP_PASSWORD: local-backup-storage-only
|
||||
networks: [local]
|
||||
depends_on:
|
||||
storage: {condition: service_healthy}
|
||||
@@ -168,12 +200,37 @@ services:
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
|
||||
# The daily database backup, against the local backup bucket. Idle until
|
||||
# BACKUP_AGE_RECIPIENT is set; tests/backup_test.py runs it with a throwaway key.
|
||||
backup:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infra/Dockerfile
|
||||
command: python -m ops.db_backup serve
|
||||
environment:
|
||||
DATABASE_ADMIN_URL: postgresql://${POSTGRES_USER:-dtf_local}:${POSTGRES_PASSWORD:-local-database-only}@db:5432/${POSTGRES_DB:-dtf_local}
|
||||
BACKUP_S3_ENDPOINT: http://storage:9000
|
||||
BACKUP_BUCKET: dtf-local-backups
|
||||
BACKUP_ACCESS_KEY_ID: dtf_backup
|
||||
BACKUP_SECRET_ACCESS_KEY: local-backup-storage-only
|
||||
BACKUP_REGION: us-east-1
|
||||
BACKUP_AGE_RECIPIENT: ${BACKUP_AGE_RECIPIENT:-}
|
||||
networks: [local]
|
||||
read_only: true
|
||||
tmpfs: [/tmp]
|
||||
depends_on:
|
||||
db-init: {condition: service_completed_successfully}
|
||||
storage-init: {condition: service_completed_successfully}
|
||||
|
||||
site:
|
||||
build:
|
||||
context: .
|
||||
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:-}
|
||||
PAYMENT_CHALLENGE_SOURCES: ${PAYMENT_CHALLENGE_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
|
||||
@@ -197,6 +254,8 @@ services:
|
||||
environment:
|
||||
WEB_INDEX: kanban.html
|
||||
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
|
||||
PAYMENT_CSP_SOURCES: ""
|
||||
PAYMENT_CHALLENGE_SOURCES: ""
|
||||
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
|
||||
networks: [local, edge]
|
||||
depends_on:
|
||||
@@ -207,6 +266,28 @@ services:
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
|
||||
browser-tests:
|
||||
profiles: [ci]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infra/Dockerfile.browser-tests
|
||||
environment:
|
||||
CHROME_BIN: /usr/bin/chromium
|
||||
CHROME_NO_SANDBOX: "1"
|
||||
CHROME_TRUST_TEST_ORIGINS: "1"
|
||||
SITE_BROWSER_ORIGIN: http://site
|
||||
KANBAN_BROWSER_ORIGIN: http://kanban
|
||||
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-operator@example.test}
|
||||
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only}
|
||||
shm_size: 1gb
|
||||
networks: [local]
|
||||
depends_on:
|
||||
site: {condition: service_healthy}
|
||||
kanban: {condition: service_healthy}
|
||||
storage: {condition: service_healthy}
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
storage-data:
|
||||
|
||||
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: {}
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infra/Dockerfile
|
||||
command: python -m app.staging_readiness /config/staging.env
|
||||
command: python -m ops.staging_readiness /config/staging.env
|
||||
volumes:
|
||||
- ./staging/staging.env:/config/staging.env:ro
|
||||
network_mode: none
|
||||
|
||||
@@ -13,14 +13,18 @@ LABEL org.opencontainers.image.title="DTF Portal/API" \
|
||||
# The base is pinned, so its OS packages are frozen at the digest's build date.
|
||||
# Upgrade them here or the image ships known-fixed Debian vulnerabilities, which
|
||||
# is what the production image was doing while the local one already did this.
|
||||
# pg_dump and age are for the database backup (ops/db_backup.py). Debian 13
|
||||
# ships PostgreSQL 17, the server's major version, which pg_dump must match.
|
||||
RUN apt-get update \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client-17 age \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY infra/requirements.txt infra/requirements.lock /app/infra/
|
||||
RUN python -m pip install --no-cache-dir --require-hashes -r infra/requirements.lock
|
||||
COPY app /app/app
|
||||
COPY ops /app/ops
|
||||
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin dtf
|
||||
USER 10001:10001
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app
|
||||
|
||||
@@ -22,7 +22,8 @@ LABEL org.opencontainers.image.title="DTF Site and Kanban" \
|
||||
org.opencontainers.image.source="DTF System repository"
|
||||
ENV WEB_INDEX=index.html PUBLIC_HOST=invalid.example S3_PUBLIC_ENDPOINT=https://invalid.example
|
||||
COPY --from=policy /build/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY web/ /usr/share/nginx/html/
|
||||
# The HTML from the policy stage, with every asset address versioned.
|
||||
COPY --from=policy /build/web/ /usr/share/nginx/html/
|
||||
|
||||
# The official entrypoint renders the server configuration at startup and Nginx
|
||||
# writes its PID/cache files. Keep the service non-root while granting it
|
||||
|
||||
@@ -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} ${PAYMENT_CHALLENGE_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self' ${PAYMENT_CHALLENGE_SOURCES}" always;
|
||||
|
||||
location = /health { access_log off; return 200 'ok'; }
|
||||
location /api/ {
|
||||
@@ -48,6 +48,16 @@ server {
|
||||
}
|
||||
# Always revalidate HTML/JS/CSS after a deployment. Without this, a browser
|
||||
# can pair a new Kanban page with a cached older script after a rollout.
|
||||
# The Site's product pages and cart are addresses of the same page (web/site-pages.js).
|
||||
location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho|pagamento|pagamento/pix)/?$ {
|
||||
expires -1;
|
||||
try_files /index.html =404;
|
||||
}
|
||||
# The customer's area: one page, which shows the right part for each address.
|
||||
location ~ ^/conta(/(entrar|pedidos|dados))?/?$ {
|
||||
expires -1;
|
||||
try_files /portal.html =404;
|
||||
}
|
||||
location / {
|
||||
expires -1;
|
||||
try_files $uri $uri/ =404;
|
||||
|
||||
@@ -27,8 +27,23 @@ POSTGRES_VOLUME=TBD
|
||||
OPERATOR_EMAIL=TBD
|
||||
|
||||
PAYMENT_ADAPTER=TBD
|
||||
# With PAYMENT_ADAPTER=mercadopago. MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET are
|
||||
# secrets: enter them in Portainer only, never in this file.
|
||||
MP_NOTIFICATION_URL=https://<SITE_DOMAIN>/api/payments/webhook
|
||||
MP_PUBLIC_KEY=
|
||||
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
|
||||
TINY_FORMA_ENVIO_RETIRADA=TBD
|
||||
# true only at go-live, after the n8n DTFIMP designer branch is removed.
|
||||
TINY_STATUS_UPDATES=false
|
||||
WHATSAPP_ADAPTER=TBD
|
||||
STORAGE_QUOTA_BYTES=TBD
|
||||
OWNER_UPLOAD_QUOTA_BYTES=TBD
|
||||
@@ -36,6 +51,13 @@ MAX_PENDING_UPLOADS=10
|
||||
MAX_UPLOAD_BYTES=5368709120
|
||||
UPLOAD_PART_BYTES=8388608
|
||||
SCAN_MAX_BYTES=134217728
|
||||
# Daily encrypted database backup to its own bucket (docs/BACKUP.md).
|
||||
BACKUP_S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com
|
||||
BACKUP_BUCKET=dtf-backups
|
||||
BACKUP_ACCESS_KEY_ID=TBD
|
||||
BACKUP_SECRET_ACCESS_KEY=TBD
|
||||
BACKUP_AGE_RECIPIENT=age1...
|
||||
BACKUP_HOUR=3
|
||||
|
||||
# Names of external Portainer/Docker Swarm secrets, never their values.
|
||||
DATABASE_URL_SECRET=TBD
|
||||
|
||||
@@ -176,6 +176,8 @@ def config_errors(values):
|
||||
errors.append('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES')
|
||||
if numeric.get('SCAN_MAX_BYTES', 0) > numeric.get('MAX_UPLOAD_BYTES', 0):
|
||||
errors.append('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES')
|
||||
if numeric.get('SCAN_MAX_BYTES', 0) > 128 * 1024 * 1024:
|
||||
errors.append('SCAN_MAX_BYTES cannot exceed the configured ClamAV 128 MiB stream limit')
|
||||
ports = {}
|
||||
for name in ('SITE_PORT', 'KANBAN_PORT'):
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from .production_preflight import config_errors, source_errors
|
||||
|
||||
|
||||
class ReleaseWorkflowTests(unittest.TestCase):
|
||||
def test_only_checked_main_publishes_and_pushes_never_deploy(self):
|
||||
workflow = (Path(__file__).resolve().parents[1] /
|
||||
'.gitea/workflows/deploy.yml').read_text()
|
||||
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(scan, publish)
|
||||
self.assertLess(publish, redeploy)
|
||||
self.assertIn("if: gitea.event_name == 'workflow_dispatch'", release[redeploy:])
|
||||
|
||||
|
||||
def valid_config():
|
||||
digest = '1' * 64
|
||||
values = {
|
||||
@@ -112,6 +134,7 @@ class ProductionPreflightTests(unittest.TestCase):
|
||||
self.assertTrue(any('distinct external Swarm secret' in error for error in errors))
|
||||
self.assertIn('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES', errors)
|
||||
self.assertIn('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES', errors)
|
||||
self.assertTrue(any('configured ClamAV 128 MiB stream limit' in error for error in errors))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -19,9 +19,60 @@ x-app-environment: &app-environment
|
||||
# this value is configured.
|
||||
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
|
||||
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
|
||||
PAYMENT_ADAPTER: fake
|
||||
FREIGHT_ADAPTER: fake
|
||||
# fake until Mercado Pago is configured; mercadopago requires MP_ACCESS_TOKEN
|
||||
# and MP_WEBHOOK_SECRET or the API and worker refuse to start. Test
|
||||
# credentials (TEST-...) until the sandbox flows have passed. The card form
|
||||
# appears only with MP_PUBLIC_KEY, and then needs PAYMENT_CSP_SOURCES too.
|
||||
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
|
||||
MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
|
||||
# The "assinatura secreta" from the webhook settings in Mercado Pago.
|
||||
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
|
||||
# https://<SITE_DOMAIN>/api/payments/webhook, sent with every payment.
|
||||
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
|
||||
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
|
||||
# The fake adapter's secret. Optional: without it the webhook verifies
|
||||
# nothing and therefore accepts nothing, which is the correct state until a
|
||||
# provider is connected. Never set it to a value anyone could guess.
|
||||
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
|
||||
# fake offers pickup only. jadlog prices delivery with Jadlog and needs the
|
||||
# credentials below and the package weight from the client; it refuses to
|
||||
# start without them. The credentials alone are enough for the console
|
||||
# check, python -m app.jadlog_probe, on the worker.
|
||||
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:-fake}
|
||||
JADLOG_TOKEN: ${JADLOG_TOKEN:-}
|
||||
# The "Usuário" Jadlog issued: the CNPJ that contracts the freight.
|
||||
JADLOG_CNPJ: ${JADLOG_CNPJ:-}
|
||||
JADLOG_CONTA: ${JADLOG_CONTA:-}
|
||||
JADLOG_CONTRATO: ${JADLOG_CONTRATO:-}
|
||||
# Package weight in kg: a base plus each billed metre of film.
|
||||
JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-}
|
||||
JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-}
|
||||
# Working days of production added to Jadlog's delivery time.
|
||||
FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0}
|
||||
# A cart the Site priced is approved at checkout and can be paid at once;
|
||||
# orders above QUOTE_AUTO_MAX_METRES, or with a grade the Site did not
|
||||
# compute, wait for an operator on the Kanban (app/quote_review.py).
|
||||
QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true}
|
||||
QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50}
|
||||
# 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:-}
|
||||
# The client's "retirar pessoalmente" forma de envio id, on pickup orders.
|
||||
TINY_FORMA_ENVIO_RETIRADA: ${TINY_FORMA_ENVIO_RETIRADA:-}
|
||||
# Sets "Aprovada" on paid orders and "Pronto para envio" on finished pickup
|
||||
# orders, which the client's Tiny -> n8n notices send to customers. Turn on
|
||||
# only together with TINY_ADAPTER=tiny and after n8n stops sending the
|
||||
# designer message for DTFIMP products.
|
||||
TINY_STATUS_UPDATES: ${TINY_STATUS_UPDATES:-false}
|
||||
WHATSAPP_ADAPTER: fake
|
||||
STORAGE_ADAPTER: s3-r2
|
||||
PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN}
|
||||
@@ -31,10 +82,13 @@ x-app-environment: &app-environment
|
||||
COOKIE_SECURE: "true"
|
||||
MAX_UPLOAD_BYTES: "5368709120"
|
||||
UPLOAD_PART_BYTES: "8388608"
|
||||
STORAGE_QUOTA_BYTES: "53687091200"
|
||||
OWNER_UPLOAD_QUOTA_BYTES: "10737418240"
|
||||
# Sheets of several GB are the normal order, so room for many of them.
|
||||
STORAGE_QUOTA_BYTES: "${STORAGE_QUOTA_BYTES:-536870912000}"
|
||||
OWNER_UPLOAD_QUOTA_BYTES: "${OWNER_UPLOAD_QUOTA_BYTES:-53687091200}"
|
||||
MAX_PENDING_UPLOADS: "10"
|
||||
SCAN_MAX_BYTES: "134217728"
|
||||
# ClamAV scans up to this; larger files (up to MAX_UPLOAD_BYTES) are released
|
||||
# after a file-format check instead (app/scanning.py).
|
||||
SCAN_MAX_BYTES: "2097152000"
|
||||
|
||||
services:
|
||||
db:
|
||||
@@ -80,7 +134,7 @@ services:
|
||||
image: clamav/clamav@sha256:9cb27d7660bdf66e9878c832cb433dd8aa152cfbe16f3c2c0084c80b04ae22b4
|
||||
entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf]
|
||||
configs:
|
||||
- source: clamd_config
|
||||
- source: clamd_config_2gb
|
||||
target: /etc/clamav/clamd.conf
|
||||
mode: 0444
|
||||
networks: [backend]
|
||||
@@ -129,12 +183,47 @@ services:
|
||||
replicas: 1
|
||||
restart_policy: {condition: on-failure, delay: 5s}
|
||||
|
||||
# Daily encrypted copy of the database to a bucket of its own, off this
|
||||
# server (ops/db_backup.py). Idle until the BACKUP_* settings are set. The
|
||||
# bucket's lifecycle rule removes old copies; this credential never deletes.
|
||||
backup:
|
||||
image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest}
|
||||
command: python -m ops.db_backup serve
|
||||
environment:
|
||||
DATABASE_ADMIN_HOST: db
|
||||
DATABASE_ADMIN_NAME: dtf
|
||||
DATABASE_ADMIN_USER: dtf_admin
|
||||
DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||
# The R2 account endpoint (the same as R2_ENDPOINT) and a bucket and API
|
||||
# token for backups only, never the artwork bucket's.
|
||||
BACKUP_S3_ENDPOINT: ${BACKUP_S3_ENDPOINT:-}
|
||||
BACKUP_BUCKET: ${BACKUP_BUCKET:-}
|
||||
BACKUP_ACCESS_KEY_ID: ${BACKUP_ACCESS_KEY_ID:-}
|
||||
BACKUP_SECRET_ACCESS_KEY: ${BACKUP_SECRET_ACCESS_KEY:-}
|
||||
# The public key (age1...). Its private key stays off this server.
|
||||
BACKUP_AGE_RECIPIENT: ${BACKUP_AGE_RECIPIENT:-}
|
||||
# Hour of day in Brasília.
|
||||
BACKUP_HOUR: ${BACKUP_HOUR:-3}
|
||||
networks: [backend, egress]
|
||||
read_only: true
|
||||
tmpfs: [/tmp]
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy: {condition: on-failure, delay: 30s}
|
||||
|
||||
site:
|
||||
image: ${WEB_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-web}:${IMAGE_TAG:-latest}
|
||||
environment:
|
||||
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:-}
|
||||
# The bank's confirmation page for debit and other 3-D Secure cards is
|
||||
# on the issuer's own domain, so it cannot be listed: "https:" lets
|
||||
# frames and form posts reach it (never scripts). Empty turns it off.
|
||||
PAYMENT_CHALLENGE_SOURCES: ${PAYMENT_CHALLENGE_SOURCES:-}
|
||||
networks: [backend]
|
||||
ports:
|
||||
- target: 8080
|
||||
@@ -157,6 +246,8 @@ services:
|
||||
WEB_INDEX: kanban.html
|
||||
PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
|
||||
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
|
||||
PAYMENT_CSP_SOURCES: ""
|
||||
PAYMENT_CHALLENGE_SOURCES: ""
|
||||
networks: [backend]
|
||||
ports:
|
||||
- target: 8080
|
||||
@@ -174,7 +265,9 @@ services:
|
||||
restart_policy: {condition: on-failure, delay: 5s}
|
||||
|
||||
configs:
|
||||
clamd_config:
|
||||
# Renamed whenever infra/clamd.conf changes: Swarm cannot update a deployed
|
||||
# config in place, and a redeploy with new content under the old name fails.
|
||||
clamd_config_2gb:
|
||||
file: ./infra/clamd.conf
|
||||
|
||||
volumes:
|
||||
|
||||
87
docs/BACKUP.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Database backup
|
||||
|
||||
The `backup` service copies the database off the server once a day
|
||||
(`ops/db_backup.py`). It runs `pg_dump`, checks the archive, encrypts it with
|
||||
[age](https://age-encryption.org) and uploads it to an R2 bucket used for
|
||||
nothing else. Each run is recorded, and the Kanban shows the latest one under
|
||||
Integrações → "Backup do banco" ("Em dia", "Verificar" or "Não configurado").
|
||||
|
||||
The artwork is not backed up. Originals and print files are temporary (7 to 30
|
||||
days) and already stored on R2. The database holds what cannot be recreated:
|
||||
orders, customers, quotes, payments, the Tiny connection and the operators.
|
||||
|
||||
## Why the backup cannot be read or deleted from the server
|
||||
|
||||
- **The server only encrypts.** It holds the public key (`age1…`). The private
|
||||
key, which is the only way to open a backup, stays with the owner.
|
||||
- **Its own bucket and token.** The backup token reaches the backup bucket
|
||||
only, and the artwork token cannot reach it.
|
||||
- **Nothing is deleted by the job.** The bucket's lifecycle rule removes old
|
||||
copies. The bucket lock keeps anyone, including someone holding the token,
|
||||
from deleting or overwriting a copy before its time.
|
||||
|
||||
## Setup (once)
|
||||
|
||||
1. **Key pair.** Generate it on your own computer, not on the server:
|
||||
|
||||
```bash
|
||||
docker run --rm gitea.blyzer.com.br/blyzer/dtf-api:latest age-keygen
|
||||
```
|
||||
|
||||
Or, with age installed (`sudo pacman -S age`, `apt install age`), just
|
||||
run `age-keygen`.
|
||||
|
||||
Save the whole output (the `AGE-SECRET-KEY-1…` line is the private key) in
|
||||
the password manager. Only the `public key: age1…` value goes to Portainer.
|
||||
Without the private key, no backup can ever be restored.
|
||||
2. **Cloudflare R2 → Create bucket.** Use a name such as `dtf-backups`.
|
||||
3. **Settings on that bucket:**
|
||||
- Object lifecycle rules: delete objects after 35 days.
|
||||
- Bucket lock rules: retain for 30 days, prefix `db/`.
|
||||
4. **R2 → Manage API tokens → Create API token.**
|
||||
- Permission: Object Read & Write.
|
||||
- Scope: the `dtf-backups` bucket only.
|
||||
5. **Portainer.** Add these to the stack's environment, then redeploy:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `BACKUP_S3_ENDPOINT` | same as `R2_ENDPOINT` |
|
||||
| `BACKUP_BUCKET` | `dtf-backups` |
|
||||
| `BACKUP_ACCESS_KEY_ID` | the new token's Access Key ID |
|
||||
| `BACKUP_SECRET_ACCESS_KEY` | the new token's Secret Access Key |
|
||||
| `BACKUP_AGE_RECIPIENT` | the public key, `age1…` |
|
||||
| `BACKUP_HOUR` | optional, default `3` (03:00 Brasília) |
|
||||
|
||||
The first backup runs as soon as the service starts. After that it runs daily,
|
||||
and a failed run is retried an hour later.
|
||||
|
||||
## Restore test
|
||||
|
||||
Do this after setup, and then once a month. Open the console of the `backup`
|
||||
container in Portainer (Containers → `…_backup…` → Console) and run:
|
||||
|
||||
```bash
|
||||
python -m ops.db_backup list
|
||||
python -m ops.db_backup verify --identity -
|
||||
```
|
||||
|
||||
`--identity -` asks for the private key to be pasted. It is kept only in the
|
||||
container's memory while the command runs. `verify` restores the latest backup
|
||||
into a scratch database, prints its row counts and drops it. The live database
|
||||
is not touched.
|
||||
|
||||
## Restoring for real
|
||||
|
||||
From the `backup` container's console:
|
||||
|
||||
```bash
|
||||
python -m ops.db_backup restore db/2026/10/dtf-20261001T060000Z.dump.age --into dtf_restored --identity -
|
||||
```
|
||||
|
||||
This restores into a new database (or an empty one), never over the running
|
||||
one. Then do one of these:
|
||||
|
||||
- **Check the restored data**, then point the stack at it.
|
||||
- **On a new server**, deploy only the `db` service first. Restore into `dtf`
|
||||
while it is still empty, then deploy the rest. `db-init` then applies the
|
||||
schema and grants on top.
|
||||
@@ -297,11 +297,12 @@ as references and are not imported or started by Compose.
|
||||
files can be quoted, commercially approved, paid, downloaded, attached as final
|
||||
files, or admitted to the print queue. Rejected/error files remain blocked and
|
||||
expire within three days. The isolated scanner uses signatures bundled in its
|
||||
pinned image and has no external network route. The transport accepts files up
|
||||
to 5 GiB, but the local scan/release limit is 128 MiB; larger files remain
|
||||
blocked. This malware gate is not print pre-flight or artwork validation.
|
||||
pinned image and has no external network route. The multipart transport could
|
||||
carry 5 GiB, but API and customer admission stop at the effective 128 MiB
|
||||
scan/release limit by default. Larger files require a new scan/release design.
|
||||
This malware gate is not print pre-flight or artwork validation.
|
||||
- A retention worker removes expired object bytes but keeps order/file metadata:
|
||||
incomplete uploads after one day, originals within seven days of manual final
|
||||
incomplete uploads after a one-hour reservation lease, originals within seven days of manual final
|
||||
artwork approval, and attached final/correction files within 30 days of the
|
||||
order's first upload. Storage lifecycle is also a 30-day backstop.
|
||||
- Structured security events are written to logs and PostgreSQL. The local
|
||||
@@ -327,7 +328,7 @@ as references and are not imported or started by Compose.
|
||||
not been deployed. Its fail-closed preflight intentionally rejects the current
|
||||
source until production adapters, Docker-secret file loading, approved inputs,
|
||||
restore rehearsal, image scans, and human security approval are complete.
|
||||
- `python3 -m app.backup create-and-verify` creates a private, Git-ignored bundle
|
||||
- `python3 -m ops.backup create-and-verify` creates a private, Git-ignored bundle
|
||||
containing a PostgreSQL dump plus every complete, unexpired object already marked
|
||||
`clean`. SHA-256 manifests protect both parts. Verification restores the database
|
||||
under a UUID name and the object bytes under a UUID MinIO prefix, hashes the
|
||||
|
||||
@@ -62,23 +62,35 @@ operator interfaces.
|
||||
4. Click the Site checkout button. Files upload directly to MinIO, remain
|
||||
quarantined until ClamAV returns `clean`, and then become eligible for a quote.
|
||||
The local banner displays a quote ID awaiting commercial review.
|
||||
5. Open Kanban and log in. In **Cotações**, download the original if needed,
|
||||
confirm/correct total metres and grade, tick the manual confirmation, and
|
||||
click **Aprovar cotação**. For the fixture keep 1.01 m and grade 0.
|
||||
5. Open Kanban and log in. Open the **Cotações** tab, pick the quote, download
|
||||
the original if needed, confirm/correct metres and grade, tick the
|
||||
confirmation at the bottom and click **Aprovar cotação**. For the fixture
|
||||
keep 1.01 m and grade 0.
|
||||
6. Return to the Site and click its checkout action again. Inspect the
|
||||
authoritative server total, then click **Criar pedido de teste**. No real
|
||||
payment occurs.
|
||||
7. Refresh Kanban. The paid order starts in **Arte recebida**. Move it using
|
||||
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.
|
||||
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.
|
||||
8. Inspect **Histórico** and **Eventos locais de integração**. Worker receipts
|
||||
should say recorded locally. Download links expire after five minutes;
|
||||
request another link from Kanban when needed.
|
||||
7. Click **Atualizar** on the Kanban. The paid order appears in **Arte
|
||||
recebida** on the **Produção** tab. Click its card to open the order panel,
|
||||
then **Mover para Arte tratada** (dragging the card to a column also works).
|
||||
In the panel's **Arquivos finais**, select the manually prepared final file
|
||||
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. The text fixture is not an image, so its print file shows
|
||||
**Preparar à mão**; with a PNG, JPEG or PDF artwork the generated file is
|
||||
preselected instead (see "Print files"). Continue with the panel's main
|
||||
button through **Fila de impressão → Imprimindo → Finalizado**. **Pedir
|
||||
correção** asks for the reason the customer will see; from **Correção** the
|
||||
order returns to **Arte recebida** or **Arte tratada**. A move made by
|
||||
mistake is undone with **Voltar para …**: one stage back, with an internal
|
||||
reason recorded in the history. The customer is not notified, approved
|
||||
finals stay, and "produção iniciada"/"pedido pronto" are sent only once per
|
||||
order even if the stage is entered again. Dragging a card only goes forward;
|
||||
the columns it can be dropped on are highlighted.
|
||||
8. The panel's **Histórico** lists every move. The **Pagamentos** tab lists
|
||||
payments that need a person; **Integrações** shows the Tiny connection and
|
||||
the send log; with the fake adapters every send reads "Registrado
|
||||
(simulado)". Download links expire after five minutes; click again for a new
|
||||
one. The previous Kanban is kept in git tag `ui-v1`.
|
||||
|
||||
The manual quote step is necessary to enforce the backend trust boundary while
|
||||
automatic pre-flight is deferred. Browser measurements and grade are proposals.
|
||||
@@ -161,8 +173,8 @@ test is unmistakable:
|
||||
```bash
|
||||
python3 -m tests.security_test
|
||||
python3 -m tests.scanning_test
|
||||
docker compose exec -T api python3 -m tests.runtime_security_test
|
||||
docker compose exec -T api python3 -m tests.retention_test
|
||||
docker compose -f compose.local.yaml exec -T api python3 -m tests.runtime_security_test
|
||||
docker compose -f compose.local.yaml exec -T api python3 -m tests.retention_test
|
||||
```
|
||||
|
||||
`local.scanning_test` stores an EICAR fixture as `SECURITY-EICAR.cdr`; ClamAV
|
||||
@@ -183,23 +195,27 @@ test order for inspection. Both integration scripts read `.env` automatically.
|
||||
## Configuration and storage
|
||||
|
||||
`.env.example` lists local ports, database/MinIO values, operator login, adapter
|
||||
selection, mock freight amount, maximum file size (5 GiB), and multipart size
|
||||
(8 MiB by default). The malware scanner releases only files up to 128 MiB by
|
||||
default (`SCAN_MAX_BYTES`); larger uploads remain blocked even though the
|
||||
multipart transport supports 5 GiB. `S3_ENDPOINT=http://storage:9000`, database
|
||||
selection, mock freight amount, transport ceiling (5 GiB), and multipart size
|
||||
(8 MiB by default). The API and customer picker admit only files within the
|
||||
malware scanner's effective 128 MiB limit by default (`SCAN_MAX_BYTES`); the
|
||||
larger-file path remains a Week 2 decision. `S3_ENDPOINT=http://storage:9000`, database
|
||||
hostname `db`,
|
||||
and the internal service ports are fixed Compose wiring. The public S3 endpoint
|
||||
must resolve from the browser; keep `http://localhost:9000` for this stack.
|
||||
Parts use 15-minute presigned URLs and uploads must finish within one day.
|
||||
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.
|
||||
|
||||
Objects are private, use UUID keys rather than filenames, and persist in a named
|
||||
volume. MinIO lifecycle rules expire objects after 30 days and abandon incomplete
|
||||
multipart uploads after one day; the API also blocks expired downloads. Order
|
||||
multipart uploads after one day as a backstop; the API lease and worker release
|
||||
unfinished reservations after one hour. The API also blocks expired downloads. Order
|
||||
history remains in PostgreSQL. Completed files start in `pending`; unknown,
|
||||
scanner-error, over-limit, encrypted/unsafe, and malware results fail closed.
|
||||
Only `clean` files can cross quote, payment, download, final-approval, and queue
|
||||
@@ -214,10 +230,123 @@ 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'`. The payment page offers credit card (the default), debit
|
||||
card and PIX. Card payments ask for 3-D Secure when the issuer requires it,
|
||||
which debit cards usually do: the bank's confirmation page opens in a frame on
|
||||
the issuer's own domain, so it needs `PAYMENT_CHALLENGE_SOURCES=https:` (frames
|
||||
and form posts only, never scripts; empty by default). Once a payment for a
|
||||
quote is approved, or a card payment is in review, the API refuses any further
|
||||
attempt for that quote; a card left waiting for the bank's confirmation stops
|
||||
blocking after ten minutes. 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
|
||||
python3 -m app.backup create-and-verify
|
||||
python3 -m ops.backup create-and-verify
|
||||
```
|
||||
|
||||
This writes a private four-file bundle in `backups/` (ignored by Git and Docker
|
||||
@@ -234,7 +363,7 @@ checks database counts, bundle hashes, every archived object's hash, and the byt
|
||||
downloaded after restore, then removes only the temporary database and objects. It
|
||||
never restores over active data. Keep every bundle file private: it contains
|
||||
customer data, password hashes, and customer artwork. To verify it again, run
|
||||
`python3 -m app.backup verify` followed by the printed
|
||||
`python3 -m ops.backup verify` followed by the printed
|
||||
`backups/...manifest.json` path. Legacy database-only `.dump` backups remain
|
||||
verifiable. Scheduling, offsite copies, and a production restore runbook remain
|
||||
unfinished.
|
||||
@@ -242,7 +371,7 @@ unfinished.
|
||||
After building the current image, test retention with synthetic files:
|
||||
|
||||
```bash
|
||||
docker compose exec -T api python3 -m tests.retention_test
|
||||
docker compose -f compose.local.yaml exec -T api python3 -m tests.retention_test
|
||||
```
|
||||
|
||||
This checks that expired bytes are removed while unexpired files survive. It
|
||||
@@ -251,14 +380,14 @@ cleans up its own synthetic object bytes and retains their metadata.
|
||||
## Operations and troubleshooting
|
||||
|
||||
```bash
|
||||
docker compose logs --tail=100 api worker scanner
|
||||
docker compose restart api worker
|
||||
docker compose ps
|
||||
docker compose down
|
||||
docker compose -f compose.local.yaml logs --tail=100 api worker scanner
|
||||
docker compose -f compose.local.yaml restart api worker
|
||||
docker compose -f compose.local.yaml ps
|
||||
docker compose -f compose.local.yaml down
|
||||
```
|
||||
|
||||
`down` stops the stack and preserves named database/storage volumes. Restart
|
||||
with `docker compose up -d --wait`. Do not add `--volumes` unless you intend to
|
||||
with `docker compose -f compose.local.yaml up -d --wait`. Do not add `--volumes` unless you intend to
|
||||
permanently erase all local orders and artwork. No reset is required for tests.
|
||||
|
||||
Seven long-running services have Docker health checks; the database and storage
|
||||
@@ -270,7 +399,7 @@ exposes `/minio/health/ready`.
|
||||
Run the redacted local alert summary inside the API network namespace:
|
||||
|
||||
```bash
|
||||
docker compose exec -T api python3 -m app.security_status
|
||||
docker compose -f compose.local.yaml exec -T api python3 -m ops.security_status
|
||||
```
|
||||
|
||||
Exit status 1 means attention is required. Review blocked artwork, rate limits,
|
||||
@@ -312,7 +441,7 @@ a direct pin, regenerate the lock in the same Python 3.12 environment and rebuil
|
||||
|
||||
```bash
|
||||
./infra/lock_dependencies.sh
|
||||
docker compose up --build -d --wait
|
||||
docker compose -f compose.local.yaml up --build -d --wait
|
||||
```
|
||||
|
||||
The generator downloads public package metadata in a disposable container and
|
||||
|
||||
@@ -17,8 +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` then builds the production images, publishes both `latest`
|
||||
and the full commit SHA, reports their vulnerabilities, 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:
|
||||
|
||||
@@ -26,17 +29,21 @@ What actually gates a deployment:
|
||||
|---|---|
|
||||
| `py_compile` and the unit tests | yes |
|
||||
| Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) | yes |
|
||||
| Browser suites | only when the runner has Chrome; otherwise warns and continues |
|
||||
| Browser suites | yes; Chrome runs in the Compose test container |
|
||||
| Trivy secret scan (HIGH/CRITICAL) | yes |
|
||||
| Source preflight (`deploy/production_preflight.py --source-only`) | only when `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 after the build |
|
||||
| Trivy image vulnerabilities, HIGH | no — reported before publication |
|
||||
| Configured Portainer webhook | no — called on manual runs when present; otherwise redeploy in Portainer |
|
||||
|
||||
The source preflight is advisory by default because it refuses a release while
|
||||
the payment and messaging adapters are fake, which is the deliberate state the
|
||||
stack runs in today. Enforcing it now would block every deployment. Set the
|
||||
repository variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` once real adapters
|
||||
land, and it becomes a hard gate.
|
||||
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
|
||||
digest, both Dockerfiles upgrade their OS packages, and the web image moved off
|
||||
@@ -60,10 +67,10 @@ Repository secrets:
|
||||
- `REGISTRY_USERNAME` and `REGISTRY_TOKEN` — package write credentials.
|
||||
- `PORTAINER_WEBHOOK` — webhook generated by the `dtf-cloud` Portainer stack.
|
||||
|
||||
The webhook is called only after the gating checks in the table above pass.
|
||||
The webhook is called only after the release gates above pass.
|
||||
`ENFORCE_PRODUCTION_PREFLIGHT` and `TRIVY_IMAGE` are optional repository
|
||||
variables; without them the preflight is advisory and a pinned default scanner
|
||||
image is used.
|
||||
variables; the former affects push checks and the latter defaults to a pinned
|
||||
scanner image.
|
||||
|
||||
## 2. One-time Portainer resources
|
||||
|
||||
@@ -86,6 +93,24 @@ as `dtf_prod_database_url_v1`, then place only those names in the corresponding
|
||||
Credential values must never be entered into Git, `portainer.env`, or Gitea
|
||||
workflow variables.
|
||||
|
||||
**R2 CORS.** The Site uploads artwork straight from the browser to the bucket
|
||||
with presigned `PUT` requests, so the bucket must allow the Site's origin.
|
||||
Without it the preflight is refused (403) and the Site shows "NetworkError
|
||||
when attempting to fetch resource" at checkout (found 2026-09-28). In the
|
||||
Cloudflare dashboard, R2 → the bucket → Settings → CORS policy:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": ["https://<SITE_DOMAIN>", "https://<KANBAN_DOMAIN>"],
|
||||
"AllowedMethods": ["PUT", "GET", "HEAD"],
|
||||
"AllowedHeaders": ["content-type"],
|
||||
"ExposeHeaders": ["ETag"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 3. Create the stack once
|
||||
|
||||
In Portainer select **Stacks → Add stack → Git repository**:
|
||||
@@ -124,10 +149,13 @@ The `db-init` service completing and stopping is expected. The other six
|
||||
services must be healthy. A failed `db-init` task or an unhealthy service blocks
|
||||
acceptance.
|
||||
|
||||
## 4. Normal deployment
|
||||
## 4. Manual deployment
|
||||
|
||||
Push to `main`. Gitea validates, tests, scans, publishes these images, and calls
|
||||
the webhook:
|
||||
Push the reviewed commit to `main` and wait for its validation workflow to pass.
|
||||
After validating the actual Portainer configuration with the full preflight in
|
||||
section 3, use Gitea Actions to manually run **Build and deploy** on `main` at
|
||||
that commit. The workflow repeats validation, tests and scans, then publishes
|
||||
these images and calls the webhook:
|
||||
|
||||
```text
|
||||
gitea.blyzer.com.br/blyzer/dtf-api:latest
|
||||
|
||||
85
docs/REMEDIATION-2026-09-22.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# DTF remediation register — 2026-09-22
|
||||
|
||||
This is the action list for all 37 findings in [the September 21 review](REVIEW-2026-09-21.md). That review contains the evidence and severity for each ID. This register includes the September 22 payment review. It is a plan, not evidence that a finding has been closed in production.
|
||||
|
||||
**Current position:** We are in Week 2. The local fixes for foreign-quote order disclosure and approval without a verified amount are implemented and tested, but are not committed or deployed. The first order-correctness slice now covers parts of findings 2, 5–8, and 11; see the progress note below. The remaining work and production verification stay open. Payment webhook work is partial progress on finding 31, not completion of real payments.
|
||||
|
||||
**Local progress, 2026-09-22:** Browser and API regressions covered stale editor items, DPI refusal and warning acknowledgement, changed-cart quote actions, oversized width, and finals invalidated by a later correction. A versioned per-file source specification survived quote review into the order snapshot. At that point exact placement coordinates and a generated print file were still missing. None of these changes is a production release.
|
||||
|
||||
**Local progress, 2026-09-23:** Specification v2 adds per-copy film coordinates, validates every copy and the quote height, and keeps a downloadable layout manifest in the approved order. The board now pages pending and approved unpaid quotes; a local regression reached all 105 pending and 22 approved fixture quotes. Final print-file generation, completed-order search, and quote cancellation/expiry lifecycle remain open.
|
||||
|
||||
**Operational progress, 2026-09-23:** Finding 23's entrypoints are repaired locally. The staging gate passed in a network-disabled image with non-secret fixture data; `ops.security_status` ran in the API image and correctly reported stale local signatures; `ops.backup create-and-verify` restored database counts and 78 clean objects in isolated temporary targets; the production API image built and imported `ops`. This verifies the commands, not production offsite recovery (finding 30) or a fresh scanner (finding 17).
|
||||
|
||||
**Quality progress, 2026-09-23:** Findings 9 and 10 are partly repaired: failed image decoding blocks checkout, mixed analyzed/manual sheets stay at table price, and rotated DPI uses the correct pixel axis. PDF measurement now uses the PDF.js page model with effective crop, rotation, UserUnit and page count; invalid or multi-page files block quoting. The same-origin PDF worker and isolated browser checks pass. Unsupported image operators, representative print-file evidence and final printability remain open.
|
||||
|
||||
**Upload progress, 2026-09-23:** The API and customer picker now reject files above the effective ClamAV stream limit before transfer, and the session advertises that limit. Unfinished reservations have a one-hour lease and a customer/operator cancel endpoint; owner-scoped cancellation has an integration check. Quota remains reserved until the object is actually purged, so a failed cleanup cannot admit unaccounted storage. PDF rendering now destroys the parser job when its timeout fires, covered by a browser check. This closes the misleading upload-then-quarantine path locally but does not satisfy the agreed large-file capability in finding 3. A tested large-file scan/release design, stronger anonymous admission controls (finding 16), and remaining browser resource bounds (finding 12) are still required.
|
||||
|
||||
## Gates
|
||||
|
||||
| Gate | Meaning |
|
||||
|---|---|
|
||||
| **W2** | Fix during Week 2 before calling the corresponding client workflow complete. These defects can be worked on while provider contracts are clarified. |
|
||||
| **Upload** | Resolve before inviting the public to upload customer artwork. |
|
||||
| **Paid** | Resolve before enabling live checkout or accepting a real paid order. |
|
||||
| **Release** | Resolve before declaring the deployed production system ready. |
|
||||
| **Incremental** | Improve alongside feature work; it does not justify a standalone rewrite. |
|
||||
|
||||
The gates are cumulative: a live release must pass W2, Upload, Paid, and Release checks. Decisions labelled **business** require an agreed product rule; engineering can build and test the surrounding flow in parallel. Where a deployment risk is conditional, verify the actual topology and document the result before closing it.
|
||||
|
||||
## Complete finding-to-action map
|
||||
|
||||
| Review ID | Gate | Required action and closure evidence |
|
||||
|---|---|---|
|
||||
| 1 | Paid | Implement real payment, freight, ERP, and notification adapters with sandbox acceptance and reconciliation; remove fake adapters from the live path. |
|
||||
| 2 | W2 | Store a versioned per-file production specification and approved layout on quote and order; prove the factory can reproduce the purchased job. |
|
||||
| 3 | Upload; business | Agree the advertised maximum and implement a scan/release path that actually supports it; reject unsupported sizes before transfer. |
|
||||
| 4 | W2 | Give quotes an explicit lifecycle and paginated/searchable operator view; verify the 101st actionable quote remains visible. |
|
||||
| 5 | W2 | Invalidate or revision-bind finals when a new correction arrives; test a correction submitted after a final was uploaded. |
|
||||
| 6 | W2 | Make quality eligibility a checkout gate and store any required acknowledgement against the artwork revision. |
|
||||
| 7 | W2 | Clear the current cart item immediately when artwork is removed or becomes invalid; test the submitted payload. |
|
||||
| 8 | W2 | Bind checkout to an immutable quoted cart snapshot; require re-quote after any material edit, including same-price edits. |
|
||||
| 9 | W2 | Measure PDF pages through the parser's page model; handle every supported page or reject multi-page/unsupported geometry explicitly. |
|
||||
| 10 | W2 | Require quality evidence per billable source; make undecodable/unknown sources explicit and correct rotation-sensitive DPI calculations. |
|
||||
| 11 | W2 | Validate physical dimensions before packing; never silently scale a requested print size. |
|
||||
| 12 | Upload | Bound browser decoding, copy count, PDF work, and preview size; cancel obsolete work and test representative large inputs. |
|
||||
| 13 | Paid | Capture and validate a full delivery-address snapshot, then connect it to freight quote and order fulfilment. |
|
||||
| 14 | Paid; business | Set written auto-approval rules and manual-exception criteria; prove eligible orders can complete after hours without an operator. |
|
||||
| 15 | W2; business | Define accepted print output, generate it from the approved versioned layout, and compare produced geometry/metres with the quote. If scope changes, update the client commitment and site claims explicitly. |
|
||||
| 16 | Upload | Limit anonymous reservation capacity and lifetime; add cancellation and cleanup, then test quota-exhaustion behavior. |
|
||||
| 17 | Upload | Update ClamAV signatures on a controlled schedule; surface signature age and fail the intake gate when stale. |
|
||||
| 18 | Release | Trust only the actual proxy hop, restrict origin access, and test real client IP/rate limits through the deployed Swarm topology. |
|
||||
| 19 | Release | Wire file-backed secrets into the active stack; give each service only necessary credentials and remove unused bootstrap secrets. |
|
||||
| 20 | Release | Recheck operator `active` atomically when issuing and using sessions; test disable-versus-login concurrency and document password-change session policy. |
|
||||
| 21 | Paid | Provide email verification and customer recovery/guest continuity, and make checkout's account-creation claim match reality. |
|
||||
| 22 | Release; business | Agree retention/export/deletion rules for profiles, quotes, orders, payloads, artwork, and backups; implement and verify them. |
|
||||
| 23 | W2 | Repair staging, backup, and security commands after the directory move; smoke-test them in the images and Compose files actually shipped. |
|
||||
| 24 | Release | Set and test PostgreSQL node placement/persistence for the intended Swarm size, plus recovery after host failure. |
|
||||
| 25 | Release | Scan before promotion, publish immutable paired API/web image identities, and deploy exactly the scanned release. |
|
||||
| 26 | Release | Make preflight enforce the active stack contract and provider behavior; verify Portainer/deployment convergence after promotion. |
|
||||
| 27 | Release | Run browser tests in a network where signed storage URLs work; fail CI when Chrome or the test endpoint is unavailable. |
|
||||
| 28 | Release | Isolate each CI Compose project, ports, networks, and volumes; serialize release promotion and test overlapping runs. |
|
||||
| 29 | Release | Separate liveness/readiness, monitor provider backlog, cleanup, scanner freshness and backup age; test alert routing and rollback acceptance. |
|
||||
| 30 | Release; business | Set recovery objectives, make consistent encrypted offsite backups, and rehearse restore of database plus required live artwork. |
|
||||
| 31 | Paid | Finish durable payment intent, idempotent webhook handling, status/refund rules, reconciliation, and ordered outbox/dead-letter recovery; test provider-success/database-failure cases. Signed event work is only partial progress. |
|
||||
| 32 | Upload | Bound upload concurrency, decouple upload from scan waiting, measure queue latency, and distinguish transient scan errors from rejected content. |
|
||||
| 33 | Release | Introduce ordered schema migrations and core constraints/relationships; test both clean install and upgrade from the existing schema. |
|
||||
| 34 | Incremental | Replace shared mutable browser cart state as part of IDs 2/7/8; then extract reusable business operations from routes and add bounded DB connection management where load measurements warrant it. |
|
||||
| 35 | Release | Add representative artwork, real PDF, failure/retry, migration, operational-command, and exact-release acceptance tests. |
|
||||
| 36 | W2 | Correct executable setup/Portainer/security instructions and PDF generator paths; check generated output against current scope. |
|
||||
| 37 | Release | Inventory and scan every deployed image and vendored asset, pin release dependencies, and set a controlled refresh process. Do not describe the existing PDF.js advisory as a proven exploit. |
|
||||
|
||||
## Execution order
|
||||
|
||||
1. **Correct the customer/order model now:** IDs 2, 4–11, 23, and 36. Keep an immutable quote revision through payment, production output, and correction approval. Close each defect with a focused regression test and a real artwork example where geometry matters.
|
||||
2. **Set the missing product rules while coding continues:** IDs 3, 14, 15, 22, and 30. Obtain representative files, accepted print format, auto-approval thresholds, retention rules, recovery objectives, and provider sandbox access. Do not collect credentials in this document.
|
||||
3. **Make public intake safe:** IDs 3, 12, 16, 17, and 32. Test the declared upload size end to end, including scan, release, quota, browser memory, and timeout behavior.
|
||||
4. **Complete live commerce:** IDs 1, 13, 14, 15, 21, and 31. Build freight/address, payment/reconciliation, ERP, and notification flows; test duplicates, outages, refunds, and human exceptions in provider sandboxes.
|
||||
5. **Prove the deployed system:** IDs 18–20, 22, 24–30, 33, 35, and 37. Run the exact images and stack, exercise migration, backup/restore, monitoring, secrets, CI and release rollback. Improve ID 34 as the affected areas are changed.
|
||||
|
||||
## Who supplies what
|
||||
|
||||
- **Engineering:** implement and test the code, schema, operational commands, CI gates, provider adapters, and recovery runbooks; gather evidence for each closure. This work can start with the order/cart defects without waiting for provider access.
|
||||
- **Business/client:** approve unattended-pricing exceptions, final print-file format and samples, the real maximum file size, shipping services and policy, privacy retention, and recovery objectives. The detailed worksheet is [production inputs](PRODUCTION_INPUTS.md).
|
||||
- **Provider/operations owners:** supply sandbox accounts and configuration through the approved secret channel, plus the real deployment topology, backup destination, alert recipients, and release/rollback ownership. No credentials belong in this register or the repository.
|
||||
|
||||
**Closure rule:** A checkbox or passing mocked flow is insufficient. For each ID, keep the original evidence, record the implemented change and test, then verify in the environment that carries the risk. The [working roadmap](ROADMAP.md) tracks Week 2 delivery status; this register tracks the full defect disposition.
|
||||
144
docs/REVIEW-2026-09-21.md
Normal file
@@ -0,0 +1,144 @@
|
||||
**DTF project review — September 21, 2026**
|
||||
|
||||
Reviewed revision: `7386469`. Commit window: September 21, 00:00–24:00, America/Sao_Paulo; 26 commits. This is a review, not an implementation change or production approval.
|
||||
|
||||
**Assessment**
|
||||
|
||||
The project has a useful local workflow and several sound controls, but it is not yet the automated ordering and production system described in the meeting. The largest risks are incomplete commercial integrations, loss of production instructions between the editor and API, incorrect cart/quote behavior, an upload limit that the scanner cannot support, and operational controls that are documented more strongly than they are implemented.
|
||||
|
||||
Today's restructuring improved navigation through the repository, but introduced broken operational entrypoints. Today's board limit also introduced a starvation bug. Passing the existing tests does not establish that the requested artwork can be reproduced correctly or that the production deployment is recoverable.
|
||||
|
||||
**Business baseline and scope**
|
||||
|
||||
I read [the September 9 meeting notes](/home/farelos/compor/dtf-sistema/docs/reuniao-2026-09-09-anotacoes.pdf) first, then compared the implementation with [the later project context](/home/farelos/compor/dtf-sistema/docs/CONTEXT.md:20) and [the client roadmap](/home/farelos/compor/dtf-sistema/docs/roadmap-cliente.pdf).
|
||||
|
||||
The meeting's core outcome is unattended order intake and payment, fewer designer handoffs, reliable large-file handling, a traceable queue, and separation of ready artwork from artwork needing assistance. Later decisions explicitly defer automatic print preflight, factory agents, FlexiPRINT integration, machine dashboards, and advanced reports. Their absence is not reported here as an accidental regression. The approved site pricing also supersedes the meeting's simplified 20% discount description; changing those prices would require a separate business decision.
|
||||
|
||||
The active layout is `web/` for browser code; `app/api/` for HTTP routes; `app/core/` for shared foundations; the remaining `app/` modules for storage, artwork, scanning, initialization, and background work; `infra/` for local images and configuration; `deploy/`, the root Swarm composition, and `.gitea/` for delivery; `ops/` for operational commands; and `tests/` for checks. Historical documents describe earlier models and should not define current acceptance criteria.
|
||||
|
||||
**Evidence and limits**
|
||||
|
||||
- Inspected the active first-party application, browser logic, schema, deployment definitions, operational scripts, tests, project documents, and today's commit history and relevant diffs. Vendored PDF.js was checked as a third-party dependency, not subjected to a line-by-line audit of its minified implementation. Historical material was used as background.
|
||||
- Ran the current fast suite: 29 tests executed, 28 passed, one skipped because it still looks for the deleted `deploy/stack.yaml`.
|
||||
- Parsed all first-party Python files and checked the syntax of all first-party browser JavaScript files successfully.
|
||||
- Ran the isolated artwork browser suite with additional review probes against synthetic files. The existing checks passed; the probes reproduced findings 6, 7, and 10 below.
|
||||
- Ran the production source preflight: it correctly reported the fake payment and messaging adapters as blockers. CI does not enforce that result by default.
|
||||
- Used read-only checks in the running local API container: `ops` is absent, `app.staging_readiness` is absent, a 128 MiB + 1 byte scan is rejected, and ClamAV reports `1.5.4/28122/Sun Sep 13 06:26:25 2026`.
|
||||
- Executed the actual `submit_files` function body against an in-memory connection double to confirm which file kinds it invalidates. Also reproduced the board query's 101st-quote omission against synthetic SQL data. These are targeted logic checks, not production database tests.
|
||||
- Did not change application code, production services, credentials, orders, or stored artwork. Did not rerun the full container integration suite, perform a current Trivy audit, test provider sandboxes, or inspect production firewall/Portainer settings. Deployment-dependent risks below are explicitly qualified.
|
||||
|
||||
P1 means a delivery blocker or a material correctness, security, or recovery risk that should be resolved before accepting real customer work. P2 means an important correctness, reliability, or maintenance issue. A finding labelled a gap or design risk is not presented as an observed production incident.
|
||||
|
||||
**Commercial flow and artwork correctness**
|
||||
|
||||
1. **P1 — Production cannot complete an order. Confirmed delivery gap.** `dev-paid` returns 503 outside local mode, while runtime configuration requires fake payment, freight, Tiny, and WhatsApp adapters. There is no real payment creation/webhook/reconciliation flow, carrier quote, ERP order, or notification delivery. R2 connectivity and a healthy public page therefore do not establish a usable sales system. Complete the provider contracts and implement their sandbox-tested flows before treating the deployment as live commerce. Evidence: [orders.py:17](/home/farelos/compor/dtf-sistema/app/api/orders.py:17), [adapters.py:9](/home/farelos/compor/dtf-sistema/app/adapters.py:9), [runtime.py:24](/home/farelos/compor/dtf-sistema/app/runtime.py:24).
|
||||
|
||||
2. **P1 — The customer's production instructions disappear at checkout. Confirmed defect.** The editor records width, copies, rotation, mirroring, and ready-sheet repetitions, but `itemAtual` retains only totals and original Files. The API receives only mode, aggregate metres, grade, and upload IDs. It cannot tell the factory whether one artwork should be printed six times at 20 cm or with a different combination producing the same length. Neither the saved cart nor the order contains the full reproducible layout. Persist a versioned per-file production specification and the approved layout/revision. Evidence: [site-cart.js:65](/home/farelos/compor/dtf-sistema/web/site-cart.js:65), [checkout.js:65](/home/farelos/compor/dtf-sistema/web/checkout.js:65), [models.py:59](/home/farelos/compor/dtf-sistema/app/core/models.py:59).
|
||||
|
||||
3. **P1 — The 5 GiB upload path cannot deliver files above 128 MiB. Reproduced.** The browser/API accept 5 GiB, but `ClamAV.scan` hard-caps acceptance at `min(128 MiB, SCAN_MAX_BYTES)`, and ClamAV has matching limits. Raising the environment variable alone cannot fix it. Oversized files are rejected, blocked from quote/download/production, and scheduled for deletion; the customer may upload gigabytes before discovering this. Expose the usable limit before transfer and implement a deliberate large-file scan/release design. Evidence: [scanning.py:32](/home/farelos/compor/dtf-sistema/app/scanning.py:32), [clamd.conf:8](/home/farelos/compor/dtf-sistema/infra/clamd.conf:8), [docker-compose.yml:31](/home/farelos/compor/dtf-sistema/docker-compose.yml:31).
|
||||
|
||||
4. **P1 — The oldest 100 unpaid quotes can permanently hide new work. Introduced today in `543a9a9`.** The board selects the oldest unpaid quotes with a limit, including approved, expired, and abandoned quotes. There is no pagination, archive/cancel action, or pending-quote expiry that frees this window. Approved quotes are immutable, and expired ones cannot be paid, so old entries can remain indefinitely. Quote 101 is invisible even while needing review. Finished-order trimming also has no operator search/archive view for older completed work. Paginate/search history and give quotes an explicit lifecycle; do not silently cap actionable work. Evidence: [operator.py:59](/home/farelos/compor/dtf-sistema/app/api/operator.py:59), [kanban.js:24](/home/farelos/compor/dtf-sistema/web/kanban.js:24).
|
||||
|
||||
5. **P1 — A new correction can leave an obsolete final approved. Targeted logic reproduction.** An operator may submit finals while the order is in `cor`. The customer may then submit another correction using the latest version. `submit_files` deactivates only files of the incoming kind, so the earlier final remains active. Moving `cor → tra → fil` checks final coverage, not whether those finals were approved after the latest correction, and can accept the stale set. Invalidate finals on every accepted customer correction, or bind final approval to the specific correction revision. Evidence: [artwork.py:15](/home/farelos/compor/dtf-sistema/app/artwork.py:15), [artwork.py:40](/home/farelos/compor/dtf-sistema/app/artwork.py:40), [operator.py:118](/home/farelos/compor/dtf-sistema/app/api/operator.py:118).
|
||||
|
||||
6. **P2 — The stated DPI rejection and customer acknowledgement do not gate checkout. Browser-reproduced.** With 25.4-DPI artwork, the quality button was disabled but the payment button remained enabled and a cart item existed. `pintaEntrega` checks only customer data and freight; `dtfCheckout` does not check the resolution gate. The warning acknowledgement is also never recorded in the order. Manual operator review currently provides a later checkpoint, but the UI's claim that these files cannot proceed is false. Use one eligibility state for cart submission and retain any required acknowledgement against the artwork revision. Evidence: [site-quality.js:158](/home/farelos/compor/dtf-sistema/web/site-quality.js:158), [site-flow.js:90](/home/farelos/compor/dtf-sistema/web/site-flow.js:90), [checkout.js:49](/home/farelos/compor/dtf-sistema/web/checkout.js:49).
|
||||
|
||||
7. **P1 — Removing artwork can leave it in the submitted cart. Browser-reproduced.** After deleting the only artwork, `artes.length` became zero while `itemAtual.localFiles` still contained the removed file and checkout stayed enabled. An incomplete evaluation hides panels without clearing `itemAtual`; other invalid edits can similarly leave old totals and Files alive. Clear or invalidate the current cart item immediately whenever its underlying artwork becomes incomplete. Evidence: [site-cart.js:7](/home/farelos/compor/dtf-sistema/web/site-cart.js:7), [site-quality.js:79](/home/farelos/compor/dtf-sistema/web/site-quality.js:79), [site-pdf.js:348](/home/farelos/compor/dtf-sistema/web/site-pdf.js:348).
|
||||
|
||||
8. **P1 — Editing the cart after requesting a quote can still purchase the old quote. Confirmed control-flow defect.** Once `draftId` exists, checkout returns early through `refresh()` and never compares the current cart with the stored quote. The editor remains usable. The confirmation displays a server total but no full immutable item comparison, so quantity/artwork edits can be silently ignored, especially when the one-metre minimum keeps the total unchanged. Bind the UI to the quoted snapshot and explicitly replace the quote on edits. Evidence: [checkout.js:51](/home/farelos/compor/dtf-sistema/web/checkout.js:51), [checkout.js:89](/home/farelos/compor/dtf-sistema/web/checkout.js:89).
|
||||
|
||||
9. **P2 — PDF quantity and geometry are not reliably measured. Confirmed algorithm limitation.** Measurement searches the first/last 4 MiB for the first textual `MediaBox` and an unrelated first `UserUnit`; rendering/quality checks only page 1. There is no multi-page rejection or sum across pages. A multi-page print file can therefore be quoted as one page. Compressed/inherited page dictionaries, rotation, and page-specific units are not handled by this textual search. Use the PDF parser's page model, and either support every page or explicitly reject unsupported documents. Evidence: [site-pdf.js:101](/home/farelos/compor/dtf-sistema/web/site-pdf.js:101), [site-pdf.js:132](/home/farelos/compor/dtf-sistema/web/site-pdf.js:132).
|
||||
|
||||
10. **P2 — Quality grades can be based on missing or incorrect evidence. Partly browser-reproduced.** A CDR with no analysis plus an analysed PNG received grade 50 and R$18.70/m for the entire item rather than the CDR's stated table rate of R$19.90/m. `filter(Boolean)` removes unanalysed files from grading while their metres remain billable. For loose artwork, failed image decoding leaves a pixel estimate derived from compressed file size. Rotation retains the original width for DPI even when the physical width now corresponds to image height. The PDF image walker also treats no recognized images as vector artwork, although unsupported image operators or failed object lookup can yield the same result. Track quality per source and fail explicitly when measurement is unknown. Evidence: [site-quality.js:19](/home/farelos/compor/dtf-sistema/web/site-quality.js:19), [site-config.js:107](/home/farelos/compor/dtf-sistema/web/site-config.js:107), [site-upload.js:96](/home/farelos/compor/dtf-sistema/web/site-upload.js:96), [site-pdf.js:46](/home/farelos/compor/dtf-sistema/web/site-pdf.js:46).
|
||||
|
||||
11. **P2 — Oversized artwork is silently shrunk by the packer. Confirmed code path.** Width inputs advertise a maximum, but their JavaScript handlers accept larger values without validating form constraints. When no orientation fits, `encaixar` scales the artwork down to film width. The requested dimension and actual preview geometry then disagree, without an explicit approval to resize. Reject impossible dimensions or offer a visible, accepted resize. Evidence: [site-pdf.js:349](/home/farelos/compor/dtf-sistema/web/site-pdf.js:349), [site-packing.js:175](/home/farelos/compor/dtf-sistema/web/site-packing.js:175).
|
||||
|
||||
12. **P2 — The browser processing path is not bounded for large artwork. Confirmed design risk.** Images are loaded as full data URLs and decoded repeatedly; PDFs use full-file `arrayBuffer`; copy count has no effective upper bound before `Array.from`; preview canvas height grows with the whole layout. PDF timeouts race a promise without cancelling parsing/rendering or destroying the document. Large files or copy counts can exhaust memory or freeze the main thread before resumable upload helps. Bound work, avoid repeated full decodes, tile previews, cancel obsolete parsing, and provide a path that does not require browser rasterization of huge files. Evidence: [site-packing.js:6](/home/farelos/compor/dtf-sistema/web/site-packing.js:6), [site-packing.js:204](/home/farelos/compor/dtf-sistema/web/site-packing.js:204), [site-pdf.js:97](/home/farelos/compor/dtf-sistema/web/site-pdf.js:97).
|
||||
|
||||
13. **P1 — Home-delivery orders cannot capture a deliverable address. Confirmed MVP gap.** Customer data contains CNPJ, phone, and email; freight contains only service and CEP. There is no recipient name, street, number, complement, city/state, or validated delivery snapshot. A CEP can support an estimate, but it does not identify the destination needed to fulfil the order. Add the delivery contract together with freight integration; automated label purchase can remain out of scope. Evidence: [models.py:10](/home/farelos/compor/dtf-sistema/app/core/models.py:10), [models.py:43](/home/farelos/compor/dtf-sistema/app/core/models.py:43), [index.html:1209](/home/farelos/compor/dtf-sistema/web/index.html:1209).
|
||||
|
||||
14. **P1 — Unattended sales still depend on a human commercial review. Product decision outstanding.** Server pricing safely recalculates prices, but metres and grade become trusted only when an operator approves every quote. This intentionally protects the local prototype; it does not solve the meeting's after-hours bottleneck. Decide which orders can be accepted automatically, what evidence supports their price, and what exceptions require a person. Automatic print preflight being deferred does not itself settle the pricing-authority question. Evidence: [orders.py:28](/home/farelos/compor/dtf-sistema/app/api/orders.py:28), [operator.py:78](/home/farelos/compor/dtf-sistema/app/api/operator.py:78), [CONTEXT.md:262](/home/farelos/compor/dtf-sistema/docs/CONTEXT.md:262).
|
||||
|
||||
15. **P1 — Final print-file generation remains missing from the promised delivery. Confirmed gap.** The server stores originals and manually uploaded finals; it never generates the layout shown in the browser, applies repetitions, splits output, or adds the promised order identification. Consequently the factory must reconstruct the job, and billed metres have no machine-verifiable connection to produced metres. The client roadmap includes final-file generation in week 2 even while deferring automatic preflight. Implement a reproducible output path and print acceptance checks, or explicitly renegotiate that deliverable and the site's promises. Evidence: [api/artwork.py:45](/home/farelos/compor/dtf-sistema/app/api/artwork.py:45), [site-packing.js:342](/home/farelos/compor/dtf-sistema/web/site-packing.js:342), [ROADMAP.md:171](/home/farelos/compor/dtf-sistema/docs/ROADMAP.md:171).
|
||||
|
||||
**Security and access**
|
||||
|
||||
16. **P1 — Anonymous reservations can exhaust the entire storage quota without uploading bytes. Confirmed arithmetic/control-flow risk; no attack run.** Quota accounting immediately reserves the declared file size. With current defaults, five guest identities can reserve two 5 GiB uploads each and occupy the global 50 GiB allowance. Guest sessions and per-owner limits do not prevent this; all reservations remain until incomplete-upload cleanup after one day, and no customer abort endpoint releases them. Add global admission safeguards, short idle reservation leases, explicit cancellation, and a suitable identity/abuse policy. Evidence: [uploads.py:21](/home/farelos/compor/dtf-sistema/app/api/uploads.py:21), [health.py:26](/home/farelos/compor/dtf-sistema/app/api/health.py:26), [worker.py:21](/home/farelos/compor/dtf-sistema/app/worker.py:21).
|
||||
|
||||
17. **P1 — Malware signatures are already stale in the inspected local runtime. Reproduced operational gap.** The scanner runs `clamd` directly from a pinned image on an internal network, with no updater or scheduled replacement. It reports September 13 signatures on September 21, exceeding the repository's own seven-day threshold. Worker health only requires a live thread and PING. A running scanner is therefore reported healthy despite stale detection data. Establish controlled signature updates and make signature freshness visible to operations. [ClamAV's signature-management documentation](https://docs.clamav.net/manual/Usage/SignatureManagement.html) describes the update mechanism. Evidence: [docker-compose.yml:79](/home/farelos/compor/dtf-sistema/docker-compose.yml:79), [worker.py:60](/home/farelos/compor/dtf-sistema/app/worker.py:60), [security_status.py:7](/home/farelos/compor/dtf-sistema/ops/security_status.py:7).
|
||||
|
||||
18. **P1 — The reverse-proxy trust boundary is broader than the claimed protection. Deployment-dependent security risk introduced in `3b92813`.** Nginx accepts forwarded client addresses from every RFC1918 network, rather than the actual trusted proxy. Any reachable private peer can supply that header. The web ports use Swarm ingress, so the assumption that a direct internet request necessarily arrives with a public socket peer also needs topology testing. If untrusted traffic arrives through a trusted internal peer, it can spoof audit addresses and rate-limit buckets. Restrict trusted proxy hops and firewall the origin ports; verify through the actual Swarm/reverse-proxy chain. I did not verify production exposure or demonstrate a public exploit. Evidence: [nginx.conf.template:17](/home/farelos/compor/dtf-sistema/deploy/nginx.conf.template:17), [docker-compose.yml:143](/home/farelos/compor/dtf-sistema/docker-compose.yml:143). References: [Nginx real-IP trust](https://nginx.org/en/docs/http/ngx_http_realip_module.html), [Docker ingress routing](https://docs.docker.com/engine/swarm/ingress/).
|
||||
|
||||
19. **P2 — Production credentials remain plain service environment values. Confirmed configuration risk.** The actual stack supplies DB, R2, and operator secrets directly, despite the new secret-file loader and documentation describing external secrets. API and worker both receive the bootstrap operator password even though runtime authentication now uses the database. Removing the unused second stack reduced drift, but did not remove this exposure from the active one. Use the implemented file loader in the real stack and restrict each service to the secrets it needs. This is metadata/operational exposure, not evidence of a public credential leak. Evidence: [docker-compose.yml:3](/home/farelos/compor/dtf-sistema/docker-compose.yml:3), [core/secrets.py:1](/home/farelos/compor/dtf-sistema/app/core/secrets.py:1).
|
||||
|
||||
20. **P2 — Operator disable can race with login. Introduced with accounts in `a874033`; static concurrency finding.** Login reads the active account, performs expensive password verification, then inserts a session in a separate transaction without rechecking `active`. Disabling between those steps deletes existing sessions, but the in-flight login can create a new one afterwards. The request guard checks only session existence/expiry, so that session can authorize a disabled account for eight hours. Recheck active status atomically when issuing sessions and in authorization. Password changes also intentionally retain current sessions; document or revise that recovery policy. Evidence: [operator.py:23](/home/farelos/compor/dtf-sistema/app/api/operator.py:23), [auth.py:102](/home/farelos/compor/dtf-sistema/app/core/auth.py:102), [operators.py:80](/home/farelos/compor/dtf-sistema/app/operators.py:80).
|
||||
|
||||
21. **P2 — Account recovery and guest continuity are incomplete. Confirmed gap.** There is no email verification, password recovery/change flow for customers, or durable guest recovery mechanism. A guest who loses the cookie or passes its seven-day expiry cannot prove ownership merely by knowing the order/CNPJ, correctly, but also has no supported way to regain access. The site incorrectly promises account creation at payment; the order route creates no account. Complete the identity/recovery journey without weakening the existing ownership checks. Evidence: [customer.py:25](/home/farelos/compor/dtf-sistema/app/api/customer.py:25), [auth.py:58](/home/farelos/compor/dtf-sistema/app/core/auth.py:58), [index.html:1190](/home/farelos/compor/dtf-sistema/web/index.html:1190).
|
||||
|
||||
22. **P2 — Personal-data lifecycle is undefined beyond artwork cleanup. Confirmed governance gap, not a legal conclusion.** Profiles, quote drafts, immutable order snapshots, and integration payloads duplicate contact data without an implemented deletion/anonymization/export policy. Artwork expiry does not cover those records or retained backups. The site does link an external privacy/terms page, so claiming there is no privacy link would be inaccurate; this review did not establish whether that notice covers this processing. Define retention and access requirements for each data class, then implement them consistently. Evidence: [schema.sql:9](/home/farelos/compor/dtf-sistema/app/schema.sql:9), [schema.sql:28](/home/farelos/compor/dtf-sistema/app/schema.sql:28), [index.html:1358](/home/farelos/compor/dtf-sistema/web/index.html:1358).
|
||||
|
||||
**Operations and release engineering**
|
||||
|
||||
23. **P1 — Today's directory move broke staging, backup, and security operations. Reproduced packaging failure in `b329f76`.** Staging runs `app.staging_readiness`, but the module now lives under `ops`. Neither API Dockerfile copies `ops`. Backup still calls `local.storage_backup` inside the API container and selects plain `docker compose`, which now means the production-oriented root file rather than `compose.local.yaml`. Documentation calls nonexistent `app.backup` and `app.security_status`. These are broken operational commands, not merely stale comments. Package a deliberate operational runtime, update every executable entrypoint, and smoke-test the shipped commands. Evidence: [compose.staging.yaml:11](/home/farelos/compor/dtf-sistema/compose.staging.yaml:11), [infra/Dockerfile:10](/home/farelos/compor/dtf-sistema/infra/Dockerfile:10), [backup.py:13](/home/farelos/compor/dtf-sistema/ops/backup.py:13), [backup.py:41](/home/farelos/compor/dtf-sistema/ops/backup.py:41).
|
||||
|
||||
24. **P1 — PostgreSQL has no Swarm data-placement contract. Conditional recovery risk.** The active stack uses a normal local named volume with no node constraint. On a multi-node Swarm, rescheduling can attach a same-named empty local volume on another node rather than the existing database. The documented `POSTGRES_VOLUME` and `dtf_database=true` placement contract is not implemented. A single-node VPS is also a single failure domain. Specify and test persistence, placement, and recovery instead of relying on the volume name alone. Evidence: [docker-compose.yml:40](/home/farelos/compor/dtf-sistema/docker-compose.yml:40), [docker-compose.yml:180](/home/farelos/compor/dtf-sistema/docker-compose.yml:180), [PORTAINER.md:73](/home/farelos/compor/dtf-sistema/docs/PORTAINER.md:73).
|
||||
|
||||
25. **P1 — `latest` is published before the release scan, and deployment is not tied to the scanned pair. Confirmed pipeline design defect.** API and web `latest` tags are pushed independently before vulnerability checks. A failed scan leaves the mutable deployment tags pointing at rejected images; a manual redeploy or another run can consume them. Concurrent runs can also overwrite each other's API/web tags, while the webhook carries no exact release identity. Build and scan first, then promote one immutable API/web release and deploy that release. Evidence: [deploy.yml:194](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:194), [deploy.yml:217](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:217), [deploy.yml:243](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:243).
|
||||
|
||||
26. **P1 — The production gate does not validate the deployed contract. Confirmed gap.** CI runs only `--source-only`, treats failure as advisory by default, and never validates the actual Portainer metadata or observes deployment convergence. Full config validation expects names/secrets/volumes from the removed stack, while the active stack uses different variables. Positive secret-loader checks also derive their cases from that deleted file, leaving the set empty; the associated unit test skips. The two remaining provider checks match literal source strings, not working provider behavior. Retarget validation to the actual deployment, retain positive loader tests independently of a file's existence, and add release acceptance against real capabilities. Evidence: [deploy.yml:155](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:155), [production_preflight.py:17](/home/farelos/compor/dtf-sistema/deploy/production_preflight.py:17), [production_preflight.py:81](/home/farelos/compor/dtf-sistema/deploy/production_preflight.py:81), [test_secrets.py:75](/home/farelos/compor/dtf-sistema/tests/test_secrets.py:75).
|
||||
|
||||
27. **P1 — Browser tests can be skipped on a green release, and their upload endpoint is incompatible with the runner topology. Confirmed CI gap.** Missing Chrome or inaccessible host loopback returns success. Meanwhile CI signs browser storage URLs for `http://storage:9000`, a Compose-only hostname; installing Chrome or making host ports reachable does not by itself give that browser access to storage. These are the only broad tests of the artwork/cart journey. Run Chrome and the tests in a compatible network, then make omission fail the release. Evidence: [deploy.yml:45](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:45), [deploy.yml:90](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:90), [browser_test.mjs:44](/home/farelos/compor/dtf-sistema/tests/browser_test.mjs:44).
|
||||
|
||||
28. **P2 — CI runs share fixed ports and lack enforced project isolation. Conditional concurrency risk.** The integration job uses the same five host ports, relies on the default Compose project name, and ends with `down -v`. Overlapping runs on the shared Docker daemon can collide; if their project names coincide, they can also operate on each other's containers and volumes. Selecting currently unused ports fixed one collision but not concurrency. Allocate a unique project/network per run, avoid unnecessary published ports, and serialize release promotion. Evidence: [deploy.yml:27](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:27), [deploy.yml:121](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:121).
|
||||
|
||||
29. **P1 — Health checks do not establish operational readiness or successful delivery. Confirmed monitoring gap.** Public `/health` always returns 200 from Nginx. API health checks DB/storage, while worker health can remain green during repeated provider failures, stale signatures, or failed cleanup. `last_tick` advances even after a delivery retry is scheduled. Cleanup handles a batch in one transaction; one consistently failing object can roll back progress and repeatedly block later expired objects. There is no implemented external alert routing or post-webhook application verification, and the stack lacks explicit rollback/update policies described in the docs. Separate liveness from readiness and alert on backlog age, cleanup progress, signatures, backup age, and deployment acceptance. Evidence: [nginx.conf.template:32](/home/farelos/compor/dtf-sistema/deploy/nginx.conf.template:32), [worker.py:21](/home/farelos/compor/dtf-sistema/app/worker.py:21), [worker.py:35](/home/farelos/compor/dtf-sistema/app/worker.py:35), [deploy.yml:243](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:243).
|
||||
|
||||
30. **P1 — Recoverability is not implemented as a production service. Confirmed gap beyond the broken commands.** There is no scheduled offsite encrypted backup, production restoration procedure, or tested recovery objective. The local backup dumps PostgreSQL and selects object bytes afterwards, without a shared snapshot or enforced pause; concurrent uploads/retention can produce mismatched state. Its restore check validates database counts and archived bytes separately, not that every required live order file is restorable. Define a consistent recovery boundary, required data classes, backup retention, and a rehearsed restoration procedure. Evidence: [backup.py:27](/home/farelos/compor/dtf-sistema/ops/backup.py:27), [storage_backup.py:46](/home/farelos/compor/dtf-sistema/ops/storage_backup.py:46), [LOCAL_SETUP.md:216](/home/farelos/compor/dtf-sistema/docs/LOCAL_SETUP.md:216).
|
||||
|
||||
31. **P1 — Real payment and integration failure semantics are not yet represented. Design gap before enabling providers.** The mock payment is called before the order transaction commits. A future provider success followed by a DB failure needs a durable payment intent, provider idempotency, and reconciliation. The outbox has a useful unique event key, but retries can let a later event for the same order overtake an earlier failed event; permanent failures retry forever without a dead-letter/operator resolution path. Provider-side success followed by a crash can also repeat delivery. There are no real pending/failed/refunded/cancelled payment states or corresponding production rules. Implement those contracts before substituting real network calls for fakes. Evidence: [orders.py:34](/home/farelos/compor/dtf-sistema/app/api/orders.py:34), [worker.py:35](/home/farelos/compor/dtf-sistema/app/worker.py:35), [runtime.py:43](/home/farelos/compor/dtf-sistema/app/runtime.py:43).
|
||||
|
||||
32. **P2 — Upload/scanning throughput and timeout behavior are poorly matched. Confirmed design limitation.** Files and their 8 MiB parts are uploaded sequentially, with one API presign round-trip per part; the browser then waits for scanning before uploading the next file. One scan thread handles all work, holds a DB transaction during the remote read/scan, and the browser gives up after roughly 150 polls. Scanner errors shorten retention to three days even if a later retry succeeds; success does not restore that deadline. Add bounded transfer concurrency, separate upload completion from scan waiting, measure queue latency, and distinguish transient scanner faults from rejected content. Evidence: [upload.js:9](/home/farelos/compor/dtf-sistema/web/upload.js:9), [checkout.js:60](/home/farelos/compor/dtf-sistema/web/checkout.js:60), [scanning.py:55](/home/farelos/compor/dtf-sistema/app/scanning.py:55).
|
||||
|
||||
**Architecture, verification, and maintenance**
|
||||
|
||||
33. **P2 — Database integrity and schema evolution rely too heavily on application convention. Confirmed design weakness.** Startup replays one growing SQL script without a general ordered migration/version contract. Orders reference artwork inside JSON instead of relational order-item/upload references; states and scan states lack checks, and upload expiry remains nullable. The database cannot enforce several invariants that the application assumes. Today's index-before-table regression, fixed in `86b8199`, demonstrates why clean initialization and upgrade paths both need testing. Use versioned migrations, explicit core relationships/constraints, and verify supported upgrades as well as empty installations. Evidence: [bootstrap.py:29](/home/farelos/compor/dtf-sistema/app/bootstrap.py:29), [schema.sql:14](/home/farelos/compor/dtf-sistema/app/schema.sql:14), [schema.sql:71](/home/farelos/compor/dtf-sistema/app/schema.sql:71).
|
||||
|
||||
34. **P2 — File separation has not yet produced clear internal boundaries. Confirmed maintenance risk.** The browser scripts share mutable globals and a required evaluation order; cart and editor state can diverge, as reproduced above. API routes still implement SQL/business transactions, and the operator artwork router imports and calls the customer upload route functions directly. `runtime.py` constructs environment-bound adapters at import time, making isolated business tests harder. Every database operation creates a new connection, and synchronous audit DB writes also run directly in async middleware. Keep the small deployment, but move reusable use cases behind explicit interfaces, use one cart state model, and introduce bounded connection management as load requires. Evidence: [index.html:1376](/home/farelos/compor/dtf-sistema/web/index.html:1376), [api/artwork.py:15](/home/farelos/compor/dtf-sistema/app/api/artwork.py:15), [runtime.py:21](/home/farelos/compor/dtf-sistema/app/runtime.py:21), [db.py:6](/home/farelos/compor/dtf-sistema/app/core/db.py:6), [app.py:30](/home/farelos/compor/dtf-sistema/app/app.py:30).
|
||||
|
||||
35. **P2 — Existing tests verify the mock workflow more strongly than the delivered product. Confirmed coverage gap.** Pricing parity is valuable, but uses only 11 lengths and shared tables; it cannot validate print geometry. Integration fixtures deliberately use plain text with a `.cdr` extension, which tests transport but proves no printability. The current isolated browser server does not serve `/vendor/...`, and its tests do not exercise real PDF parsing. Missing operational entrypoints and stale-cart behavior passed the suite. CI tests local images/configuration, not the final production images and Swarm proxy topology. Add targeted acceptance for the defects above, real representative artwork, fault/retry cases, clean/upgrade schema paths, operational command packaging, and the exact release artifacts. Evidence: [test_pricing.py:10](/home/farelos/compor/dtf-sistema/tests/test_pricing.py:10), [fixtures/local-test.cdr](/home/farelos/compor/dtf-sistema/tests/fixtures/local-test.cdr), [artwork_browser_test.mjs:26](/home/farelos/compor/dtf-sistema/tests/artwork_browser_test.mjs:26), [deploy.yml:59](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:59).
|
||||
|
||||
36. **P2 — Documentation and generated deliverables can send operators to the wrong system. Partly introduced today.** README's active quick start still selects the production Compose file. Portainer instructions tell users to populate secret/volume/host variables the actual stack does not consume. Security/context documents still claim absent rollback, secrets, blocked deployment, or obsolete CDN behavior. The historical index calls the local milestone report a different prototype. The client PDF's diagram still labels a worker preflight while its scope defers it. Moving the PDF generator in `66ddb17` left `parents[2]`, which now resolves to `/home/farelos/compor`, outside this repository, and its output path no longer matches the documented PDF. Correct executable instructions and current claims, and validate generated output paths. Evidence: [README.md:3](/home/farelos/compor/dtf-sistema/README.md:3), [PORTAINER.md:73](/home/farelos/compor/dtf-sistema/docs/PORTAINER.md:73), [SECURITY_REPORT.md:40](/home/farelos/compor/dtf-sistema/docs/SECURITY_REPORT.md:40), [generate_dtf_report.py:17](/home/farelos/compor/dtf-sistema/tools/generate_dtf_report.py:17).
|
||||
|
||||
37. **P2 — Dependency/rebuild claims exceed the enforced supply-chain contract. Confirmed maintenance gap.** Vendoring PDF.js removed the CDN dependency, but version 3.11.174 remains old and has no automated inventory/update check. Its known eval advisory is mitigated by the existing `isEvalSupported:false` and restrictive CSP; this is not reported as demonstrated arbitrary code execution. PostgreSQL remains a mutable tag; CI scans only the application images. Unversioned OS upgrades and optional base-image overrides also mean a pinned base alone does not guarantee identical rebuilt images. The pricing tables are still separately maintained in JS/Python, though parity tests help. Record complete dependency provenance, scan all deployed images and vendored assets, and maintain a controlled refresh process. Evidence: [vendor/README.md:7](/home/farelos/compor/dtf-sistema/web/vendor/README.md:7), [site-pdf.js:97](/home/farelos/compor/dtf-sistema/web/site-pdf.js:97), [docker-compose.yml:41](/home/farelos/compor/dtf-sistema/docker-compose.yml:41), [deploy/Dockerfile.api:16](/home/farelos/compor/dtf-sistema/deploy/Dockerfile.api:16). Reference: [Mozilla's advisory and workaround](https://github.com/mozilla/pdf.js/security/advisories/GHSA-wgrm-67xf-hhpq). No fresh vulnerability counts are asserted here.
|
||||
|
||||
**Today's commit assessment**
|
||||
|
||||
| Commits | Result at reviewed HEAD |
|
||||
|---|---|
|
||||
| `341f154`, `d2f7b2c` | Secret-file loader and tests are useful; active stack still does not use them. |
|
||||
| `9da2a7d`, `bbcc8ab` | Added real gates, but source readiness remains advisory and publication precedes image acceptance. |
|
||||
| `4c9fa24`, `010c2a1` | Pinned application bases and added CRITICAL scanning. Do not infer current zero findings for every deployed service. |
|
||||
| `6c52ad6`, `6a50e6d` | Fixed CI image retrieval/configuration packaging. |
|
||||
| `24cb52d`, `7cab210`, `c1a07a7`, `dbc9ba4` | Improved integration execution after port/network failures; browser/network coverage and run isolation remain incomplete. |
|
||||
| `3b92813` | Recovered proxy client headers but trusts overly broad private ranges; needs real-topology verification. |
|
||||
| `e95a42d`, `9926d3a`, `91269ce` | Removed duplicate stack definition; active hardening and documentation were not reconciled completely. |
|
||||
| `da903db` | Removed PDF.js CDN dependency; old dependency and PDF logic remain. |
|
||||
| `a874033` | Added useful per-operator attribution; session issuance has a disable/login race. |
|
||||
| `543a9a9`, `86b8199` | Added useful indexes and bounded finished history; fixed table/index ordering. Quote truncation creates starvation. |
|
||||
| `ca69843` | Removed inactive prototypes; this correctly reduces ambiguity and should not be counted as lost active functionality. |
|
||||
| `96f1d27`, `c9f8122` | Improved code navigation and router assembly. Global state and business boundaries remain coupled. |
|
||||
| `66ddb17` | Removed accidentally tracked deliverables from HEAD and relocated artifacts; generator root/output was not updated. Untracking does not remove historical Git objects. |
|
||||
| `b329f76` | Improved directory roles; broke staging/backup/security command packaging. |
|
||||
| `7386469` | Centralized engineering docs; several executable instructions and current-state claims still disagree with code. |
|
||||
|
||||
The overall problem with today's work is incomplete acceptance around operational entrypoints and domain behavior. The reorganizations themselves are reasonable. Neither a microservice rewrite nor returning to the deleted prototypes would address the findings above.
|
||||
|
||||
**Recommended order of work**
|
||||
|
||||
1. Fix confirmed data/work-loss defects: preserve production instructions; clear stale cart state; bind quotes to the displayed cart; remove quote starvation; invalidate finals after corrections.
|
||||
2. Repair operational command packaging and establish a real backup/restore path. Address stale signatures, anonymous quota exhaustion, and the actual proxy trust boundary before accepting public uploads.
|
||||
3. Resolve the two central product decisions: unattended price authority and supported large-file processing. Align the site promises with the chosen interim behavior.
|
||||
4. Complete the MVP contracts: destination address/freight, durable payment intents and webhooks, Tiny, final-file generation, and the four WhatsApp events. Test their failure/reconciliation paths in staging.
|
||||
5. Make release identity, exact-artifact testing, browser tests, production configuration checks, and post-deployment verification enforceable. Reconcile the documentation with that one implementation.
|
||||
|
||||
The current controls worth preserving are server-calculated prices, immutable approved quotes, owner-scoped access, parameterized SQL, password hashing, revocable HttpOnly sessions, strict script CSP, private signed storage access, malware quarantine, state/version checks, transactional outbox insertion, and dependency hashes. This review identifies material defects and gaps supported by the inspected code; it does not establish that every possible flaw has been found.
|
||||
511
docs/ROADMAP.md
@@ -7,18 +7,59 @@
|
||||
> 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:** Block 0 closed; Block 2 closed except 2.9–2.11; 4.1, 4.2, 4.5,
|
||||
5.1, 5.3, 5.4 and 5.8 done. Remaining work needs decisions (3.1, 3.2, 3.3) or
|
||||
client inputs (1.1, 1.2, 2.10). Block 1 still waits on client inputs for
|
||||
1.1/1.2.
|
||||
**Current step (2026-09-25, last day of Week 2):** Still waiting on the client
|
||||
for Mercado Pago credentials and webhook access (1.1) and freight data (1.2).
|
||||
Tiny is connected in production and reads orders, but the connected user
|
||||
cannot read contacts (403) and the client's catalogue has no per-metre UV
|
||||
product; both wait on the client (1.3). Built and deployed without them: server-side print-file
|
||||
generation (1.4), the delivery address (3.8), the Kanban's payment-issue and
|
||||
print-file views and its redesign (1.5), the Site redesign (5.17), and Mercado
|
||||
Pago and Tiny adapters written from the public API documentation and tested
|
||||
against fake transports. None of the provider work is a verified integration.
|
||||
Every change passed the full integration sequence locally (Docker Engine in
|
||||
WSL) and on the Gitea runner, which publishes images on green pushes to `main`;
|
||||
the user verified each release in production after redeploying in Portainer.
|
||||
Operator and Site guides are in `docs/` (see Reporting).
|
||||
**Left for today:** the Week 2 client report. Waiting on the client: the
|
||||
Contatos permission and the product decision, then product ids and one
|
||||
supervised real order (1.3); Mercado Pago credentials for the sandbox PIX and
|
||||
card payments (1.1); freight data (1.2, the one Week 2 item that slips, on
|
||||
client inputs).
|
||||
|
||||
**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
|
||||
quotes, including a tested 101st pending quote. Operational entrypoints in
|
||||
5.12 are repaired and locally exercised. Image decoding, mixed-sheet grading,
|
||||
rotation-sensitive DPI, and PDF page geometry are corrected in 3.9/4.4.
|
||||
`main` pushes now validate without publishing; manual release requires a passing
|
||||
source preflight. The containerized browser gate passes locally, pending a Gitea
|
||||
runner run.
|
||||
Next address the upload/scanner safety gate and unsupported PDF image evidence.
|
||||
The customer/API upload admission now stops above the scanner's effective limit
|
||||
before transfer; the 5 GiB large-file product path still needs agreement and
|
||||
implementation.
|
||||
Unfinished upload reservations now expire after one hour or can be cancelled
|
||||
explicitly; anonymous admission and browser resource bounds stay open.
|
||||
Generated print output, lifecycle/recovery, and provider work remain open.
|
||||
Obtain decisions for unattended pricing, print-file acceptance and large files,
|
||||
plus sandbox inputs for freight and Mercado Pago. Week 2 delivery items 1.1–1.5
|
||||
remain open; 1.6 is complete.
|
||||
|
||||
> Paths in closed items are written as they were when the finding was made.
|
||||
> The repository was laid out by role on 2026-09-21 (`local/` became `app/`,
|
||||
> with `tests/`, `ops/`, `infra/` and `web/` beside it); the history is left
|
||||
> as recorded rather than rewritten.
|
||||
|
||||
**Last audit:** 2026-09-18, full read of `app/`, `dtf-site.html`, `deploy/`,
|
||||
`.gitea/`, docs and legacy prototypes. Findings below carry their audit IDs.
|
||||
**Last audit:** 2026-09-18, full read of the then-current tree. The 2026-09-21
|
||||
full review is `docs/REVIEW-2026-09-21.md`; the 2026-09-22 review and payment
|
||||
probes added new findings to Blocks 2–5 below. Historical paths in closed items
|
||||
remain as recorded.
|
||||
|
||||
**Full remediation register:** `docs/REMEDIATION-2026-09-22.md` maps every one
|
||||
of the 37 review findings to an action and a release gate. Use it alongside
|
||||
this Week 2 tracker; a green milestone here does not close the production gate.
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
@@ -29,6 +70,29 @@ client inputs (1.1, 1.2, 2.10). Block 1 still waits on client inputs for
|
||||
|
||||
---
|
||||
|
||||
## Week 2 execution sequence
|
||||
|
||||
This sequence keeps the client commitments in Block 1 visible while correcting
|
||||
defects that would make those commitments unsafe or impossible to operate.
|
||||
Do not mark a provider item complete from a fake-adapter test or a healthy page.
|
||||
|
||||
| Order | Work | Exit evidence |
|
||||
|---|---|---|
|
||||
| 1. Immediate safety — done 2026-09-22 | Close 2.13 and 2.14; cover foreign quote IDs, missing/invalid amounts, duplicates and valid approvals. | Local integration checks pass and no other customer's order is returned. |
|
||||
| 2. Order correctness | Fix 3.6 and 4.6: one current cart/quote snapshot, versioned per-file production instructions, correction/final revision binding, visible actionable quotes. | The approved quote, order and final file can be traced back to the same reviewed layout; edits cannot buy an old cart. |
|
||||
| 3. Resolve product contracts | Decide 3.1–3.3: which quotes may auto-approve, what generates the print file, and which sizes the upload and scanner can release. | Written acceptance rules and representative artwork/large-file cases before enabling unattended payment. |
|
||||
| 4. Week 2 integrations | Add destination data and real freight first, then Mercado Pago payment intents/webhooks/reconciliation, then Tiny/Olist order creation. Keep the four agreed WhatsApp events in the same delivery contract. | Sandbox flows and failure/retry cases pass; no fake provider is presented as production ready. |
|
||||
| 5. Operability and release | Repair 5.12–5.14, signatures, proxy trust and backup/restore; gate browser tests and the exact deployed images. | Fresh install, upgrade, recovery and deployed release checks pass with alert ownership recorded. |
|
||||
|
||||
Client inputs needed for steps 3–4 are listed in `docs/PRODUCTION_INPUTS.md`.
|
||||
Engineering can complete steps 1–2 and repair local operational commands while
|
||||
those inputs are gathered. The full disposition of architecture, security,
|
||||
quality, operational, and delivery findings is in
|
||||
`docs/REMEDIATION-2026-09-22.md`; all release gates there must be met before
|
||||
accepting real customer work.
|
||||
|
||||
---
|
||||
|
||||
## Block 0 · Broken right now
|
||||
|
||||
Nothing in this block is optional. Until it is closed, the system cannot be
|
||||
@@ -157,21 +221,184 @@ Ports 8090/8091/8010 were used; 8080 was held by an unrelated preview server.
|
||||
|
||||
From the report already sent. These are dated promises, not backlog.
|
||||
|
||||
- `[ ]` 1.1 — Mercado Pago transparent checkout, signed and idempotent webhooks.
|
||||
Requires production credentials + webhook access. Payment must never be created
|
||||
before the freight amount is final.
|
||||
- `[~]` 1.1 — Mercado Pago transparent checkout, signed and idempotent webhooks.
|
||||
**Current foundation (2026-09-22):** a fake signer exercises signature rejection,
|
||||
event-ID deduplication, amount comparison and transactional order creation.
|
||||
This is not a Mercado Pago integration. Complete a durable payment intent,
|
||||
provider payment ID and currency binding, real verification and status lookup,
|
||||
delayed/duplicate event handling, refund/cancellation rules and reconciliation.
|
||||
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.
|
||||
**Account setup (2026-09-28):** the client's application is Checkout
|
||||
Transparente on the Payments API, webhook event "Pagamentos (legacy)" only,
|
||||
URL `https://dtf.agenciacompor.com.br/api/payments/webhook`. The production
|
||||
compose now takes `PAYMENT_ADAPTER` and `MP_*` from Portainer (it hard-coded
|
||||
the fake adapter), so the sandbox runs on production with test credentials;
|
||||
the Site has no real customers yet. A verified notification whose payment
|
||||
does not exist (the panel's "Simular notificação") is acknowledged instead
|
||||
of answering 500, which would have made Mercado Pago retry it.
|
||||
- `[ ]` 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.
|
||||
**Received (2026-09-25):** Jadlog, API access (user/CNPJ, client code,
|
||||
token, account), origin CEP 14402-310, service .PACKAGE (modalidade 3),
|
||||
home delivery, billed by contract. Still missing: packaging weight and
|
||||
dimensions per length and how freight is charged to the customer (asked
|
||||
2026-09-28). `app/jadlog.py` prices one package from the manual (v2.3);
|
||||
`python -m app.jadlog_probe` prices test weights to six regions, read-only,
|
||||
to confirm token, account and contract on the client's account. Not yet run.
|
||||
**Adapter (2026-09-29):** `FREIGHT_ADAPTER=jadlog` prices "Receber em casa"
|
||||
with Jadlog from the order's billed metres (`JADLOG_PESO_BASE_KG` plus
|
||||
`JADLOG_PESO_POR_METRO_KG` per metre) and value, adds
|
||||
`FREIGHT_PRODUCTION_DAYS` to Jadlog's time, re-quotes the cart when it
|
||||
changes, and quotes again at approval from the priced items. It refuses to
|
||||
start without the credentials and the weights, which have no default. The
|
||||
production stack now takes the Jadlog settings, so the probe runs from the
|
||||
worker's console. Cubed weight is not computed: the client's box sizes will
|
||||
tell whether it is needed.
|
||||
- `[~]` 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 — this is the same problem).
|
||||
- `[ ]` 1.5 — Main Kanban production states consolidated.
|
||||
- `[ ]` 1.6 — **Block 0.2 + 0.3**, promised as "início da próxima semana".
|
||||
**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.
|
||||
**Supervised run (2026-09-25):** the payload and query parameters were
|
||||
checked against Tiny's published v3 OpenAPI spec (`GET /pedidos` accepts
|
||||
`cpfCnpj` and `dataInicial`; `GET /pedidos/{id}` returns
|
||||
`numeroOrdemCompra`). "Testar conexão" on the Kanban now also reads the four
|
||||
configured products and requires each to be active. `python -m
|
||||
app.tiny_probe` runs from the worker console: `produtos` and `conferir` are
|
||||
read-only; `pedido` shows the test order and creates it only with
|
||||
`--confirmar`, through the worker's own `deliver`, then proves the duplicate
|
||||
guard by search first and only then by a second delivery. Not yet run
|
||||
against the client's account.
|
||||
**Staying connected (2026-09-25):** Tiny documents a 4-hour access token and
|
||||
a 1-day refresh token; the worker renews about every 4 hours. The connection
|
||||
now asks for `offline_access` (listed by Tiny's Keycloak; if refused for this
|
||||
application the callback retries once without it). Renewal failures are
|
||||
stored: a refused refresh token marks the connection lost and is not retried,
|
||||
a transient failure shows as a warning until the next success, and a
|
||||
session grant with under 12 hours left is flagged. Previously a refused
|
||||
refresh still showed "Tiny conectado". Whether Tiny grants offline access,
|
||||
and whether a session grant has an undocumented maximum, is only known on
|
||||
the client's account. There is no alert channel yet (no e-mail; WhatsApp
|
||||
is fake): problems show on the Kanban only.
|
||||
**Client account (2026-09-25):** connected in production with the
|
||||
developer user the client provided. "Testar conexão": `pedidos` ok,
|
||||
`contatos` 403, unchanged after reconnecting. The application's permissions
|
||||
are correct (Contatos, Pedidos: read and include/edit; Produtos: read);
|
||||
Olist documents that v3 calls also depend on the logged-in user's module
|
||||
permissions, and that user's Cadastros menu shows only Produtos. Orders
|
||||
need Contatos (search by CNPJ, create when new; delete never). **Client
|
||||
to decide:** grant that user Clientes e Fornecedores, or reconnect with a
|
||||
user that has it (a dedicated integration user is recommended).
|
||||
**Products (2026-09-25),** from the client's product export: none of the
|
||||
36 "DTF" products matches the four Site products. `951438842` "IMPRESSÃO
|
||||
DTF PERSONALIZADO 57X100 (1 METRO)" (MT, R$ 19,90, active) fits Têxtil;
|
||||
there is no per-metre UV product (the UV one is 27 cm, per 10 cm), and no
|
||||
separate "artes avulsas" product. **Client to decide:** create four per-metre
|
||||
products (recommended, copying `951438842`), or share `951438842` between
|
||||
the two Têxtil modes; UV needs a product either way. The item note already
|
||||
carries the Site mode and grade. No product ids are set in production yet;
|
||||
both questions go to the client with the Mercado Pago credentials request.
|
||||
**Customer notices through Tiny (2026-09-25, Week 3 head start):** the client
|
||||
already sends WhatsApp notices from Tiny's order situação (Tiny webhook ->
|
||||
the `api-tiny-n8n` middleware, which reads the order through API v2 -> n8n
|
||||
-> WhatsApp templates): Aprovado, Pronto para envio with forma de envio `X`
|
||||
(v2 "Customizada", their pickup), Enviado, Entregue. So the system's own
|
||||
WhatsApp sender stays off and Tiny drives the notices. With
|
||||
`TINY_STATUS_UPDATES=true` a paid order is created "Aberta" and set to
|
||||
"Aprovada" (a retry finishes a half-done approval; an order already moved on
|
||||
is left alone), and moving a pickup order to Finalizado sets "Pronto para
|
||||
envio" by the Tiny id from the sale's receipt, searching only when it is
|
||||
missing and retrying while the sale has not reached Tiny. Pickup orders
|
||||
carry `TINY_FORMA_ENVIO_RETIRADA` (`tiny_probe formas-envio` lists the ids;
|
||||
"Testar conexão" now checks it). Delivery orders get "Enviada" with 1.2.
|
||||
Off by default: while n8n's `isDTFIMP` branch exists, "Aprovado" on a
|
||||
`DTFIMP` product sends the designer message, and the Site's Têxtil product
|
||||
code starts with `DTFIMP`. **Go-live together:** `TINY_ADAPTER=tiny`,
|
||||
`TINY_STATUS_UPDATES=true`, n8n's `isDTFIMP`/"DTF Aprovado - Designer"
|
||||
removed with "Mapear Whatsapp do Vendedor" connected to `If6`, and the
|
||||
middleware's `numero_ecommerce` falling back to the purchase order so the
|
||||
message shows `DTF-<n>`. **Unverified on the account:** that an API status
|
||||
change fires Tiny's webhook, that Tiny accepts Aprovada -> Pronto para envio
|
||||
without Faturada, and the v2 field name for the purchase order. Correção
|
||||
necessária and Produção iniciada have no Tiny situação; client to decide
|
||||
whether they need messages.
|
||||
- `[~]` 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).
|
||||
**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.
|
||||
**2026-09-24:** a mistaken move can be undone one stage back (`BACK` in
|
||||
`app/runtime.py`) with an internal reason, flagged in the history
|
||||
(`movements.back`), without notifying the customer; "production started" and
|
||||
"ready" are enqueued once per order. Previously the only way back was
|
||||
Correção, which messages the customer and invalidates approved finals.
|
||||
**Redesign (2026-09-24):** tabs for Produção, Cotações, Pagamentos and
|
||||
Integrações; an order panel with stages, items, print files, final files and
|
||||
history; numbered pagination (20 per page) on quotes, payment issues and the
|
||||
integration log, which also has filters; messages dismiss themselves. The
|
||||
operator guide (`docs/guia-operador-kanban.pdf`) documents the flow for the
|
||||
operation to confirm.
|
||||
- `[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'`)
|
||||
and ships only fake adapters, so the deployed system cannot take an order at all.
|
||||
1.1 is what unblocks it.
|
||||
Real freight, payment initiation and verified provider events are required to
|
||||
unblock it; the fake webhook alone does not.
|
||||
|
||||
---
|
||||
|
||||
@@ -326,14 +553,54 @@ proving control of the e-mail. Needs a transactional mail provider — **client
|
||||
### `[ ]` 2.11 — LGPD `(F15)`
|
||||
|
||||
CNPJ, phone and e-mail are kept indefinitely in `accounts.profile` and
|
||||
`orders.snapshot`. Artwork has a 30-day policy; personal data has none, and there is
|
||||
no privacy notice, consent record or deletion path.
|
||||
`orders.snapshot`. Artwork has a 30-day policy; personal data has no defined
|
||||
retention, export or deletion path. The Site links an external privacy notice;
|
||||
confirm that it covers this processing and define the required records and
|
||||
customer rights flow before production activation.
|
||||
|
||||
### `[x]` 2.13 — A foreign paid quote ID exposes an order (2026-09-22 review)
|
||||
|
||||
The `dev-paid` refusal fallback fetched `orders` by `quote_id` without `owner`.
|
||||
A separate local customer session received the full paid order when supplied
|
||||
another customer's quote ID. `app/api/orders.py` now includes the owner in the
|
||||
fallback query. The local payment integration test confirms a foreign ID returns
|
||||
404 while the owner can still retrieve the already-paid order.
|
||||
|
||||
### `[x]` 2.14 — A signed approval without a paid amount creates an order
|
||||
|
||||
`app/payments.py` compared amounts only when the event contained one. A local
|
||||
signed `approved` event without `amount_cents` created an order. The service now
|
||||
requires an actual integer amount equal to the approved total; local integration
|
||||
tests cover missing, non-integer, underpaid and correct values. Currency and
|
||||
provider payment identity belong to the wider contract in 3.7; this gate does
|
||||
not complete 1.1.
|
||||
|
||||
### `[~]` 2.15 — Public intake controls need operational proof
|
||||
|
||||
Unfinished reservations now have a one-hour lease and owner-scoped cancellation;
|
||||
anonymous admission capacity still needs a firm bound. Keep ClamAV signatures
|
||||
current and alert on stale data; scope reverse-proxy IP trust
|
||||
to the actual hop and verify it through Swarm ingress. These are separate
|
||||
controls, but all must work before public large-file intake is considered safe.
|
||||
The production topology has not been verified by the repository review.
|
||||
|
||||
---
|
||||
|
||||
## Block 3 · Architecture — needs a decision before code
|
||||
|
||||
### `[?]` 3.1 — Manual quote approval contradicts the 24h business case `(F16)`
|
||||
### `[~]` 3.1 — Manual quote approval contradicts the 24h business case `(F16)`
|
||||
|
||||
**Decided 2026-09-28 (the user: "we are an e-commerce"):** a cart the Site
|
||||
priced is approved when the quote is created and can be paid at once
|
||||
(`app/quote_review.py`, the same pricing the operator's approval uses). A
|
||||
person reviews only orders above `QUOTE_AUTO_MAX_METRES` (50 m) and items
|
||||
that claim a grade the Site could not have computed (unanalysed art with a
|
||||
discount). The Kanban lists automatic approvals and the reason for each
|
||||
manual one. **Still open:** the grade and layout are the browser's (3.2,
|
||||
3.9), so a customer who edits the page can claim a better grade, up to the
|
||||
top tier's discount; the server must compute the grade before this closes.
|
||||
|
||||
Previously:
|
||||
|
||||
Payment requires `quotes.approved`, set only by an authenticated operator. The
|
||||
meeting's premise was that the 17h30 order waiting until 5am is what costs the
|
||||
@@ -360,26 +627,118 @@ the site already did.
|
||||
generator, with the browser as preview only. This is the single largest gap between
|
||||
what was promised in the meeting and what exists.
|
||||
|
||||
### `[?]` 3.3 — The 5 GB problem is unsolved `(F19)`
|
||||
**2026-09-30:** the grade (the resolution discount) is no longer taken on trust.
|
||||
Before an automatic approval the API recomputes it from the uploaded files by the
|
||||
Site's rules (`app/grade_check.py`): image headers for PNG/JPG/WebP, the placed
|
||||
images of a PDF up to 150 MB. A claim more than 2 points above the file's grade,
|
||||
or a discount on a file the server cannot grade, waits for review with the reason
|
||||
on the Kanban. The metres (the packing) are still the browser's.
|
||||
|
||||
### `[~]` 3.3 — The 5 GB problem is unsolved `(F19)`
|
||||
|
||||
Transport accepts 5 GiB; `SCAN_MAX_BYTES` / ClamAV `StreamMaxLength` release only
|
||||
≤ 128 MiB. Files above that are quarantined permanently with no path forward. This
|
||||
is exactly the risk Jorge raised in the meeting.
|
||||
≤ 128 MiB. As of 2026-09-23, customer selection and API reservation reject files
|
||||
above the effective scan limit before transfer, and the Site displays the current
|
||||
limit. This prevents a doomed upload; it does not deliver the promised 5 GiB path.
|
||||
This is exactly the risk Jorge raised in the meeting.
|
||||
|
||||
**Decide:** raise the scan ceiling with a resource/timeout design, or define an
|
||||
explicit large-file path (staged scan, sampled scan, operator override with audit).
|
||||
|
||||
**Built 2026-09-29 (sheets of several GB are the normal order, not the
|
||||
exception):** files up to 5 GB. ClamAV scans up to 2 GB (`StreamMaxLength
|
||||
2000M`); above that a file is released only when its first bytes match the
|
||||
format its name claims (option A, the user's choice). The Site grades a sheet
|
||||
over 150 MB from the pixel size in the PNG/JPEG/WebP header without decoding
|
||||
it, and measures large PDFs through ranged reads; the worker never opens a
|
||||
source over 300 MB: a single finished sheet placed whole becomes its own print
|
||||
file, anything else goes to hand preparation. Uploads start as items enter the
|
||||
cart and the lease renews with each part. Verified on the local stack: 386 MB
|
||||
(ClamAV 78 s), 1.8 GB (ClamAV 6 min 18 s, scanner under 430 MB of memory, the
|
||||
original as print file), 2.3 GB PNG released by the format check and a
|
||||
disguised 2.3 GB file refused, and the header grade of a 200 MB file in 0.5 s.
|
||||
**Open:** large PDFs get no automatic grade (the DPI of images inside is not
|
||||
read without rendering); the scanner takes one file at a time, so several
|
||||
multi-GB uploads queue; pieces, residue and background of a large sheet are
|
||||
not checked automatically.
|
||||
|
||||
### `[ ]` 3.4 — Upload throughput `(F20)`
|
||||
|
||||
8 MiB parts, strictly sequential in `local/static/upload.js:21`, one presign
|
||||
round-trip per part → ~640 sequential API calls for a 5 GB file, through an nginx
|
||||
`limit_req` of 20r/s. Add parallelism (4–6 in flight) and batch presigning.
|
||||
|
||||
### `[ ]` 3.5 — Payment ordering `(F27)`
|
||||
### `[~]` 3.5 — Payment ordering `(F27)`
|
||||
|
||||
`dev_paid` charges before persisting the order and passes no idempotency key.
|
||||
Harmless with `FakePayment`; with Mercado Pago that ordering is how you get double
|
||||
charges. Fix as part of 1.1.
|
||||
The local fake `pay` call now runs inside the order transaction, and the inbound
|
||||
webhook records and applies a delivery transactionally. This does not make an
|
||||
external charge atomic with PostgreSQL: a provider can succeed while the database
|
||||
write fails, or deliver the approval later. Add a durable payment intent,
|
||||
provider idempotency key and reconciliation as part of 1.1 and 3.7.
|
||||
|
||||
### `[~]` 3.6 — Preserve and bind the order the customer actually reviewed
|
||||
|
||||
The browser's width, copies, rotation, mirroring and repetitions are absent from
|
||||
the API item, so the factory cannot reproduce the priced layout. Removing an
|
||||
artwork can leave a stale cart item; editing after quote creation can leave the
|
||||
old quote payable; a new correction can leave an obsolete final active. Persist
|
||||
a versioned per-file production specification, tie the displayed cart to its
|
||||
immutable quote, and tie final approval to the latest correction revision.
|
||||
Cover the real editor-to-quote-to-final journey, not only the pricing table.
|
||||
|
||||
**Local progress 2026-09-23:** The Site includes per-upload width, length,
|
||||
copies, rotation, mirroring, measurement source, and the exact placement of
|
||||
each copy in production specification v2. The API checks coverage, dimensions,
|
||||
film bounds, and quote height; commercial review cannot replace the layout.
|
||||
The order snapshot and downloadable Kanban manifest retain it. Browser quote actions are disabled when
|
||||
the cart differs, including same-price changes. Editor changes invalidate the
|
||||
current cart item immediately; a new customer correction deactivates prior
|
||||
finals. Browser and local API regressions pass. **Still open:** generate and
|
||||
validate the final print file from the approved source revision, and
|
||||
make quote/cart continuity work across devices through a server-authoritative
|
||||
confirmation flow. Current quote binding is a browser guard.
|
||||
|
||||
### `[?]` 3.7 — Complete payment state and reconciliation rules
|
||||
|
||||
Event-ID deduplication does not establish which provider payment settled which
|
||||
quote. Define intent creation, provider transaction ID, currency, paid-at time,
|
||||
pending/rejected/refunded/cancelled states, late approval after quote expiry,
|
||||
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
|
||||
|
||||
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
|
||||
cannot be established. Do not infer pixels from compressed file size, grade a
|
||||
mixed item from only the readable files, silently shrink oversized artwork, or
|
||||
allow a displayed DPI rejection to proceed through checkout. Use representative
|
||||
real artwork in acceptance checks.
|
||||
|
||||
**Local progress 2026-09-22:** DPI refusal and warning acknowledgement now gate
|
||||
the cart and quote API records the acknowledgement. Oversized loose-art width
|
||||
is rejected in the UI, API production contract, and packer. On 2026-09-23,
|
||||
unreadable loose images are blocked, mixed analyzed/manual sheets receive no
|
||||
automatic grade or discount, and rotated DPI uses the pixel dimension that
|
||||
corresponds to printed width. PDF dimensions now come from the parsed page
|
||||
model, with page count, crop, rotation, and UserUnit checks; multi-page and
|
||||
malformed PDFs block quoting. Isolated browser checks cover those cases and
|
||||
same-origin PDF rendering. Unsupported PDF image operators and representative
|
||||
print-file evidence still need correction before this item can close.
|
||||
|
||||
---
|
||||
|
||||
@@ -394,15 +753,22 @@ charges. Fix as part of 1.1.
|
||||
window of recent finished ones (`BOARD_FINISHED_LIMIT`, default 50) and the true
|
||||
finished total. An operator can never lose a card they could act on; only terminal
|
||||
ones are trimmed. The Kanban column reads "Finalizado · 50 de 213" when truncated,
|
||||
so the count is not mistaken for an all-time total. Pending quotes are capped too.
|
||||
so the count is not mistaken for an all-time total. Pending quotes now have
|
||||
a paginated view; older completed orders still need search in 4.6.
|
||||
- `[ ]` 4.3 — Scan throughput `(F21)`: one `scan_loop` thread, `worker` at
|
||||
`replicas: 1`, ClamAV `MaxThreads 2`, browser gives up after 150s.
|
||||
- `[ ]` 4.4 — Quality grade fallback `(F22)`: when `carregarImagem` fails,
|
||||
`px(f)=Math.sqrt(f.size/1024)*95` stands — a DPI inferred from **file size in
|
||||
bytes** — and it drives up to a 25% discount. Fail closed instead.
|
||||
- `[x]` 4.4 — Failed image decoding no longer infers pixels from compressed
|
||||
file size. Unreadable loose images cannot enter the cart or receive a grade;
|
||||
an isolated browser regression covers the failure path (2026-09-23).
|
||||
- `[x]` 4.5 — Dead config `(F28)`: resolved by deleting `deploy/stack.yaml` in 2.12.
|
||||
`CLAMD_HOST` no longer appears anywhere; `scanning.py` reaching `'scanner'`
|
||||
directly is now simply how it works, not a contradiction.
|
||||
- `[~]` 4.6 — Older unpaid quotes can disappear behind the board limit.
|
||||
On 2026-09-23 the board began showing newest pending and approved unpaid
|
||||
quotes separately, with cursor pagination and counts; a local regression
|
||||
retrieved all 105 pending and 22 approved fixture quotes and cleaned them up.
|
||||
**Still open:** an explicit terminal state for abandoned/expired quotes and
|
||||
search/history for older completed orders.
|
||||
|
||||
---
|
||||
|
||||
@@ -453,14 +819,9 @@ charges. Fix as part of 1.1.
|
||||
integration job should run `down -v` before `up` — or a dedicated step should
|
||||
apply `schema.sql` twice to a fresh database, proving both a first install and
|
||||
a re-run.
|
||||
- `[ ]` 5.10 — The browser suites do not run in CI. Chrome runs in the runner
|
||||
container and can only reach the stack through ports published on the host, which
|
||||
is a different network namespace when the runner is itself a container. The API,
|
||||
workflow, security, scanning, retention and runtime suites were moved inside the
|
||||
stack's network and do gate. The browser suites are the only coverage for the
|
||||
artwork editor and the full customer journey, so they need either Chrome in a
|
||||
container on that network, or a runner with host networking. Until then they gate
|
||||
locally only, and CI warns when it skips them.
|
||||
- `[ ]` 5.10 — The browser suites were moved into a Chrome container on the
|
||||
Compose network and made required in CI. Confirm the complete checkout journey
|
||||
passes in that topology and on the actual Gitea runner before closing this item.
|
||||
- `[ ]` 5.9 — `local/browser_test.mjs` failed once and passed on an immediate
|
||||
re-run, with no code change in between (2026-09-21). It is a deploy gate when the
|
||||
runner has Chrome, so an intermittent failure there blocks releases for no reason.
|
||||
@@ -471,6 +832,60 @@ charges. Fix as part of 1.1.
|
||||
days. The copy now states 30 days, says a later order needs the file again, and
|
||||
keeps only the true part: order history remains in the account. Policy unchanged;
|
||||
the promise was corrected to match it.
|
||||
- `[x]` 5.12 — Repair operational entrypoints after the `local/` split.
|
||||
On 2026-09-23, staging and both API images package `ops/`; staging calls
|
||||
`ops.staging_readiness`; local backup calls `ops.storage_backup` through
|
||||
`compose.local.yaml`; documented security and backup commands use the real
|
||||
modules. Verified a network-disabled staging pass with non-secret fixture
|
||||
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.
|
||||
- `[x]` 5.17 — Site redesign (2026-09-24), Dropstar brand kept; the previous
|
||||
look is tag `ui-v1`. The home, one page per product (`/arquivo-por-metro`,
|
||||
`/artes-avulsas`, `/uv-arquivo-por-metro`, `/uv-artes-avulsas`) and
|
||||
`/carrinho` have their own addresses but stay one document, so artwork held in
|
||||
the browser survives moving between them (`web/site-pages.js`,
|
||||
`web/site-steps.js`; nginx serves `index.html` for those paths). The product
|
||||
page is built around a buy box (`web/site-compra.js`) that shows the item the
|
||||
flow already priced (grade, price per metre, metres charged, total,
|
||||
resolution acknowledgement and "Adicionar ao carrinho"); the duplicated
|
||||
quality and preview panels are hidden. The cart has "Remover" per item,
|
||||
"Esvaziar carrinho" and an 8-second undo. Fixed on the way, all already in
|
||||
production before: the saved-cart note took a grid column and pushed the
|
||||
order into a narrow strip, the panels outside `.w` ran edge to edge, and
|
||||
"57 cm" wrapped on the ready-sheet option. The browser suite covers product
|
||||
and cart addresses, reload on a product page, and remove/undo; the security
|
||||
suite checks the new addresses carry the same CSP.
|
||||
- `[ ]` 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.
|
||||
**2026-09-29:** database part built. The `backup` service dumps daily,
|
||||
encrypts with age (the server holds only the public key) and uploads to a
|
||||
bucket of its own, whose lifecycle rule expires copies and whose lock keeps
|
||||
them from being deleted early. The Kanban shows the latest run, and
|
||||
`tests/backup_test.py` restores a copy in CI. Setup and restore:
|
||||
docs/BACKUP.md. Artwork is not copied: it is temporary and already on R2.
|
||||
- `[ ]` 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
|
||||
redeploys, and isolate concurrent CI stacks.
|
||||
|
||||
---
|
||||
|
||||
@@ -580,6 +995,30 @@ charges. Fix as part of 1.1.
|
||||
|
||||
### Reporting
|
||||
|
||||
- `[x]` Operator guide (`docs/guia-operador-kanban.pdf`) and Site guide
|
||||
(`docs/guia-site-dtf.pdf`), 2026-09-24, with screenshots of a throwaway stack
|
||||
and fictional orders. The payment step has no screenshot until Mercado Pago
|
||||
is configured, and the guides name the provisional Kanban domain; regenerate
|
||||
them when either changes. Early work toward Week 3's "orientação à operação".
|
||||
- `[x]` Operator guide rebuilt from a committed source (2026-09-25):
|
||||
`docs/guias/operador/` (HTML and the original screenshots, cropped as
|
||||
before), printed by `docs/guias/imprimir.sh`. The source of the 09-24 PDF
|
||||
was never committed. Corrected: stage moves do not update Tiny (only
|
||||
"Aprovada" and, for pickup, "Pronto para envio", once enabled); WhatsApp
|
||||
notices go through the client's Tiny -> n8n flow; the correction notice is
|
||||
not automatic, the reason is in Minha conta; the Tiny card's renewal,
|
||||
"Verificar" and "Testar conexão" checks. Found while writing it: the
|
||||
customer's order history showed operators' internal reasons for moving an
|
||||
order back; it now omits back moves and shows reasons only for corrections.
|
||||
The Site guide's source is also not in the repository.
|
||||
- `[x]` Week-2 client report (`Relatorio-Semana-2-DTF.docx`), written 2026-09-25,
|
||||
sent 2026-09-28. Before sending, the FlexiPRINT import was taken out of
|
||||
"O que falta" (it is done this week) and the print-file paragraph no longer
|
||||
names PSD, AI, CDR or multi-page PDFs as hand-preparation cases. **Reversed
|
||||
2026-09-29:** those formats stay as built: no automatic check, the full rate
|
||||
per metre, and the operator prepares them by hand. Automatic generation is
|
||||
not planned; the sent report omits them from its list of exceptions. Freight is reported as Jadlog data received,
|
||||
waiting on packaging weight and dimensions and the charging policy.
|
||||
- `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to
|
||||
remove the inaccurate "Arquivo por metro permanece separado, com seleção explícita"
|
||||
claim and the internal commit reference.
|
||||
|
||||
@@ -77,14 +77,15 @@ the signatures bundled into that image. On closeout it reported ClamAV
|
||||
below the seven-day alert threshold.
|
||||
|
||||
The multipart transport supports uploads up to 5 GiB, but `SCAN_MAX_BYTES` and
|
||||
ClamAV stream limits release at most 128 MiB by default. Larger files remain
|
||||
blocked. Supporting larger files requires a deliberate resource/timeout design,
|
||||
not simply increasing the upload limit.
|
||||
ClamAV stream limits release at most 128 MiB by default. As of 2026-09-23 the
|
||||
API and customer picker reject larger files before transfer. Supporting them
|
||||
requires a deliberate resource/timeout design, not simply increasing the
|
||||
transport limit.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
docker compose exec -T api python3 -m app.security_status
|
||||
docker compose -f compose.local.yaml exec -T api python3 -m ops.security_status
|
||||
```
|
||||
|
||||
An exit status of 1 requires review. At closeout, attention was expected because
|
||||
@@ -131,15 +132,12 @@ change when the advisory database or selected base digest changes.
|
||||
The files in `deploy/` and `.gitea/workflows/` are a guarded delivery mechanism,
|
||||
not an approval to operate the current application on the public internet.
|
||||
|
||||
Corrected 2026-09-21: an earlier version of this section described gates the
|
||||
workflow did not contain. The workflow now runs static validation, the
|
||||
integration suite against a live stack, and a blocking Trivy secret scan before
|
||||
publishing. The source preflight is advisory unless
|
||||
`ENFORCE_PRODUCTION_PREFLIGHT` is set, and image vulnerabilities are reported
|
||||
rather than enforced, because the current bases carry HIGH/CRITICAL findings
|
||||
with no upstream fix. Base images are still mutable tags, not digests.
|
||||
`PORTAINER.md` holds the authoritative table of what gates and what does not.
|
||||
It publishes both `latest` and the full commit SHA, then calls the Portainer
|
||||
Updated 2026-09-23: pushes to `main` run checks only. A manual workflow run on
|
||||
`main` requires the source preflight and a configured Portainer webhook before
|
||||
building. It scans built images before publication; CRITICAL findings block and
|
||||
HIGH findings are reported. The browser suites run in a required Compose Chrome
|
||||
container. `PORTAINER.md` holds the authoritative gate table. A permitted release
|
||||
publishes both `latest` and the full commit SHA, then calls the Portainer
|
||||
webhook. Application/provider secrets are created directly as versioned external
|
||||
Swarm secrets and never cross the workflow. Rollback selects the prior commit SHA
|
||||
in Portainer and does not roll back the database.
|
||||
|
||||
BIN
docs/guia-operador-kanban.pdf
Normal file
BIN
docs/guia-site-dtf.pdf
Normal file
8
docs/guias/imprimir.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Print the guide sources in docs/guias/ to the PDFs in docs/ with headless
|
||||
# Chrome (A4, no browser header or footer). Needs Chrome and the DejaVu fonts.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
CHROME=${CHROME:-$(command -v google-chrome-stable || command -v google-chrome || command -v chromium)}
|
||||
"$CHROME" --headless --disable-gpu --no-pdf-header-footer --run-all-compositor-stages-before-draw \
|
||||
--print-to-pdf="$PWD/../guia-operador-kanban.pdf" "file://$PWD/operador/guia-operador-kanban.html"
|
||||
320
docs/guias/operador/guia-operador-kanban.html
Normal file
@@ -0,0 +1,320 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Source of docs/guia-operador-kanban.pdf. Print with docs/guias/imprimir.sh.
|
||||
Screenshots in img/ come from a throwaway stack with fictional orders. -->
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Kanban DTF · Guia do operador</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 0; }
|
||||
:root {
|
||||
--navy: #0B1320; --orange: #F08A24; --ink: #1B2331; --muted: #6B7280;
|
||||
--line: #D6DCE4; --head: #EAF0F8; --note: #FFF5E8; --note-line: #F4C58E;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { font-family: 'DejaVu Sans', sans-serif; color: var(--ink); font-size: 9.7pt; line-height: 1.55;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.page { width: 210mm; height: 297mm; position: relative; padding: 26mm 18mm 20mm; overflow: hidden;
|
||||
page-break-after: always; break-after: page; }
|
||||
.page:last-child { page-break-after: auto; break-after: auto; }
|
||||
.running { position: absolute; top: 9mm; left: 18mm; right: 18mm; font-size: 7.4pt; color: var(--muted);
|
||||
border-bottom: 0.6pt solid #E3E7ED; padding-bottom: 2.4mm; }
|
||||
.folio { position: absolute; bottom: 9mm; right: 18mm; font-size: 7.4pt; color: var(--muted); }
|
||||
.kicker { color: var(--orange); font-weight: bold; font-size: 7.6pt; letter-spacing: 0.04em; margin: 0 0 1mm; }
|
||||
h1 { font-size: 20.5pt; line-height: 1.2; margin: 0 0 2mm; color: #101826; }
|
||||
h1::after { content: ""; display: block; width: 18mm; height: 1.4mm; background: var(--orange); margin-top: 3mm; }
|
||||
h2 { font-size: 11.5pt; margin: 7mm 0 2.5mm; color: #101826; }
|
||||
p { margin: 0 0 2.6mm; }
|
||||
b { font-weight: bold; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 2mm 0 3mm; font-size: 8.6pt; line-height: 1.45; }
|
||||
th, td { border: 0.6pt solid var(--line); padding: 2mm 2.4mm; vertical-align: top; text-align: left; }
|
||||
th { background: var(--head); font-weight: bold; }
|
||||
td.n { color: var(--orange); font-weight: bold; font-size: 11pt; width: 7mm; text-align: center; }
|
||||
td.k { font-weight: bold; }
|
||||
.note { background: var(--note); border: 0.6pt solid var(--note-line); border-radius: 2mm; padding: 3mm 3.6mm;
|
||||
font-size: 8.6pt; margin: 3mm 0; }
|
||||
ul { margin: 0 0 3mm; padding-left: 4.5mm; }
|
||||
ul li { margin-bottom: 1.1mm; }
|
||||
ul li::marker { color: var(--orange); }
|
||||
ol { margin: 0 0 3mm; padding-left: 5mm; }
|
||||
ol li { margin-bottom: 1.3mm; }
|
||||
ol li::marker { color: var(--orange); font-weight: bold; }
|
||||
.chip { display: inline-block; border: 0.6pt solid #C9D0DA; background: #F4F6F9; border-radius: 1.2mm;
|
||||
padding: 0 1.6mm; font-size: 7.6pt; line-height: 1.6; white-space: nowrap; }
|
||||
.shot { display: block; width: 100%; border-radius: 1.6mm; margin: 2mm 0 1mm; }
|
||||
.caption { font-size: 7.2pt; color: var(--muted); margin: 0 0 3mm; }
|
||||
.split { display: flex; gap: 6mm; align-items: flex-start; }
|
||||
.split > div { flex: 1; }
|
||||
.split > .side { flex: 0 0 79.5mm; }
|
||||
.quote { font-style: normal; }
|
||||
|
||||
/* Cover */
|
||||
.cover { background: var(--navy); color: #F3F5F8; padding: 0 18mm; }
|
||||
.cover .block { position: absolute; left: 18mm; right: 18mm; top: 158mm; }
|
||||
.cover .kicker { margin-bottom: 4mm; }
|
||||
.cover .title { font-size: 27pt; font-weight: bold; line-height: 1.25; margin: 0 0 4mm; color: #fff; }
|
||||
.cover .lead { font-size: 9.6pt; color: #C8CFDA; margin: 0 0 8mm; }
|
||||
.cover table { font-size: 8pt; margin: 0 0 8mm; }
|
||||
.cover th, .cover td { border-color: #2A3547; background: transparent; color: #E6EAF0; padding: 3mm 3.6mm; }
|
||||
.cover th { color: var(--orange); }
|
||||
.cover .version { font-size: 8pt; color: #A9B2C0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 1 · Capa -->
|
||||
<section class="page cover">
|
||||
<div class="block">
|
||||
<p class="kicker">GUIA DE OPERAÇÃO</p>
|
||||
<p class="title">Kanban DTF<br>Guia do operador</p>
|
||||
<p class="lead">Como conferir, produzir e entregar os pedidos que chegam pelo Site DTF.</p>
|
||||
<table>
|
||||
<tr><th style="width:50%">Acesso</th><th>Quem usa</th></tr>
|
||||
<tr><td>dtf.kanban.agenciacompor.com.br<br>e-mail e senha do operador</td>
|
||||
<td>Equipe da sala de DTF e atendimento</td></tr>
|
||||
</table>
|
||||
<p class="version">Versão de 25 de setembro de 2026</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2 · Visão geral -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">VISÃO GERAL</p>
|
||||
<h1>O caminho de um pedido</h1>
|
||||
<p>O cliente monta a folha e envia as artes pelo Site DTF. Daí em diante, tudo acontece no Kanban: a equipe
|
||||
confere a cotação, o cliente paga, e o pedido percorre as etapas de produção até ficar pronto para retirada.</p>
|
||||
<table>
|
||||
<tr><td class="n">1</td><td class="k" style="width:31mm">Cliente envia</td>
|
||||
<td>Monta a folha no Site, vê a nota e o preço e envia o pedido para conferência.</td></tr>
|
||||
<tr><td class="n">2</td><td class="k">Equipe confere a cotação</td>
|
||||
<td>Na aba <b>Cotações</b>: confere arquivos, metragem e nota e aprova.</td></tr>
|
||||
<tr><td class="n">3</td><td class="k">Cliente paga</td>
|
||||
<td>O total aprovado aparece no Site. O pagamento é pelo Mercado Pago (PIX ou cartão).</td></tr>
|
||||
<tr><td class="n">4</td><td class="k">Pedido entra na produção</td>
|
||||
<td>Com o pagamento aprovado, o pedido aparece em <b>Arte recebida</b>, com o PDF de impressão já gerado.</td></tr>
|
||||
<tr><td class="n">5</td><td class="k">Equipe produz</td>
|
||||
<td>Aprova o arquivo final, baixa, importa no FlexiPRINT e move o card a cada etapa.</td></tr>
|
||||
<tr><td class="n">6</td><td class="k">Pedido pronto</td>
|
||||
<td>Em <b>Finalizado</b>, o pedido fica pronto para retirada e o cliente é avisado (página 7).</td></tr>
|
||||
</table>
|
||||
<h2>As quatro abas</h2>
|
||||
<table>
|
||||
<tr><th style="width:31mm">Aba</th><th>Para que serve</th></tr>
|
||||
<tr><td class="k">Produção</td><td>O quadro com os pedidos pagos, uma coluna por etapa.</td></tr>
|
||||
<tr><td class="k">Cotações</td><td>Pedidos enviados pelo Site esperando conferência. O número ao lado indica quantos faltam revisar.</td></tr>
|
||||
<tr><td class="k">Pagamentos</td><td>Pagamentos que o sistema não conseguiu ligar a um pedido. Normalmente fica vazia.</td></tr>
|
||||
<tr><td class="k">Integrações</td><td>Situação do Tiny, Mercado Pago, frete e WhatsApp, e o registro de tudo o que foi enviado a eles.</td></tr>
|
||||
</table>
|
||||
<div class="note">O quadro não se atualiza sozinho. Clique em <b>Atualizar</b>, no canto superior direito, para ver pedidos e cotações novos.</div>
|
||||
<div class="folio">Página 2</div>
|
||||
</section>
|
||||
|
||||
<!-- 3 · Entrar e ler o quadro -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PRODUÇÃO</p>
|
||||
<h1>Entrar e ler o quadro</h1>
|
||||
<div class="split">
|
||||
<div>
|
||||
<p>Acesse <b>dtf.kanban.agenciacompor.com.br</b> e entre com seu e-mail e senha. Cada operador tem o próprio
|
||||
acesso: é o nome dele que fica registrado em cada movimento do pedido.</p>
|
||||
<p>Depois de várias senhas erradas seguidas, o acesso fica bloqueado por 15 minutos.</p>
|
||||
</div>
|
||||
<div class="side"><img class="shot" src="img/login.png" alt="Tela de entrada do Kanban"></div>
|
||||
</div>
|
||||
<img class="shot" src="img/quadro.png" alt="Quadro de produção">
|
||||
<p class="caption">Quadro de produção com três pedidos em etapas diferentes.</p>
|
||||
<h2>O que cada card mostra</h2>
|
||||
<ul>
|
||||
<li><b>#número</b> do pedido e há quanto tempo ele está parado na etapa.</li>
|
||||
<li>E-mail do cliente, produto e metragem cobrada (ex.: <span class="chip">Têxtil avulsa · 1,00 m</span>).</li>
|
||||
<li>Situação do arquivo: <span class="chip">PDF pronto</span> (gerado, falta aprovar), <span class="chip">Final aprovado</span>
|
||||
(pode ir para a fila) ou <span class="chip">Preparar à mão</span> (o sistema não conseguiu gerar; veja a página 6).</li>
|
||||
<li><span class="chip">Retirada</span> ou <span class="chip">Entrega · UF</span>.</li>
|
||||
<li>Um aviso <b>Parado há mais de 4 h</b> aparece quando o pedido não anda.</li>
|
||||
</ul>
|
||||
<p>Os filtros <b>Têxtil</b>, <b>UV</b> e <b>Preparar à mão</b> mostram só esses pedidos. A busca encontra pedido, cliente ou CNPJ.</p>
|
||||
<div class="folio">Página 3</div>
|
||||
</section>
|
||||
|
||||
<!-- 4 · Cotações -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">COTAÇÕES</p>
|
||||
<h1>Conferir e aprovar uma cotação</h1>
|
||||
<img class="shot" src="img/cotacoes.png" alt="Aba Cotações">
|
||||
<p class="caption">À esquerda, as cotações a revisar; à direita, a cotação selecionada.</p>
|
||||
<ol>
|
||||
<li>Abra a aba <b>Cotações</b> e clique na cotação em <b>A revisar</b>.</li>
|
||||
<li>Confira a montagem na miniatura. Se precisar, abra os arquivos em <b>Original</b> ou a lista de peças em <b>Manifesto</b>.</li>
|
||||
<li>Confira <b>Metros conferidos</b> e <b>Nota conferida</b>. Os valores do cliente já vêm preenchidos; mude só se
|
||||
estiverem errados. A nota define o preço do metro.</li>
|
||||
<li>Marque <b>Arquivos, metragem e nota conferidos</b> e clique em <b>Aprovar cotação</b>.</li>
|
||||
</ol>
|
||||
<p>O cliente vê o total aprovado no Site e tem <b>24 horas</b> para pagar. As aprovadas ficam em
|
||||
<b>Aprovadas, aguardando pagamento</b> até o pagamento chegar.</p>
|
||||
<div class="note"><b>Ressalva de resolução aceita pelo cliente</b>: alguma arte tem menos de 300 DPI e o cliente confirmou
|
||||
que quer imprimir assim. <b>Montagem antiga</b>: a cotação foi feita numa versão anterior do Site; peça ao cliente
|
||||
para enviar de novo.</div>
|
||||
<div class="folio">Página 4</div>
|
||||
</section>
|
||||
|
||||
<!-- 5 · Abrir um pedido -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PRODUÇÃO</p>
|
||||
<h1>Abrir um pedido</h1>
|
||||
<img class="shot" src="img/painel.png" alt="Painel do pedido">
|
||||
<p class="caption">Clique em qualquer card para abrir o painel do pedido.</p>
|
||||
<table>
|
||||
<tr><th style="width:36mm">Parte do painel</th><th>O que tem</th></tr>
|
||||
<tr><td class="k">Topo</td><td>Número, etapa atual, data do pagamento e os botões para mover o pedido.</td></tr>
|
||||
<tr><td class="k">Cliente e Entrega</td><td>E-mail, CNPJ e WhatsApp do cliente; endereço ou <b>Retirada em Franca</b>.</td></tr>
|
||||
<tr><td class="k">Itens e arquivos de impressão</td><td>Montagem de cada item, peças, tamanho da folha e nota. <b>Baixar PDF</b>
|
||||
baixa o arquivo pronto para o FlexiPRINT; <b>Original</b> baixa o arquivo que o cliente enviou; <b>Manifesto</b>
|
||||
lista as peças e suas posições.</td></tr>
|
||||
<tr><td class="k">Arquivos finais</td><td>O arquivo que vai ser impresso, aprovado por um operador (próxima página).</td></tr>
|
||||
<tr><td class="k">Histórico</td><td>Cada movimento do pedido, com hora, operador e motivo.</td></tr>
|
||||
</table>
|
||||
<div class="folio">Página 5</div>
|
||||
</section>
|
||||
|
||||
<!-- 6 · Arquivo final -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PRODUÇÃO</p>
|
||||
<h1>Aprovar o arquivo final</h1>
|
||||
<div class="split">
|
||||
<div>
|
||||
<p>Antes de ir para a <b>Fila de impressão</b>, todo item precisa de um arquivo final aprovado. O sistema já gera
|
||||
um PDF com a montagem que o cliente viu e pagou.</p>
|
||||
<ol>
|
||||
<li>Baixe o PDF em <b>Baixar PDF</b> e confira.</li>
|
||||
<li>Deixe marcado <b>Usar o PDF gerado</b>. Se preferir usar outro arquivo, desmarque e envie o seu.</li>
|
||||
<li>Escreva uma <b>Nota da revisão</b> (ex.: “Conferido, pronto para imprimir”).</li>
|
||||
<li>Marque <b>Arquivos conferidos</b> e clique em <b>Aprovar arquivos finais</b>.</li>
|
||||
</ol>
|
||||
<p>O card passa a mostrar <span class="chip">Final aprovado</span>.</p>
|
||||
</div>
|
||||
<div class="side"><img class="shot" src="img/arquivo-final.png" alt="Aprovação do arquivo final"></div>
|
||||
</div>
|
||||
<h2>Quando o PDF não é gerado</h2>
|
||||
<table>
|
||||
<tr><th style="width:40mm">No card ou no item</th><th>O que fazer</th></tr>
|
||||
<tr><td><span class="chip">Na fila para gerar</span></td><td>Aguarde alguns segundos e clique em <b>Atualizar</b>.</td></tr>
|
||||
<tr><td><span class="chip">Preparar à mão</span></td><td>O cliente enviou um formato que o gerador não lê (CDR, AI, PSD, TIFF)
|
||||
ou pediu correção. Baixe o <b>Original</b>, prepare o arquivo no seu programa e envie-o em <b>Arquivos finais</b>.</td></tr>
|
||||
<tr><td><span class="chip">Falhou ao gerar</span></td><td>Clique em <b>Gerar novamente</b>. Se falhar de novo, prepare à mão.</td></tr>
|
||||
</table>
|
||||
<div class="folio">Página 6</div>
|
||||
</section>
|
||||
|
||||
<!-- 7 · Mover pelas etapas -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PRODUÇÃO</p>
|
||||
<h1>Mover o pedido pelas etapas</h1>
|
||||
<p>Use o botão laranja <b>Mover para…</b> no topo do painel ou arraste o card para a coluna seguinte. Ao arrastar,
|
||||
só as colunas permitidas ficam destacadas.</p>
|
||||
<table>
|
||||
<tr><th style="width:27mm">Etapa</th><th>Significa</th><th style="width:43mm">Aviso ao cliente</th></tr>
|
||||
<tr><td class="k">Arte recebida</td><td>Pedido pago, arquivo gerado. Ainda não conferido.</td><td>Pedido aprovado (automático)</td></tr>
|
||||
<tr><td class="k">Arte tratada</td><td>Arquivo conferido e ajustado, se preciso.</td><td>—</td></tr>
|
||||
<tr><td class="k">Fila de impressão</td><td>Pronto para imprimir. Exige o arquivo final aprovado.</td><td>—</td></tr>
|
||||
<tr><td class="k">Imprimindo</td><td>Na máquina.</td><td>—</td></tr>
|
||||
<tr><td class="k">Finalizado</td><td>Impresso e pronto para retirada ou envio.</td><td>Pedido pronto para retirada</td></tr>
|
||||
<tr><td class="k">Correção</td><td>Algo no arquivo precisa ser resolvido pelo cliente.</td><td>O motivo aparece em Minha conta, no Site (página 8)</td></tr>
|
||||
</table>
|
||||
<div class="note">Os avisos por WhatsApp saem pelo número e pelas mensagens que a Dropstar já usa, a partir da situação
|
||||
do pedido no Tiny: o pedido pago fica <b>Aprovado</b> e, em <b>Finalizado</b>, o pedido de retirada fica
|
||||
<b>Pronto para envio</b>. Cada aviso sai <b>uma vez só</b>, mesmo que o pedido volte uma etapa e avance de novo.
|
||||
As outras etapas não mudam o pedido no Tiny. Enquanto essa integração não estiver ativa, avise o cliente como hoje.</div>
|
||||
<h2>Moveu errado? Volte uma etapa</h2>
|
||||
<img class="shot" src="img/voltar-etapa.png" alt="Botão Voltar para">
|
||||
<p class="caption">Botão <b>Voltar para…</b>: pede um motivo interno e não avisa o cliente.</p>
|
||||
<p>Clique em <b>Voltar para [etapa anterior]</b>, escreva o motivo (ex.: “movi por engano”) e confirme. O retorno fica
|
||||
no histórico como <b>Voltou para…</b>. O cliente não vê esse retorno nem o motivo.</p>
|
||||
<div class="folio">Página 7</div>
|
||||
</section>
|
||||
|
||||
<!-- 8 · Correção -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PRODUÇÃO</p>
|
||||
<h1>Pedir correção ao cliente</h1>
|
||||
<img class="shot" src="img/pedir-correcao.png" alt="Botão Pedir correção" style="width:78%">
|
||||
<p class="caption">Botão <b>Pedir correção</b>: o motivo vai para o cliente.</p>
|
||||
<ol>
|
||||
<li>No painel do pedido, clique em <b>Pedir correção</b>.</li>
|
||||
<li>Escreva o motivo de forma clara para o cliente (ex.: “A arte escudo.png está com fundo branco. Envie com fundo transparente.”).</li>
|
||||
<li>Clique em <b>Enviar para correção</b>. O pedido vai para a coluna <b>Correção</b>.</li>
|
||||
</ol>
|
||||
<p>O cliente vê o motivo em <b>Minha conta</b>, no Site, e envia por lá o arquivo corrigido. Esse aviso ainda não sai
|
||||
pelo WhatsApp: avise o cliente de que há uma correção pendente. O arquivo corrigido aparece em <b>Arquivos finais</b>
|
||||
como <b>Correção do cliente</b>. Confira, mova o pedido para <b>Arte recebida</b> ou <b>Arte tratada</b> e siga o fluxo normal.</p>
|
||||
<div class="note">Correção é para problemas que só o cliente resolve (resolução, fundo, arte errada). Ajustes que a equipe
|
||||
faz sozinha não precisam de correção: trate o arquivo em <b>Arte tratada</b>.</div>
|
||||
<h2>Histórico</h2>
|
||||
<img class="shot" src="img/historico.png" alt="Histórico do pedido" style="width:78%">
|
||||
<p class="caption">Cada movimento fica registrado com data, hora e operador.</p>
|
||||
<div class="folio">Página 8</div>
|
||||
</section>
|
||||
|
||||
<!-- 9 · Pagamentos e integrações -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PAGAMENTOS E INTEGRAÇÕES</p>
|
||||
<h1>Pagamentos com problema e integrações</h1>
|
||||
<p>A aba <b>Pagamentos</b> lista os pagamentos que chegaram mas não puderam virar pedido sozinhos. Exemplos:</p>
|
||||
<ul>
|
||||
<li>valor pago diferente do total da cotação;</li>
|
||||
<li>cotação vencida antes do pagamento (passou das 24 h);</li>
|
||||
<li>pagamento estornado ou cancelado depois que o pedido já existia.</li>
|
||||
</ul>
|
||||
<p>Resolva com o cliente ou no Mercado Pago e clique em <b>Registrar resolução</b>, descrevendo o que foi feito.
|
||||
O item passa para <b>Resolvidos</b>.</p>
|
||||
<img class="shot" src="img/pagamentos.png" alt="Aba Pagamentos">
|
||||
<h2>Integrações</h2>
|
||||
<img class="shot" src="img/integracoes.png" alt="Aba Integrações">
|
||||
<p class="caption">Imagem de um ambiente sem integrações ativas. Em produção, cada cartão mostra se a integração está conectada.</p>
|
||||
<ul>
|
||||
<li><b>Tiny</b>: o cartão mostra quem conectou, quando a conexão foi renovada e até quando vale; o sistema renova
|
||||
sozinho. Se aparecer <b>Não conectado</b> ou <b>Verificar</b>, clique em <b>Conectar Tiny</b> ou <b>Reconectar</b> e
|
||||
entre com o usuário do Tiny indicado pela Dropstar. <b>Testar conexão</b> confere pedidos, contatos, os produtos
|
||||
e a forma de envio de retirada, sem criar nada.</li>
|
||||
<li><b>Registro de envios</b>: tudo o que o sistema mandou ao Tiny e ao WhatsApp, com filtros por destino, situação,
|
||||
evento e pedido. <b>Com erro</b> indica um envio que falhou; o sistema tenta de novo sozinho, mas o erro precisa de atenção.</li>
|
||||
</ul>
|
||||
<div class="folio">Página 9</div>
|
||||
</section>
|
||||
|
||||
<!-- 10 · Dúvidas -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">DÚVIDAS FREQUENTES</p>
|
||||
<h1>Mensagens e o que fazer</h1>
|
||||
<table>
|
||||
<tr><th style="width:62mm">Mensagem ou situação</th><th>O que fazer</th></tr>
|
||||
<tr><td>“O pedido mudou. Clique em Atualizar.”</td><td>Outra pessoa mexeu no pedido ao mesmo tempo. Clique em <b>Atualizar</b> e repita a ação.</td></tr>
|
||||
<tr><td>“Aprove os arquivos finais de todos os itens antes de colocar na fila.”</td><td>Aprove o arquivo final de cada item (página 6) e tente de novo.</td></tr>
|
||||
<tr><td>“Informe o motivo da correção.” / “Informe por que o pedido está voltando de etapa.”</td><td>Preencha o motivo antes de confirmar.</td></tr>
|
||||
<tr><td>Cotação com <b>Montagem antiga</b></td><td>Peça ao cliente para enviar o pedido de novo pelo Site.</td></tr>
|
||||
<tr><td>Card com <b>Parado há mais de 4 h</b></td><td>Veja se o pedido está esperando alguém ou se esqueceram de movê-lo.</td></tr>
|
||||
<tr><td>Arquivo aparece como <b>expirado</b></td><td>Os arquivos ficam guardados por 30 dias. Depois disso, o cliente precisa enviar de novo.</td></tr>
|
||||
<tr><td>No topo, <b>Tiny: verificar conexão</b> ou <b>Tiny não conectado</b></td><td>Abra <b>Integrações</b> e siga a mensagem do cartão do Tiny. Se pedir, clique em <b>Reconectar</b>.</td></tr>
|
||||
<tr><td>A internet da fábrica caiu</td><td>O Site continua recebendo pedidos e pagamentos. Quando a conexão voltar, clique em <b>Atualizar</b>.</td></tr>
|
||||
</table>
|
||||
<h2>Boas práticas</h2>
|
||||
<ul>
|
||||
<li>Mova o card assim que a etapa mudar: é o que avisa o cliente e mede o tempo de produção.</li>
|
||||
<li>Escreva motivos que outra pessoa entenda sem perguntar.</li>
|
||||
<li>Use sempre o seu acesso: o histórico mostra quem fez cada movimento.</li>
|
||||
<li>Clique em <b>Sair</b> ao deixar o computador.</li>
|
||||
</ul>
|
||||
<div class="folio">Página 10</div>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
docs/guias/operador/img/arquivo-final.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/guias/operador/img/cotacoes.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
docs/guias/operador/img/historico.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/guias/operador/img/integracoes.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
docs/guias/operador/img/login.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/guias/operador/img/pagamentos.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/guias/operador/img/painel.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
docs/guias/operador/img/pedir-correcao.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/guias/operador/img/quadro.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/guias/operador/img/voltar-etapa.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
@@ -1,13 +1,17 @@
|
||||
# Same pinned base as deploy/Dockerfile.api, so the integration suite exercises
|
||||
# the image that ships rather than a different one.
|
||||
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
|
||||
# pg_dump and age are for the database backup (ops/db_backup.py). Debian 13
|
||||
# ships PostgreSQL 17, the server's major version, which pg_dump must match.
|
||||
RUN apt-get update \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client-17 age \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY infra/requirements.txt infra/requirements.lock /app/infra/
|
||||
RUN pip install --no-cache-dir --require-hashes -r infra/requirements.lock
|
||||
COPY app /app/app
|
||||
COPY ops /app/ops
|
||||
# The local image carries the suites so they can run inside the stack network.
|
||||
# deploy/Dockerfile.api deliberately does not: tests are not part of what ships.
|
||||
COPY tests /app/tests
|
||||
|
||||
13
infra/Dockerfile.browser-tests
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspace
|
||||
COPY tests /workspace/tests
|
||||
COPY web /workspace/web
|
||||
RUN mkdir -p /workspace/output/local \
|
||||
&& chown -R node:node /workspace/output
|
||||
USER node
|
||||
ENV CHROME_BIN=/usr/bin/chromium
|
||||
@@ -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
|
||||
|
||||
@@ -10,5 +10,6 @@ RUN apk upgrade --no-cache
|
||||
ENV WEB_INDEX=index.html
|
||||
COPY --from=policy /build/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
ENV S3_PUBLIC_ENDPOINT=http://localhost:9000
|
||||
COPY web/ /usr/share/nginx/html/
|
||||
# The HTML from the policy stage, with every asset address versioned.
|
||||
COPY --from=policy /build/web/ /usr/share/nginx/html/
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ TCPSocket 3310
|
||||
TCPAddr 0.0.0.0
|
||||
MaxThreads 2
|
||||
MaxQueue 8
|
||||
StreamMaxLength 128M
|
||||
MaxFileSize 128M
|
||||
MaxScanSize 256M
|
||||
MaxScanTime 120000
|
||||
StreamMaxLength 2000M
|
||||
MaxFileSize 2000M
|
||||
MaxScanSize 4000M
|
||||
MaxScanTime 900000
|
||||
ReadTimeout 900
|
||||
CommandReadTimeout 900
|
||||
AlertExceedsMax yes
|
||||
AlertEncrypted yes
|
||||
ScanPDF yes
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Compile the gateway configuration: hash any trusted inline script for the CSP.
|
||||
"""Compile the gateway configuration and version the Site's assets.
|
||||
|
||||
Inline scripts are hashed for the CSP; local scripts and stylesheets get a
|
||||
content hash in their address.
|
||||
|
||||
The Site's behaviour now lives in separate files, so normally there is nothing to
|
||||
hash and the policy is simply script-src 'self' — no allowlist to get wrong. The
|
||||
@@ -12,6 +15,20 @@ from pathlib import Path
|
||||
import re
|
||||
|
||||
root = Path('/build')
|
||||
|
||||
# Every local script and stylesheet is addressed by its content, so a release
|
||||
# can never pair new HTML with an old cached file: the proxy in front of the
|
||||
# stack caches assets for hours, and a new index.html calling an old script
|
||||
# broke the Site (2026-09-28). The HTML itself is served no-cache.
|
||||
def versioned(match):
|
||||
attribute, path = match.group(1), match.group(2)
|
||||
digest = hashlib.sha256((root / 'web' / path.lstrip('/')).read_bytes()).hexdigest()[:12]
|
||||
return f'{attribute}="{path}?v={digest}"'
|
||||
|
||||
for html in (root / 'web').glob('*.html'):
|
||||
text = re.sub(r'\b(src|href)="(/[\w./-]+\.(?:js|css))(?:\?[^"]*)?"', versioned, html.read_text())
|
||||
html.write_text(text)
|
||||
|
||||
hashes = []
|
||||
for html in (root / 'web').glob('*.html'):
|
||||
for attributes, script in re.findall(r'<script\b([^>]*)>(.*?)</script>', html.read_text(), re.S | re.I):
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; }
|
||||
server_name localhost site kanban;
|
||||
if ($host !~ ^(localhost|127\.0\.0\.1|site|kanban)$) { return 400; }
|
||||
root /usr/share/nginx/html;
|
||||
index ${WEB_INDEX};
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
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} ${PAYMENT_CHALLENGE_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self' ${PAYMENT_CHALLENGE_SOURCES}" always;
|
||||
location = /health { access_log off; return 200 'ok'; }
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=100 nodelay;
|
||||
@@ -19,12 +19,20 @@ server {
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
client_max_body_size 2m;
|
||||
}
|
||||
# The Site's product pages and cart are addresses of the same page (web/site-pages.js).
|
||||
location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho|pagamento|pagamento/pix)/?$ {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
# The customer's area: one page, which shows the right part for each address.
|
||||
location ~ ^/conta(/(entrar|pedidos|dados))?/?$ {
|
||||
try_files /portal.html =404;
|
||||
}
|
||||
location / { try_files $uri $uri/ =404; }
|
||||
}
|
||||
server {
|
||||
listen 81;
|
||||
server_name localhost;
|
||||
if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; }
|
||||
server_name localhost site kanban;
|
||||
if ($host !~ ^(localhost|127\.0\.0\.1|site|kanban)$) { return 400; }
|
||||
client_max_body_size 2m;
|
||||
location / {
|
||||
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
|
||||
|
||||
@@ -21,4 +21,13 @@ printf '%s' "$policy" > /tmp/policy.json
|
||||
mc admin policy create local dtf-artwork /tmp/policy.json >/dev/null
|
||||
mc admin policy attach local dtf-artwork --user "$S3_APP_USER" >/dev/null
|
||||
mc ilm import "local/$S3_BUCKET" < /lifecycle.json
|
||||
# The backup bucket and its own account, which can write and read backups but
|
||||
# not delete them: the same separation as the backup token on R2.
|
||||
case "$S3_BACKUP_BUCKET" in *[!a-z0-9.-]*|'') echo 'Invalid local backup bucket name' >&2; exit 1;; esac
|
||||
mc mb --ignore-existing "local/$S3_BACKUP_BUCKET" >/dev/null
|
||||
mc anonymous set none "local/$S3_BACKUP_BUCKET" >/dev/null
|
||||
mc admin user add local "$S3_BACKUP_USER" "$S3_BACKUP_PASSWORD" >/dev/null
|
||||
printf '%s' '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:ListBucket","s3:GetBucketLocation"],"Resource":["arn:aws:s3:::'"$S3_BACKUP_BUCKET"'"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],"Resource":["arn:aws:s3:::'"$S3_BACKUP_BUCKET"'/*"]}]}' > /tmp/backup-policy.json
|
||||
mc admin policy create local dtf-backup /tmp/backup-policy.json >/dev/null
|
||||
mc admin policy attach local dtf-backup --user "$S3_BACKUP_USER" >/dev/null
|
||||
echo 'Local storage runtime account provisioned.'
|
||||
|
||||
@@ -9,9 +9,10 @@ from uuid import uuid4
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
BACKUPS = ROOT / 'backups'
|
||||
COMPOSE = ['docker','compose','-f','compose.local.yaml']
|
||||
|
||||
def docker(script, *args, **kwargs):
|
||||
return subprocess.run(['docker','compose','exec','-T','db','sh','-c',script,'sh',*args],
|
||||
return subprocess.run([*COMPOSE,'exec','-T','db','sh','-c',script,'sh',*args],
|
||||
cwd=ROOT,check=True,**kwargs)
|
||||
|
||||
def checksum(path):
|
||||
@@ -21,7 +22,7 @@ def checksum(path):
|
||||
return digest.hexdigest()
|
||||
|
||||
def compose_exec(service, *command, **kwargs):
|
||||
return subprocess.run(['docker','compose','exec','-T',service,*command],
|
||||
return subprocess.run([*COMPOSE,'exec','-T',service,*command],
|
||||
cwd=ROOT,check=True,**kwargs)
|
||||
|
||||
def create():
|
||||
@@ -38,7 +39,7 @@ def create():
|
||||
docker('pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom',stdout=stream)
|
||||
with objects.open('xb') as stream:
|
||||
created.append(objects);objects.chmod(0o600)
|
||||
result=compose_exec('api','python','-m','local.storage_backup','export',
|
||||
result=compose_exec('api','python','-m','ops.storage_backup','export',
|
||||
stdout=stream,stderr=subprocess.PIPE)
|
||||
summaries=[line.removeprefix('DTF_BACKUP_SUMMARY ') for line in
|
||||
result.stderr.decode().splitlines() if line.startswith('DTF_BACKUP_SUMMARY ')]
|
||||
@@ -111,7 +112,7 @@ def verify(path):
|
||||
if had_legacy:legacy_sidecar.write_bytes(previous)
|
||||
else:legacy_sidecar.unlink(missing_ok=True)
|
||||
with objects.open('rb') as stream:
|
||||
compose_exec('api','python','-m','local.storage_backup','verify',stdin=stream)
|
||||
compose_exec('api','python','-m','ops.storage_backup','verify',stdin=stream)
|
||||
print('PASS: combined database and clean-object backup verified. Active data was untouched.')
|
||||
|
||||
if __name__=='__main__':
|
||||
|
||||
289
ops/db_backup.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""Daily off-server backup of the database to its own bucket.
|
||||
|
||||
python -m ops.db_backup serve # the stack's backup service
|
||||
python -m ops.db_backup once # one backup now
|
||||
python -m ops.db_backup list # what the bucket holds
|
||||
python -m ops.db_backup verify --identity F # restore the latest into a scratch database, then drop it
|
||||
python -m ops.db_backup restore KEY --identity F --into NAME
|
||||
|
||||
Each backup is `pg_dump --format=custom`, checked with `pg_restore --list`,
|
||||
encrypted with age to BACKUP_AGE_RECIPIENT (a public key) and uploaded to
|
||||
BACKUP_BUCKET with credentials of its own. The server holds only the public
|
||||
key: it can write backups but not read them. The private key (the identity)
|
||||
stays with the owner, off the server, and is needed only to restore.
|
||||
|
||||
Old backups are removed by the bucket's lifecycle rule, not by this job, so
|
||||
its credential never needs to delete; a bucket lock keeps a stolen one from
|
||||
deleting either. Every run is recorded in dtf_local.backups, which the Kanban
|
||||
shows on its Integrations tab.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import boto3
|
||||
import psycopg
|
||||
from botocore.config import Config
|
||||
from psycopg import sql
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
|
||||
from app.bootstrap import admin_connect
|
||||
from app.core.secrets import load as load_secret_files
|
||||
|
||||
BRASILIA = timezone(timedelta(hours=-3))
|
||||
PREFIX = 'db/'
|
||||
# After a failure the next attempt comes this much later, not a day later.
|
||||
RETRY = timedelta(hours=1)
|
||||
# A backup older than this means the daily run has stopped working.
|
||||
STALE = timedelta(hours=26)
|
||||
TABLES = ('orders', 'quotes', 'uploads', 'accounts', 'movements', 'payment_intents')
|
||||
|
||||
|
||||
def configured():
|
||||
return all(os.environ.get(k) for k in
|
||||
('BACKUP_S3_ENDPOINT', 'BACKUP_BUCKET', 'BACKUP_ACCESS_KEY_ID',
|
||||
'BACKUP_SECRET_ACCESS_KEY', 'BACKUP_AGE_RECIPIENT'))
|
||||
|
||||
|
||||
def bucket():
|
||||
client = boto3.client('s3', endpoint_url=os.environ['BACKUP_S3_ENDPOINT'],
|
||||
aws_access_key_id=os.environ['BACKUP_ACCESS_KEY_ID'],
|
||||
aws_secret_access_key=os.environ['BACKUP_SECRET_ACCESS_KEY'],
|
||||
region_name=os.environ.get('BACKUP_REGION', 'auto'),
|
||||
config=Config(signature_version='s3v4', s3={'addressing_style': 'path'},
|
||||
retries={'max_attempts': 5, 'mode': 'standard'}))
|
||||
return client, os.environ['BACKUP_BUCKET']
|
||||
|
||||
|
||||
def admin_params(database=None):
|
||||
"""The administrator's connection, optionally to another database."""
|
||||
if os.environ.get('DATABASE_ADMIN_HOST'):
|
||||
params = {'host': os.environ['DATABASE_ADMIN_HOST'], 'user': os.environ['DATABASE_ADMIN_USER'],
|
||||
'password': os.environ['DATABASE_ADMIN_PASSWORD'], 'dbname': os.environ['DATABASE_ADMIN_NAME']}
|
||||
else:
|
||||
params = conninfo_to_dict(os.environ['DATABASE_ADMIN_URL'])
|
||||
if database:
|
||||
params['dbname'] = database
|
||||
return params
|
||||
|
||||
|
||||
def libpq_env(database=None):
|
||||
"""pg_dump and pg_restore read the credentials from the environment, so the
|
||||
password never appears in a process list."""
|
||||
names = {'host': 'PGHOST', 'port': 'PGPORT', 'user': 'PGUSER', 'password': 'PGPASSWORD', 'dbname': 'PGDATABASE'}
|
||||
return {**os.environ, **{names[k]: str(v) for k, v in admin_params(database).items() if k in names}}
|
||||
|
||||
|
||||
def run(command, **kwargs):
|
||||
result = subprocess.run(command, capture_output=True, **kwargs)
|
||||
if result.returncode:
|
||||
detail = result.stderr.decode(errors='replace').strip().splitlines()
|
||||
raise RuntimeError(f'{command[0]} failed: ' + (detail[-1] if detail else f'exit {result.returncode}'))
|
||||
return result
|
||||
|
||||
|
||||
def sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, 'rb') as stream:
|
||||
for block in iter(lambda: stream.read(1 << 20), b''):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def record(started, status, key=None, size=None, detail=None):
|
||||
with admin_connect() as c:
|
||||
c.execute('''INSERT INTO dtf_local.backups(id,started_at,finished_at,status,object_key,bytes,detail)
|
||||
VALUES(%s,%s,now(),%s,%s,%s,%s)''',
|
||||
(uuid4(), started, status, key, size, detail))
|
||||
|
||||
|
||||
def run_once():
|
||||
"""Dump, check, encrypt and upload one backup. Returns its object key."""
|
||||
started = datetime.now(timezone.utc)
|
||||
key = PREFIX + started.strftime('%Y/%m/dtf-%Y%m%dT%H%M%SZ') + '.dump.age'
|
||||
try:
|
||||
client, name = bucket()
|
||||
with tempfile.TemporaryDirectory(dir=os.environ.get('BACKUP_TMP')) as work:
|
||||
dump, sealed = Path(work, 'dtf.dump'), Path(work, 'dtf.dump.age')
|
||||
run(['pg_dump', '--format=custom', '--compress=6', '--file', str(dump)], env=libpq_env())
|
||||
# A truncated or damaged archive fails here, before it is kept.
|
||||
run(['pg_restore', '--list', str(dump)])
|
||||
run(['age', '--encrypt', '--recipient', os.environ['BACKUP_AGE_RECIPIENT'],
|
||||
'--output', str(sealed), str(dump)])
|
||||
size = sealed.stat().st_size
|
||||
client.upload_file(str(sealed), name, key, ExtraArgs={'Metadata': {
|
||||
'sha256': sha256(sealed), 'dump-sha256': sha256(dump), 'format': 'pg-custom+age'}})
|
||||
record(started, 'ok', key, size)
|
||||
print(f'Backup {key} uploaded ({size} bytes).', flush=True)
|
||||
return key
|
||||
except Exception as error:
|
||||
# The message names a command or a provider error, never a credential.
|
||||
detail = str(error)[:500]
|
||||
try:
|
||||
record(started, 'failed', key, None, detail)
|
||||
finally:
|
||||
print(f'Backup failed: {detail}', file=sys.stderr, flush=True)
|
||||
raise
|
||||
|
||||
|
||||
def last_ok():
|
||||
with admin_connect() as c:
|
||||
row = c.execute("SELECT max(finished_at) FROM dtf_local.backups WHERE status='ok'").fetchone()
|
||||
return row[0]
|
||||
|
||||
|
||||
def next_run(now, hour):
|
||||
"""The next `hour` o'clock in Brasília after `now`."""
|
||||
local = now.astimezone(BRASILIA)
|
||||
at = local.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
if at <= local:
|
||||
at += timedelta(days=1)
|
||||
return at.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def serve():
|
||||
if not configured():
|
||||
print('Backup not configured: set BACKUP_S3_ENDPOINT, BACKUP_BUCKET, BACKUP_ACCESS_KEY_ID, '
|
||||
'BACKUP_SECRET_ACCESS_KEY and BACKUP_AGE_RECIPIENT. Idle.', flush=True)
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
hour = int(os.environ.get('BACKUP_HOUR', '3'))
|
||||
# Wait for the schema (db-init) before the first query.
|
||||
for _ in range(60):
|
||||
try:
|
||||
previous = last_ok()
|
||||
break
|
||||
except psycopg.Error:
|
||||
time.sleep(10)
|
||||
else:
|
||||
previous = last_ok()
|
||||
now = datetime.now(timezone.utc)
|
||||
# A fresh deployment, or one that missed a day, backs up straight away.
|
||||
due = now if previous is None or now - previous > STALE else next_run(now, hour)
|
||||
while True:
|
||||
time.sleep(max(0, (due - datetime.now(timezone.utc)).total_seconds()))
|
||||
try:
|
||||
run_once()
|
||||
due = next_run(datetime.now(timezone.utc), hour)
|
||||
except Exception:
|
||||
due = datetime.now(timezone.utc) + RETRY
|
||||
|
||||
|
||||
def listing():
|
||||
client, name = bucket()
|
||||
keys = []
|
||||
for page in client.get_paginator('list_objects_v2').paginate(Bucket=name, Prefix=PREFIX):
|
||||
keys += [(o['Key'], o['Size'], o['LastModified']) for o in page.get('Contents', [])]
|
||||
return sorted(keys)
|
||||
|
||||
|
||||
def counts(database):
|
||||
with psycopg.connect(**admin_params(database)) as c:
|
||||
return {t: c.execute(sql.SQL('SELECT count(*) FROM dtf_local.{}').format(sql.Identifier(t))).fetchone()[0]
|
||||
for t in TABLES}
|
||||
|
||||
|
||||
def restore(key, identity, into):
|
||||
"""Restore one backup into a database that is new or holds no DTF data."""
|
||||
client, name = bucket()
|
||||
with admin_connect() as c:
|
||||
c.autocommit = True
|
||||
if into == c.execute('SELECT current_database()').fetchone()[0]:
|
||||
raise SystemExit('Refusing to restore over the database the stack is using.')
|
||||
exists = c.execute('SELECT 1 FROM pg_database WHERE datname=%s', (into,)).fetchone()
|
||||
if not exists:
|
||||
c.execute(sql.SQL('CREATE DATABASE {}').format(sql.Identifier(into)))
|
||||
if exists:
|
||||
with psycopg.connect(**admin_params(into)) as c:
|
||||
if c.execute("SELECT 1 FROM pg_namespace WHERE nspname='dtf_local'").fetchone():
|
||||
raise SystemExit(f'{into} already holds DTF data; choose an empty or new database.')
|
||||
with tempfile.TemporaryDirectory(dir=os.environ.get('BACKUP_TMP')) as work:
|
||||
sealed, dump = Path(work, 'dtf.dump.age'), Path(work, 'dtf.dump')
|
||||
client.download_file(name, key, str(sealed))
|
||||
expected = client.head_object(Bucket=name, Key=key)['Metadata'].get('sha256')
|
||||
if expected and sha256(sealed) != expected:
|
||||
raise SystemExit('The downloaded backup does not match its checksum.')
|
||||
run(['age', '--decrypt', '--identity', identity, '--output', str(dump), str(sealed)])
|
||||
run(['pg_restore', '--exit-on-error', '--no-owner', '--no-privileges', '--dbname', into, str(dump)],
|
||||
env=libpq_env(into))
|
||||
return counts(into)
|
||||
|
||||
|
||||
def drop(database):
|
||||
with admin_connect() as c:
|
||||
c.autocommit = True
|
||||
c.execute(sql.SQL('DROP DATABASE IF EXISTS {} WITH (FORCE)').format(sql.Identifier(database)))
|
||||
|
||||
|
||||
def verify(identity, key=None):
|
||||
"""The restore test: the latest backup into a scratch database, counted, dropped."""
|
||||
key = key or listing()[-1][0]
|
||||
scratch = 'dtf_verify_' + uuid4().hex[:12]
|
||||
try:
|
||||
restored = restore(key, identity, scratch)
|
||||
finally:
|
||||
drop(scratch)
|
||||
print(f'PASS: {key} decrypted and restored into a scratch database (now dropped). Rows: ' +
|
||||
', '.join(f'{t} {n}' for t, n in restored.items()))
|
||||
return restored
|
||||
|
||||
|
||||
def identity_file(value):
|
||||
"""The identity as a path, or '-' to paste it: nothing is written to disk
|
||||
except a private temporary file removed when the command ends."""
|
||||
if value != '-':
|
||||
return value, None
|
||||
print('Paste the private key (AGE-SECRET-KEY-...) and press Enter:', file=sys.stderr)
|
||||
line = sys.stdin.readline().strip()
|
||||
handle = tempfile.NamedTemporaryFile('w', prefix='identity-', dir=os.environ.get('BACKUP_TMP'), delete=False)
|
||||
os.chmod(handle.name, 0o600)
|
||||
handle.write(line + '\n')
|
||||
handle.close()
|
||||
return handle.name, handle.name
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
load_secret_files()
|
||||
parser = argparse.ArgumentParser(prog='python -m ops.db_backup')
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
sub.add_parser('serve')
|
||||
sub.add_parser('once')
|
||||
sub.add_parser('list')
|
||||
for name in ('verify', 'restore'):
|
||||
p = sub.add_parser(name)
|
||||
if name == 'restore':
|
||||
p.add_argument('key')
|
||||
p.add_argument('--into', required=True, help='a new or empty database')
|
||||
else:
|
||||
p.add_argument('key', nargs='?', help='default: the latest backup')
|
||||
p.add_argument('--identity', required=True, help="the private key file, or '-' to paste it")
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == 'serve':
|
||||
serve()
|
||||
elif args.command == 'once':
|
||||
run_once()
|
||||
elif args.command == 'list':
|
||||
for key, size, modified in listing():
|
||||
print(f'{modified:%Y-%m-%d %H:%M} UTC {size:>12} {key}')
|
||||
else:
|
||||
path, temporary = identity_file(args.identity)
|
||||
try:
|
||||
if args.command == 'verify':
|
||||
verify(path, args.key)
|
||||
else:
|
||||
rows = restore(args.key, path, args.into)
|
||||
print(f'Restored {args.key} into {args.into}. Rows: ' + ', '.join(f'{t} {n}' for t, n in rows.items()))
|
||||
finally:
|
||||
if temporary:
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -96,5 +96,5 @@ def main(path: Path) -> int:
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit('usage: python -m local.staging_readiness PATH')
|
||||
raise SystemExit('usage: python -m ops.staging_readiness PATH')
|
||||
raise SystemExit(main(Path(sys.argv[1])))
|
||||
|
||||
@@ -188,5 +188,5 @@ def verify_archive():
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in ('export', 'verify'):
|
||||
raise SystemExit('usage: python -m local.storage_backup export|verify')
|
||||
raise SystemExit('usage: python -m ops.storage_backup export|verify')
|
||||
export_archive() if sys.argv[1] == 'export' else verify_archive()
|
||||
|
||||
@@ -13,6 +13,30 @@ const html=await readFile('web/index.html','utf8');
|
||||
const hashes=[...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
|
||||
.filter(m=>! /\bsrc\s*=/i.test(m[1]))
|
||||
.map(m=>"'sha256-"+createHash('sha256').update(m[2]).digest('base64')+"'");
|
||||
function pdfFixture({pages=1,media='[0 0 720 360]',crop='',rotate=0,unit=1}={}){
|
||||
const objects=[
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids ['+Array.from({length:pages},(_,i)=>i+3+' 0 R').join(' ')+
|
||||
'] /Count '+pages+' /MediaBox '+media+' >>',
|
||||
...Array.from({length:pages},()=> '<< /Type /Page /Parent 2 0 R'+
|
||||
(crop?' /CropBox '+crop:'')+(rotate?' /Rotate '+rotate:'')+
|
||||
(unit!==1?' /UserUnit '+unit:'')+' >>')
|
||||
];
|
||||
const chunks=['%PDF-1.6\n%\xE2\xE3\xCF\xD3\n'], offsets=[0];
|
||||
let length=Buffer.byteLength(chunks[0],'latin1');
|
||||
for(let i=0;i<objects.length;i++){
|
||||
offsets.push(length);
|
||||
const part=(i+1)+' 0 obj\n'+objects[i]+'\nendobj\n';
|
||||
chunks.push(part);length+=Buffer.byteLength(part,'latin1');
|
||||
}
|
||||
const xref=length;
|
||||
chunks.push('xref\n0 '+(objects.length+1)+'\n0000000000 65535 f \n');
|
||||
for(const offset of offsets.slice(1))chunks.push(String(offset).padStart(10,'0')+' 00000 n \n');
|
||||
chunks.push('trailer\n<< /Size '+(objects.length+1)+
|
||||
' /Root 1 0 R >>\nstartxref\n'+xref+'\n%%EOF\n');
|
||||
return Buffer.from(chunks.join(''),'latin1');
|
||||
}
|
||||
const pdfData=options=>JSON.stringify(pdfFixture(options).toString('base64'));
|
||||
const server=createServer(async(req,res)=>{
|
||||
if(req.url.startsWith('/api/')){
|
||||
res.setHeader('Content-Type','application/json');
|
||||
@@ -27,7 +51,7 @@ const server=createServer(async(req,res)=>{
|
||||
// Serve any script the page asks for, resolved inside web/, rather than
|
||||
// a hardcoded list: the Site's behaviour is split across several files and a
|
||||
// list would silently 404 the next one added.
|
||||
if(/^\/[\w.-]+\.js$/.test(req.url)){
|
||||
if(/^\/[\w.-]+\.js$/.test(req.url) || /^\/vendor\/pdf\.(worker\.)?min\.js$/.test(req.url)){
|
||||
try{
|
||||
const body=await readFile(resolve('web'+req.url));
|
||||
res.setHeader('Content-Type','text/javascript');res.end(body);return;
|
||||
@@ -39,6 +63,7 @@ await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
||||
const profile=await mkdtemp(tmpdir()+'/dtf-artwork-');
|
||||
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
|
||||
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
|
||||
...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]),
|
||||
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
|
||||
],{stdio:['ignore','ignore','pipe']});
|
||||
let stderr='',ws,next=0;
|
||||
@@ -90,7 +115,7 @@ try{
|
||||
await call('Runtime.enable');await call('Page.enable');
|
||||
await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false});
|
||||
await call('Page.navigate',{url:`http://127.0.0.1:${server.address().port}/`});
|
||||
await waitFor(()=>evaluate(`typeof sel==='function' && typeof AUTO!=='undefined'`),'Site scripts');
|
||||
await waitFor(()=>evaluate(`typeof sel==='function' && typeof pintaEntrega==='function'`),'Site scripts');
|
||||
// Observe the actual engine, without replacing its placement or rendering.
|
||||
await evaluate(`(()=>{
|
||||
const original=encaixar;
|
||||
@@ -124,17 +149,51 @@ try{
|
||||
// Navigation links must reach the chooser, never preselect a product.
|
||||
assert.equal(await evaluate(`document.querySelectorAll('[data-modo-cta]').length`),0);
|
||||
await click('[data-modo="file"]');
|
||||
// Opening a product paints on the next frame; on a busy runner the check
|
||||
// must wait for it rather than race it.
|
||||
await waitFor(()=>evaluate(`!$('tipoEnvio').hidden`),'by-metre product open');
|
||||
// By-metre opens declaring a mounted sheet; switching is explicit and priced.
|
||||
assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden,
|
||||
on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`),
|
||||
{shown:true,on:['folha']});
|
||||
await evaluate(`(()=>{const f=new File(['x'],'oversize.cdr');Object.defineProperty(f,'size',{value:5*1073741824+1});sel([f]);})()`);
|
||||
assert.equal(await evaluate(`folhas.length===0 && $('recusa').textContent.includes('5 GB')`),true,
|
||||
'files beyond the 5 GB upload limit are rejected before browser analysis');
|
||||
// A multi-GB sheet is the normal order: graded from the pixel size in its
|
||||
// header, never decoded. 6732 x 35433 px across 57 cm is 3 m at 300 DPI.
|
||||
await evaluate(`(()=>{const h=new Uint8Array(33);h.set([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0,0,0,13,0x49,0x48,0x44,0x52]);
|
||||
const v=new DataView(h.buffer);v.setUint32(16,6732);v.setUint32(20,35433);h.set([8,6,0,0,0],24);
|
||||
const f=new File([h],'folha-grande.png');Object.defineProperty(f,'size',{value:3*1073741824});sel([f]);})()`);
|
||||
await waitFor(()=>evaluate(`folhas.length===1 && !!folhas[0].an`),'large sheet graded from its header');
|
||||
assert.deepEqual(await evaluate(`({grande:folhas[0].an.grande,dpi:folhas[0].an.dpi,alt:folhas[0].med.alt,preview:!!folhas[0].previewSrc})`),
|
||||
{grande:true,dpi:300,alt:300,preview:false});
|
||||
await evaluate(`folhas=[];pintaFolha()`);
|
||||
await evaluate(`pickTipo('avulsa')`);
|
||||
assert.equal(await evaluate('modo'),'avulsa');
|
||||
// A large JPG artwork cannot be read in parts: it is measured from its
|
||||
// header and placed as a full box, with the discount, instead of refused.
|
||||
await evaluate(`(()=>{const h=new Uint8Array(20);h.set([0xFF,0xD8,0xFF,0xC0,0,17,8,0x09,0x3A,0x09,0x3A,3]);
|
||||
const f=new File([h],'arte-grande.jpg');Object.defineProperty(f,'size',{value:400*1048576});sel([f]);})()`);
|
||||
await waitFor(()=>evaluate(`artes.length===1 && artes[0].semPrevia===true`),'large JPG artwork measured from its header');
|
||||
await fill('[data-cm]',20);
|
||||
await waitFor(()=>evaluate(`!!itemAtual?.production?.sources?.length`),'large JPG artwork packed');
|
||||
assert.deepEqual(await evaluate(`({dpi:Math.round(dpiDe(artes[0])),src:!!artes[0].src,
|
||||
measurement:itemAtual.production.sources[0].measurement,note:$('lista').textContent.includes('Sem prévia')})`),
|
||||
{dpi:300,src:false,measurement:'file',note:true});
|
||||
await evaluate(`artes=[];pintaArtes()`);
|
||||
await evaluate('sendImage()');
|
||||
await waitFor(()=>evaluate(`artes.length===1 && !!artes[0].src`),'JPG artwork model');
|
||||
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});
|
||||
await fill('[data-cm]',20);await fill('[data-q]',6);
|
||||
let layout=await packed(6);
|
||||
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.copies===6`),'per-file production record');
|
||||
assert.deepEqual(await evaluate(`({version:itemAtual.production.version,source:itemAtual.production.sources[0]})`),
|
||||
{version:2,source:{kind:'artwork',width_cm:20,length_cm:40,copies:6,
|
||||
rotation_degrees:0,mirrored:false,measurement:'file'}});
|
||||
assert.equal(await evaluate('itemAtual.production.placements.length'),6);
|
||||
assert.equal(await evaluate('itemAtual.production.height_cm'),121);
|
||||
assert.deepEqual(await evaluate('itemAtual.production.placements.map(p=>[p.source_index,p.copy_index,p.x_cm,p.y_cm])'),
|
||||
[[0,0,0,0],[0,1,20.5,0],[0,2,0,40.5],[0,3,20.5,40.5],[0,4,0,81],[0,5,20.5,81]]);
|
||||
assert.equal(layout.heading,'Montagem ao vivo');assert.equal(layout.height,121);
|
||||
assert.equal(layout.billed,1.3);
|
||||
assert.deepEqual(layout.pos.map(p=>[p.x,p.y]),[[0,0],[20.5,0],[0,40.5],[20.5,40.5],[0,81],[20.5,81]]);
|
||||
@@ -149,6 +208,32 @@ try{
|
||||
await click('[data-esp]');
|
||||
await waitFor(()=>evaluate(`$('vArea').querySelector('canvas').toDataURL()!==${JSON.stringify(beforeMirror)}`),'mirror updates pixels');
|
||||
assert.equal(await evaluate('metros'),0.235);
|
||||
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.mirrored===true`),'transforms in production record');
|
||||
assert.deepEqual(await evaluate(`({rotation:itemAtual.production.sources[0].rotation_degrees,copies:itemAtual.production.sources[0].copies})`),
|
||||
{rotation:90,copies:9});
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='rejected'`),'low resolution refusal');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false);
|
||||
await fill('[data-cm]',3);
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='ok'`),'rotated DPI uses image height');
|
||||
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),339);
|
||||
await click('[data-giro]');
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'unrotated resolution warning');
|
||||
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),169);
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false);
|
||||
await click('#cienteOk');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),true);
|
||||
await fill('[data-q]',8);
|
||||
assert.equal(await evaluate('itemAtual===null'),true,'editing invalidates the old cart immediately');
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'edited warning');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false,'acknowledgement does not survive an edit');
|
||||
await fill('[data-cm]',58);
|
||||
await waitFor(()=>evaluate(`itemAtual===null && artes[0].cm===58`),'oversized width rejected');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false);
|
||||
assert.equal(await evaluate(`(()=>{try{encaixar([{w:58,h:10,img:null}],57);return false}catch(error){return error instanceof RangeError}})()`),true,
|
||||
'packing engine must not shrink an oversized source');
|
||||
await click('[data-rm]');
|
||||
assert.equal(await evaluate(`artes.length===0 && itemAtual===null && !cartPodeEnviar()`),true,
|
||||
'removed artwork cannot remain in the cart');
|
||||
// A transparent asymmetric image exposes masks that ignore user transforms.
|
||||
// Its top-left quarter becomes bottom-right after a mirror and 90° turn.
|
||||
assert.equal(await evaluate(`(()=>{
|
||||
@@ -169,6 +254,8 @@ try{
|
||||
await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await waitFor(()=>evaluate('metros===0.684'),'ready-sheet repetitions');
|
||||
assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1);
|
||||
await evaluate(`(()=>{folhas.push({f:new File(['manual'],'manual.cdr'),med:null,rep:1,m:1,an:null});pintaFolha();})()`);
|
||||
await waitFor(()=>evaluate(`itemAtual?.nota===0 && itemAtual?.unit===TABELA[modo]`),'mixed analyzed and manual sheets use table pricing');
|
||||
// An image too small to span the film is refused, never silently repriced.
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
await evaluate('sendImage()');
|
||||
@@ -182,6 +269,33 @@ try{
|
||||
await waitFor(()=>evaluate(`!!$('lista').querySelector('[data-comp]')`),'manual ready sheet');
|
||||
await evaluate(`(()=>{const el=$('lista').querySelector('[data-comp]');el.value=1.01;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await waitFor(()=>evaluate('itemAtual?.total===21.89'),'unchanged manual pricing');
|
||||
await evaluate(`folhas[0].semAnalise='<img src=x onerror=alert(1)>';pintaFolha()`);
|
||||
assert.equal(await evaluate(`!$('lista').querySelector('img') && $('lista').textContent.includes('<img src=x')`),true,
|
||||
'PDF parser errors are text, never executable markup');
|
||||
// Parsed geometry honors inherited MediaBox, CropBox, rotation and UserUnit.
|
||||
// Extra pages and unreadable PDFs must never fall through to manual pricing.
|
||||
const pdfFile=(data,name='sheet.pdf')=>`new File([Uint8Array.from(atob(${data}),c=>c.charCodeAt(0))],${JSON.stringify(name)},{type:'application/pdf'})`;
|
||||
const rotated=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))})`);
|
||||
assert.deepEqual(rotated,{larg:50.8,alt:25.4,fonte:'página do PDF · UserUnit 2'});
|
||||
const rendered=await evaluate(`rasterizarPdf(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))},50.8,25.4).then(r=>({ok:!!r.tela,error:r.erro||null}))`);
|
||||
assert.deepEqual(rendered,{ok:true,error:null});
|
||||
const multi=await evaluate(`medirFolha(${pdfFile(pdfData({pages:2}))})`);
|
||||
assert.equal(multi.rejected,true);assert.match(multi.reason,/2 páginas/);
|
||||
const beyondSpec=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 20000 720]'}))})`);
|
||||
assert.equal(beyondSpec.rejected,true);assert.match(beyondSpec.reason,/508 cm/);
|
||||
const broken=await evaluate(`medirFolha(new File(['broken'],'broken.pdf',{type:'application/pdf'}))`);
|
||||
assert.equal(broken.rejected,true);
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
await evaluate(`sel([${pdfFile(pdfData({pages:2}),'two-pages.pdf')}])`);
|
||||
await waitFor(()=>evaluate(`folhas.length===1 && !!folhas[0].measurementError`),'multipage PDF refusal');
|
||||
assert.equal(await evaluate(`avaliar().pronto`),false);
|
||||
assert.equal(await evaluate(`cartPodeEnviar()`),false);
|
||||
assert.match(await evaluate(`$('lista').textContent`),/não pode ser orçado/);
|
||||
await click('#bVoltar');await click('[data-modo="avulsa"]');
|
||||
await evaluate(`sel([new File(['not an image'],'broken.png',{type:'image/png'})])`);
|
||||
await waitFor(()=>evaluate('artes.length===1 && artes[0].decodeError===true'),'failed image decode');
|
||||
await fill('[data-cm]',20);
|
||||
await waitFor(()=>evaluate('itemAtual===null && !cartPodeEnviar()'),'undecodable image cannot be quoted');
|
||||
// PNG drag/drop follows exactly the same artwork path; UV stays UV. Reaching it
|
||||
// from a by-metre product is one declared click, and it is reversible.
|
||||
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv'],['uv','uv']]){
|
||||
@@ -193,14 +307,30 @@ try{
|
||||
await fill('[data-cm]',10);await fill('[data-q]',4);await packed(4);
|
||||
}
|
||||
// An older image load must not overwrite a newer edit, even before debounce.
|
||||
await evaluate(`(()=>{window.realLoad=carregarImagem;window.delayed=[];
|
||||
carregarImagem=f=>new Promise(resolve=>delayed.push(()=>realLoad(f).then(resolve)));
|
||||
// The packing loads each artwork's image through imagemDaArte.
|
||||
await evaluate(`(()=>{window.realLoad=imagemDaArte;window.delayed=[];
|
||||
imagemDaArte=a=>new Promise(resolve=>delayed.push(()=>realLoad(a).then(resolve)));
|
||||
})()`);
|
||||
await fill('[data-q]',5);
|
||||
await waitFor(()=>evaluate('delayed.length===1'),'delayed preview');
|
||||
await fill('[data-q]',7);
|
||||
await evaluate(`carregarImagem=realLoad;delayed[0]()`);
|
||||
await evaluate(`imagemDaArte=realLoad;delayed[0]()`);
|
||||
await packed(7);
|
||||
const timeoutCleanup=await evaluate(`(async()=>{
|
||||
const realLoader=carregarPdfJs, realTimer=setTimeout, realWorker=temWorker;
|
||||
let destroyed=false, fail;
|
||||
carregarPdfJs=async()=>({getDocument:()=>({
|
||||
promise:new Promise((_,reject)=>{fail=reject}),
|
||||
destroy:()=>{destroyed=true;fail(new Error('cancelled'));return Promise.resolve()}
|
||||
})});
|
||||
temWorker=true;
|
||||
window.setTimeout=(fn,ms)=>realTimer(fn,ms===30000?1:ms);
|
||||
try{
|
||||
const result=await rasterizarPdf({arrayBuffer:async()=>new ArrayBuffer(1)},10,10);
|
||||
return {timedOut:result.erro==='demorou demais neste navegador',destroyed};
|
||||
}finally{carregarPdfJs=realLoader;window.setTimeout=realTimer;temWorker=realWorker;}
|
||||
})()`);
|
||||
assert.deepEqual(timeoutCleanup,{timedOut:true,destroyed:true});
|
||||
// Inspect the supplied local artwork, when requested, using the real file input.
|
||||
if(process.env.ARTWORK_FILE){
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
|
||||
79
tests/backup_test.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""The database backup against the local stack: dump, encrypt, upload, restore.
|
||||
|
||||
docker compose -f compose.local.yaml exec -T backup python -m tests.backup_test
|
||||
|
||||
Uses a throwaway age key made here, so nothing secret is kept in the repository.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from app.bootstrap import admin_connect
|
||||
from ops import db_backup
|
||||
|
||||
|
||||
def check(condition, message):
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
print('PASS:', message)
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as work:
|
||||
identity = Path(work, 'identity.txt')
|
||||
subprocess.run(['age-keygen', '-o', str(identity)], check=True, capture_output=True)
|
||||
public = next(line.split(': ', 1)[1] for line in identity.read_text().splitlines()
|
||||
if line.startswith('# public key: '))
|
||||
os.environ['BACKUP_AGE_RECIPIENT'] = public
|
||||
check(db_backup.configured(), 'the local backup service is configured once a recipient is set')
|
||||
|
||||
live = db_backup.counts(None)
|
||||
key = db_backup.run_once()
|
||||
with admin_connect() as c:
|
||||
row = c.execute('SELECT status,bytes FROM dtf_local.backups WHERE object_key=%s', (key,)).fetchone()
|
||||
check(row is not None and row[0] == 'ok' and row[1] > 0, 'the run is recorded for the Kanban')
|
||||
|
||||
client, bucket = db_backup.bucket()
|
||||
head = client.get_object(Bucket=bucket, Key=key, Range='bytes=0-20')['Body'].read()
|
||||
check(head.startswith(b'age-encryption.org/v1'), 'what leaves the server is encrypted')
|
||||
check(key in [k for k, _, _ in db_backup.listing()], 'the backup is listed in its bucket')
|
||||
try:
|
||||
client.delete_object(Bucket=bucket, Key=key)
|
||||
deleted = True
|
||||
except ClientError:
|
||||
deleted = False
|
||||
check(not deleted and key in [k for k, _, _ in db_backup.listing()],
|
||||
'the backup credential cannot delete backups')
|
||||
|
||||
restored = db_backup.verify(str(identity), key)
|
||||
check(restored == live, f'the restore has the same rows as the live database ({restored})')
|
||||
|
||||
live_name = db_backup.admin_params()['dbname']
|
||||
try:
|
||||
db_backup.restore(key, str(identity), live_name)
|
||||
refused = False
|
||||
except SystemExit:
|
||||
refused = True
|
||||
check(refused, 'a restore over the live database is refused')
|
||||
|
||||
other = Path(work, 'other.txt')
|
||||
subprocess.run(['age-keygen', '-o', str(other)], check=True, capture_output=True)
|
||||
try:
|
||||
db_backup.verify(str(other), key)
|
||||
opened = True
|
||||
except RuntimeError:
|
||||
opened = False
|
||||
check(not opened, 'another key cannot open the backup')
|
||||
|
||||
brt = lambda h, m=0: datetime(2026, 9, 29, h + 3, m, tzinfo=timezone.utc)
|
||||
check(db_backup.next_run(brt(2), 3) == brt(3), 'before 03:00 in Brasília the run is that day')
|
||||
check(db_backup.next_run(brt(3), 3) == datetime(2026, 9, 30, 6, tzinfo=timezone.utc),
|
||||
'at or after 03:00 the run is the next day')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -14,6 +14,11 @@ try {
|
||||
const profile=await mkdtemp(tmpdir()+'/dtf-browser-');
|
||||
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
|
||||
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
|
||||
...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]),
|
||||
// Production uses HTTPS; internal Compose HTTP names need a secure context
|
||||
// for Web Crypto during the upload and checkout journey.
|
||||
...(process.env.CHROME_TRUST_TEST_ORIGINS==='1'
|
||||
? ['--unsafely-treat-insecure-origin-as-secure=http://site,http://kanban'] : []),
|
||||
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
|
||||
],{stdio:['ignore','ignore','pipe']});
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
@@ -41,7 +46,9 @@ try{
|
||||
await p.call('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
await waitFor(()=>p.eval('document.readyState === "complete"'),'page load');return p;
|
||||
}
|
||||
const site=await page('http://localhost:'+(process.env.SITE_PORT||8080));
|
||||
const siteOrigin=process.env.SITE_BROWSER_ORIGIN||'http://localhost:'+(process.env.SITE_PORT||8080);
|
||||
const kanbanOrigin=process.env.KANBAN_BROWSER_ORIGIN||'http://localhost:'+(process.env.KANBAN_PORT||8081);
|
||||
const site=await page(siteOrigin);
|
||||
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'checkout bridge');
|
||||
// Prove escaping itself, independently of the CSP's second line of defense.
|
||||
await site.call('Page.setBypassCSP',{enabled:true});
|
||||
@@ -55,8 +62,13 @@ try{
|
||||
await site.call('Page.setBypassCSP',{enabled:false});
|
||||
await site.call('Page.reload');
|
||||
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe');
|
||||
// The check above can still pass on the page being replaced; wait for the new one's cards.
|
||||
await waitFor(()=>site.eval(`!!document.querySelector('[data-modo="file"]') && document.readyState==='complete'`),'product cards after reload');
|
||||
await site.click('[data-modo="file"]');
|
||||
await site.click('[data-cam="tabela"]');
|
||||
// Each product has its own page; the home's parts are not on it.
|
||||
assert.deepEqual(await site.eval(`({path:location.pathname,page:document.documentElement.dataset.rota,
|
||||
cards:getComputedStyle($('cards')).display,cart:getComputedStyle($('carr')).display})`),
|
||||
{path:'/arquivo-por-metro',page:'produto',cards:'none',cart:'none'});
|
||||
const root=await site.call('DOM.getDocument');
|
||||
const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'});
|
||||
await site.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]});
|
||||
@@ -67,24 +79,74 @@ try{
|
||||
await site.fill('#fCnpj','11222333000181');
|
||||
await site.fill('#fZap','11999999999');
|
||||
await site.fill('#fMail','local-browser@example.test');
|
||||
await pause(800);
|
||||
// Reload only once the cart is in the browser's storage: the save waits for
|
||||
// typing to stop and writes the files, so a fixed pause raced it.
|
||||
await waitFor(()=>site.eval(`new Promise(done=>{const open=indexedDB.open('dtf-local-cart',1);
|
||||
open.onerror=()=>done(false);
|
||||
open.onsuccess=()=>{const all=open.result.transaction('cart').objectStore('cart').getAll();
|
||||
all.onsuccess=()=>done(all.result.some(v=>v.items?.length===1));all.onerror=()=>done(false);};})`),'cart saved in the browser');
|
||||
await site.call('Page.reload');
|
||||
await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery');
|
||||
// Reloading a product page reopens that product; the cart is its own page.
|
||||
assert.equal(await site.eval('modo'),'file');
|
||||
await site.click('#cartLink');
|
||||
await waitFor(()=>site.eval(`location.pathname==='/carrinho' && getComputedStyle($('carr')).display!=='none' && getComputedStyle($('foco')).display==='none'`),'cart page');
|
||||
// Removing an item is one click and can be undone.
|
||||
await site.click('[data-rmi="0"]');
|
||||
// The empty state is painted on the next animation frame (site-steps.js).
|
||||
await waitFor(()=>site.eval(`!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens')`),'empty cart state');
|
||||
assert.deepEqual(await site.eval(`({items:pedido.length,empty:!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens'),undo:!$('desfazer').hidden})`),{items:0,empty:true,undo:true});
|
||||
await site.click('#desfazerBtn');
|
||||
assert.equal(await site.eval('pedido.length===1 && pedido[0].total===21.89 && $("desfazer").hidden'),true);
|
||||
// The items come back after a reload; the customer's details do not.
|
||||
assert.deepEqual(await site.eval(`['fCnpj','fZap','fMail'].map(id=>$(id).value)`),['','','']);
|
||||
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),true);
|
||||
await site.fill('#fCnpj','11222333000181');
|
||||
await site.fill('#fZap','11999999999');
|
||||
await site.fill('#fMail','local-browser@example.test');
|
||||
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
|
||||
// An unchecked item stays in the cart but is not paid for now.
|
||||
await site.click('[data-sel="0"]');
|
||||
assert.deepEqual(await site.eval(`({marcado:pedido[0].marcado,pagar:itensAPagar().length,zero:$('carrTot').textContent.includes('0,00'),row:document.querySelector('.item').classList.contains('fora')})`),
|
||||
{marcado:false,pagar:0,zero:true,row:true});
|
||||
await site.click('[data-sel="0"]');
|
||||
assert.equal(await site.eval('pedido[0].marcado===true && itensAPagar().length===1'),true);
|
||||
assert.equal(await site.eval('pedido[0].total'),21.89);
|
||||
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
|
||||
await site.click('#bPagar');
|
||||
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
|
||||
try{
|
||||
// A cart the Site can price is approved at once: the customer pays now.
|
||||
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'browser upload and automatic approval',45000);
|
||||
// Payment is its own page: the cart form is gone and the order summary is shown.
|
||||
assert.equal(await site.eval('location.pathname'),'/pagamento');
|
||||
assert.equal(await site.eval('getComputedStyle(document.getElementById("carr")).display'),'none');
|
||||
assert.equal(await site.eval('!document.getElementById("pagResumo").hidden && document.getElementById("pagResumo").textContent.includes("Total")'),true);
|
||||
}catch(error){
|
||||
console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent'));
|
||||
throw error;
|
||||
}
|
||||
const qid=await site.eval('localStorage.getItem("dtf-quote")');
|
||||
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
|
||||
const kanban=await page(kanbanOrigin);
|
||||
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
|
||||
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
|
||||
await kanban.eval('document.getElementById("login").requestSubmit()');
|
||||
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
|
||||
// Quotes live in their own tab; an automatic approval is listed as such.
|
||||
await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in');
|
||||
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
|
||||
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
|
||||
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
|
||||
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
|
||||
await kanban.click('[data-tab="quotes"]');
|
||||
await waitFor(()=>kanban.eval('document.querySelectorAll("#quote-filters button").length===2'),'quote filters');
|
||||
await kanban.eval('document.querySelectorAll("#quote-filters button")[1].click()');
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'approved quote listed on Kanban');
|
||||
assert.equal(await kanban.eval(`document.querySelector('[data-quote-pick="${qid}"]').textContent.includes('Aprovada automaticamente')`),true);
|
||||
assert.equal(await site.eval('pedido[0].production.sources[0].copies'),1);
|
||||
await site.eval('pedido[0].production.sources[0].copies=2;pintaPedido()');
|
||||
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'same-price production edit invalidates quote');
|
||||
await site.eval('pedido[0].production.sources[0].copies=1;pintaPedido()');
|
||||
await site.fill('#fMail','changed-browser@example.test');
|
||||
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'quote invalidated by cart edit');
|
||||
assert.equal(await site.eval('[...document.querySelectorAll("button")].some(x=>x.textContent==="Criar pedido de teste")'),false);
|
||||
await site.fill('#fMail','local-browser@example.test');
|
||||
await site.eval('window.dtfCheckout()');
|
||||
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed');
|
||||
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido de teste").click()');
|
||||
@@ -92,21 +154,36 @@ try{
|
||||
await kanban.click('#refresh');
|
||||
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card');
|
||||
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
|
||||
// Click real transition buttons, including rerender after each move.
|
||||
assert.deepEqual(await kanban.eval(`(()=>{const spec=board.orders.find(o=>o.id===${JSON.stringify(oid)}).snapshot.items[0].production;const source=spec.sources[0];return {kind:source.kind,copies:source.copies,length:Number(source.length_cm),height:Number(spec.height_cm),placed:spec.placements.length}})()`),
|
||||
{kind:'sheet',copies:1,length:101,height:101,placed:1});
|
||||
// Open the order's panel from its card, then click the real transition
|
||||
// buttons there, including the rerender after each move.
|
||||
await kanban.click('[data-tab="board"]');
|
||||
await kanban.click(`[data-card="${oid}"]`);
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move]')`),'order panel');
|
||||
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
|
||||
if(state==='fil'){
|
||||
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent==='Arquivos de produção').click();})()`);
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls');
|
||||
const doc=await kanban.call('DOM.getDocument');
|
||||
const input=await kanban.call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:`[data-order="${oid}"] [data-final-item]`});
|
||||
await kanban.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]});
|
||||
await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file');
|
||||
await kanban.eval(`(()=>{const form=document.querySelector('[data-order="${oid}"] form');form.querySelector('[type=checkbox]').click();form.requestSubmit();})()`);
|
||||
await kanban.eval(`(()=>{const check=document.querySelector('[data-order="${oid}"] [data-confirm]');check.click();check.closest('form').requestSubmit();})()`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval');
|
||||
}
|
||||
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent===${JSON.stringify('→ '+title)}).click();})()`);
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="${state}"]')`),'move to '+title);
|
||||
await kanban.click(`[data-order="${oid}"] [data-move="${state}"]`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state);
|
||||
}
|
||||
// Undo a mistaken move from the panel: one stage back with an internal reason.
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-back="imp"]')`),'back button');
|
||||
await kanban.click(`[data-order="${oid}"] [data-back="imp"]`);
|
||||
await kanban.fill(`[data-order="${oid}"] form.reason input`,'Movido por engano (teste)');
|
||||
await kanban.eval(`document.querySelector('[data-order="${oid}"] form.reason input').closest('form').requestSubmit()`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='imp'`),'undo to Imprimindo');
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="fin"]')`),'move to Finalizado again');
|
||||
await kanban.click(`[data-order="${oid}"] [data-move="fin"]`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='fin'`),'finished again');
|
||||
// Reload proves the board is persisted on the backend.
|
||||
await kanban.call('Page.reload');
|
||||
await waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board');
|
||||
@@ -115,25 +192,31 @@ try{
|
||||
await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position');
|
||||
await site.screenshot('output/local/site.png');
|
||||
await kanban.screenshot('output/local/kanban.png');
|
||||
const portal=await page('http://localhost:'+(process.env.SITE_PORT||8080)+'/portal.html?order='+oid);
|
||||
// A guest sees the order paid in this browser; creating an account keeps it.
|
||||
const portal=await page(siteOrigin+'/conta/pedidos?order='+oid);
|
||||
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking');
|
||||
await waitFor(()=>portal.eval('document.querySelector("#order-'+oid+' .detalhe")?.hidden===false'),'order from the link opened');
|
||||
await portal.click('#navEntrar');
|
||||
await waitFor(()=>portal.eval('location.pathname==="/conta/entrar" && !document.getElementById("auth").hidden'),'sign-in page');
|
||||
await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999');
|
||||
await portal.fill('#register-email','browser-'+Date.now()+'@example.test');
|
||||
await portal.fill('#register-password','local-browser-password-123');
|
||||
await portal.eval('document.getElementById("register").requestSubmit()');
|
||||
await waitFor(()=>portal.eval('document.getElementById("auth").hidden'),'customer registration');
|
||||
assert.ok((await portal.text()).includes('Finalizado'));
|
||||
await waitFor(()=>portal.eval('document.getElementById("auth").hidden && location.pathname==="/conta"'),'customer registration');
|
||||
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'overview shows the last order');
|
||||
await portal.click('[data-view="dados"]');
|
||||
await waitFor(()=>portal.eval('location.pathname==="/conta/dados" && document.getElementById("dCnpj").value==="11.222.333/0001-81"'),'account details');
|
||||
await portal.screenshot('output/local/portal.png');
|
||||
// A logout must clear draft file blobs and metadata, including other open Site tabs.
|
||||
await portal.eval(`(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onsuccess=()=>resolve(r.result);r.onerror=reject;});await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').put({items:[new File(['private'],'private.cdr')],expires:Date.now()+86400000},'security-fixture');tx.oncomplete=resolve;tx.onerror=reject;});db.close();})()`);
|
||||
await portal.click('#logout');
|
||||
await waitFor(()=>portal.eval('document.getElementById("logout").hidden'),'customer logout');
|
||||
await waitFor(()=>portal.eval('document.getElementById("logout")?.hidden===true'),'customer logout');
|
||||
let stored;
|
||||
await waitFor(async()=>{stored=await portal.eval(`(async()=>{try{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});const n=await new Promise((resolve,reject)=>{const r=db.transaction('cart').objectStore('cart').count();r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});db.close();return n;}catch{return -1;}})()`);return stored>=0;},'IndexedDB available after Clear-Site-Data');
|
||||
assert.equal(stored,0);
|
||||
assert.deepEqual(portal.errors,[]);
|
||||
assert.deepEqual(site.errors,[]);assert.deepEqual(kanban.errors,[]);
|
||||
console.log('PASS: browser Site upload → operator quote → local paid order → all main Kanban states → reload persistence. Order '+oid);
|
||||
console.log('PASS: browser Site upload → automatic approval → local paid order → all main Kanban states → reload persistence. Order '+oid);
|
||||
console.log('Screenshots: output/local/site.png and output/local/kanban.png');
|
||||
console.log('PASS: filename XSS escaping with CSP bypassed, no stored operator password, logout clears browser file blobs.');
|
||||
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1500));process.exitCode=1;}
|
||||
|
||||
184
tests/payment_test.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""The webhook path, against a running stack.
|
||||
|
||||
A provider retries. It delivers out of order, twice, and late. None of that may
|
||||
produce a second order or a second notification to the customer, and nothing
|
||||
unsigned may produce one at all.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core import db
|
||||
from tests.smoke_test import BASE, Client, approved_quote, upload_bytes, item_spec, with_host
|
||||
|
||||
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
|
||||
|
||||
|
||||
def deliver(payload, expected=200, signature=None):
|
||||
body = json.dumps(payload).encode()
|
||||
sig = signature if signature is not None else hmac.new(SECRET, body, hashlib.sha256).hexdigest()
|
||||
request = Request(BASE + '/api/payments/webhook', data=body,
|
||||
headers=with_host({'Content-Type': 'application/json',
|
||||
'x-payment-signature': sig}))
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
assert response.status == expected, (response.status, expected)
|
||||
return json.load(response)
|
||||
except HTTPError as exc:
|
||||
assert exc.code == expected, (exc.code, expected, exc.read().decode())
|
||||
return {}
|
||||
|
||||
|
||||
def reviewed_quote():
|
||||
"""A quote an operator has approved, ready to be paid."""
|
||||
customer = Client()
|
||||
customer.call('/session')
|
||||
uid = upload_bytes(customer, b'PAYMENT WEBHOOK TEST')
|
||||
item = item_spec('file', '1.01', 0, uid)
|
||||
profile = {'cnpj': '11222333000181', 'zap': '11999999999',
|
||||
'mail': 'payment-' + uuid4().hex[:8] + '@example.test'}
|
||||
quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile,
|
||||
'items': [item], 'freight': {'service': 'pickup'}})
|
||||
approved = approved_quote(customer, quote, [item])
|
||||
return customer, quote['id'], approved['total_cents']
|
||||
|
||||
|
||||
def run():
|
||||
customer, quote_id, total = reviewed_quote()
|
||||
|
||||
# Nothing unsigned creates an order, and a tampered body is not signed.
|
||||
deliver({'event_id': 'unsigned-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': total}, expected=403, signature='')
|
||||
deliver({'event_id': 'tampered-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': total}, expected=403, signature='0' * 64)
|
||||
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 pix['expires_at']
|
||||
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == pix['id']
|
||||
# Once the code has expired, asking again opens a new one, and only one.
|
||||
with db.connect() as c:
|
||||
c.execute('''UPDATE dtf_local.payment_intents
|
||||
SET response=jsonb_set(response,'{expires_at}',to_jsonb((now()-interval '1 minute')::text))
|
||||
WHERE provider_payment_id=%s''', (pix['id'],))
|
||||
renewed = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
|
||||
assert renewed['id'] != pix['id'], 'an expired PIX was offered again'
|
||||
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == renewed['id']
|
||||
with db.connect() as c:
|
||||
statuses = {r['provider_payment_id']: r['status'] for r in c.execute(
|
||||
"SELECT provider_payment_id,status FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'",
|
||||
(quote_id,)).fetchall()}
|
||||
assert statuses == {pix['id']: 'expired', renewed['id']: 'pending'}, statuses
|
||||
print('PASS: a PIX code expires after 30 minutes and is replaced by exactly one new code')
|
||||
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})
|
||||
assert not customer.call('/quotes/' + quote_id)['order'], 'underpayment created an order'
|
||||
deliver({'event_id': 'missing-amount-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved'})
|
||||
assert not customer.call('/quotes/' + quote_id)['order'], 'missing paid amount created an order'
|
||||
deliver({'event_id': 'invalid-amount-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': str(total)})
|
||||
assert not customer.call('/quotes/' + quote_id)['order'], 'non-integer paid amount created an order'
|
||||
print('PASS: a missing, invalid or mismatched paid amount is refused')
|
||||
|
||||
# The real thing, then the same delivery again, and a second event for the
|
||||
# same quote: a provider does all three.
|
||||
event = 'paid-' + uuid4().hex
|
||||
payload = {'event_id': event, 'reference': quote_id, 'status': 'approved',
|
||||
'amount_cents': total}
|
||||
first = deliver(payload)
|
||||
assert first['status'] == 'applied', first
|
||||
order = customer.call('/quotes/' + quote_id)['order']
|
||||
assert order, 'approved payment did not create an order'
|
||||
|
||||
again = deliver(payload)
|
||||
assert again['status'] == 'duplicate', again
|
||||
later = deliver({**payload, 'event_id': 'retry-' + uuid4().hex})
|
||||
assert 'already existed' in later.get('outcome', ''), later
|
||||
assert customer.call('/quotes/' + quote_id)['order']['id'] == order['id'], 'a second order appeared'
|
||||
print('PASS: one order from a repeated and re-sent approval')
|
||||
|
||||
assert customer.call('/orders/dev-paid', {'quote_id': quote_id})['id'] == order['id']
|
||||
other = Client()
|
||||
other.call('/session')
|
||||
other.call('/orders/dev-paid', {'quote_id': quote_id}, expected=404)
|
||||
print('PASS: another customer cannot retrieve the paid order by quote id')
|
||||
|
||||
# The customer is told once, not once per delivery.
|
||||
board = Client()
|
||||
events = board.call('/operator/events?order=' + str(order['number']), operator=True)['events']
|
||||
paid = [e for e in events if e['payload'].get('order_id') == order['id']
|
||||
and e['payload'].get('event') == 'payment_approved']
|
||||
assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}'
|
||||
assert {e['provider'] for e in paid} == {'tiny', 'whatsapp'}, paid
|
||||
print('PASS: exactly one notification per provider for the order')
|
||||
|
||||
# A payment that was never reviewed, and one for something that is not a quote.
|
||||
deliver({'event_id': 'nonsense-' + uuid4().hex, 'reference': 'not-a-uuid',
|
||||
'status': 'approved', 'amount_cents': 100})
|
||||
deliver({'event_id': 'missing-' + uuid4().hex, 'reference': str(uuid4()),
|
||||
'status': 'approved', 'amount_cents': 100})
|
||||
deliver({'event_id': 'pending-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'pending', 'amount_cents': total})
|
||||
print('PASS: unknown references and non-approved statuses are recorded without acting')
|
||||
|
||||
# An operator's test order runs the production flow and notifies no one.
|
||||
tester, test_quote, _ = reviewed_quote()
|
||||
order = tester.call('/operator/quotes/' + test_quote + '/test-order', {}, operator=True)
|
||||
assert order['payment']['provider'] == 'teste' and order['state'] == 'rec', order
|
||||
assert tester.call('/operator/quotes/' + test_quote + '/test-order', {}, operator=True)['id'] == order['id']
|
||||
version = order['version']
|
||||
for state in ('tra',):
|
||||
moved = tester.call('/operator/orders/' + order['id'] + '/move', {'state': state, 'version': version}, operator=True)
|
||||
version = moved['version']
|
||||
with db.connect() as c:
|
||||
queued = c.execute("SELECT count(*) AS n FROM dtf_local.outbox WHERE event_key LIKE %s", (order['id'] + ':%',)).fetchone()['n']
|
||||
jobs = c.execute('SELECT count(*) AS n FROM dtf_local.print_files WHERE order_id=%s', (order['id'],)).fetchone()['n']
|
||||
assert queued == 0 and jobs == 1, (queued, jobs)
|
||||
print('PASS: an operator test order reaches the board and the print queue, never Tiny or WhatsApp')
|
||||
|
||||
# Files are uploaded before payment. An unpaid cart keeps them briefly; a
|
||||
# paid order keeps them for its 30 days; a payment never starts for files
|
||||
# that are gone.
|
||||
def files_of(qid):
|
||||
with db.connect() as c:
|
||||
q = c.execute('SELECT approved,draft FROM dtf_local.quotes WHERE id=%s', (qid,)).fetchone()
|
||||
return [u for item in (q['approved'] or q['draft'])['items'] for u in item['uploads']]
|
||||
|
||||
def days(ids, since='now()'):
|
||||
with db.connect() as c:
|
||||
return [float(r['d']) for r in c.execute(
|
||||
f'SELECT extract(epoch FROM expires_at-{since})/86400 AS d FROM dtf_local.uploads WHERE id=ANY(%s)',
|
||||
(ids,)).fetchall()]
|
||||
fresh = upload_bytes(customer, b'UNPAID HOLD TEST')
|
||||
assert all(1.99 < d <= 2.0 for d in days([fresh])), days([fresh])
|
||||
assert all(abs(d - 30) < 0.01 for d in days(files_of(quote_id), 'created_at')), days(files_of(quote_id), 'created_at')
|
||||
late, late_quote, _ = reviewed_quote()
|
||||
assert all(d > 1.99 for d in days(files_of(late_quote))), days(files_of(late_quote))
|
||||
with db.connect() as c:
|
||||
c.execute("UPDATE dtf_local.uploads SET expires_at=now()-interval '1 second' WHERE id=ANY(%s)",
|
||||
(files_of(late_quote),))
|
||||
late.call('/payments/intent', {'quote_id': late_quote, 'method': {'type': 'pix'}}, expected=410)
|
||||
print('PASS: unpaid files are held 2 days, paid ones 30 days, and expired files are never charged for')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
184
tests/print_file_test.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""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, approved_quote as approval, 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 = approval(client, quote, [item])
|
||||
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 'não é uma imagem' 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/payment-events?state=open&limit=100', operator=True)['issues']
|
||||
issue = next(i for i in issues if i['event_id'] == event_id)
|
||||
assert client.call('/operator/board', operator=True)['payment_issues_total'] >= 1
|
||||
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/payment-events?state=open&limit=100', operator=True)['issues']
|
||||
assert not any(i['event_id'] == event_id for i in issues)
|
||||
resolved_page = client.call('/operator/payment-events?state=resolved&limit=100', operator=True)
|
||||
assert resolved_page['total'] >= 1
|
||||
resolved = resolved_page['issues']
|
||||
done = next(i for i in resolved if i['event_id'] == event_id)
|
||||
assert done['resolution'] == 'Local test: refunded the underpayment' and done['resolved_by']
|
||||
print('PASS: refused paid notification is listed until an operator resolves it, then kept as history')
|
||||
|
||||
# The send log pages by id and filters by destination, status, event and order.
|
||||
page = client.call('/operator/events?limit=2', operator=True)
|
||||
assert len(page['events']) == 2 and page['total'] > 2
|
||||
second = client.call('/operator/events?limit=2&offset=2', operator=True)
|
||||
assert all(e['id'] < page['events'][-1]['id'] for e in second['events'])
|
||||
assert second['total'] == page['total']
|
||||
tiny = client.call('/operator/events?provider=tiny&event=payment_approved&status=delivered&limit=100', operator=True)
|
||||
assert tiny['events'] and all(e['provider'] == 'tiny' and e['payload']['event'] == 'payment_approved'
|
||||
and e['delivered_at'] for e in tiny['events'])
|
||||
client.call('/operator/events?provider=email', operator=True, expected=422)
|
||||
client.call('/operator/events?limit=500', operator=True, expected=422)
|
||||
quotes = client.call('/operator/quotes?kind=approved&limit=1&offset=0', operator=True)
|
||||
assert 'total' in quotes and len(quotes['quotes']) <= 1
|
||||
finished = client.call('/operator/orders/finished?limit=1', operator=True)
|
||||
if finished['orders']:
|
||||
last = finished['orders'][-1]
|
||||
client.call('/operator/orders/finished?before_created_at=' + last['created_at'].replace('+', '%2B')
|
||||
+ '&before_id=' + last['id'], operator=True)
|
||||
print('PASS: send log, payment history and finished orders page and filter')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
60
tests/quote_pagination_test.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""The 101st pending quote and older approved quotes remain reachable on the board."""
|
||||
from uuid import uuid4
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.core import db
|
||||
from tests.smoke_test import Client
|
||||
|
||||
|
||||
def run():
|
||||
owner = uuid4()
|
||||
pending_ids = [uuid4() for _ in range(105)]
|
||||
approved_ids = [uuid4() for _ in range(22)]
|
||||
created = pending_ids + approved_ids
|
||||
draft = {'customer': {'mail': 'pagination-fixture@example.test'},
|
||||
'items': [], 'freight': {'service': 'pickup'}}
|
||||
try:
|
||||
with db.connect() as c:
|
||||
for uid in pending_ids:
|
||||
c.execute('''INSERT INTO dtf_local.quotes
|
||||
(id,owner,request_key,request_hash,draft,created_at)
|
||||
VALUES(%s,%s,%s,%s,%s,now()+interval '1 hour')''',
|
||||
(uid, owner, uuid4(), 'pagination-fixture', Jsonb(draft)))
|
||||
for uid in approved_ids:
|
||||
c.execute('''INSERT INTO dtf_local.quotes
|
||||
(id,owner,request_key,request_hash,draft,approved,approved_at,created_at)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,now(),now()+interval '1 hour')''',
|
||||
(uid, owner, uuid4(), 'pagination-fixture', Jsonb(draft),
|
||||
Jsonb({'items': [], 'total_cents': 0})))
|
||||
|
||||
client = Client()
|
||||
board = client.call('/operator/board', operator=True)
|
||||
assert board['pending_total'] >= 105
|
||||
assert board['approved_total'] >= 22
|
||||
for kind, fixture_ids in [('pending', pending_ids), ('approved', approved_ids)]:
|
||||
first = [q for q in board['quotes'] if (q['approved'] is None) == (kind == 'pending')]
|
||||
seen = {q['id'] for q in first}
|
||||
assert len(first) == (100 if kind == 'pending' else 20)
|
||||
last = first[-1]
|
||||
for _ in range(5):
|
||||
path = '/operator/quotes?' + urlencode({
|
||||
'kind': kind, 'limit': 50,
|
||||
'before_created_at': last['created_at'], 'before_id': last['id']})
|
||||
page = client.call(path, operator=True)
|
||||
assert page['quotes'], 'An older quote page disappeared'
|
||||
assert not seen.intersection(q['id'] for q in page['quotes']), 'Quote page repeated rows'
|
||||
seen.update(q['id'] for q in page['quotes'])
|
||||
if set(map(str, fixture_ids)) <= seen:
|
||||
break
|
||||
last = page['quotes'][-1]
|
||||
assert set(map(str, fixture_ids)) <= seen, f'{kind} quotes were hidden by the board limit'
|
||||
print('PASS: 105 pending and 22 approved quotes remain reachable across board pages')
|
||||
finally:
|
||||
with db.connect() as c:
|
||||
c.execute('DELETE FROM dtf_local.quotes WHERE id=ANY(%s)', (created,))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@@ -5,7 +5,7 @@ from botocore.exceptions import ClientError
|
||||
from app.core.db import connect
|
||||
from app.adapters import LocalS3Storage
|
||||
from app.core.auth import client_ip, password_hash, password_matches
|
||||
from app.scanning import ClamAV, require_clean
|
||||
from app.scanning import ClamAV, format_matches, require_clean
|
||||
from fastapi import HTTPException
|
||||
|
||||
class FakeRequest:
|
||||
@@ -78,7 +78,13 @@ def run():
|
||||
except HTTPException as error:assert error.status_code==409
|
||||
require_clean({'complete':True,'scan_state':'clean'})
|
||||
assert ClamAV().ping() and ClamAV().version().startswith('ClamAV ')
|
||||
assert ClamAV().scan(None,134217729)[0]=='rejected'
|
||||
# Above the antivirus limit only a file whose bytes match its name is released.
|
||||
for name,head,ok in (('folha.png',b'\x89PNG\r\n\x1a\n\x00',True),('folha.png',b'MZ\x90\x00',False),
|
||||
('folha.jpg',b'\xff\xd8\xff\xe0',True),('folha.pdf',b'%PDF-1.7',True),
|
||||
('folha.pdf',b'#!/bin/sh',False),('folha.tif',b'II*\x00',True),('folha.psd',b'8BPS',True),
|
||||
('folha.ai',b'%!PS-Adobe',True),('folha.cdr',b'RIFF\x10\x00\x00\x00CDRv',True),
|
||||
('folha.cdr',b'RIFF\x10\x00\x00\x00WEBP',False),('folha.exe',b'MZ',False)):
|
||||
assert format_matches(name,head)==ok,(name,head)
|
||||
with patch('app.scanning.socket.create_connection',side_effect=OSError('offline')):
|
||||
try:ClamAV().scan(None,1);raise AssertionError('Offline scanner returned success')
|
||||
except OSError:pass
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Harmless EICAR anti-malware test and blocked download/quote regressions."""
|
||||
from uuid import uuid4
|
||||
from tests.smoke_test import Client, upload_bytes
|
||||
from tests.smoke_test import Client, upload_bytes, item_spec
|
||||
|
||||
def run():
|
||||
customer=Client();customer.call('/session')
|
||||
@@ -9,7 +9,7 @@ def run():
|
||||
uid=upload_bytes(customer,marker,name='SECURITY-EICAR.cdr',expected_scan='rejected')
|
||||
customer.call('/operator/uploads/'+uid+'/download',operator=True,expected=409)
|
||||
customer.call('/quotes',{'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'security@example.test'},
|
||||
'items':[{'mode':'file','metres':'1','grade':0,'uploads':[uid]}],'freight':{'service':'pickup'}},expected=409)
|
||||
'items':[item_spec('file','1',0,uid)],'freight':{'service':'pickup'}},expected=409)
|
||||
clean=upload_bytes(customer,b'Harmless local artwork fixture',name='SECURITY-CLEAN.cdr')
|
||||
customer.call('/operator/uploads/'+clean+'/download',operator=True)
|
||||
print('PASS: real ClamAV detects EICAR; rejected artwork cannot be downloaded or quoted; clean artwork is released.')
|
||||
|
||||
@@ -29,6 +29,10 @@ def run():
|
||||
assert script_src == "script-src 'self'", script_src
|
||||
assert "object-src 'none'" in policy
|
||||
assert 'cdnjs' not in policy, 'pdf.js is vendored; no CDN belongs in the policy'
|
||||
# Product pages and the cart are addresses of the Site's page, under the same policy.
|
||||
for page in ('/arquivo-por-metro','/artes-avulsas','/uv-arquivo-por-metro','/uv-artes-avulsas','/carrinho'):
|
||||
assert raw(page,200)['Content-Security-Policy']==policy,page
|
||||
raw('/carrinho/outra-coisa',404)
|
||||
raw('/api/health',400,{'Host':'attacker.invalid'})
|
||||
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'https://attacker.invalid'},b'{}')
|
||||
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}')
|
||||
|
||||
@@ -37,14 +37,15 @@ class Client:
|
||||
self.jar=http.cookiejar.CookieJar()
|
||||
self.opener=build_opener(HTTPCookieProcessor(self.jar))
|
||||
self.operator_client=None
|
||||
def call(self,path,body=None,operator=False,expected=200):
|
||||
def call(self,path,body=None,operator=False,expected=200,method=None):
|
||||
headers=with_host({'Content-Type':'application/json'})
|
||||
if operator:
|
||||
if self.operator_client is None:
|
||||
self.operator_client=Client()
|
||||
self.operator_client.call('/operator/login',{'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
|
||||
return self.operator_client.call(path,body,expected=expected)
|
||||
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),headers=headers)
|
||||
return self.operator_client.call(path,body,expected=expected,method=method)
|
||||
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),
|
||||
headers=headers,method=method)
|
||||
try:
|
||||
with self.opener.open(request,timeout=30) as response:
|
||||
assert response.status==expected,(path,response.status,expected)
|
||||
@@ -78,10 +79,39 @@ def upload_bytes(client, content, name='LOCAL-TEST.cdr', order_id=None, expected
|
||||
wait_scan(client,uid,operator,expected_scan)
|
||||
return uid
|
||||
|
||||
def item_spec(mode, metres, grade, uid):
|
||||
film_width=28.5 if mode in ('uvfile','uv') else 57
|
||||
length_cm=float(metres)*100
|
||||
return {'mode':mode,'metres':str(metres),'grade':grade,'uploads':[uid],
|
||||
'production':{'version':2,'film_width_cm':film_width,'height_cm':length_cm,
|
||||
'sources':[{'upload_id':uid,
|
||||
'kind':'sheet' if mode in ('file','uvfile') else 'artwork',
|
||||
'width_cm':film_width,'length_cm':length_cm,'copies':1,
|
||||
'rotation_degrees':0,'mirrored':False,'measurement':'customer'}],
|
||||
'placements':[{'source_index':0,'copy_index':0,'x_cm':0,'y_cm':0,
|
||||
'width_cm':film_width,'length_cm':length_cm,
|
||||
'rotation_degrees':0,'mirrored':False}]},
|
||||
'quality_status':'unverified' if grade==0 else 'ok',
|
||||
'quality_acknowledged':False}
|
||||
|
||||
def approved_quote(client, quote, items):
|
||||
"""The approval a customer pays against: automatic, or by the operator."""
|
||||
if quote['status']=='approved':
|
||||
return client.call('/quotes/'+quote['id'])['approved']
|
||||
return client.call('/operator/quotes/'+quote['id']+'/approve',{'items':items},operator=True)
|
||||
|
||||
def run():
|
||||
client=Client();other=Client()
|
||||
config=client.call('/session');other.call('/session')
|
||||
assert client.call('/health')['integrations']=='fake'
|
||||
# Sheets of several GB are the normal order: 5 GB per file.
|
||||
assert config['max_upload_bytes'] == 5 * 1024 ** 3
|
||||
client.call('/uploads',{'name':'too-large.cdr',
|
||||
'size':config['max_upload_bytes']+1},expected=413)
|
||||
cancelled=client.call('/uploads',{'name':'CANCELLED-PART.cdr','size':3})['id']
|
||||
other.call('/uploads/'+cancelled,expected=404,method='DELETE')
|
||||
assert client.call('/uploads/'+cancelled,method='DELETE')['cancelled']
|
||||
client.call('/uploads/'+cancelled,expected=410)
|
||||
client.call('/operator/board',expected=401)
|
||||
block=config['part_bytes'];content=b'DTF local multipart test\n'+b'x'*block
|
||||
uid=client.call('/uploads',{'name':'LOCAL-SMOKE-ONLY.cdr','size':len(content)})['id']
|
||||
@@ -104,24 +134,71 @@ def run():
|
||||
except HTTPError as exc:assert exc.code==403
|
||||
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
|
||||
|
||||
items=[{'mode':m,'metres':'2.75','grade':90,'uploads':[uid]} for m in ('file','avulsa','uvfile','uv')]
|
||||
# Above QUOTE_AUTO_MAX_METRES (50 m by default), so a person reviews it.
|
||||
items=[item_spec(m,'13',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}]}}
|
||||
client.call('/quotes',{**draft,'items':[outside]},expected=422)
|
||||
client.call('/quotes',{**draft,'items':[{**items[0],'metres':'1.00'}]},expected=422)
|
||||
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
|
||||
quote=client.call('/quotes',draft)
|
||||
assert quote['status']=='pending_review'
|
||||
assert client.call('/quotes/'+quote['id'])['review_reason']=='Pedido acima de 50 m'
|
||||
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)
|
||||
client.call('/operator/quotes/'+qid+'/approve',{'items':items},expected=401)
|
||||
altered={**items[0],'production':{**items[0]['production'],
|
||||
'sources':[{**items[0]['production']['sources'][0],'measurement':'file'}]}}
|
||||
client.call('/operator/quotes/'+qid+'/approve',{'items':[altered,*items[1:]]},operator=True,expected=422)
|
||||
# Reviewer corrects a browser-supplied grade and length. Browser values are proposals.
|
||||
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
|
||||
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['total_cents']==2189+32370+90870+109070+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)
|
||||
assert not client.call('/quotes/'+qid)['auto_approved']
|
||||
# A cart the Site priced is approved at once and can be paid straight away,
|
||||
# at the server's own prices; no operator can then change it.
|
||||
# The server works the grade out from the file: this PNG is 6059 px across
|
||||
# 57 cm, 270 DPI, which is grade 90.
|
||||
png=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR'+(6059).to_bytes(4,'big')+(2000).to_bytes(4,'big')+b'\x08\x06\x00\x00\x00'+b'\x00'*4
|
||||
graded=upload_bytes(client,png,name='arte-270dpi.png')
|
||||
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,graded)]}
|
||||
auto=client.call('/quotes',small)
|
||||
assert auto['status']=='approved'
|
||||
seen=client.call('/quotes/'+auto['id'])
|
||||
assert seen['auto_approved'] and seen['review_reason'] is None
|
||||
assert seen['approved']['total_cents']==6972+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
|
||||
assert client.call('/quotes',small)['status']=='approved'
|
||||
client.call('/operator/quotes/'+auto['id']+'/approve',{'items':small['items']},operator=True,expected=409)
|
||||
# The Site grades only art it analysed; a discount on unanalysed art waits for a person.
|
||||
claimed={**item_spec('avulsa','2.75',0,uid),'grade':90}
|
||||
held=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[claimed]})
|
||||
assert held['status']=='pending_review'
|
||||
assert client.call('/quotes/'+held['id'])['review_reason']=='Nota informada sem análise da arte'
|
||||
# A better grade than the file supports, or one the server cannot check, waits too.
|
||||
inflated=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',100,graded)]})
|
||||
assert inflated['status']=='pending_review'
|
||||
assert client.call('/quotes/'+inflated['id'])['review_reason']=='Nota 100 maior que a do arquivo (90)'
|
||||
unchecked=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]})
|
||||
assert client.call('/quotes/'+unchecked['id'])['review_reason']=='Nota não conferida no servidor'
|
||||
print('PASS: priced carts are approved at checkout; large or inconsistent ones wait for review')
|
||||
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
|
||||
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
|
||||
# Concurrent retries must produce precisely one payment/order/outbox pair.
|
||||
@@ -145,14 +222,35 @@ def run():
|
||||
version+=1;assert moved['version']==version
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409)
|
||||
assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6
|
||||
# A mistaken move can be undone one stage at a time, with an internal
|
||||
# reason. The customer is not told again: going back and forward once more
|
||||
# adds no messages (the outbox count below stays 8).
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version},operator=True,expected=422)
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'tra','version':version},operator=True,expected=409)
|
||||
back=client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version,'reason':'Movido por engano'},operator=True)
|
||||
assert back['state']=='imp';version+=1
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':version},operator=True);version+=1
|
||||
history=client.call('/operator/orders/'+oid+'/history',operator=True)
|
||||
assert len(history)==8 and history[-2]['back'] and history[-2]['reason']=='Movido por engano' and not history[-1]['back']
|
||||
# The customer sees the stages and the correction's reason, never the
|
||||
# internal reason for going back.
|
||||
seen=client.call('/customer/orders/'+oid)['history']
|
||||
assert len(seen)==7 and all(h['reason']=='' or h['to_state']=='cor' for h in seen)
|
||||
assert any(h['to_state']=='cor' and h['reason']=='Local test correction' for h in seen)
|
||||
print('PASS: a mistaken move is undone one stage back with a reason, without messaging the customer again')
|
||||
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
|
||||
deadline=time.monotonic()+30
|
||||
while time.monotonic()<deadline:
|
||||
events=[e for e in client.call('/operator/board',operator=True)['events'] if e['payload']['order_id']==oid]
|
||||
events=[e for e in client.call('/operator/events?order='+str(order['number']),operator=True)['events'] if e['payload']['order_id']==oid]
|
||||
if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
|
||||
time.sleep(1)
|
||||
else:raise AssertionError('Mock outbox did not drain')
|
||||
assert len({e['event_key'] for e in events})==8
|
||||
# Tiny sets the pickup situação from the order it is sent; the fake sale
|
||||
# has no Tiny id, so the real adapter would search for it.
|
||||
ready={e['provider']:e['payload'] for e in events if e['payload']['event']=='ready'}
|
||||
assert ready['tiny']['order']['freight']==order['snapshot']['freight'] and 'tiny_id' in ready['tiny']
|
||||
assert 'order' not in ready['whatsapp']
|
||||
print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.")
|
||||
return oid
|
||||
|
||||
|
||||
86
tests/test_grade_check.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""The server's grade against real image and PDF files.
|
||||
|
||||
Runs where Pillow and pikepdf are installed (the API image).
|
||||
"""
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import pikepdf
|
||||
from PIL import Image
|
||||
|
||||
from app import grade_check
|
||||
|
||||
|
||||
class FakeFiles:
|
||||
def __init__(self, blobs):
|
||||
self.blobs = blobs
|
||||
|
||||
def head(self, row):
|
||||
return self.blobs[row['name']][:grade_check.HEAD_BYTES]
|
||||
|
||||
def pdf_dpi(self, row):
|
||||
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
|
||||
handle.write(self.blobs[row['name']])
|
||||
handle.flush()
|
||||
return grade_check.pdf_dpi(handle.name)
|
||||
|
||||
|
||||
def encoded(fmt, size, **options):
|
||||
out = io.BytesIO()
|
||||
Image.new('RGBA' if fmt in ('PNG', 'WEBP') else 'RGB', size, (200, 30, 30)).save(out, fmt, **options)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def item(kind, grade, sources):
|
||||
return {'grade': grade, 'production': {'sources': [
|
||||
{'upload_id': name, 'kind': kind, 'width_cm': width, 'rotation_degrees': rotation}
|
||||
for name, width, rotation in sources]}}
|
||||
|
||||
|
||||
class GradeCheckTests(unittest.TestCase):
|
||||
def test_image_sizes_from_the_first_bytes(self):
|
||||
for fmt, options in (('PNG', {}), ('JPEG', {'quality': 80}), ('JPEG', {'progressive': True}),
|
||||
('WEBP', {'lossless': True}), ('WEBP', {'quality': 80})):
|
||||
self.assertEqual(grade_check.image_size(encoded(fmt, (1234, 567), **options)), (1234, 567), (fmt, options))
|
||||
# A JPEG with a large metadata block before the frame header.
|
||||
exif = Image.Exif()
|
||||
exif[0x010E] = 'x' * 60000
|
||||
self.assertEqual(grade_check.image_size(encoded('JPEG', (800, 600), exif=exif)), (800, 600))
|
||||
self.assertIsNone(grade_check.image_size(b'not an image at all'))
|
||||
|
||||
def test_pdf_dpi_is_the_images_area_weighted(self):
|
||||
out = io.BytesIO()
|
||||
Image.new('RGB', (1500, 750), 'white').save(out, 'PDF', resolution=150)
|
||||
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
|
||||
handle.write(out.getvalue()); handle.flush()
|
||||
self.assertEqual(grade_check.pdf_dpi(handle.name), 150)
|
||||
vector = pikepdf.new()
|
||||
vector.add_blank_page(page_size=(1615, 850))
|
||||
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
|
||||
vector.save(handle.name)
|
||||
self.assertEqual(grade_check.pdf_dpi(handle.name), 300)
|
||||
|
||||
def test_grades_follow_the_site(self):
|
||||
# 57 cm is 22.44 in: 6059 px is 270 DPI, grade 90; 3366 px is 150 DPI, grade 50.
|
||||
files = FakeFiles({'a.png': encoded('PNG', (6059, 100)), 'b.jpg': encoded('JPEG', (3366, 100)),
|
||||
'tall.png': encoded('PNG', (100, 2362)), 'x.cdr': b'CDR'})
|
||||
rows = {n: {'name': n, 'size': 1} for n in files.blobs}
|
||||
# A sheet takes the worst sheet's grade.
|
||||
self.assertEqual(grade_check.item_grade(files, item('sheet', 0, [('a.png', 57, 0)]), rows), 90)
|
||||
self.assertEqual(grade_check.item_grade(files, item('sheet', 0, [('a.png', 57, 0), ('b.jpg', 57, 0)]), rows), 50)
|
||||
# Artworks average; a rotated one is measured along its height.
|
||||
self.assertEqual(grade_check.item_grade(files, item('artwork', 0, [('a.png', 57, 0), ('b.jpg', 57, 0)]), rows), 70)
|
||||
self.assertEqual(grade_check.item_grade(files, item('artwork', 0, [('tall.png', 20, 90)]), rows), 100)
|
||||
self.assertIsNone(grade_check.item_grade(files, item('sheet', 0, [('x.cdr', 57, 0)]), rows))
|
||||
# What the cart claims is checked, with a point or two of rounding.
|
||||
claim = lambda g, src: grade_check.mismatch(files, [item('sheet', g, src)], rows)
|
||||
self.assertIsNone(claim(90, [('a.png', 57, 0)]))
|
||||
self.assertIsNone(claim(92, [('a.png', 57, 0)]))
|
||||
self.assertEqual(claim(100, [('a.png', 57, 0)]), 'Nota 100 maior que a do arquivo (90)')
|
||||
self.assertEqual(claim(40, [('x.cdr', 57, 0)]), 'Nota não conferida no servidor')
|
||||
self.assertIsNone(claim(0, [('x.cdr', 57, 0)]))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
147
tests/test_jadlog.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""The Jadlog quote client against a fake HTTP transport: payload and errors.
|
||||
|
||||
This proves the manual's contract only; app.jadlog_probe must still confirm
|
||||
it on the client's account. Runs where httpx is installed.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import httpx
|
||||
|
||||
from app.jadlog import QUOTE_URL, JadlogError, JadlogFreight, JadlogQuotes
|
||||
from app.jadlog_probe import run
|
||||
|
||||
CNPJ = '11.222.333/0001-81'
|
||||
|
||||
|
||||
def client(handler, **settings):
|
||||
return JadlogQuotes(token=settings.pop('token', 'tok-1'), cnpj=CNPJ, conta='123456',
|
||||
transport=httpx.MockTransport(handler), **settings)
|
||||
|
||||
|
||||
class JadlogQuoteTest(unittest.TestCase):
|
||||
def test_payload_follows_the_manual_and_price_becomes_centavos(self):
|
||||
seen = []
|
||||
|
||||
def handler(request):
|
||||
seen.append(request)
|
||||
item = json.loads(request.content)['frete'][0]
|
||||
return httpx.Response(200, json={'frete': [{**item, 'vltotal': 23.455, 'prazo': 4}]})
|
||||
|
||||
result = client(handler).quote('01310-100', 1.5, 12990)
|
||||
request = seen[0]
|
||||
self.assertEqual(str(request.url), QUOTE_URL)
|
||||
self.assertEqual(request.headers['authorization'], 'tok-1')
|
||||
item = json.loads(request.content)['frete'][0]
|
||||
self.assertEqual(item, {'cepori': '14402310', 'cepdes': '01310100', 'frap': 'N', 'peso': 1.5,
|
||||
'cnpj': '11222333000181', 'conta': '123456', 'contrato': None,
|
||||
'modalidade': 3, 'tpentrega': 'D', 'tpseguro': 'N',
|
||||
'vldeclarado': 129.9, 'vlcoleta': 0})
|
||||
self.assertEqual((result['total_cents'], result['days']), (2346, 4))
|
||||
|
||||
def test_contract_is_sent_only_when_configured(self):
|
||||
def handler(request):
|
||||
item = json.loads(request.content)['frete'][0]
|
||||
self.assertEqual(item['contrato'], '042')
|
||||
return httpx.Response(200, json={'frete': [{'vltotal': 10, 'prazo': 2}]})
|
||||
|
||||
client(handler, contrato='042').quote('01310100', 1, 100)
|
||||
|
||||
def test_account_is_sent_as_configured(self):
|
||||
def handler(request):
|
||||
self.assertEqual(json.loads(request.content)['frete'][0]['conta'], '123456-7')
|
||||
return httpx.Response(200, json={'frete': [{'vltotal': 10, 'prazo': 2}]})
|
||||
|
||||
JadlogQuotes(token='tok', cnpj=CNPJ, conta=' 123456-7 ', transport=httpx.MockTransport(handler)).quote('01310100', 1, 100)
|
||||
|
||||
def test_account_and_item_errors_are_raised_with_jadlog_text(self):
|
||||
replies = [
|
||||
httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'frete[0].contrato Numero de contrato invalido'}}),
|
||||
httpx.Response(200, json={'frete': [{'erro': {'id': 2, 'descricao': 'CEP destino invalido'}}]}),
|
||||
httpx.Response(200, json={'frete': [{'prazo': 3}]}),
|
||||
httpx.Response(502, text='<html>bad gateway</html>'),
|
||||
]
|
||||
messages = ['contrato invalido', 'CEP destino invalido', 'no freight value', 'not JSON']
|
||||
for reply, message in zip(replies, messages):
|
||||
with self.subTest(message=message), self.assertRaisesRegex(JadlogError, message):
|
||||
client(lambda request, reply=reply: reply).quote('01310100', 1, 100)
|
||||
|
||||
def test_missing_credentials_refuse_to_start(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
JadlogQuotes(token='', cnpj=CNPJ)
|
||||
with self.assertRaises(RuntimeError):
|
||||
JadlogQuotes(token='tok', cnpj='123')
|
||||
|
||||
|
||||
class JadlogFreightTest(unittest.TestCase):
|
||||
WEIGHTS = {'JADLOG_PESO_BASE_KG': '0.2', 'JADLOG_PESO_POR_METRO_KG': '0.15', 'FREIGHT_PRODUCTION_DAYS': '2'}
|
||||
|
||||
def freight(self, handler, **env):
|
||||
with mock.patch.dict(os.environ, {**self.WEIGHTS, **env}):
|
||||
return JadlogFreight(client(handler))
|
||||
|
||||
def test_package_weight_value_and_days(self):
|
||||
seen = []
|
||||
|
||||
def handler(request):
|
||||
seen.append(json.loads(request.content)['frete'][0])
|
||||
return httpx.Response(200, json={'frete': [{'vltotal': 18.4, 'prazo': 3}]})
|
||||
|
||||
quote = self.freight(handler).quote('jadlog', '01310100', '2.8', 6972)
|
||||
# 0.2 kg of packaging plus 0.15 kg for each of the 2.8 billed metres.
|
||||
self.assertEqual((seen[0]['peso'], seen[0]['vldeclarado'], seen[0]['cepdes']), (0.62, 69.72, '01310100'))
|
||||
self.assertEqual((quote['total_cents'], quote['days'], quote['service']), (1840, 5, 'jadlog'))
|
||||
|
||||
def test_pickup_is_free_and_bad_requests_are_refused(self):
|
||||
freight = self.freight(lambda request: httpx.Response(500))
|
||||
self.assertEqual(freight.quote('pickup', '')['total_cents'], 0)
|
||||
for args in (('mock-standard', '01310100', '1'), ('jadlog', '0131', '1'), ('jadlog', '01310100', None)):
|
||||
with self.subTest(args=args), self.assertRaises(ValueError):
|
||||
freight.quote(*args)
|
||||
|
||||
def test_a_jadlog_failure_is_a_clear_refusal(self):
|
||||
freight = self.freight(lambda request: httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'CEP destino invalido'}}))
|
||||
with self.assertRaisesRegex(ValueError, 'CEP destino invalido'):
|
||||
freight.quote('jadlog', '01310100', '1', 100)
|
||||
|
||||
def test_the_weight_has_no_default(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=True), self.assertRaises(KeyError):
|
||||
JadlogFreight(client(lambda request: httpx.Response(200)))
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.freight(lambda request: httpx.Response(200), JADLOG_PESO_POR_METRO_KG='0')
|
||||
|
||||
|
||||
class JadlogProbeTest(unittest.TestCase):
|
||||
def test_bearer_is_tried_once_after_a_401_and_kept(self):
|
||||
headers = []
|
||||
|
||||
def handler(request):
|
||||
headers.append(request.headers['authorization'])
|
||||
if not request.headers['authorization'].startswith('Bearer '):
|
||||
return httpx.Response(401, json={'descricao': 'TOKEN INVALIDO', 'id': 1})
|
||||
return httpx.Response(200, json={'frete': [{'vltotal': 12.5, 'prazo': 3}]})
|
||||
|
||||
lines = []
|
||||
status = run(client(handler), ['01310100', '20040002'], ['1'], 10000, lines.append)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(headers, ['tok-1', 'Bearer tok-1', 'Bearer tok-1'])
|
||||
self.assertIn('Bearer', lines[0])
|
||||
self.assertIn('R$ 12,50', lines[1])
|
||||
|
||||
def test_first_failure_stops_the_run(self):
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'CNPJ invalido'}})
|
||||
|
||||
lines = []
|
||||
self.assertEqual(run(client(handler), ['01310100', '20040002'], ['1', '2'], 10000, lines.append), 1)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn('CNPJ invalido', lines[0])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
42
tests/test_large_files.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Large sheets: when the original is its own print file, and the format check."""
|
||||
import unittest
|
||||
|
||||
from app.printjobs import whole_sheet
|
||||
from app.scanning import format_matches
|
||||
|
||||
ROW = {'id': 'u1', 'name': 'folha.png', 'size': 3 * 1024 ** 3, 'scan_state': 'clean',
|
||||
'purged_at': None, 'expired': False}
|
||||
|
||||
|
||||
def sheet(**changes):
|
||||
source = {'kind': 'sheet', 'width_cm': 57, 'length_cm': 500, 'copies': 1}
|
||||
place = {'x_cm': 0, 'y_cm': 0, 'rotation_degrees': 0, 'mirrored': False}
|
||||
for key, value in changes.items():
|
||||
(source if key in source else place)[key] = value
|
||||
return {'uploads': ['u1'], 'production': {'film_width_cm': 57, 'sources': [source], 'placements': [place]}}
|
||||
|
||||
|
||||
class WholeSheetTest(unittest.TestCase):
|
||||
def test_a_finished_sheet_placed_whole_is_its_own_print_file(self):
|
||||
self.assertIs(whole_sheet(sheet(), {'u1': ROW}), ROW)
|
||||
|
||||
def test_anything_else_is_prepared_by_hand(self):
|
||||
for changes in ({'copies': 2}, {'kind': 'artwork'}, {'rotation_degrees': 90}, {'mirrored': True},
|
||||
{'x_cm': 1}, {'width_cm': 50}):
|
||||
with self.subTest(changes=changes):
|
||||
self.assertIsNone(whole_sheet(sheet(**changes), {'u1': ROW}))
|
||||
self.assertIsNone(whole_sheet(sheet(), {'u1': {**ROW, 'name': 'folha.cdr'}}))
|
||||
self.assertIsNone(whole_sheet(sheet(), {'u1': {**ROW, 'scan_state': 'pending'}}))
|
||||
self.assertIsNone(whole_sheet(sheet(), {'u1': {**ROW, 'expired': True}}))
|
||||
|
||||
|
||||
class FormatTest(unittest.TestCase):
|
||||
def test_bytes_must_match_the_name(self):
|
||||
self.assertTrue(format_matches('A.PNG', b'\x89PNG\r\n\x1a\n'))
|
||||
self.assertTrue(format_matches('a.tiff', b'MM\x00*'))
|
||||
self.assertFalse(format_matches('a.png', b'%PDF-1.4'))
|
||||
self.assertFalse(format_matches('semextensao', b'\x89PNG\r\n\x1a\n'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
170
tests/test_mercadopago.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""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
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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]
|
||||
if payment_id == '500':
|
||||
return httpx.Response(500, json={'message': 'internal_error'})
|
||||
if payment_id not in self.payments:
|
||||
return httpx.Response(404, json={'message': 'Payment not found'})
|
||||
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_notification_for_an_unknown_payment_is_acknowledged(self):
|
||||
# The panel's "Simular notificação" sends a payment id that does not
|
||||
# exist. Raising would answer 500 and Mercado Pago would retry for ever.
|
||||
body = json.dumps({'id': 43, 'type': 'payment', 'data': {'id': '123456'}}).encode()
|
||||
self.assertIsNone(self.mp.parse(body, {'data.id': '123456', 'type': 'payment'}))
|
||||
# Any other failure still raises, so a real notification is retried.
|
||||
with self.assertRaises(httpx.HTTPStatusError):
|
||||
self.mp.parse(json.dumps({'type': 'payment', 'data': {'id': '500'}}).encode(), {'data.id': '500'})
|
||||
|
||||
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-1')
|
||||
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'))
|
||||
# The code expires in 30 minutes, in the format Mercado Pago documents.
|
||||
expires = datetime.fromisoformat(body['date_of_expiration'])
|
||||
self.assertRegex(body['date_of_expiration'], r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}-03:00$')
|
||||
self.assertAlmostEqual((expires - datetime.now(timezone.utc)).total_seconds(), 1800, delta=60)
|
||||
self.assertEqual(created['expires_at'], body['date_of_expiration'])
|
||||
# A new code after the last expired is the next attempt, not the same payment.
|
||||
self.mp.create('11111111-2222-3333-4444-555555555555', 12345,
|
||||
{'mail': 'a@example.test', 'cnpj': '11222333000181'}, {'type': 'pix', 'attempt': 2})
|
||||
self.assertTrue(self.requests[-1].headers['x-idempotency-key'].endswith('-pix-2'))
|
||||
|
||||
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-'))
|
||||
# 3-D Secure is asked of debit only; the cardholder is the payer.
|
||||
self.assertNotIn('three_d_secure_mode', body)
|
||||
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
|
||||
{'type': 'card', 'token': 'tok_deb', 'payment_method_id': 'debmaster',
|
||||
'payer_document_type': 'CPF', 'payer_document': '12345678909'})
|
||||
debit = json.loads(self.requests[-1].content)
|
||||
self.assertEqual(debit['three_d_secure_mode'], 'optional')
|
||||
self.assertEqual(debit['payer']['identification'], {'type': 'CPF', 'number': '12345678909'})
|
||||
self.assertEqual(body['payer']['identification'], {'type': 'CNPJ', 'number': '11222333000181'})
|
||||
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
|
||||
{'type': 'card', 'token': 'tok_iss', 'payment_method_id': 'master', 'issuer_id': '24'})
|
||||
self.assertNotIn('issuer_id', json.loads(self.requests[-1].content))
|
||||
self.assertIsNone(self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
|
||||
{'type': 'card', 'token': 'tok_ghi', 'payment_method_id': 'visa'})['challenge'])
|
||||
|
||||
def test_a_card_the_bank_must_confirm_returns_its_challenge(self):
|
||||
def handler(request):
|
||||
return httpx.Response(201, json={'id': 777, 'status': 'pending', 'status_detail': 'pending_challenge',
|
||||
'three_ds_info': {'external_resource_url': 'https://acs.bank.example/challenge',
|
||||
'creq': 'eyJjcmVxIjoiMSJ9'}})
|
||||
mp = MercadoPagoPayment('TEST-token', SECRET, transport=httpx.MockTransport(handler), clock=lambda: NOW)
|
||||
created = mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
|
||||
{'type': 'card', 'token': 'tok_debit', 'payment_method_id': 'debvisa'})
|
||||
self.assertEqual((created['status'], created['status_detail']), ('pending', 'pending_challenge'))
|
||||
self.assertEqual(created['challenge'], {'url': 'https://acs.bank.example/challenge', 'creq': 'eyJjcmVxIjoiMSJ9'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
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, 'proporções'):
|
||||
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
|
||||
with self.assertRaisesRegex(Unsupported, 'cobrados'):
|
||||
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, 'não é um 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, 'não é uma imagem'):
|
||||
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 páginas'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('two.pdf', pages=2)])
|
||||
with self.assertRaisesRegex(Unsupported, 'senha'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10),
|
||||
[self.pdf('locked.pdf', password='secret')])
|
||||
with self.assertRaisesRegex(Unsupported, 'proporções'):
|
||||
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()
|
||||
38
tests/test_quote_review.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Which quotes the Site approves at checkout and which wait for a person."""
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from app.quote_review import review_reason
|
||||
|
||||
|
||||
def item(mode='avulsa', metres='2', grade=90, quality='ok', version=2):
|
||||
return {'mode': mode, 'metres': metres, 'grade': grade, 'quality_status': quality,
|
||||
'quality_acknowledged': quality == 'warning', 'production': {'version': version}}
|
||||
|
||||
|
||||
class ReviewReasonTest(unittest.TestCase):
|
||||
def reason(self, *items, **env):
|
||||
with mock.patch.dict(os.environ, env):
|
||||
return review_reason({'items': list(items)})
|
||||
|
||||
def test_a_priced_cart_is_approved(self):
|
||||
self.assertIsNone(self.reason(item()))
|
||||
# Accepted resolution warnings and unanalysed art at the full rate too.
|
||||
self.assertIsNone(self.reason(item(quality='warning'), item(grade=0, quality='unverified')))
|
||||
|
||||
def test_large_orders_wait_for_review(self):
|
||||
self.assertIsNone(self.reason(item(metres='30'), item(metres='20')))
|
||||
self.assertEqual(self.reason(item(metres='30'), item(metres='20.1')), 'Pedido acima de 50 m')
|
||||
self.assertEqual(self.reason(item(metres='6'), QUOTE_AUTO_MAX_METRES='5'), 'Pedido acima de 5 m')
|
||||
|
||||
def test_a_discount_the_site_could_not_have_given_waits(self):
|
||||
self.assertEqual(self.reason(item(grade=90, quality='unverified')), 'Nota informada sem análise da arte')
|
||||
|
||||
def test_old_layouts_and_switching_it_off(self):
|
||||
self.assertEqual(self.reason(item(version=1)), 'Montagem antiga')
|
||||
self.assertEqual(self.reason(item(), QUOTE_AUTO_APPROVE='false'), 'Aprovação automática desligada')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
296
tests/test_tiny.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""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', 'TINY_FORMA_ENVIO_RETIRADA': '77'}
|
||||
PICKUP = {**PAID, 'order': {**PAID['order'], 'destination': None, 'freight': {'service': 'pickup', 'total_cents': 0}}}
|
||||
READY = {'order_id': PAID['order_id'], 'number': 42, 'event': 'ready', 'reason': '', 'order': PICKUP['order']}
|
||||
|
||||
|
||||
class FakeAuth:
|
||||
def access_token(self):
|
||||
return 'access-1'
|
||||
|
||||
|
||||
class FakeTinyCase(unittest.TestCase):
|
||||
"""A Tiny account in memory: contacts, orders, products and formas de envio."""
|
||||
|
||||
def setUp(self):
|
||||
self.contacts = []
|
||||
self.orders = []
|
||||
self.calls = []
|
||||
self.refuse_status = 0
|
||||
self.methods = {'77': {'id': 77, 'nome': 'Retirar pessoalmente', 'tipo': '6'},
|
||||
'78': {'id': 78, 'nome': 'Correios', 'tipo': '1'}}
|
||||
self.products = {value: {'id': int(value), 'sku': f'DTF-{value}', 'descricao': f'Produto {value}',
|
||||
'situacao': 'A', 'precos': {'preco': 14.9}} for value in PRODUCTS.values()}
|
||||
|
||||
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.get('cpfCnpj')
|
||||
return httpx.Response(200, json={'itens': [c for c in self.contacts if cnpj in (None, c['cpfCnpj'])]})
|
||||
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.startswith('/produtos/'):
|
||||
wanted = path.rsplit('/', 1)[-1]
|
||||
if wanted not in self.products:
|
||||
return httpx.Response(404, json={'mensagem': 'não encontrado'})
|
||||
return httpx.Response(200, json=self.products[wanted])
|
||||
if request.method == 'GET' and path == '/produtos':
|
||||
return httpx.Response(200, json={'itens': list(self.products.values())})
|
||||
if request.method == 'GET' and path.startswith('/formas-envio/'):
|
||||
wanted = path.rsplit('/', 1)[-1]
|
||||
if wanted not in self.methods:
|
||||
return httpx.Response(404, json={'mensagem': 'não encontrado'})
|
||||
return httpx.Response(200, json=self.methods[wanted])
|
||||
if request.method == 'GET' and path == '/formas-envio':
|
||||
return httpx.Response(200, json={'itens': list(self.methods.values())})
|
||||
if request.method == 'PUT' and path.startswith('/pedidos/') and path.endswith('/situacao'):
|
||||
if self.refuse_status:
|
||||
self.refuse_status -= 1
|
||||
return httpx.Response(503, text='indisponível')
|
||||
wanted = int(path.split('/')[2])
|
||||
next(o for o in self.orders if o['id'] == wanted)['situacao'] = json.loads(request.content)['situacao']
|
||||
return httpx.Response(204)
|
||||
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 = {'situacao': 0, **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.handler = handler
|
||||
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
|
||||
today=date(2026, 9, 24))
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PRODUCTS)
|
||||
class TinyTests(FakeTinyCase):
|
||||
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_connection_check_only_reads(self):
|
||||
from app.tiny import check
|
||||
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
|
||||
self.assertEqual(set(results.values()), {'ok'})
|
||||
self.assertEqual(len(results), 7)
|
||||
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
|
||||
self.products['103']['situacao'] = 'I'
|
||||
with mock.patch.dict(os.environ, {'TINY_PRODUCT_UV_AVULSA': ''}):
|
||||
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
|
||||
self.assertIn('não está ativo', results['DTF UV 28,5 cm · folha montada'])
|
||||
self.assertEqual(results['DTF UV 28,5 cm · artes avulsas'], 'TINY_PRODUCT_UV_AVULSA sem id')
|
||||
self.assertEqual(results['pedidos'], 'ok')
|
||||
denied = check(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(403, text='forbidden')))
|
||||
self.assertTrue(denied['pedidos'].startswith('GET /pedidos: 403'))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PRODUCTS)
|
||||
class TinyStatusTests(FakeTinyCase):
|
||||
"""Situações that the client's Tiny -> n8n notices turn into WhatsApp messages."""
|
||||
|
||||
def puts(self):
|
||||
return [call for call in self.calls if call[0] == 'PUT']
|
||||
|
||||
def test_nothing_changes_while_status_updates_are_off(self):
|
||||
self.assertEqual(self.tiny.deliver('k1', PICKUP)['status'], 'created')
|
||||
self.assertEqual(self.orders[0]['situacao'], 0)
|
||||
self.assertEqual(self.tiny.deliver('k2', READY)['status'], 'not-applicable')
|
||||
self.assertEqual(self.puts(), [])
|
||||
|
||||
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
|
||||
def test_paid_order_is_approved_once(self):
|
||||
first = self.tiny.deliver('k1', PICKUP)
|
||||
self.assertEqual((first['status'], first['situacao']), ('created', 'Aprovada'))
|
||||
self.assertEqual(self.orders[0]['situacao'], 3)
|
||||
second = self.tiny.deliver('k1', PICKUP)
|
||||
self.assertEqual(second['status'], 'already-created')
|
||||
self.assertNotIn('situacao', second)
|
||||
self.assertEqual(len(self.puts()), 1)
|
||||
|
||||
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
|
||||
def test_retry_after_a_failed_approval_finishes_it(self):
|
||||
self.refuse_status = 1
|
||||
with self.assertRaises(TinyError):
|
||||
self.tiny.deliver('k1', PICKUP)
|
||||
self.assertEqual((len(self.orders), self.orders[0]['situacao']), (1, 0))
|
||||
retry = self.tiny.deliver('k1', PICKUP)
|
||||
self.assertEqual((retry['status'], retry['situacao']), ('already-created', 'Aprovada'))
|
||||
self.assertEqual(self.calls.count(('POST', '/pedidos')), 1)
|
||||
|
||||
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
|
||||
def test_an_order_already_moved_on_is_not_approved_again(self):
|
||||
self.tiny.deliver('k1', PICKUP)
|
||||
self.orders[0]['situacao'] = 7
|
||||
self.tiny.deliver('k1', PICKUP)
|
||||
self.assertEqual(self.orders[0]['situacao'], 7)
|
||||
|
||||
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
|
||||
def test_ready_pickup_order_is_set_ready_once_by_its_known_id(self):
|
||||
sale = self.tiny.deliver('k1', PICKUP)
|
||||
self.calls.clear()
|
||||
ready = self.tiny.deliver('k2', {**READY, 'tiny_id': sale['tiny_id']})
|
||||
self.assertEqual((ready['status'], ready['situacao'], ready['tiny_number']),
|
||||
('status-updated', 'Pronto para envio', sale['tiny_number']))
|
||||
self.assertEqual(self.orders[0]['situacao'], 7)
|
||||
self.assertNotIn(('GET', '/pedidos'), self.calls, 'a known id needs no search')
|
||||
again = self.tiny.deliver('k2', {**READY, 'tiny_id': sale['tiny_id']})
|
||||
self.assertEqual(again['status'], 'status-unchanged')
|
||||
self.assertEqual(len(self.puts()), 1)
|
||||
|
||||
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
|
||||
def test_ready_without_an_id_finds_the_order(self):
|
||||
self.tiny.deliver('k1', PICKUP)
|
||||
self.assertEqual(self.tiny.deliver('k2', READY)['status'], 'status-updated')
|
||||
self.assertEqual(self.orders[0]['situacao'], 7)
|
||||
|
||||
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
|
||||
def test_ready_before_the_sale_reached_tiny_is_retried(self):
|
||||
with self.assertRaisesRegex(TinyError, 'not in Tiny yet'):
|
||||
self.tiny.deliver('k2', READY)
|
||||
self.assertEqual(self.puts(), [])
|
||||
|
||||
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
|
||||
def test_ready_delivery_order_waits_for_freight(self):
|
||||
self.tiny.deliver('k1', PAID)
|
||||
self.calls.clear()
|
||||
ready = self.tiny.deliver('k2', {**READY, 'order': PAID['order']})
|
||||
self.assertEqual(ready['status'], 'not-applicable')
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_pickup_orders_carry_the_pickup_forma_de_envio(self):
|
||||
self.assertEqual(order_payload(PICKUP, 1)['transportador'], {'formaEnvio': {'id': 77}})
|
||||
self.assertNotIn('transportador', order_payload(PAID, 1))
|
||||
with mock.patch.dict(os.environ, {'TINY_FORMA_ENVIO_RETIRADA': ''}):
|
||||
self.assertNotIn('transportador', order_payload(PICKUP, 1))
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PRODUCTS)
|
||||
class TinyProbeTests(FakeTinyCase):
|
||||
"""The supervised console tool run against the fake Tiny."""
|
||||
|
||||
def probe(self, *argv):
|
||||
from app import tiny_probe
|
||||
lines = []
|
||||
code = tiny_probe.main(list(argv), orders=self.tiny, out=lines.append)
|
||||
return code, '\n'.join(lines)
|
||||
|
||||
def test_products_and_settings_are_listed_without_writing(self):
|
||||
code, text = self.probe('produtos', 'DTF')
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('101\tDTF-101\tProduto 101', text)
|
||||
code, text = self.probe('conferir')
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('TINY_PRODUCT_UV_AVULSA (DTF UV 28,5 cm · artes avulsas): ok DTF-104', text)
|
||||
self.assertIn('TINY_FORMA_ENVIO_RETIRADA (retirada): ok Retirar pessoalmente', text)
|
||||
code, text = self.probe('formas-envio')
|
||||
self.assertIn('77\t6\tRetirar pessoalmente', text)
|
||||
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
|
||||
|
||||
def test_order_is_shown_and_not_created_without_confirmation(self):
|
||||
code, text = self.probe('pedido', '--cnpj', '11.222.333/0001-81', '--email', 'a@example.test',
|
||||
'--celular', '16999999999')
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('"numeroOrdemCompra": "DTF-TESTE-', text)
|
||||
self.assertIn('Nada foi criado', text)
|
||||
self.assertEqual(self.orders, [])
|
||||
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
|
||||
|
||||
def test_confirmed_order_is_created_once_and_found_on_resend(self):
|
||||
code, text = self.probe('pedido', '--cnpj', '11222333000181', '--email', 'a@example.test',
|
||||
'--celular', '16999999999', '--modo', 'uv', '--confirmar')
|
||||
self.assertEqual(code, 0, text)
|
||||
self.assertEqual(len(self.orders), 1)
|
||||
self.assertEqual(self.orders[0]['itens'][0]['produto'], {'id': 104})
|
||||
self.assertIn('2º envio: already-created', text)
|
||||
|
||||
def test_resend_is_skipped_when_the_search_cannot_find_the_order(self):
|
||||
from app import tiny_probe
|
||||
blind = lambda request: (httpx.Response(200, json={'itens': []})
|
||||
if request.method == 'GET' and request.url.path.endswith('/pedidos')
|
||||
else self.handler(request))
|
||||
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(blind), today=date(2026, 9, 24))
|
||||
payload = tiny_probe.test_order('11222333000181', 'a@example.test', '16999999999', 'file')
|
||||
lines = []
|
||||
self.assertFalse(tiny_probe.pedido(self.tiny, payload, True, lines.append, wait=lambda s: None))
|
||||
self.assertEqual(len(self.orders), 1)
|
||||
self.assertIn('o 2º envio não foi feito', lines[-1])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
184
tests/tiny_oauth_test.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""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 = []
|
||||
server = {'mode': 'session', 'calls': 0}
|
||||
|
||||
def token_server(request):
|
||||
server['calls'] += 1
|
||||
if server['mode'] == 'down':
|
||||
return httpx.Response(503, text='unavailable')
|
||||
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}')
|
||||
if server['mode'] == 'offline':
|
||||
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 0,
|
||||
'scope': 'openid offline_access profile'})
|
||||
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400,
|
||||
'scope': 'openid profile'})
|
||||
|
||||
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
|
||||
assert query['scope'] == 'openid offline_access'
|
||||
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 status['problem'] is None and not status['offline'] and status['expires_at']
|
||||
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')
|
||||
|
||||
# Tiny unreachable: the failure is shown, the connection kept, and the next
|
||||
# renewal clears it.
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
|
||||
server['mode'] = 'down'
|
||||
try:
|
||||
auth.access_token()
|
||||
raise AssertionError('an unreachable token server must fail the renewal')
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['problem'] == 'renewal-failing' and status['failed_at']
|
||||
server['mode'] = 'session'
|
||||
assert auth.access_token() == 'access-3'
|
||||
assert auth.status()['problem'] is None
|
||||
print('PASS: a failed renewal is shown and cleared by the next successful one')
|
||||
|
||||
# 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
|
||||
status = auth.status()
|
||||
assert not status['connected'] and status['problem'] == 'refused'
|
||||
calls = server['calls']
|
||||
try:
|
||||
auth.access_token()
|
||||
raise AssertionError('a refused connection must stay refused')
|
||||
except TinyNotConnected:
|
||||
pass
|
||||
assert server['calls'] == calls, 'a refused refresh token must not be sent again'
|
||||
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')
|
||||
|
||||
# Reconnecting with an offline grant: no daily end, and the refusal is cleared.
|
||||
server['mode'] = 'offline'
|
||||
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
auth.complete('good-code', state)
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['offline'] and status['expires_at'] is None
|
||||
assert status['problem'] is None
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
|
||||
auth.access_token()
|
||||
assert auth.status()['offline'] and auth.status()['expires_at'] is None
|
||||
print('PASS: an offline grant is stored without a daily expiry and survives renewal')
|
||||
|
||||
# Tiny refusing offline_access restarts the authorisation without it, once.
|
||||
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
retry = parse_qs(urlparse(auth.without_offline(state)).query)
|
||||
assert retry['scope'] == ['openid'] and retry['state'][0] != state
|
||||
try:
|
||||
auth.without_offline(state)
|
||||
raise AssertionError('the refused state must be spent')
|
||||
except TinyError:
|
||||
pass
|
||||
# A session grant close to its end is flagged while renewals are not happening.
|
||||
server['mode'] = 'session'
|
||||
auth.complete('good-code', retry['state'][0])
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=now()+interval '2 hours' WHERE provider='tiny'")
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['problem'] == 'expiring' and not status['offline']
|
||||
print('PASS: a refused offline scope falls back once; a connection near its end is flagged')
|
||||
|
||||
# Tiny's redirect back with an error instead of a code.
|
||||
from app.api.operator import tiny_callback
|
||||
declined = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
assert tiny_callback('', declined, 'access_denied').headers['location'] == '/?tiny=failed'
|
||||
assert tiny_callback('', '', '').headers['location'] == '/?tiny=failed'
|
||||
scoped = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
location = urlparse(tiny_callback('', scoped, 'invalid_scope').headers['location'])
|
||||
assert location.netloc == 'accounts.tiny.com.br' and parse_qs(location.query)['scope'] == ['openid']
|
||||
assert tiny_callback('', scoped, 'invalid_scope').headers['location'] == '/?tiny=failed'
|
||||
print('PASS: a declined or malformed callback returns to the Kanban; a refused scope retries without it')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@@ -1,21 +1,37 @@
|
||||
"""Customer identity, correction and final-file trust boundaries against local stack."""
|
||||
from uuid import uuid4
|
||||
from urllib.request import urlopen
|
||||
from tests.smoke_test import Client, upload_bytes
|
||||
from tests.smoke_test import Client, approved_quote, upload_bytes, item_spec
|
||||
|
||||
def run():
|
||||
customer=Client();other=Client();customer.call('/session');other.call('/session')
|
||||
uid=upload_bytes(customer,b'LOCAL ORIGINAL ONLY')
|
||||
item={'mode':'file','metres':'1.01','grade':0,'uploads':[uid]}
|
||||
item=item_spec('file','1.01',0,uid)
|
||||
profile={'cnpj':'11222333000181','zap':'11999999999','mail':'workflow-'+uuid4().hex[:8]+'@example.test'}
|
||||
q=customer.call('/quotes',{'request_key':str(uuid4()),'customer':profile,'items':[item],'freight':{'service':'pickup'}})
|
||||
customer.call('/operator/quotes/'+q['id']+'/approve',{'items':[item]},operator=True)
|
||||
approved_quote(customer,q,[item])
|
||||
order=customer.call('/orders/dev-paid',{'quote_id':q['id']});oid=order['id']
|
||||
before=list(customer.jar)[0].value
|
||||
password='local-test-password-'+uuid4().hex
|
||||
customer.call('/account/register',{'customer':profile,'password':password})
|
||||
assert customer.call('/account/me')['customer']['mail']==profile['mail']
|
||||
assert customer.call('/customer/orders')['orders'][0]['id']==oid
|
||||
assert [e['id'] for e in customer.call('/customer/orders')['items'] if e['kind']=='order'][0]==oid
|
||||
# The list filters, pages and counts by group.
|
||||
listing=customer.call('/customer/orders?status=prod&page=1&size=1')
|
||||
assert listing['counts']['prod']>=1 and listing['total']==listing['counts']['prod'] and len(listing['items'])==1, listing
|
||||
assert customer.call('/customer/orders?status=fin')['items']==[]
|
||||
assert customer.call('/customer/orders?number='+str(order['number']))['items'][0]['id']==oid
|
||||
# The account's details change; the CNPJ does not, and e-mail and password need the password.
|
||||
address={'recipient':'Workflow Ltda','street':'Rua de Teste','number':'10','complement':'',
|
||||
'district':'Centro','city':'Franca','state':'SP','postal_code':'14400000'}
|
||||
saved=customer.call('/account/profile',{'zap':'(16) 98888-7777','address':address})['customer']
|
||||
assert saved['zap']=='16988887777' and saved['cnpj']==profile['cnpj'] and saved['address']['city']=='Franca'
|
||||
assert customer.call('/account/me')['customer']['address']['postal_code']=='14400000'
|
||||
customer.call('/account/profile',{'zap':'16988887777','cnpj':'00000000000000'},expected=422)
|
||||
customer.call('/account/email',{'email':'x-'+profile['mail'],'password':'wrong-password'},expected=401)
|
||||
customer.call('/account/password',{'current':'wrong-password','new':'another-password-123'},expected=401)
|
||||
other.call('/account/profile',{'zap':'16988887777'},expected=401)
|
||||
print('PASS: customer order list filters and pages; account details change, CNPJ does not')
|
||||
# Email/CNPJ do not grant ownership; only current guest session is migrated.
|
||||
other.call('/customer/orders/'+oid,expected=404)
|
||||
other.call('/account/login',{'email':profile['mail'],'password':'wrong-password'},expected=401)
|
||||
@@ -47,6 +63,10 @@ def run():
|
||||
guest=Client();guest.call('/session');guest.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
|
||||
version=move('fil',version);version=move('imp',version);version=move('cor',version)
|
||||
customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
|
||||
premature_final=upload_bytes(customer,b'FINAL BEFORE CUSTOMER CORRECTION',order_id=oid)
|
||||
version=customer.call('/operator/orders/'+oid+'/final-files',
|
||||
{'version':version,'files':[{'item_index':0,'upload_id':premature_final}],
|
||||
'note':'Prepared before customer sent the new correction'},operator=True)['version']
|
||||
correction_id=upload_bytes(customer,b'LOCAL CORRECTED ORIGINAL')
|
||||
payload={'version':version,'files':[{'item_index':0,'upload_id':correction_id}],'note':'Replaced the artwork as requested'}
|
||||
guest.call('/customer/orders/'+oid+'/corrections',payload,expected=404)
|
||||
@@ -54,6 +74,7 @@ def run():
|
||||
version=customer.call('/customer/orders/'+oid+'/corrections',payload)['version']
|
||||
files=customer.call('/operator/orders/'+oid+'/files',operator=True)
|
||||
assert any(f['kind']=='correction' and f['active'] and f['upload_id']==correction_id for f in files)
|
||||
assert not any(f['kind']=='final' and f['active'] for f in files), 'new correction kept a stale final active'
|
||||
version=move('tra',version)
|
||||
customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
|
||||
new_final=upload_bytes(customer,b'LOCAL FINAL VERSION TWO',order_id=oid)
|
||||
@@ -67,7 +88,7 @@ def run():
|
||||
old_cookie=list(other.jar)[0].value
|
||||
other.call('/account/logout',{})
|
||||
cookie.value=old_cookie;revoked.jar.set_cookie(cookie);revoked.call('/customer/orders',expected=401)
|
||||
other.call('/session');assert other.call('/customer/orders')['orders']==[]
|
||||
other.call('/session');assert other.call('/customer/orders')['items']==[]
|
||||
print('PASS: logout revokes server session and signed-out visitors cannot see account orders')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
|
||||
40
web/cart.js
@@ -1,9 +1,15 @@
|
||||
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h. */
|
||||
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h.
|
||||
Only the items are kept: the customer's details and address are typed again
|
||||
after a reload or a closed tab, so nothing personal stays in the browser. */
|
||||
(() => {
|
||||
// A browser may put back what was typed before the reload without telling
|
||||
// the page, which then shows values it does not know and cannot pay with.
|
||||
const CAMPOS=['fCnpj','fZap','fMail','cepIn','eNome','eRua','eNum','eComp','eBairro','eCidade','eUf'];
|
||||
for(const id of CAMPOS)if($(id))$(id).value='';
|
||||
let database,scope,timer,restoring=true;
|
||||
let signedOut=false;
|
||||
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];});
|
||||
const notice=document.createElement('p');notice.style.cssText='font:13px system-ui;color:#56616d;padding:8px 20px';
|
||||
const notice=document.createElement('p');notice.className='carrAviso';
|
||||
document.getElementById('carr').prepend(notice);
|
||||
function transaction(mode,fn){return new Promise((resolve,reject)=>{const tx=database.transaction('cart',mode);const request=fn(tx.objectStore('cart'));let result;request.onsuccess=()=>result=request.result;tx.oncomplete=()=>resolve(result);tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error);});}
|
||||
async function save(){
|
||||
@@ -11,8 +17,8 @@
|
||||
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
try{
|
||||
if(!items.length){await transaction('readwrite',store=>store.delete(scope));return;}
|
||||
await transaction('readwrite',store=>store.put({items,customer:{...cliente},delivery:{...entrega},expires:Date.now()+86400000},scope));
|
||||
notice.textContent='Carrinho salvo neste navegador por 24 horas. Você pode remover itens e adicionar outros após recarregar.';
|
||||
await transaction('readwrite',store=>store.put({items,expires:Date.now()+86400000},scope));
|
||||
notice.textContent='';
|
||||
}catch(error){notice.textContent='Não foi possível salvar o carrinho neste navegador. Mantenha esta página aberta até enviar o pedido.';}
|
||||
}
|
||||
window.dtfClearCart=async()=>{clearTimeout(timer);if(database&&scope)await transaction('readwrite',store=>store.delete(scope));notice.textContent='';};
|
||||
@@ -30,16 +36,26 @@
|
||||
for(const key of records){const value=await transaction('readonly',store=>store.get(key));if(value.expires<Date.now())await transaction('readwrite',store=>store.delete(key));}
|
||||
const saved=await transaction('readonly',store=>store.get(scope));
|
||||
if(saved && !pedido.length && !itemAtual){
|
||||
pedido=saved.items;cliente=saved.customer;entrega=saved.delivery;
|
||||
// Freight must be quoted again; restored browser values are never final.
|
||||
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;}
|
||||
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
|
||||
$('cepIn').value=entrega.cep;
|
||||
pedido=saved.items;
|
||||
$('atual').style.display='none';pintaEntrega();
|
||||
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
|
||||
}else{
|
||||
const account=await window.dtfApi('/account/me');
|
||||
if(account.customer && !cliente.mail){cliente={...account.customer};for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];pintaEntrega();}
|
||||
}
|
||||
// A signed-in customer's details come from the account, with the saved
|
||||
// delivery address, whether or not a cart was recovered.
|
||||
const account=await window.dtfApi('/account/me');
|
||||
const c=account.customer;
|
||||
if(c && !cliente.mail){
|
||||
cliente={cnpj:c.cnpj,zap:c.zap,mail:c.mail};
|
||||
// The fields' own input handlers format them, as if typed.
|
||||
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']]){$(id).value=cliente[key];$(id).dispatchEvent(new Event('input'));}
|
||||
const a=c.address;
|
||||
if(a && !entrega.cep){
|
||||
entrega.cep=a.postal_code;
|
||||
entrega.end={nome:a.recipient,rua:a.street,num:a.number,comp:a.complement||'',bairro:a.district,cidade:a.city,uf:a.state};
|
||||
$('cepIn').value=a.postal_code.replace(/^(\d{5})(\d{3})$/,'$1-$2');
|
||||
for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key];
|
||||
}
|
||||
pintaEntrega();
|
||||
}
|
||||
}catch(error){notice.textContent='Recuperação de carrinho indisponível; o pedido ainda pode ser feito nesta sessão.';}
|
||||
finally{restoring=false;}
|
||||
|
||||
532
web/checkout.js
@@ -1,11 +1,34 @@
|
||||
/* Checkout bridge only: approved commercial functions in web/index.html stay intact. */
|
||||
/* Checkout bridge only: approved commercial functions in web/index.html stay intact.
|
||||
The cart sends the order; the quote is paid on its own page (/pagamento). */
|
||||
(() => {
|
||||
const status = document.getElementById('checkoutStatus');
|
||||
const actions = document.getElementById('checkoutActions');
|
||||
const resumo = document.getElementById('pagResumo');
|
||||
const naPagina = () => ['pagamento','pix'].includes(document.documentElement.dataset.rota);
|
||||
const naPix = () => document.documentElement.dataset.rota === 'pix';
|
||||
let shownCart = null;
|
||||
let pixClock = null;
|
||||
let busy = false;
|
||||
let draftId = localStorage.getItem('dtf-quote');
|
||||
let requestKey = localStorage.getItem('dtf-request-key');
|
||||
let requestBody = localStorage.getItem('dtf-request-body');
|
||||
let quotedCart = localStorage.getItem('dtf-quote-cart');
|
||||
let refreshVersion = 0;
|
||||
function cartSnapshot() {
|
||||
const items=itensAPagar();
|
||||
return JSON.stringify({customer:cliente,delivery:entrega,items:items.map(item=>({
|
||||
mode:item.modo,metres:item.metros,grade:item.nota,production:item.production,
|
||||
quality:item.qualityStatus,
|
||||
acknowledged:item.qualityAcknowledged,
|
||||
files:(item.localFiles||[]).map(file=>({name:file.name,size:file.size,lastModified:file.lastModified}))
|
||||
}))});
|
||||
}
|
||||
function clearDraft() {
|
||||
draftId=null;quotedCart=null;requestKey=null;requestBody=null;
|
||||
for(const key of ['dtf-quote','dtf-quote-cart','dtf-request-key','dtf-request-body'])
|
||||
localStorage.removeItem(key);
|
||||
actions.replaceChildren();
|
||||
}
|
||||
const api = async (path, body) => {
|
||||
const response = await fetch('/api'+path, {
|
||||
credentials: 'same-origin', headers: {'Content-Type':'application/json'},
|
||||
@@ -19,52 +42,243 @@
|
||||
return data;
|
||||
};
|
||||
const ready = api('/session');
|
||||
ready.then(session=>{
|
||||
window.dtfUploadMaxBytes=session.max_upload_bytes;
|
||||
const limit=document.getElementById('zLimite');
|
||||
if(limit)limit.textContent='Até '+(session.max_upload_bytes/1073741824).toFixed(0)+' GB por arquivo.';
|
||||
}).catch(()=>{});
|
||||
window.dtfSessionReady=ready;
|
||||
window.dtfApi=api;
|
||||
ready.catch(error => { status.textContent = error.message; });
|
||||
function message(text) { status.textContent = text; }
|
||||
function button(label, handler) {
|
||||
function button(label, handler, into = actions, className = 'pagBtn') {
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.className = className;
|
||||
el.textContent = label;
|
||||
el.style.cssText = 'margin:8px 8px 0 0;padding:8px 14px;cursor:pointer';
|
||||
el.onclick = handler;
|
||||
actions.append(el);
|
||||
into.append(el);
|
||||
return el;
|
||||
}
|
||||
async function upload(file) {
|
||||
const session=await ready;
|
||||
return window.dtfUpload(file,{api,progress:message,scope:session.cart_scope});
|
||||
function node(tag, text, className) {
|
||||
const el=document.createElement(tag);
|
||||
if (text!=null) el.textContent=text;
|
||||
if (className) el.className=className;
|
||||
return el;
|
||||
}
|
||||
// What the customer is paying for, from the server's approval.
|
||||
function pintaResumo(quote) {
|
||||
const approved = quote ? quote.approved : null;
|
||||
resumo.hidden = !approved;
|
||||
if (!approved) { resumo.replaceChildren(); return; }
|
||||
const linha = (label, value, className='l') => {
|
||||
const row=document.createElement('div'); row.className=className;
|
||||
const name=document.createElement('span'); name.textContent=label;
|
||||
const amount=document.createElement('b'); amount.textContent=value;
|
||||
row.append(name,amount); return row;
|
||||
};
|
||||
const rows = approved.items.map(item => linha(
|
||||
(MODOS[item.mode]?.tit || item.mode)+' · '+fmtM(Number(item.billed_metres))+' m', rs(item.total_cents/100)));
|
||||
const pickup = approved.freight.service === 'pickup';
|
||||
const prazo = approved.freight.days ? ' · até '+approved.freight.days+' dias úteis' : '';
|
||||
rows.push(linha(pickup ? 'Retirada em Franca' : 'Frete'+prazo,
|
||||
approved.freight.total_cents ? rs(approved.freight.total_cents/100) : 'Grátis'));
|
||||
rows.push(linha('Total', rs(approved.total_cents/100), 'tot'));
|
||||
resumo.replaceChildren(node('h4','Resumo do pedido'), ...rows);
|
||||
}
|
||||
function esqueletoPagamento() {
|
||||
const linhas=['skel skelLinha','skel skelLinha','skel skelLinha curta'].map(c=>node('div',null,c));
|
||||
resumo.hidden=false; resumo.replaceChildren(node('h4','Resumo do pedido'),...linhas);
|
||||
actions.replaceChildren(node('div',null,'skel skelLinha curta'),node('div',null,'skel skelBloco'));
|
||||
}
|
||||
function semPedido() {
|
||||
pintaResumo(null);
|
||||
actions.replaceChildren();
|
||||
message('Nenhum pedido aguardando pagamento.');
|
||||
button('Ir para o carrinho', () => vaiPara(CARRINHO));
|
||||
}
|
||||
// Files start uploading as soon as they are in the cart, so a sheet of
|
||||
// several GB is on its way while the customer fills in the order. The
|
||||
// checkout waits for whatever is still going.
|
||||
const envios=new Map();
|
||||
const chaveArquivo=f=>[f.name,f.size,f.lastModified].join('|');
|
||||
function enviar(file) {
|
||||
const k=chaveArquivo(file);
|
||||
let e=envios.get(k);
|
||||
if (!e) {
|
||||
e={file, sent:0, done:false, failed:null};
|
||||
e.promise=(async()=>{
|
||||
const session=await ready;
|
||||
return window.dtfUpload(file,{api,scope:session.cart_scope,progress:()=>{},
|
||||
onBytes:n=>{e.sent=n;pintaEnvio();}});
|
||||
})();
|
||||
e.promise.then(()=>{e.done=true;falhas.delete(k);pintaEnvio();},
|
||||
error=>{envios.delete(k);falhas.set(k,{file,error});pintaEnvio();});
|
||||
envios.set(k,e);
|
||||
}
|
||||
return e.promise;
|
||||
}
|
||||
// A failed upload is shown and waits for the customer, never retried behind
|
||||
// their back: a file the check refused would fail again on every change.
|
||||
const falhas=new Map();
|
||||
function tentaDeNovo() {
|
||||
const arquivos=[...falhas.values()].map(f=>f.file);
|
||||
falhas.clear();
|
||||
arquivos.forEach(f=>enviar(f).catch(()=>{}));
|
||||
pintaEnvio();
|
||||
}
|
||||
const arquivosDoCarrinho=()=>[...pedido.filter(it=>it.marcado!==false),...(busy&&itemAtual?[itemAtual]:[])].flatMap(it=>it.localFiles||[]);
|
||||
const gb=n=>(n/1073741824).toLocaleString('pt-BR',{maximumFractionDigits:1})+' GB';
|
||||
const mb=n=>n>=1073741824 ? gb(n) : Math.round(n/1048576)+' MB';
|
||||
// Where the uploads stand, and roughly how long is left from the recent speed.
|
||||
const amostras=[];
|
||||
function estadoEnvio() {
|
||||
const files=arquivosDoCarrinho(); if(!files.length) return null;
|
||||
let total=0, sent=0, pendentes=0;
|
||||
for (const f of files) {
|
||||
const e=envios.get(chaveArquivo(f));
|
||||
total+=f.size; sent+=e ? Math.min(e.sent,f.size) : 0;
|
||||
if (!e || !e.done) pendentes++;
|
||||
}
|
||||
const agora=Date.now();
|
||||
amostras.push([agora,sent]); while(amostras.length>2 && agora-amostras[0][0]>15000) amostras.shift();
|
||||
const [t0,s0]=amostras[0], taxa=agora>t0 ? (sent-s0)/((agora-t0)/1000) : 0;
|
||||
const fase=!pendentes ? 'pronto' : sent>=total ? 'verificando' : 'enviando';
|
||||
return {total, sent, fase, pct:Math.floor(sent/total*100),
|
||||
restante: fase==='enviando' && taxa>0 ? (total-sent)/taxa : null};
|
||||
}
|
||||
const falta=s=>s<60 ? 'falta menos de 1 min' : 'faltam cerca de '+duracao(s);
|
||||
// The time left, measured from the upload's own speed; until there is a
|
||||
// measure, a range from the size.
|
||||
const restante=e=>e.restante!=null ? falta(e.restante) : 'estimativa '+faixaEnvio(e.total-e.sent);
|
||||
function textoEnvio(e) {
|
||||
if (!e) return '';
|
||||
if (e.fase==='pronto') return 'Arquivos enviados';
|
||||
if (e.fase==='verificando') return 'Arquivos enviados · verificando a segurança…';
|
||||
return 'Enviando seus arquivos · '+restante(e);
|
||||
}
|
||||
function pintaEnvio() {
|
||||
const e=estadoEnvio(), texto=textoEnvio(e);
|
||||
const el=document.getElementById('envioArq');
|
||||
const falhou=arquivosDoCarrinho().map(f=>falhas.get(chaveArquivo(f))).filter(Boolean);
|
||||
const topo=document.getElementById('cartEnvio');
|
||||
if (falhou.length) {
|
||||
if (el) {
|
||||
el.replaceChildren(document.createTextNode('Não foi possível enviar '+falhou[0].file.name+': '+
|
||||
(falhou[0].error?.message||'erro no envio').replace(/\.$/,'')+(falhou.length>1?' (e mais '+(falhou.length-1)+')':'')+'. '));
|
||||
const b=document.createElement('button'); b.type='button'; b.className='envioDeNovo'; b.textContent='Tentar de novo';
|
||||
b.onclick=tentaDeNovo; el.append(b);
|
||||
el.classList.remove('ok'); el.classList.add('erro');
|
||||
}
|
||||
if (topo) topo.textContent='· falha no envio';
|
||||
return;
|
||||
}
|
||||
if (el) el.classList.remove('erro');
|
||||
if (el) {
|
||||
el.replaceChildren();
|
||||
if (e && e.fase!=='pronto') {
|
||||
const barra=document.createElement('div'); barra.className='envioBarra';
|
||||
const i=document.createElement('i'); i.style.width=(e.fase==='verificando'?100:e.pct)+'%'; barra.append(i);
|
||||
el.append(barra);
|
||||
}
|
||||
if (e?.fase==='pronto') el.insertAdjacentHTML('beforeend', icone('okCirculo'));
|
||||
if (texto) el.append(document.createTextNode(texto));
|
||||
if (e?.fase==='enviando') {
|
||||
const nota=document.createElement('small'); nota.className='envioNota';
|
||||
nota.textContent='O tempo depende da velocidade da sua internet. Mantenha a página aberta.';
|
||||
el.append(nota);
|
||||
}
|
||||
el.classList.toggle('ok', e?.fase==='pronto');
|
||||
}
|
||||
// Visible on every page while it runs.
|
||||
if (topo) topo.textContent = !e || e.fase==='pronto' ? '' : e.fase==='verificando' ? '· verificando' : e.restante!=null ? '· '+duracao(e.restante) : '· enviando';
|
||||
if (busy && texto) {
|
||||
message(texto);
|
||||
$('bPagarTx').textContent = e && e.fase!=='pronto' ? (e.fase==='verificando' ? 'Verificando os arquivos…' : 'Enviando arquivos… '+restante(e)) : $('bPagarTx').textContent;
|
||||
}
|
||||
}
|
||||
// Leaving the page pauses an upload; it resumes, but the customer is warned.
|
||||
window.addEventListener('beforeunload',event=>{
|
||||
const e=estadoEnvio();
|
||||
if (e && e.fase==='enviando') { event.preventDefault(); event.returnValue=''; }
|
||||
});
|
||||
// Only what is in the cart: the item on the product page may still change.
|
||||
window.addEventListener('dtf-cart-changed',()=>{
|
||||
arquivosDoCarrinho().forEach(f=>{ if(!falhas.has(chaveArquivo(f))) enviar(f).catch(()=>{}); });
|
||||
pintaEnvio();
|
||||
});
|
||||
async function upload(file) {
|
||||
return enviar(file);
|
||||
}
|
||||
// The cart's package: billed metres and value. The charged freight is
|
||||
// quoted again by the server from the approved items.
|
||||
const pacote = () => {
|
||||
const items=itensAPagar();
|
||||
return {metres:items.reduce((t,it)=>t+(it.cob||0),0), cents:Math.round(items.reduce((t,it)=>t+(it.total||0),0)*100)};
|
||||
};
|
||||
let freightQuoted = null;
|
||||
window.dtfFreight = async () => {
|
||||
const cep = entrega.cep;
|
||||
const service = (await ready).freight_service;
|
||||
if (!service) {
|
||||
$('cepMsg').textContent = 'A entrega ainda não está disponível. Escolha a retirada em Franca.';
|
||||
return;
|
||||
}
|
||||
const {metres, cents} = pacote();
|
||||
if (!metres) { $('cepMsg').textContent = 'Adicione um item ao pedido para cotar o frete.'; return; }
|
||||
$('cepMsg').textContent = 'Cotando o frete…';
|
||||
try {
|
||||
const result = await api('/freight',{service:'mock-standard',postal_code:cep});
|
||||
const result = await api('/freight',{service,postal_code:cep,metres:metres.toFixed(2),declared_cents:cents});
|
||||
if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
|
||||
entrega.valor = result.total_cents/100;
|
||||
entrega.dias = result.days || null;
|
||||
entrega.cotado = true;
|
||||
$('cepMsg').textContent = 'Frete estimado: '+rs(entrega.valor)+'.';
|
||||
freightQuoted = JSON.stringify(pacote());
|
||||
$('cepMsg').textContent = '';
|
||||
pintaEntrega();
|
||||
} catch(error) { $('cepMsg').textContent = error.message; }
|
||||
};
|
||||
// A different package is a different freight: quote it again.
|
||||
window.addEventListener('dtf-cart-changed',()=>{
|
||||
if (entrega.tipo==='frete' && entrega.cotado && freightQuoted && freightQuoted!==JSON.stringify(pacote())) {
|
||||
entrega.cotado=false; freightQuoted=null; window.dtfFreight();
|
||||
}
|
||||
});
|
||||
window.dtfCheckout = async () => {
|
||||
if (busy) return;
|
||||
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
|
||||
// The cart that was sent goes straight to its payment; a changed cart is sent again.
|
||||
if (draftId && quotedCart === cartSnapshot()) { await refresh(true); return; }
|
||||
if (draftId) clearDraft();
|
||||
if (!clienteOk() || !entrega.cotado) return;
|
||||
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
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 = itensAPagar();
|
||||
if (!cart.length) return message('Adicione um item ao pedido.');
|
||||
const initialCart=cartSnapshot();
|
||||
busy = true;
|
||||
$('bPagar').disabled = true;
|
||||
try {
|
||||
await ready;
|
||||
if((await api('/session')).cart_scope !== (await ready).cart_scope) throw new Error('Sua conta ou sessão mudou. Recarregue a página antes de enviar o carrinho.');
|
||||
const items=[];
|
||||
pintaEnvio();
|
||||
for (const item of cart) {
|
||||
if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.');
|
||||
const uploads=[];
|
||||
for (const file of item.localFiles) uploads.push(await upload(file));
|
||||
items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads});
|
||||
if (item.production?.version!==2 || item.production.sources?.length!==uploads.length)
|
||||
throw new Error('A montagem deste item precisa ser refeita antes da cotação.');
|
||||
items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads,
|
||||
production:{...item.production,sources:item.production.sources.map((source,index)=>({
|
||||
upload_id:uploads[index],...source}))},
|
||||
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 service = entrega.tipo==='retira' ? 'pickup' : (await ready).freight_service;
|
||||
if (!service) throw new Error('A entrega ainda não está disponível. Escolha a retirada em Franca.');
|
||||
const content = {customer:{...cliente},items,freight:{service,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) {
|
||||
requestKey = crypto.randomUUID(); requestBody = serialized;
|
||||
@@ -72,50 +286,310 @@
|
||||
localStorage.setItem('dtf-request-body',requestBody);
|
||||
}
|
||||
const quote = await api('/quotes',{request_key:requestKey,...content});
|
||||
quotedCart=initialCart;localStorage.setItem('dtf-quote-cart',quotedCart);
|
||||
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
|
||||
await refresh();
|
||||
status.scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
await refresh(true);
|
||||
} catch(error) { message(error.message); }
|
||||
finally { busy=false; pintaEntrega(); }
|
||||
};
|
||||
async function refresh() {
|
||||
if (!draftId) return;
|
||||
// `go`: the customer asked to pay, so the cart page moves to the payment page.
|
||||
async function refresh(go) {
|
||||
if (!naPagina()) { if (!go || !draftId) return; }
|
||||
else if (!draftId) { semPedido(); return; }
|
||||
const version=++refreshVersion, shownId=draftId;
|
||||
if (naPagina() && !actions.children.length) esqueletoPagamento();
|
||||
try {
|
||||
await ready;
|
||||
const quote=await api('/quotes/'+draftId);
|
||||
const quote=await api('/quotes/'+shownId);
|
||||
if(version!==refreshVersion || draftId!==shownId) return;
|
||||
if (!naPagina()) { message(''); vaiPara(PAGAMENTO); return; }
|
||||
shownCart=cartSnapshot();
|
||||
clearInterval(pixClock);
|
||||
unmountCard();
|
||||
actions.replaceChildren();
|
||||
message('');
|
||||
pintaResumo(quote);
|
||||
document.querySelector('.pagVolta').hidden = quote.status==='paid';
|
||||
if (quote.status==='paid') { confirmado(quote); return; }
|
||||
if (naPix() && quote.status!=='approved') { vaiPara(PAGAMENTO); return; }
|
||||
if (!quotedCart || quotedCart!==cartSnapshot()) {
|
||||
message('O carrinho mudou ou não está disponível neste navegador. A cotação anterior continua separada; envie o carrinho atual para uma nova revisão.');
|
||||
button('Enviar carrinho atual',()=>{clearDraft();window.dtfCheckout();});
|
||||
return;
|
||||
}
|
||||
if (quote.status==='pending_review') {
|
||||
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
|
||||
message((await ready).environment==='local'
|
||||
? 'Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.'
|
||||
: 'Arquivos enviados. Nossa equipe está conferindo a cotação '+draftId.slice(0,8)+'; o valor final aparece aqui em seguida.');
|
||||
} 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') {
|
||||
if (naPix()) await paginaPix(quote, version);
|
||||
else await escolhaPagamento(quote, version);
|
||||
return;
|
||||
}
|
||||
if ((await ready).environment !== 'local') {
|
||||
message('Cotação revisada. O pagamento online ainda não está disponível.');
|
||||
return;
|
||||
}
|
||||
// Local stack only: the simulated payment.
|
||||
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
|
||||
button('Criar pedido de teste',async event=>{
|
||||
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
|
||||
event.target.disabled=true;
|
||||
try {
|
||||
const order=await api('/orders/dev-paid',{quote_id:draftId});
|
||||
pedido=[]; itemAtual=null; limpaPaineis();
|
||||
await window.dtfClearCart?.();
|
||||
await pagos();
|
||||
message('Pedido #'+order.number+' criado e disponível no Kanban.');
|
||||
await refresh();
|
||||
} catch(error) { message(error.message); event.target.disabled=false; }
|
||||
});
|
||||
} else if (quote.status==='paid') {
|
||||
message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.');
|
||||
button('Novo pedido',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();});
|
||||
} else {
|
||||
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
|
||||
button('Nova cotação',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
||||
message('Cotação expirada. Envie o carrinho de novo para pagar.');
|
||||
button('Voltar ao carrinho',()=>{clearDraft();vaiPara(CARRINHO);});
|
||||
}
|
||||
} catch(error) {
|
||||
if(version!==refreshVersion || draftId!==shownId) return;
|
||||
message(error.message);
|
||||
actions.replaceChildren();
|
||||
button('Limpar referência e tentar de novo',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
||||
button('Limpar referência e tentar de novo',clearDraft);
|
||||
}
|
||||
}
|
||||
// The order is created by Mercado Pago's notification, never by this page,
|
||||
// so after paying the page only waits for the order to exist.
|
||||
let waitTimer=null;
|
||||
function waitForOrder() {
|
||||
clearInterval(waitTimer);
|
||||
waitTimer=setInterval(async()=>{
|
||||
try {
|
||||
const quote=await api('/quotes/'+draftId);
|
||||
if (quote.status==='paid') {
|
||||
clearInterval(waitTimer);
|
||||
await pagos();
|
||||
await refresh();
|
||||
} else if (quote.payment?.status==='rejected') {
|
||||
// Refused after the bank's confirmation or the provider's review.
|
||||
clearInterval(waitTimer);
|
||||
await refresh();
|
||||
message('Pagamento recusado. Tente de novo ou escolha outra forma de pagamento.');
|
||||
}
|
||||
} catch(_) {}
|
||||
},3000);
|
||||
}
|
||||
// 3-D Secure: the bank's own page, opened by posting its request in a frame.
|
||||
function showChallenge(challenge, into) {
|
||||
unmountCard();
|
||||
const frame=document.createElement('iframe');
|
||||
frame.name='confirmacaoBanco'; frame.title='Confirmação do banco'; frame.className='desafio';
|
||||
const form=document.createElement('form');
|
||||
form.method='POST'; form.action=challenge.url; form.target=frame.name;
|
||||
const input=document.createElement('input');
|
||||
input.type='hidden'; input.name='creq'; input.value=challenge.creq;
|
||||
form.append(input);
|
||||
into.replaceChildren(node('p','Confirme o pagamento na página do seu banco, abaixo.','pagNota'),frame,form);
|
||||
form.submit();
|
||||
form.remove();
|
||||
}
|
||||
function confirmado(quote) {
|
||||
clearInterval(waitTimer);
|
||||
const box=node('div',null,'confirmado');
|
||||
const titulo=node('h4','Pagamento confirmado');
|
||||
titulo.insertAdjacentHTML('afterbegin', icone('okCirculo'));
|
||||
box.append(titulo,
|
||||
node('p','Pedido #'+quote.order.number+' recebido e enviado para a produção. Você acompanha cada etapa em Meus pedidos.'));
|
||||
button('Ver meus pedidos',()=>location.assign('/conta/pedidos?order='+quote.order.id),box);
|
||||
actions.append(box);
|
||||
}
|
||||
const cartOk=()=>!!quotedCart && quotedCart===cartSnapshot();
|
||||
// What was paid leaves the cart; unchecked items stay for another order.
|
||||
async function pagos() {
|
||||
pedido=pedido.filter(it=>it.marcado===false); itemAtual=null; limpaPaineis();
|
||||
if (pedido.length) pintaPedido(); else await window.dtfClearCart?.();
|
||||
}
|
||||
|
||||
// Paying: card (the default, paid on this page) or PIX (paid on its own page).
|
||||
async function escolhaPagamento(quote, version) {
|
||||
const session=await ready;
|
||||
const opcoes=[...(session.payment_public_key?[['credito','Cartão de crédito'],['debito','Cartão de débito']]:[]),['pix','PIX']];
|
||||
let escolhido=opcoes[0][0];
|
||||
const grupo=node('div',null,'metodos');
|
||||
grupo.setAttribute('role','radiogroup'); grupo.setAttribute('aria-label','Forma de pagamento');
|
||||
const area=node('div',null,'area');
|
||||
const radios=opcoes.map(([key,label])=>{
|
||||
const el=button(label,()=>{ if(escolhido!==key){ escolhido=key; pinta(); } },grupo,'metodo');
|
||||
el.setAttribute('role','radio'); el.dataset.metodo=key;
|
||||
el.insertAdjacentHTML('afterbegin', icone(key==='pix'?'qrcode':'cartao'));
|
||||
return el;
|
||||
});
|
||||
actions.append(node('h4','Forma de pagamento'),grupo,area);
|
||||
async function pinta() {
|
||||
radios.forEach(el=>el.setAttribute('aria-checked',String(el.dataset.metodo===escolhido)));
|
||||
unmountCard(); area.replaceChildren(); message('');
|
||||
if (escolhido!=='pix') { await showCard(quote.approved, area, version, escolhido); return; }
|
||||
area.append(node('p','Clique em Pagar para gerar o QR code do PIX.','pagNota'));
|
||||
button('Pagar',async event=>{
|
||||
if (!cartOk()) { await refresh(); return; }
|
||||
event.target.disabled=true;
|
||||
try {
|
||||
await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}});
|
||||
vaiPara(PAGAMENTO_PIX);
|
||||
} catch(error) { message(error.message); event.target.disabled=false; }
|
||||
},area);
|
||||
}
|
||||
await pinta();
|
||||
}
|
||||
|
||||
// The PIX page. The intent is idempotent on the quote, so reopening this
|
||||
// page shows the same QR code instead of creating another charge.
|
||||
async function paginaPix(quote, version) {
|
||||
let intent;
|
||||
try { intent=await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}}); }
|
||||
catch(error) {
|
||||
if (version!==refreshVersion) return;
|
||||
message(error.message);
|
||||
button('Escolher outra forma de pagamento',()=>vaiPara(PAGAMENTO),actions,'pagBtn sec');
|
||||
return;
|
||||
}
|
||||
if (version!==refreshVersion) return;
|
||||
if (!intent.pix_qr_code) { message('Não foi possível gerar o PIX. Tente de novo em instantes.'); return; }
|
||||
const box=node('div',null,'pixBox');
|
||||
box.append(node('h4','Pague com PIX'),
|
||||
node('p','Abra o app do seu banco, escolha pagar com PIX e leia o QR code ou cole o código abaixo.','pagNota'));
|
||||
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=240;img.height=240;
|
||||
box.append(img);
|
||||
}
|
||||
const linha=node('div',null,'codigo');
|
||||
const code=document.createElement('input');
|
||||
code.readOnly=true;code.value=intent.pix_qr_code;code.setAttribute('aria-label','Código PIX copia e cola');
|
||||
linha.append(code);
|
||||
const copiar=button('Copiar código',async()=>{
|
||||
try{ await navigator.clipboard.writeText(intent.pix_qr_code);
|
||||
copiar.replaceChildren(); copiar.insertAdjacentHTML('beforeend', icone('ok')+'<span>Código copiado</span>'); }
|
||||
catch(_){ code.select(); }
|
||||
},linha);
|
||||
copiar.insertAdjacentHTML('afterbegin', icone('copiar'));
|
||||
const prazo=node('p',null,'prazo');
|
||||
prazo.insertAdjacentHTML('afterbegin', icone('relogio'));
|
||||
const prazoTx=node('span'); prazo.append(prazoTx);
|
||||
box.append(linha,prazo,node('p','Aguardando a confirmação do pagamento…','aguarda'));
|
||||
actions.append(box);
|
||||
waitForOrder();
|
||||
// The code stops working when it expires; a new one is a click away.
|
||||
const fim=Date.parse(intent.expires_at);
|
||||
if (!Number.isFinite(fim)) { prazo.remove(); return; }
|
||||
const tick=()=>{
|
||||
const s=Math.max(0,Math.ceil((fim-Date.now())/1000));
|
||||
prazoTx.textContent='Pague em '+String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');
|
||||
if (s>0) return;
|
||||
clearInterval(pixClock);
|
||||
const fimBox=node('div',null,'pixBox');
|
||||
fimBox.append(node('h4','O código PIX expirou'),
|
||||
node('p','Gere um novo código para pagar. O anterior não pode mais ser pago.','pagNota'));
|
||||
button('Gerar novo PIX',()=>refresh(),fimBox);
|
||||
actions.replaceChildren(fimBox);
|
||||
};
|
||||
tick();
|
||||
pixClock=setInterval(tick,1000);
|
||||
}
|
||||
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
// The cardholder's document as the card form collected it.
|
||||
function cardholder(id) {
|
||||
const number=String(id?.number||'').replace(/\D/g,'');
|
||||
const type=String(id?.type||'').toUpperCase();
|
||||
return ['CPF','CNPJ'].includes(type) && /^[0-9]{11,14}$/.test(number)
|
||||
? {payer_document_type:type, payer_document:number} : {};
|
||||
}
|
||||
let cardBrick=null;
|
||||
function unmountCard() {
|
||||
if (cardBrick) { try { cardBrick.unmount(); } catch(_) {} cardBrick=null; }
|
||||
}
|
||||
async function showCard(approved, into, version, tipo) {
|
||||
const holder=node('div');
|
||||
holder.id='cardPaymentBrick';
|
||||
const espera=node('div',null,'skel skelBloco');
|
||||
into.append(espera,holder);
|
||||
try { await loadMercadoPago(); }
|
||||
catch(error) { espera.remove(); message(error.message); return; }
|
||||
const session=await ready;
|
||||
// The customer may have switched to PIX, or the page re-rendered, meanwhile.
|
||||
if (!holder.isConnected || version!==refreshVersion) return;
|
||||
unmountCard();
|
||||
const mp=new window.MercadoPago(session.payment_public_key,{locale:'pt-BR'});
|
||||
cardBrick=await mp.bricks().create('cardPayment','cardPaymentBrick',{
|
||||
initialization:{amount:approved.total_cents/100, payer:{email:approved.customer.mail}},
|
||||
// Each option takes only its kind of card; debit is paid at once.
|
||||
customization:{paymentMethods:tipo==='debito'
|
||||
? {maxInstallments:1,types:{excluded:['credit_card']}}
|
||||
: {maxInstallments:12,types:{excluded:['debit_card']}},
|
||||
// The option above already names the card; the form's own title
|
||||
// ("crédito ou débito") would contradict it.
|
||||
visual:{hideFormTitle:true,
|
||||
style:{customVariables:{baseColor:'#FFA81A',buttonTextColor:'#03060B'}}}},
|
||||
callbacks:{
|
||||
onReady:()=>{ espera.remove(); },
|
||||
onError:error=>{ console.error(error); message('Erro no formulário do cartão. Confira os dados e tente de novo.'); },
|
||||
onSubmit:async data=>{
|
||||
if (!cartOk()) { await refresh(); throw new Error('cart changed'); }
|
||||
message('');
|
||||
let result;
|
||||
try {
|
||||
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),
|
||||
...cardholder(data.payer?.identification)}});
|
||||
} catch(error) {
|
||||
// Rejecting stops the form's spinner; the reason is shown above it.
|
||||
message(error.message || 'Não foi possível concluir o pagamento. Tente de novo.');
|
||||
status.scrollIntoView({behavior:'smooth',block:'center'});
|
||||
throw error;
|
||||
}
|
||||
if (result.challenge) { showChallenge(result.challenge, into); waitForOrder(); return; }
|
||||
if (result.status==='approved' || result.status==='pending') {
|
||||
unmountCard();
|
||||
into.replaceChildren(node('p',result.status==='approved'
|
||||
? 'Pagamento aprovado. Confirmando seu pedido…'
|
||||
: 'Pagamento em análise pelo Mercado Pago. Esta página atualiza assim que ele for confirmado.','pagNota'));
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!holder.isConnected || version!==refreshVersion) unmountCard();
|
||||
}
|
||||
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
|
||||
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
|
||||
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){
|
||||
if(draftId!==quoteFromPortal){quotedCart=null;localStorage.removeItem('dtf-quote-cart');}
|
||||
draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);
|
||||
}
|
||||
// Re-render the payment page only when the cart really changed: re-rendering
|
||||
// would remount the card form under the customer's typing.
|
||||
window.addEventListener('dtf-cart-changed',()=>{if(draftId && naPagina() && shownCart!==cartSnapshot()) refresh();});
|
||||
// The cart page keeps only the sending progress and errors, never a payment.
|
||||
window.addEventListener('dtf-page-changed',()=>{
|
||||
if (naPagina()) refresh();
|
||||
else if (!busy) { refreshVersion++; clearInterval(pixClock); message(''); actions.replaceChildren(); pintaResumo(null); }
|
||||
});
|
||||
refresh();
|
||||
})();
|
||||
|
||||