Compare commits

...

31 Commits

Author SHA1 Message Date
Cauê Faleiros
641dc6053b ci: publish images from every green push to main
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m5s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m54s
The release job enforced the production source preflight, which blocks while
the payment and messaging adapters are fake. They still are, by design, so
since 2026-09-23 no release could succeed and production kept running older
images while main moved on.

Pushes to main that pass validation, the integration suite and the scans now
build, scan and publish the images. Nothing is deployed automatically:
production changes when the stack is pulled and redeployed in Portainer. A
manual run also calls the Portainer webhook when one is configured. The
preflight stays in the scan job, advisory unless ENFORCE_PRODUCTION_PREFLIGHT
is true, in which case a blocked preflight stops publishing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:30:43 -03:00
Cauê Faleiros
4c01e932c3 feat: place PDF artwork in print files, add card payment, count only failed logins
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m23s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
PDF artwork: a single-page PDF source is placed in the print file as a
vector form through pikepdf, never rasterised, using the CropBox and
inherited /Rotate the Site measured with pdf.js. Multi-page and protected
PDFs go to hand preparation. PyMuPDF was not used because of its AGPL
licence. Raster tests cover crop, page rotation, placement rotation and
mirroring, and fail when the rotation or crop handling is broken.

Card payment: Mercado Pago's Card Payment Brick on the Site 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, and the intent
route refuses new attempts once a payment is approved or a card is in
review, so a quote cannot be charged twice. The Site CSP admits Mercado
Pago's origins only through PAYMENT_CSP_SOURCES, empty by default.

Logins: every attempt counts against the source address, only failures
against the account. Counting successful sign-ins let ordinary use lock an
operator out and made CI's final browser sign-in fail.

No new required settings; production behaviour is unchanged until the
provider credentials are configured. Verified with the full CI integration
sequence locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:14:56 -03:00
Cauê Faleiros
e3d5558198 feat: connect Tiny through its v3 API with OAuth
All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Tiny v3 replaces the v2 token adapter. An operator connects Tiny once from
the Kanban; the callback is authorised by a single-use state, because Tiny's
cross-site redirect does not carry the SameSite=Strict operator cookie.
Tokens are kept in provider_tokens, the refresh token rotates under a row
lock, and the worker keeps the connection alive while order creation is off.

Orders find or create the customer's contact by CNPJ, then POST /pedidos
with product ids from TINY_PRODUCT_TEXTIL_FOLHA, _TEXTIL_AVULSA, _UV_FOLHA
and _UV_AVULSA and numeroOrdemCompra DTF-<number>; a retry searches the
customer's recent orders for that number first. The product settings avoid a
_FILE suffix, which the secrets loader reads as a secret file path.

Production passes the application credentials through but keeps
TINY_ADAPTER fake: Tiny has no sandbox, so creating real orders waits for a
supervised test. compose.providers.yaml gives the local API and worker an
internet route for provider testing; the default local stack still has none.

Verified with the full CI integration sequence locally, including the new
tiny_oauth_test against the real database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:46:09 -03:00
Cauê Faleiros
c18b9e5b87 feat: generate print files, collect delivery addresses, add provider adapters
All checks were successful
Build and deploy / Validate source (push) Successful in 1m45s
Build and deploy / Integration suite on a real stack (push) Successful in 4m48s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Week 2 work that did not need client inputs.

Print files (1.4): 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. Sources are embedded once at original resolution; JPEG
bytes pass through and PNG alpha becomes a soft mask. Artwork the generator
cannot reproduce goes to hand preparation with the reason. The worker renders
outside any transaction, and the operator approves the generated file as the
final one through the existing review.

Delivery address (3.8): required for any non-pickup quote, bound to the
quoted CEP, carried into the order snapshot, the Kanban card and Tiny.

Kanban (1.5): print-file status per item, and a panel of payment events that
need a person (money without an order, refunds after an order) until an
operator records the resolution.

Mercado Pago and Tiny (1.1, 1.3): adapters written from the public API
documentation and tested against fake transports only. Selectable for
sandbox testing with their credentials; the production preflight still
blocks release. Adds payment intents and a PIX step on the Site.

MinIO: Docker Hub and quay.io now refuse anonymous pulls, so local and CI
storage use Chainguard's MinIO build, pinned by digest.

Verified with the full CI integration sequence on a fresh local build,
including the new print_file_test and both browser suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 11:56:46 -03:00
Cauê Faleiros
cfcbe545f1 ci: require manual gated releases from main
All checks were successful
Build and deploy / Validate source (push) Successful in 1m28s
Build and deploy / Integration suite on a real stack (push) Successful in 4m3s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
2026-09-23 11:27:18 -03:00
Cauê Faleiros
24013458c9 fix: harden week-two ordering, artwork and operations 2026-09-23 10:40:18 -03:00
Cauê Faleiros
ccc25a2d5d feat: accept payment notifications, once, from a verified sender
There was no inbound payment path at all: a button called a fake synchronously
and wrote an order. A real provider does the opposite — it charges, then tells
us, repeatedly, out of order, and sometimes long afterwards.

POST /api/payments/webhook verifies the signature before the body is parsed, so
an unsigned or tampered delivery is refused and recorded without touching an
order. Verified deliveries are stored under the provider's own event id with a
unique constraint, and applied inside the same transaction that marks them
processed: a repeat is a no-op, a crash is retried rather than half-applied.

An approval whose amount disagrees with the reviewed quote does not become an
order. Underpayment would ship artwork nobody paid for, and overpayment means
something a person should look at.

Order creation moved to app/payments.py so the webhook and the local development
checkout share one implementation and cannot drift. That also closes 3.5: the
charge happens inside the transaction that persists the order, rather than
before it.

The adapter contract is create/verify/parse. FakePayment implements it with a
real HMAC scheme so the whole path is exercised now, by tests/payment_test.py:
unsigned, tampered, underpaid, duplicate, re-sent, unknown reference, and
non-approved statuses. Connecting Mercado Pago is one adapter; no service code
changes.

PAYMENT_WEBHOOK_SECRET is optional in production on purpose. Required would
break the next Portainer render, and a guessable default would be worse than
either: with no secret configured the adapter verifies nothing and therefore
accepts nothing, which is the right state until a provider is connected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:25:08 -03:00
Cauê Faleiros
7386469404 docs: move the engineering documents into docs/
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 1m18s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m26s
Thirteen files at the repository root, seven of them documents. Only README.md
earns a place there; the rest are now in docs/ beside the meeting notes, the
client roadmap and the historical material.

The compose files stay. docker-compose.yml is the path the dtf-cloud Portainer
stack reads, so moving it would break deployment, and Docker resolves a compose
file's relative build contexts against its own directory, so moving the other
two would silently break every build. Both reasons are now written down where
someone would otherwise try it.

Correcting references turned up a live fault: the Portainer stack creation
instructions still named deploy/stack.yaml as the compose path. That file was
removed, so anyone recreating the stack from these instructions would have
failed. It names docker-compose.yml now, with the reason it stays at the root.

ROADMAP.md keeps the paths its closed findings were written with, and says so at
the top. Those entries record where a fault was when it was found; rewriting
them to match a later layout would make the record less true, not more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:52:20 -03:00
Cauê Faleiros
b329f76378 refactor: lay the repository out by role
All checks were successful
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Successful in 1m25s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m31s
local/ held six unrelated things under a name that stopped being true once it
became the production runtime: the service, the frontend, the tests, the ops
commands, the container definitions and the dependency lock, 65 files with
nothing to tell them apart.

  app/      the service: api/ routers, core/ for identity, database, models,
            prices and secret loading, and the worker, bootstrap and schema
  tests/    the twelve suites, no longer inside the shipped package
  ops/      backup, readiness, dependency audit, security summary
  infra/    Dockerfiles, gateway templates, ClamAV and storage configuration,
            the requirements and their hash lock
  web/      the Site, Kanban and portal pages with their scripts

deploy/Dockerfile.api now copies app/ alone, so the tests stop shipping to
production; the local image still carries them, because the suites run inside
the stack's network.

Five kinds of reference had to follow, and each was found by something different
rather than by reading. Imports of the form "from . import db" survived a rewrite
that only matched "from .db import". Tests kept relative imports of modules that
had left the package. A mock.patch target names its module in a string, where no
import rewriting can see it. The browser test resolves a fixture by path. And the
release gate's markers pointed at local/runtime.py and local/worker.py, which is
the decay its new marker test exists to catch — it caught it.

Verified from docker compose down -v: the stack starts, all six integration
suites, both browser suites and the twenty-nine unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:40:57 -03:00
Cauê Faleiros
c9f8122600 refactor: give the frontend its own directory and split the API into routers
The Site's page sat at the repository root while its scripts lived in
local/static, a split with no reason behind it. They are together in web/ now,
with the page as index.html, which is also what the image serves.

app.py held the adapters, the configuration, the shared query helpers and
nineteen routes; customer.py held fourteen more but could not import from it
without a cycle, so it was wired by passing nine callables into install_routes.
Configuration and shared helpers move to local/runtime.py, the rules for
attaching artwork to an order move to local/artwork.py where a customer
correction and an operator final-file set can share them, and the routes become
seven routers under local/api. app.py is 48 lines that create the application,
apply the middleware and include them. Routers import downwards only.

Three faults came out of the extraction and are worth recording, because each
passed a check that looked sufficient. ast reports a function's line at the def,
so every decorator on the line above fell outside the extracted range: twelve
routes and the security middleware were defined but never registered, and the
files still imported and parsed cleanly. Names the old closure renamed on the
way in, and a Jsonb import, were missing in three modules. A name-resolution
pass over every new module found those; the route count matching the original
exactly, 32, is what confirmed the first.

The release gate's marker for the fake payment adapter pointed at app.py and the
adapter moved to runtime.py, so the gate passed while the condition it guards was
unchanged. That is the same silent decay 2.5 set out to fix. A test now asserts
every marker still matches something in its file, so the next move fails loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:22:00 -03:00
Cauê Faleiros
66ddb17f02 chore: untrack deliverables committed by mistake, and home the stray files
All checks were successful
Build and deploy / Validate source (push) Successful in 10s
Build and deploy / Integration suite on a real stack (push) Successful in 1m47s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m38s
The previous commit used git add -A and swept in four binaries that were
deliberately untracked: the week-1 client report as .docx and .pdf, a duplicate
of it under output/documents, and imagem-teste.jpg, an input dropped in to test
with. None of them are the repository's to version. They are untracked here,
left on disk, and covered by .gitignore so the mistake cannot repeat.

Three files had no sensible home. The meeting notes sat at the repository root
under a 78-character name with spaces and accents, the roadmap generator lived
in tmp/ — a directory otherwise ignored as scratch — and its output in output/,
which is otherwise generated evidence. They are now docs/reuniao-2026-09-09-
anotacoes.pdf, tools/generate_dtf_report.py and docs/roadmap-cliente.pdf, with
CONTEXT.md and ROADMAP.md updated to match and a docs/README.md saying what each
document is for.

.gitignore no longer needs four rules to keep one generator out of an ignored
directory; tmp/ is scratch again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 16:47:36 -03:00
Cauê Faleiros
96f1d27221 refactor: split the Site's behaviour out of one 1,575-line inline script
dtf-site.html held commercial rules, the nesting engine, PDF analysis, the cart
and every handler in a single inline script, 42% of the runtime code in one
file, and the money logic lived in the middle of it.

It is now nine files under local/static, cut at the section markers the original
author left, so no function was split across a boundary: config, product modes,
upload, sheet analysis, PDF, quality, packing, cart, flow. They load as classic
scripts in the original order and share one global scope, so evaluation is
exactly what it was; the extraction was checked byte-identical against the
original before the tags replaced it. dtf-site.html is 1,394 lines of markup and
style.

With no inline script left anywhere, the policy no longer needs a hash
allowlist: script-src is now 'self' alone, which is stronger than what it
replaced and cannot drift as the page changes.

Three things depended on the old shape and were updated rather than worked
around. The pricing parity test read the ladder out of the HTML and now reads it
from site-config.js, still proving the server agrees with what the customer is
shown. The isolated artwork test served four hardcoded script paths and now
serves any script that resolves inside local/static, so the next file added does
not silently 404. The CSP assertion checked the whole policy for 'unsafe-inline'
and now checks the script-src directive alone, since style-src legitimately
carries it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 16:44:49 -03:00
Cauê Faleiros
ca698434a2 chore: remove the abandoned prototypes and archive what described them
portal/, kanban/ and agente/ were 2,034 lines implementing the original
Tiny-first model: token upload links, a second SQLite Kanban, a factory agent.
Nothing imported or started any of it, and several endpoints took the acting
user from the request body with no authentication at all. Their real cost was
that a reader arriving at this repository found two Kanbans and two portals and
had to work out which one was real. The root schema.sql and .env.exemplo went
with them: both code paths load local/schema.sql, and having .env.exemplo beside
.env.example differing by one letter was a trap rather than a convenience.

The documents describing that model are archived rather than deleted. They
record decisions and reasoning the current documents do not repeat, so they are
worth keeping as background, with a header saying plainly that they are not
instructions.

README.md keeps its business case — the capacity figures and the cost argument
are still the reason this project exists — but now states where the prototype
documentation begins and that the code it describes is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 16:34:20 -03:00
Cauê Faleiros
86b8199612 fix: create indexes after the tables they name
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 1m20s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m37s
The 4.1 indexes were added beside the existing uploads_owner, which sits partway
through schema.sql, so CREATE INDEX ... ON dtf_local.order_files ran before that
table was created and bootstrap aborted with UndefinedTable. db-init then
restarted on failure without ever completing, and everything waiting on it timed
out.

Every local run passed because those volumes already had the tables. Only a
clean database exposes it, which is what CI has and my checks did not.

All eleven indexes now sit at the end of the file, after every table, with an
assertion in the change that each indexed table is created before its index.
Verified from docker compose down -v: the stack starts, bootstrap completes,
eleven indexes exist, and the full suite passes.

Recorded as ROADMAP 5.11: nothing exercises the schema against an empty
database, which is the only way this class of fault appears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 15:00:10 -03:00
Cauê Faleiros
543a9a9fb4 perf: index the real queries, bound the board, and correct what the Site promises
Some checks failed
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Failing after 10m30s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Five items that needed no decisions.

Indexes: the schema indexed only uploads(owner), so the worker's once-a-second
outbox poll scanned a table that only grows, and every per-customer and
per-order lookup did the same. Ten indexes now follow queries the application
actually issues, and no more, since each one is paid for on every write. The
outbox and live uploads use partial indexes so they stay the size of the backlog
rather than of all history. Confirmed against the database that the planner
chooses them.

Board: /api/operator/board returned every order ever created. Finished orders
are terminal, so they were pure growth. It now returns everything still in
progress however old, plus a window of recent finished ones and the true
finished total, and the Kanban column says "50 de 213" rather than letting the
count read as an all-time figure. An operator cannot lose a card they could act
on.

Dependencies: the root requirements.txt was the prototype's, pinned by wildcard,
listing packages this system does not use, next to the hash-locked lock file.
Deleted. pip was pinned as a runtime dependency, which installed a package
manager into the read-only production image; nothing depended on it, so it is
gone from both the direct list and the lock, and the base image's pip performs
the hash-enforced install.

Retention copy: the Site told customers their artwork was kept 90 days with 12
months of history, and invited them to reorder without uploading again. Files
are kept 30 days. The copy now matches the policy and drops the promise the
system cannot keep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:29:07 -03:00
Cauê Faleiros
91269ce414 docs: close 4.5, resolved by removing the second stack definition
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:18:09 -03:00
Cauê Faleiros
a87403338d feat: give each Kanban operator their own account
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 1m17s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m37s
One OPERATOR_EMAIL and OPERATOR_PASSWORD served the whole factory, so every card
movement recorded the same name and the movement history could not answer who
did what. Traceability was one of the things the project set out to provide.

Accounts live in dtf_local.operators, authenticated with the same scrypt hashing
as customer accounts and with comparable work whether or not the account exists,
so absence is not observable by timing. Administration is a CLI in the API
container, like the schema migration: list, add, password, disable, enable.
Passwords are read from the terminal rather than an argument so they stay out of
shell history and the process list, and disabling deletes that operator's open
sessions instead of leaving them valid for the rest of the eight-hour window.

Migration is the part that could hurt: an empty table means 503 and a factory
locked out of its Kanban. OPERATOR_EMAIL and OPERATOR_PASSWORD seed the first
account, and only when that email is absent, so a password changed through the
CLI survives a redeploy carrying a stale environment variable. The first attempt
at this silently did nothing, because db-init receives its own small environment
and had neither variable; both compose files now pass them to it.

Verified against a running stack: bootstrap seeds the existing credential, that
credential still logs in unchanged, a second operator authenticates separately,
wrong passwords and unknown accounts are rejected alike, and disabling revokes
an open session immediately.

Roles are left out on purpose. The separation of duties the meeting described
governs rework authorisation, which this system does not implement, so a role
model would have no consumer to serve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:13:47 -03:00
Cauê Faleiros
da903db32a feat: serve pdf.js from this origin instead of a CDN
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 1m10s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m43s
The Site pulled pdf.js 3.11.174 from cdnjs with no integrity attribute, and the
policy trusted the whole of cdnjs.cloudflare.com for both script-src and
worker-src. Anything that host served would have executed, and a customer
measuring a PDF sheet depended on it being reachable.

Vendor both files instead of pinning a hash: it removes the dependency rather
than constraining it, and lets the policy name only 'self'. Provenance and
SHA-256 digests are recorded in local/static/vendor/README.md, verified on
download against the SRI digests cdnjs publishes for that release.

cdnjs is now absent from script-src, worker-src and connect-src in both gateway
templates. Workers are 'self' plus blob:, which the Site needs for the worker it
constructs itself.

Verified in a browser against the running stack: pdf.js loads from /vendor/, the
blob worker starts, and a real seven-page PDF parses with no CSP violation. Both
browser suites and the full integration suite pass.

The version is deliberately unchanged. 3.11.174 is old, but its known eval path
is already closed by isEvalSupported:false, and upgrading is an API change that
needs its own testing rather than riding along with this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:56:02 -03:00
Cauê Faleiros
9926d3ab55 chore: remove the unused second stack definition
deploy/stack.yaml arrived in the first commit and was never deployed. Portainer
runs the repository's docker-compose.yml. Keeping both meant two definitions
drifting apart, with the documentation naming the one nobody used, which is how
the credential question came up at all.

The hardening it offered is narrower than it looks: Docker secrets keep values
out of docker inspect and the Portainer console, but local/secrets.py loads them
into the process environment regardless, and anyone able to read docker inspect
can already read the secret files. With a single Portainer user, the benefit that
remains does not outweigh maintaining a divergent copy.

local/secrets.py stays: inert against the deployed file, and it lets a stack
switch to Docker secrets later without touching code. The preflight and its tests
degrade cleanly when no such stack is present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:48:22 -03:00
Cauê Faleiros
e95a42dbed docs: name the stack Portainer actually deploys, and its credential exposure
PORTAINER.md called deploy/stack.yaml the production stack. The deployed file is
docker-compose.yml, which supplies eight credentials as plain environment
variables where stack.yaml uses Docker secrets. That puts the database password,
operator password and the R2 secret key in the container environment, readable
through docker inspect and the Portainer stack editor.

Recorded as ROADMAP 2.12 with the three options rather than changed here:
altering how production receives credentials is not a quiet change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:42:12 -03:00
Cauê Faleiros
c1a07a75fa ci: run the integration suites inside the stack's own network
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 1m12s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m50s
The suites connected to localhost:<published port>, which works for a developer
but not on a containerised runner: published ports live in the host's network
namespace, so the runner container gets connection refused.

Run them from inside the stack instead, against the gateway by service name.
SITE_BASE_URL and SITE_HOST_HEADER make that possible without weakening what is
under test: the Host stays "localhost", so the gateway's host check and
TrustedHostMiddleware see exactly what a localhost run produces, and the tests
that deliberately send their own Host still override it.

S3_PUBLIC_ENDPOINT has to agree, because presigned URLs are signed against it
and the signature covers the host, so it cannot be rewritten afterwards. CI
points the whole stack at http://storage:9000 so the URLs it hands out are
reachable by whoever follows them.

The browser suites still need Chrome to reach the stack from the runner, which
the same namespace split prevents. They now check reachability and skip with a
warning instead of failing with a bare connection error; recorded as ROADMAP
5.10, since they are the only coverage for the artwork editor.

Verified both ways: the six suites pass inside the network, and an unchanged
developer localhost run still passes, as do both browser suites locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:29:15 -03:00
Cauê Faleiros
3b92813491 fix: recover the customer address behind the host reverse proxy
Some checks failed
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Failing after 51s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
The production gateway does not face the internet: nginx-proxy-manager owns
80/443 on the host and proxies to it. So $remote_addr inside the gateway is that
proxy, and overwriting X-Forwarded-For with it discarded the customer address
the proxy had already recorded. Every request would have been attributed to one
internal address, which is exactly the fault 2.1 set out to fix, reintroduced in
production only.

Use real_ip to take the customer address from the proxy's header, trusting only
private networks. A request that reaches the published port directly from the
internet is not trusted, so its header is ignored and $remote_addr stays the
real peer: the anti-spoofing property is kept.

Also downgrade 2.9. TLS is not missing, it is terminated by that proxy. The gap
is that the repository never says so, which would break every session cookie if
the stack moved to a host without one.

Validated with nginx -t against the rendered production configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:07:56 -03:00
Cauê Faleiros
7cab210674 ci: move the integration stack clear of the ports that host already uses
8000 was Portainer's Edge tunnel, not a stray process. The first attempt at a
fix picked 18080/18081, which are the production dtf-cloud stack's own defaults
in docker-compose.yml: it would have passed only while that stack was down and
collided again the moment it came back.

Use 28080/28081/28000/29000/29001, clear of Portainer (8000, 9443), both
production stack definitions (18080/18081 and 8080/8081) and the usual MinIO
ports. The occupants are listed in the workflow so the next person choosing a
port can see what is taken.

Ephemeral ports would remove the guesswork but do not work here: the published
port is baked into PUBLIC_ORIGIN, ALLOWED_ORIGINS and the CSP when the
containers start, so it has to be known before they run.

Full suite verified on the new block, including the browser end-to-end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:04:56 -03:00
Cauê Faleiros
24cb52d992 fix: stop the CI stack colliding with ports already used on the runner
The integration job failed with "Bind for 0.0.0.0:8000 failed: port is already
allocated". The runner shares the host's Docker daemon, so every published port
is claimed on the machine itself, where other services already listen. Port 8000
was the first collision; 8080, 8081, 9000 and 9001 were equally exposed.

MinIO's ports were hardcoded, and S3_PUBLIC_ENDPOINT was pinned to
localhost:9000 independently, so moving storage would have broken the presigned
URLs the browser fetches. Both now derive from STORAGE_PORT and move together.

CI runs on 18080/18081/18000/19000/19001. Local defaults are unchanged.

Verified by running the whole stack and the full suite on exactly those ports,
including the browser end-to-end, which downloads through a presigned URL and so
proves the storage endpoint followed the port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:00:17 -03:00
Cauê Faleiros
6a50e6db4d fix: bake scanner and storage config into images instead of bind-mounting
Some checks failed
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Failing after 54s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
The integration job failed starting the scanner:

  error mounting ".../local/clamd.conf" to rootfs at "/etc/clamav/clamd.conf":
  not a directory

The files are in the repository, so this was not a missing checkout. A
containerised CI runner shares the host's Docker daemon, so "./local/clamd.conf"
resolves to a workspace path that exists inside the runner but not on the host
where the daemon creates the mount. The daemon makes an empty directory there
and the container cannot start. Only the bind-mounting services were affected,
which is why PostgreSQL and MinIO came up first.

Build the scanner and storage-init images with their configuration copied in, so
compose.local.yaml no longer bind-mounts anything from the host and works
regardless of how the runner reaches the daemon. Both bases stay overridable
through CLAMAV_IMAGE and MINIO_IMAGE.

The production stack is unaffected: it ships clamd.conf as a Swarm config, which
the manager reads at deploy time.

Verified from a clean slate: the stack starts, the scanner runs the baked
configuration, storage provisioning runs from the baked script, and the full
suite passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 12:51:08 -03:00
Cauê Faleiros
dbc9ba4dee docs: record an intermittent browser test failure
Some checks failed
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Failing after 1m10s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 12:33:58 -03:00
Cauê Faleiros
6c52ad655e fix: pull MinIO from quay.io so CI can start the stack
The integration job failed on the runner with "pull access denied for
minio/minio ... may require 'docker login'". Docker Hub now refuses anonymous
pulls of minio/minio: an unauthenticated manifest request returns 401
UNAUTHORIZED, while library/postgres returns 200, which is why only MinIO
failed. It worked locally only because this machine is logged in to Docker Hub.

quay.io serves the same release anonymously, and it is the same image: both
registries resolve to image ID sha256:a1ea29fa2835. MINIO_IMAGE overrides it for
anyone mirroring into their own registry.

Verified by deleting the Docker Hub copy locally and starting the stack from
quay alone, then running the full suite against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 12:33:46 -03:00
Cauê Faleiros
010c2a162f docs: record base image pinning and the CRITICAL image gate
Some checks failed
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Failing after 6s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:58:46 -03:00
Cauê Faleiros
4c9fa2436e build: pin base image digests and clear every fixable image finding
The production images built on mutable tags with --pull, so the same commit
could produce different bases, and neither Dockerfile upgraded its OS packages
even though the local ones did. The published API image carried 56 HIGH and 3
CRITICAL findings, 15 of them with an upstream fix available.

Pin both bases by digest and upgrade OS packages in the production images. That
removes all 3 CRITICAL and 13 of the 15 fixable findings. The remaining two,
msgpack and setuptools, come from a third-party SBOM; neither package is
importable or listed by pip in the built image, which I confirmed rather than
taking the previous report's word for it.

The web image could not be fixed this way: the official 1.28 line pins
nginx=1.28.3-r1 in /etc/apk/world, so apk upgrade leaves five HIGH findings in
place even though Alpine ships 1.28.3-r7. Moving to nginx:alpine (1.31.6)
clears them completely; 1.29-alpine scans worse, at 37 HIGH. Same uid 101 and
the same template entrypoint, and the local images now use the same pinned
bases so the integration suite exercises what ships. Full suite passes on
nginx 1.31.6, including the browser end-to-end.

With both images at zero CRITICAL, the image scan now blocks on CRITICAL and
reports HIGH, instead of reporting everything. PYTHON_BASE_IMAGE and
NGINX_BASE_IMAGE are wired through to the builds so a base can move forward
without editing the repository, which is what PORTAINER.md already promised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:58:32 -03:00
Cauê Faleiros
bbcc8ab100 docs: record the release gates and what they do not cover
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:45:23 -03:00
Cauê Faleiros
9da2a7dcad ci: add the release gates, and document the ones that do not gate
PORTAINER.md and SECURITY_REPORT.md described a pipeline that required
regressions, HIGH/CRITICAL secret, misconfiguration and image gates, and stated
that the source preflight stopped this application from publishing. None of it
ran: the workflow built and called the webhook unconditionally.

Add a blocking Trivy secret scan. Verified both ways: a planted AWS key pair,
GitHub token and private key block the job, and the repository passes clean.
Note that Trivy allowlists documented example credentials, so this gate is a
backstop, not permission to commit secrets.

The source preflight now runs on every push and always prints its verdict, but
enforces only when ENFORCE_PRODUCTION_PREFLIGHT is true. Enforcing it today
would block every deployment, because it refuses a release while the payment
and messaging adapters are fake, which is the deliberate state the stack runs
in. Set the variable when real adapters land.

Image vulnerabilities are reported after each build rather than enforced. The
current bases carry 56 HIGH and 3 CRITICAL findings, only 15 of them with an
upstream fix, so failing on them would stop releases without making anything
safer. Pinning digests and triaging the fixable ones is ROADMAP 2.6.

Both documents now carry a table of what gates and what does not, instead of
describing checks that did not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:45:11 -03:00
154 changed files with 9088 additions and 6221 deletions

View File

@@ -20,6 +20,23 @@ APP_ENV=local
PAYMENT_ADAPTER=fake
FREIGHT_ADAPTER=fake
TINY_ADAPTER=fake
# Sandbox only (see docs/LOCAL_SETUP.md, "Provider sandboxes"):
# PAYMENT_ADAPTER=mercadopago
# MP_ACCESS_TOKEN=TEST-...
# MP_WEBHOOK_SECRET=...
# MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
# Tiny API v3: client ID/secret come from the "Aplicativo" created in Tiny
# (Configurações > Geral > Aplicativos); the redirect URI registered there
# must be exactly TINY_REDIRECT_URI. Product ids are the Tiny products each
# Site product becomes. Tiny has no sandbox: orders created are real.
# TINY_CLIENT_ID=...
# TINY_CLIENT_SECRET=...
# TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback
# TINY_PRODUCT_TEXTIL_FOLHA=...
# TINY_PRODUCT_TEXTIL_AVULSA=...
# TINY_PRODUCT_UV_FOLHA=...
# TINY_PRODUCT_UV_AVULSA=...
# TINY_ADAPTER=tiny # only once connected and tested: creates real orders
WHATSAPP_ADAPTER=fake
STORAGE_ADAPTER=s3-local
MOCK_FREIGHT_CENTS=1500

View File

@@ -1,27 +0,0 @@
# HISTORICAL PROTOTYPE ONLY. Do not use for the local stack; use .env.example.
# ---- portal (nuvem) ----
DATABASE_URL=postgresql+psycopg://dtf:senha@localhost/dtf
S3_ENDPOINT=https://<conta>.r2.cloudflarestorage.com
S3_BUCKET=dtf-artes
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
BASE_PORTAL=https://arte.dropstaratacado.com.br
BASE_KANBAN=http://servidor:8080
WEBHOOK_TOKEN=troque-isto
AGENTE_TOKEN=troque-isto-tambem
AGENTE_WEBHOOK=http://ip-da-fabrica:8080/api/agente/acordar
# ---- Tiny ----
TINY_CLIENT_ID=
TINY_CLIENT_SECRET=
TINY_TOKEN_URL=
TINY_BASE=https://api.tiny.com.br/public-api/v3
# ---- WhatsApp ----
WHATS_PROVEDOR=meta
WHATS_TOKEN=
WHATS_NUMERO_ID=
# ---- fábrica ----
PASTA_DTF=\\servidor\DTF
KANBAN_DB=C:\dtf\kanban.db

View File

@@ -16,14 +16,14 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Run fast regression checks
run: |
python3 -m py_compile local/*.py deploy/*.py
python3 -m py_compile app/*.py app/**/*.py ops/*.py deploy/*.py
python3 -m unittest \
local.test_dependency_lock \
local.test_staging_readiness \
tests.test_dependency_lock \
tests.test_staging_readiness \
deploy.test_production_preflight \
local.test_pricing \
local.test_secrets -v
sh -n local/lock_dependencies.sh
tests.test_pricing \
tests.test_secrets -v
sh -n infra/lock_dependencies.sh
integration:
name: Integration suite on a real stack
@@ -31,9 +31,26 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
SITE_PORT: "8080"
KANBAN_PORT: "8081"
API_PORT: "8000"
# The runner shares the host's Docker daemon, so every published port is
# taken on the machine itself. Known occupants of that host:
# 8000, 9443 Portainer (the Edge tunnel and its UI)
# 18080/18081 the production dtf-cloud stack (docker-compose.yml defaults)
# 9000/9001 MinIO defaults elsewhere
# This block avoids all of them. Ephemeral ports are not an option: the
# published port is baked into PUBLIC_ORIGIN, ALLOWED_ORIGINS and the CSP
# when the containers start, so it has to be known beforehand.
SITE_PORT: "28080"
KANBAN_PORT: "28081"
API_PORT: "28000"
STORAGE_PORT: "29000"
STORAGE_CONSOLE_PORT: "29001"
# Presigned URLs are signed against this endpoint, so it must be reachable
# 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
@@ -49,39 +66,41 @@ jobs:
$COMPOSE up --build -d --wait --wait-timeout 600
$COMPOSE ps
# Run inside the stack's own network. The runner is itself a container, so
# ports published on the host's loopback are in a different namespace and
# unreachable from here. SITE_HOST_HEADER keeps the Host the gateway and
# TrustedHostMiddleware expect, so the configuration under test is the same
# one a developer exercises on localhost.
- name: API and workflow regressions
run: |
python3 -m local.smoke_test
python3 -m local.workflow_test
python3 -m local.security_test
python3 -m local.scanning_test
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 \
-e SITE_HOST_HEADER=localhost \
api python -m "tests.$suite"
done
# Need Pillow and httpx, which only the application image has. The raster
# check needs PyMuPDF as well and skips here; run it locally when changing
# the generator's geometry. The provider suites use a fake transport: they
# prove the documented contract, not the integration.
- name: Print-file geometry and provider adapters
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny -v
- name: Runtime and retention regressions
run: |
$COMPOSE exec -T api python -m local.retention_test
$COMPOSE exec -T api python -m local.runtime_security_test
$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
# 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
echo "Using $CHROME_BIN"
node local/artwork_browser_test.mjs
node local/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()
@@ -93,12 +112,61 @@ jobs:
if: always()
run: $COMPOSE down -v || true
scan:
name: Secret scan and release gate
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 30
env:
TRIVY_IMAGE: ${{ vars.TRIVY_IMAGE }}
ENFORCE_PRODUCTION_PREFLIGHT: ${{ vars.ENFORCE_PRODUCTION_PREFLIGHT }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# Blocking. A credential committed by accident must never reach the
# registry or the deployed stack, and the repository is clean today, so
# this gate costs nothing until it is actually needed.
- name: Secret scan
run: |
image="${TRIVY_IMAGE:-aquasec/trivy:0.58.1}"
docker run --rm -v "$PWD:/src:ro" "$image" \
fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \
--no-progress /src
# 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
python3 deploy/production_preflight.py --source-only
verdict=$?
set -e
if [ "$verdict" -eq 0 ]; then
echo "Source preflight passes."
exit 0
fi
if [ "${ENFORCE_PRODUCTION_PREFLIGHT:-false}" = "true" ]; then
echo "::error::Source preflight blocked the release."
exit "$verdict"
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
needs: [validate, integration]
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
name: Publish images
needs: [validate, integration, scan]
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
timeout-minutes: 45
env:
TRIVY_IMAGE: ${{ vars.TRIVY_IMAGE }}
PYTHON_BASE_IMAGE: ${{ vars.PYTHON_BASE_IMAGE }}
NGINX_BASE_IMAGE: ${{ vars.NGINX_BASE_IMAGE }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -111,28 +179,67 @@ 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"
docker build --pull --file deploy/Dockerfile.api \
# The Dockerfiles pin digests themselves; these variables let a base be
# moved forward without editing the repository. --pull is intentionally
# absent: a digest already names one immutable image.
set --
[ -n "$PYTHON_BASE_IMAGE" ] && set -- --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE"
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"
docker build --pull --file deploy/Dockerfile.web \
set --
[ -n "$PYTHON_BASE_IMAGE" ] && set -- --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE"
[ -n "$NGINX_BASE_IMAGE" ] && set -- "$@" --build-arg NGINX_BASE_IMAGE="$NGINX_BASE_IMAGE"
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
# them would stop releases without making anything safer.
- name: Image vulnerabilities
run: |
image="${TRIVY_IMAGE:-aquasec/trivy:0.58.1}"
failed=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 --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 --image-src docker --scanners vuln --severity CRITICAL --no-progress \
--format table --exit-code 1 "$target" || failed=1
done
if [ "$failed" -ne 0 ]; then
echo "::error::A CRITICAL vulnerability was found in a release image."
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"

14
.gitignore vendored
View File

@@ -35,8 +35,12 @@ deploy/portainer.env
output/local/
output/security/
# Keep the roadmap generator, not its extracted text or render-review scratch.
tmp/*
!tmp/pdfs/
tmp/pdfs/*
!tmp/pdfs/generate_dtf_report.py
# Scratch only. The roadmap generator moved to tools/, the PDFs to docs/.
tmp/
# Client deliverables and scratch inputs live here but are not the repository's
# to version: they are produced for a specific week, or dropped in to test with.
Relatorio-*.docx
Relatorio-*.pdf
imagem-teste.*
output/documents/

View File

@@ -1,12 +1,12 @@
# Sistema DTF 24h — Altus Group
> **Active local milestone (2026-09-11):** Read [CONTEXT.md](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 [LOCAL_SETUP.md](LOCAL_SETUP.md) for the complete test flow,
> to `.env`. Follow [docs/LOCAL_SETUP.md](docs/LOCAL_SETUP.md) for the complete test flow,
> local login, health checks, and troubleshooting. See
> [IMPLEMENTATION_REPORT.md](IMPLEMENTATION_REPORT.md) for scope and reuse decisions.
> Production delivery uses one Portainer stack; see [PORTAINER.md](PORTAINER.md).
> [IMPLEMENTATION_REPORT.md](docs/historico/IMPLEMENTATION_REPORT.md) for scope and reuse decisions.
> Production delivery uses one Portainer stack; see [docs/PORTAINER.md](docs/PORTAINER.md).
> Everything below is preserved historical prototype documentation, not the active
> setup or delivery specification. Do not run its production integrations or agent.
@@ -14,6 +14,16 @@
> a arquitetura, o código e o que ainda depende de resposta.
> Base: conversas de 29 e 30/08/2026 com Marcus.
> **2026-09-21.** Everything below this line is the original prototype
> documentation. The code it describes (`portal/`, `kanban/`, `agente/`, the root
> `schema.sql`, `.env.exemplo`) no longer exists, and the model it describes is
> not the one implemented. Kept for the business reasoning in it — the capacity
> figures, the cost argument, the meeting decisions. For how the system actually
> works read [docs/CONTEXT.md](docs/CONTEXT.md); for what is outstanding read
> [docs/ROADMAP.md](docs/ROADMAP.md); for the original documents see
> [docs/historico/](docs/historico/README.md).
---
## Por que este projeto existe
@@ -418,7 +428,7 @@ depende de a pasta de rede funcionar.
```bash
apt install -y python3.12-venv libvips-tools postgresql nginx certbot
python3 -m venv /opt/dtf/venv && source /opt/dtf/venv/bin/activate
pip install -r requirements.txt
pip install fastapi uvicorn sqlmodel 'psycopg[binary]' boto3 httpx # prototype only; no longer tracked
cp .env.exemplo .env # preencher
cd portal && uvicorn main:app --host 0.0.0.0 --port 8000
```

View File

@@ -1,436 +0,0 @@
# DTF System — Working Roadmap
> Internal engineering tracker. Not a client document, not a promise sheet.
> The client-facing narrative lives in `output/pdf/dtf-plano-producao-e-roadmap.pdf`
> and in the weekly report (`Relatorio-Semana-1-DTF.docx`).
>
> 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, plus 2.1, 2.2, 2.3, 2.5 and 5.1. Next: 2.4 (the
release gate the docs describe but the workflow never ran — partly addressed by
5.1), then 2.6/2.7. Block 1 still waits on client inputs for 1.1/1.2.
**Last audit:** 2026-09-18, full read of `local/`, `dtf-site.html`, `deploy/`,
`.gitea/`, docs and legacy prototypes. Findings below carry their audit IDs.
| Status | Meaning |
|---|---|
| `[ ]` | not started |
| `[~]` | in progress |
| `[x]` | done and verified |
| `[?]` | blocked on a decision (product or client), not on code |
---
## Block 0 · Broken right now
Nothing in this block is optional. Until it is closed, the system cannot be
demonstrated, and the week-1 claims cannot be defended.
### `[x]` 0.1 — API returns 500 on every session, login and registration `(F1)`
`local/auth.py:46` calls `os.environ` and the module never imports `os`.
Reproduced: `NameError: name 'os' is not defined`. `/api/session` calls
`new_session()` whenever there is no cookie, so the Site checkout bridge, cart
recovery and the customer portal all fail on first visit. Introduced in `e3e37f6`.
- Add `import os` to `local/auth.py`.
- Move the `COOKIE_SECURE` read to a module constant so it is evaluated once.
- **Accept:** a fresh browser hits the Site and `/api/session` returns 200 with a
`cart_scope`; `local/smoke_test.py` and `local/workflow_test.py` pass.
### `[x]` 0.2 — "Arquivo por metro" is unreachable in practice
Two independent causes, both from 2026-09-18 commits. Verified in a browser.
**a. Every entry point hard-routes to loose artwork** (`483a083`)
| Control | Currently opens |
|---|---|
| Nav "Impressão DTF" | `avulsa` |
| "DTF Têxtil · 57 cm" | `avulsa` |
| "DTF UV · 28,5 cm" | `uv` |
| Hero "Enviar minha arte" | `avulsa` |
Only the price card reaches `file`. Revert the three `data-modo-cta` attributes
so navigation links land on the product chooser, not on a product.
**b. Dropping a PNG/JPG in `file` mode silently switches the order** `(F25)`
`dtf-site.html` `sel()` — from `abrir('file')`, dropping `imagem-teste.jpg` gives
`modo: "avulsa"`, header "Artes avulsas", price `R$ 29,90/m`. Meanwhile the same
screen says *"Arraste suas folhas montadas · PNG, JPG ou PDF"*, marks
*"PNG, JPG ou PDF · a partir de R$ 14,90"* as the recommended path, and sets
`input.accept=".png,.jpg,.jpeg,.pdf"`. The page invites the drop and then
reprices the order 50% higher.
The escape hatch `#imagemComoFolha` sits above the drop zone as small text inside
an informational notice, defaults to unchecked, and must be ticked *before* the
drop. After the switch fires, `pintaModo()` sets `trocarParaAvulsa.hidden = true`,
so the checkbox disappears and there is no way back in place.
Net effect: the only formats that survive `file` mode are PDF/TIFF/PSD/AI/CDR —
the path the UI itself marks as the worse option. A mixed drop (JPG + PDF) is
rejected and accepts nothing.
- Make the ready-sheet choice an explicit two-option control **inside** the drop
area, styled like the existing `.cam` selector — not a checkbox in a notice.
- Stop advertising PNG/JPG in the by-metre drop zone while rejecting them.
- When a switch does happen, show the price change and offer one-click undo.
- **Accept:** a customer can complete a by-metre order with a PNG from any entry
point, and no product/price change ever happens without a visible confirmation.
### `[x]` 0.3 — Ready-sheet declaration is unverified and worth money `(F22 related)`
Ticking `#imagemComoFolha` is an honour-system claim that moves the price from
R$ 29,90/m to R$ 19,90/m (R$ 14,90 with a good grade). `medirFolha` then derives
sheet height purely from aspect ratio × 57 cm — the original bug, now opt-in.
Measured with `imagem-teste.jpg` (466 × 659 px):
| Route | System behaviour | Billed |
|---|---|---|
| Ticked | treated as a 57 × 80,6 cm mounted sheet | 1 m × R$ 19,90 = **R$ 19,90** |
| Not ticked | 20 cm wide, packs to 28,3 cm of film | 1 m × R$ 29,90 = **R$ 29,90** |
- Validate the claim: declared width must be ≈ film width (57 / 28,5 cm) at a
plausible DPI before the sheet model is accepted.
- Re-check server-side in `/api/operator/quotes/{id}/approve` before pricing.
- **Accept:** a small single artwork declared as a ready sheet is rejected with a
clear message; a genuine 57 cm sheet passes; the operator sees the verdict.
### `[x]` 0.4 — Documented local startup fails `(F2)`
`LOCAL_SETUP.md` says `docker compose up --build` with no `.env`.
`docker compose --env-file .env.example config` exits 1: `R2_ENDPOINT`,
`R2_ACCESS_KEY_ID`, `SITE_DOMAIN`, `KANBAN_DOMAIN` missing. `docker-compose.yml`
became a production/R2 stack in `e3e37f6`; `.env.example` is still the MinIO one
and there is no MinIO service left.
- Decide: keep one production compose and add `compose.local.yaml` with MinIO, or
restore a local default. Recommend the former.
- **Accept:** a clean clone reaches a working Site + Kanban with the documented
command, and `LOCAL_SETUP.md` matches what actually runs.
### `[x]` 0.5 — App DB role shares the admin password `(F3)`
`docker-compose.yml:65` sets `APP_DB_PASSWORD: ${POSTGRES_PASSWORD}` — the same
value as `dtf_admin`. `bootstrap.py` grants the app role DML-only and then hands
it a credential that also logs in as the owner. Anyone reading the API container
env has admin on the database.
- **Accept:** distinct secrets; connecting as `dtf_app` with the admin password fails.
### `[x]` 0.6 — A paid order showed the customer nothing (found while closing Block 0)
`#checkoutStatus` and `#checkoutActions` lived inside `#carr`, and the success path
in `checkout.js` clears the cart (`pedido=[]; limpaPaineis()`) before writing the
confirmation — `.carr{display:none}` then hid the panel holding it. Present since
the first commit; it only surfaced once 0.1 made a payment reachable at all.
Both elements now sit in their own always-visible `.checkout` container.
### How Block 0 was verified
A live stack (`compose.local.yaml`), then the full suite:
| Check | Result |
|---|---|
| `/api/session` on a cold browser | 200 with `cart_scope` + session cookie |
| smoke · workflow · security · scanning | pass |
| retention · runtime security (in-container) | pass |
| `artwork_browser_test.mjs` | pass, updated to the new declared-product behaviour |
| `browser_test.mjs` end-to-end | pass — upload → quote → operator approval → paid order → all Kanban states |
| 4 CI unit tests | pass |
Ports 8090/8091/8010 were used; 8080 was held by an unrelated preview server.
---
## Block 1 · Week 2 — committed to the client
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.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.
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".
`[!]` 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.
---
## Block 2 · Security — before any public exposure
### `[x]` 2.1 — Rate limiting and audit logs are blind to the client `(F5)`
uvicorn runs without trusted proxy headers (`forwarded_allow_ips` defaults to
`127.0.0.1`; nginx is a different container IP), so `request.client.host` is nginx
for every request. `rate_limit('auth-source', ...)` at 60/15min becomes a single
global bucket — **60 failed logins lock out every customer** — and every `audit()`
record has no attacker IP.
- Set `--proxy-headers` with `FORWARDED_ALLOW_IPS` scoped to the nginx service, or
read `X-Forwarded-For` explicitly at the edge.
- **Accept:** two clients on different IPs have independent buckets; audit rows
carry the real IP.
### `[x]` 2.2 — The public site throttles itself `(F6)`
`local/app.py:115` — `rate_limit('guest-sessions', ENVIRONMENT, 120, 900)` is keyed
on the environment name: 120 new visitors per 15 minutes **site-wide** (~8/min).
Normal traffic 429s. Key per source IP (after 2.1) and raise the ceiling.
### `[x]` 2.3 — `deploy/stack.yaml` cannot boot `(F7)`
It passes `DATABASE_URL_FILE`, `AWS_ACCESS_KEY_ID_FILE`, `OPERATOR_PASSWORD_FILE`,
`OPERATOR_USER`. The code reads `DATABASE_URL`, `AWS_ACCESS_KEY_ID`,
`OPERATOR_PASSWORD`, `OPERATOR_EMAIL`, and no `_FILE` loader exists.
`app.py:58` does `os.environ['OPERATOR_PASSWORD']` → `KeyError` → 500 instead of 503.
- Implement `local/secrets.py` (the preflight already expects it) reading `*_FILE`
with env fallback. Reconcile `OPERATOR_USER` vs `OPERATOR_EMAIL`.
- **Accept:** the stack renders and boots against Swarm secrets; missing operator
config yields 503, not 500.
### `[ ]` 2.4 — The documented release gate does not exist `(F8)`
`PORTAINER.md` and `SECURITY_REPORT.md` claim the workflow runs the full isolated
suite, Trivy HIGH/CRITICAL image gates, secret scanning and the source preflight
before calling Portainer. `.gitea/workflows/deploy.yml` runs `py_compile` plus four
unit tests, then builds, pushes `latest` and calls the webhook **unconditionally**.
`deploy/production_preflight.py` is never invoked — only its unit test runs.
- Either implement the gate or correct both documents. Do not leave the gap.
### `[x]` 2.5 — The preflight has silently decayed `(F9)`
It blocks by string-matching source. **4 of 6 markers are dead** after the R2
refactor: `'This runtime only supports APP_ENV=local'`,
`'Only local S3 storage is supported'`, `"allowed_hosts=['localhost', '127.0.0.1']"`,
`"'environment': 'local'"`. String gates weaken without failing.
- Replace marker matching with behavioural assertions (import the module, assert
the adapter classes in use).
### `[ ]` 2.6 — Base images are not pinned `(F10)`
Dockerfiles default to mutable `python:3.12-slim` / `nginx:1.28-alpine`, the
workflow passes no digest build-args, and `--pull` makes builds non-reproducible —
while `PORTAINER.md` documents digest-pinned immutable bases.
### `[ ]` 2.7 — pdf.js loaded from CDN without integrity `(F11)`
3.11.174 from `cdnjs`, no SRI, and CSP allows the whole host for `script-src` **and**
`worker-src`. Vendor the asset or pin `integrity` and narrow the CSP to the exact path.
### `[ ]` 2.8 — Single shared operator credential `(F12)`
One `OPERATOR_EMAIL`/`OPERATOR_PASSWORD` for the whole factory; `movements.operator`
records the same name for everyone. The meeting asked for traceability, and the old
`kanban/main.py` explicitly designed separation of duties (Mayana classifies,
Thales/Alexandre authorise). Needs real per-person accounts with roles.
### `[ ]` 2.9 — No TLS in the stack `(F13)`
Ports publish plain HTTP on 18080/18081 while `COOKIE_SECURE: "true"` — cookies are
silently dropped unless something external terminates TLS. Nothing in the repo
provisions certificates; `TAREFAS.md` A2 still lists it as pending.
### `[ ]` 2.10 — No email verification, no password recovery `(F14)`
A locked-out customer has no path back, and registration accepts any CNPJ without
proving control of the e-mail. Needs a transactional mail provider — **client input**.
### `[ ]` 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.
---
## Block 3 · Architecture — needs a decision before code
### `[?]` 3.1 — Manual quote approval contradicts the 24h business case `(F16)`
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
money. As built, a 2am order still waits for a person. `CONTEXT.md` frames this as a
temporary development trust boundary — the risk is that it silently becomes the
delivered model.
**Decide:** what makes a quote auto-approvable (mode, metre range, grade floor,
returning customer), and what still routes to a human.
### `[?]` 3.2 — Billable metres are computed in the customer's browser `(F17, F18)`
For loose artwork, `metros` comes from `desenhaMontagem`/`encaixar` — a canvas
alpha-mask packer running client-side. The server never recomputes it.
`passoDe()`/`CELULAS_MAX` coarsen the grid for large sheets and image decoding
differs by browser, so **the same cart can price differently on different devices**.
The code comments reference "o motor do servidor"; that engine does not exist.
Worse, the layout the customer is quoted on is never produced — operators upload
final files by hand, so billed metres ≠ printed metres and a designer redoes work
the site already did.
**Decide:** port the packer to the server as the pricing authority and the print-file
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)`
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.
**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).
### `[ ]` 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)`
`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.
---
## Block 4 · Scale and performance
- `[ ]` 4.1 — Missing indexes `(F23)`. `local/schema.sql` indexes only
`uploads(owner)`. Add `orders(owner)`, `quotes(owner)`, `order_files(order_id)`,
`movements(order_id)`, and a partial index on
`outbox(available_at) WHERE delivered_at IS NULL` — the worker polls that table
every second and it only grows.
- `[ ]` 4.2 — `/api/operator/board` is unpaginated `(F24)`: every order ever, plus a
per-quote subquery each. Fine at 10 orders, not at 200/day.
- `[ ]` 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.
- `[ ]` 4.5 — Dead config `(F28)`: `stack.yaml` sets `CLAMD_HOST: scanner`,
`scanning.py` hardcodes `'scanner'`.
---
## Block 5 · Hygiene and maintenance
- `[x]` 5.1 — CI coverage `(F33)`. An `integration` job now builds the localhost
stack and runs smoke, workflow, security, scanning, retention, runtime security
and both browser suites; `publish-and-deploy` depends on it. Verified by
reintroducing the 0.1 defect: `py_compile` and the unit tests still passed while
`smoke_test` failed on `/session`, blocking the release. Browser tests skip with
a warning when the runner has no Chrome — **install `google-chrome-stable` on the
runner (or set `CHROME_BIN`) to make them gate as well.**
- `[ ]` 5.2 — Remove or archive the dead prototypes `(F30)`: `portal/`, `kanban/`,
`agente/`, root `schema.sql` (~1,500 lines describing an abandoned model). Several
expose unauthenticated endpoints taking the acting user from the request body
(`/api/puxar`, `/api/devolver`).
- `[ ]` 5.3 — Root `requirements.txt` is the prototype's `(F31)`: wildcard pins,
unused `sqlmodel`/`pyvips`/`qrcode`/`pillow`, next to the hash-locked
`local/requirements.lock`.
- `[ ]` 5.4 — `pip==26.2.1` pinned as a runtime dependency `(F32)` — pip ships inside
the read-only production image.
- `[ ]` 5.5 — Doc drift `(F34)`. `README.md`, `CONTEXT.md`, `LOCAL_SETUP.md` and
`SECURITY_REPORT.md` describe a MinIO localhost stack, an API with "no external
network route", a `operator` / `local-operator-only` login the email-validated
model rejects, and a release gate — none match the current tree.
- `[ ]` 5.6 — `dtf-site.html` is 2,889 lines with commercial rules, the packing
engine, PDF analysis, UI and checkout inline `(F29)`. Split at least the pricing
table and the packer so 3.2 has somewhere to land.
- `[ ]` 5.7 — Commercial rules duplicated between `FAIXAS` (JS) and `TIERS` (Python)
`(F26)`. `test_pricing` guards parity; generate one from the other instead.
- `[ ]` 5.8 — The Site promises retention the system does not honour. The cart aside
still reads *"O arquivo fica guardado por 90 dias e o histórico do pedido por 12
meses"*, while `CONTEXT.md` and the implemented retention are 30 days maximum.
This is a customer-facing commercial promise, so correct the copy or the policy —
do not leave them disagreeing. Found 2026-09-18 while closing Block 0.
---
## Done
### Week 1 — infrastructure, uploads, service base
- `[x]` Compose stack: Site, Kanban, API, PostgreSQL, worker, ClamAV, R2/MinIO storage.
- `[x]` Gitea + Portainer publication path; web service startup and API rollout fixed.
- `[x]` Database passwords with special characters handled via discrete libpq fields.
- `[x]` Kanban e-mail/password login; missing operator config no longer breaks stack boot.
- `[x]` Direct resumable multipart browser → private object storage.
- `[x]` Quarantine + ClamAV gate: only `clean` files can be quoted, paid, downloaded or queued.
- `[x]` Retention worker: incomplete 1d, rejected 3d, originals 7d after approval, finals 30d.
- `[x]` Server-side pricing authority with parity test against the Site JavaScript (4,444 cases).
- `[x]` Loose-artwork packing flow: per-file width, copies, rotate, mirror, live 57 cm preview,
5 mm gap, ruler and watermark preserved; metres follow packed height.
- `[x]` Rotation/mirror applied to packing masks; stale renders no longer overwrite a newer preview.
- `[x]` Hash-locked Python dependencies; Trivy reports in `output/security/`.
### Block 0 — 2026-09-18
- `[x]` `import os` restored in `local/auth.py`; `COOKIE_SECURE` is now a single
module constant shared with `local/app.py`.
- `[x]` Navigation links no longer preselect a product (`data-modo-cta` removed).
- `[x]` Ready sheet vs loose artwork is an explicit, priced, reversible selector
(`#tipoEnvio`), shown in all four modes and locked once a file is attached.
The product is the declaration; `sel()` no longer switches anything silently.
- `[x]` `medirFolha` returns `dpiFolha`; an image that cannot span the film width
at `DPI_RECUSA` is refused as a sheet, with one click to send it as loose artwork.
- `[x]` `pintaCaminhos` scoped to `#caminhos .cam` — its global `.cam` selector was
clobbering the new control.
- `[x]` `compose.local.yaml` restored (MinIO, fake providers, builds from source).
- `[x]` `APP_DB_PASSWORD` separated from `POSTGRES_PASSWORD`, with a `bootstrap.py`
guard that refuses identical credentials in both configuration forms.
- `[x]` Checkout confirmation moved out of the panel the success path hides.
### Block 2 and CI — 2026-09-18
- `[x]` The web gateway overwrites `X-Forwarded-For` with the peer address instead
of appending to it, and `client_ip()` resolves the requester for rate-limit
buckets and security events. Verified: a forged `203.0.113.99` never reaches the
audit trail.
- `[x]` Guest sessions are limited per source. The first attempt used 30/IP, which
the new regression caught as too tight for shared NAT — recreating the original
fault in a narrower form — so the ceiling is 240 per 15 minutes, overridable with
`GUEST_SESSION_LIMIT`.
- `[x]` Security events now carry the source address (`operator_login_failed`,
`customer_login_failed`, `cross_origin_rejected`, `http_security_event`).
- `[x]` CI runs the integration suites against a real stack before publishing.
### 2026-09-21
- `[x]` 2.3 — `local/secrets.py` resolves every `<NAME>_FILE` into `<NAME>` from the
API, worker and bootstrap entrypoints, failing closed on an unreadable or empty
secret and on a value supplied both ways. Verified by booting the API, the worker
and bootstrap with credentials supplied only as mounted files, including a
password containing `:/?#[]&=+$ ,%`. `OPERATOR_USER` in the stack became
`OPERATOR_EMAIL`, which is what the runtime reads.
- `[x]` 2.5 — The gate now loads `local/secrets.py` and makes it resolve every
secret `deploy/stack.yaml` declares, plus asserts it fails closed. Verified
against a no-op loader (11 blockers) and one that swallows a missing file
(1 blocker); only the real implementation passes. The four marker strings that
stopped matching when R2 support landed were removed; the two describing real
blockers stay, so the gate still refuses a release while the payment and
messaging adapters are fake.
### Reporting
- `[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.

View File

@@ -1,141 +0,0 @@
"""
Agente da fábrica — traz a arte aprovada do portal para o servidor local.
Roda como serviço:
Windows: nssm install DtfAgente "C:\\Python312\\python.exe" "C:\\dtf\\agente.py"
Linux: systemd (ver dtf-agente.service no repositório)
Duas fontes de trabalho:
- webhook do portal (chega na hora)
- polling a cada 60 s (rede de segurança se o webhook falhar)
Se a internet cair, o agente para e o portal continua recebendo.
Quando voltar, ele baixa o acumulado. Nada se perde.
"""
from __future__ import annotations
import hashlib
import logging
import os
import sqlite3
import time
from datetime import datetime
from pathlib import Path
import httpx
PORTAL = os.environ["BASE_PORTAL"]
TOKEN = os.environ["AGENTE_TOKEN"]
RAIZ = Path(os.environ.get("PASTA_DTF", r"\\servidor\DTF"))
BANCO = Path(os.environ.get("KANBAN_DB", r"C:\dtf\kanban.db"))
INTERVALO = 60
HEARTBEAT = 300
PASTA_ENTRADA = RAIZ / "00_ARTE_RECEBIDA"
logging.basicConfig(
filename=RAIZ / "_log" / "agente.log",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("agente")
def sha256(caminho: Path) -> str:
h = hashlib.sha256()
with open(caminho, "rb") as f:
for bloco in iter(lambda: f.read(1 << 20), b""):
h.update(bloco)
return h.hexdigest()
def registrar_no_kanban(arte: dict, arquivos: list[Path]) -> None:
"""Insere o card. Idempotente: a chave é o arte_id, não o nome do arquivo."""
con = sqlite3.connect(BANCO)
try:
con.execute("""
CREATE TABLE IF NOT EXISTS card(
arte_id INTEGER PRIMARY KEY,
pedido TEXT, cliente TEXT, metros REAL,
coluna TEXT DEFAULT 'rec', desde TEXT,
maquina TEXT, partes INTEGER
)""")
con.execute("""
INSERT OR IGNORE INTO card(arte_id,pedido,cliente,metros,desde,partes)
VALUES (?,?,?,?,?,?)""",
(arte["arte_id"], arte["pedido"], arte["cliente"],
arte["metros"], datetime.now().isoformat(timespec="seconds"),
len(arquivos)))
con.commit()
finally:
con.close()
def baixar(cliente: httpx.Client, arte: dict) -> bool:
PASTA_ENTRADA.mkdir(parents=True, exist_ok=True)
salvos: list[Path] = []
for parte in arte["partes"]:
destino = PASTA_ENTRADA / f"{arte['pedido']}_{parte['nome']}"
if destino.exists():
salvos.append(destino)
continue
tmp = destino.with_suffix(".parcial")
with cliente.stream("GET", parte["url"]) as r:
r.raise_for_status()
with open(tmp, "wb") as f:
for bloco in r.iter_bytes(1 << 20):
f.write(bloco)
tmp.rename(destino) # só aparece na pasta quando está completo
salvos.append(destino)
log.info("baixado %s (%s) sha=%s", destino.name,
f"{parte['metros']:.2f} m", sha256(destino)[:12])
registrar_no_kanban(arte, salvos)
cliente.post(f"{PORTAL}/api/artes/{arte['arte_id']}/baixada",
headers={"x-token": TOKEN}, timeout=15)
return True
def ciclo(cliente: httpx.Client) -> int:
r = cliente.get(f"{PORTAL}/api/artes", headers={"x-token": TOKEN}, timeout=30)
r.raise_for_status()
artes = r.json()
for arte in artes:
try:
baixar(cliente, arte)
except Exception as e:
log.error("falha na arte %s: %s", arte["arte_id"], e)
return len(artes)
def main() -> None:
log.info("agente iniciado · portal=%s · pasta=%s", PORTAL, RAIZ)
ultimo_hb = 0.0
with httpx.Client(follow_redirects=True) as cliente:
while True:
try:
n = ciclo(cliente)
if n:
log.info("%d arte(s) processada(s)", n)
except httpx.HTTPError as e:
# internet caiu: não é erro fatal, o portal segue recebendo
log.warning("sem conexão com o portal: %s", e)
except Exception as e:
log.exception("erro inesperado: %s", e)
agora = time.time()
if agora - ultimo_hb > HEARTBEAT:
try:
cliente.post(f"{PORTAL}/api/agente/heartbeat",
headers={"x-token": TOKEN}, timeout=10)
ultimo_hb = agora
except Exception:
pass # sem sinal por 15 min, o portal alerta o TI
time.sleep(INTERVALO)
if __name__ == "__main__":
main()

View File

@@ -1,14 +0,0 @@
[Unit]
Description=Agente DTF - baixa artes do portal
After=network-online.target
[Service]
Type=simple
User=dtf
EnvironmentFile=/etc/dtf/.env
ExecStart=/usr/bin/python3 /opt/dtf/agente.py
Restart=always
RestartSec=15
[Install]
WantedBy=multi-user.target

View File

@@ -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,26 @@ def require_runtime():
checkout until their audited implementations are added.
"""
environment = os.environ.get('APP_ENV', 'local')
for name in ('PAYMENT', 'FREIGHT', 'TINY', 'WHATSAPP'):
for name in ('FREIGHT', 'WHATSAPP'):
if os.environ.get(f'{name}_ADAPTER') != 'fake':
raise RuntimeError(f'{name} must use the currently supported fake adapter')
tiny = os.environ.get('TINY_ADAPTER')
if tiny == 'tiny':
from .tiny import required_settings
for name in required_settings():
if not os.environ.get(name):
raise RuntimeError(f'{name} is required for the Tiny adapter')
elif tiny != 'fake':
raise RuntimeError('TINY must use the fake or tiny adapter')
# Mercado Pago is selectable only with its credentials present; it has not
# yet passed the sandbox flows, so production preflight still blocks it.
payment = os.environ.get('PAYMENT_ADAPTER')
if payment == 'mercadopago':
for name in ('MP_ACCESS_TOKEN', 'MP_WEBHOOK_SECRET'):
if not os.environ.get(name):
raise RuntimeError(f'{name} is required for the Mercado Pago adapter')
elif payment != 'fake':
raise RuntimeError('PAYMENT must use the fake or mercadopago adapter')
if environment == 'local':
if os.environ.get('STORAGE_ADAPTER') != 's3-local':
raise RuntimeError('Local runtime requires local S3 storage')
@@ -41,10 +61,83 @@ 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')
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}
@@ -84,6 +177,8 @@ class ObjectStorage(Protocol):
def complete(self, key: str, upload_id: str, parts: list): ...
def size(self, key: str) -> int: ...
def download(self, key: str, name: str) -> str: ...
def fetch(self, key: str, path: str): ...
def store(self, key: str, path: str, content_type: str): ...
def health(self): ...
def discard(self, key: str, upload_id: str, complete: bool): ...
@@ -132,6 +227,14 @@ class LocalS3Storage:
def size(self, key):
return self.client.head_object(Bucket=self.bucket, Key=key)['ContentLength']
def fetch(self, key, path):
"""Copy a stored object to a local file (the worker's scratch space)."""
self.client.download_file(self.bucket, key, path)
def store(self, key, path, content_type):
"""Upload a file the service generated itself, such as a print file."""
self.client.upload_file(path, self.bucket, key, ExtraArgs={'ContentType': content_type})
def download(self, key, name):
from urllib.parse import quote
return self.public.generate_presigned_url('get_object', Params={

2
app/api/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""HTTP routers, one per resource. They import from local.runtime, never
from each other or from local.app."""

53
app/api/artwork.py Normal file
View File

@@ -0,0 +1,53 @@
"""Operator artwork handling: final files, and the uploads that carry them.
These reuse the customer upload routes with the operator's derived identity, so
one transport serves both and there is only one place where parts are signed.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from ..core import db
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, cancel_upload, complete_upload, part_url, upload_status
router = APIRouter()
@router.post('/api/operator/orders/{oid}/uploads')
def begin_final(oid: UUID, body: UploadStart, user=Depends(operator)):
with db.connect() as c:
row = c.execute('SELECT state FROM dtf_local.orders WHERE id=%s', (oid,)).fetchone()
if not row: raise HTTPException(404, 'Order not found')
if row['state'] not in ('rec','tra','cor'): raise HTTPException(409, 'Order is not in artwork review')
return begin_upload(body, session_id=operator_identity(user))
@router.get('/api/operator/uploads/{uid}')
def final_status(uid: UUID, user=Depends(operator)):
return upload_status(uid,session_id=operator_identity(user))
@router.post('/api/operator/uploads/{uid}/parts/{number}')
def final_part(uid: UUID, number: int, user=Depends(operator)):
return part_url(uid,number,session_id=operator_identity(user))
@router.post('/api/operator/uploads/{uid}/complete')
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:
return file_rows(c,oid)
@router.post('/api/operator/orders/{oid}/final-files')
def final_files(oid: UUID, body: ArtworkSubmission, user=Depends(operator)):
with db.connect() as c:
order = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (oid,)).fetchone()
if not order: raise HTTPException(404, 'Order not found')
return submit_files(c,order,body,operator_identity(user),'final',user)

119
app/api/customer.py Normal file
View File

@@ -0,0 +1,119 @@
"""Customer accounts, their orders, and the corrections they submit."""
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, 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,
login_failed, password_hash, password_matches, session_row, throttle, transfer_guest)
from ..core.models import ArtworkSubmission, Login, Register
from ..runtime import STATES, file_rows, owned_order, storage
from ..scanning import require_clean
router = APIRouter()
def current(request):
try: return session_row(request)
except HTTPException: return None
@router.post('/api/account/register')
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()
profile = body.customer.model_dump()
profile['mail'] = email
try:
with db.connect() as c:
if previous and c.execute('SELECT id FROM dtf_local.accounts WHERE id=%s', (previous['owner'],)).fetchone():
raise HTTPException(409, 'Sign out before registering another account')
c.execute('INSERT INTO dtf_local.accounts(id,email,password_hash,profile) VALUES(%s,%s,%s,%s)', (identity,email,encoded,Jsonb(profile)))
if previous: transfer_guest(c, previous, identity)
new_session(c, response, identity)
except UniqueViolation:
raise HTTPException(409, 'An account already exists; sign in')
audit('account_registered', account=str(identity))
return {'customer': profile}
@router.post('/api/account/login')
def login(body: Login, request: Request, response: Response):
email = body.email.strip().lower()
throttle(email, request)
with db.connect() as c:
account = c.execute('SELECT * FROM dtf_local.accounts WHERE email=%s', (email,)).fetchone()
# Comparable password work even when the email is absent.
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not matches:
login_failed(email)
audit('customer_login_failed', ip=client_ip(request))
raise HTTPException(401, 'Invalid email or password')
previous = current(request)
with db.connect() as c:
if not stored.startswith('scrypt-v2$'):
c.execute('UPDATE dtf_local.accounts SET password_hash=%s WHERE id=%s', (password_hash(body.password),account['id']))
if previous:
transfer_guest(c, previous, account['id'])
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
new_session(c, response, account['id'])
audit('customer_login_success', account=str(account['id']))
return {'customer': account['profile']}
@router.post('/api/account/logout')
def logout(request: Request, response: Response):
previous = current(request)
if previous:
with db.connect() as c:
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
response.delete_cookie('dtf_session', httponly=True, samesite='strict')
response.headers['Clear-Site-Data'] = '"storage"'
audit('customer_logout')
return {'ok': True}
@router.get('/api/account/me')
def me(identity=Depends(owner)):
with db.connect() as c:
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)):
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}
@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()
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
'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)):
with db.connect() as c:
order = owned_order(c, oid, identity, lock=True)
return submit_files(c,order,body,identity,'correction','customer')
@router.get('/api/customer/orders/{oid}/files/{fid}/download')
def customer_download(oid: UUID, fid: UUID, identity=Depends(owner)):
with db.connect() as c:
owned_order(c,oid,identity)
row = c.execute('SELECT u.* FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.id=%s AND f.order_id=%s AND f.active', (fid,oid)).fetchone()
if not row: raise HTTPException(404, 'Active file not found')
if row['expires_at'] <= datetime.now(timezone.utc): raise HTTPException(410, 'File expired')
require_clean(row)
return {'url':storage.download(row['object_key'],row['name']), 'name':row['name']}

46
app/api/health.py Normal file
View File

@@ -0,0 +1,46 @@
"""Health, session bootstrap and freight quoting."""
import os
from fastapi import APIRouter, HTTPException, Request, Response
from ..core import db
from ..core.auth import client_ip, owner, new_session, rate_limit
from ..core.limits import upload_limit_bytes
from ..core.models import Freight
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, storage
router = APIRouter()
@router.get('/health')
@router.get('/api/health')
def health():
try:
with db.connect() as c:
c.execute('SELECT 1')
storage.health()
except Exception:
raise HTTPException(503, 'Database or storage unavailable')
return {'status': 'ok', 'environment': ENVIRONMENT,
'storage': 'minio' if ENVIRONMENT == 'local' else 'r2', 'integrations': 'fake'}
@router.get('/api/session')
def session(request: Request, response: Response):
try:
session_id = owner(request)
except HTTPException:
# Per source, not per deployment: keyed on the environment name this was a
# single global bucket, so ~8 new visitors a minute exhausted it site-wide.
rate_limit('guest-sessions', client_ip(request), GUEST_SESSION_LIMIT, 900)
with db.connect() as c:
session_id = new_session(c, response)
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name,
# Public by design: Mercado Pago's card form needs it in the browser.
'payment_public_key': os.environ.get('MP_PUBLIC_KEY', '') if payment.name == 'mercadopago' else ''}
@router.post('/api/freight')
def quote_freight(body: Freight):
try:
return freight.quote(body.service, body.postal_code)
except ValueError as exc:
raise HTTPException(422, str(exc))

255
app/api/operator.py Normal file
View File

@@ -0,0 +1,255 @@
"""Kanban: sign-in, the board, commercial review and card movement."""
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, Query, Request, Response
from fastapi.responses import RedirectResponse
from psycopg.types.json import Jsonb
from ..core import db
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
login_failed, password_matches, throttle)
from ..core.models import Move, OperatorLogin, Resolution, Review
from ..core.pricing import price
from ..printjobs import queue as queue_print_files
from .. import tiny
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
enqueue, freight, quote_view, storage, upload_row)
from ..scanning import require_clean
router = APIRouter()
@router.post('/api/operator/login')
def operator_login(body: OperatorLogin, request: Request, response: Response):
email = body.email
throttle('operator:'+email, request)
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')
# 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')
token = secrets.token_urlsafe(32)
with db.connect() as c:
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
(hashlib.sha256(token.encode()).hexdigest(), email))
c.execute('UPDATE dtf_local.operators SET last_login_at=now() WHERE id=%s', (account['id'],))
response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
samesite='strict', path='/api/operator', max_age=28800)
audit('operator_login_success', operator=email)
return {'ok': True}
@router.post('/api/operator/logout')
def operator_logout(request: Request, response: Response):
with db.connect() as c:
digest = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (digest,))
response.delete_cookie('dtf_operator', path='/api/operator', httponly=True,
secure=COOKIE_SECURE, samesite='strict')
audit('operator_logout')
return {'ok': True}
@router.get('/api/operator/board')
def board(user=Depends(operator)):
with db.connect() as c:
# Everything still in progress, however old: an operator must never lose a
# card they can act on. Finished orders are terminal and only accumulate,
# so the board carries a recent window of them and reports the true total.
active = c.execute("SELECT * FROM dtf_local.orders WHERE state<>'fin' ORDER BY created_at").fetchall()
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']
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 = active + list(reversed(finished))
generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name
FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
for order in orders:
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
# A paid notification that did not become an order is money received
# for nothing the factory will make. It stays on the board until a
# person records what was done about it.
refused = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
ORDER BY received_at LIMIT 100''').fetchall()
return {'states': STATES, 'transitions': TRANSITIONS,
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(),
'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in pending + approved],
'pending_total': pending_total, 'approved_total': approved_total,
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
@router.get('/api/operator/quotes')
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
before_id: UUID | None = None, limit: int = Query(default=50, ge=1, le=100),
user=Depends(operator)):
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 ''
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1,)
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''', params).fetchall()
return {'quotes':[quote_view(c,row) for row in rows[:limit]],
'has_more':len(rows)>limit}
@router.post('/api/operator/quotes/{uid}/approve')
def approve(uid: UUID, body: Review, user=Depends(operator)):
with db.connect() as c:
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 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(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')
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']))
items.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']})
quoted_freight = freight.quote(**draft['freight'])
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
'destination': draft.get('destination'),
'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']}
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved),user,uid))
return approved
@router.post('/api/operator/orders/{uid}/move')
def move(uid: UUID, body: Move, user=Depends(operator)):
with db.connect() as c:
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'Order not found')
if body.version != row['version']:
raise HTTPException(409, 'Order changed; refresh the board')
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')
if body.state == 'cor' and not body.reason.strip():
raise HTTPException(422, 'Correction requires a reason')
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')
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))
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}'})
return changed
@router.post('/api/operator/orders/{uid}/print-files')
def regenerate(uid: UUID, user=Depends(operator)):
"""Queue generation again for items that failed or went to manual preparation,
and for orders paid before the generator existed."""
with db.connect() as c:
row = c.execute('SELECT id,snapshot,state FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'Order not found')
if row['state'] not in ('rec','tra'):
raise HTTPException(409, 'Print files are generated only before the order is queued')
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1", (uid,)).fetchone():
raise HTTPException(409, 'A customer correction replaced the original artwork; prepare the final file by hand')
queue_print_files(c, uid, len(row['snapshot']['items']), only_missing=True)
audit('print_file_requeued', order=str(uid), operator=user)
return c.execute('SELECT * FROM dtf_local.print_files WHERE order_id=%s ORDER BY item_index', (uid,)).fetchall()
@router.post('/api/operator/payment-events/{uid}/resolve')
def resolve_payment(uid: UUID, body: Resolution, user=Depends(operator)):
with db.connect() as c:
row = c.execute('''UPDATE dtf_local.payment_events SET resolved_at=now(), resolved_by=%s, resolution=%s
WHERE id=%s AND (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') AND resolved_at IS NULL RETURNING id''',
(user, body.note, uid)).fetchone()
if not row:
raise HTTPException(404, 'No open payment issue with this id')
audit('payment_issue_resolved', payment_event=str(uid), operator=user)
return {'ok': True}
def tiny_status():
if not tiny.configured():
return {'configured': False}
return {'configured': True, 'orders_enabled': os.environ.get('TINY_ADAPTER') == 'tiny',
**tiny.TinyAuth().status()}
@router.post('/api/operator/tiny/connect')
def tiny_connect(user=Depends(operator)):
"""Start the one-time authorisation of this system in the client's Tiny."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
audit('tiny_connect_started', operator=user)
return {'url': tiny.TinyAuth().authorize_url(user)}
@router.get('/api/operator/tiny/callback')
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
single-use state an operator created is what authorises it."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
try:
who = tiny.TinyAuth().complete(code, state)
except tiny.TinyError:
audit('tiny_connect_failed')
return RedirectResponse('/?tiny=failed', status_code=303)
audit('tiny_connected', operator=who)
return RedirectResponse('/?tiny=connected', status_code=303)
@router.get('/api/operator/orders/{uid}/history')
def history(uid: UUID, user=Depends(operator)):
with db.connect() as c:
return c.execute('SELECT * FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (uid,)).fetchall()
@router.get('/api/operator/uploads/{uid}/download')
def download(uid: UUID, user=Depends(operator)):
with db.connect() as c:
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND complete', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'Completed upload not found')
if row['expires_at'] <= datetime.now(timezone.utc):
raise HTTPException(410, 'Artwork retention expired')
require_clean(row)
return {'name':row['name'], 'url':storage.download(row['object_key'],row['name']), 'expires_in':300}

38
app/api/orders.py Normal file
View File

@@ -0,0 +1,38 @@
"""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 .. import payments
from ..core import db
from ..core.auth import owner
from ..core.models import Pay
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:
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

97
app/api/payments.py Normal file
View File

@@ -0,0 +1,97 @@
"""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
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
@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
@router.post('/api/payments/intent')
def intent(body: PaymentIntent, session_id=Depends(owner)):
"""Start paying an approved quote: a PIX code, or a card token from the
provider's own form. Asking twice for the same method returns the same
payment; a quote already paid returns 409."""
rate_limit('payment-intent', str(session_id), 30, 900)
with db.connect() as c:
try:
quote = payments.approved_quote(c, body.quote_id, session_id)
except payments.PaymentRefused as refusal:
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
raise HTTPException(409, 'Quote is already paid')
# Never a second charge: an approved payment is waiting for its
# notification to become the order, and a card in review may still be.
if c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s
AND (status='approved' OR (method='card' AND status='pending'))''', (body.quote_id,)).fetchone():
raise HTTPException(409, 'A payment for this quote is already approved or in review')
if body.method.type == 'pix':
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'
AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id,)).fetchone()
if existing:
return existing['response']
try:
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
quote['approved']['customer'], body.method.model_dump())
except ValueError as exc:
raise HTTPException(422, str(exc))
except Exception:
audit('payment_intent_failed', quote=str(body.quote_id))
raise HTTPException(502, 'Payment provider unavailable; try again')
c.execute('''INSERT INTO dtf_local.payment_intents(id,quote_id,provider,provider_payment_id,method,
status,amount_cents,response) VALUES(%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(provider,provider_payment_id) DO NOTHING''',
(uuid4(), body.quote_id, payment.name, created['id'], body.method.type,
created['status'], quote['approved']['total_cents'], Jsonb(created)))
return created

50
app/api/quotes.py Normal file
View File

@@ -0,0 +1,50 @@
"""Quotes: the customer's cart, and the operator-reviewed version of it."""
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 ..core import db
from ..core.auth import owner
from ..core.models import QuoteRequest
from ..runtime import freight, quote_view, 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')
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))
with db.connect() as c:
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)
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()
if row['request_hash'] != digest:
raise HTTPException(409, 'Request key already used for a different cart')
return {'id': row['id'], 'status': 'pending_review'}
@router.get('/api/quotes/{uid}')
def get_quote(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s', (uid,session_id)).fetchone()
if not row:
raise HTTPException(404, 'Quote not found')
return quote_view(c, row)

99
app/api/uploads.py Normal file
View File

@@ -0,0 +1,99 @@
"""Customer uploads: reservation, signed parts, completion.
The operator artwork routes reuse these functions directly with a different
identity, which is why they are plain functions with a session_id argument.
"""
import math
import os
from uuid import UUID, uuid4
from botocore.exceptions import ClientError
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 ..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 > 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)
with db.connect() as c:
# Serialize reservations across API processes, including changing guest IDs.
c.execute('SELECT pg_advisory_xact_lock(804208)')
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()
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')
multipart = storage.begin(key)
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}
@router.get('/api/uploads/{uid}')
def upload_status(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
row = upload_row(c, uid, session_id)
parts = [] if row['complete'] else storage.parts(row['object_key'], row['multipart_id'])
return {'id': uid, 'complete': row['complete'], 'part_bytes': PART_BYTES,
'scan_state':row['scan_state'], 'scan_reason':row['scan_reason'],
'parts': [p['PartNumber'] for p in parts]}
@router.post('/api/uploads/{uid}/parts/{part}')
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')
size = min(PART_BYTES, row['size']-(part-1)*PART_BYTES)
return {'url': storage.part_url(row['object_key'], row['multipart_id'], part, size)}
@router.post('/api/uploads/{uid}/complete')
def complete_upload(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
row = upload_row(c, uid, session_id, lock=True)
if row['complete']:
return {'id': uid, 'complete': True}
try:
existing_size = storage.size(row['object_key'])
except ClientError as exc:
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
raise
existing_size = None
if existing_size is None:
parts = storage.parts(row['object_key'], row['multipart_id'])
expected = math.ceil(row['size'] / PART_BYTES)
if [p['PartNumber'] for p in parts] != list(range(1, expected+1)) or any(
p['Size'] != min(PART_BYTES, row['size'] - i*PART_BYTES) for i,p in enumerate(parts)):
raise HTTPException(409, 'Parts are missing or their sizes do not match')
storage.complete(row['object_key'], row['multipart_id'], parts)
existing_size = storage.size(row['object_key'])
if existing_size != row['size']:
raise HTTPException(409, 'Stored size differs from declared size')
c.execute("UPDATE dtf_local.uploads SET complete=true,expires_at=now()+interval '30 days' WHERE id=%s", (uid,))
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}

48
app/app.py Normal file
View File

@@ -0,0 +1,48 @@
"""The DTF Portal/API service: assembly only.
Configuration and shared helpers are in local.runtime; every route lives in a
router under local.api. This module creates the application, applies the
cross-cutting middleware, and includes them.
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import JSONResponse
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, payments, quotes, uploads
@asynccontextmanager
async def lifespan(app):
with db.connect() as c:
c.execute('SELECT 1 FROM dtf_local.operator_sessions LIMIT 1')
storage.health()
yield
app = FastAPI(title='DTF Portal/API', lifespan=lifespan, docs_url=None, redoc_url=None)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
@app.middleware('http')
async def safe_headers(request, call_next):
if request.method not in ('GET','HEAD','OPTIONS'):
origin = request.headers.get('origin')
if request.headers.get('sec-fetch-site') == 'cross-site' or (origin and origin not in ALLOWED_ORIGINS):
audit('cross_origin_rejected', ip=client_ip(request))
return JSONResponse({'detail':'Cross-origin request rejected'}, status_code=403)
response = await call_next(request)
if response.status_code in (401,403,429) or response.status_code>=500:
audit('http_security_event', method=request.method, status=response.status_code, ip=client_ip(request))
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
return response
# Order is not significant: no two routers declare the same path.
for module in (health, uploads, quotes, orders, payments, operator, customer, artwork):
app.include_router(module.router)

75
app/artwork.py Normal file
View File

@@ -0,0 +1,75 @@
"""Attaching artwork to an order: the rules shared by customers and operators.
A customer submits a correction and an operator submits the final set; both go
through the same checks, so the rule about what may be attached, when, and what
it does to retention lives in one place.
"""
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
from fastapi import HTTPException
from .printjobs import generated_identity
from .runtime import upload_row
from .scanning import require_clean
def generated_owner(c, order, ref, kind):
"""The owner to look a generated print file up under, or None if it is not one.
A generated file may be approved as the final for the item it was made
from, and only while that item still has its original artwork: after a
customer correction it reproduces a layout nobody wants printed.
"""
generated = c.execute('''SELECT item_index FROM dtf_local.print_files
WHERE order_id=%s AND upload_id=%s AND status='ready' ''', (order['id'], ref.upload_id)).fetchone()
if not generated:
return None
if kind != 'final' or generated['item_index'] != ref.item_index:
raise HTTPException(422, 'A generated print file can only be the final file of its own item')
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1",
(order['id'],)).fetchone():
raise HTTPException(409, 'A customer correction replaced the artwork this file was generated from')
return generated_identity(order['id'])
def submit_files(c, order, body, identity, kind, actor):
if order['version'] != body.version:
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
if kind == 'final' and order['state'] not in ('rec','tra','cor'):
raise HTTPException(409, 'Final files can only change during artwork review')
if kind == 'correction' and order['state'] != 'cor':
raise HTTPException(409, 'This order is not awaiting artwork correction')
if len({f.upload_id for f in body.files}) != len(body.files):
raise HTTPException(422, 'Each uploaded file must appear once')
count = len(order['snapshot']['items'])
if any(f.item_index >= count for f in body.files):
raise HTTPException(422, 'Invalid order item')
if kind == 'final' and {f.item_index for f in body.files} != set(range(count)):
raise HTTPException(422, 'Final-file set must cover every order item')
original_ids = [UUID(uid) for item in order['snapshot']['items'] for uid in item['uploads']]
first = c.execute('SELECT min(created_at) AS first FROM dtf_local.uploads WHERE id=ANY(%s)', (original_ids,)).fetchone()['first']
expiry = first + timedelta(days=30)
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, 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.')
# 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))
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,))
return {'ok': True, 'version': order['version']+1, 'expires_at': expiry}

View File

@@ -4,7 +4,8 @@ from pathlib import Path
from urllib.parse import urlparse
import psycopg
from psycopg import sql
from .secrets import load as load_secret_files
from .core.secrets import load as load_secret_files
from .operators import seed_from_environment
def admin_connect():
@@ -45,6 +46,13 @@ def main():
c.execute(sql.SQL('GRANT USAGE ON SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
c.execute(sql.SQL('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
c.execute(sql.SQL('GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
# Turn the configured credential into a real account so an existing
# deployment keeps logging in exactly as before. Inserts only when that
# email is absent, so a password changed with local.operators is never
# reverted by a stale environment variable on the next deploy.
seeded = seed_from_environment(c)
print('Local schema migrated; runtime role has DML only.')
if seeded:
print(f'Seeded operator account {seeded} from OPERATOR_EMAIL.')
if __name__ == '__main__': main()

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Shared foundations: identity, database, models, prices, secret loading."""

View File

@@ -6,7 +6,7 @@ import logging
import json
from uuid import UUID, uuid4
from fastapi import HTTPException, Request
from .db import connect
from ..core.db import connect
COOKIE_SECURE = os.environ.get('COOKIE_SECURE', 'false').lower() == 'true'
@@ -94,10 +94,25 @@ def rate_limit(scope, identity, limit, seconds=900):
audit('rate_limit', scope=scope)
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
ACCOUNT_FAILURES = 10
def throttle(email, request):
# Independent account and source buckets prevent bypass by rotating emails.
# Every attempt counts against the source. Only failures count against the
# account: guessing a password is what the account bucket stops, and
# counting successful sign-ins too let ordinary use lock an operator out.
rate_limit('auth-source', client_ip(request), 60)
rate_limit('auth-account', email, 10)
key = hashlib.sha256(('auth-account|'+email).encode()).hexdigest()
with connect() as c:
row = c.execute("""SELECT attempts FROM dtf_local.login_attempts
WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone()
if row and row['attempts'] >= ACCOUNT_FAILURES:
audit('rate_limit', scope='auth-account')
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After': '900'})
def login_failed(email):
"""Count a failed sign-in (or registration attempt) against the account."""
rate_limit('auth-account', email, 1_000_000)
def operator(request: Request):
token = request.cookies.get('dtf_operator', '')

View File

@@ -19,4 +19,5 @@ def connect():
def initialize():
with connect() as c:
c.execute(Path(__file__).with_name('schema.sql').read_text())
# The schema lives at the package root, one level up from core/.
c.execute((Path(__file__).resolve().parent.parent / 'schema.sql').read_text())

13
app/core/limits.py Normal file
View File

@@ -0,0 +1,13 @@
"""Limits shared by upload admission and the malware scanner."""
import os
CLAMAV_STREAM_MAX_BYTES = 128 * 1024 * 1024 # infra/clamd.conf
def scan_limit_bytes():
return min(CLAMAV_STREAM_MAX_BYTES, int(os.environ.get('SCAN_MAX_BYTES', '134217728')))
def upload_limit_bytes():
transport = int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))
return min(transport, scan_limit_bytes())

235
app/core/models.py Normal file
View File

@@ -0,0 +1,235 @@
import re
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class StrictModel(BaseModel):
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
class Customer(StrictModel):
cnpj: str
zap: str
mail: str = Field(max_length=254)
@field_validator('cnpj')
@classmethod
def cnpj_valid(cls, value):
digits = re.sub(r'\D', '', value)
if len(digits) != 14 or len(set(digits)) == 1 or not digits.isascii():
raise ValueError('Invalid CNPJ')
def check(base, weights):
rem = sum(int(n) * w for n,w in zip(base, weights)) % 11
return 0 if rem < 2 else 11-rem
if check(digits[:12], [5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[12]) or check(digits[:13], [6,5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[13]):
raise ValueError('Invalid CNPJ')
return digits
@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
@field_validator('mail')
@classmethod
def email_valid(cls, value):
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value.strip()):
raise ValueError('Invalid email')
return value.strip()
class Freight(StrictModel):
service: Literal['pickup','mock-standard'] = 'pickup'
postal_code: str = Field(default='', max_length=8)
UF = Literal['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB',
'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']
class Destination(StrictModel):
"""Where a shipped order goes. A CEP alone quotes freight; it does not deliver."""
recipient: str = Field(min_length=2, max_length=120)
street: str = Field(min_length=2, max_length=160)
number: str = Field(min_length=1, max_length=20)
complement: str = Field(default='', max_length=80)
district: str = Field(min_length=2, max_length=80)
city: str = Field(min_length=2, max_length=80)
state: UF
postal_code: str = Field(pattern=r'^[0-9]{8}$')
@field_validator('recipient', 'street', 'number', 'complement', 'district', 'city', mode='before')
@classmethod
def trimmed(cls, value):
if isinstance(value, str):
value = ' '.join(value.split())
if any(ord(ch) < 32 for ch in value):
raise ValueError('Invalid characters')
return value
class UploadStart(StrictModel):
name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$')
size: int = Field(gt=0, strict=True)
@field_validator('name')
@classmethod
def artwork_extension(cls, value):
# Union of existing Site product formats; this is NOT malware/pre-flight validation.
if not re.search(r'\.(png|jpe?g|webp|tiff?|pdf|psd|psb|ai|cdr)$', value, re.I):
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)
class Pay(StrictModel):
quote_id: UUID
class PaymentMethod(StrictModel):
type: Literal['pix', 'card']
# Card fields come from the provider's own form, which tokenises the card
# in the browser; the number never reaches this server.
token: str | None = Field(default=None, max_length=200)
payment_method_id: str | None = Field(default=None, max_length=40, pattern=r'^[a-z_]+$')
installments: int = Field(default=1, ge=1, le=12, strict=True)
issuer_id: str | None = Field(default=None, max_length=40)
@model_validator(mode='after')
def card_needs_token(self):
if self.type == 'card' and not (self.token and self.payment_method_id):
raise ValueError('Card payment requires the provider token and method')
return self
class PaymentIntent(StrictModel):
quote_id: UUID
method: PaymentMethod
class Move(StrictModel):
state: Literal['rec','tra','fil','imp','cor','fin']
version: int = Field(ge=0)
reason: str = Field(default='', max_length=1000)
class Resolution(StrictModel):
note: str = Field(min_length=3, max_length=1000)
class Register(StrictModel):
customer: Customer
password: str = Field(min_length=12, max_length=128)
class Login(StrictModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128)
class OperatorLogin(StrictModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128)
@field_validator('email')
@classmethod
def operator_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 FileReference(StrictModel):
upload_id: UUID
item_index: int = Field(ge=0, strict=True)
class ArtworkSubmission(StrictModel):
version: int = Field(ge=0, strict=True)
files: list[FileReference] = Field(min_length=1, max_length=60)
note: str = Field(min_length=1, max_length=1000)

View File

@@ -1,9 +1,9 @@
"""Resolve Docker secret files into the environment before configuration is read.
Swarm mounts each secret as a file and the stack passes its path as `<NAME>_FILE`.
Nothing read `_FILE` settings, so `deploy/stack.yaml` could not boot: the runtime
looked for `DATABASE_URL`, `AWS_ACCESS_KEY_ID` and `OPERATOR_PASSWORD` while the
stack supplied only the `_FILE` form.
The deployed `docker-compose.yml` passes credentials as plain environment
variables, so this module is inert there. It exists so a stack can supply them as
Docker secrets instead without any code change; see `docs/ROADMAP.md` 2.12.
Call `load()` in every entrypoint before any configuration is read.
@@ -12,9 +12,9 @@ secrets` elsewhere in the package to the standard library, not to this file.
"""
import os
# The settings production supplies as secret files. Any other `*_FILE` variable is
# The settings a stack may supply as secret files. Any other `*_FILE` variable is
# resolved the same way; this list documents the contract and is what the release
# gate checks against, so keep it in step with `deploy/stack.yaml`.
# gate checks against.
SECRET_FILE_SETTINGS = (
'DATABASE_URL',
'DATABASE_ADMIN_URL',

164
app/mercadopago.py Normal file
View File

@@ -0,0 +1,164 @@
"""Mercado Pago: payment creation, webhook verification and status lookup.
Written from the public API documentation and exercised only against a fake
HTTP transport. It is not a verified integration until it has passed the
sandbox flows in docs/PRODUCTION_INPUTS.md with the client's own account;
until then the runtime refuses to select it without explicit credentials.
The notification is only a pointer. Its body says "payment 123 changed" and
nothing about amount or status, so nothing in it is trusted beyond the id:
the payment is fetched from the API with our access token, and that response
is what the order service compares against the approved quote.
Signature (x-signature: "ts=<unix>,v1=<hex>"): HMAC-SHA256, keyed with the
webhook secret from the integration panel, over the manifest
"id:<data.id>;request-id:<x-request-id>;ts:<ts>;", where data.id comes from
the notification URL's query string (lower-cased when alphanumeric). A part
whose value is absent from the notification is left out of the manifest.
"""
import hashlib
import hmac
import json
import os
import time
from decimal import Decimal, InvalidOperation
from typing import Mapping
import httpx
from .adapters import PaymentEvent
API = 'https://api.mercadopago.com'
# How old a signed timestamp may be. Mercado Pago retries a failed delivery
# every 15 minutes, re-signing each attempt, so a replayed old one is refused.
MAX_SIGNATURE_AGE = 30 * 60
STATUSES = {'approved': 'approved', 'pending': 'pending', 'in_process': 'pending',
'authorized': 'pending', 'in_mediation': 'pending', 'rejected': 'rejected',
'cancelled': 'cancelled', 'refunded': 'refunded', 'charged_back': 'refunded'}
class MercadoPagoPayment:
name = 'mercadopago'
def __init__(self, access_token=None, webhook_secret=None, notification_url=None,
transport=None, clock=time.time):
self.access_token = access_token or os.environ.get('MP_ACCESS_TOKEN', '')
self.webhook_secret = (webhook_secret or os.environ.get('MP_WEBHOOK_SECRET', '')).encode()
self.notification_url = notification_url or os.environ.get('MP_NOTIFICATION_URL', '')
if not self.access_token or not self.webhook_secret:
raise RuntimeError('Mercado Pago needs MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET')
self.http = httpx.Client(base_url=API, transport=transport, timeout=15,
headers={'Authorization': f'Bearer {self.access_token}'})
self.clock = clock
# Payments ------------------------------------------------------------
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
"""Create a payment for an approved quote.
The quote id is the idempotency key, so a retry after a timeout returns
the payment already created rather than charging again. `method` is
{'type': 'pix'} or {'type': 'card', 'token', 'payment_method_id',
'installments', 'issuer_id'} from Mercado Pago's card form: card data is
tokenised in the customer's browser and never reaches this server.
"""
method = method or {'type': 'pix'}
body = {'transaction_amount': float(Decimal(total_cents) / 100),
'description': f'DTF - cotação {quote_id[:8]}',
'external_reference': quote_id,
'payer': {'email': customer['mail'],
'identification': {'type': 'CNPJ', 'number': customer['cnpj']}}}
if self.notification_url:
body['notification_url'] = self.notification_url
if method['type'] == 'pix':
body['payment_method_id'] = 'pix'
elif method['type'] == 'card':
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
installments=int(method.get('installments', 1)))
if method.get('issuer_id'):
body['issuer_id'] = method['issuer_id']
else:
raise ValueError('Unsupported payment method')
# A PIX retry must return the same code. A card retry after a decline
# is a new attempt with a new token, so the token is part of the key;
# the intent route refuses new attempts once one is approved or in review.
key = f'dtf-quote-{quote_id}-pix' if method['type'] == 'pix' else \
f"dtf-quote-{quote_id}-card-{hashlib.sha256(method['token'].encode()).hexdigest()[:24]}"
response = self.http.post('/v1/payments', json=body, headers={'X-Idempotency-Key': key})
response.raise_for_status()
payment = response.json()
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}
return {'provider': self.name, 'id': str(payment['id']),
'status': STATUSES.get(payment.get('status'), 'pending'),
'status_detail': payment.get('status_detail'),
'total_cents': total_cents,
'pix_qr_code': transaction.get('qr_code'),
'pix_qr_code_base64': transaction.get('qr_code_base64'),
'ticket_url': transaction.get('ticket_url')}
def lookup(self, payment_id: str) -> dict:
response = self.http.get(f'/v1/payments/{payment_id}')
response.raise_for_status()
return response.json()
# Webhooks ------------------------------------------------------------
def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
signature = headers.get('x-signature') or ''
parts = dict(part.strip().split('=', 1) for part in signature.split(',') if '=' in part)
ts, supplied = parts.get('ts', ''), parts.get('v1', '')
if not ts.isdigit() or not supplied:
return False
if abs(self.clock() - int(ts[:10])) > MAX_SIGNATURE_AGE:
return False
data_id = (query or {}).get('data.id', '')
if data_id.isalnum():
data_id = data_id.lower()
manifest = ''
if data_id:
manifest += f'id:{data_id};'
request_id = headers.get('x-request-id') or ''
if request_id:
manifest += f'request-id:{request_id};'
manifest += f'ts:{ts};'
expected = hmac.new(self.webhook_secret, manifest.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, supplied.lower())
def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
try:
data = json.loads(body)
except ValueError:
return None
if not isinstance(data, dict) or data.get('type') != 'payment':
return None
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
if not payment_id.isdigit():
return None
payment = self.lookup(payment_id)
return event_from_payment(payment, notification_id=str(data.get('id', '')))
def event_from_payment(payment: dict, notification_id: str = '') -> PaymentEvent:
"""Normalise a payment fetched from the API. Amount is None unless it is BRL
and a whole number of centavos, so a foreign or malformed amount is refused."""
amount = None
if payment.get('currency_id') == 'BRL':
try:
cents = Decimal(str(payment.get('transaction_amount'))) * 100
if cents == cents.to_integral_value():
amount = int(cents)
except (InvalidOperation, TypeError, ValueError):
amount = None
status = STATUSES.get(payment.get('status'), 'pending')
# One event per payment state: a notification id alone would let the same
# approval be applied twice under two notifications, and would collapse a
# later refund into the earlier approval.
event_id = f"{payment.get('id')}:{payment.get('status')}"
return PaymentEvent(event_id=event_id, reference=str(payment.get('external_reference') or ''),
status=status, amount_cents=amount,
raw={'provider': 'mercadopago', 'payment_id': str(payment.get('id')),
'status': payment.get('status'), 'status_detail': payment.get('status_detail'),
'currency_id': payment.get('currency_id'),
'transaction_amount': payment.get('transaction_amount'),
'date_approved': payment.get('date_approved'),
'notification_id': notification_id})

130
app/operators.py Normal file
View File

@@ -0,0 +1,130 @@
"""Kanban operator accounts.
One shared login meant every card movement was attributed to the same name, so
the movement history could not answer who did what. Accounts live in the
database; `OPERATOR_EMAIL` and `OPERATOR_PASSWORD` seed the first one so an
existing deployment keeps working unchanged.
Administered from the API container, the same way the schema is:
python -m local.operators list
python -m local.operators add maria@example.com --name "Maria"
python -m local.operators password maria@example.com
python -m local.operators disable maria@example.com
python -m local.operators enable maria@example.com
Passwords are read from the terminal, never from an argument, so they do not
reach shell history or the process list.
"""
import getpass
import os
import sys
from uuid import uuid4
from .core.auth import password_hash
from .core.db import connect
MIN_PASSWORD = 12
def seed_from_environment(cursor):
"""Make the configured credential a real account, once.
Only inserts when that email is absent, so a password changed here is never
reverted by a stale environment variable on the next deploy.
"""
email = os.environ.get('OPERATOR_EMAIL', '').strip().lower()
password = os.environ.get('OPERATOR_PASSWORD', '')
if not email or not password:
return None
existing = cursor.execute(
'SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
if existing:
return None
cursor.execute(
'INSERT INTO dtf_local.operators(id,email,name,password_hash) VALUES(%s,%s,%s,%s)',
(uuid4(), email, 'Operador', password_hash(password)))
return email
def _ask_password(email):
first = getpass.getpass(f'New password for {email}: ')
if len(first) < MIN_PASSWORD:
raise SystemExit(f'Password must be at least {MIN_PASSWORD} characters.')
if first != getpass.getpass('Repeat: '):
raise SystemExit('Passwords did not match.')
return first
def add(email, name=''):
email = email.strip().lower()
with connect() as c:
if c.execute('SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone():
raise SystemExit(f'{email} already exists. Use "password" or "enable".')
c.execute('INSERT INTO dtf_local.operators(id,email,name,password_hash) VALUES(%s,%s,%s,%s)',
(uuid4(), email, name, password_hash(_ask_password(email))))
print(f'Added {email}.')
def password(email):
email = email.strip().lower()
with connect() as c:
if not c.execute('SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone():
raise SystemExit(f'{email} does not exist.')
c.execute('UPDATE dtf_local.operators SET password_hash=%s WHERE email=%s',
(password_hash(_ask_password(email)), email))
print(f'Password changed for {email}. Existing sessions stay valid until they expire;'
' run "disable" first if the account is compromised.')
def set_active(email, active):
email = email.strip().lower()
with connect() as c:
updated = c.execute(
'UPDATE dtf_local.operators SET active=%s WHERE email=%s RETURNING email',
(active, email)).fetchone()
if not updated:
raise SystemExit(f'{email} does not exist.')
if not active:
# Revoke immediately: disabling must end access now, not in eight hours.
c.execute('DELETE FROM dtf_local.operator_sessions WHERE username=%s', (email,))
print(f'{email} {"enabled" if active else "disabled; open sessions revoked"}.')
def listing():
with connect() as c:
rows = c.execute('''SELECT email,name,active,last_login_at FROM dtf_local.operators
ORDER BY email''').fetchall()
if not rows:
print('No operator accounts. Set OPERATOR_EMAIL and OPERATOR_PASSWORD and run'
' "python -m local.bootstrap", or add one here.')
return
for row in rows:
seen = row['last_login_at'].strftime('%Y-%m-%d %H:%M') if row['last_login_at'] else 'never'
state = 'active ' if row['active'] else 'DISABLED'
print(f"{state} {row['email']:<34} {row['name'][:20]:<20} last login {seen}")
def main(argv):
if not argv:
raise SystemExit(__doc__)
command, rest = argv[0], argv[1:]
if command == 'list':
return listing()
if not rest:
raise SystemExit(f'usage: python -m local.operators {command} <email>')
email = rest[0]
if command == 'add':
name = rest[rest.index('--name') + 1] if '--name' in rest else ''
return add(email, name)
if command == 'password':
return password(email)
if command == 'disable':
return set_active(email, False)
if command == 'enable':
return set_active(email, True)
raise SystemExit(__doc__)
if __name__ == '__main__':
main(sys.argv[1:])

125
app/payments.py Normal file
View File

@@ -0,0 +1,125 @@
"""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
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()
queue_print_files(c, order['id'], len(approved['items']))
for provider in ('tiny', 'whatsapp'):
enqueue(c, f"{order['id']}:paid:{provider}", provider,
{'order_id': str(order['id']), 'number': order['number'],
'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)')

488
app/printfile.py Normal file
View File

@@ -0,0 +1,488 @@
"""The print file: the reviewed layout, reproduced exactly, at the film's size.
The customer is quoted on a layout the Site computes: each copy of each artwork
at a width, rotation, mirror and position on the film. Production spec v2 keeps
that layout through the approved order. This module turns it into one PDF per
order item, with a page exactly as wide as the film and as long as the layout,
so the operator imports what the customer approved instead of rebuilding it.
Each source image is embedded once, at its original resolution, and every copy
is a placement of it. Nothing is resampled: a 300 DPI artwork is still 300 DPI
in the file, a JPEG keeps its original bytes, and transparency survives as a
soft mask. The output is therefore about the size of the artwork, not of a
57 cm x 20 m raster, and it never needs that raster in memory.
Raster sources are JPEG, PNG, WebP and TIFF. A single-page PDF is placed as a
vector form, never rasterised. Anything else (PSD, AI, CDR, multi-page or
protected PDFs), or a file whose proportions do not match the size it was
quoted at, is refused with a reason, and the operator prepares that item by
hand exactly as before.
"""
import math
import os
import tempfile
import zlib
from decimal import Decimal
from PIL import Image, ImageOps
PT_PER_CM = 72 / 2.54
# Acrobat's page limit. Longer layouts scale user space with /UserUnit (PDF 1.6)
# rather than cutting the film into pages a RIP might print with gaps.
MAX_PAGE_PT = 14400
# How far a file's proportions may drift from the quoted size before the item
# is refused: rounding in the Site keeps real files well inside this.
ASPECT_TOLERANCE = 0.01
# The quote may round up (10 cm steps, 1 m minimum) but never down.
HEIGHT_TOLERANCE_CM = Decimal('0.05')
SUPPORTED_FORMATS = {'JPEG', 'PNG', 'WEBP', 'TIFF'}
STRIP_ROWS = 256
# Decoding holds the whole image in memory (about 4 bytes a pixel). 57 cm x 3 m
# at 300 DPI is 239 Mpx; above this the item goes to the operator instead.
MAX_DECODED_PIXELS = int(os.environ.get('PRINT_MAX_PIXELS', '250000000'))
# Pillow's own bomb guard would refuse a genuine long sheet before we can
# decide; the explicit limit above is the one that applies.
Image.MAX_IMAGE_PIXELS = None
class Unsupported(Exception):
"""This item cannot be generated automatically; the reason is for the operator."""
class Name(str):
pass
class Ref(int):
pass
def serialize(value):
if isinstance(value, Ref):
return b'%d 0 R' % value
if isinstance(value, Name):
return b'/' + value.encode('ascii')
if isinstance(value, bool):
return b'true' if value else b'false'
if isinstance(value, int):
return b'%d' % value
if isinstance(value, (float, Decimal)):
text = f'{float(value):.4f}'.rstrip('0').rstrip('.')
return (text if text not in ('', '-0') else '0').encode('ascii')
if isinstance(value, dict):
return b'<<' + b''.join(b'/' + k.encode('ascii') + b' ' + serialize(v)
for k, v in value.items()) + b'>>'
if isinstance(value, (list, tuple)):
return b'[' + b' '.join(serialize(v) for v in value) + b']'
if isinstance(value, str):
escaped = value.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
return b'(' + escaped.encode('latin-1', 'replace') + b')'
raise TypeError(f'Cannot serialize {type(value).__name__}')
class PdfWriter:
"""A sequential PDF writer: objects go straight to the file, streams included.
Stream lengths are indirect objects written after the data, so an image is
compressed strip by strip into the output without being held in memory.
"""
def __init__(self, fp):
self.fp = fp
self.offsets = {}
self.next_id = 1
self.write(b'%PDF-1.6\n%\xe2\xe3\xcf\xd3\n')
def write(self, data):
self.fp.write(data)
def tell(self):
return self.fp.tell()
def alloc(self):
ref = Ref(self.next_id)
self.next_id += 1
return ref
def obj(self, value, ref=None):
ref = ref or self.alloc()
self.offsets[ref] = self.tell()
self.write(b'%d 0 obj\n' % ref + serialize(value) + b'\nendobj\n')
return ref
def stream(self, dictionary, chunks, ref=None):
"""Write a stream from an iterable of already-encoded byte chunks."""
ref = ref or self.alloc()
length = self.alloc()
self.offsets[ref] = self.tell()
self.write(b'%d 0 obj\n' % ref + serialize({**dictionary, 'Length': length}) + b'\nstream\n')
start = self.tell()
for chunk in chunks:
self.write(chunk)
size = self.tell() - start
self.write(b'\nendstream\nendobj\n')
self.obj(size, length)
return ref
def finish(self, root, info):
xref = self.tell()
count = self.next_id
self.write(b'xref\n0 %d\n0000000000 65535 f \n' % count)
for ref in range(1, count):
self.write(b'%010d 00000 n \n' % self.offsets[ref])
self.write(b'trailer\n' + serialize({'Size': count, 'Root': root, 'Info': info}) +
b'\nstartxref\n%d\n%%%%EOF\n' % xref)
def deflate(pieces):
compressor = zlib.compressobj(6)
for piece in pieces:
out = compressor.compress(piece)
if out:
yield out
yield compressor.flush()
class SourceImage:
"""One customer file, opened for reading pixels and measured as the browser sees it."""
def __init__(self, path, name):
self.path = path
self.name = name
try:
self.image = Image.open(path)
self.format = self.image.format
except Image.DecompressionBombError as exc:
raise Unsupported(f'"{name}" is too large to generate automatically') from exc
except Exception as exc:
raise Unsupported(f'"{name}" is not an image this generator can read') from exc
if self.format not in SUPPORTED_FORMATS:
raise Unsupported(f'"{name}" is {self.format or "an unknown format"}; '
'only JPEG, PNG, WebP and TIFF are generated automatically')
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
raise Unsupported(f'"{name}" is animated or has several frames')
# Browsers draw a photo upright according to its EXIF orientation, and
# the Site measured it that way, so the print must too.
try:
self.orientation = self.image.getexif().get(0x0112, 1)
except Exception:
self.orientation = 1
width, height = self.image.size
self.size = (height, width) if self.orientation in (5, 6, 7, 8) else (width, height)
def passthrough(self):
"""Whether the original JPEG bytes can go into the PDF unchanged."""
return (self.format == 'JPEG' and self.orientation == 1 and
self.image.mode in ('L', 'RGB', 'CMYK'))
def embed(self, pdf):
"""Write this image (and its alpha) as XObjects; returns the image reference."""
if self.passthrough():
return self._embed_jpeg(pdf)
width, height = self.image.size
if width * height > MAX_DECODED_PIXELS:
raise Unsupported(f'"{self.name}" has {width} x {height} px, more than the '
'generator decodes; prepare this item by hand')
return self._embed_pixels(pdf)
def _colorspace(self, pdf, mode):
components = {'L': 1, 'RGB': 3, 'CMYK': 4}[mode]
device = Name({'L': 'DeviceGray', 'RGB': 'DeviceRGB', 'CMYK': 'DeviceCMYK'}[mode])
profile = self.image.info.get('icc_profile')
if not profile:
return device
icc = pdf.stream({'N': components, 'Alternate': device, 'Filter': Name('FlateDecode')},
deflate([profile]))
return [Name('ICCBased'), icc]
def _embed_jpeg(self, pdf):
image = self.image
extra = {}
if image.mode == 'CMYK' and 'adobe' in image.info:
# Adobe writes CMYK JPEGs inverted; PDF readers expect the Decode flip.
extra['Decode'] = [1, 0, 1, 0, 1, 0, 1, 0]
def chunks():
with open(self.path, 'rb') as source:
while block := source.read(1 << 20):
yield block
return pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
'Width': image.width, 'Height': image.height,
'ColorSpace': self._colorspace(pdf, image.mode),
'BitsPerComponent': 8, 'Filter': Name('DCTDecode'), **extra},
chunks())
def _upright(self):
image = self.image
image.load()
if self.orientation != 1:
image = ImageOps.exif_transpose(image)
return image
def _embed_pixels(self, pdf):
image = self._upright()
mode = image.mode
has_alpha = mode in ('RGBA', 'LA', 'PA', 'RGBa', 'La') or (
mode == 'P' and 'transparency' in image.info) or (
mode in ('L', 'RGB') and 'transparency' in image.info)
if mode in ('I;16', 'I;16B', 'I;16L', 'I'):
image = image.convert('I').point(lambda value: value * (1 / 257)).convert('L')
mode = 'L'
if mode == 'CMYK':
color_mode = 'CMYK'
elif mode in ('1', 'L', 'LA', 'La'):
color_mode = 'L'
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
color_mode = 'RGB'
else:
raise Unsupported(f'"{self.name}" uses the {mode} colour mode, which is not generated automatically')
if has_alpha:
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
width, height = image.size
def strips(convert):
for top in range(0, height, STRIP_ROWS):
yield convert(image.crop((0, top, width, min(height, top + STRIP_ROWS)))).tobytes()
smask = None
if has_alpha:
smask = pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
'Width': width, 'Height': height,
'ColorSpace': Name('DeviceGray'), 'BitsPerComponent': 8,
'Filter': Name('FlateDecode')},
deflate(strips(lambda strip: strip.getchannel('A'))))
colorspace = self._colorspace(pdf, color_mode)
dictionary = {'Type': Name('XObject'), 'Subtype': Name('Image'),
'Width': width, 'Height': height, 'ColorSpace': colorspace,
'BitsPerComponent': 8, 'Filter': Name('FlateDecode')}
if smask:
dictionary['SMask'] = smask
return pdf.stream(dictionary, deflate(strips(lambda strip: strip.convert(color_mode))))
def close(self):
self.image.close()
def placement_matrix(placement, page_height_pt):
"""Map the image's unit square onto its box on the film.
Matches the Site's canvas: the artwork is mirrored first, then turned
clockwise about the centre of its box, and the turned image fills the box.
"""
x = float(placement['x_cm']) * PT_PER_CM
top = page_height_pt - float(placement['y_cm']) * PT_PER_CM
w = float(placement['width_cm']) * PT_PER_CM
h = float(placement['length_cm']) * PT_PER_CM
rotation = placement['rotation_degrees'] % 360
mirrored = placement['mirrored']
def to_page(u, v):
# Unit square (v up) -> image frame (s right, t down).
s, t = u, 1 - v
if mirrored:
s = 1 - s
s, t = {0: (s, t), 90: (1 - t, s), 180: (1 - s, 1 - t), 270: (t, 1 - s)}[rotation]
return x + s * w, top - t * h
ox, oy = to_page(0, 0)
ax, ay = to_page(1, 0)
cx, cy = to_page(0, 1)
return [ax - ox, ay - oy, cx - ox, cy - oy, ox, oy]
class PdfPage:
"""One page of a customer PDF, placed as a vector form: nothing is rasterised.
The box and orientation are the ones the Site measured with pdf.js: the
CropBox (which pikepdf uses for the form's BBox), turned by the page's
/Rotate, inherited or not.
"""
def __init__(self, path, name):
import pikepdf
self.name = name
try:
self.pdf = pikepdf.open(path)
except pikepdf.PasswordError as exc:
raise Unsupported(f'"{name}" is password-protected') from exc
except Exception as exc:
raise Unsupported(f'"{name}" is not a PDF this generator can read') from exc
try:
pages = len(self.pdf.pages)
if pages != 1:
raise Unsupported(f'"{name}" has {pages} pages; only single-page PDFs are generated automatically')
self.page = self.pdf.pages[0]
self.rotation = int(self.page.rotation) % 360
if self.rotation not in (0, 90, 180, 270):
raise Unsupported(f'"{name}" has an unsupported page rotation')
box = [float(v) for v in self.page.cropbox]
except Unsupported:
self.pdf.close()
raise
except Exception as exc:
self.pdf.close()
raise Unsupported(f'"{name}" has a page this generator cannot read') from exc
self.x0, self.y0 = min(box[0], box[2]), min(box[1], box[3])
self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1])
if self.w <= 0 or self.h <= 0:
self.pdf.close()
raise Unsupported(f'"{name}" has an empty page')
# As displayed, which is what the proportions are checked against.
self.size = (self.h, self.w) if self.rotation in (90, 270) else (self.w, self.h)
def normalise(self):
"""Matrix from the page's box, as displayed, onto the unit square."""
a, d = 1 / self.w, 1 / self.h
scale = [a, 0, 0, d, -self.x0 * a, -self.y0 * d]
turn = {0: [1, 0, 0, 1, 0, 0], 90: [0, -1, 1, 0, 0, 1],
180: [-1, 0, 0, -1, 1, 1], 270: [0, 1, -1, 0, 1, 0]}[self.rotation]
return multiply(scale, turn)
def form(self, target):
"""The page as a form XObject copied into the target document."""
form = self.page.as_form_xobject(handle_transformations=False)
group = self.page.obj.get('/Group')
if group is not None and '/Group' not in form:
form.Group = group
return target.copy_foreign(form)
def close(self):
self.pdf.close()
def multiply(first, then):
"""PDF matrices: a point transformed by `first`, then by `then`."""
a1, b1, c1, d1, e1, f1 = first
a2, b2, c2, d2, e2, f2 = then
return [a1 * a2 + b1 * c2, a1 * b2 + b1 * d2, c1 * a2 + d1 * c2, c1 * b2 + d1 * d2,
e1 * a2 + f1 * c2 + e2, e1 * b2 + f1 * d2 + f2]
def open_source(path, name):
with open(path, 'rb') as handle:
head = handle.read(1024)
if b'%PDF-' in head:
return PdfPage(path, name)
return SourceImage(path, name)
def check_layout(item, sizes, vector=()):
"""Refuse a layout the file cannot reproduce faithfully. Returns the lowest
DPI of the raster sources (vector pages have none)."""
production = item['production']
height = Decimal(str(production['height_cm']))
billed = Decimal(str(item['billed_metres'])) * 100
if height > billed + HEIGHT_TOLERANCE_CM:
raise Unsupported(f'layout is {height} cm long but only {billed} cm were billed')
lowest = None
for placement in production['placements']:
width_px, height_px = sizes[placement['source_index']]
if placement['rotation_degrees'] % 180 == 90:
width_px, height_px = height_px, width_px
width_cm = float(placement['width_cm'])
length_cm = float(placement['length_cm'])
drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
if drift > ASPECT_TOLERANCE:
source = production['sources'][placement['source_index']]
raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match '
f'the quoted {source["width_cm"]} x {source["length_cm"]} cm')
if placement['source_index'] in vector:
continue
dpi = width_px / (width_cm / 2.54)
lowest = dpi if lowest is None else min(lowest, dpi)
return lowest
def render(item, files, out, title):
"""Write the PDF for one approved order item.
`files` maps each source index to (local path, original name). Returns the
evidence the Kanban shows: page size, what was billed, and the lowest DPI.
Raster sources are written by the streaming writer above. PDF sources are
then added as vector forms by a second pass through pikepdf, so a sheet
exported as PDF keeps its vectors, fonts and transparency.
"""
production = item['production']
if production.get('version') != 2:
raise Unsupported('item uses an obsolete production layout')
sources = []
try:
for index in range(len(production['sources'])):
path, name = files[index]
sources.append(open_source(path, name))
vector = {index for index, source in enumerate(sources) if isinstance(source, PdfPage)}
lowest_dpi = check_layout(item, [source.size for source in sources], vector)
width_pt = float(production['film_width_cm']) * PT_PER_CM
height_pt = float(production['height_cm']) * PT_PER_CM
unit = max(1, math.ceil(max(width_pt, height_pt) / MAX_PAGE_PT))
first = tempfile.TemporaryFile() if vector else out
try:
write_rasters(production, sources, first, title, width_pt, height_pt, unit)
if vector:
first.seek(0)
add_vector_pages(production, sources, first, out, height_pt)
finally:
if vector:
first.close()
finally:
for source in sources:
source.close()
return {'film_width_cm': str(production['film_width_cm']),
'height_cm': str(production['height_cm']),
'billed_metres': str(item['billed_metres']),
'placements': len(production['placements']),
'sources': len(sources),
'vector_sources': len(vector),
'min_dpi': round(lowest_dpi) if lowest_dpi else None,
'user_unit': unit}
def write_rasters(production, sources, out, title, width_pt, height_pt, unit):
pdf = PdfWriter(out)
images = {index: source.embed(pdf) for index, source in enumerate(sources)
if isinstance(source, SourceImage)}
# The user-space scale is not wrapped in q/Q, so it also applies to the
# vector placements appended by the second pass.
commands = [b'%s 0 0 %s 0 0 cm\n' % (serialize(1 / unit), serialize(1 / unit))] if unit > 1 else []
for placement in production['placements']:
if placement['source_index'] not in images:
continue
matrix = placement_matrix(placement, height_pt)
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
b' cm /Im%d Do Q\n' % placement['source_index'])
content = pdf.stream({'Filter': Name('FlateDecode')}, deflate(commands))
pages = pdf.alloc()
page_box = [0, 0, width_pt / unit, height_pt / unit]
page = {'Type': Name('Page'), 'Parent': pages, 'MediaBox': page_box, 'TrimBox': page_box,
'Resources': {'XObject': {f'Im{index}': ref for index, ref in images.items()}},
'Contents': content}
if unit > 1:
page['UserUnit'] = unit
page_ref = pdf.obj(page)
pdf.obj({'Type': Name('Pages'), 'Kids': [page_ref], 'Count': 1}, pages)
root = pdf.obj({'Type': Name('Catalog'), 'Pages': pages})
info = pdf.obj({'Title': title, 'Producer': 'DTF System print-file generator'})
pdf.finish(root, info)
def add_vector_pages(production, sources, first, out, height_pt):
import pikepdf
with pikepdf.open(first) as document:
page = document.pages[0]
xobjects = page.obj.Resources.XObject
for index, source in enumerate(sources):
if isinstance(source, PdfPage):
xobjects[f'/Pdf{index}'] = source.form(document)
commands = []
for placement in production['placements']:
source = sources[placement['source_index']]
if not isinstance(source, PdfPage):
continue
matrix = multiply(source.normalise(), placement_matrix(placement, height_pt))
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
b' cm /Pdf%d Do Q\n' % placement['source_index'])
page.contents_add(pikepdf.Stream(document, b''.join(commands)), prepend=False)
document.save(out, min_version='1.6')

139
app/printjobs.py Normal file
View File

@@ -0,0 +1,139 @@
"""Generating print files in the background, one order item at a time.
A paid order queues one job per item. The worker claims a job, commits the
claim, and renders outside any transaction, so a long render never holds a row
lock or a database connection. A claim older than CLAIM_TIMEOUT is assumed to
belong to a worker that died and is taken again.
The result is an ordinary upload row owned by an identity derived from the
order, already marked clean: its only inputs are artwork that passed the
malware scan, and the bytes are written here. The operator still decides
whether it becomes the final file; generation never approves anything.
"""
import logging
import os
import tempfile
import time
from datetime import timedelta
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
from psycopg.types.json import Jsonb
from .core.auth import audit
from .core.db import connect
from .printfile import Unsupported, render
CLAIM_TIMEOUT = timedelta(minutes=15)
MAX_ATTEMPTS = 3
def generated_identity(order_id):
return uuid5(NAMESPACE_URL, f'dtf-print-file:{order_id}')
def queue(c, order_id, items, only_missing=False):
"""Queue (or re-queue) generation for every item of an order."""
for index in range(items):
if only_missing:
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
ON CONFLICT(order_id,item_index) DO UPDATE SET status='pending', attempts=0,
claimed_at=NULL, finished_at=NULL, detail='{}'
WHERE dtf_local.print_files.status IN ('failed','manual')''',
(uuid4(), order_id, index))
else:
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
ON CONFLICT(order_id,item_index) DO NOTHING''', (uuid4(), order_id, index))
def claim(c):
return c.execute('''SELECT p.*, o.snapshot, o.number FROM dtf_local.print_files p
JOIN dtf_local.orders o ON o.id=p.order_id
WHERE p.status='pending' OR (p.status='rendering' AND p.claimed_at < now()-%s)
ORDER BY p.created_at FOR UPDATE OF p SKIP LOCKED LIMIT 1''', (CLAIM_TIMEOUT,)).fetchone()
def render_one(storage):
with connect() as c:
job = claim(c)
if not job:
return False
c.execute('''UPDATE dtf_local.print_files SET status='rendering', claimed_at=now(),
attempts=attempts+1 WHERE id=%s''', (job['id'],))
item = job['snapshot']['items'][job['item_index']]
uploads = c.execute('''SELECT id,name,object_key,scan_state,purged_at,expires_at,
(expires_at<=now()) AS expired FROM dtf_local.uploads WHERE id=ANY(%s)''',
([UUID(u) for u in item['uploads']],)).fetchall()
# Every generated file shares its order's artwork retention deadline.
expiry = c.execute('SELECT min(created_at)+interval \'30 days\' AS e FROM dtf_local.uploads WHERE id=ANY(%s)',
([UUID(u) for u in item['uploads']],)).fetchone()['e']
by_id = {str(row['id']): row for row in uploads}
try:
result = produce(storage, job, item, by_id)
except Unsupported as reason:
finish(job, 'manual', {'reason': str(reason)})
audit('print_file_manual', order=str(job['order_id']), item=job['item_index'])
return True
except Exception as exc:
logging.exception('Print file generation failed')
final = job['attempts'] + 1 >= MAX_ATTEMPTS
finish(job, 'failed' if final else 'pending', {'error': type(exc).__name__})
return True
path, name, size, detail = result
try:
uid = uuid4()
key = f'originals/{uid}'
storage.store(key, path, 'application/pdf')
finally:
os.unlink(path)
with connect() as c:
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,complete,
expires_at,scan_state,scan_reason,scanned_at)
VALUES(%s,%s,%s,%s,%s,'',true,%s,'clean','generated from scanned artwork',now())''',
(uid, generated_identity(job['order_id']), name, size, key, expiry))
c.execute('''UPDATE dtf_local.print_files SET status='ready', upload_id=%s, detail=%s,
finished_at=now() WHERE id=%s''', (uid, Jsonb(detail), job['id']))
audit('print_file_ready', order=str(job['order_id']), item=job['item_index'])
return True
def produce(storage, job, item, uploads):
"""Fetch the item's artwork and render it. Returns (pdf path, name, size, evidence)."""
for upload_id in item['uploads']:
row = uploads.get(upload_id)
if not row or row['scan_state'] != 'clean':
raise Unsupported('an original file is missing or not cleared by the malware scan')
if row['purged_at'] or row['expired']:
raise Unsupported('an original file has passed its retention period')
number = job['number']
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
with tempfile.TemporaryDirectory(prefix='print-') as scratch:
files = {}
for index, upload_id in enumerate(item['uploads']):
local = os.path.join(scratch, f'source-{index}')
storage.fetch(uploads[upload_id]['object_key'], local)
files[index] = (local, uploads[upload_id]['name'])
handle, output = tempfile.mkstemp(prefix='print-', suffix='.pdf')
try:
with os.fdopen(handle, 'wb') as out:
detail = render(item, files, out, f'Pedido {number} - item {job["item_index"] + 1}')
except BaseException:
os.unlink(output)
raise
return output, name, os.path.getsize(output), detail
def finish(job, status, detail):
with connect() as c:
c.execute('''UPDATE dtf_local.print_files SET status=%s, detail=%s,
finished_at=CASE WHEN %s='pending' THEN NULL ELSE now() END,
claimed_at=NULL WHERE id=%s''', (status, Jsonb(detail), status, job['id']))
def render_loop(storage):
while True:
try:
if render_one(storage):
continue
except Exception:
logging.exception('Print render worker tick failed')
time.sleep(2)

90
app/runtime.py Normal file
View File

@@ -0,0 +1,90 @@
"""Composition root, and the pieces every router needs.
app.py held the adapters, the configuration, the shared query helpers and all
nineteen of its routes, so customer.py could not import from it without a cycle
and was wired instead by passing nine callables into install_routes. Everything
shared lives here: routers import downwards, never from each other.
"""
import os
from datetime import datetime, timedelta, timezone
from uuid import uuid5, NAMESPACE_URL
from fastapi import HTTPException
from psycopg.types.json import Jsonb
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
from .core.secrets import load as load_secret_files
# Secret files must resolve before any configuration below is read.
load_secret_files()
require_runtime()
storage = LocalS3Storage()
if os.environ.get('PAYMENT_ADAPTER') == 'mercadopago':
from .mercadopago import MercadoPagoPayment
payment = MercadoPagoPayment()
else:
payment = FakePayment()
freight = FakeFreight()
ENVIRONMENT = os.environ.get('APP_ENV', 'local')
PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost')
ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host]
ALLOWED_ORIGINS = [origin for origin in os.environ.get('ALLOWED_ORIGINS', PUBLIC_ORIGIN).split(',') if origin]
# Per source and generous: a browser needs one session and keeps the cookie, but
# offices and mobile carriers put many real customers behind one address, so a
# tight per-IP ceiling would lock out the same people the old global one did.
GUEST_SESSION_LIMIT = int(os.environ.get('GUEST_SESSION_LIMIT', '240'))
# The board returned every order ever created. Finished ones are terminal, so
# they were pure growth: at a few hundred a day the response and the page both
# degrade with no operator benefit.
BOARD_FINISHED_LIMIT = int(os.environ.get('BOARD_FINISHED_LIMIT', '50'))
BOARD_QUOTE_LIMIT = int(os.environ.get('BOARD_QUOTE_LIMIT', '100'))
PART_BYTES = int(os.environ.get('UPLOAD_PART_BYTES', '8388608'))
if not 5242880 <= PART_BYTES <= 67108864:
raise RuntimeError('UPLOAD_PART_BYTES must be between 5 and 64 MiB')
STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impressão',
'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
def upload_row(c, upload_id, session_id, lock=False):
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND owner=%s' +
(' FOR UPDATE' if lock else ''), (upload_id, session_id)).fetchone()
if not row:
raise HTTPException(404, 'Upload not found')
days = 30 if row['complete'] else 1
if row['purged_at'] or row['expires_at'] <= datetime.now(timezone.utc) or row['created_at'] < datetime.now(timezone.utc) - timedelta(days=days):
raise HTTPException(410, 'Upload expired; select the file again')
return row
def quote_view(c, row):
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
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',
'order': order}
def enqueue(c, event_key, provider, payload):
c.execute('INSERT INTO dtf_local.outbox(event_key,provider,payload) VALUES(%s,%s,%s) ON CONFLICT(event_key) DO NOTHING',
(event_key, provider, Jsonb(payload)))
def owned_order(c, oid, identity, lock=False):
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s AND owner=%s'+(' FOR UPDATE' if lock else ''), (oid,identity)).fetchone()
if not row: raise HTTPException(404, 'Order not found')
return row
def file_rows(c, oid):
return c.execute('''SELECT f.id,f.upload_id,f.item_index,f.kind,f.active,f.note,f.created_at,
u.name,u.size,u.expires_at,(u.expires_at<=now()) AS expired
FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id
WHERE order_id=%s ORDER BY f.created_at''', (oid,)).fetchall()
def operator_identity(user):
return uuid5(NAMESPACE_URL, 'dtf-local-operator:'+user)

View File

@@ -1,11 +1,11 @@
"""Local ClamAV boundary. Unknown/error/over-limit results NEVER release artwork."""
import os
import socket
import struct
import time
from fastapi import HTTPException
from .auth import audit
from .db import connect
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,7 +30,7 @@ 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'))):
if size > scan_limit_bytes():
return 'rejected', 'File exceeds the malware scan limit'
with socket.create_connection(('scanner',3310),timeout=10) as sock:
sock.settimeout(150)

185
app/schema.sql Normal file
View File

@@ -0,0 +1,185 @@
-- Separate schema: never imports/migrates the historical schema.sql or SQLite.
CREATE SCHEMA IF NOT EXISTS dtf_local;
CREATE TABLE IF NOT EXISTS dtf_local.uploads (
id uuid PRIMARY KEY, owner uuid NOT NULL, name text NOT NULL,
size bigint NOT NULL, object_key text UNIQUE NOT NULL, multipart_id text NOT NULL,
complete boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.quotes (
id uuid PRIMARY KEY, owner uuid NOT NULL, request_key uuid NOT NULL,
request_hash text NOT NULL, draft jsonb NOT NULL, approved jsonb,
reviewed_by text, approved_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(owner, request_key)
);
CREATE TABLE IF NOT EXISTS dtf_local.orders (
id uuid PRIMARY KEY, number bigint GENERATED ALWAYS AS IDENTITY UNIQUE,
quote_id uuid NOT NULL UNIQUE REFERENCES dtf_local.quotes(id), owner uuid NOT NULL,
snapshot jsonb NOT NULL, payment jsonb NOT NULL, state text NOT NULL DEFAULT 'rec',
version integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.movements (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
from_state text NOT NULL, to_state text NOT NULL, operator text NOT NULL,
reason text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_key text NOT NULL UNIQUE,
provider text NOT NULL, payload jsonb NOT NULL, attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(), delivered_at timestamptz,
last_error text, receipt jsonb
);
CREATE TABLE IF NOT EXISTS dtf_local.accounts (
id uuid PRIMARY KEY, email text UNIQUE NOT NULL, password_hash text NOT NULL,
profile jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.sessions (
id uuid PRIMARY KEY, owner uuid NOT NULL,
expires_at timestamptz NOT NULL DEFAULT now() + interval '7 days'
);
CREATE TABLE IF NOT EXISTS dtf_local.migrations (name text PRIMARY KEY);
-- Preserve pre-account guest sessions once, without resurrecting logged-out sessions.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM dtf_local.migrations WHERE name='customer-sessions-v1') THEN
INSERT INTO dtf_local.sessions(id,owner)
SELECT owner,owner FROM (
SELECT owner FROM dtf_local.orders UNION SELECT owner FROM dtf_local.quotes
UNION SELECT owner FROM dtf_local.uploads
) legacy ON CONFLICT DO NOTHING;
INSERT INTO dtf_local.migrations VALUES('customer-sessions-v1');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS dtf_local.login_attempts (
key text PRIMARY KEY, attempts integer NOT NULL DEFAULT 0,
started_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.operator_sessions (
token_hash text PRIMARY KEY, username text NOT NULL,
expires_at timestamptz NOT NULL DEFAULT now() + interval '8 hours'
);
CREATE TABLE IF NOT EXISTS dtf_local.operators (
id uuid PRIMARY KEY, email text UNIQUE NOT NULL, name text NOT NULL DEFAULT '',
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()
);
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS expires_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS purged_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_state text NOT NULL DEFAULT 'pending';
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_reason text;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scanned_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_after timestamptz NOT NULL DEFAULT now();
UPDATE dtf_local.uploads SET expires_at=created_at + interval '30 days' WHERE expires_at IS NULL;
ALTER TABLE dtf_local.uploads ALTER COLUMN expires_at SET DEFAULT now() + interval '30 days';
CREATE TABLE IF NOT EXISTS dtf_local.order_files (
id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
upload_id uuid NOT NULL REFERENCES dtf_local.uploads(id), item_index integer NOT NULL,
kind text NOT NULL CHECK(kind IN ('final','correction')), active boolean NOT NULL DEFAULT true,
note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(order_id,upload_id,kind)
);
-- A payment started at the provider for an approved quote: which provider
-- payment belongs to which quote, and its latest known status.
CREATE TABLE IF NOT EXISTS dtf_local.payment_intents (
id uuid PRIMARY KEY, quote_id uuid NOT NULL REFERENCES dtf_local.quotes(id),
provider text NOT NULL, provider_payment_id text NOT NULL, method text NOT NULL,
status text NOT NULL, amount_cents bigint NOT NULL, response jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(provider, provider_payment_id)
);
-- OAuth connections to providers (Tiny). One row per provider; the refresh
-- token rotates on use, so it lives here, never in configuration.
CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
provider text PRIMARY KEY, access_token text NOT NULL, refresh_token text NOT NULL,
access_expires_at timestamptz NOT NULL, refresh_expires_at timestamptz,
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Single-use states for an operator-started OAuth connection. They protect the
-- callback, which arrives cross-site without the operator's cookie.
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
state text PRIMARY KEY, provider text NOT NULL, operator text NOT NULL,
expires_at timestamptz NOT NULL
);
-- The print file generated from each paid item's approved layout. One row per
-- item: the worker claims it, renders, and records either the file or why the
-- item has to be prepared by hand. Regenerating replaces the row's result.
CREATE TABLE IF NOT EXISTS dtf_local.print_files (
id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
item_index integer NOT NULL,
status text NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','rendering','ready','manual','failed')),
upload_id uuid REFERENCES dtf_local.uploads(id), detail jsonb NOT NULL DEFAULT '{}',
attempts integer NOT NULL DEFAULT 0, claimed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
UNIQUE(order_id,item_index)
);
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
-- Indexes follow the queries the application actually issues. Only these; every
-- extra index is paid for on each write.
-- The customer portal lists a person's orders and open quotes, newest first.
CREATE INDEX IF NOT EXISTS orders_owner ON dtf_local.orders(owner, created_at DESC);
CREATE INDEX IF NOT EXISTS quotes_owner ON dtf_local.quotes(owner, created_at DESC);
-- Order detail and the movement history read by order.
CREATE INDEX IF NOT EXISTS movements_order ON dtf_local.movements(order_id, id);
CREATE INDEX IF NOT EXISTS order_files_order ON dtf_local.order_files(order_id);
-- submit_files checks whether an upload is already attached, by upload.
CREATE INDEX IF NOT EXISTS order_files_upload ON dtf_local.order_files(upload_id);
-- The worker polls this once a second, and the table only grows. Partial, so the
-- index stays the size of the backlog rather than of all history.
CREATE INDEX IF NOT EXISTS outbox_pending ON dtf_local.outbox(available_at, id)
WHERE delivered_at IS NULL;
-- The scanner and the retention sweep both walk live uploads in arrival order.
-- now() cannot appear in a partial index predicate, so the time comparisons stay
-- in the query and the index narrows to rows still worth looking at.
CREATE INDEX IF NOT EXISTS uploads_live ON dtf_local.uploads(created_at)
WHERE purged_at IS NULL;
-- Sign-in transfers and logout delete a person's sessions by owner.
CREATE INDEX IF NOT EXISTS sessions_owner ON dtf_local.sessions(owner);
-- Disabling an operator revokes their open sessions by name.
CREATE INDEX IF NOT EXISTS operator_sessions_username ON dtf_local.operator_sessions(username);
-- 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');
-- The Kanban lists payment events a person must act on (money without an
-- order, or a reversed payment on an existing order) until resolved.
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz;
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_by text;
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolution text;
CREATE INDEX IF NOT EXISTS payment_events_open_issues ON dtf_local.payment_events(received_at)
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL;
-- Payment intents look up by quote (customer retry) and by provider id (webhook).
CREATE INDEX IF NOT EXISTS payment_intents_quote ON dtf_local.payment_intents(quote_id, created_at DESC);

276
app/tiny.py Normal file
View File

@@ -0,0 +1,276 @@
"""Tiny/Olist ERP (API v3): create the sales order once a payment is approved.
Written from the public API documentation and exercised only against a fake
HTTP transport. Tiny has no sandbox: the first real test creates a real order
in the client's ERP, so test with a marked order and cancel it afterwards.
Authentication is OAuth2 on Tiny's Keycloak. The client creates an
"Aplicativo" in Tiny (Configurações > Geral > Aplicativos), which yields a
client ID and secret and registers our callback URL. An operator then clicks
"Conectar Tiny" on the Kanban once; the tokens are kept in the database and
the refresh token is rotated on every refresh, under a row lock so two
workers never spend the same one.
Orders are created by contact and product id: the customer's contact is found
by CNPJ or created, and each product mode maps to a product that must already
exist in Tiny (PRODUCT_SETTINGS).
Idempotency: the outbox may deliver the same event more than once. Every order
carries numeroOrdemCompra = "DTF-<order number>", and before creating one the
customer's recent orders are searched for that number, so a second delivery
finds the first order instead of creating another.
"""
import os
import secrets
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from urllib.parse import urlencode
import httpx
API = 'https://api.tiny.com.br/public-api/v3'
AUTH = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect'
# Not TINY_PRODUCT_<MODE>: app/core/secrets.py reads any variable ending in
# _FILE as a path to a secret file, and one product mode is called "file".
PRODUCT_SETTINGS = {'file': 'TINY_PRODUCT_TEXTIL_FOLHA', 'avulsa': 'TINY_PRODUCT_TEXTIL_AVULSA',
'uvfile': 'TINY_PRODUCT_UV_FOLHA', 'uv': 'TINY_PRODUCT_UV_AVULSA'}
PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
'avulsa': 'DTF Têxtil 57 cm · artes avulsas',
'uvfile': 'DTF UV 28,5 cm · folha montada',
'uv': 'DTF UV 28,5 cm · artes avulsas'}
# How far back to look for an order a previous delivery may already have made.
SEARCH_DAYS = 7
STATE_MINUTES = 10
# Brazil has had no daylight saving since 2019; the order date is the local day.
BRASILIA = timezone(timedelta(hours=-3))
def local_today():
return datetime.now(BRASILIA).date()
class TinyError(Exception):
pass
class TinyNotConnected(TinyError):
"""No authorised connection yet: an operator must click "Conectar Tiny"."""
def purchase_order(number):
return f'DTF-{number}'
def configured():
"""Whether the OAuth application is configured (orders may still be fake)."""
return all(os.environ.get(name) for name in ('TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'))
def required_settings():
return ['TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'] + list(PRODUCT_SETTINGS.values())
# OAuth -------------------------------------------------------------------
class TinyAuth:
"""The OAuth application and the stored connection."""
def __init__(self, connect=None, transport=None, clock=time.time):
self.client_id = os.environ.get('TINY_CLIENT_ID', '')
self.client_secret = os.environ.get('TINY_CLIENT_SECRET', '')
self.redirect_uri = os.environ.get('TINY_REDIRECT_URI', '')
if connect is None:
from .core.db import connect
self.connect = connect
self.http = httpx.Client(timeout=20, transport=transport)
self.clock = clock
def authorize_url(self, operator):
"""Start a connection. The state is single-use, short-lived, and only an
authenticated operator can create one, which is what protects the
callback: Tiny's redirect back is cross-site, so the operator's
SameSite=Strict cookie does not travel with it."""
state = secrets.token_urlsafe(32)
with self.connect() as c:
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
c.execute('''INSERT INTO dtf_local.oauth_states(state,provider,operator,expires_at)
VALUES(%s,'tiny',%s,now()+%s)''', (state, operator, timedelta(minutes=STATE_MINUTES)))
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
'redirect_uri': self.redirect_uri, 'scope': 'openid',
'state': state})
def complete(self, code, state):
"""Exchange the authorisation code; returns the operator who started it."""
with self.connect() as c:
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
if not row:
raise TinyError('Unknown or expired authorisation state')
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
'redirect_uri': self.redirect_uri})
self._store(c, tokens, row['operator'])
return row['operator']
def status(self):
with self.connect() as c:
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
if not row:
return {'connected': False}
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc)
return {'connected': not expired, 'connected_by': row['connected_by'],
'connected_at': row['connected_at'], 'expires_at': row['refresh_expires_at']}
def access_token(self):
"""A valid access token, refreshing (and rotating) under a row lock."""
with self.connect() as c:
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
FOR UPDATE''').fetchone()
if not row:
raise TinyNotConnected('Tiny is not connected; use "Conectar Tiny" on the Kanban')
now = datetime.now(timezone.utc)
if row['access_expires_at'] > now + timedelta(seconds=60):
return row['access_token']
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
self._store(c, tokens, row['connected_by'], refreshed=True)
return tokens['access_token']
def _token(self, form):
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
'client_secret': self.client_secret})
if response.status_code == 400 and form['grant_type'] == 'refresh_token':
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
response.raise_for_status()
tokens = response.json()
if not tokens.get('access_token') or not tokens.get('refresh_token'):
raise TinyError('Tiny token response is missing tokens')
return tokens
def _store(self, c, tokens, operator, refreshed=False):
now = datetime.now(timezone.utc)
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
refresh_in = tokens.get('refresh_expires_in')
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token,
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at)
VALUES('tiny',%s,%s,%s,%s,%s,now(),now())
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(),
connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END,
connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''',
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
operator, refreshed, refreshed))
# Orders ------------------------------------------------------------------
def money(cents):
return float(Decimal(cents) / 100)
def contact_payload(order):
customer = order['customer']
destination = order.get('destination')
contact = {'nome': (destination or {}).get('recipient') or customer['mail'],
'tipoPessoa': 'J', 'cpfCnpj': customer['cnpj'], 'email': customer['mail'],
'celular': customer['zap'], 'situacao': 'A'}
if destination:
contact['endereco'] = address(destination)
return contact
def address(destination):
return {'endereco': destination['street'], 'numero': destination['number'],
'complemento': destination.get('complement', ''), 'bairro': destination['district'],
'municipio': destination['city'], 'cep': destination['postal_code'],
'uf': destination['state'], 'pais': 'Brasil'}
def order_payload(payload, contact_id, today=None):
"""The v3 'pedido' for a paid order's approved snapshot."""
order = payload['order']
items = []
for item in order['items']:
setting = PRODUCT_SETTINGS[item['mode']]
product = os.environ.get(setting, '')
if not product.isdigit():
raise TinyError(f'{setting} must be the Tiny product id')
items.append({'produto': {'id': int(product)},
'quantidade': float(Decimal(item['billed_metres'])),
'valorUnitario': money(item['unit_cents']),
'infoAdicional': PRODUCTS[item['mode']] + f" · nota {item['grade']}"})
freight = order['freight']
pickup = freight.get('service') == 'pickup'
pedido = {'data': (today or local_today()).isoformat(),
'idContato': contact_id,
'numeroOrdemCompra': purchase_order(payload['number']),
'itens': items,
'valorFrete': money(freight['total_cents']),
'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''),
'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"}
destination = order.get('destination')
if destination:
pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'],
'nomeDestinatario': destination['recipient']}
del pedido['enderecoEntrega']['numero']
ecommerce = os.environ.get('TINY_ECOMMERCE_ID', '')
if ecommerce.isdigit():
pedido['ecommerce'] = {'id': int(ecommerce), 'numeroPedidoEcommerce': purchase_order(payload['number'])}
return pedido
class TinyOrders:
def __init__(self, auth=None, transport=None, today=None):
missing = [name for name in required_settings() if not os.environ.get(name)]
if auth is None and missing:
raise RuntimeError('Tiny needs ' + ', '.join(missing))
self.auth = auth or TinyAuth()
self.http = httpx.Client(base_url=API, transport=transport, timeout=30)
self.today = today
def request(self, method, path, **kwargs):
headers = {'Authorization': f'Bearer {self.auth.access_token()}'}
response = self.http.request(method, path, headers=headers, **kwargs)
if response.status_code == 429:
raise TinyError('Tiny rate limit reached; the outbox will retry')
if response.status_code >= 400:
raise TinyError(f'{method} {path}: {response.status_code} {response.text[:300]}')
return response.json() if response.content else {}
def contact(self, order):
cnpj = order['customer']['cnpj']
found = self.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5})
for entry in found.get('itens') or []:
if ''.join(ch for ch in str(entry.get('cpfCnpj') or '') if ch.isdigit()) == cnpj:
return entry['id']
return self.request('POST', '/contatos', json=contact_payload(order))['id']
def find(self, payload):
"""An order a previous delivery already created, or None."""
wanted = purchase_order(payload['number'])
since = ((self.today or local_today()) - timedelta(days=SEARCH_DAYS)).isoformat()
found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'],
'dataInicial': since, 'limit': 100})
for entry in found.get('itens') or []:
detail = self.request('GET', f"/pedidos/{entry['id']}")
if detail.get('numeroOrdemCompra') == wanted:
return detail
return None
def deliver(self, event_key, payload):
if payload.get('event') != 'payment_approved':
# Production progress is not written to Tiny; only the sale is.
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable',
'event': payload.get('event')}
existing = self.find(payload)
if existing:
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
contact_id = self.contact(payload['order'])
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today))
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))}

View File

@@ -1,22 +1,30 @@
"""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
from psycopg.types.json import Jsonb
from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_runtime
from .secrets import load as load_secret_files
from .db import connect
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()

View File

@@ -9,12 +9,12 @@
x-app: &app
build:
context: .
dockerfile: local/Dockerfile
dockerfile: infra/Dockerfile
environment: &environment
APP_ENV: local
DATABASE_URL: postgresql://${APP_DB_USER:-dtf_app}:${APP_DB_PASSWORD:-local-app-database-only}@db:5432/${POSTGRES_DB:-dtf_local}
S3_ENDPOINT: http://storage:9000
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000}
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork}
AWS_ACCESS_KEY_ID: ${S3_APP_USER:-dtf_app}
AWS_SECRET_ACCESS_KEY: ${S3_APP_PASSWORD:-local-app-storage-only}
@@ -23,13 +23,29 @@ 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
# Sandbox testing only: set PAYMENT_ADAPTER=mercadopago with the MP_* test
# credentials, or TINY_ADAPTER=tiny with a TINY_TOKEN, in .env. Never real
# production credentials on a developer machine.
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-local-webhook-secret}
MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
FREIGHT_ADAPTER: fake
TINY_ADAPTER: fake
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-}
TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-}
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
TINY_ECOMMERCE_ID: ${TINY_ECOMMERCE_ID:-}
WHATSAPP_ADAPTER: fake
STORAGE_ADAPTER: s3-local
MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500}
@@ -65,12 +81,21 @@ services:
retries: 30
storage:
image: 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}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-local-storage-only}
ports: ["127.0.0.1:9000:9000", "127.0.0.1:9001:9001"]
ports:
- "127.0.0.1:${STORAGE_PORT:-9000}:9000"
- "127.0.0.1:${STORAGE_CONSOLE_PORT:-9001}:9001"
volumes: [storage-data:/data]
networks: [local, edge]
healthcheck:
@@ -82,20 +107,30 @@ services:
db-init:
build:
context: .
dockerfile: local/Dockerfile
command: python -m local.bootstrap
dockerfile: infra/Dockerfile
command: python -m app.bootstrap
environment:
# Local only: the app role keeps a password distinct from the administrator.
DATABASE_ADMIN_URL: postgresql://${POSTGRES_USER:-dtf_local}:${POSTGRES_PASSWORD:-local-database-only}@db:5432/${POSTGRES_DB:-dtf_local}
APP_DB_USER: ${APP_DB_USER:-dtf_app}
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-local-app-database-only}
# The migration job seeds the first operator account from these, so an
# existing deployment keeps its Kanban login after the accounts table
# lands. Without them there would be no account at all and login would
# fail closed with 503.
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-operator@example.test}
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only}
networks: [local]
depends_on:
db: {condition: service_healthy}
restart: on-failure
storage-init:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
build:
context: .
dockerfile: infra/Dockerfile.storage-init
args:
MINIO_IMAGE: ${MINIO_IMAGE:-cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1}
entrypoint: [/bin/sh, /init.sh]
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
@@ -103,19 +138,19 @@ 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}
volumes:
- ./local/storage-init.sh:/init.sh:ro
- ./local/storage-policy.json:/policy.json:ro
- ./local/storage-lifecycle.json:/lifecycle.json:ro
networks: [local]
depends_on:
storage: {condition: service_healthy}
restart: on-failure
scanner:
image: clamav/clamav@sha256:9cb27d7660bdf66e9878c832cb433dd8aa152cfbe16f3c2c0084c80b04ae22b4
# Built, not bind-mounted: see local/Dockerfile.scanner.
build:
context: .
dockerfile: infra/Dockerfile.scanner
args:
CLAMAV_IMAGE: ${CLAMAV_IMAGE:-clamav/clamav@sha256:9cb27d7660bdf66e9878c832cb433dd8aa152cfbe16f3c2c0084c80b04ae22b4}
entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf]
volumes: [./local/clamd.conf:/etc/clamav/clamd.conf:ro]
networks: [local]
security_opt: [no-new-privileges:true]
healthcheck:
@@ -130,7 +165,7 @@ services:
api:
<<: *app
command: uvicorn local.app:app --host 0.0.0.0 --port 8000 --no-access-log
command: uvicorn app.app:app --host 0.0.0.0 --port 8000 --no-access-log
depends_on:
db-init: {condition: service_completed_successfully}
storage-init: {condition: service_completed_successfully}
@@ -142,7 +177,7 @@ services:
worker:
<<: *app
command: python -m local.worker
command: python -m app.worker
depends_on:
api: {condition: service_healthy}
scanner: {condition: service_healthy}
@@ -155,11 +190,17 @@ services:
site:
build:
context: .
dockerfile: local/Dockerfile.web
dockerfile: infra/Dockerfile.web
environment:
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000}
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
# Empty unless testing Mercado Pago's card form; see docs/LOCAL_SETUP.md.
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
ports:
# Published ports are host-wide even bound to loopback, so on a shared
# machine any of them can collide with something unrelated. CI overrides
# every one; see .gitea/workflows/deploy.yml.
- "127.0.0.1:${SITE_PORT:-8080}:80"
# Convenience only: the API through its own gateway. No test uses it.
- "127.0.0.1:${API_PORT:-8000}:81"
networks: [local, edge]
depends_on:
@@ -173,10 +214,11 @@ services:
kanban:
build:
context: .
dockerfile: local/Dockerfile.web
dockerfile: infra/Dockerfile.web
environment:
WEB_INDEX: kanban.html
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000}
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
PAYMENT_CSP_SOURCES: ""
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
networks: [local, edge]
depends_on:
@@ -187,6 +229,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
View 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: {}

View File

@@ -7,8 +7,8 @@ services:
readiness:
build:
context: .
dockerfile: local/Dockerfile
command: python -m local.staging_readiness /config/staging.env
dockerfile: infra/Dockerfile
command: python -m ops.staging_readiness /config/staging.env
volumes:
- ./staging/staging.env:/config/staging.env:ro
network_mode: none

View File

@@ -1,5 +1,8 @@
# syntax=docker/dockerfile:1
ARG PYTHON_BASE_IMAGE=python:3.12-slim
# Pinned by digest so a rebuild of the same commit produces the same base.
# Override with the PYTHON_BASE_IMAGE repository variable to move it forward
# deliberately, and update this default in the same change.
ARG PYTHON_BASE_IMAGE=python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
FROM ${PYTHON_BASE_IMAGE}
ARG VCS_REF=unknown
@@ -7,12 +10,20 @@ LABEL org.opencontainers.image.title="DTF Portal/API" \
org.opencontainers.image.revision="$VCS_REF" \
org.opencontainers.image.source="DTF System repository"
# 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.
RUN apt-get update \
&& apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY local/requirements.txt local/requirements.lock /app/local/
RUN python -m pip install --no-cache-dir --require-hashes -r local/requirements.lock
COPY local /app/local
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
EXPOSE 8000
CMD ["uvicorn", "local.app:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
CMD ["uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]

View File

@@ -1,23 +1,29 @@
# syntax=docker/dockerfile:1
ARG PYTHON_BASE_IMAGE=python:3.12-slim
ARG NGINX_BASE_IMAGE=nginx:1.28-alpine
ARG PYTHON_BASE_IMAGE=python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
# Both bases are pinned by digest; override with the repository variables to
# move them forward deliberately.
# nginx 1.31.6. The 1.28 line pins nginx=1.28.3-r1 in /etc/apk/world, so its five
# HIGH findings cannot be upgraded in place; 1.29 scans worse. This one is clean.
ARG NGINX_BASE_IMAGE=nginx:alpine@sha256:62ff2089abf5a9ed33bd232895bef5e22f7bb4b200675cec49a5ebc48e3d4ac8
FROM ${PYTHON_BASE_IMAGE} AS policy
WORKDIR /build
COPY dtf-site.html /build/dtf-site.html
COPY local /build/local
COPY web /build/web
COPY infra /build/infra
COPY deploy /build/deploy
ENV NGINX_TEMPLATE=/build/deploy/nginx.conf.template
RUN python local/compile_web.py
RUN python infra/compile_web.py
FROM ${NGINX_BASE_IMAGE}
# Same reason as the API image: a pinned base freezes its packages.
RUN apk upgrade --no-cache
ARG VCS_REF=unknown
LABEL org.opencontainers.image.title="DTF Site and Kanban" \
org.opencontainers.image.revision="$VCS_REF" \
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 dtf-site.html /usr/share/nginx/html/index.html
COPY local/static/ /usr/share/nginx/html/
COPY 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
# ownership of only those runtime locations. This works in Docker Swarm,

View File

@@ -5,7 +5,7 @@ substitute for `production_preflight.py`, CI, staging acceptance, or change appr
## Application and external contracts
- [ ] `PRODUCTION_INPUTS.md` has owners, decisions, and evidence for every item.
- [ ] `docs/PRODUCTION_INPUTS.md` has owners, decisions, and evidence for every item.
- [ ] Production R2, freight, Mercado Pago, Tiny/Olist, and WhatsApp adapters have
sandbox contract tests and least-privilege credentials.
- [ ] Payment webhook authenticity, replay handling, and idempotency are tested.

View File

@@ -4,7 +4,7 @@ The DTF application is one Portainer-owned Docker Swarm stack. Gitea builds,
tests, scans, and publishes the two application images, then calls the stack's
Portainer webhook. Start with the short operator guide in `../PORTAINER.md`.
- `stack.yaml` — the single Portainer stack.
- The deployed stack is the repository's `docker-compose.yml`, not a file here.
- `Dockerfile.api` and `Dockerfile.web` — prebuilt registry images.
- `portainer.env.example` — non-secret Portainer variables.
- `production_preflight.py` — fail-closed application/configuration validator.

View File

@@ -8,6 +8,17 @@ server {
# unavailable or still creating its database schema.
resolver 127.0.0.11 ipv6=off valid=10s;
set $api_upstream api:8000;
# This gateway sits behind the host's reverse proxy, so $remote_addr is that
# proxy, not the customer. Recover the real address from the header it sets,
# and only when the connection comes from a private network: a request that
# reaches the published port directly from the internet is not trusted, so
# its X-Forwarded-For is ignored and $remote_addr stays the actual peer.
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
root /usr/share/nginx/html;
index ${WEB_INDEX};
@@ -16,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@ https://cdnjs.cloudflare.com; 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 https://cdnjs.cloudflare.com; worker-src 'self' blob: https://cdnjs.cloudflare.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {
@@ -29,6 +40,7 @@ server {
proxy_set_header X-Forwarded-Proto https;
# Overwrite, never append: $proxy_add_x_forwarded_for keeps any header the
# client sent, and the leftmost value would then be attacker-controlled.
# After real_ip above, $remote_addr is the customer even behind the proxy.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;

View File

@@ -29,6 +29,14 @@ OPERATOR_EMAIL=TBD
PAYMENT_ADAPTER=TBD
FREIGHT_ADAPTER=TBD
TINY_ADAPTER=TBD
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The
# redirect URL registered there must equal TINY_REDIRECT_URI.
TINY_CLIENT_ID=TBD
TINY_REDIRECT_URI=https://<KANBAN_DOMAIN>/api/operator/tiny/callback
TINY_PRODUCT_TEXTIL_FOLHA=TBD
TINY_PRODUCT_TEXTIL_AVULSA=TBD
TINY_PRODUCT_UV_FOLHA=TBD
TINY_PRODUCT_UV_AVULSA=TBD
WHATSAPP_ADAPTER=TBD
STORAGE_QUOTA_BYTES=TBD
OWNER_UPLOAD_QUOTA_BYTES=TBD

View File

@@ -38,10 +38,10 @@ SOURCE_BLOCKERS = {
# matching when R2 support landed; they were removed rather than left to rot.
# What remains is the real blocker: no production payment or messaging adapter
# exists, so these lines must change before a release can be meaningful.
'local/app.py': (
'app/runtime.py': (
'payment = FakePayment()',
),
'local/worker.py': (
'app/worker.py': (
"adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}",
),
}
@@ -68,7 +68,7 @@ def secret_loading_errors(root=ROOT):
import importlib.util
import tempfile
module_path = root / 'local' / 'secrets.py'
module_path = root / 'app' / 'core' / 'secrets.py'
if not module_path.exists():
return ['local runtime does not load the production Docker secret *_FILE settings']
try:
@@ -76,7 +76,7 @@ def secret_loading_errors(root=ROOT):
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
except Exception as exc:
return [f'local/secrets.py could not be loaded: {exc}']
return [f'app/core/secrets.py could not be loaded: {exc}']
stack_names = set()
stack = root / 'deploy' / 'stack.yaml'
@@ -97,7 +97,7 @@ def secret_loading_errors(root=ROOT):
try:
module.load(environ)
except Exception as exc:
failures.append(f'{name}_FILE is not resolved by local/secrets.py: {exc}')
failures.append(f'{name}_FILE is not resolved by app/core/secrets.py: {exc}')
continue
if environ.get(name) != 'resolved-value':
failures.append(f'{name}_FILE did not produce {name}')
@@ -107,7 +107,7 @@ def secret_loading_errors(root=ROOT):
except Exception:
pass
else:
failures.append('local/secrets.py does not fail closed on an unreadable secret')
failures.append('app/core/secrets.py does not fail closed on an unreadable secret')
return failures
@@ -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:

View File

@@ -1,310 +0,0 @@
version: "3.8"
x-app-environment: &app-environment
APP_ENV: production
DATABASE_URL_FILE: /run/secrets/database_url
S3_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
S3_BUCKET: ${R2_BUCKET:?set R2_BUCKET}
AWS_ACCESS_KEY_ID_FILE: /run/secrets/r2_access_key_id
AWS_SECRET_ACCESS_KEY_FILE: /run/secrets/r2_secret_access_key
AWS_DEFAULT_REGION: auto
# The Kanban authenticates by email; the runtime reads OPERATOR_EMAIL.
OPERATOR_EMAIL: ${OPERATOR_EMAIL:?set OPERATOR_EMAIL}
OPERATOR_PASSWORD_FILE: /run/secrets/operator_password
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:?set PAYMENT_ADAPTER}
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:?set FREIGHT_ADAPTER}
TINY_ADAPTER: ${TINY_ADAPTER:?set TINY_ADAPTER}
WHATSAPP_ADAPTER: ${WHATSAPP_ADAPTER:?set WHATSAPP_ADAPTER}
STORAGE_ADAPTER: s3-r2
PAYMENT_TOKEN_FILE: /run/secrets/payment_token
PAYMENT_WEBHOOK_SECRET_FILE: /run/secrets/payment_webhook_secret
TINY_TOKEN_FILE: /run/secrets/tiny_token
WHATSAPP_TOKEN_FILE: /run/secrets/whatsapp_token
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}
PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST}
ALLOWED_HOSTS: ${PUBLIC_HOST:?set PUBLIC_HOST},${KANBAN_HOST:?set KANBAN_HOST}
ALLOWED_ORIGINS: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN},https://${KANBAN_HOST:?set KANBAN_HOST}
COOKIE_SECURE: "true"
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-5368709120}
UPLOAD_PART_BYTES: ${UPLOAD_PART_BYTES:-8388608}
STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:?set STORAGE_QUOTA_BYTES}
OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:?set OWNER_UPLOAD_QUOTA_BYTES}
MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10}
SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728}
x-app-secrets: &app-secrets
- database_url
- r2_access_key_id
- r2_secret_access_key
- operator_password
- payment_token
- payment_webhook_secret
- tiny_token
- whatsapp_token
x-rolling: &rolling
update_config:
parallelism: 1
delay: 10s
order: start-first
failure_action: rollback
monitor: 45s
rollback_config:
parallelism: 1
delay: 5s
order: start-first
failure_action: pause
monitor: 45s
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 5
window: 60s
services:
db:
image: ${POSTGRES_IMAGE:?set POSTGRES_IMAGE}
environment:
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER}
POSTGRES_PASSWORD_FILE: /run/secrets/db_admin_password
secrets: [db_admin_password]
volumes:
- postgres-data:/var/lib/postgresql/data
networks: [backend]
healthcheck:
test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
stop_grace_period: 60s
deploy:
replicas: 1
placement:
constraints: [node.labels.dtf_database == true]
update_config:
parallelism: 1
order: stop-first
failure_action: rollback
monitor: 60s
rollback_config:
parallelism: 1
order: stop-first
failure_action: pause
monitor: 60s
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 5
window: 120s
resources:
limits: {cpus: "2.0", memory: 4G}
reservations: {cpus: "0.5", memory: 1G}
db-init:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
command: python -m local.bootstrap
environment:
APP_ENV: production
DATABASE_ADMIN_URL_FILE: /run/secrets/database_admin_url
APP_DB_USER: ${APP_DB_USER:?set APP_DB_USER}
APP_DB_PASSWORD_FILE: /run/secrets/app_db_password
secrets: [database_admin_url, app_db_password]
networks: [backend]
deploy:
replicas: 1
restart_policy: {condition: none}
placement:
constraints: [node.platform.os == linux]
resources:
limits: {cpus: "0.5", memory: 512M}
scanner:
image: ${CLAMAV_IMAGE:?set CLAMAV_IMAGE}
user: "100:101"
entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf]
configs:
- source: clamd_config
target: /etc/clamav/clamd.conf
mode: 0444
networks: [backend]
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
tmpfs:
- /tmp:uid=100,gid=101,mode=0750
- /run/clamav:uid=100,gid=101,mode=0750
- /var/log/clamav:uid=100,gid=101,mode=0750
healthcheck:
test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"]
interval: 15s
timeout: 5s
retries: 20
start_period: 90s
deploy:
replicas: 1
restart_policy: {condition: on-failure, delay: 10s}
resources:
limits: {cpus: "2.0", memory: 3G}
reservations: {cpus: "0.5", memory: 1G}
api:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
environment: *app-environment
secrets: *app-secrets
networks: [backend, egress]
read_only: true
tmpfs: [/tmp]
init: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test:
- CMD-SHELL
- >-
python -c "import os,urllib.request; r=urllib.request.Request('http://localhost:8000/health',headers={'Host':os.environ['PUBLIC_HOST']}); urllib.request.urlopen(r,timeout=3)"
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
stop_grace_period: 30s
deploy:
<<: *rolling
replicas: 2
resources:
limits: {cpus: "1.0", memory: 1G}
reservations: {cpus: "0.25", memory: 256M}
worker:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
command: python -m local.worker
environment:
<<: *app-environment
CLAMD_HOST: scanner
secrets: *app-secrets
networks: [backend, egress]
read_only: true
tmpfs: [/tmp]
init: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health',timeout=3)"]
interval: 15s
timeout: 5s
retries: 12
start_period: 90s
stop_grace_period: 60s
deploy:
<<: *rolling
replicas: 1
update_config:
parallelism: 1
order: stop-first
failure_action: rollback
monitor: 60s
rollback_config:
parallelism: 1
order: stop-first
failure_action: pause
monitor: 60s
resources:
limits: {cpus: "1.5", memory: 2G}
reservations: {cpus: "0.25", memory: 512M}
site:
image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest}
environment:
WEB_INDEX: index.html
PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
networks: [backend]
ports:
- target: 8080
published: ${SITE_PORT:-8080}
protocol: tcp
mode: ingress
read_only: true
tmpfs:
- /tmp:uid=101,gid=101,mode=0750
- /var/cache/nginx:uid=101,gid=101,mode=0750
- /var/run:uid=101,gid=101,mode=0750
- /etc/nginx/conf.d:uid=101,gid=101,mode=0750
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
deploy:
<<: *rolling
replicas: 2
resources:
limits: {cpus: "0.5", memory: 256M}
reservations: {cpus: "0.1", memory: 64M}
kanban:
image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest}
environment:
WEB_INDEX: kanban.html
PUBLIC_HOST: ${KANBAN_HOST:?set KANBAN_HOST}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
networks: [backend]
ports:
- target: 8080
published: ${KANBAN_PORT:-8081}
protocol: tcp
mode: ingress
read_only: true
tmpfs:
- /tmp:uid=101,gid=101,mode=0750
- /var/cache/nginx:uid=101,gid=101,mode=0750
- /var/run:uid=101,gid=101,mode=0750
- /etc/nginx/conf.d:uid=101,gid=101,mode=0750
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
deploy:
<<: *rolling
replicas: 1
resources:
limits: {cpus: "0.5", memory: 256M}
reservations: {cpus: "0.1", memory: 64M}
configs:
clamd_config:
file: ../local/clamd.conf
secrets:
database_url: {external: true, name: "${DATABASE_URL_SECRET:?set DATABASE_URL_SECRET}"}
database_admin_url: {external: true, name: "${DATABASE_ADMIN_URL_SECRET:?set DATABASE_ADMIN_URL_SECRET}"}
db_admin_password: {external: true, name: "${DB_ADMIN_PASSWORD_SECRET:?set DB_ADMIN_PASSWORD_SECRET}"}
app_db_password: {external: true, name: "${APP_DB_PASSWORD_SECRET:?set APP_DB_PASSWORD_SECRET}"}
r2_access_key_id: {external: true, name: "${R2_ACCESS_KEY_ID_SECRET:?set R2_ACCESS_KEY_ID_SECRET}"}
r2_secret_access_key: {external: true, name: "${R2_SECRET_ACCESS_KEY_SECRET:?set R2_SECRET_ACCESS_KEY_SECRET}"}
operator_password: {external: true, name: "${OPERATOR_PASSWORD_SECRET:?set OPERATOR_PASSWORD_SECRET}"}
payment_token: {external: true, name: "${PAYMENT_TOKEN_SECRET:?set PAYMENT_TOKEN_SECRET}"}
payment_webhook_secret: {external: true, name: "${PAYMENT_WEBHOOK_SECRET:?set PAYMENT_WEBHOOK_SECRET}"}
tiny_token: {external: true, name: "${TINY_TOKEN_SECRET:?set TINY_TOKEN_SECRET}"}
whatsapp_token: {external: true, name: "${WHATSAPP_TOKEN_SECRET:?set WHATSAPP_TOKEN_SECRET}"}
volumes:
postgres-data:
external: true
name: ${POSTGRES_VOLUME:?set POSTGRES_VOLUME}
networks:
backend:
driver: overlay
internal: true
egress:
driver: overlay

View File

@@ -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 = {
@@ -50,6 +72,26 @@ def valid_config():
return values
class SourceMarkerTests(unittest.TestCase):
"""A marker that stops matching weakens the gate without failing it.
This is how four markers silently died when the runtime gained R2 support,
and how the payment one died again when it moved to runtime.py. Assert that
every marker still points at something real.
"""
def test_every_marker_is_found_in_its_file(self):
from deploy.production_preflight import ROOT, SOURCE_BLOCKERS
for relative, markers in SOURCE_BLOCKERS.items():
path = ROOT / relative
self.assertTrue(path.exists(), f'{relative} no longer exists')
text = path.read_text()
for marker in markers:
self.assertIn(marker, text,
f'{relative} no longer contains {marker!r}: the gate '
'would pass without the condition being resolved')
class ProductionPreflightTests(unittest.TestCase):
def test_structurally_complete_metadata_passes(self):
self.assertEqual(config_errors(valid_config()), [])
@@ -80,8 +122,8 @@ class ProductionPreflightTests(unittest.TestCase):
def test_fake_checkout_is_explicitly_blocked(self):
errors = source_errors()
self.assertTrue(any('local/app.py remains local-only' in error for error in errors))
self.assertTrue(any('local/worker.py remains local-only' in error for error in errors))
self.assertTrue(any('app/runtime.py remains local-only' in error for error in errors))
self.assertTrue(any('app/worker.py remains local-only' in error for error in errors))
def test_secret_reuse_and_incoherent_limits_are_rejected(self):
values = valid_config()
@@ -92,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__':

View File

@@ -20,8 +20,23 @@ x-app-environment: &app-environment
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
PAYMENT_ADAPTER: fake
# Optional: without it the webhook verifies nothing and therefore accepts
# nothing, which is the correct state until a provider is connected. Set it
# when the provider is configured, never to a value anyone could guess.
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
FREIGHT_ADAPTER: fake
# Order creation in Tiny stays off until it has been tested against the
# client's account (Tiny has no sandbox). The application credentials can be
# set now: they let an operator connect Tiny from the Kanban, and the worker
# keeps that connection alive. Callback: https://<KANBAN_DOMAIN>/api/operator/tiny/callback
TINY_ADAPTER: fake
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-}
TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-}
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
WHATSAPP_ADAPTER: fake
STORAGE_ADAPTER: s3-r2
PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN}
@@ -57,7 +72,7 @@ services:
db-init:
image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest}
command: python -m local.bootstrap
command: python -m app.bootstrap
environment:
DATABASE_ADMIN_HOST: db
DATABASE_ADMIN_NAME: dtf
@@ -65,6 +80,12 @@ services:
DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
APP_DB_USER: dtf_app
APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD}
# The migration job seeds the first operator account from these, so an
# existing deployment keeps its Kanban login after the accounts table
# lands. Without them there would be no account at all and login would
# fail closed with 503.
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
networks: [backend]
deploy:
replicas: 1
@@ -108,7 +129,7 @@ services:
worker:
image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest}
command: python -m local.worker
command: python -m app.worker
environment: *app-environment
networks: [backend, egress]
read_only: true
@@ -129,6 +150,9 @@ services:
WEB_INDEX: index.html
PUBLIC_HOST: ${SITE_DOMAIN:?set SITE_DOMAIN}
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
# Mercado Pago's card form loads from these origins; empty keeps the
# Site at script-src 'self'. Set together with the Mercado Pago adapter.
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
networks: [backend]
ports:
- target: 8080
@@ -151,6 +175,7 @@ services:
WEB_INDEX: kanban.html
PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
PAYMENT_CSP_SOURCES: ""
networks: [backend]
ports:
- target: 8080
@@ -169,7 +194,7 @@ services:
configs:
clamd_config:
file: ./local/clamd.conf
file: ./infra/clamd.conf
volumes:
postgres-data:

View File

@@ -4,7 +4,7 @@
Read this document before changing code, documentation, architecture, or scope.
It is the current English source of truth for the project. It supersedes older
decisions in `README.md`, `ESPECIFICACAO.md`, `schema.sql`, and the existing
decisions in `README.md`, `docs/historico/ESPECIFICACAO.md`, `schema.sql`, and the existing
prototype code whenever they conflict.
Update this file whenever the team makes a material product, process, or
@@ -141,7 +141,7 @@ taxes, and payment-provider fees before presenting a commercial quote.
## Commercial rules
The existing rules in `dtf-site.html` are approved as the current source of
The existing rules in `web/index.html` are approved as the current source of
truth. Do **not** redesign, simplify, or change prices, discounts, minimums,
rounding, or product modes without explicit approval.
@@ -163,7 +163,7 @@ image dimensions alone must never classify a normal artwork as a finished sheet.
## Freight
The original visual freight flow in `dtf-site.html` was a stub with only pickup
The original visual freight flow in `web/index.html` was a stub with only pickup
functional. The localhost bridge now supports pickup and backend fake freight
quotes; there is still no real carrier quotation or production checkout.
@@ -249,7 +249,7 @@ supports subsequent corrections or scoped enhancements.
The current implementation target is localhost before any production connection.
Use `docker-compose.yml`, `.env.example`, and `LOCAL_SETUP.md`. The runtime is in
`local/`; historical `portal/`, `kanban/`, `agente/`, and `schema.sql` are preserved
`app/`; historical `portal/`, `kanban/`, `agente/`, and `schema.sql` are preserved
as references and are not imported or started by Compose.
- One local `dtf-cloud` Compose project runs seven long-lived services: Site and
@@ -297,23 +297,24 @@ 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
`security_status` command summarizes authentication, rate-limit, scan, and
scanner/signature alerts without exposing secrets. Security regression tests,
exact-runtime Python dependency auditing, and Trivy image reports live under
`local/` and `output/security/`; open findings are documented in
`app/` and `output/security/`; open findings are documented in
`SECURITY_REPORT.md` and are not a production-readiness claim.
- All direct and transitive Python packages are pinned with artifact hashes in
`local/requirements.lock`; the API image build requires those hashes. The lock
`infra/requirements.lock`; the API image build requires those hashes. The lock
is regenerated in a disposable Python 3.12 container by
`local/lock_dependencies.sh`. A fresh exact-runtime audit found no known Python
`infra/lock_dependencies.sh`. A fresh exact-runtime audit found no known Python
advisories on 2026-09-15; this does not cover OS/container findings.
- `compose.staging.yaml` is a separate, network-disabled readiness gate only. It
validates non-secret staging decisions and rejects placeholders, local endpoints,
@@ -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 local.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
@@ -340,7 +341,7 @@ as references and are not imported or started by Compose.
activation remain incomplete. Deployment plumbing exists but cannot pass its
release gate yet. No factory automation or pre-flight is added.
See `IMPLEMENTATION_REPORT.md` for verification, `PRODUCTION_INPUTS.md` for the
See `docs/historico/IMPLEMENTATION_REPORT.md` for verification, `PRODUCTION_INPUTS.md` for the
decisions and evidence required before staging or production connection, and
`PORTAINER.md` for the production delivery contract.
The client roadmap promises are unchanged, so its PDF source is not regenerated
@@ -348,17 +349,18 @@ for this local implementation checkpoint.
### Existing assets
- `dtf-site.html`: rich static front-end prototype. It includes the current
commercial rules, client-side artwork analysis, current-session cart, and
delivery UI. The localhost checkout bridge connects it to the new local API.
- `portal/`: early FastAPI prototype for the original Tiny-first, token-link
upload flow.
- `kanban/`: early FastAPI/SQLite Kanban prototype.
- `agente/`: factory-side agent prototype; out of current MVP scope.
- `tmp/pdfs/generate_dtf_report.py`: generator for the current client-facing
roadmap PDF.
- `output/pdf/dtf-plano-producao-e-roadmap.pdf`: current generated roadmap.
- `Reunião iniciada às 2026_09_09 09_39 GMT-03_00 - Anotações do Gemini.pdf`:
- `web/`: the Site (`index.html`), the Kanban and customer portal pages, and
the scripts behind them. `web/site-*.js` holds the Site's behaviour.
- `docs/historico/`: the original specification, endpoint sketch, task plan and
status report. Background only — they describe a model this system does not
implement.
The prototypes they describe (`portal/`, `kanban/`, `agente/`, the root
`schema.sql` and `.env.exemplo`) were removed on 2026-09-21 once nothing
referenced them; recover from Git history if ever needed.
- `tools/generate_dtf_report.py`: generator for the client-facing roadmap PDF.
- `docs/roadmap-cliente.pdf`: the generated roadmap, as last sent.
- `docs/reuniao-2026-09-09-anotacoes.pdf`:
meeting notes that established the business direction. Treat it as context;
the latest decisions in this document define the active scope.
@@ -367,12 +369,12 @@ for this local implementation checkpoint.
- The original Site had no backend payment, freight quote, order persistence,
authentication, purchase history, or real cart persistence. Local order
persistence, reviewed quotes, mock freight and fake payment now work through
`local/static/checkout.js`. The local customer portal now provides accounts and
`web/checkout.js`. The local customer portal now provides accounts and
order history; cart recovery is browser-local. Production account verification,
recovery, and cross-device cart editing are still absent.
- Without the local bridge, the original freight fallback remains a stub.
Real freight quotation remains unimplemented in all runtimes.
- The existing `portal/whats.py` and `kanban/whats.py` are duplicated and send
- The existing the removed prototypes duplicated WhatsApp senders and send
direct text messages. They are not production-ready notification modules.
- Older documentation and code describe a Tiny webhook creating an upload link,
a factory agent, local Kanban, FlexiPRINT automation, 90-day retention, and
@@ -382,8 +384,7 @@ for this local implementation checkpoint.
`context.md` is the compact operating context for agents. The current
client-facing narrative, diagrams, and formatting live in
`output/pdf/dtf-plano-producao-e-roadmap.pdf`, generated from
`tmp/pdfs/generate_dtf_report.py`.
`docs/roadmap-cliente.pdf`, generated from `tools/generate_dtf_report.py`.
When a decision changes, update both the relevant implementation context here
and the roadmap source when it changes what the client-facing plan promises.

View File

@@ -1,6 +1,6 @@
# DTF local development
Read `CONTEXT.md` first. The current runtime lives in `local/`; older portal,
Read `CONTEXT.md` first. The service lives in `app/`; older portal,
Kanban, agent, requirements, and SQL files are historical prototypes.
## Start
@@ -51,7 +51,7 @@ operator interfaces.
## Browser test order
1. Open the Site. Choose **Arquivo por metro** and the manual/table-price path
(CDR/AI/PSD/TIFF). Select `local/fixtures/local-test.cdr`. This is deliberately
(CDR/AI/PSD/TIFF). Select `tests/fixtures/local-test.cdr`. This is deliberately
harmless text for upload testing, not a printable CDR file.
2. Enter **1.01 metres**. The original calculation bills **1.10 m × R$19.90 =
R$21.89**, with grade 0 and pickup. You can also use your own non-sensitive
@@ -72,7 +72,9 @@ operator interfaces.
the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**,
select the manually prepared final files for every item, enter a review note,
tick the confirmation and click **Aprovar arquivos finais**. Use the harmless
fixture again only for this local test; no printable file is generated.
fixture again only for this local test. The text fixture is not an image, so
its print file shows **preparar à mão**; with a PNG or JPEG artwork the
generated PDF is preselected instead (see "Print files").
Continue through **Fila de impressão →
Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can
return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally.
@@ -135,15 +137,15 @@ Pricing parity requires Python 3.10+ and Node 22+ on the host, no package instal
python3 -m unittest local.test_pricing -v
```
This executes the real pricing constants/functions extracted from `dtf-site.html`
This executes the real pricing constants/functions extracted from `web/site-config.js`
and compares all four modes, 101 grades, and 11 lengths (4,444 cases) to backend
pricing, plus assembly and invalid-input checks.
With the stack healthy, run the integration test (Python standard library only):
```bash
python3 -m local.smoke_test
python3 -m local.workflow_test
python3 -m tests.smoke_test
python3 -m tests.workflow_test
```
It checks direct two-part upload/resume, missing parts, session isolation,
@@ -159,10 +161,10 @@ Security and malware regressions are separate so an authorized harmless EICAR
test is unmistakable:
```bash
python3 -m local.security_test
python3 -m local.scanning_test
docker compose exec -T api python -m local.runtime_security_test
docker compose exec -T api python -m local.retention_test
python3 -m tests.security_test
python3 -m tests.scanning_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
@@ -172,7 +174,7 @@ is retained for at most three days, so it temporarily appears in alert summaries
For an automated real-browser walkthrough, install Chrome and use Node 22+:
```bash
node local/browser_test.mjs
node tests/browser_test.mjs
```
Set `CHROME_BIN` if Chrome is not at `/usr/bin/google-chrome-stable`. The test
@@ -183,23 +185,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 +220,117 @@ storage permits; otherwise reselect them. Saved quote IDs also survive reloads.
Clearing cookies loses a guest session, while registered customers can sign in
again. The operator can still inspect order records.
## Print files
Every paid order queues one print-file job per item. The worker renders a PDF
exactly as wide as the film and as long as the approved layout, placing each
copy at the position, rotation and mirror the customer reviewed. Each source
image is embedded once at its original resolution (JPEG bytes unchanged, PNG
transparency kept as a soft mask), so nothing is resampled. The Kanban card
shows the status per item, the page size and the lowest DPI, and offers
**Baixar PDF**. In **Arquivos de produção** the generated file is preselected
as the final file; untick it to upload one by hand instead.
JPEG, PNG, WebP and TIFF are embedded as images. A single-page PDF is placed
as a vector form (never rasterised), using the page's CropBox and `/Rotate`
exactly as the Site measured it with pdf.js; the card then shows **PDF
vetorial**. PSD, AI and CDR artwork, multi-page or password-protected PDFs, a
file whose proportions do not match the quoted size, a layout longer than was
billed, or an image above `PRINT_MAX_PIXELS` (250 Mpx by default) goes to
**preparar à mão** with the reason, and the operator prepares it as before.
**Gerar arquivos de impressão** retries those items and generates files for
orders paid before the generator existed. After a customer correction the
generated file is no longer offered: it reproduces the replaced artwork.
Layouts longer than about 5 m use the PDF `UserUnit` page scale instead of
being split into pages. Confirm on the factory's FlexiPRINT that such a file
imports at full length before relying on it for long orders.
```bash
docker compose -f compose.local.yaml exec -T api python -m unittest tests.test_printfile -v
docker compose -f compose.local.yaml exec -T api python -m tests.print_file_test
```
With PyMuPDF installed locally (`pip install pymupdf`), the unit suite also
draws each page and checks where every quadrant of the artwork lands.
## Provider sandboxes
`app/mercadopago.py` and `app/tiny.py` follow the providers' public API
documentation and pass their unit suites against a fake transport. They are not
verified integrations until they pass with the client's accounts.
The default local stack gives the API and worker no route to the internet, so
provider testing adds `compose.providers.yaml`, which does:
```bash
docker compose -f compose.local.yaml -f compose.providers.yaml up -d --wait
```
Put only **test** credentials in `.env`:
```bash
PAYMENT_ADAPTER=mercadopago
MP_ACCESS_TOKEN=TEST-...
MP_WEBHOOK_SECRET=... # "Assinatura secreta" in the webhook settings
MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
```
### Tiny (API v3)
Tiny uses OAuth2. In the client's Tiny (Construa plan or above, with the
"Gestão de Aplicativos" extension): **Configurações → Geral → Aplicativos →
+ novo aplicativo**, with the redirect URL set to this system's callback. That
gives a client ID and secret:
```bash
TINY_CLIENT_ID=...
TINY_CLIENT_SECRET=...
TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback # exactly as registered in Tiny
TINY_PRODUCT_TEXTIL_FOLHA=... # Tiny product id for each Site product
TINY_PRODUCT_TEXTIL_AVULSA=...
TINY_PRODUCT_UV_FOLHA=...
TINY_PRODUCT_UV_AVULSA=...
```
With the client ID and secret set, the Kanban header shows **Conectar Tiny**.
Someone with a Tiny login approves access once; the tokens are stored in the
database (the refresh token rotates on every use) and the worker keeps the
connection alive. Only then set `TINY_ADAPTER=tiny`, which starts creating an
order in Tiny for every paid order: find or create the customer's contact by
CNPJ, then `POST /pedidos` with `numeroOrdemCompra = DTF-<order number>`. A
retry searches the customer's recent orders for that number first, so it does
not create a second one. **Tiny has no sandbox**: every test order is real, so
agree the test with the client and cancel the test orders afterwards.
`tests.tiny_oauth_test` checks the connection flow against the real database
with a fake token server; it saves and restores any existing connection.
With Mercado Pago selected, an approved quote shows **Pagar com PIX** on the
Site instead of the local test button, and **Pagar com cartão** when
`MP_PUBLIC_KEY` is set. The card form is Mercado Pago's Card Payment Brick: the
card is typed into Mercado Pago's secure fields and only a one-time token
reaches the API. It loads from Mercado Pago, so the Site's CSP must allow it:
```bash
MP_PUBLIC_KEY=TEST-...
PAYMENT_CSP_SOURCES=https://sdk.mercadopago.com https://*.mercadopago.com https://*.mlstatic.com https://*.mercadolibre.com
```
`PAYMENT_CSP_SOURCES` is empty by default, which keeps the Site at
`script-src 'self'`. Once a payment for a quote is approved, or a card payment
is in review, the API refuses any further attempt for that quote. The order is created only by the signed
notification, after the payment is fetched from the Mercado Pago API and its
BRL amount matches the approved total. Mercado Pago must be able to reach the
webhook, so a local run needs a public HTTPS tunnel to the Site port. A paid
notification that cannot become an order, or a refund on an existing order,
appears under **Pagamentos que precisam de atenção** on the Kanban until an
operator records the resolution.
## Local backup and restore check
```bash
python3 -m local.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 +347,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 local.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 +355,7 @@ unfinished.
After building the current image, test retention with synthetic files:
```bash
docker compose exec -T api python -m local.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 +364,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 +383,7 @@ exposes `/minio/health/ready`.
Run the redacted local alert summary inside the API network namespace:
```bash
docker compose exec -T api python -m local.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,
@@ -306,13 +419,13 @@ Offline PDF previews may fall back to the prototype's manual/table-price path.
## Dependency lock
The API, worker, and database initializer install every Python dependency from
`local/requirements.lock` with `--require-hashes`. `local/requirements.txt`
`infra/requirements.lock` with `--require-hashes`. `infra/requirements.txt`
remains the human-maintained direct dependency list. After deliberately changing
a direct pin, regenerate the lock in the same Python 3.12 environment and rebuild:
```bash
./local/lock_dependencies.sh
docker compose up --build -d --wait
./infra/lock_dependencies.sh
docker compose -f compose.local.yaml up --build -d --wait
```
The generator downloads public package metadata in a disposable container and
@@ -339,7 +452,7 @@ contracts and owners. See `staging/README.md`.
Keep this stack local. Before connecting real services, confirm the production
checkout trust workflow, complete `PRODUCTION_INPUTS.md`, and pass the isolated
staging-readiness gate. Then implement the actual staging composition using the adapter contracts in
`local/adapters.py`: start with private S3 staging storage, CORS, signed multipart
`app/adapters.py`: start with private S3 staging storage, CORS, signed multipart
contract tests and scoped credentials injected outside Git. Add provider sandbox
adapters one at a time. Mercado Pago requires authenticated, signed, idempotent
webhook handling before real payment is allowed; Tiny/Olist and WhatsApp need
@@ -363,7 +476,7 @@ Run the source-only gate without credentials:
python3 deploy/production_preflight.py --source-only
```
It must remain blocked while `local/` supports only local fake adapters and does
It must remain blocked while `app/` supports only local fake adapters and does
not load Docker secret `*_FILE` settings. Do not bypass or delete this check.
After approved production implementations and inputs exist, follow
`PORTAINER.md` and `deploy/PRODUCTION_CHECKLIST.md`; release and deployment

View File

@@ -4,16 +4,56 @@ DTF follows the same operating model as Graphs and ComporHUB: Gitea builds
prebuilt images, pushes them to the Gitea registry, and calls one Portainer
webhook. Portainer owns and redeploys one Docker Swarm stack named `dtf-cloud`.
The production stack is `deploy/stack.yaml`. It contains Site, Kanban, API,
The deployed stack is the repository's `docker-compose.yml`, which the
`dtf-cloud` Portainer stack points at. It was once accompanied by `deploy/stack.yaml`, a more hardened definition
supplying credentials as Docker secrets; that file was removed in favour of one
definition. See `ROADMAP.md` 2.12 for the reasoning and what remains open.
The stack contains Site, Kanban, API,
worker, PostgreSQL, ClamAV, and a one-time database initializer. Production uses
Cloudflare R2, so MinIO is not part of this stack.
## 1. Gitea configuration
The single workflow is `.gitea/workflows/deploy.yml`. Pull requests run static
validation. A push to `main` runs the full isolated test suite, builds and scans
the production images, publishes both `latest` and the full commit SHA, then
calls Portainer.
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. 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:
| Check | Gates? |
|---|---|
| `py_compile` and the unit tests | yes |
| Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) | yes |
| Browser suites | yes; Chrome runs in the Compose test container |
| Trivy secret scan (HIGH/CRITICAL) | yes |
| Source preflight (`deploy/production_preflight.py --source-only`) | advisory unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true`, which blocks publishing |
| Trivy image vulnerabilities, CRITICAL | yes |
| Trivy image vulnerabilities, HIGH | no — reported before publication |
| Configured Portainer webhook | no — called on manual runs when present; otherwise redeploy in Portainer |
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
the 1.28 nginx line, which pins `nginx=1.28.3-r1` in `/etc/apk/world` and so
cannot be patched in place. HIGH findings are reported rather than enforced
because the remainder have no upstream fix.
`PYTHON_BASE_IMAGE` and `NGINX_BASE_IMAGE` override the digests pinned in the
Dockerfiles. Update the Dockerfile default in the same change, so the repository
still records what a build used.
Repository variables:
@@ -27,9 +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 tests and HIGH/CRITICAL secret,
misconfiguration, and image gates pass. The current local-only application
fails the source preflight intentionally, so it cannot publish yet.
The webhook is called only after the release gates above pass.
`ENFORCE_PRODUCTION_PREFLIGHT` and `TRIVY_IMAGE` are optional repository
variables; the former affects push checks and the latter defaults to a pinned
scanner image.
## 2. One-time Portainer resources
@@ -59,7 +100,8 @@ In Portainer select **Stacks → Add stack → Git repository**:
- Name: `dtf-cloud`
- Repository: this Gitea repository
- Reference: `main`
- Compose path: `deploy/stack.yaml`
- Compose path: `docker-compose.yml` (the repository default, which is what the
existing `dtf-cloud` stack uses; it must stay at the root for this reason)
- Registry: the private `gitea.blyzer.com.br` registry
- Re-pull image: enabled
- Automatic update: webhook
@@ -89,10 +131,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

15
docs/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Documents
| File | What it is |
|---|---|
| `reuniao-2026-09-09-anotacoes.pdf` | Meeting notes that set the business direction: the bottleneck, the 24h goal, the automation and payment decisions. Still the record of what was asked for. |
| `roadmap-cliente.pdf` | The client-facing plan, as last sent. Generated by `tools/generate_dtf_report.py`; regenerate rather than edit. |
| `historico/` | The original specification and prototype documents. Background only — they describe a model this system does not implement. |
Current engineering documents live at the repository root: `CONTEXT.md` for how
the system works, `ROADMAP.md` for what is outstanding, `LOCAL_SETUP.md` to run
it, `PORTAINER.md` to deploy it, `SECURITY_REPORT.md` and `PRODUCTION_INPUTS.md`
for the security position and the decisions still owed by the client.
Weekly client reports are deliverables, not repository content. They are produced
for a specific week and are deliberately not versioned here.

View 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
View 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.

836
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,836 @@
# DTF System — Working Roadmap
> Internal engineering tracker. Not a client document, not a promise sheet.
> The client-facing narrative lives in `docs/roadmap-cliente.pdf` and in the weekly
> report, which is a deliverable and is not versioned here.
>
> Update the **Current step** line and the item status every time something moves.
> Add new findings at the bottom of the relevant block rather than rewriting history.
**Current step (2026-09-24, Week 2):** Client inputs for Mercado Pago and
freight were requested on 2026-09-24; Tiny access is already with the client.
Built without them: server-side print-file generation (1.4), the delivery
address (3.8), the Kanban's payment-issue and print-file views (1.5), and
Mercado Pago and Tiny adapters written from the public API documentation and
tested against fake transports (1.1, 1.3). None of the provider work is a
verified integration. The complete CI integration sequence passed locally on
2026-09-24 (all API suites including the new `print_file_test`, adapter and
generator unit suites, retention, runtime security, and both browser suites),
run with Docker Engine in WSL against a fresh build; pending a Gitea runner run.
**Previous step (2026-09-23):** Payment safety fixes 2.13 and 2.14 and
the local order-correctness work in 3.6/3.9 have passed integration checks.
Production specification v2 now records each copy's film coordinates and is
kept through the approved order; 4.6 now pages pending and approved unpaid
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 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 |
|---|---|
| `[ ]` | not started |
| `[~]` | in progress |
| `[x]` | done and verified |
| `[?]` | blocked on a decision (product or client), not on code |
---
## 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
demonstrated, and the week-1 claims cannot be defended.
### `[x]` 0.1 — API returns 500 on every session, login and registration `(F1)`
`local/auth.py:46` calls `os.environ` and the module never imports `os`.
Reproduced: `NameError: name 'os' is not defined`. `/api/session` calls
`new_session()` whenever there is no cookie, so the Site checkout bridge, cart
recovery and the customer portal all fail on first visit. Introduced in `e3e37f6`.
- Add `import os` to `local/auth.py`.
- Move the `COOKIE_SECURE` read to a module constant so it is evaluated once.
- **Accept:** a fresh browser hits the Site and `/api/session` returns 200 with a
`cart_scope`; `local/smoke_test.py` and `local/workflow_test.py` pass.
### `[x]` 0.2 — "Arquivo por metro" is unreachable in practice
Two independent causes, both from 2026-09-18 commits. Verified in a browser.
**a. Every entry point hard-routes to loose artwork** (`483a083`)
| Control | Currently opens |
|---|---|
| Nav "Impressão DTF" | `avulsa` |
| "DTF Têxtil · 57 cm" | `avulsa` |
| "DTF UV · 28,5 cm" | `uv` |
| Hero "Enviar minha arte" | `avulsa` |
Only the price card reaches `file`. Revert the three `data-modo-cta` attributes
so navigation links land on the product chooser, not on a product.
**b. Dropping a PNG/JPG in `file` mode silently switches the order** `(F25)`
`dtf-site.html` `sel()` — from `abrir('file')`, dropping `imagem-teste.jpg` gives
`modo: "avulsa"`, header "Artes avulsas", price `R$ 29,90/m`. Meanwhile the same
screen says *"Arraste suas folhas montadas · PNG, JPG ou PDF"*, marks
*"PNG, JPG ou PDF · a partir de R$ 14,90"* as the recommended path, and sets
`input.accept=".png,.jpg,.jpeg,.pdf"`. The page invites the drop and then
reprices the order 50% higher.
The escape hatch `#imagemComoFolha` sits above the drop zone as small text inside
an informational notice, defaults to unchecked, and must be ticked *before* the
drop. After the switch fires, `pintaModo()` sets `trocarParaAvulsa.hidden = true`,
so the checkbox disappears and there is no way back in place.
Net effect: the only formats that survive `file` mode are PDF/TIFF/PSD/AI/CDR —
the path the UI itself marks as the worse option. A mixed drop (JPG + PDF) is
rejected and accepts nothing.
- Make the ready-sheet choice an explicit two-option control **inside** the drop
area, styled like the existing `.cam` selector — not a checkbox in a notice.
- Stop advertising PNG/JPG in the by-metre drop zone while rejecting them.
- When a switch does happen, show the price change and offer one-click undo.
- **Accept:** a customer can complete a by-metre order with a PNG from any entry
point, and no product/price change ever happens without a visible confirmation.
### `[x]` 0.3 — Ready-sheet declaration is unverified and worth money `(F22 related)`
Ticking `#imagemComoFolha` is an honour-system claim that moves the price from
R$ 29,90/m to R$ 19,90/m (R$ 14,90 with a good grade). `medirFolha` then derives
sheet height purely from aspect ratio × 57 cm — the original bug, now opt-in.
Measured with `imagem-teste.jpg` (466 × 659 px):
| Route | System behaviour | Billed |
|---|---|---|
| Ticked | treated as a 57 × 80,6 cm mounted sheet | 1 m × R$ 19,90 = **R$ 19,90** |
| Not ticked | 20 cm wide, packs to 28,3 cm of film | 1 m × R$ 29,90 = **R$ 29,90** |
- Validate the claim: declared width must be ≈ film width (57 / 28,5 cm) at a
plausible DPI before the sheet model is accepted.
- Re-check server-side in `/api/operator/quotes/{id}/approve` before pricing.
- **Accept:** a small single artwork declared as a ready sheet is rejected with a
clear message; a genuine 57 cm sheet passes; the operator sees the verdict.
### `[x]` 0.4 — Documented local startup fails `(F2)`
`LOCAL_SETUP.md` says `docker compose up --build` with no `.env`.
`docker compose --env-file .env.example config` exits 1: `R2_ENDPOINT`,
`R2_ACCESS_KEY_ID`, `SITE_DOMAIN`, `KANBAN_DOMAIN` missing. `docker-compose.yml`
became a production/R2 stack in `e3e37f6`; `.env.example` is still the MinIO one
and there is no MinIO service left.
- Decide: keep one production compose and add `compose.local.yaml` with MinIO, or
restore a local default. Recommend the former.
- **Accept:** a clean clone reaches a working Site + Kanban with the documented
command, and `LOCAL_SETUP.md` matches what actually runs.
### `[x]` 0.5 — App DB role shares the admin password `(F3)`
`docker-compose.yml:65` sets `APP_DB_PASSWORD: ${POSTGRES_PASSWORD}` — the same
value as `dtf_admin`. `bootstrap.py` grants the app role DML-only and then hands
it a credential that also logs in as the owner. Anyone reading the API container
env has admin on the database.
- **Accept:** distinct secrets; connecting as `dtf_app` with the admin password fails.
### `[x]` 0.6 — A paid order showed the customer nothing (found while closing Block 0)
`#checkoutStatus` and `#checkoutActions` lived inside `#carr`, and the success path
in `checkout.js` clears the cart (`pedido=[]; limpaPaineis()`) before writing the
confirmation — `.carr{display:none}` then hid the panel holding it. Present since
the first commit; it only surfaced once 0.1 made a payment reachable at all.
Both elements now sit in their own always-visible `.checkout` container.
### How Block 0 was verified
A live stack (`compose.local.yaml`), then the full suite:
| Check | Result |
|---|---|
| `/api/session` on a cold browser | 200 with `cart_scope` + session cookie |
| smoke · workflow · security · scanning | pass |
| retention · runtime security (in-container) | pass |
| `artwork_browser_test.mjs` | pass, updated to the new declared-product behaviour |
| `browser_test.mjs` end-to-end | pass — upload → quote → operator approval → paid order → all Kanban states |
| 4 CI unit tests | pass |
Ports 8090/8091/8010 were used; 8080 was held by an unrelated preview server.
---
## Block 1 · Week 2 — committed to the client
From the report already sent. These are dated promises, not backlog.
- `[~]` 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.
- `[ ]` 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.
Confirm endpoints, tag behaviour and rate limits first.
**Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's
Keycloak. An operator starts the connection from the Kanban; the callback is
authorised by a single-use state (the operator cookie is SameSite=Strict and
does not survive Tiny's cross-site redirect). Tokens live in
`provider_tokens`; the refresh token rotates under a row lock and the worker
keeps the connection alive. Orders: contact found by CNPJ or created, then
`POST /pedidos` with product ids from `TINY_PRODUCT_TEXTIL_FOLHA` / `_TEXTIL_AVULSA` / `_UV_FOLHA`
/ `_UV_AVULSA` (not `_<MODE>`: a name ending in `_FILE` is read as a secret
file path by `app/core/secrets.py`) and
`numeroOrdemCompra = DTF-<number>`; a retry searches the customer's last
seven days of orders for that number first. Production passes the app
credentials through but keeps `TINY_ADAPTER: fake`. Tested against fake
transports (`tests.test_tiny`) and, for OAuth, the real database
(`tests.tiny_oauth_test`). Tiny has no sandbox: the first real test creates
real orders. Still to confirm on the client's account: plan (Construa+),
product ids, token lifetimes, whether pickup needs a transportador, and
rate limits.
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
must survive checkout before an output engine can reproduce the approved job).
**Built (2026-09-24):** each paid item gets a PDF the width of the film and
the length of the approved layout, with every copy at its reviewed position,
rotation and mirror (`app/printfile.py`, rendered by the worker from
`app/printjobs.py`). Sources are embedded once at original resolution; JPEG
bytes pass through, PNG alpha becomes a soft mask, EXIF orientation is
honoured. A file whose proportions differ from the quote, a layout longer than
billed, or PDF/PSD/AI/CDR artwork goes to hand preparation with the reason.
The operator approves the generated PDF as the final file through the
existing review. Unit tests include a raster check of every rotation and
mirror, and `tests.print_file_test` passes on the running stack (generate,
download, approve as final, queue; hand-preparation routing and retry).
**Still open:** a FlexiPRINT import of real
generated files (including one longer than 5 m, which uses `UserUnit`).
**PDF artwork (2026-09-24):** a single-page PDF source is placed as a vector
form through pikepdf (MPL-2.0; PyMuPDF was rejected for its AGPL licence),
using the CropBox and inherited `/Rotate` the Site measured with pdf.js.
Raster tests cover crop, page rotation, placement rotation and mirroring,
and were shown to fail when the rotation or crop handling is broken.
Multi-page and protected PDFs go to hand preparation.
- `[~]` 1.5 — Main Kanban production states consolidated. The six states and
their transitions are unchanged; cards now show the delivery address, the
print-file status per item, and a panel lists payments that need a person
(money without an order, refunds after an order) until resolved. Confirm
with the operation that these are the main states before closing.
- `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18.
`[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)
and ships only fake adapters, so the deployed system cannot take an order at all.
Real freight, payment initiation and verified provider events are required to
unblock it; the fake webhook alone does not.
---
## Block 2 · Security — before any public exposure
### `[x]` 2.1 — Rate limiting and audit logs are blind to the client `(F5)`
uvicorn runs without trusted proxy headers (`forwarded_allow_ips` defaults to
`127.0.0.1`; nginx is a different container IP), so `request.client.host` is nginx
for every request. `rate_limit('auth-source', ...)` at 60/15min becomes a single
global bucket — **60 failed logins lock out every customer** — and every `audit()`
record has no attacker IP.
- Set `--proxy-headers` with `FORWARDED_ALLOW_IPS` scoped to the nginx service, or
read `X-Forwarded-For` explicitly at the edge.
- **Accept:** two clients on different IPs have independent buckets; audit rows
carry the real IP.
### `[x]` 2.2 — The public site throttles itself `(F6)`
`local/app.py:115` — `rate_limit('guest-sessions', ENVIRONMENT, 120, 900)` is keyed
on the environment name: 120 new visitors per 15 minutes **site-wide** (~8/min).
Normal traffic 429s. Key per source IP (after 2.1) and raise the ceiling.
### `[x]` 2.3 — `deploy/stack.yaml` cannot boot `(F7)`
It passes `DATABASE_URL_FILE`, `AWS_ACCESS_KEY_ID_FILE`, `OPERATOR_PASSWORD_FILE`,
`OPERATOR_USER`. The code reads `DATABASE_URL`, `AWS_ACCESS_KEY_ID`,
`OPERATOR_PASSWORD`, `OPERATOR_EMAIL`, and no `_FILE` loader exists.
`app.py:58` does `os.environ['OPERATOR_PASSWORD']` → `KeyError` → 500 instead of 503.
- Implement `local/secrets.py` (the preflight already expects it) reading `*_FILE`
with env fallback. Reconcile `OPERATOR_USER` vs `OPERATOR_EMAIL`.
- **Accept:** the stack renders and boots against Swarm secrets; missing operator
config yields 503, not 500.
### `[x]` 2.4 — The documented release gate does not exist `(F8)`
`PORTAINER.md` and `SECURITY_REPORT.md` claim the workflow runs the full isolated
suite, Trivy HIGH/CRITICAL image gates, secret scanning and the source preflight
before calling Portainer. `.gitea/workflows/deploy.yml` runs `py_compile` plus four
unit tests, then builds, pushes `latest` and calls the webhook **unconditionally**.
`deploy/production_preflight.py` is never invoked — only its unit test runs.
- Either implement the gate or correct both documents. Do not leave the gap.
### `[x]` 2.5 — The preflight has silently decayed `(F9)`
It blocks by string-matching source. **4 of 6 markers are dead** after the R2
refactor: `'This runtime only supports APP_ENV=local'`,
`'Only local S3 storage is supported'`, `"allowed_hosts=['localhost', '127.0.0.1']"`,
`"'environment': 'local'"`. String gates weaken without failing.
- Replace marker matching with behavioural assertions (import the module, assert
the adapter classes in use).
### `[x]` 2.12 — Two divergent stack definitions; the docs named the wrong one
Found 2026-09-21 by asking which file Portainer deploys. `PORTAINER.md` called
`deploy/stack.yaml` "the production stack"; the deployed file is the repository's
`docker-compose.yml`. `deploy/stack.yaml` came from the first commit and was never
deployed — it supplied credentials as Docker secrets where the deployed file uses
plain environment variables.
**Decision (2026-09-21): keep `docker-compose.yml`, delete `deploy/stack.yaml`.**
The gain from Docker secrets here is narrower than it sounds. It keeps values out
of `docker inspect` and the Portainer UI, but `local/secrets.py` loads them into
the process environment anyway, and anyone who can read `docker inspect` is
already root or in the docker group and could read the secret files directly. The
operator is the only Portainer user, so the main benefit — limiting what a
lower-privileged console user can see — does not apply. Maintaining two
definitions that drift was the larger real cost.
`local/secrets.py` stays. It is inert against the deployed file and costs nothing,
and it means a stack can switch to Docker secrets later without a code change.
Still open: **rotate the R2 secret key.** Not because of Portainer, but because it
grants read and write over every customer's artwork and has been readable from the
stack environment for some time. The operator password is worth rotating with it.
### `[x]` 2.6 — Base images are not pinned `(F10)`
Dockerfiles default to mutable `python:3.12-slim` / `nginx:1.28-alpine`, the
workflow passes no digest build-args, and `--pull` makes builds non-reproducible —
while `PORTAINER.md` documents digest-pinned immutable bases.
### `[x]` 2.7 — pdf.js loaded from CDN without integrity `(F11)`
Vendored rather than integrity-pinned, so the Site no longer depends on a third
party being reachable and honest when a customer opens it. Both files are served
from this origin and their provenance is recorded in `local/static/vendor/README.md`,
verified against the SRI digests cdnjs publishes for 3.11.174.
`cdnjs.cloudflare.com` is gone from `script-src`, `worker-src` and `connect-src` in
both gateway templates: scripts and workers are now `'self'` plus `blob:` for the
worker the Site builds itself.
Verified in a browser against the running stack: pdf.js loads from `/vendor/`,
the blob worker starts, and a real 7-page PDF parses with no CSP violation. Both
browser suites and the full integration suite pass.
**Still open: the version.** 3.11.174 is old. GHSA-wgrm-67xf-hhpq is mitigated —
`dtf-site.html` already passes `isEvalSupported: false`, which is the documented
workaround — but staying on it indefinitely is not a posture. Upgrading is an API
change rather than a file swap and needs its own browser testing, so it is
deliberately not bundled here.
### `[x]` 2.8 — Single shared operator credential `(F12)`
Accounts now live in `dtf_local.operators`, one per person, with `movements.operator`
and `order_files.created_by` recording who actually acted. Administered from the API
container with `python3 -m app.operators` (list, add, password, disable, enable);
passwords are read from the terminal so they never reach shell history or the
process list, and disabling revokes open sessions immediately rather than leaving
them valid for the rest of the eight-hour window.
Migration was the risk, since getting it wrong locks the factory out of the Kanban.
`OPERATOR_EMAIL`/`OPERATOR_PASSWORD` seed the first account, once: a password
changed through the CLI is never reverted by a stale environment variable on the
next deploy. The first attempt did not work — `db-init` was not given those
variables in either compose file, so no account would have been created and login
would have failed closed with 503. Both files now pass them to the migration job.
Verified end to end: the unchanged credential still logs in, a second operator
authenticates separately, wrong passwords and unknown accounts are rejected, and
disabling ends access at once.
**Roles are deliberately not included.** The meeting described separation of duties
for rework authorisation (Mayana classifies, Thales or Alexandre authorise), but the
rework feature does not exist in this system, so there is nothing for a role to
gate. Building an authorisation model with no consumer would be guesswork. Add roles
with the feature that needs them.
### `[~]` 2.9 — TLS is terminated outside the repository `(F13)`
Downgraded 2026-09-21. The stack publishes plain HTTP on 18080/18081 while
`COOKIE_SECURE: "true"`, and nothing in the repo provisions certificates — but
`nginx-proxy-manager` on the host owns 80/443 and terminates TLS in front of it,
so cookies are not being dropped in practice. This is undocumented operational
knowledge rather than a live defect.
What remains: record the proxy in `PORTAINER.md` as part of the deployment
contract, so nobody moves the stack to a host without one and silently breaks
every session cookie. `TAREFAS.md` A2 still lists the certificate as pending;
confirm it is actually issued for the DTF subdomain.
### `[ ]` 2.10 — No email verification, no password recovery `(F14)`
A locked-out customer has no path back, and registration accepts any CNPJ without
proving control of the e-mail. Needs a transactional mail provider — **client input**.
### `[ ]` 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 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)`
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
money. As built, a 2am order still waits for a person. `CONTEXT.md` frames this as a
temporary development trust boundary — the risk is that it silently becomes the
delivered model.
**Decide:** what makes a quote auto-approvable (mode, metre range, grade floor,
returning customer), and what still routes to a human.
### `[?]` 3.2 — Billable metres are computed in the customer's browser `(F17, F18)`
For loose artwork, `metros` comes from `desenhaMontagem`/`encaixar` — a canvas
alpha-mask packer running client-side. The server never recomputes it.
`passoDe()`/`CELULAS_MAX` coarsen the grid for large sheets and image decoding
differs by browser, so **the same cart can price differently on different devices**.
The code comments reference "o motor do servidor"; that engine does not exist.
Worse, the layout the customer is quoted on is never produced — operators upload
final files by hand, so billed metres ≠ printed metres and a designer redoes work
the site already did.
**Decide:** port the packer to the server as the pricing authority and the print-file
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)`
Transport accepts 5 GiB; `SCAN_MAX_BYTES` / ClamAV `StreamMaxLength` release only
≤ 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).
### `[ ]` 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)`
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.
---
## Block 4 · Scale and performance
- `[x]` 4.1 — Ten indexes added, each matched to a query the application issues,
and no more: every extra index is paid for on each write. The outbox and live
uploads use partial indexes so they stay the size of the backlog rather than of
all history. Verified against the running database — the planner chooses
`outbox_pending` and `orders_owner` for the queries they exist for.
- `[x]` 4.2 — The board returns every order still in progress, however old, plus a
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 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.
- `[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.
---
## Block 5 · Hygiene and maintenance
- `[x]` 5.1 — CI coverage `(F33)`. An `integration` job now builds the localhost
stack and runs smoke, workflow, security, scanning, retention, runtime security
and both browser suites; `publish-and-deploy` depends on it. Verified by
reintroducing the 0.1 defect: `py_compile` and the unit tests still passed while
`smoke_test` failed on `/session`, blocking the release. Browser tests skip with
a warning when the runner has no Chrome — **install `google-chrome-stable` on the
runner (or set `CHROME_BIN`) to make them gate as well.**
- `[x]` 5.2 — `portal/`, `kanban/`, `agente/`, the root `schema.sql` and
`.env.exemplo` removed: 2,283 lines implementing a model this system abandoned,
referenced by nothing, with several endpoints taking the acting user from the
request body. The documents describing them are archived under `docs/historico/`
with a header saying they are background, not instructions.
- `[x]` 5.3 — Root `requirements.txt` deleted. It pinned by wildcard, listed
packages the system does not use, and sat next to the hash-locked
`infra/requirements.lock` inviting the wrong one to be installed. Only historical
documentation referred to it.
- `[x]` 5.4 — `pip` removed from `infra/requirements.txt` and from the lock. Nothing
depended on it; it was pinned only because it was listed directly, and installing
it put a package manager inside the read-only runtime image. The base image's own
pip performs the hash-enforced install. Verified: the image builds under
`--require-hashes` and reports the base pip, 25.0.1.
- `[ ]` 5.5 — Doc drift `(F34)`. `README.md`, `CONTEXT.md`, `LOCAL_SETUP.md` and
`SECURITY_REPORT.md` describe a MinIO localhost stack, an API with "no external
network route", a `operator` / `local-operator-only` login the email-validated
model rejects, and a release gate — none match the current tree.
- `[x]` 5.6 — The Site's 1,575-line inline script is now nine files under
`local/static/`, cut at the author's own section boundaries so no function was
split: config, modes, upload, sheet analysis, PDF, quality, packing, cart, flow.
`dtf-site.html` is 1,394 lines of markup and style. The extraction was verified
byte-identical before the tags were swapped in, and they load as classic scripts
in the original order, so evaluation semantics are unchanged.
A consequence worth having: with no inline script anywhere, the policy needs no
hash allowlist and is now simply `script-src 'self'`. `site-packing.js` is also
where a server-side packer (3.2) has to agree, which was the point of splitting.
- `[ ]` 5.7 — Commercial rules duplicated between `FAIXAS` (JS) and `TIERS` (Python)
`(F26)`. `test_pricing` guards parity; generate one from the other instead.
- `[ ]` 5.11 — Nothing tests the schema against an empty database. The 4.1 indexes
were added next to the existing one, which sits before the tables they name, so
`CREATE INDEX ... ON dtf_local.order_files` ran before that table existed. Every
local run passed because the volume already had the tables; CI caught it on its
clean volume. A migration is only really exercised from nothing, so the
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 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.
Suspect Chrome startup timing or a race against stack readiness. Watch it, and if
it recurs add an explicit readiness wait rather than a retry.
- `[x]` 5.8 — The Site claimed 90-day file storage and 12-month history in two
places, and invited customers to reorder "sem subir de novo". Files are kept 30
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.
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
and object backups, a consistent snapshot boundary, Swarm data placement and
a restore rehearsal that opens every required live order file.
- `[ ]` 5.14 — Promote and verify one immutable release. **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.
---
## Done
### Week 1 — infrastructure, uploads, service base
- `[x]` Compose stack: Site, Kanban, API, PostgreSQL, worker, ClamAV, R2/MinIO storage.
- `[x]` Gitea + Portainer publication path; web service startup and API rollout fixed.
- `[x]` Database passwords with special characters handled via discrete libpq fields.
- `[x]` Kanban e-mail/password login; missing operator config no longer breaks stack boot.
- `[x]` Direct resumable multipart browser → private object storage.
- `[x]` Quarantine + ClamAV gate: only `clean` files can be quoted, paid, downloaded or queued.
- `[x]` Retention worker: incomplete 1d, rejected 3d, originals 7d after approval, finals 30d.
- `[x]` Server-side pricing authority with parity test against the Site JavaScript (4,444 cases).
- `[x]` Loose-artwork packing flow: per-file width, copies, rotate, mirror, live 57 cm preview,
5 mm gap, ruler and watermark preserved; metres follow packed height.
- `[x]` Rotation/mirror applied to packing masks; stale renders no longer overwrite a newer preview.
- `[x]` Hash-locked Python dependencies; Trivy reports in `output/security/`.
### Block 0 — 2026-09-18
- `[x]` `import os` restored in `local/auth.py`; `COOKIE_SECURE` is now a single
module constant shared with `local/app.py`.
- `[x]` Navigation links no longer preselect a product (`data-modo-cta` removed).
- `[x]` Ready sheet vs loose artwork is an explicit, priced, reversible selector
(`#tipoEnvio`), shown in all four modes and locked once a file is attached.
The product is the declaration; `sel()` no longer switches anything silently.
- `[x]` `medirFolha` returns `dpiFolha`; an image that cannot span the film width
at `DPI_RECUSA` is refused as a sheet, with one click to send it as loose artwork.
- `[x]` `pintaCaminhos` scoped to `#caminhos .cam` — its global `.cam` selector was
clobbering the new control.
- `[x]` `compose.local.yaml` restored (MinIO, fake providers, builds from source).
- `[x]` `APP_DB_PASSWORD` separated from `POSTGRES_PASSWORD`, with a `bootstrap.py`
guard that refuses identical credentials in both configuration forms.
- `[x]` Checkout confirmation moved out of the panel the success path hides.
### Block 2 and CI — 2026-09-18
- `[x]` The web gateway overwrites `X-Forwarded-For` with the peer address instead
of appending to it, and `client_ip()` resolves the requester for rate-limit
buckets and security events. Verified: a forged `203.0.113.99` never reaches the
audit trail.
- `[x]` Guest sessions are limited per source. The first attempt used 30/IP, which
the new regression caught as too tight for shared NAT — recreating the original
fault in a narrower form — so the ceiling is 240 per 15 minutes, overridable with
`GUEST_SESSION_LIMIT`.
- `[x]` Security events now carry the source address (`operator_login_failed`,
`customer_login_failed`, `cross_origin_rejected`, `http_security_event`).
- `[x]` CI runs the integration suites against a real stack before publishing.
### 2026-09-21
- `[x]` 2.3 — `local/secrets.py` resolves every `<NAME>_FILE` into `<NAME>` from the
API, worker and bootstrap entrypoints, failing closed on an unreadable or empty
secret and on a value supplied both ways. Verified by booting the API, the worker
and bootstrap with credentials supplied only as mounted files, including a
password containing `:/?#[]&=+$ ,%`. `OPERATOR_USER` in the stack became
`OPERATOR_EMAIL`, which is what the runtime reads.
- `[x]` 2.5 — The gate now loads `local/secrets.py` and makes it resolve every
secret `deploy/stack.yaml` declares, plus asserts it fails closed. Verified
against a no-op loader (11 blockers) and one that swallows a missing file
(1 blocker); only the real implementation passes. The four marker strings that
stopped matching when R2 support landed were removed; the two describing real
blockers stay, so the gate still refuses a release while the payment and
messaging adapters are fake.
- `[x]` 2.4 — A blocking Trivy secret scan was added and verified both ways: a
planted AWS key pair, GitHub token and private key block the job; the repository
passes clean. Worth knowing: Trivy allowlists documented example credentials, so
my first probe passed with AWS's own sample keys — the gate is a backstop, not
permission to commit secrets. The source preflight now runs and always prints its
verdict, enforcing only when `ENFORCE_PRODUCTION_PREFLIGHT` is `true`; enforcing
it today would block every deploy, since it refuses a release while the adapters
are fake. Image vulnerabilities are reported after each build, not enforced —
56 HIGH and 3 CRITICAL, only 15 with an upstream fix. `PORTAINER.md` and
`SECURITY_REPORT.md` now carry a table of what gates and what does not, replacing
descriptions of checks that never ran.
- `[x]` 2.6 — Both bases pinned by digest, OS packages upgraded in the production
images, and the web image moved off the nginx 1.28 line.
| Image | Before | After |
|---|---|---|
| API | 56 HIGH, 3 CRITICAL (15 fixable) | 46 HIGH, 0 CRITICAL |
| Web | 5 HIGH, all unfixable in place | 0 HIGH, 0 CRITICAL |
The 1.28 nginx pins `nginx=1.28.3-r1` in `/etc/apk/world`, so `apk upgrade`
cannot patch it even though Alpine ships `-r7`; `nginx:alpine` (1.31.6) is clean
while `1.29-alpine` scans worse at 37 HIGH. The two remaining "fixable" API
findings are `msgpack` and `setuptools`, which I confirmed are absent from the
built image rather than trusting the earlier report. Local images now share the
pinned bases, so the integration suite exercises what ships; full suite passes on
nginx 1.31.6. With both images at zero CRITICAL, the image scan now **gates on
CRITICAL** and reports HIGH.
### 2026-09-21 — from the runner host inventory
- `[x]` Fixed a regression in 2.1: the production gateway sits behind
`nginx-proxy-manager`, so `$remote_addr` there is the proxy, not the customer.
Overwriting `X-Forwarded-For` with it would have recorded the proxy's address for
every request in production — the same bug 2.1 set out to fix. The gateway now
uses `real_ip` to recover the customer's address from the proxy's header, trusting
only private networks, so a request arriving directly at the published port
cannot spoof it. Validated with `nginx -t` against the rendered config.
- `[~]` 2.9 downgraded: TLS is terminated by that proxy, not missing.
### Reporting
- `[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.

View File

@@ -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 python -m local.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
@@ -99,7 +100,7 @@ errors, unexplained authentication bursts, or stale signatures as incidents.
`pip-audit` inspected the exact packages installed in the hash-enforced rebuilt API
and found no known Python advisories on 2026-09-15. All 26 direct and transitive
runtime packages are pinned with artifact hashes in `local/requirements.lock`.
runtime packages are pinned with artifact hashes in `infra/requirements.lock`.
`local/lock_dependencies.sh` regenerates it in a disposable Python 3.12 container.
This is a point-in-time package-database result, not proof that the dependencies
or image are vulnerability-free. Scheduled lock refresh and audit automation remain
@@ -129,10 +130,14 @@ change when the advisory database or selected base digest changes.
## Production delivery security boundary
The files in `deploy/` and `.gitea/workflows/` are a guarded delivery mechanism,
not an approval to operate the current application on the public internet. The
single Gitea workflow requires a protected Docker runner, exact commit checkout,
digest-pinned base/scanner images, regressions, and HIGH/CRITICAL Trivy gates.
It publishes both `latest` and the full commit SHA, then calls the Portainer
not an approval to operate the current application on the public internet.
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.

26
docs/historico/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Historical documents
These describe the project as it was originally designed: a Tiny-first flow with
token upload links, a factory-side agent, a local SQLite Kanban, FlexiPRINT
automation, 90-day retention and 12-month artwork reuse.
**None of it describes the system that exists.** `CONTEXT.md` is the current
source of truth and `ROADMAP.md` tracks outstanding work. These are kept because
they record decisions and reasoning that the current documents do not repeat —
read them for background, never as instructions.
| File | What it was |
|---|---|
| `ESPECIFICACAO.md` | Original specification of the Tiny-first model |
| `API.md` | Endpoint sketch for the prototype portal |
| `TAREFAS.md` | Task plan written before the current scope |
| `IMPLEMENTATION_REPORT.md` | Status report for the prototype milestone |
| `dtf-organograma-*` | Organisation charts, referenced by nothing |
The prototype code they describe — `portal/`, `kanban/`, `agente/`, the root
`schema.sql` and `.env.exemplo` — was removed once nothing referenced it. Recover
it from Git history if it is ever needed:
```bash
git log --oneline --diff-filter=D -- portal kanban agente
```

17
infra/Dockerfile Normal file
View File

@@ -0,0 +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
RUN apt-get update \
&& apt-get upgrade -y \
&& 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
RUN useradd --uid 10001 --create-home dtf
USER dtf
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1

View 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

7
infra/Dockerfile.scanner Normal file
View File

@@ -0,0 +1,7 @@
# The scanner configuration is baked in rather than bind-mounted.
# A containerised CI runner shares the host's Docker daemon, so a host path from
# the runner's workspace does not exist where the daemon creates the mount: it
# makes an empty directory instead and the container fails to start.
ARG CLAMAV_IMAGE=clamav/clamav@sha256:9cb27d7660bdf66e9878c832cb433dd8aa152cfbe16f3c2c0084c80b04ae22b4
FROM ${CLAMAV_IMAGE}
COPY infra/clamd.conf /etc/clamav/clamd.conf

View File

@@ -0,0 +1,6 @@
# Provisioning script and policies baked in, for the same reason as the scanner.
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
COPY infra/storage-lifecycle.json /lifecycle.json

14
infra/Dockerfile.web Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9 AS policy
WORKDIR /build
COPY web /build/web
COPY infra /build/infra
RUN python infra/compile_web.py
# Same pinned base as deploy/Dockerfile.web.
FROM nginx:alpine@sha256:62ff2089abf5a9ed33bd232895bef5e22f7bb4b200675cec49a5ebc48e3d4ac8
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/

26
infra/compile_web.py Normal file
View File

@@ -0,0 +1,26 @@
"""Compile the gateway configuration: hash any trusted inline script for the CSP.
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
hashing stays because an inline script added later must not silently need
'unsafe-inline'; it is hashed automatically instead.
"""
import base64
import hashlib
import os
from pathlib import Path
import re
root = Path('/build')
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):
if not re.search(r'\bsrc\s*=', attributes, re.I) and script.strip():
hashes.append("'sha256-" + base64.b64encode(hashlib.sha256(script.encode()).digest()).decode() + "'")
template = Path(os.environ.get('NGINX_TEMPLATE', root / 'infra/nginx.conf.template')).read_text()
rendered = template.replace('@SCRIPT_HASHES@', ' '.join(hashes))
# Collapse the gap an empty hash list leaves behind, so the policy reads cleanly.
rendered = re.sub(r"(script-src 'self')\s+;", r'\1;', rendered)
(root / 'default.conf.template').write_text(rendered)
print(f'CSP script-src: {len(hashes)} inline hash(es)')

View File

@@ -10,4 +10,4 @@ docker run --rm \
--volume "$root:/src" \
--workdir /src \
python:3.12-slim \
sh -c 'python -m pip install --no-cache-dir --target /tmp/piptools pip==25.3 pip-tools==7.5.2 && PYTHONPATH=/tmp/piptools python -m piptools compile --generate-hashes --allow-unsafe --strip-extras --no-emit-index-url --output-file local/requirements.lock local/requirements.txt'
sh -c 'python -m pip install --no-cache-dir --target /tmp/piptools pip==25.3 pip-tools==7.5.2 && PYTHONPATH=/tmp/piptools python -m piptools compile --generate-hashes --allow-unsafe --strip-extras --no-emit-index-url --output-file infra/requirements.lock infra/requirements.txt'

View File

@@ -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@ https://cdnjs.cloudflare.com; 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 https://cdnjs.cloudflare.com; worker-src 'self' blob: https://cdnjs.cloudflare.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {
limit_req zone=api_limit burst=100 nodelay;
@@ -23,8 +23,8 @@ server {
}
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;

628
infra/requirements.lock Normal file
View File

@@ -0,0 +1,628 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# ./local/lock_dependencies.sh
#
annotated-doc==0.0.5 \
--hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
--hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
# via fastapi
annotated-types==0.8.0 \
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
# via pydantic
anyio==4.15.1 \
--hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \
--hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94
# via
# httpx
# starlette
boto3==1.38.23 \
--hash=sha256:70ab8364f1f6f0a7e0eaf97f62fbdacf9c1e4cc1de330faf1c146ef9ab01e7d0 \
--hash=sha256:bcf73aca469add09e165b8793be18e7578db8d2604d82505ab13dc2495bad982
# via -r infra/requirements.txt
botocore==1.38.46 \
--hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \
--hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b
# via
# boto3
# s3transfer
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via
# httpcore
# httpx
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via uvicorn
fastapi==0.141.1 \
--hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
--hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
# via -r infra/requirements.txt
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via
# httpcore
# uvicorn
httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
# via httpx
httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via -r infra/requirements.txt
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via
# anyio
# httpx
jmespath==1.1.0 \
--hash=sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d \
--hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64
# 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 infra/requirements.txt
psycopg-binary==3.2.9 \
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
--hash=sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b \
--hash=sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab \
--hash=sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4 \
--hash=sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78 \
--hash=sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4 \
--hash=sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a \
--hash=sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2 \
--hash=sha256:1f1736d5b21f69feefeef8a75e8d3bf1f0a1e17c165a7488c3111af9d6936e91 \
--hash=sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb \
--hash=sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523 \
--hash=sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4 \
--hash=sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351 \
--hash=sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd \
--hash=sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a \
--hash=sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee \
--hash=sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a \
--hash=sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074 \
--hash=sha256:418f52b77b715b42e8ec43ee61ca74abc6765a20db11e8576e7f6586488a266f \
--hash=sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2 \
--hash=sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734 \
--hash=sha256:4df22ec17390ec5ccb38d211fb251d138d37a43344492858cea24de8efa15003 \
--hash=sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093 \
--hash=sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40 \
--hash=sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe \
--hash=sha256:5918c0fab50df764812f3ca287f0d716c5c10bedde93d4da2cefc9d40d03f3aa \
--hash=sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2 \
--hash=sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7 \
--hash=sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724 \
--hash=sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0 \
--hash=sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186 \
--hash=sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5 \
--hash=sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b \
--hash=sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6 \
--hash=sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9 \
--hash=sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786 \
--hash=sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7 \
--hash=sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21 \
--hash=sha256:7b617b81f08ad8def5edd110de44fd6d326f969240cc940c6f6b3ef21fe9c59f \
--hash=sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8 \
--hash=sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87 \
--hash=sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07 \
--hash=sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff \
--hash=sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e \
--hash=sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f \
--hash=sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891 \
--hash=sha256:a4d76e28df27ce25dc19583407f5c6c6c2ba33b443329331ab29b6ef94c8736d \
--hash=sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04 \
--hash=sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d \
--hash=sha256:b2d7a6646d41228e9049978be1f3f838b557a1bde500b919906d54c4390f5086 \
--hash=sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748 \
--hash=sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695 \
--hash=sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a \
--hash=sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e \
--hash=sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d \
--hash=sha256:cf789be42aea5752ee396d58de0538d5fcb76795c85fb03ab23620293fb81b6f \
--hash=sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc \
--hash=sha256:e0f05b9dafa5670a7503abc715af081dbbb176a8e6770de77bccaeb9024206c5 \
--hash=sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8 \
--hash=sha256:eac3a6e926421e976c1c2653624e1294f162dc67ac55f9addbe8f7b8d08ce603 \
--hash=sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b \
--hash=sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95 \
--hash=sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1 \
--hash=sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb
# via psycopg
pydantic==2.13.5 \
--hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \
--hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08
# via fastapi
pydantic-core==2.46.5 \
--hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \
--hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \
--hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \
--hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \
--hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \
--hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \
--hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \
--hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \
--hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \
--hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \
--hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \
--hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \
--hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \
--hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \
--hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \
--hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \
--hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \
--hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \
--hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \
--hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \
--hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \
--hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \
--hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \
--hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \
--hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \
--hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \
--hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \
--hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \
--hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \
--hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \
--hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \
--hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \
--hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \
--hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \
--hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \
--hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \
--hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \
--hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \
--hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \
--hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \
--hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \
--hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \
--hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \
--hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \
--hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \
--hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \
--hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \
--hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \
--hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \
--hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \
--hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \
--hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \
--hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \
--hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \
--hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \
--hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \
--hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \
--hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \
--hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \
--hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \
--hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \
--hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \
--hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \
--hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \
--hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \
--hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \
--hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \
--hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \
--hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \
--hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \
--hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \
--hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \
--hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \
--hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \
--hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \
--hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \
--hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \
--hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \
--hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \
--hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \
--hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \
--hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \
--hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \
--hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \
--hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \
--hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \
--hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \
--hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \
--hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \
--hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \
--hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \
--hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \
--hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \
--hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \
--hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \
--hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \
--hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \
--hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \
--hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \
--hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \
--hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \
--hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \
--hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \
--hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \
--hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \
--hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \
--hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \
--hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \
--hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \
--hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \
--hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \
--hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \
--hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \
--hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \
--hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \
--hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \
--hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \
--hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \
--hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \
--hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9
# via pydantic
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via botocore
s3transfer==0.13.1 \
--hash=sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724 \
--hash=sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf
# via boto3
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
starlette==1.6.0 \
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
# via
# -r infra/requirements.txt
# fastapi
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
# via
# anyio
# fastapi
# psycopg
# pydantic
# pydantic-core
# starlette
# typing-inspection
typing-inspection==0.4.4 \
--hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \
--hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
# via
# fastapi
# pydantic
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via botocore
uvicorn==0.34.2 \
--hash=sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328 \
--hash=sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403
# via -r infra/requirements.txt

View File

@@ -1,7 +1,8 @@
fastapi==0.141.1
starlette==1.6.0
pip==26.2.1
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

View File

@@ -1,557 +0,0 @@
"""
Kanban da sala de DTF — roda no servidor da fábrica, rede interna.
Princípio: o KANBAN é a única interface. O operador move o card aqui e as
pastas do servidor seguem sozinhas — ninguém abre o Explorer.
Decidido em 30/08/2026: não existe watchdog de pastas. Movimento feito fora do
kanban não é suportado.
A fila é sempre por HORÁRIO DE CHEGADA: quem chegou primeiro fica em cima.
Mover o card grava o movimento, move o arquivo, enfileira o marcador no Tiny
e o WhatsApp ao cliente. Ninguém abre o Tiny para atualizar nada.
O log de movimentos é gravado SEMPRE, mesmo se o Tiny estiver fora do ar.
A medição de tempo por etapa nunca depende de API de terceiro.
Rodar:
uvicorn main:app --host 0.0.0.0 --port 8080
"""
from __future__ import annotations
import json
import os
import shutil
import sqlite3
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
import tiny
import whats
BANCO = Path(os.environ.get("KANBAN_DB", r"C:\dtf\kanban.db"))
RAIZ = Path(os.environ.get("PASTA_DTF", r"\\servidor\DTF"))
# O KANBAN é a fonte de verdade da operação. O marcador no Tiny existe só para
# quem NÃO abre o kanban: comercial atendendo cliente no telefone, expedição
# conferindo se o DTF saiu, e o Marcus olhando pelo celular fora da fábrica.
#
# Por isso o Tiny recebe o estágio grosso, não o detalhe fino:
# está sendo feito · travou · ficou pronto
#
# Três chamadas por pedido em vez de sete. A 213 pedidos/dia, são 640 requisições
# em vez de 1.500 — folga no limite da API e menos job para o TI monitorar.
COLUNAS = [
("aut", "Aguardando autorização", "05_AUTORIZACAO", None, False), # só retrabalho
("rec", "Arte recebida", "00_ARTE_RECEBIDA", None, False),
("tra", "Arte tratada", "10_ARTE_TRATADA", None, False),
("apc", "Aprovação de cor", "15_APROVACAO_COR", "DTF-PROVA-COR",False),
("fil", "Fila de impressão", "20_FILA_IMPRESSAO", None, False),
("imp", "Imprimindo", "30_IMPRIMINDO", "DTF-PRODUCAO", False),
("cor", "Correção", "40_CORRECAO", "CORRECAO DTF", False),
("fin", "Finalizado", "60_FINALIZADO", "DTF-PRONTO", False),
]
# A sala só IMPRIME o filme — quem aplica na peça é o cliente.
# Por isso não existe coluna de aplicação nem causa de retrabalho por aplicação.
IDS = [c[0] for c in COLUNAS]
INFO = {c[0]: c for c in COLUNAS}
AVISA_CLIENTE = {"apc": "prova_cor", "imp": "producao",
"cor": "correcao", "fin": "concluido"}
app = FastAPI(title="Kanban DTF")
# ---------------------------------------------------------------------------
# Distribuição entre as máquinas — POR PUXADA, não por empurro
# ---------------------------------------------------------------------------
# O sistema NÃO decide qual máquina roda qual pedido. A fila é única, por
# horário de chegada, e a máquina que termina puxa o próximo.
#
# Empurrar exigiria adivinhar qual máquina estará livre daqui a duas horas. Se
# uma travar, a fila dela para enquanto outra fica ociosa, e alguém remaneja na
# mão. Por puxada nunca desbalanceia.
#
# Para o operador: um botão "puxar próximo". O arquivo já vem com o spot pronto
# do robô — ele só abre no Flexi e roda.
# Ajustado em 02/09/2026 pelo Thales e Alexandre:
# "reserva de 15 min é demais, no máx 3 min"
# "pedido por vez" — não puxar lote de trabalho
RESERVA_MIN = 3 # sem entrar em Imprimindo nesse prazo, volta para a fila
PUXAR_ATE_MIN = 0 # 0 = um pedido por vez, como a sala pediu
class Puxar(BaseModel):
maquina: int
usuario: str
minutos: int = PUXAR_ATE_MIN
@app.post("/api/puxar")
def puxar(p: Puxar):
"""
A máquina puxa o próximo da fila. Reserva por 15 min e move o arquivo para
a hot folder daquela máquina — o FlexiPRINT processa sozinho de lá.
"""
agora = datetime.now()
limite = (agora - timedelta(minutes=RESERVA_MIN)).isoformat(timespec="seconds")
pegos, minutos = [], 0
with con() as c:
# solta reservas vencidas antes de distribuir
c.execute("UPDATE card SET reservado_por=NULL, reservado_em=NULL "
"WHERE reservado_em IS NOT NULL AND reservado_em < ?", (limite,))
fila = c.execute(
"SELECT * FROM card WHERE coluna='fil' AND reservado_por IS NULL "
"ORDER BY desde").fetchall() # sempre por horário de chegada
for card in fila:
# estimativa de máquina: o campo do card manda; sem ele, calcula pelos metros
mm = card["minutos_maquina"] or round(card["metros"] / 20 * 60)
if pegos: # um pedido por vez
break
c.execute("UPDATE card SET reservado_por=?, reservado_em=?, maquina=? "
"WHERE arte_id=?",
(f"Maq {p.maquina}", agora.isoformat(timespec="seconds"),
f"Maq {p.maquina}", card["arte_id"]))
mover_arquivos(card["pedido"], "20_FILA_IMPRESSAO", f"30_MAQ{p.maquina}")
pegos.append({"pedido": card["pedido"], "metros": card["metros"],
"minutos": mm})
minutos += mm
return {"maquina": f"Maq {p.maquina}", "pedidos": pegos,
"minutos_total": minutos,
"reserva_expira_em": RESERVA_MIN}
@app.post("/api/devolver")
def devolver(arte_id: int, usuario: str):
"""
Máquina travou no meio. O pedido volta ao TOPO da fila, não ao fim —
ele já esperou uma vez.
"""
with con() as c:
card = c.execute("SELECT * FROM card WHERE arte_id=?", (arte_id,)).fetchone()
if not card:
raise HTTPException(404)
maq = (card["maquina"] or "Maq 1").replace("Maq ", "")
mover_arquivos(card["pedido"], f"30_MAQ{maq}", "20_FILA_IMPRESSAO")
# desde antigo = volta para o topo da ordem por horário de chegada
c.execute("UPDATE card SET coluna='fil', reservado_por=NULL, "
"reservado_em=NULL, maquina=NULL WHERE arte_id=?", (arte_id,))
c.execute("""INSERT INTO movimento
(arte_id,pedido,de,para,entrou_em,saiu_em,segundos,usuario,maquina)
VALUES (?,?,?,?,?,?,?,?,?)""",
(arte_id, card["pedido"], "imp", "fil", card["desde"],
datetime.now().isoformat(timespec="seconds"), 0, usuario, card["maquina"]))
return {"ok": True, "voltou_ao_topo": True}
# ---------------------------------------------------------------------------
# Aprovação de cor — evita o retrabalho em vez de repor depois
# ---------------------------------------------------------------------------
# Imprime uma amostra pequena, fotografa e manda ao cliente. Ele aprova, e só
# então o pedido vai para a fila. Custa centímetros de filme e evita metros.
#
# Dispara sozinho em três situações:
PROVA_METROS = 10.0 # arquivo grande: erro de cor custa caro
PROVA_CLIENTE_NOVO = True # primeiro pedido do cliente
# cores fora do gamut CMYK, que quase nunca saem como o cliente vê na tela
PROVA_CORES_TENSAS = {
"vermelho_saturado", "laranja", "verde_vivo", "azul_royal",
"roxo", "tom_de_pele", "cinza_neutro",
}
def precisa_prova_cor(metros: float, cliente_novo: bool,
cores_detectadas: set[str]) -> tuple[bool, str]:
if metros >= PROVA_METROS:
return True, f"arquivo de {metros:.1f} m"
if cliente_novo and PROVA_CLIENTE_NOVO:
return True, "primeiro pedido do cliente"
tensas = cores_detectadas & PROVA_CORES_TENSAS
if tensas:
return True, "cor fora do gamut: " + ", ".join(sorted(tensas))
return False, ""
# ---------------------------------------------------------------------------
# Retrabalho — SKU CRRMP.TX.100CM
# ---------------------------------------------------------------------------
# A causa é obrigatória e define quem absorve o custo. Sem ela o card não anda.
CAUSAS = {
"arte_cliente": ("Arte ruim aprovada pelo cliente", "cliente"),
"tratamento": ("Erro de tratamento", "casa"),
"impressao": ("Falha de impressão", "casa"),
"perfil_cor": ("Perfil de cor", "casa"),
"pedido": ("Erro de pedido", "casa"),
}
# Quem abre e quem autoriza são pessoas diferentes, de propósito.
#
# MAYANA abre e CLASSIFICA a causa. Ela conhece o cliente e sabe se a
# reclamação procede. A causa fica TRAVADA depois de aberta.
#
# THALES ou ALEXANDRE autorizam. O retrabalho da casa entra na meta e na
# remuneração deles — então têm incentivo real para questionar o que não
# procede. É controle natural, não burocracia.
#
# Por que a causa não pode ser mudada por quem autoriza: com o indicador
# atrelado a bônus, existiria incentivo para reclassificar falha de máquina
# como "arte do cliente". Quem discorda CONTESTA, e a contestação fica
# registrada com quem pediu e quem decidiu.
ABRE = "mayana"
AUTORIZA = ("thales", "alexandre")
# Alçada por metragem. Cada metro custa R$ 4,94 de insumo mais tempo de máquina.
ALCADA = [(15.0, "sala"), (float("inf"), "financeiro")]
# Meta por causa, não meta única. Cada um responde pelo que controla.
# 0,4% em cada uma das quatro causas da casa = 1,6% no total.
#
# Perfil de cor é causa PRÓPRIA, separada de falha de impressão. Cor errada é o
# retrabalho mais comum de DTF e quase nunca é defeito de máquina: ou a arte
# veio em CMYK, ou o monitor do cliente não é calibrado, ou o perfil da
# impressora está desatualizado. Só o terceiro é da casa — e separando dá para
# saber quanto é cada coisa.
META_POR_CAUSA = {
"impressao": (0.4, "Thales e Alexandre"),
"perfil_cor": (0.4, "Thales e Alexandre"),
"tratamento": (0.4, "designers"),
"pedido": (0.4, "comercial"),
"arte_cliente": (None, None), # reposição comercial: fora da meta
}
# soma das metas da casa = 1,6%
TETO_MES_PCT = 6.0 # acima disso tudo sobe uma alçada
TETO_CLIENTE_PCT = 10.0 # cliente acima disso fica sinalizado no painel
def quem_autoriza(metros: float, vez: int, pct_mes: float) -> str:
"""Reincidência e teto do mês sobem a alçada automaticamente."""
if vez >= 3:
return "diretoria"
nivel = next(n for lim, n in ALCADA if metros <= lim)
if vez == 2:
nivel = {"sala": "financeiro"}.get(nivel, "diretoria")
if pct_mes > TETO_MES_PCT:
nivel = {"sala": "financeiro", "financeiro": "diretoria"}.get(nivel, "diretoria")
return nivel
def conta_na_meta(causa: str) -> bool:
"""Só o retrabalho da casa entra na meta. O do cliente, não."""
return CAUSAS[causa][1] == "casa"
@app.get("/api/relatorio/retrabalho")
def retrabalho(dias: int = 30):
"""
Retrabalho por causa, com responsável e meta.
Base para a bonificação: cada equipe é medida só pelo que controla.
"""
with con() as c:
total = c.execute(
"SELECT COUNT(*) n FROM card WHERE desde >= date('now', ?)",
(f"-{dias} days",)).fetchone()["n"] or 1
linhas = c.execute("""
SELECT causa, COUNT(*) n, SUM(metros) m
FROM retrabalho WHERE aberto_em >= date('now', ?)
GROUP BY causa""", (f"-{dias} days",)).fetchall()
saida, casa = [], 0.0
for r in linhas:
pct = r["n"] / total * 100
meta, quem = META_POR_CAUSA.get(r["causa"], (None, None))
if conta_na_meta(r["causa"]):
casa += pct
saida.append({
"causa": r["causa"], "descricao": CAUSAS[r["causa"]][0],
"responsavel": quem, "pedidos": r["n"], "metros": r["m"],
"pct": round(pct, 2), "meta": meta,
"bate": None if meta is None else pct <= meta,
})
return {"por_causa": saida, "total_casa_pct": round(casa, 2),
"meta_casa_pct": round(sum(m for m, _ in META_POR_CAUSA.values()
if m is not None), 2)}
# --------------------------------------------------------------------------
def con():
c = sqlite3.connect(BANCO, timeout=10)
c.row_factory = sqlite3.Row
return c
def criar_tabelas() -> None:
with con() as c:
c.executescript("""
CREATE TABLE IF NOT EXISTS card(
arte_id INTEGER PRIMARY KEY, pedido TEXT, cliente TEXT, metros REAL,
coluna TEXT DEFAULT 'rec', desde TEXT, maquina TEXT, partes INTEGER,
reservado_por TEXT, reservado_em TEXT,
minutos_maquina INTEGER); -- estimativa; herda os marcadores 30min/1h/3h
CREATE TABLE IF NOT EXISTS movimento(
id INTEGER PRIMARY KEY AUTOINCREMENT,
arte_id INTEGER, pedido TEXT, de TEXT, para TEXT,
entrou_em TEXT, saiu_em TEXT, segundos INTEGER,
usuario TEXT, maquina TEXT);
CREATE TABLE IF NOT EXISTS job(
id INTEGER PRIMARY KEY AUTOINCREMENT,
tipo TEXT, payload TEXT, tentativas INTEGER DEFAULT 0,
proxima_em REAL, erro TEXT, feito INTEGER DEFAULT 0);
CREATE INDEX IF NOT EXISTS ix_mov_arte ON movimento(arte_id);
CREATE INDEX IF NOT EXISTS ix_job_fila ON job(feito, proxima_em);
""")
criar_tabelas()
# --------------------------------------------------------------------------
class Mover(BaseModel):
arte_id: int
para: str
usuario: str
maquina: str | None = None
@app.get("/api/quadro")
def quadro():
agora = datetime.now()
with con() as c:
cards = c.execute("SELECT * FROM card ORDER BY desde").fetchall()
saida = {i: [] for i in IDS}
for r in cards:
desde = datetime.fromisoformat(r["desde"])
saida[r["coluna"]].append({
"arte_id": r["arte_id"], "pedido": r["pedido"], "cliente": r["cliente"],
"metros": r["metros"], "maquina": r["maquina"], "partes": r["partes"],
"desde": r["desde"],
"parado_seg": int((agora - desde).total_seconds()),
"minutos_maquina": round(r["metros"] / 20 * 60), # 20 m/h por máquina
})
return {"colunas": [{"id": c[0], "nome": c[1]} for c in COLUNAS], "cards": saida}
@app.post("/api/mover")
def mover(m: Mover):
if m.para not in IDS:
raise HTTPException(400, "coluna inválida")
agora = datetime.now()
with con() as c:
card = c.execute("SELECT * FROM card WHERE arte_id=?", (m.arte_id,)).fetchone()
if not card:
raise HTTPException(404, "card não encontrado")
if card["coluna"] == m.para:
return {"ok": True, "sem_mudanca": True}
de = card["coluna"]
desde = datetime.fromisoformat(card["desde"])
# 1. o movimento é gravado ANTES de qualquer integração externa
c.execute("""INSERT INTO movimento
(arte_id,pedido,de,para,entrou_em,saiu_em,segundos,usuario,maquina)
VALUES (?,?,?,?,?,?,?,?,?)""",
(m.arte_id, card["pedido"], de, m.para, card["desde"],
agora.isoformat(timespec="seconds"),
int((agora - desde).total_seconds()), m.usuario,
m.maquina or card["maquina"]))
c.execute("UPDATE card SET coluna=?, desde=?, maquina=COALESCE(?,maquina) "
"WHERE arte_id=?",
(m.para, agora.isoformat(timespec="seconds"), m.maquina, m.arte_id))
# 2. arquivo acompanha o card
mover_arquivos(card["pedido"], INFO[de][2], INFO[m.para][2])
# 3. jobs: marcador no Tiny e WhatsApp
# a máquina fica só no kanban — no Tiny basta saber que entrou em produção
marcador = INFO[m.para][3]
if marcador:
enfileirar(c, "tiny_marcador",
{"pedido": card["pedido"], "marcador": marcador})
if m.para in AVISA_CLIENTE:
enfileirar(c, "whats",
{"pedido": card["pedido"], "tipo": AVISA_CLIENTE[m.para]})
return {"ok": True, "de": de, "para": m.para}
def mover_arquivos(pedido: str, pasta_de: str, pasta_para: str) -> None:
origem, destino = RAIZ / pasta_de, RAIZ / pasta_para
destino.mkdir(parents=True, exist_ok=True)
for f in origem.glob(f"{pedido}_*"):
shutil.move(str(f), str(destino / f.name))
# --------------------------------------------------------------------------
# Fila de jobs — tabela simples, sem Redis nem Celery
# --------------------------------------------------------------------------
def enfileirar(c, tipo: str, payload: dict) -> None:
c.execute("INSERT INTO job(tipo,payload,proxima_em) VALUES (?,?,?)",
(tipo, json.dumps(payload), time.time()))
ESPERA = [60, 300, 1800] # 1 min, 5 min, 30 min
def worker() -> None:
while True:
try:
with con() as c:
job = c.execute(
"SELECT * FROM job WHERE feito=0 AND proxima_em<=? "
"ORDER BY id LIMIT 1", (time.time(),)).fetchone()
if not job:
time.sleep(3)
continue
p = json.loads(job["payload"])
try:
if job["tipo"] == "tiny_marcador":
tiny.marcador(p["pedido"], p["marcador"])
elif job["tipo"] == "whats":
whats.avisar(p["pedido"], p["tipo"])
c.execute("UPDATE job SET feito=1 WHERE id=?", (job["id"],))
except Exception as e:
n = job["tentativas"] + 1
if n > len(ESPERA):
c.execute("UPDATE job SET feito=1, erro=? WHERE id=?",
(f"desistiu: {e}", job["id"]))
# TODO: alertar o TI
else:
c.execute(
"UPDATE job SET tentativas=?, proxima_em=?, erro=? WHERE id=?",
(n, time.time() + ESPERA[n - 1], str(e), job["id"]))
except Exception:
time.sleep(5)
threading.Thread(target=worker, daemon=True).start()
# --------------------------------------------------------------------------
# QR do carimbo aponta para cá
# --------------------------------------------------------------------------
@app.get("/p/{pedido}")
def abrir_card(pedido: str):
"""A revisão bipa o QR do filme e cai direto no card."""
with con() as c:
card = c.execute("SELECT arte_id FROM card WHERE pedido=?", (pedido,)).fetchone()
if not card:
raise HTTPException(404, "pedido não encontrado no kanban")
return RedirectResponse(f"/?card={card['arte_id']}")
@app.get("/api/pedido/{pedido}")
def trilha(pedido: str):
"""Tempo em cada etapa e quanto do total foi só esperando."""
PARADO = {"rec", "tra", "fil", "cor"}
with con() as c:
movs = c.execute(
"SELECT * FROM movimento WHERE pedido=? ORDER BY id", (pedido,)).fetchall()
card = c.execute("SELECT * FROM card WHERE pedido=?", (pedido,)).fetchone()
if not card:
raise HTTPException(404)
agora = int((datetime.now() - datetime.fromisoformat(card["desde"])).total_seconds())
etapas = [{"coluna": m["de"], "nome": INFO[m["de"]][1], "segundos": m["segundos"]}
for m in movs]
etapas.append({"coluna": card["coluna"], "nome": INFO[card["coluna"]][1],
"segundos": agora, "atual": True})
total = sum(e["segundos"] for e in etapas)
esperando = sum(e["segundos"] for e in etapas if e["coluna"] in PARADO)
return {"pedido": pedido, "cliente": card["cliente"], "metros": card["metros"],
"etapas": etapas, "total_seg": total, "esperando_seg": esperando}
PERIODOS = {"hoje": 1, "7": 7, "30": 30, "mes": 30, "ant": 60}
@app.get("/api/relatorio/impressao")
def tempo_impressao(dias: int = 7):
"""
Tempo médio de impressão e velocidade real por máquina.
Sai da coluna 'imp' da tabela movimento, cruzada com os metros do card.
É este número que diz se 20 m/h é a velocidade real ou só a de catálogo.
"""
with con() as c:
linhas = c.execute("""
SELECT m.maquina, COUNT(*) n,
AVG(m.segundos) media_seg,
SUM(c.metros) metros,
SUM(m.segundos) total_seg
FROM movimento m JOIN card c ON c.arte_id = m.arte_id
WHERE m.de = 'imp' AND m.saiu_em >= date('now', ?)
GROUP BY m.maquina""", (f"-{dias} days",)).fetchall()
saida = []
for r in linhas:
horas = (r["total_seg"] or 0) / 3600
saida.append({
"maquina": r["maquina"], "pedidos": r["n"],
"media_min": round((r["media_seg"] or 0) / 60),
"metros": round(r["metros"] or 0, 1),
"m_por_hora": round((r["metros"] or 0) / horas, 1) if horas else None,
})
return saida
# Depósito do Tiny para onde o insumo é transferido antes de imprimir.
# A Altus também VENDE insumo, então esse depósito separa o que é consumo
# interno do que é revenda. Confirmar o nome exato no cadastro do Tiny.
DEPOSITO_IMPRESSAO = "Sala DTF"
SKU_FILME = "DTF.FILME.57"
@app.get("/api/relatorio/aproveitamento")
def aproveitamento(dias: int = 30):
"""
Quanto do filme transferido virou metro faturado.
Compra-se rolo de 100 m e imprime-se cerca de 90, por acerto de máquina,
prova de cor, refile e sobra de ponta. Cada ponto percentual vale cerca de
R$ 980/mês no volume atual.
NÃO exige apontamento novo na sala: o consumo já é registrado hoje, quando
o insumo é transferido para o depósito de impressão no Tiny. Este endpoint
só lê a movimentação desse depósito.
"""
fat = 0.0
with con() as c:
fat = c.execute(
"SELECT SUM(metros) m FROM card WHERE coluna='fin' AND desde >= date('now', ?)",
(f"-{dias} days",)).fetchone()["m"] or 0
cons = tiny.transferido_para_deposito(
sku=SKU_FILME, deposito=DEPOSITO_IMPRESSAO, dias=dias)
pct = fat / cons * 100 if cons else None
return {"metros_faturados": round(fat, 1),
"metros_de_filme": round(cons, 1),
"fonte": f"transferências para o depósito {DEPOSITO_IMPRESSAO} no Tiny",
"aproveitamento_pct": round(pct, 1) if pct else None,
"valor_do_ponto_mes": 980}
@app.get("/api/relatorio/etapas")
def relatorio(dias: int = 7):
"""Tempo médio por etapa. É o número que hoje não existe em lugar nenhum."""
with con() as c:
linhas = c.execute("""
SELECT de, COUNT(*) n, AVG(segundos) media, MAX(segundos) pior
FROM movimento
WHERE saiu_em >= date('now', ?)
GROUP BY de""", (f"-{dias} days",)).fetchall()
return [{"coluna": r["de"], "nome": INFO[r["de"]][1], "movimentos": r["n"],
"media_min": round(r["media"] / 60), "pior_min": round(r["pior"] / 60)}
for r in linhas]
@app.get("/")
def index():
return FileResponse("static/kanban.html")

View File

@@ -1,271 +0,0 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DTF · sala de impressão</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#7C828C;
--ciano:#00B8DA;--magenta:#E0357C;--amarelo:#EFB700;--branco:#EDEBE6;--verde:#48B072;
--alerta:#E0642A;--roxo:#8E7CE8}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--tx);font-family:"IBM Plex Mono",ui-monospace,monospace;
font-size:14px;line-height:1.45;-webkit-font-smoothing:antialiased;padding:16px 14px 34px}
h1,h2{font-family:"Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
.topo{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px}
h1{font-size:23px;font-weight:700}
.sub{color:var(--fraco);font-size:12px}
.rel{margin-left:auto;font-family:"Space Grotesk",sans-serif;font-size:19px;font-weight:700;color:var(--ciano)}
.aviso{background:#152026;border:1px solid #24404A;border-left:3px solid var(--ciano);
border-radius:0 8px 8px 0;padding:10px 14px;font-size:12.5px;color:#B9CBD2;margin:10px 0 14px}
.aviso b{color:var(--ciano);font-family:"Space Grotesk",sans-serif}
/* máquinas */
.maqs{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:14px}
@media(max-width:900px){.maqs{grid-template-columns:repeat(3,1fr)}}
.mq{background:var(--card);border:1px solid var(--linha);border-radius:9px;padding:11px 12px;position:relative}
.mq.ocup{border-color:var(--alerta)}
.mq .luz{width:11px;height:11px;border-radius:50%;position:absolute;top:11px;right:11px;background:var(--verde)}
.mq.ocup .luz{background:var(--alerta);box-shadow:0 0 0 3px rgba(224,100,42,.2)}
.mq b{font-family:"Space Grotesk",sans-serif;font-size:13px;display:block;margin-bottom:4px}
.mq .st{font-size:10.5px;color:var(--fraco);line-height:1.4;min-height:28px}
.mq button{margin-top:8px;width:100%;font-family:inherit;font-size:11px;padding:6px;border-radius:6px;
border:1px solid var(--linha);background:var(--card2);color:var(--fraco);cursor:pointer}
.mq.livre button{background:var(--ciano);border-color:var(--ciano);color:#03181D;font-weight:600}
.mq.livre button:hover{filter:brightness(1.1)}
.mq.ocup button{opacity:.4;cursor:default}
/* kpis */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:9px;margin-bottom:14px}
.k{background:var(--card);border:1px solid var(--linha);border-radius:9px;padding:11px 14px;border-left:3px solid var(--c,var(--linha))}
.k dt{font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--fraco);margin-bottom:3px}
.k dd{font-family:"Space Grotesk",sans-serif;font-size:23px;font-weight:700}
.k dd small{font-size:12px;color:var(--fraco);font-weight:400;font-family:"IBM Plex Mono",monospace}
/* kanban */
.kan{display:grid;grid-template-columns:repeat(7,minmax(140px,1fr));gap:8px;overflow-x:auto;padding-bottom:16px}
@media(max-width:1200px){.kan{grid-template-columns:repeat(7,168px)}}
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;display:flex;flex-direction:column;min-height:250px}
.col.alvo{border-color:var(--ciano);background:#101820}
.ch{padding:10px 11px 8px;border-bottom:1px solid var(--linha)}
.ch b{font-family:"Space Grotesk",sans-serif;font-size:12px;display:block}
.ch b .p{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--cc)}
.ch small{font-size:10px;color:var(--fraco)}
.cb{padding:8px;display:flex;flex-direction:column;gap:6px;flex:1}
.vazio{color:var(--fraco);font-size:10.5px;text-align:center;padding:14px 4px}
.cd{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc);
border-radius:5px;padding:8px 9px;cursor:grab;font-size:11px}
.cd:active{cursor:grabbing}
.cd.arrasta{opacity:.35}
.cd b{display:block;font-family:"Space Grotesk",sans-serif;font-size:12.5px}
.cd .cli{color:var(--fraco);font-size:10px;margin:1px 0 5px}
.cd .l{display:flex;justify-content:space-between;font-size:10px}
.cd .l i{font-style:normal;color:var(--fraco)}
.cd .mq2{display:inline-block;font-size:9px;padding:1px 5px;border-radius:3px;background:var(--linha);margin-top:4px}
.cd.q .l i{color:var(--amarelo)}
.cd.r{border-left-color:var(--alerta)}
.cd.r .l i{color:var(--alerta)}
.rod{margin-top:14px;display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--fraco);font-size:11.5px}
.rod button{font-family:inherit;font-size:11.5px;padding:7px 12px;border-radius:7px;
border:1px solid var(--linha);background:none;color:var(--fraco);cursor:pointer}
.rod button:hover{border-color:var(--ciano);color:var(--ciano)}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<div class="topo">
<h1>Kanban da sala de DTF</h1>
<span class="sub" id="ctx">carregando…</span>
<span class="rel" id="hora">—</span>
</div>
<div class="aviso" id="aviso" style="display:none"></div>
<div class="maqs" id="maqs"></div>
<dl class="kpis" id="kpis"></dl>
<div class="kan" id="kan"></div>
<div class="rod">
<button id="atualizar">Atualizar</button>
<span id="msg"></span>
</div>
<script>
const COLS=[
{id:'rec', nome:'Arte recebida', cor:'var(--ciano)'},
{id:'tra', nome:'Arte tratada', cor:'var(--magenta)'},
{id:'cor2',nome:'Prova de cor', cor:'var(--roxo)'},
{id:'fil', nome:'Fila', cor:'var(--amarelo)'},
{id:'imp', nome:'Imprimindo', cor:'var(--branco)'},
{id:'cor', nome:'Correção', cor:'var(--alerta)'},
{id:'fin', nome:'Finalizado', cor:'var(--verde)'}
];
const API=''; // mesma origem do PCP
const USUARIO=window.PCP_USUARIO || 'operador';
const MAQUINA=new URLSearchParams(location.search).get('maq'); // ?maq=4
const PARADO=['rec','tra','cor2','fil','cor'];
const H=36e5, M=6e4;
let cards=[], maquinas=[], erro=false;
async function api(rota,opt){
const r=await fetch(API+rota,{headers:{'Content-Type':'application/json'},...opt});
if(!r.ok){
let m='erro '+r.status;
try{ m=(await r.json()).detail || m; }catch(e){}
throw new Error(m);
}
return r.status===204? null : r.json();
}
async function carregar(){
try{
const d=await api('/api/quadro');
cards=[]; maquinas=d.maquinas||[];
Object.entries(d.cards||{}).forEach(([col,lista])=>
lista.forEach(c=>cards.push({
id:String(c.arte_id), pedido:c.pedido, cli:c.cliente,
m:c.metros, min:c.minutos_maquina || Math.round(c.metros/20*60),
col, desde:Date.parse(c.desde), maq:c.maquina? +String(c.maquina).replace(/\D/g,''):null
})));
erro=false; esconderAviso();
}catch(e){
erro=true; mostrarAviso('Sem conexão com o servidor — '+e.message+'. A tela não está atualizando.');
}
pinta();
}
function mostrarAviso(t){
const a=document.getElementById('aviso');
a.style.display='block'; a.innerHTML='<b>Atenção</b> '+t;
}
function esconderAviso(){ document.getElementById('aviso').style.display='none'; }
function salvar(){ /* estado vive no servidor */ }
async function puxar(n){
try{
const r=await api('/api/puxar',{method:'POST',
body:JSON.stringify({maquina:n, usuario:USUARIO})});
msg('Maq '+n+' puxou o #'+r.pedido+' · '+r.minutos+' min · reserva de '+r.reserva_expira_em+' min');
}catch(e){ msg(e.message); }
carregar();
}
async function mover(arte_id,para,maq){
try{
await api('/api/mover',{method:'POST',
body:JSON.stringify({arte_id:+arte_id, para, usuario:USUARIO, maquina:maq||null})});
}catch(e){ msg(e.message); }
carregar();
}
async function devolver(arte_id){
try{
await api('/api/devolver',{method:'POST',
body:JSON.stringify({arte_id:+arte_id, usuario:USUARIO})});
msg('devolvido ao topo da fila');
}catch(e){ msg(e.message); }
carregar();
}
function pintaMaqs(){
const el=document.getElementById('maqs'); el.innerHTML='';
for(let n=1;n<=6;n++){
const info=maquinas.find(x=>x.n===n) || {};
const job=cards.find(c=>c.col==='imp' && c.maq===n) ||
(info.ocupada? {pedido:info.pedido,m:info.metros,desde:Date.now()-(info.rodando_seg||0)*1000}:null);
if(MAQUINA && String(n)!==MAQUINA) continue;
const d=document.createElement('div');
d.className='mq '+(job?'ocup':'livre');
d.innerHTML='<span class="luz"></span><b>Maq '+n+'</b>'+
'<div class="st">'+(job
? '#'+job.pedido+' · '+job.m.toFixed(1).replace('.',',')+' m<br>rodando há '+dur(Date.now()-job.desde)
: 'livre<br>&nbsp;')+'</div>'+
'<button '+(job?'disabled':'')+'>'+(job?'ocupada':'puxar próximo')+'</button>';
if(!job) d.querySelector('button').addEventListener('click',()=>puxar(n));
el.appendChild(d);
}
}
function puxar(n){
const fila=cards.filter(c=>c.col==='fil').sort((a,b)=>a.desde-b.desde);
if(!fila.length) return msg('a fila está vazia');
const c=fila[0];
c.col='imp'; c.maq=n; c.desde=Date.now();
salvar(); pinta();
msg('Maq '+n+' puxou o #'+c.pedido+' — o mais antigo da fila');
}
function pintaKpis(){
const fila=cards.filter(c=>['rec','tra','cor2','fil'].includes(c.col));
const mf=fila.reduce((s,c)=>s+c.m,0), mn=fila.reduce((s,c)=>s+c.min,0);
const feito=cards.filter(c=>c.col==='fin').reduce((s,c)=>s+c.m,0);
const livres=[1,2,3,4,5,6].filter(n=>!cards.some(c=>c.col==='imp'&&c.maq===n)).length;
const cap=20*6*4.5; // 6 máquinas, 20 m/h, 4h30 restantes
const risco=cards.filter(c=>PARADO.includes(c.col)&&Date.now()-c.desde>3*H).length;
const k=[
['Na fila', mf.toFixed(1).replace('.',',')+' <small>m</small>','var(--amarelo)'],
['Minutos de máquina', mn+' <small>min</small>','var(--amarelo)'],
['Capacidade até 20h', Math.round(cap)+' <small>m</small>', mf>cap?'var(--alerta)':'var(--verde)'],
['Máquinas livres',livres+' <small>de 6</small>','var(--ciano)'],
['Impresso hoje', feito.toFixed(1).replace('.',',')+' <small>m</small>','var(--verde)'],
['Parados +3h', risco+' <small>pedido'+(risco===1?'':'s')+'</small>', risco?'var(--alerta)':'var(--verde)']
];
document.getElementById('kpis').innerHTML=k.map(x=>
'<div class="k" style="--c:'+x[2]+'"><dt>'+x[0]+'</dt><dd>'+x[1]+'</dd></div>').join('');
}
function pinta(){
pintaMaqs(); pintaKpis();
const agora=Date.now(), el=document.getElementById('kan'); el.innerHTML='';
COLS.forEach(col=>{
const its=cards.filter(c=>c.col===col.id).sort((a,b)=>a.desde-b.desde);
const mt=its.reduce((s,c)=>s+c.m,0);
const d=document.createElement('div');
d.className='col'; d.dataset.col=col.id; d.style.setProperty('--cc',col.cor);
d.innerHTML='<div class="ch"><b><span class="p"></span>'+col.nome+'</b>'+
'<small>'+its.length+' · '+mt.toFixed(1).replace('.',',')+' m</small></div><div class="cb"></div>';
const cb=d.querySelector('.cb');
if(!its.length) cb.innerHTML='<div class="vazio">—</div>';
its.forEach(c=>{
const p=agora-c.desde, fin=col.id==='fin';
const cl=fin?'':(p>3*H?' r':(p>1*H?' q':''));
const e=document.createElement('div');
e.className='cd'+cl; e.draggable=true; e.dataset.id=c.id;
e.innerHTML='<b>#'+c.pedido+'</b><div class="cli">'+c.cli+'</div>'+
'<div class="l"><i>'+(fin?'concluído':'há '+dur(p))+'</i>'+
'<span>'+c.m.toFixed(1).replace('.',',')+' m · '+c.min+' min</span></div>'+
(c.maq?'<span class="mq2">Maq '+c.maq+'</span>':'');
e.addEventListener('dragstart',ev=>{ev.dataTransfer.setData('text/plain',c.id);e.classList.add('arrasta')});
e.addEventListener('dragend',()=>e.classList.remove('arrasta'));
cb.appendChild(e);
});
d.addEventListener('dragover',ev=>{ev.preventDefault();d.classList.add('alvo')});
d.addEventListener('dragleave',()=>d.classList.remove('alvo'));
d.addEventListener('drop',ev=>{
ev.preventDefault(); d.classList.remove('alvo');
const c=cards.find(x=>x.id===ev.dataTransfer.getData('text/plain'));
if(!c||c.col===col.id) return;
if(c.col==='imp'&&col.id==='fil') return devolver(c.id);
let maq=null;
if(col.id==='imp'){
maq=[1,2,3,4,5,6].find(n=>!cards.some(x=>x.col==='imp'&&x.maq===n));
if(!maq) return msg('todas as máquinas estão ocupadas');
}
mover(c.id,col.id,maq);
});
el.appendChild(d);
});
}
document.getElementById('atualizar').addEventListener('click',carregar);
document.getElementById('ctx').textContent =
MAQUINA ? 'Maq '+MAQUINA+' · '+USUARIO : 'visão geral · '+USUARIO;
function tic(){
document.getElementById('hora').textContent=
new Date().toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'});
}
tic(); setInterval(tic,30000);
setInterval(pinta,30000); // relógio dos cards
setInterval(carregar,15000); // busca o estado no servidor
carregar();
</script>
</body>
</html>

View File

@@ -1,89 +0,0 @@
"""
Cliente da API do Tiny (Olist) — v3, OAuth2 client credentials.
ATENÇÃO Wagner: confirmar na documentação oficial antes de subir:
- o endpoint exato de marcadores do pedido
- o limite de requisições por minuto (dimensiona o worker)
- se o refresh token expira e com que frequência
Este módulo isola a API: se o endpoint mudar, muda só aqui.
"""
from __future__ import annotations
import os
import time
import httpx
BASE = os.environ.get("TINY_BASE", "https://api.tiny.com.br/public-api/v3")
CLIENT_ID = os.environ["TINY_CLIENT_ID"]
CLIENT_SECRET = os.environ["TINY_CLIENT_SECRET"]
TOKEN_URL = os.environ["TINY_TOKEN_URL"]
_token: dict = {"valor": None, "expira": 0.0}
def _acesso() -> str:
if _token["valor"] and time.time() < _token["expira"] - 60:
return _token["valor"]
r = httpx.post(TOKEN_URL, data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
}, timeout=20)
r.raise_for_status()
d = r.json()
_token["valor"] = d["access_token"]
_token["expira"] = time.time() + d.get("expires_in", 3600)
return _token["valor"]
def _headers() -> dict:
return {"Authorization": f"Bearer {_acesso()}", "Content-Type": "application/json"}
def buscar_pedido(numero: str) -> dict:
r = httpx.get(f"{BASE}/pedidos", params={"numero": numero},
headers=_headers(), timeout=20)
r.raise_for_status()
itens = r.json().get("itens") or []
if not itens:
raise LookupError(f"pedido {numero} não encontrado no Tiny")
return itens[0]
def transferido_para_deposito(sku: str, deposito: str, dias: int) -> float:
"""
Soma o que foi transferido para o depósito de impressão no período.
A Altus já faz essa transferência hoje, porque também revende insumo — o
depósito separa consumo interno de revenda. Por isso o aproveitamento sai
de graça: nenhum apontamento novo na sala.
ATENÇÃO Wagner: confirmar o endpoint de movimentações de estoque por
depósito na API v3 e o nome exato do depósito no cadastro.
"""
r = httpx.get(
f"{BASE}/estoque/movimentacoes",
params={"codigo": sku, "deposito": deposito, "dias": dias, "tipo": "E"},
headers=_headers(), timeout=30,
)
r.raise_for_status()
return sum(float(m.get("quantidade", 0)) for m in r.json().get("itens", []))
def marcador(numero: str, marcador: str) -> None:
"""
Troca o marcador do pedido. Substitui os marcadores de etapa do DTF,
preservando marcadores de outra natureza (multiempresa, VALE, Troca...).
"""
pedido = buscar_pedido(numero)
atuais = [m["descricao"] for m in pedido.get("marcadores", [])]
# só três marcadores de etapa vivem no Tiny; o resto do fluxo é do kanban
ETAPAS = {"DTF-RECEBIDA", "DTF-PRODUCAO", "CORRECAO DTF", "DTF-PRONTO"}
mantem = [m for m in atuais if m not in ETAPAS]
novos = mantem + [marcador]
r = httpx.put(f"{BASE}/pedidos/{pedido['id']}/marcadores",
json={"marcadores": [{"descricao": m} for m in novos]},
headers=_headers(), timeout=20)
r.raise_for_status()

View File

@@ -1,68 +0,0 @@
"""
Envio de WhatsApp. Três avisos ao cliente, não sete:
aprovada · em produção · finalizada (+ correção, a única que pede resposta)
Provedor definido por WHATS_PROVEDOR = meta | zapi
A API oficial da Meta exige template aprovado para mensagem iniciada por nós.
"""
from __future__ import annotations
import os
import httpx
PROVEDOR = os.environ.get("WHATS_PROVEDOR", "meta")
TOKEN = os.environ.get("WHATS_TOKEN", "")
NUMERO_ID = os.environ.get("WHATS_NUMERO_ID", "")
ZAPI_URL = os.environ.get("ZAPI_URL", "")
TEXTOS = {
"prova_cor": ("Antes de imprimir o pedido {pedido} inteiro, fizemos uma amostra "
"de cor. Confira a foto e responda APROVO para seguirmos. A cor no "
"filme pode variar em relação ao seu monitor."),
"producao": "Seu pedido {pedido} entrou em produção. Avisamos quando ficar pronto.",
"concluido": "Pedido {pedido} finalizado e pronto para retirada ou envio.",
"correcao": "Precisamos de um ajuste na arte do pedido {pedido}: {motivo}",
}
def _enviar(telefone: str, texto: str) -> None:
if PROVEDOR == "zapi":
httpx.post(ZAPI_URL, json={"phone": telefone, "message": texto}, timeout=20)
return
httpx.post(
f"https://graph.facebook.com/v20.0/{NUMERO_ID}/messages",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"messaging_product": "whatsapp", "to": telefone,
"type": "text", "text": {"body": texto}},
timeout=20,
).raise_for_status()
def enviar_link(telefone: str, pedido: str, link: str) -> None:
_enviar(telefone,
f"Pedido {pedido} confirmado. Envie sua arte por aqui: {link}\n"
"O link vale por 7 dias. Gabarito 57 × 97 cm, PNG 300 DPI com fundo "
"transparente.")
def enviar_aprovacao(pedido, r) -> None:
n = len(r.partes)
_enviar(pedido.telefone if hasattr(pedido, "telefone") else "",
f"Arte do pedido {pedido.numero_tiny} aprovada. "
f"{r.metros_totais:.2f} m em {n} arquivo{'s' if n > 1 else ''}. "
"Entrou na fila de impressão.".replace(".", ",", 1))
def enviar_recusa(pedido, motivo: str) -> None:
_enviar(getattr(pedido, "telefone", ""),
f"A arte do pedido {pedido.numero_tiny} precisa de ajuste: {motivo}\n"
"O prazo só começa a contar depois que a arte for aprovada.")
def avisar(pedido: str, tipo: str, motivo: str = "") -> None:
# o telefone vem do cadastro do pedido no Tiny
import tiny
p = tiny.buscar_pedido(pedido)
telefone = (p.get("cliente") or {}).get("fone", "")
_enviar(telefone, TEXTOS[tipo].format(pedido=pedido, motivo=motivo))

View File

@@ -1,11 +0,0 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY local/requirements.txt local/requirements.lock /app/local/
RUN pip install --no-cache-dir --require-hashes -r local/requirements.lock
COPY local /app/local
RUN useradd --uid 10001 --create-home dtf
USER dtf
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1

View File

@@ -1,13 +0,0 @@
FROM python:3.12-slim AS policy
WORKDIR /build
COPY dtf-site.html /build/dtf-site.html
COPY local /build/local
RUN python local/compile_web.py
FROM nginx:1.28-alpine
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 dtf-site.html /usr/share/nginx/html/index.html
COPY local/static/ /usr/share/nginx/html/

View File

@@ -1,362 +0,0 @@
import hashlib
import json
import math
import os
import secrets
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
from botocore.exceptions import ClientError
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.trustedhost import TrustedHostMiddleware
from psycopg.types.json import Jsonb
from . import db
from .secrets import load as load_secret_files
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
from .pricing import price
from .auth import COOKIE_SECURE, client_ip, owner, session_row, new_session, operator, throttle, audit, rate_limit
from .scanning import require_clean
load_secret_files()
require_runtime()
storage = LocalS3Storage()
payment = FakePayment()
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]
ALLOWED_ORIGINS = [origin for origin in os.environ.get('ALLOWED_ORIGINS', PUBLIC_ORIGIN).split(',') if origin]
# Per source and generous: a browser needs one session and keeps the cookie, but
# offices and mobile carriers put many real customers behind one address, so a
# tight per-IP ceiling would lock out the same people the old global one did.
GUEST_SESSION_LIMIT = int(os.environ.get('GUEST_SESSION_LIMIT', '240'))
PART_BYTES = int(os.environ.get('UPLOAD_PART_BYTES', '8388608'))
if not 5242880 <= PART_BYTES <= 67108864:
raise RuntimeError('UPLOAD_PART_BYTES must be between 5 and 64 MiB')
STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impressão',
'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
@asynccontextmanager
async def lifespan(app):
with db.connect() as c:
c.execute('SELECT 1 FROM dtf_local.operator_sessions LIMIT 1')
storage.health()
yield
app = FastAPI(title='DTF Portal/API', lifespan=lifespan, docs_url=None, redoc_url=None)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
@app.post('/api/operator/login')
def operator_login(body: OperatorLogin, request: Request, response: Response):
configured_email = os.environ.get('OPERATOR_EMAIL', '').strip().lower()
if not configured_email:
raise HTTPException(503, 'Kanban operator email is not configured')
email = body.email
throttle('operator:'+email, request)
valid_user = secrets.compare_digest(email.encode(), configured_email.encode())
valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode())
if not (valid_user and valid_password):
audit('operator_login_failed', ip=client_ip(request))
raise HTTPException(401, 'Invalid operator login')
token = secrets.token_urlsafe(32)
with db.connect() as c:
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
(hashlib.sha256(token.encode()).hexdigest(), email))
response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
samesite='strict', path='/api/operator', max_age=28800)
audit('operator_login_success', operator=email)
return {'ok': True}
@app.post('/api/operator/logout')
def operator_logout(request: Request, response: Response):
with db.connect() as c:
digest = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (digest,))
response.delete_cookie('dtf_operator', path='/api/operator', httponly=True,
secure=COOKIE_SECURE, samesite='strict')
audit('operator_logout')
return {'ok': True}
@app.middleware('http')
async def safe_headers(request, call_next):
if request.method not in ('GET','HEAD','OPTIONS'):
origin = request.headers.get('origin')
if request.headers.get('sec-fetch-site') == 'cross-site' or (origin and origin not in ALLOWED_ORIGINS):
audit('cross_origin_rejected', ip=client_ip(request))
return JSONResponse({'detail':'Cross-origin request rejected'}, status_code=403)
response = await call_next(request)
if response.status_code in (401,403,429) or response.status_code>=500:
audit('http_security_event', method=request.method, status=response.status_code, ip=client_ip(request))
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
return response
@app.get('/health')
@app.get('/api/health')
def health():
try:
with db.connect() as c:
c.execute('SELECT 1')
storage.health()
except Exception:
raise HTTPException(503, 'Database or storage unavailable')
return {'status': 'ok', 'environment': ENVIRONMENT,
'storage': 'minio' if ENVIRONMENT == 'local' else 'r2', 'integrations': 'fake'}
@app.get('/api/session')
def session(request: Request, response: Response):
try:
session_id = owner(request)
except HTTPException:
# Per source, not per deployment: keyed on the environment name this was a
# single global bucket, so ~8 new visitors a minute exhausted it site-wide.
rate_limit('guest-sessions', client_ip(request), GUEST_SESSION_LIMIT, 900)
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'))}
@app.post('/api/freight')
def quote_freight(body: Freight):
try:
return freight.quote(body.service, body.postal_code)
except ValueError as exc:
raise HTTPException(422, str(exc))
def upload_row(c, upload_id, session_id, lock=False):
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND owner=%s' +
(' FOR UPDATE' if lock else ''), (upload_id, session_id)).fetchone()
if not row:
raise HTTPException(404, 'Upload not found')
days = 30 if row['complete'] else 1
if row['purged_at'] or row['expires_at'] <= datetime.now(timezone.utc) or row['created_at'] < datetime.now(timezone.utc) - timedelta(days=days):
raise HTTPException(410, 'Upload expired; select the file again')
return row
@app.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')
uid = uuid4()
key = f'originals/{uid}'
rate_limit('upload-start', str(session_id), 60, 900)
with db.connect() as c:
# Serialize reservations across API processes, including changing guest IDs.
c.execute('SELECT pg_advisory_xact_lock(804208)')
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()
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')
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)',
(uid, session_id, body.name, body.size, key, multipart))
return {'id': uid, 'part_bytes': PART_BYTES}
@app.get('/api/uploads/{uid}')
def upload_status(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
row = upload_row(c, uid, session_id)
parts = [] if row['complete'] else storage.parts(row['object_key'], row['multipart_id'])
return {'id': uid, 'complete': row['complete'], 'part_bytes': PART_BYTES,
'scan_state':row['scan_state'], 'scan_reason':row['scan_reason'],
'parts': [p['PartNumber'] for p in parts]}
@app.post('/api/uploads/{uid}/parts/{part}')
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')
size = min(PART_BYTES, row['size']-(part-1)*PART_BYTES)
return {'url': storage.part_url(row['object_key'], row['multipart_id'], part, size)}
@app.post('/api/uploads/{uid}/complete')
def complete_upload(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
row = upload_row(c, uid, session_id, lock=True)
if row['complete']:
return {'id': uid, 'complete': True}
try:
existing_size = storage.size(row['object_key'])
except ClientError as exc:
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
raise
existing_size = None
if existing_size is None:
parts = storage.parts(row['object_key'], row['multipart_id'])
expected = math.ceil(row['size'] / PART_BYTES)
if [p['PartNumber'] for p in parts] != list(range(1, expected+1)) or any(
p['Size'] != min(PART_BYTES, row['size'] - i*PART_BYTES) for i,p in enumerate(parts)):
raise HTTPException(409, 'Parts are missing or their sizes do not match')
storage.complete(row['object_key'], row['multipart_id'], parts)
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,))
return {'id': uid, 'complete': True}
@app.post('/api/quotes')
def create_quote(body: QuoteRequest, session_id=Depends(owner)):
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))
with db.connect() as c:
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)
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()
if row['request_hash'] != digest:
raise HTTPException(409, 'Request key already used for a different cart')
return {'id': row['id'], 'status': 'pending_review'}
def quote_view(c, row):
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (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'],
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
'order': order}
@app.get('/api/quotes/{uid}')
def get_quote(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s', (uid,session_id)).fetchone()
if not row:
raise HTTPException(404, 'Quote not found')
return quote_view(c, row)
def enqueue(c, event_key, provider, payload):
c.execute('INSERT INTO dtf_local.outbox(event_key,provider,payload) VALUES(%s,%s,%s) ON CONFLICT(event_key) DO NOTHING',
(event_key, provider, Jsonb(payload)))
@app.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
@app.get('/api/operator/board')
def board(user=Depends(operator)):
with db.connect() as c:
orders = c.execute('SELECT * FROM dtf_local.orders ORDER BY created_at').fetchall()
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').fetchall()
return {'states': STATES, 'transitions': TRANSITIONS, 'orders': orders,
'quotes': [quote_view(c, q) for q in quotes],
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
@app.post('/api/operator/quotes/{uid}/approve')
def approve(uid: UUID, body: Review, user=Depends(operator)):
with db.connect() as c:
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
@app.post('/api/operator/orders/{uid}/move')
def move(uid: UUID, body: Move, user=Depends(operator)):
with db.connect() as c:
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'Order not found')
if body.version != row['version']:
raise HTTPException(409, 'Order changed; refresh the board')
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')
if body.state == 'cor' and not body.reason.strip():
raise HTTPException(422, 'Correction requires a reason')
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')
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))
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}'})
return changed
@app.get('/api/operator/orders/{uid}/history')
def history(uid: UUID, user=Depends(operator)):
with db.connect() as c:
return c.execute('SELECT * FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (uid,)).fetchall()
@app.get('/api/operator/uploads/{uid}/download')
def download(uid: UUID, user=Depends(operator)):
with db.connect() as c:
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND complete', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'Completed upload not found')
if row['expires_at'] <= datetime.now(timezone.utc):
raise HTTPException(410, 'Artwork retention expired')
require_clean(row)
return {'name':row['name'], 'url':storage.download(row['object_key'],row['name']), 'expires_in':300}
from .customer import install_routes
install_routes(app, operator, storage, begin_upload, upload_status, part_url, complete_upload, upload_row, STATES)

View File

@@ -1,15 +0,0 @@
"""Hash only trusted source inline scripts at image build time for strict CSP."""
import base64
import hashlib
import os
from pathlib import Path
import re
root=Path('/build')
hashes=[]
for html in [root/'dtf-site.html',*(root/'local/static').glob('*.html')]:
for attributes,body in re.findall(r'<script\b([^>]*)>(.*?)</script>',html.read_text(),re.S|re.I):
if not re.search(r'\bsrc\s*=',attributes,re.I):
hashes.append("'sha256-"+base64.b64encode(hashlib.sha256(body.encode()).digest()).decode()+"'")
template=Path(os.environ.get('NGINX_TEMPLATE', root/'local/nginx.conf.template')).read_text()
(root/'default.conf.template').write_text(template.replace('@SCRIPT_HASHES@',' '.join(hashes)))

View File

@@ -1,191 +0,0 @@
"""Customer portal and manual artwork handoff, composed into the local API."""
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4, uuid5, NAMESPACE_URL
from fastapi import Depends, HTTPException, Request, Response
from psycopg.errors import UniqueViolation
from psycopg.types.json import Jsonb
from . import db
from .auth import owner, session_row, new_session, password_hash, password_matches, transfer_guest, throttle, DUMMY_PASSWORD_HASH, audit, client_ip
from .models import Register, Login, UploadStart, ArtworkSubmission
from .scanning import require_clean
def install_routes(app, operator, storage, begin, status, part, complete, upload_row, states):
def current(request):
try: return session_row(request)
except HTTPException: return None
@app.post('/api/account/register')
def register(body: Register, request: Request, response: Response):
email = body.customer.mail.strip().lower()
throttle(email, request)
previous = current(request)
encoded = password_hash(body.password)
identity = uuid4()
profile = body.customer.model_dump()
profile['mail'] = email
try:
with db.connect() as c:
if previous and c.execute('SELECT id FROM dtf_local.accounts WHERE id=%s', (previous['owner'],)).fetchone():
raise HTTPException(409, 'Sign out before registering another account')
c.execute('INSERT INTO dtf_local.accounts(id,email,password_hash,profile) VALUES(%s,%s,%s,%s)', (identity,email,encoded,Jsonb(profile)))
if previous: transfer_guest(c, previous, identity)
new_session(c, response, identity)
except UniqueViolation:
raise HTTPException(409, 'An account already exists; sign in')
audit('account_registered', account=str(identity))
return {'customer': profile}
@app.post('/api/account/login')
def login(body: Login, request: Request, response: Response):
email = body.email.strip().lower()
throttle(email, request)
with db.connect() as c:
account = c.execute('SELECT * FROM dtf_local.accounts WHERE email=%s', (email,)).fetchone()
# Comparable password work even when the email is absent.
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not matches:
audit('customer_login_failed', ip=client_ip(request))
raise HTTPException(401, 'Invalid email or password')
previous = current(request)
with db.connect() as c:
if not stored.startswith('scrypt-v2$'):
c.execute('UPDATE dtf_local.accounts SET password_hash=%s WHERE id=%s', (password_hash(body.password),account['id']))
if previous:
transfer_guest(c, previous, account['id'])
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
new_session(c, response, account['id'])
audit('customer_login_success', account=str(account['id']))
return {'customer': account['profile']}
@app.post('/api/account/logout')
def logout(request: Request, response: Response):
previous = current(request)
if previous:
with db.connect() as c:
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
response.delete_cookie('dtf_session', httponly=True, samesite='strict')
response.headers['Clear-Site-Data'] = '"storage"'
audit('customer_logout')
return {'ok': True}
@app.get('/api/account/me')
def me(identity=Depends(owner)):
with db.connect() as c:
row = c.execute('SELECT profile FROM dtf_local.accounts WHERE id=%s', (identity,)).fetchone()
return {'customer': row['profile'] if row else None}
def owned_order(c, oid, identity, lock=False):
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s AND owner=%s'+(' FOR UPDATE' if lock else ''), (oid,identity)).fetchone()
if not row: raise HTTPException(404, 'Order not found')
return row
def file_rows(c, oid):
return c.execute('''SELECT f.id,f.upload_id,f.item_index,f.kind,f.active,f.note,f.created_at,
u.name,u.size,u.expires_at,(u.expires_at<=now()) AS expired
FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id
WHERE order_id=%s ORDER BY f.created_at''', (oid,)).fetchall()
@app.get('/api/customer/orders')
def orders(identity=Depends(owner)):
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}
@app.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()
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}
def submit_files(c, order, body, identity, kind, actor):
if order['version'] != body.version:
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
if kind == 'final' and order['state'] not in ('rec','tra','cor'):
raise HTTPException(409, 'Final files can only change during artwork review')
if kind == 'correction' and order['state'] != 'cor':
raise HTTPException(409, 'This order is not awaiting artwork correction')
if len({f.upload_id for f in body.files}) != len(body.files):
raise HTTPException(422, 'Each uploaded file must appear once')
count = len(order['snapshot']['items'])
if any(f.item_index >= count for f in body.files):
raise HTTPException(422, 'Invalid order item')
if kind == 'final' and {f.item_index for f in body.files} != set(range(count)):
raise HTTPException(422, 'Final-file set must cover every order item')
original_ids = [UUID(uid) for item in order['snapshot']['items'] for uid in item['uploads']]
first = c.execute('SELECT min(created_at) AS first FROM dtf_local.uploads WHERE id=ANY(%s)', (original_ids,)).fetchone()['first']
expiry = first + timedelta(days=30)
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)
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))
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))
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,))
return {'ok': True, 'version': order['version']+1, 'expires_at': expiry}
@app.post('/api/customer/orders/{oid}/corrections')
def correction(oid: UUID, body: ArtworkSubmission, identity=Depends(owner)):
with db.connect() as c:
order = owned_order(c, oid, identity, lock=True)
return submit_files(c,order,body,identity,'correction','customer')
@app.get('/api/customer/orders/{oid}/files/{fid}/download')
def customer_download(oid: UUID, fid: UUID, identity=Depends(owner)):
with db.connect() as c:
owned_order(c,oid,identity)
row = c.execute('SELECT u.* FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.id=%s AND f.order_id=%s AND f.active', (fid,oid)).fetchone()
if not row: raise HTTPException(404, 'Active file not found')
if row['expires_at'] <= datetime.now(timezone.utc): raise HTTPException(410, 'File expired')
require_clean(row)
return {'url':storage.download(row['object_key'],row['name']), 'name':row['name']}
def operator_identity(user):
return uuid5(NAMESPACE_URL, 'dtf-local-operator:'+user)
@app.post('/api/operator/orders/{oid}/uploads')
def begin_final(oid: UUID, body: UploadStart, user=Depends(operator)):
with db.connect() as c:
row = c.execute('SELECT state FROM dtf_local.orders WHERE id=%s', (oid,)).fetchone()
if not row: raise HTTPException(404, 'Order not found')
if row['state'] not in ('rec','tra','cor'): raise HTTPException(409, 'Order is not in artwork review')
return begin(body, session_id=operator_identity(user))
@app.get('/api/operator/uploads/{uid}')
def final_status(uid: UUID, user=Depends(operator)):
return status(uid,session_id=operator_identity(user))
@app.post('/api/operator/uploads/{uid}/parts/{number}')
def final_part(uid: UUID, number: int, user=Depends(operator)):
return part(uid,number,session_id=operator_identity(user))
@app.post('/api/operator/uploads/{uid}/complete')
def final_complete(uid: UUID, user=Depends(operator)):
return complete(uid,session_id=operator_identity(user))
@app.get('/api/operator/orders/{oid}/files')
def operator_files(oid: UUID, user=Depends(operator)):
with db.connect() as c:
return file_rows(c,oid)
@app.post('/api/operator/orders/{oid}/final-files')
def final_files(oid: UUID, body: ArtworkSubmission, user=Depends(operator)):
with db.connect() as c:
order = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (oid,)).fetchone()
if not order: raise HTTPException(404, 'Order not found')
return submit_files(c,order,body,operator_identity(user),'final',user)

View File

@@ -1,109 +0,0 @@
import re
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
class StrictModel(BaseModel):
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
class Customer(StrictModel):
cnpj: str
zap: str
mail: str = Field(max_length=254)
@field_validator('cnpj')
@classmethod
def cnpj_valid(cls, value):
digits = re.sub(r'\D', '', value)
if len(digits) != 14 or len(set(digits)) == 1 or not digits.isascii():
raise ValueError('Invalid CNPJ')
def check(base, weights):
rem = sum(int(n) * w for n,w in zip(base, weights)) % 11
return 0 if rem < 2 else 11-rem
if check(digits[:12], [5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[12]) or check(digits[:13], [6,5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[13]):
raise ValueError('Invalid CNPJ')
return digits
@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
@field_validator('mail')
@classmethod
def email_valid(cls, value):
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value.strip()):
raise ValueError('Invalid email')
return value.strip()
class Freight(StrictModel):
service: Literal['pickup','mock-standard'] = 'pickup'
postal_code: str = Field(default='', max_length=8)
class UploadStart(StrictModel):
name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$')
size: int = Field(gt=0, strict=True)
@field_validator('name')
@classmethod
def artwork_extension(cls, value):
# Union of existing Site product formats; this is NOT malware/pre-flight validation.
if not re.search(r'\.(png|jpe?g|webp|tiff?|pdf|psd|psb|ai|cdr)$', value, re.I):
raise ValueError('Unsupported artwork file extension')
return value
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)
class QuoteRequest(StrictModel):
request_key: UUID
customer: Customer
items: list[Item] = Field(min_length=1, max_length=30)
freight: Freight
class Review(StrictModel):
items: list[Item] = Field(min_length=1, max_length=30)
class Pay(StrictModel):
quote_id: UUID
class Move(StrictModel):
state: Literal['rec','tra','fil','imp','cor','fin']
version: int = Field(ge=0)
reason: str = Field(default='', max_length=1000)
class Register(StrictModel):
customer: Customer
password: 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)
class OperatorLogin(StrictModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128)
@field_validator('email')
@classmethod
def operator_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 FileReference(StrictModel):
upload_id: UUID
item_index: int = Field(ge=0, strict=True)
class ArtworkSubmission(StrictModel):
version: int = Field(ge=0, strict=True)
files: list[FileReference] = Field(min_length=1, max_length=60)
note: str = Field(min_length=1, max_length=1000)

View File

@@ -1,316 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# ./local/lock_dependencies.sh
#
annotated-doc==0.0.5 \
--hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
--hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
# via fastapi
annotated-types==0.8.0 \
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
# via pydantic
anyio==4.15.1 \
--hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \
--hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94
# via
# httpx
# starlette
boto3==1.38.23 \
--hash=sha256:70ab8364f1f6f0a7e0eaf97f62fbdacf9c1e4cc1de330faf1c146ef9ab01e7d0 \
--hash=sha256:bcf73aca469add09e165b8793be18e7578db8d2604d82505ab13dc2495bad982
# via -r local/requirements.txt
botocore==1.38.46 \
--hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \
--hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b
# via
# boto3
# s3transfer
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via
# httpcore
# httpx
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via uvicorn
fastapi==0.141.1 \
--hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
--hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
# via -r local/requirements.txt
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via
# httpcore
# uvicorn
httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
# via httpx
httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via -r local/requirements.txt
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via
# anyio
# httpx
jmespath==1.1.0 \
--hash=sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d \
--hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64
# via
# boto3
# botocore
psycopg==3.2.9 \
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
# via -r local/requirements.txt
psycopg-binary==3.2.9 \
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
--hash=sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b \
--hash=sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab \
--hash=sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4 \
--hash=sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78 \
--hash=sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4 \
--hash=sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a \
--hash=sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2 \
--hash=sha256:1f1736d5b21f69feefeef8a75e8d3bf1f0a1e17c165a7488c3111af9d6936e91 \
--hash=sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb \
--hash=sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523 \
--hash=sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4 \
--hash=sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351 \
--hash=sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd \
--hash=sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a \
--hash=sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee \
--hash=sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a \
--hash=sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074 \
--hash=sha256:418f52b77b715b42e8ec43ee61ca74abc6765a20db11e8576e7f6586488a266f \
--hash=sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2 \
--hash=sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734 \
--hash=sha256:4df22ec17390ec5ccb38d211fb251d138d37a43344492858cea24de8efa15003 \
--hash=sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093 \
--hash=sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40 \
--hash=sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe \
--hash=sha256:5918c0fab50df764812f3ca287f0d716c5c10bedde93d4da2cefc9d40d03f3aa \
--hash=sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2 \
--hash=sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7 \
--hash=sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724 \
--hash=sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0 \
--hash=sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186 \
--hash=sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5 \
--hash=sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b \
--hash=sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6 \
--hash=sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9 \
--hash=sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786 \
--hash=sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7 \
--hash=sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21 \
--hash=sha256:7b617b81f08ad8def5edd110de44fd6d326f969240cc940c6f6b3ef21fe9c59f \
--hash=sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8 \
--hash=sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87 \
--hash=sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07 \
--hash=sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff \
--hash=sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e \
--hash=sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f \
--hash=sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891 \
--hash=sha256:a4d76e28df27ce25dc19583407f5c6c6c2ba33b443329331ab29b6ef94c8736d \
--hash=sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04 \
--hash=sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d \
--hash=sha256:b2d7a6646d41228e9049978be1f3f838b557a1bde500b919906d54c4390f5086 \
--hash=sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748 \
--hash=sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695 \
--hash=sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a \
--hash=sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e \
--hash=sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d \
--hash=sha256:cf789be42aea5752ee396d58de0538d5fcb76795c85fb03ab23620293fb81b6f \
--hash=sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc \
--hash=sha256:e0f05b9dafa5670a7503abc715af081dbbb176a8e6770de77bccaeb9024206c5 \
--hash=sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8 \
--hash=sha256:eac3a6e926421e976c1c2653624e1294f162dc67ac55f9addbe8f7b8d08ce603 \
--hash=sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b \
--hash=sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95 \
--hash=sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1 \
--hash=sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb
# via psycopg
pydantic==2.13.5 \
--hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \
--hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08
# via fastapi
pydantic-core==2.46.5 \
--hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \
--hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \
--hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \
--hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \
--hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \
--hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \
--hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \
--hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \
--hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \
--hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \
--hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \
--hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \
--hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \
--hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \
--hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \
--hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \
--hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \
--hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \
--hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \
--hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \
--hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \
--hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \
--hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \
--hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \
--hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \
--hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \
--hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \
--hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \
--hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \
--hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \
--hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \
--hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \
--hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \
--hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \
--hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \
--hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \
--hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \
--hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \
--hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \
--hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \
--hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \
--hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \
--hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \
--hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \
--hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \
--hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \
--hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \
--hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \
--hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \
--hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \
--hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \
--hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \
--hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \
--hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \
--hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \
--hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \
--hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \
--hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \
--hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \
--hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \
--hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \
--hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \
--hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \
--hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \
--hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \
--hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \
--hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \
--hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \
--hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \
--hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \
--hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \
--hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \
--hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \
--hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \
--hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \
--hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \
--hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \
--hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \
--hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \
--hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \
--hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \
--hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \
--hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \
--hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \
--hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \
--hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \
--hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \
--hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \
--hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \
--hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \
--hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \
--hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \
--hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \
--hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \
--hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \
--hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \
--hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \
--hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \
--hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \
--hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \
--hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \
--hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \
--hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \
--hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \
--hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \
--hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \
--hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \
--hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \
--hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \
--hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \
--hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \
--hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \
--hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \
--hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \
--hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \
--hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \
--hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \
--hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \
--hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \
--hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9
# via pydantic
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via botocore
s3transfer==0.13.1 \
--hash=sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724 \
--hash=sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf
# via boto3
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
starlette==1.6.0 \
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
# via
# -r local/requirements.txt
# fastapi
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
# via
# anyio
# fastapi
# psycopg
# pydantic
# pydantic-core
# starlette
# typing-inspection
typing-inspection==0.4.4 \
--hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \
--hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
# via
# fastapi
# pydantic
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via botocore
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:
pip==26.2.1 \
--hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \
--hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f
# via -r local/requirements.txt

View File

@@ -1,81 +0,0 @@
-- Separate schema: never imports/migrates the historical schema.sql or SQLite.
CREATE SCHEMA IF NOT EXISTS dtf_local;
CREATE TABLE IF NOT EXISTS dtf_local.uploads (
id uuid PRIMARY KEY, owner uuid NOT NULL, name text NOT NULL,
size bigint NOT NULL, object_key text UNIQUE NOT NULL, multipart_id text NOT NULL,
complete boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.quotes (
id uuid PRIMARY KEY, owner uuid NOT NULL, request_key uuid NOT NULL,
request_hash text NOT NULL, draft jsonb NOT NULL, approved jsonb,
reviewed_by text, approved_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(owner, request_key)
);
CREATE TABLE IF NOT EXISTS dtf_local.orders (
id uuid PRIMARY KEY, number bigint GENERATED ALWAYS AS IDENTITY UNIQUE,
quote_id uuid NOT NULL UNIQUE REFERENCES dtf_local.quotes(id), owner uuid NOT NULL,
snapshot jsonb NOT NULL, payment jsonb NOT NULL, state text NOT NULL DEFAULT 'rec',
version integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.movements (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
from_state text NOT NULL, to_state text NOT NULL, operator text NOT NULL,
reason text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_key text NOT NULL UNIQUE,
provider text NOT NULL, payload jsonb NOT NULL, attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(), delivered_at timestamptz,
last_error text, receipt jsonb
);
CREATE TABLE IF NOT EXISTS dtf_local.accounts (
id uuid PRIMARY KEY, email text UNIQUE NOT NULL, password_hash text NOT NULL,
profile jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.sessions (
id uuid PRIMARY KEY, owner uuid NOT NULL,
expires_at timestamptz NOT NULL DEFAULT now() + interval '7 days'
);
CREATE TABLE IF NOT EXISTS dtf_local.migrations (name text PRIMARY KEY);
-- Preserve pre-account guest sessions once, without resurrecting logged-out sessions.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM dtf_local.migrations WHERE name='customer-sessions-v1') THEN
INSERT INTO dtf_local.sessions(id,owner)
SELECT owner,owner FROM (
SELECT owner FROM dtf_local.orders UNION SELECT owner FROM dtf_local.quotes
UNION SELECT owner FROM dtf_local.uploads
) legacy ON CONFLICT DO NOTHING;
INSERT INTO dtf_local.migrations VALUES('customer-sessions-v1');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS dtf_local.login_attempts (
key text PRIMARY KEY, attempts integer NOT NULL DEFAULT 0,
started_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.operator_sessions (
token_hash text PRIMARY KEY, username text NOT NULL,
expires_at timestamptz NOT NULL DEFAULT now() + interval '8 hours'
);
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()
);
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS expires_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS purged_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_state text NOT NULL DEFAULT 'pending';
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_reason text;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scanned_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_after timestamptz NOT NULL DEFAULT now();
UPDATE dtf_local.uploads SET expires_at=created_at + interval '30 days' WHERE expires_at IS NULL;
ALTER TABLE dtf_local.uploads ALTER COLUMN expires_at SET DEFAULT now() + interval '30 days';
CREATE TABLE IF NOT EXISTS dtf_local.order_files (
id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
upload_id uuid NOT NULL REFERENCES dtf_local.uploads(id), item_index integer NOT NULL,
kind text NOT NULL CHECK(kind IN ('final','correction')), active boolean NOT NULL DEFAULT true,
note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(order_id,upload_id,kind)
);

View File

@@ -1,121 +0,0 @@
/* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */
(() => {
const status = document.getElementById('checkoutStatus');
const actions = document.getElementById('checkoutActions');
let busy = false;
let draftId = localStorage.getItem('dtf-quote');
let requestKey = localStorage.getItem('dtf-request-key');
let requestBody = localStorage.getItem('dtf-request-body');
const api = async (path, body) => {
const response = await fetch('/api'+path, {
credentials: 'same-origin', headers: {'Content-Type':'application/json'},
...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)})
});
const text = await response.text();
let data;
try { data = text ? JSON.parse(text) : {}; }
catch (_) { throw new Error(response.ok ? 'Resposta inválida do serviço.' : 'O serviço está indisponível. Tente novamente em instantes.'); }
if (!response.ok) { const error=new Error(typeof data.detail === 'string' ? data.detail : 'Confira os dados do pedido ('+response.status+').');error.status=response.status;throw error; }
return data;
};
const ready = api('/session');
window.dtfSessionReady=ready;
window.dtfApi=api;
ready.catch(error => { status.textContent = error.message; });
function message(text) { status.textContent = text; }
function button(label, handler) {
const el = document.createElement('button');
el.textContent = label;
el.style.cssText = 'margin:8px 8px 0 0;padding:8px 14px;cursor:pointer';
el.onclick = handler;
actions.append(el);
return el;
}
async function upload(file) {
const session=await ready;
return window.dtfUpload(file,{api,progress:message,scope:session.cart_scope});
}
window.dtfFreight = async () => {
const cep = entrega.cep;
try {
const result = await api('/freight',{service:'mock-standard',postal_code:cep});
if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
entrega.valor = result.total_cents/100;
entrega.cotado = true;
$('cepMsg').textContent = 'Frete estimado: '+rs(entrega.valor)+'.';
pintaEntrega();
} catch(error) { $('cepMsg').textContent = error.message; }
};
window.dtfCheckout = async () => {
if (busy) return;
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
if (!clienteOk() || !entrega.cotado) return;
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
if (!cart.length) return message('Adicione um item ao pedido.');
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=[];
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});
}
const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}};
const serialized = JSON.stringify(content);
if (!requestKey || serialized !== requestBody) {
requestKey = crypto.randomUUID(); requestBody = serialized;
localStorage.setItem('dtf-request-key',requestKey);
localStorage.setItem('dtf-request-body',requestBody);
}
const quote = await api('/quotes',{request_key:requestKey,...content});
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
await refresh();
status.scrollIntoView({behavior:'smooth',block:'nearest'});
} catch(error) { message(error.message); }
finally { busy=false; pintaEntrega(); }
};
async function refresh() {
if (!draftId) return;
try {
await ready;
const quote=await api('/quotes/'+draftId);
actions.replaceChildren();
if (quote.status==='pending_review') {
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
} else if (quote.status==='approved') {
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
if ((await ready).environment !== 'local') {
message('Cotação revisada. O pagamento online ainda não está disponível.');
return;
}
button('Criar pedido de teste',async event=>{
event.target.disabled=true;
try {
const order=await api('/orders/dev-paid',{quote_id:draftId});
pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.();
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();});
}
} catch(error) {
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();});
}
}
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
refresh();
})();

View File

@@ -1,107 +0,0 @@
/* Reuses the prototype palette, column names and drag/drop interaction; no machine controls. */
const $ = id=>document.getElementById(id);
// Remove credentials saved by older local builds. Only HttpOnly sessions now.
sessionStorage.removeItem('dtf-operator');
let board;
const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(className)e.className=className;return e;}
function action(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('status').textContent=e.message;}finally{b.disabled=false;}};return b;}
async function api(path,body){
const r=await fetch('/api/operator'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});
const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos ('+r.status+').');error.status=r.status;throw error;}return d;
}
function files(container,items){
for(const uid of [...new Set(items.flatMap(i=>i.uploads))])container.append(action('Baixar original '+uid.slice(0,6),async()=>{
const result=await api('/uploads/'+uid+'/download');
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
}));
}
async function load(){
try{
board=await api('/board');$('login').hidden=true;
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString();
render();
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
}
function render(){
$('reviews').replaceChildren();
if(board.quotes.length)$('reviews').append(node('h2','Cotações · conferência comercial'));
for(const quote of board.quotes){
const card=node('article',undefined,'review');
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
if(quote.status!=='pending_review'){
card.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':'Aprovada: '+money(quote.approved.total_cents)+'. Volte ao Site, atualize o pedido e confirme o pagamento local.'));
}else{
const form=node('form');
const edits=quote.draft.items.map((item,index)=>{
const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' '));
const metres=node('input');metres.type='number';metres.min='0.001';metres.max='12000';metres.step='any';metres.value=item.metres;metres.required=true;
const grade=node('input');grade.type='number';grade.min='0';grade.max='100';grade.step='1';grade.value=item.grade;grade.required=true;
const ml=node('label','Metros conferidos');ml.append(metres);const gl=node('label','Nota conferida');gl.append(grade);row.append(ml,gl);form.append(row);
return ()=>({...item,metres:metres.value,grade:Number(grade.value)});
});
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi os arquivos, a metragem total (incluindo repetições/montagem) e a nota.');label.prepend(check);form.append(label);
const submit=node('button','Aprovar cotação');submit.type='submit';form.append(submit);
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;try{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
card.append(form);
}
const downloads=node('div',undefined,'actions');files(downloads,quote.draft.items);card.append(downloads);$('reviews').append(card);
}
$('kan').replaceChildren();
const colors=['#00b8da','#e0357c','#efb700','#edebe6','#e0642a','#48b072'];
Object.entries(board.states).forEach(([state,title],index)=>{
const column=node('section',undefined,'col');column.dataset.state=state;column.style.setProperty('--cc',colors[index]);
const orders=board.orders.filter(o=>o.state===state);column.append(node('h2',title+' · '+orders.length));
for(const order of orders){
const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id;
card.append(node('b','#'+order.number+' · Pago local'),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
for(const item of order.snapshot.items)card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
const actions=node('div',undefined,'actions');files(actions,order.snapshot.items);
const artwork=node('div');
actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork)));
actions.append(action('Histórico',async()=>{const rows=await api('/orders/'+order.id+'/history');alert(rows.map(r=>board.states[r.from_state]+' → '+board.states[r.to_state]+' · '+r.operator+(r.reason?' · '+r.reason:'')).join('\n')||'Pedido recebido.');}));
for(const next of board.transitions[state])actions.append(action('→ '+board.states[next],()=>move(order,next)));
card.append(actions,artwork);card.ondragstart=e=>e.dataTransfer.setData('text/plain',order.id);column.append(card);
}
column.ondragover=e=>{e.preventDefault();column.classList.add('alvo');};column.ondragleave=()=>column.classList.remove('alvo');
column.ondrop=async e=>{e.preventDefault();column.classList.remove('alvo');const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));if(order)try{await move(order,state);}catch(error){$('status').textContent=error.message;}};
$('kan').append(column);
});
$('events').textContent=board.events.map(e=>e.provider+' · '+e.payload.event+' · pedido #'+e.payload.number+' · '+(e.delivered_at?'registrado localmente':'pendente')+' · tentativas '+e.attempts).join('\n')||'Nenhum evento.';
}
async function move(order,state){
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
}
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}};
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();};
$('refresh').onclick=load;
load();
async function artworkPanel(order,container){
container.replaceChildren();
const revisions=await api('/orders/'+order.id+'/files');
for(const file of revisions){
const row=node('p',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':file.active?'atual':'substituído'),'meta');
row.append(node('p',file.note));
if(!file.expired)row.append(action('Baixar '+file.name,async()=>{const result=await api('/uploads/'+file.upload_id+'/download');const link=node('a');link.href=result.url;link.download=result.name;link.referrerPolicy='no-referrer';link.click();}));
container.append(row);
}
if(!['rec','tra','cor'].includes(order.state))return;
const form=node('form');
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.'));
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';label.append(input);form.append(label);return input;});
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note);
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label);
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit);
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
const refs=[];
for(const [index,input] of inputs.entries())for(const file of input.files){
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text});
refs.push({item_index:index,upload_id:uid});
}
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
await load();
}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
container.append(form);
}

1
ops/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Operational commands: backup, readiness, audit, security summary."""

View File

@@ -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__':

Some files were not shown because too many files have changed in this diff Show More