first commit
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
.agents
|
||||
.codex
|
||||
.env
|
||||
.venv
|
||||
**/__pycache__
|
||||
node_modules
|
||||
output
|
||||
tmp
|
||||
*.pdf
|
||||
agente
|
||||
backups
|
||||
staging/staging.env
|
||||
deploy/portainer.env
|
||||
portal
|
||||
kanban
|
||||
32
.env.example
Normal file
32
.env.example
Normal file
@@ -0,0 +1,32 @@
|
||||
# LOCAL DEVELOPMENT ONLY. These are public disposable development values.
|
||||
COMPOSE_PROJECT_NAME=dtf-cloud
|
||||
SITE_PORT=8080
|
||||
KANBAN_PORT=8081
|
||||
API_PORT=8000
|
||||
POSTGRES_DB=dtf_local
|
||||
POSTGRES_USER=dtf_local
|
||||
POSTGRES_PASSWORD=local-database-only
|
||||
APP_DB_USER=dtf_app
|
||||
APP_DB_PASSWORD=local-app-database-only
|
||||
MINIO_ROOT_USER=dtf_local
|
||||
MINIO_ROOT_PASSWORD=local-storage-only
|
||||
S3_APP_USER=dtf_app
|
||||
S3_APP_PASSWORD=local-app-storage-only
|
||||
S3_BUCKET=dtf-local-artwork
|
||||
S3_PUBLIC_ENDPOINT=http://localhost:9000
|
||||
OPERATOR_USER=operator
|
||||
OPERATOR_PASSWORD=local-operator-only
|
||||
APP_ENV=local
|
||||
PAYMENT_ADAPTER=fake
|
||||
FREIGHT_ADAPTER=fake
|
||||
TINY_ADAPTER=fake
|
||||
WHATSAPP_ADAPTER=fake
|
||||
STORAGE_ADAPTER=s3-local
|
||||
MOCK_FREIGHT_CENTS=1500
|
||||
MAX_UPLOAD_BYTES=5368709120
|
||||
UPLOAD_PART_BYTES=8388608
|
||||
STORAGE_QUOTA_BYTES=53687091200
|
||||
OWNER_UPLOAD_QUOTA_BYTES=10737418240
|
||||
MAX_PENDING_UPLOADS=10
|
||||
# Files above 128 MiB remain blocked (ClamAV config must agree with this limit).
|
||||
SCAN_MAX_BYTES=134217728
|
||||
27
.env.exemplo
Normal file
27
.env.exemplo
Normal file
@@ -0,0 +1,27 @@
|
||||
# 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
|
||||
109
.gitea/workflows/deploy.yml
Normal file
109
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,109 @@
|
||||
name: Validate, publish and deploy
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Validate source and deployment definitions
|
||||
run: |
|
||||
python3 -m py_compile local/*.py deploy/*.py
|
||||
python3 -m unittest local.test_dependency_lock local.test_staging_readiness deploy.test_production_preflight local.test_pricing -v
|
||||
sh -n local/lock_dependencies.sh
|
||||
docker compose config --quiet
|
||||
docker compose -f compose.staging.yaml config --quiet
|
||||
set -a
|
||||
. deploy/portainer.env.example
|
||||
set +a
|
||||
docker stack config --compose-file deploy/stack.yaml >/dev/null
|
||||
|
||||
publish-and-deploy:
|
||||
needs: validate
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: dtf-release-${{ gitea.run_number }}
|
||||
REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
|
||||
REGISTRY_OWNER: ${{ vars.REGISTRY_OWNER }}
|
||||
PYTHON_BASE_IMAGE: ${{ vars.PYTHON_BASE_IMAGE }}
|
||||
NGINX_BASE_IMAGE: ${{ vars.NGINX_BASE_IMAGE }}
|
||||
TRIVY_IMAGE: ${{ vars.TRIVY_IMAGE }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Require production-capable application source
|
||||
run: python3 deploy/production_preflight.py --source-only
|
||||
- name: Validate immutable build inputs
|
||||
run: |
|
||||
for value in "$PYTHON_BASE_IMAGE" "$NGINX_BASE_IMAGE" "$TRIVY_IMAGE"; do
|
||||
echo "$value" | grep -Eq '^[a-z0-9][a-z0-9._:/-]*@sha256:[0-9a-f]{64}$'
|
||||
echo "$value" | grep -Ev '@sha256:0{64}$' >/dev/null
|
||||
done
|
||||
case "$REGISTRY_HOST/$REGISTRY_OWNER" in *[A-Z]*|*' '*|'/'*) exit 2;; esac
|
||||
- name: Run complete isolated regression suite
|
||||
run: |
|
||||
docker compose up --build -d --wait
|
||||
python3 -m local.security_test
|
||||
python3 -m local.scanning_test
|
||||
python3 -m local.smoke_test
|
||||
python3 -m local.workflow_test
|
||||
docker compose exec -T api python -m local.runtime_security_test
|
||||
docker compose exec -T api python -m local.retention_test
|
||||
node local/browser_test.mjs
|
||||
python3 -m local.backup create-and-verify
|
||||
- name: Build production images
|
||||
run: |
|
||||
api_sha="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-api:${{ gitea.sha }}"
|
||||
api_latest="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-api:latest"
|
||||
web_sha="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-web:${{ gitea.sha }}"
|
||||
web_latest="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-web:latest"
|
||||
docker build --pull --file deploy/Dockerfile.api \
|
||||
--build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
|
||||
--build-arg VCS_REF="${{ gitea.sha }}" \
|
||||
--tag "$api_sha" --tag "$api_latest" .
|
||||
docker build --pull --file deploy/Dockerfile.web \
|
||||
--build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
|
||||
--build-arg NGINX_BASE_IMAGE="$NGINX_BASE_IMAGE" \
|
||||
--build-arg VCS_REF="${{ gitea.sha }}" \
|
||||
--tag "$web_sha" --tag "$web_latest" .
|
||||
- name: Block secret, configuration and image findings
|
||||
run: |
|
||||
api="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-api:${{ gitea.sha }}"
|
||||
web="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-web:${{ gitea.sha }}"
|
||||
docker run --rm --volume "$PWD:/src:ro" --workdir /src "$TRIVY_IMAGE" \
|
||||
fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL .
|
||||
docker run --rm --volume "$PWD:/src:ro" --workdir /src "$TRIVY_IMAGE" \
|
||||
config --exit-code 1 --severity HIGH,CRITICAL deploy
|
||||
docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock:ro "$TRIVY_IMAGE" \
|
||||
image --exit-code 1 --scanners vuln --severity HIGH,CRITICAL "$api"
|
||||
docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock:ro "$TRIVY_IMAGE" \
|
||||
image --exit-code 1 --scanners vuln --severity HIGH,CRITICAL "$web"
|
||||
- name: Publish latest and rollback tags
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
trap 'docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT
|
||||
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin
|
||||
docker push "$REGISTRY_HOST/$REGISTRY_OWNER/dtf-api:${{ gitea.sha }}"
|
||||
docker push "$REGISTRY_HOST/$REGISTRY_OWNER/dtf-api:latest"
|
||||
docker push "$REGISTRY_HOST/$REGISTRY_OWNER/dtf-web:${{ gitea.sha }}"
|
||||
docker push "$REGISTRY_HOST/$REGISTRY_OWNER/dtf-web:latest"
|
||||
- name: Trigger the Portainer stack webhook
|
||||
env:
|
||||
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
|
||||
run: |
|
||||
test -n "$PORTAINER_WEBHOOK"
|
||||
curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK"
|
||||
- name: Stop isolated test stack
|
||||
if: always()
|
||||
run: docker compose down --volumes --remove-orphans
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
backups/
|
||||
staging/staging.env
|
||||
deploy/portainer.env
|
||||
328
API.md
Normal file
328
API.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# API — contratos
|
||||
|
||||
> Historical prototype contracts only. The active local API is documented at
|
||||
> [localhost:8000/docs](http://localhost:8000/docs) and in [LOCAL_SETUP.md](LOCAL_SETUP.md).
|
||||
> `CONTEXT.md` overrides the legacy endpoints and workflows below.
|
||||
|
||||
> Todos os endpoints do módulo DTF, com requisição e resposta.
|
||||
> Base: `portal/main.py` e `kanban/main.py`.
|
||||
|
||||
---
|
||||
|
||||
## Autenticação
|
||||
|
||||
| Quem chama | Como se autentica |
|
||||
|---|---|
|
||||
| Tiny (webhook) | header `x-token` com `WEBHOOK_TOKEN` |
|
||||
| Cliente | token no path da URL — sem login |
|
||||
| Agente | header `x-token` com `AGENTE_TOKEN` |
|
||||
| Kanban (aba do PCP) | sessão do PCP — **rede interna, sem porta para fora** |
|
||||
|
||||
---
|
||||
|
||||
# PORTAL · na nuvem
|
||||
|
||||
## `POST /webhook/tiny`
|
||||
|
||||
Chamado quando nasce um pedido de DTF. **É o gatilho de tudo.**
|
||||
|
||||
```json
|
||||
{ "numero": "48213", "cliente": "Estamparia Vitória",
|
||||
"telefone": "5516999998888", "metros": 2.4, "cliente_novo": false }
|
||||
```
|
||||
|
||||
**200**
|
||||
```json
|
||||
{ "ok": true, "link": "https://arte.dropstaratacado.com.br/arte/a7f3k9..." }
|
||||
```
|
||||
|
||||
Cria o pedido, gera token de 7 dias e enfileira o WhatsApp com o link.
|
||||
Se o número já existir, devolve `{"ok":true,"ja_existia":true}` sem duplicar.
|
||||
|
||||
**Latência importa.** O link só sai depois que o pedido existe no Tiny — se a
|
||||
integração VNDA → Tiny demorar, o cliente espera. Medir antes de subir.
|
||||
|
||||
---
|
||||
|
||||
## `GET /arte/{token}`
|
||||
|
||||
Página de upload. HTML. O token já sabe o pedido — **o cliente não digita nada.**
|
||||
|
||||
## `GET /api/arte/{token}`
|
||||
|
||||
```json
|
||||
{ "pedido": "48213", "cliente": "Estamparia Vitória", "metros": 2.4,
|
||||
"area_util_cm": 97, "largura_max_cm": 57,
|
||||
"limite_arquivo_m": 20, "max_gb": 5, "max_arquivos": 10 }
|
||||
```
|
||||
|
||||
**410** se o token expirou.
|
||||
|
||||
## `POST /api/arte/{token}/url`
|
||||
|
||||
```json
|
||||
{ "nome": "estampa.png", "bytes": 84000000 }
|
||||
```
|
||||
```json
|
||||
{ "url": "https://r2.../48213/ab12_estampa.png?X-Amz-...",
|
||||
"chave": "48213/ab12_estampa.png", "expira_em": 3600 }
|
||||
```
|
||||
|
||||
**Upload direto para o storage.** Arquivo de 5 GB passando pelo VPS derrubaria
|
||||
o processo. Em partes, para arquivos grandes.
|
||||
|
||||
## `POST /api/arte/{token}/pronto`
|
||||
|
||||
```json
|
||||
{ "chave": "48213/ab12_estampa.png", "repeticoes": 20 }
|
||||
```
|
||||
```json
|
||||
{ "arte_id": 991, "status": "processando" }
|
||||
```
|
||||
|
||||
Dispara o pré-flight em background. O cliente acompanha por polling em
|
||||
`GET /api/arte/{token}/status`.
|
||||
|
||||
## `GET /api/arte/{token}/status`
|
||||
|
||||
**Aprovada:**
|
||||
```json
|
||||
{ "status": "aprovada", "dpi_efetivo": 300, "qualidade_pct": 100,
|
||||
"metros_totais": 48.0, "minutos_maquina": 144,
|
||||
"partes": [{"ordem":1,"metros":20,"nome":"48213_p1.png"},
|
||||
{"ordem":2,"metros":20,"nome":"48213_p2.png"},
|
||||
{"ordem":3,"metros":8,"nome":"48213_p3_carimbado.png"}],
|
||||
"avisos": [], "previsao_saida": "2026-09-02T18:00:00-03:00" }
|
||||
```
|
||||
|
||||
**Recusada:**
|
||||
```json
|
||||
{ "status": "recusada",
|
||||
"motivo": "A resolução real da arte é de 72 DPI no tamanho que você comprou. Precisamos de pelo menos 150 DPI — o ideal é 300. Reenvie em maior resolução." }
|
||||
```
|
||||
|
||||
**O relógio do prazo só começa quando `status = aprovada`.** Arquivo ruim
|
||||
enviado às 17h não faz as horas correrem contra a casa.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/artes` · o agente busca
|
||||
|
||||
Header `x-token`. Devolve o que está aprovado, com vírus liberado e ainda não baixado.
|
||||
|
||||
```json
|
||||
[{ "arte_id": 991, "pedido": "48213", "cliente": "Estamparia Vitória",
|
||||
"metros": 48.0, "minutos_maquina": 144, "conferir_manual": false,
|
||||
"partes": [{"ordem":1,"metros":20,"nome":"48213_p1.png",
|
||||
"url":"https://r2.../...","sha256":"a3f9..."}] }]
|
||||
```
|
||||
|
||||
## `POST /api/artes/{id}/baixada` · confirma o download
|
||||
## `POST /api/agente/heartbeat` · a cada 5 min
|
||||
|
||||
**Sem sinal por 15 minutos, alertar o TI.** Serviço silencioso parado é pior que
|
||||
erro barulhento: ninguém percebe até o cliente cobrar.
|
||||
|
||||
---
|
||||
|
||||
## `GET /cliente/{token_cliente}` · minhas artes
|
||||
|
||||
Link permanente do cliente, não do pedido. Lista as artes tratadas dos últimos
|
||||
12 meses.
|
||||
|
||||
## `POST /cliente/{token_cliente}/reimprimir`
|
||||
|
||||
```json
|
||||
{ "arte_id": 812, "metros": 20 }
|
||||
```
|
||||
|
||||
Abre pedido novo no Tiny com a arte já pronta. **Recompra sem atrito.**
|
||||
|
||||
---
|
||||
|
||||
# KANBAN · rede interna, aba do PCP
|
||||
|
||||
## `GET /api/quadro`
|
||||
|
||||
```json
|
||||
{ "colunas": [{"id":"rec","nome":"Arte recebida"}, ...],
|
||||
"cards": {
|
||||
"fil": [{ "arte_id": 991, "pedido": "48213", "cliente": "Estamparia Vitória",
|
||||
"metros": 48.0, "minutos_maquina": 144, "partes": 3,
|
||||
"maquina": null, "desde": "2026-09-02T14:02:00",
|
||||
"parado_seg": 4320, "conferir_manual": false }]
|
||||
},
|
||||
"maquinas": [{ "n": 1, "ocupada": true, "pedido": "48190",
|
||||
"metros": 9.0, "rodando_seg": 720 },
|
||||
{ "n": 3, "ocupada": false }] }
|
||||
```
|
||||
|
||||
`maquinas` é o que pinta o **círculo vermelho**. Com seis máquinas vendo o mesmo
|
||||
quadro, é o que evita dois operadores no mesmo arquivo.
|
||||
|
||||
---
|
||||
|
||||
## `POST /api/puxar`
|
||||
|
||||
```json
|
||||
{ "maquina": 4, "usuario": "alexandre" }
|
||||
```
|
||||
```json
|
||||
{ "maquina": "Maq 4", "pedido": "48197", "metros": 18.0,
|
||||
"minutos": 60, "reserva_expira_em": 3 }
|
||||
```
|
||||
|
||||
**Um pedido por vez** e **reserva de 3 minutos** — ajustes pedidos pela sala.
|
||||
Pega sempre o mais antigo da fila. Se não entrar em `imp` em 3 min, volta.
|
||||
|
||||
**409** se a máquina já estiver ocupada. **404** se a fila estiver vazia.
|
||||
|
||||
## `POST /api/devolver`
|
||||
|
||||
```json
|
||||
{ "arte_id": 991, "usuario": "thales", "motivo": "travou no meio" }
|
||||
```
|
||||
|
||||
Volta ao **topo** da fila, não ao fim — o pedido já esperou uma vez.
|
||||
|
||||
## `POST /api/mover`
|
||||
|
||||
```json
|
||||
{ "arte_id": 991, "para": "fin", "usuario": "poliana", "maquina": 4 }
|
||||
```
|
||||
|
||||
Grava o movimento **antes** de qualquer integração externa. Se o Tiny estiver
|
||||
fora, o job fica na fila e tenta de novo em 1, 5 e 30 min — **mas a medição de
|
||||
tempo já está salva.**
|
||||
|
||||
Move o arquivo entre as pastas e enfileira marcador e WhatsApp quando a coluna
|
||||
pedir. Ao ir para `apc`, pergunta o motivo da prova de cor.
|
||||
|
||||
## `PATCH /api/card/{arte_id}`
|
||||
|
||||
```json
|
||||
{ "minutos_maquina": 90 }
|
||||
```
|
||||
|
||||
Ajuste da estimativa. **O sistema sugere por `metros/20*60`; quem trata a arte
|
||||
corrige só quando foge do normal.** É esse número que soma a fila contra a
|
||||
capacidade do dia.
|
||||
|
||||
## `GET /p/{pedido}`
|
||||
|
||||
O QR do carimbo aponta para cá. Redireciona para o card. **A revisão bipa e cai
|
||||
na tela do pedido** em vez de procurar na lista.
|
||||
|
||||
---
|
||||
|
||||
## Retrabalho
|
||||
|
||||
### `POST /api/retrabalho`
|
||||
|
||||
```json
|
||||
{ "pedido_origem": "48184", "metros": 2.0, "causa": "impressao",
|
||||
"aberto_por": "mayana", "evidencia": "print-whatsapp.jpg" }
|
||||
```
|
||||
```json
|
||||
{ "id": 44, "alcada": "sala", "vez": 1, "conta_na_meta": true }
|
||||
```
|
||||
|
||||
**A Mayana abre e classifica** — conhece o cliente e a reclamação.
|
||||
**A causa fica travada.** Quem discorda contesta, não altera.
|
||||
|
||||
### `POST /api/retrabalho/{id}/autorizar`
|
||||
|
||||
```json
|
||||
{ "usuario": "thales" }
|
||||
```
|
||||
|
||||
**Thales ou Alexandre autorizam**, porque o retrabalho da casa entra na meta
|
||||
deles. Eles confirmaram achar justo.
|
||||
|
||||
**403** se a alçada exigir financeiro ou diretoria.
|
||||
|
||||
### `POST /api/retrabalho/{id}/contestar`
|
||||
|
||||
```json
|
||||
{ "usuario": "alexandre", "texto": "não foi impressão, a arte veio em CMYK" }
|
||||
```
|
||||
|
||||
Registra a discordância sem mudar a causa. Fica com quem pediu e quem decidiu.
|
||||
|
||||
---
|
||||
|
||||
## Relatórios
|
||||
|
||||
### `GET /api/relatorio/etapas?dias=7`
|
||||
|
||||
```json
|
||||
[{ "coluna": "tra", "nome": "Arte tratada", "movimentos": 84,
|
||||
"media_min": 94, "pior_min": 380 }]
|
||||
```
|
||||
|
||||
**Responde a pergunta que decide o terceiro turno:** quanto do tempo é fila e
|
||||
quanto é trabalho. Se a arte demora 6h e a máquina imprime em 40 min, rodar 24h
|
||||
só faz a fila esperar de madrugada.
|
||||
|
||||
### `GET /api/relatorio/impressao?dias=7`
|
||||
|
||||
```json
|
||||
[{ "maquina": "Maq 1", "pedidos": 42, "media_min": 34,
|
||||
"metros": 310.5, "m_por_hora": 20.4 }]
|
||||
```
|
||||
|
||||
**`m_por_hora` valida ou derruba os 20 m/h.** Todo o cálculo de capacidade —
|
||||
60.480 metros/mês, R$ 148 mil de ganho — depende desse número. Se for 14, a
|
||||
conta muda inteira.
|
||||
|
||||
### `GET /api/relatorio/aproveitamento?dias=30`
|
||||
|
||||
```json
|
||||
{ "metros_faturados": 11605, "metros_de_filme": 12693,
|
||||
"aproveitamento_pct": 91.4, "valor_do_ponto_mes": 980,
|
||||
"fonte": "transferências para o depósito Sala DTF no Tiny" }
|
||||
```
|
||||
|
||||
**Não exige apontamento novo.** O consumo já é registrado quando o insumo é
|
||||
transferido para o depósito de impressão — a Altus faz isso porque também
|
||||
revende insumo.
|
||||
|
||||
### `GET /api/relatorio/retrabalho?dias=30`
|
||||
|
||||
```json
|
||||
{ "por_causa": [{ "causa": "impressao", "descricao": "Falha de impressão",
|
||||
"responsavel": "Thales e Alexandre", "pedidos": 9,
|
||||
"metros": 24.5, "pct": 1.1, "meta": 0.4, "bate": false }],
|
||||
"total_casa_pct": 2.9, "meta_casa_pct": 1.6 }
|
||||
```
|
||||
|
||||
**⚠ A meta ainda não está decidida.** A proposta era 0,4% por causa; a sala
|
||||
respondeu que "meta geral seria melhor". `META_POR_CAUSA` está isolado no código
|
||||
para trocar sem mexer no resto.
|
||||
|
||||
---
|
||||
|
||||
## Códigos de erro
|
||||
|
||||
| Código | Quando |
|
||||
|---|---|
|
||||
| 400 | payload inválido, coluna inexistente, mais de 10 arquivos |
|
||||
| 401 | token do webhook ou do agente errado |
|
||||
| 403 | alçada insuficiente para autorizar retrabalho |
|
||||
| 404 | token não existe, card não encontrado, fila vazia |
|
||||
| 409 | máquina já ocupada, pedido já reservado |
|
||||
| 410 | token do link expirou |
|
||||
| 413 | arquivo acima de 5 GB |
|
||||
| 429 | rate limit — 20 req/min por token |
|
||||
|
||||
---
|
||||
|
||||
## O que testar antes de dar por pronto
|
||||
|
||||
- [ ] Upload de arquivo de 5 GB sem derrubar o VPS
|
||||
- [ ] Dois operadores clicando `puxar` ao mesmo tempo — só um pega
|
||||
- [ ] Reserva expirando: puxar e não mover em 3 min devolve à fila
|
||||
- [ ] Tiny fora do ar: o movimento grava e o job fica na fila
|
||||
- [ ] Agente sem internet: para, e ao voltar baixa o acumulado
|
||||
- [ ] Arquivo baixado pela metade não aparece no kanban
|
||||
- [ ] Token expirado devolve 410 com mensagem clara ao cliente
|
||||
392
CONTEXT.md
Normal file
392
CONTEXT.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# DTF System - Project Context for AI Agents
|
||||
|
||||
## Purpose of this document
|
||||
|
||||
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
|
||||
prototype code whenever they conflict.
|
||||
|
||||
Update this file whenever the team makes a material product, process, or
|
||||
architecture decision.
|
||||
|
||||
## Project goal
|
||||
|
||||
Build a DTF ordering and production-flow system for Dropstar/Altus.
|
||||
|
||||
The current process is slow and manual: customers send artwork through
|
||||
WhatsApp, staff forward files, designers discover problems late, and production
|
||||
status is not visible outside the factory. The business has substantially more
|
||||
machine capacity than it currently uses. The goal is to let customers place DTF
|
||||
orders online, pay, send artwork, and follow production without making
|
||||
WhatsApp, shared folders, or manual handoffs the bottleneck.
|
||||
|
||||
The customer-facing value is speed and clarity. The operations value is a
|
||||
traceable queue and fewer lost or manually handled orders.
|
||||
|
||||
## Current MVP scope
|
||||
|
||||
The MVP must be delivered in **at most three weeks**. The target is a working
|
||||
online system, not factory-machine automation.
|
||||
|
||||
### Included
|
||||
|
||||
- Dedicated DTF site/subdomain.
|
||||
- Existing Site DTF commercial rules and product experience.
|
||||
- Direct, resumable multipart file upload to Cloudflare R2.
|
||||
- Mercado Pago payment integration with signed and idempotent webhooks.
|
||||
- Freight quotation and charging at checkout.
|
||||
- Idempotent Tiny/Olist order integration and order traceability.
|
||||
- Online Kanban for production status and secure file download.
|
||||
- WhatsApp status notifications.
|
||||
- Private object storage, 30-day file retention, backups, and basic security.
|
||||
- Operator guidance for the factory team.
|
||||
|
||||
### Explicitly outside the three-week delivery
|
||||
|
||||
- Automatic pre-flight validation. It will be validated after delivery using
|
||||
real customer files and real printed output, then refined as support work.
|
||||
- Direct FlexiPRINT integration.
|
||||
- Factory-side agent, hot folder, internal file server automation, VPN, and
|
||||
heartbeat monitoring.
|
||||
- Production-room dashboard, printer/machine telemetry, and advanced reports.
|
||||
- Automated shipping-label purchase, shipping-label printing, and dispatch
|
||||
automation. The MVP freight scope is quote selection and charging only.
|
||||
- Personalized cart behaviour based on past orders, day, or time.
|
||||
|
||||
After delivery, the team provides support and evaluates requested changes. Do
|
||||
not turn post-delivery support into a new committed delivery phase without an
|
||||
explicit decision.
|
||||
|
||||
## Agreed production model
|
||||
|
||||
```text
|
||||
Customer browser
|
||||
-> Site DTF / API on VPS
|
||||
-> direct multipart upload to private Cloudflare R2
|
||||
-> Mercado Pago payment + freight selected at checkout
|
||||
-> Tiny/Olist order record
|
||||
-> online Kanban
|
||||
-> factory operator downloads final file and imports it manually into FlexiPRINT
|
||||
```
|
||||
|
||||
WhatsApp is a notification channel, not the artwork-upload channel. It is used
|
||||
for payment confirmation, correction requests, production status, and order
|
||||
completion.
|
||||
|
||||
The operator works through the browser. The factory does not need an installed
|
||||
agent or an internal server for the MVP.
|
||||
|
||||
If the factory internet connection fails, only the factory team temporarily
|
||||
loses access to the Kanban and secure downloads. The public portal, payments,
|
||||
uploads, R2 objects, queue, and cloud services remain online. Factory work
|
||||
resumes when local access returns.
|
||||
|
||||
## Infrastructure and deployment
|
||||
|
||||
- **VPS:** runs the website, API, worker, PostgreSQL, ClamAV, Kanban, and
|
||||
third-party integrations.
|
||||
- **Cloudflare R2:** private S3-compatible object storage for original uploads
|
||||
and final files. It does not run the application, database, worker, or
|
||||
antivirus.
|
||||
- **Upload path:** the browser uploads directly to R2 using short-lived,
|
||||
server-issued presigned multipart URLs. Large files must never be proxied
|
||||
through the VPS.
|
||||
- **Deployment:** one Portainer-owned `dtf-cloud` Docker Swarm stack. One Gitea
|
||||
Actions workflow tests/scans, publishes `latest` plus commit-SHA application
|
||||
images, and calls the Portainer webhook. Activation remains blocked pending
|
||||
the production work listed below.
|
||||
- **Recommended VPS baseline:** 4 vCPU, 16 GB RAM, and 200 GB NVMe. Existing
|
||||
VPS capacity may be used if it safely meets or exceeds this baseline.
|
||||
- **Backups:** PostgreSQL backups go to R2. Application secrets are never
|
||||
committed to Git.
|
||||
|
||||
## File retention
|
||||
|
||||
The business does not want an unlimited artwork library.
|
||||
|
||||
| Artifact | Retention |
|
||||
|---|---:|
|
||||
| Incomplete multipart upload | 1 day |
|
||||
| Rejected or infected upload | 3 days |
|
||||
| Customer original after approval | Up to 7 days |
|
||||
| Final print artwork | Maximum 30 days from the first upload |
|
||||
| Order history and metrics | Kept in the database without keeping the image |
|
||||
|
||||
The 30-day retention decision overrides older references to 90 days or
|
||||
12-month artwork reordering.
|
||||
|
||||
## R2 planning cost reference
|
||||
|
||||
R2 cost is driven primarily by the average number of gigabytes stored during a
|
||||
month; read/write operations are expected to be a much smaller cost at the
|
||||
project's scale. There is no egress charge in the current planning model.
|
||||
|
||||
The client-facing roadmap uses the following planning examples, based on
|
||||
standard R2 storage pricing, the included storage allowance, and an exchange
|
||||
rate reference of USD 1 = BRL 5.13:
|
||||
|
||||
| Average storage | Approx. R2/month | Approx. BRL/month |
|
||||
|---|---:|---:|
|
||||
| 100 GB | USD 1.35 | BRL 7 |
|
||||
| 500 GB | USD 7.35 | BRL 38 |
|
||||
| 1 TB | USD 14.85 | BRL 76 |
|
||||
| 3 TB | USD 44.85 | BRL 230 |
|
||||
|
||||
These are planning estimates only. Confirm Cloudflare pricing, exchange rates,
|
||||
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
|
||||
truth. Do **not** redesign, simplify, or change prices, discounts, minimums,
|
||||
rounding, or product modes without explicit approval.
|
||||
|
||||
The current site contains four product modes, quality-based price tiers,
|
||||
artwork-ready discounts, separate assembly charges for loose artwork, a
|
||||
one-metre minimum, and ten-centimetre billing rounding.
|
||||
|
||||
The browser may display the calculation, but production payment creation must
|
||||
recalculate and validate all price, quantity, freight, and discount values on
|
||||
the server. Client-provided totals are never authoritative.
|
||||
|
||||
## Freight
|
||||
|
||||
The original visual freight flow in `dtf-site.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.
|
||||
|
||||
The customer already uses Correios and other shipping platforms. Before freight
|
||||
can be completed, obtain:
|
||||
|
||||
- The platform(s) that should be used as the source of truth.
|
||||
- API credentials or delegated access for the selected platform.
|
||||
- Origin postal code/address.
|
||||
- Available services and carriers to offer.
|
||||
- Packaging weight and dimensions by DTF length/package.
|
||||
- Freight business policy: exact pass-through, subsidy, free-shipping rules,
|
||||
pickup, or other exceptions.
|
||||
|
||||
At checkout, the backend must quote freight from the selected source, store the
|
||||
chosen service and quoted amount, include the amount in the Mercado Pago
|
||||
payment, and persist it in the order. Do not create a payment before the freight
|
||||
amount is final.
|
||||
|
||||
## Integrations
|
||||
|
||||
### Mercado Pago
|
||||
|
||||
Required before production payment integration, not for the local mock milestone:
|
||||
|
||||
- Production credentials.
|
||||
- Webhook configuration/access.
|
||||
|
||||
Webhook processing must verify authenticity and be idempotent. A duplicate or
|
||||
late delivery must not create a duplicate payment, order, message, or queue
|
||||
card.
|
||||
|
||||
### Tiny/Olist
|
||||
|
||||
Tiny/Olist is already available. The implementation must create/update orders
|
||||
idempotently and use the order number for traceability. Confirm the actual API
|
||||
endpoints, marker/tag behaviour, and rate limits before production use.
|
||||
|
||||
### WhatsApp
|
||||
|
||||
WhatsApp is already available, but the technical provider still needs to be
|
||||
confirmed (official Meta Cloud API, Z-API, or another provider).
|
||||
|
||||
Implement only the required customer events:
|
||||
|
||||
1. Payment approved.
|
||||
2. Artwork correction needed, with a secure link back to the site.
|
||||
3. Order entered production.
|
||||
4. Order ready for collection or shipping.
|
||||
|
||||
Messages initiated by the business may require approved templates depending on
|
||||
the provider and conversation state. Use an outbox/job mechanism with retries,
|
||||
delivery status handling, and idempotency. Do not send artwork through
|
||||
WhatsApp.
|
||||
|
||||
## Factory responsibilities after delivery
|
||||
|
||||
The client/factory team is responsible for:
|
||||
|
||||
- Opening the Kanban, downloading final files, and importing them manually into
|
||||
the current FlexiPRINT setup.
|
||||
- Performing real print tests and reporting mismatches in the final file or
|
||||
printed result.
|
||||
- Maintaining PCs, printers, FlexiPRINT licences, printing profiles, internal
|
||||
folders, and the local factory network.
|
||||
- Informing the development team about changes to prices, freight, operational
|
||||
rules, or external platforms that need system changes.
|
||||
|
||||
The development team delivers the online system, provides usage guidance, and
|
||||
supports subsequent corrections or scoped enhancements.
|
||||
|
||||
## Three-week roadmap
|
||||
|
||||
| Week | Delivery | Result |
|
||||
|---|---|---|
|
||||
| 1 | Infrastructure and upload | VPS, domain, database, R2, multipart upload, antivirus, and service foundation. |
|
||||
| 2 | Payment, freight, and order | Mercado Pago, freight quotation, idempotent Tiny/Olist order creation, final file generation, and main Kanban states. |
|
||||
| 3 | Kanban and handover | Online Kanban, secure download, WhatsApp status messages, 30-day retention, and operational handover. |
|
||||
|
||||
## Known implementation status
|
||||
|
||||
### Active localhost milestone (2026-09-15)
|
||||
|
||||
The current implementation target is localhost before any production connection.
|
||||
Use `compose.yaml`, `.env.example`, and `LOCAL_SETUP.md`. The runtime is in
|
||||
`local/`; 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
|
||||
Kanban web gateways, FastAPI Portal/API, PostgreSQL, MinIO, ClamAV, and a
|
||||
PostgreSQL outbox/scanning worker. Separate database and storage initialization
|
||||
jobs provision restricted runtime identities, for nine Compose services total.
|
||||
- MinIO implements the S3 storage boundary with direct multipart browser uploads,
|
||||
resumable parts, private objects, and short-lived signed operator downloads.
|
||||
- Payment, freight, Tiny/Olist, and WhatsApp use fake adapters only. No production
|
||||
credentials or integrations are configured. The API and worker have no external
|
||||
network route; only local web/storage gateways publish loopback ports.
|
||||
- The original Site commercial calculation and appearance remain the source of
|
||||
truth. Its existing browser artwork analysis is retained as prototype advice;
|
||||
no new automatic pre-flight, server artwork analysis, or print-file generation
|
||||
is implemented or invoked.
|
||||
- Since client length and grade could be tampered with and automatic pre-flight
|
||||
is deferred, a local operator manually confirms those commercial inputs before
|
||||
checkout. This is a development trust-boundary decision, not a new production
|
||||
promise. The backend calculates all tiers, discounts, assembly-inclusive rates,
|
||||
one-metre minimum, ten-centimetre rounding, freight, and final totals. Immutable
|
||||
approved quotes expire after 24 hours. A customer confirms the server total
|
||||
on the Site to create one idempotent local paid order.
|
||||
- Paid orders use `rec`, `tra`, `fil`, `imp`, `cor`, `fin`, with allowed transitions,
|
||||
operator authentication, concurrency checks, and durable movement history.
|
||||
Local mock integration receipts are visible in the Kanban.
|
||||
- The customer portal at `/portal.html` supports local registration/login,
|
||||
stronger scrypt password hashing with legacy-hash upgrade, revocable HttpOnly
|
||||
sessions, owned order history/status, secure active
|
||||
file downloads, and correction uploads. Registration/login can claim only the
|
||||
current guest session's records, never orders found by email or CNPJ. Email
|
||||
verification and password recovery are not implemented.
|
||||
- Unfinished carts (including File blobs) recover from IndexedDB for 24 hours in
|
||||
the same browser, scoped to the guest/account identity. Recovered items can be
|
||||
removed/replaced or joined by new items; in-place reconstruction of the artwork
|
||||
editor and cross-device cart synchronization are not implemented. Browser
|
||||
storage capacity limits apply. Payment clears the saved cart. Logout revokes
|
||||
the server session and clears local checkout metadata and saved File blobs
|
||||
across open Site tabs.
|
||||
- Operators manually upload and approve a complete final-file set, with one or
|
||||
more files per order item. Queue entry requires active final files for every
|
||||
item. Corrections invalidate the old set; customers submit corrections through
|
||||
their portal, and operators reapprove final files. No artwork transformation,
|
||||
automatic pre-flight, or machine control is performed.
|
||||
- Every completed upload is quarantined pending a local ClamAV scan. Only `clean`
|
||||
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.
|
||||
- A retention worker removes expired object bytes but keeps order/file metadata:
|
||||
incomplete uploads after one day, 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
|
||||
`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
|
||||
is regenerated in a disposable Python 3.12 container by
|
||||
`local/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,
|
||||
fake providers, embedded secret settings, and unsafe secret sources. It does not
|
||||
deploy the application, contain credentials, or contact any real provider.
|
||||
- A separate production delivery package now exists under `deploy/`, with
|
||||
non-root API/web image definitions, a single Portainer Docker Swarm stack,
|
||||
external secret/volume contracts, health-monitored rolling updates,
|
||||
commit-SHA rollback images, and one protected Gitea workflow under
|
||||
`.gitea/workflows/`. The package never contains provider credentials and has
|
||||
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
|
||||
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
|
||||
restored bytes, then removes only those temporary targets. Active data is never
|
||||
overwritten; pending, rejected, errored, expired, and purged objects are excluded.
|
||||
Run this local snapshot while uploads and retention are idle. Scheduling, offsite
|
||||
copies, and production restore operations remain unfinished.
|
||||
- Real providers, production authentication hardening, email verification/recovery,
|
||||
automatic final-file generation, production backup operations, and production
|
||||
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
|
||||
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
|
||||
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`:
|
||||
meeting notes that established the business direction. Treat it as context;
|
||||
the latest decisions in this document define the active scope.
|
||||
|
||||
### Important gaps and stale assumptions
|
||||
|
||||
- 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
|
||||
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
|
||||
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
|
||||
12-month artwork reuse. Those are not the current MVP model.
|
||||
|
||||
## Documentation synchronization
|
||||
|
||||
`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`.
|
||||
|
||||
When a decision changes, update both the relevant implementation context here
|
||||
and the roadmap source when it changes what the client-facing plan promises.
|
||||
|
||||
## Change-control rules for agents
|
||||
|
||||
- Preserve the current Site DTF commercial rules unless explicitly asked to
|
||||
change them.
|
||||
- Do not add factory-side automation, a local agent, hot folders, FlexiPRINT
|
||||
control, machine dashboards, or a new post-MVP delivery commitment by
|
||||
inference.
|
||||
- Do not claim that R2 hosts or executes application services.
|
||||
- Keep all customer-facing and internal documentation in English only when this
|
||||
file is the requested artifact; otherwise follow the user's requested
|
||||
language.
|
||||
- When a requirement is unclear, distinguish between the MVP, a future
|
||||
enhancement, and an existing prototype assumption before changing code.
|
||||
307
ESPECIFICACAO.md
Normal file
307
ESPECIFICACAO.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# Módulo DTF no PCP — especificação de implementação
|
||||
|
||||
> Para o Wagner. Consolida o desenho depois das reuniões com os designers e com
|
||||
> a sala de impressão em 02/09/2026.
|
||||
> Complementa o `README.md` (arquitetura) e o código em `portal/`, `agente/` e
|
||||
> `kanban/`.
|
||||
|
||||
---
|
||||
|
||||
## O que mudou depois das reuniões
|
||||
|
||||
Sete decisões vieram da equipe e **já estão refletidas no código**. Se algo no
|
||||
código parecer estranho, provavelmente é uma delas.
|
||||
|
||||
| Item | Era | Ficou | Quem definiu |
|
||||
|---|---|---|---|
|
||||
| Reserva ao puxar | 15 min | **3 min** | sala |
|
||||
| Puxar trabalho | até 30 min | **um pedido por vez** | sala |
|
||||
| Marcadores de duração | seriam apagados | **viram campo `minutos_maquina`** | sala |
|
||||
| Hot folder em rede | a confirmar | **aceita** | sala |
|
||||
| Production Manager | a confirmar | **centraliza, mas o PC central precisa de mais capacidade** | sala |
|
||||
| Licenças do Flexi | a confirmar | **por PC** | sala |
|
||||
| Meta de retrabalho | 0,4% por causa | **rever: a sala prefere meta geral** | sala |
|
||||
|
||||
---
|
||||
|
||||
## Números que a equipe deu e que entram no cálculo
|
||||
|
||||
| Dado | Valor | De onde veio |
|
||||
|---|---|---|
|
||||
| Arquivos que chegam usáveis | **30%** | designers |
|
||||
| Tempo para tratar arquivo bom | **3 minutos** | designers |
|
||||
| Tempo para tratar arquivo ruim | **1h30** | designers |
|
||||
| Formatos que mais chegam | **PNG e PDF** | designers |
|
||||
| PDF que chega | **vetorial** | designers |
|
||||
| SPOT | **sempre igual** | designers |
|
||||
| Cores que sempre dão problema | azul, vermelho, castanho, laranja, verde — **secundárias** | sala |
|
||||
| Prova de cor hoje | **não mandam para ninguém** | sala |
|
||||
| Como o operador sabe o próximo | **notinha do pedido** | sala |
|
||||
| Tempos de setup | **"não temos informação"** | sala |
|
||||
|
||||
**O de 30% é o mais consequente.** Sete de cada dez arquivos passam pelo
|
||||
designer. Se o pré-flight barrar metade dos ruins na entrada, são ~3,5 arquivos
|
||||
em 10 que deixam de consumir tempo de arte.
|
||||
|
||||
---
|
||||
|
||||
## Fases de implementação
|
||||
|
||||
Cada fase entrega valor sozinha. **Não espere a fase 4 para colocar algo no ar.**
|
||||
|
||||
### Fase 1 · Limpeza do Tiny — pode começar hoje
|
||||
|
||||
Não depende de nada nem de ninguém.
|
||||
|
||||
- [ ] Criar os marcadores `DTF-PRODUCAO`, `DTF-PRONTO` e `DTF-PROVA-COR`
|
||||
- [ ] Tirar da **lista de sugestão** as variações digitadas à mão: `inicio 14:45`,
|
||||
`inicio13:34`, `1hrs`, `3h`, `+30min de correcao` e as demais
|
||||
- [ ] **Não apagar do histórico dos pedidos** — só da lista de opções
|
||||
- [ ] **Preservar** `30 min`, `1 hora` e `3 horas`: são estimativa de máquina e
|
||||
viram o campo `minutos_maquina`
|
||||
|
||||
### Fase 2 · Portal e robô — recebe 24h sozinho
|
||||
|
||||
Entrega valor sem o kanban existir.
|
||||
|
||||
- [ ] Contratar VPS, storage S3 e o subdomínio (ver README)
|
||||
- [ ] Webhook do Tiny → cria token → dispara link no WhatsApp
|
||||
- [ ] Página de upload com campo de repetição e prévia da montagem
|
||||
- [ ] URL pré-assinada · upload em partes até **5 GB**
|
||||
- [ ] Pré-flight (`preflight.py` pronto)
|
||||
- [ ] Normalização: vetor → PDF, raster → TIF, CMYK → RGB
|
||||
- [ ] Montagem empilhada · fatiamento em 20 m · carimbo com QR de 20 mm
|
||||
- [ ] ClamAV em segundo plano
|
||||
- [ ] Retenção de 30 dias · artes para recompra, 12 meses
|
||||
|
||||
### Fase 3 · Agente — o arquivo cai na pasta sozinho
|
||||
|
||||
- [ ] Serviço no servidor da fábrica (NSSM ou systemd)
|
||||
- [ ] Webhook + polling de 60 s
|
||||
- [ ] Download com verificação de hash
|
||||
- [ ] Heartbeat a cada 5 min · alerta se sumir por 15
|
||||
|
||||
### Fase 4 · Kanban como aba do PCP
|
||||
|
||||
- [ ] Quadro com as 7 colunas e cronômetro por card
|
||||
- [ ] Painel das 6 máquinas com **indicador vermelho de ocupada**
|
||||
- [ ] `puxar próximo` — reserva de 3 min, um pedido por vez
|
||||
- [ ] `devolver à fila` — volta ao topo
|
||||
- [ ] Campo `minutos_maquina` no card, com sugestão automática
|
||||
- [ ] Retrabalho: abertura, classificação de causa, autorização
|
||||
- [ ] Painel com filtros de período
|
||||
- [ ] Endpoints de relatório: etapas, impressão, aproveitamento, retrabalho
|
||||
|
||||
### Fase 5 · Duas semanas medindo
|
||||
|
||||
**Não pule.** É o que decide se vale o terceiro turno.
|
||||
|
||||
O que sai depois de duas semanas rodando:
|
||||
- tempo real de cada etapa
|
||||
- metros/hora reais por máquina
|
||||
- quanto do tempo do designer é retrabalho de arquivo ruim
|
||||
- retrabalho medido contra o que a sala achava que era
|
||||
|
||||
---
|
||||
|
||||
## Funcionalidades, uma a uma
|
||||
|
||||
### Portal do cliente
|
||||
|
||||
**Link com token.** Um por pedido, UUID v4, 7 dias de validade. O token já
|
||||
carrega o número do pedido no Tiny — o cliente não digita nada.
|
||||
|
||||
**Upload direto para o storage.** URL pré-assinada, em partes, até 5 GB. O
|
||||
arquivo nunca passa pelo VPS.
|
||||
|
||||
**Campo de repetição.** O cliente sobe uma arte e informa quantas vezes repetir.
|
||||
O robô empilha. É o trabalho braçal que hoje o designer faz à mão.
|
||||
|
||||
**Prévia da montagem.** Mostra como a folha ficou antes de fechar o pedido.
|
||||
Montagem **empilhada**, sem encaixe lado a lado — decisão do Marcus.
|
||||
|
||||
**Gabarito 57 × 97 cm** para download. Os 30 mm do rodapé são do carimbo.
|
||||
|
||||
**Resposta na hora.** Aprovado ou recusado com o motivo em português. **O relógio
|
||||
do prazo só começa quando a arte é aprovada.**
|
||||
|
||||
### Robô de pré-flight
|
||||
|
||||
Ordem: **normalizar → validar → repetir → fatiar → carimbar.**
|
||||
|
||||
| Verificação | Limite | Ação |
|
||||
|---|---|---|
|
||||
| Canal de transparência | obrigatório | recusa |
|
||||
| DPI efetivo na medida comprada | < 150 | recusa |
|
||||
| Largura | > 57 cm | recusa |
|
||||
| Formato | JPEG | recusa |
|
||||
| DPI efetivo | 150 a 300 | aceita com aviso |
|
||||
| Alfa parcial nas bordas | > 15% | aceita com aviso |
|
||||
| CDR | — | **entra sem validar**, etiqueta "conferir" |
|
||||
|
||||
**DPI efetivo = pixels ÷ (cm comprados ÷ 2,54).** O metadado do arquivo mente.
|
||||
|
||||
**Normalização por natureza:** vetor sai PDF mantendo vetor, raster sai TIF com
|
||||
alfa. CMYK vira RGB. **O original é sempre preservado** — pedido dos designers.
|
||||
|
||||
### Kanban
|
||||
|
||||
**Sete colunas:** arte recebida, arte tratada, aprovação de cor, fila,
|
||||
imprimindo, correção, finalizado. **Não há coluna de aplicação** — a sala só
|
||||
imprime o filme.
|
||||
|
||||
**Fila por ordem de chegada, sempre.**
|
||||
|
||||
**Puxar próximo.** Um botão por máquina. Reserva por 3 minutos; se não entrar em
|
||||
Imprimindo nesse prazo, volta para a fila. **Um pedido por vez.**
|
||||
|
||||
**Indicador de máquina ocupada.** Círculo vermelho e botão desabilitado. Com seis
|
||||
máquinas vendo o mesmo quadro, é o que evita dois operadores no mesmo arquivo.
|
||||
|
||||
**Devolver à fila.** Volta ao **topo**, não ao fim — o pedido já esperou uma vez.
|
||||
|
||||
**Cronômetro por card.** Tempo parado na coluna atual, em tempo real. Verde até
|
||||
1h, amarelo até 3h, vermelho acima.
|
||||
|
||||
**Trilha por pedido.** Tempo em cada etapa e quanto do total foi só esperando.
|
||||
|
||||
### Estimativa de máquina
|
||||
|
||||
Campo `minutos_maquina` no card. **Herda os marcadores `30 min`, `1 hora` e
|
||||
`3 horas` do Tiny**, que a sala confirmou serem estimativa.
|
||||
|
||||
- O sistema sugere pelo tamanho: `metros ÷ 20 × 60`
|
||||
- Quem trata a arte ajusta **só quando foge do normal**
|
||||
- É esse número que soma a fila contra a capacidade do dia
|
||||
|
||||
### Aprovação de cor
|
||||
|
||||
A sala confirmou que **hoje não manda prova para ninguém**, e que as cores
|
||||
problemáticas são as secundárias: azul, vermelho, castanho, laranja e verde.
|
||||
São cores fora do gamut CMYK — o monitor do cliente mostra o que a máquina não
|
||||
reproduz.
|
||||
|
||||
Dispara sozinho em três casos: arquivo acima de 10 m, primeiro pedido do
|
||||
cliente, ou cor detectada fora do gamut.
|
||||
|
||||
**Custo:** ~30 cm de filme, R$ 1,50. Uma reposição de 20 m custa R$ 99.
|
||||
|
||||
**Sugestão de implantação:** começar só com **cliente novo**. Os outros dois
|
||||
gatilhos entram depois, senão metade dos pedidos vai esperar resposta e o prazo
|
||||
morre.
|
||||
|
||||
### Retrabalho
|
||||
|
||||
SKU `CRRMP.TX.100CM`. **Mayana abre e classifica a causa; Thales ou Alexandre
|
||||
autorizam** — eles confirmaram que acham justo, já que entra na meta deles.
|
||||
|
||||
**A causa fica travada depois de aberta.** Com o indicador atrelado a bônus,
|
||||
haveria incentivo para reclassificar falha de máquina como culpa do cliente.
|
||||
Quem discorda contesta, e a contestação fica registrada.
|
||||
|
||||
**⚠ A meta precisa ser redefinida.** A proposta era 0,4% por causa, somando 1,6%.
|
||||
A sala respondeu que **"meta geral seria melhor"**. Antes de codificar, decidir
|
||||
com o Marcus: meta única sobre o total da casa, ou por causa. O código tem
|
||||
`META_POR_CAUSA` isolado justamente para isso.
|
||||
|
||||
### Integração com o Tiny
|
||||
|
||||
**Três marcadores, não sete.** O kanban é a fonte de verdade; o Tiny mostra o
|
||||
estágio grosso para quem não abre o kanban.
|
||||
|
||||
| Movimento | Marcador |
|
||||
|---|---|
|
||||
| Imprimindo | `DTF-PRODUCAO` |
|
||||
| Correção | `CORRECAO DTF` |
|
||||
| Finalizado | `DTF-PRONTO` |
|
||||
|
||||
640 chamadas de API por dia em vez de 1.500.
|
||||
|
||||
### Mensagens ao cliente
|
||||
|
||||
| Quando | Mensagem |
|
||||
|---|---|
|
||||
| 1 hora sem arte | lembrete com o link · o pedido aparece como *faltando arquivo* |
|
||||
| Arte recusada | motivo em português · o prazo não começou |
|
||||
| Arte aprovada | qualidade em **% e DPI**, metragem, tempo estimado, horário previsto |
|
||||
| Entrou em produção | aviso simples |
|
||||
| Correção | motivo · **única que pede resposta** |
|
||||
| Finalizado | pronto para retirada ou envio |
|
||||
|
||||
---
|
||||
|
||||
## O que ainda não pode ser codificado
|
||||
|
||||
### 1 · O SPOT automático
|
||||
|
||||
**Hipótese, não fato.** Os designers disseram que o SPOT é sempre igual; a sala
|
||||
disse que "é possível criar um perfil só para isso". A documentação do Flexi 22
|
||||
cita *transparency mask*, que gera o branco a partir da transparência de PNG e
|
||||
TIF.
|
||||
|
||||
**Mas ninguém configurou ainda**, e a edição MiniTX é OEM.
|
||||
|
||||
O roteiro de teste está em `dtf-teste-branco-automatico.pdf`. Sete passos, uma
|
||||
máquina, meia hora.
|
||||
|
||||
- **Se funcionar:** o arquivo vai do portal direto para a hot folder e o PC
|
||||
central atende só exceções. A madrugada roda sem ninguém.
|
||||
- **Se não funcionar:** o SPOT segue manual e todo pedido passa pelo PC central.
|
||||
**A conta das 24 horas muda.**
|
||||
|
||||
### 2 · A capacidade do PC central
|
||||
|
||||
A sala confirmou que o Production Manager centraliza várias impressoras, **mas
|
||||
que o PC central precisa de mais capacidade**. E as licenças do Flexi são **por
|
||||
PC** — então centralizar o RIP exige licença lá.
|
||||
|
||||
Levantar antes de decidir: configuração atual do PC central, custo de uma
|
||||
licença adicional, e quanto de memória o RIP consome num trabalho de 15 m.
|
||||
|
||||
### 3 · As regras de choke
|
||||
|
||||
Os designers **não responderam** os valores de choke nem a espessura mínima de
|
||||
traço. Sem isso, a regra proposta — 2 px acima de 2 mm, 1 px de 1 a 2 mm,
|
||||
nenhum abaixo de 1 mm — é chute informado.
|
||||
|
||||
**Só entra em código depois de validada com um arquivo real.**
|
||||
|
||||
### 4 · Metros/hora reais
|
||||
|
||||
Todo o cálculo de capacidade assume **20 m/h por máquina**. A sala não respondeu
|
||||
o valor real, e disse que não tem informação sobre tempos.
|
||||
|
||||
Se o real for 14, a capacidade cai de 60.480 para 42.336 metros/mês e o plano
|
||||
das 24 horas precisa ser refeito.
|
||||
|
||||
---
|
||||
|
||||
## Ressalvas honestas sobre o código
|
||||
|
||||
**`tiny.py` é chute informado.** Estrutura padrão de OAuth2 e endpoints v3, não
|
||||
validado contra a documentação real. Está isolado de propósito: se o endpoint
|
||||
for diferente, muda só ali.
|
||||
|
||||
**`carimbar()` precisa de teste visual.** A lógica está certa, mas posicionamento
|
||||
de texto com pyvips sempre pede ajuste olhando o resultado impresso.
|
||||
|
||||
**O front do kanban usa dados fixos.** Precisa trocar por chamadas a
|
||||
`/api/quadro` e `/api/mover`. O protótipo `dtf-kanban-treino.html` tem o layout e
|
||||
o comportamento final.
|
||||
|
||||
**Nada instalado nos PCs da sala.** Decisão do Marcus. O navegador não abre
|
||||
arquivo no FlexiPRINT — o desenho é miniatura no card mais botão que copia o
|
||||
caminho, e File System Access API para mover arquivo pelo navegador.
|
||||
|
||||
---
|
||||
|
||||
## O que precisa de resposta antes da fase 4
|
||||
|
||||
1. Servidor da fábrica é Windows ou Linux?
|
||||
2. A conta do Tiny já tem aplicação na API v3?
|
||||
3. Confirmar na documentação do Tiny o endpoint de marcadores e o limite de
|
||||
requisições por minuto
|
||||
4. O número do WhatsApp migra para a API oficial da Meta?
|
||||
5. Qual a latência da integração VNDA → Tiny? Define quando o link sai
|
||||
6. Meta de retrabalho: geral ou por causa?
|
||||
7. Configuração e licença do PC central, se for virar servidor de RIP
|
||||
219
IMPLEMENTATION_REPORT.md
Normal file
219
IMPLEMENTATION_REPORT.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Local milestone implementation report
|
||||
|
||||
## Implemented
|
||||
|
||||
- A single `dtf-cloud` Compose project: Site, Kanban, FastAPI Portal/API,
|
||||
PostgreSQL 17, private MinIO S3 storage, isolated ClamAV, and a mock integration
|
||||
outbox/scanning worker. Restricted database and MinIO runtime identities are
|
||||
provisioned by separate one-shot initialization jobs.
|
||||
- Existing Site product UI and commercial functions retained, with a separate
|
||||
local checkout bridge. Server pricing mirrors the four price ladders, all
|
||||
grade discounts, assembly-inclusive rates, minimum, and rounding.
|
||||
- Direct multipart browser uploads, part resumption, owned upload completion,
|
||||
size verification, private five-minute operator downloads, persistent volumes,
|
||||
and 30-day object/one-day incomplete-upload lifecycle rules.
|
||||
- Authenticated manual quote review supplies trusted commercial quantities and
|
||||
grades without adding pre-flight. Immutable server quotes include mock freight;
|
||||
the customer confirms the total and creates an idempotent local paid order.
|
||||
- Shared PostgreSQL orders appear in Kanban. Validated transitions, correction
|
||||
reasons, version checks and movement history persist. Tiny/WhatsApp events use
|
||||
a transactional outbox, retry scheduling, unique event keys, and fake receipts.
|
||||
- Loopback-only published ports; API/worker/database on an isolated network;
|
||||
guards reject production mode, real adapters and nonlocal storage endpoints.
|
||||
All long-running services have health checks. `.env.example` contains disposable local
|
||||
defaults only. No new production credentials or provider endpoints.
|
||||
- Local customer registration/login, scrypt password hashes, revocable database
|
||||
sessions and attempt throttling. Customers see owned orders, timelines, final
|
||||
files and correction requests, and can upload corrections through the portal.
|
||||
- Browser-local unfinished-cart recovery for 24 hours, including file blobs,
|
||||
with storage errors surfaced. Recovered rows can be removed/replaced and new
|
||||
items added. Customer navigation now points to local pages instead of the old
|
||||
external account/cart destinations.
|
||||
- Manual final-file sets cover every order item, support multiple parts, and
|
||||
are required before queue entry. Corrections invalidate old final approvals.
|
||||
Customer and operator file views distinguish originals, corrections and finals.
|
||||
- Worker retention cleanup: one-day unfinished uploads, seven-day originals
|
||||
after final artwork approval, and final/correction files no later than 30 days
|
||||
from the first upload. Combined database/clean-object backup plus isolated,
|
||||
hash-checked restore verification.
|
||||
- Upload extension/size/count quotas, exact signed multipart `Content-Length`,
|
||||
Host/cross-origin rejection, CSP and security headers, escaped filenames, and
|
||||
logout cleanup of browser cart blobs and checkout metadata.
|
||||
- Expiring, revocable HttpOnly operator sessions replace browser-stored Basic
|
||||
credentials. Customer passwords use stronger scrypt parameters, with legacy
|
||||
verification and upgrade on login.
|
||||
- Completed artwork is quarantined until local ClamAV marks it `clean`. Quote,
|
||||
payment, download, final-file approval, queue, and printing gates fail closed.
|
||||
Scanner errors/rejections remain blocked and expire within three days. This is
|
||||
malware scanning only, not print pre-flight.
|
||||
- Structured redacted security logging, a 30-day `security_events` table, live
|
||||
scanner/signature alert summaries, security regressions, exact-runtime Python
|
||||
auditing, and JSON image-scan reports under `output/security/`.
|
||||
- Reproducible Python 3.12 dependency resolution: all 26 direct/transitive runtime
|
||||
packages are pinned with artifact hashes, and image builds enforce those hashes.
|
||||
- A separate network-disabled staging-readiness gate validates non-secret inputs;
|
||||
it is deliberately not an application deployment or provider connection.
|
||||
- A separate production delivery package defines non-root, hash-locked API/web
|
||||
images, one external-secret Portainer Docker Swarm stack, health-monitored
|
||||
rolling updates, commit-SHA rollback, and one Gitea test/scan/publish/webhook
|
||||
workflow matching the established Graphs/ComporHUB operating model. Its
|
||||
preflight deliberately blocks the current local-only source and placeholder
|
||||
inputs; no registry push, Swarm deployment, or real provider call occurred.
|
||||
|
||||
## URLs
|
||||
|
||||
| Component | URL |
|
||||
|---|---|
|
||||
| Site | http://localhost:8080 |
|
||||
| Customer portal | http://localhost:8080/portal.html |
|
||||
| Kanban | http://localhost:8081 |
|
||||
| API health | http://localhost:8000/health |
|
||||
| Local object API and console | http://localhost:9000 · http://localhost:9001 |
|
||||
|
||||
Kanban local login: `operator` / `local-operator-only`.
|
||||
Setup and the complete browser test are in `LOCAL_SETUP.md`.
|
||||
Interactive `/docs` and `/redoc` are disabled.
|
||||
|
||||
## Verification completed
|
||||
|
||||
- `docker compose up --build -d --wait`: all seven long-running services healthy;
|
||||
both initialization jobs exited successfully and published ports are loopback-only.
|
||||
- `python3 -m unittest local.test_pricing -v`: all tests passed, including
|
||||
4,444 comparisons with the actual Site JavaScript calculator.
|
||||
- `python3 -m local.smoke_test`: passed multipart resume/incomplete completion,
|
||||
download byte identity, private bucket and session ownership, input/tamper
|
||||
rejection, four-mode pricing, reviewed corrections, mock freight, concurrent
|
||||
payment idempotency, valid/invalid transitions, history and eight mock receipts.
|
||||
- `node local/browser_test.mjs`: passed actual browser upload, manual review,
|
||||
local payment, cart recovery, final-file approval, customer registration,
|
||||
customer tracking, Kanban state changes and persisted board reload, with no
|
||||
JavaScript exceptions. Screenshots inspected in `output/local/`.
|
||||
- Local test orders are retained in **Finalizado** for inspection, including
|
||||
the browser fixture at **R$21.89** and the four-mode smoke order at **R$537.25**.
|
||||
- Restarts of all long-running containers preserved order snapshots, states, mock
|
||||
receipts and original file bytes; all services returned to healthy.
|
||||
- Explicit guard checks rejected production mode, every real integration adapter,
|
||||
R2 selection and a nonlocal S3 endpoint without contacting external services.
|
||||
- `python3 -m local.workflow_test`: passed account/session isolation, guest
|
||||
migration, revoked-cookie rejection, customer corrections, final revision
|
||||
invalidation, secure file downloads and final-file gating.
|
||||
- `python3 -m local.backup create-and-verify`: the refreshed 2026-09-15 bundle
|
||||
archived 61 clean MinIO objects (58,723,323 bytes) alongside the local database,
|
||||
verified SHA-256 manifests,
|
||||
restored and rehashed every object under an isolated MinIO prefix, restored the
|
||||
database with 16 orders and 107 upload records, then removed all temporary restore
|
||||
targets. The prior database-only backup also passed the legacy verifier.
|
||||
- `docker compose exec -T api python -m local.retention_test`: verified expired
|
||||
object deletion, preservation of unexpired bytes, and retention of upload
|
||||
metadata. Only synthetic retention-test object bytes were cleaned up.
|
||||
- `python3 -m local.security_test`: passed CSP/frame/Host/origin defenses,
|
||||
rejection of Basic credentials, HttpOnly operator-session revocation, extension
|
||||
and multipart-size signing, upload quotas, and login throttling.
|
||||
- `python3 -m local.scanning_test`: a real harmless EICAR fixture was rejected by
|
||||
ClamAV and could not be downloaded or quoted; a clean control was released.
|
||||
- `docker compose exec -T api python -m local.runtime_security_test`: passed
|
||||
database/MinIO least privilege, legacy/current password hashes, quarantine
|
||||
states, live scanner commands, scan-size rejection, and fail-closed offline behavior.
|
||||
- The hash-enforced rebuild completed successfully; `pip check`, four staging-gate
|
||||
regressions, security, smoke, and runtime-security tests passed. A fresh
|
||||
exact-runtime `pip-audit` found no known Python advisories on 2026-09-15.
|
||||
- `node local/browser_test.mjs`: additionally passed filename XSS probes with CSP
|
||||
bypassed, absence of stored operator credentials, and logout removal of File blobs.
|
||||
- The final rebuilt stack has seven healthy long-running services; Site/API routing and a fresh
|
||||
guest portal session were checked after the rebuild.
|
||||
- Production-package validation passed Python/unit and shell syntax checks, Gitea
|
||||
workflow YAML parsing, and `docker stack config` rendering. The API production
|
||||
image built from an immutable Python base. The web production image built from
|
||||
immutable Python/Nginx bases and ran as UID 101 with a read-only filesystem,
|
||||
returning 200 for its configured Host and 400 for an unexpected Host.
|
||||
- The pinned ClamAV validation image started as UID 100:101 with a read-only
|
||||
filesystem, all capabilities dropped, and `no-new-privileges`, then returned
|
||||
`PONG` through the production health command; those restrictions are encoded in
|
||||
the Swarm stack.
|
||||
- `python3 deploy/production_preflight.py --source-only` returned the expected
|
||||
blocked result for local-only adapters/hosts/fake providers and missing Docker
|
||||
secret-file loading. This is verified fail-closed behavior, not production
|
||||
acceptance.
|
||||
|
||||
The final closeout smoke test retained local order #14 in **Finalizado** with
|
||||
eight durable fake receipts. The final workflow test again passed identity,
|
||||
correction, download, invalidation, and final-file gates after the PostgreSQL 17
|
||||
image refresh. Existing database and MinIO named volumes were preserved.
|
||||
|
||||
## Reuse and replacement decisions
|
||||
|
||||
| Existing asset | Decision |
|
||||
|---|---|
|
||||
| `dtf-site.html` | Reused directly. Preserved layout, modes, pricing, client previews, minimums and rounding; added file references and hooks for local checkout/freight. |
|
||||
| `portal/main.py` | Inspected and preserved. Reused FastAPI and direct signed S3 upload design; replaced runtime with `local/app.py` because the prototype starts from Tiny and invokes pre-flight/agent delivery. |
|
||||
| `portal/preflight.py` | Inspected, untouched, never imported by local runtime. |
|
||||
| `kanban/main.py` | Inspected and preserved. Reused workflow state names, movement-history concept and outbox approach; replaced SQLite/folder/machine runtime with PostgreSQL endpoints. |
|
||||
| `kanban/static/kanban.html` | Inspected and preserved. Reused dark palette, cards, columns and drag/drop pattern in `local/static/kanban.html`. Legacy machine controls and conflicting local/server handlers are not loaded. |
|
||||
| Duplicated Tiny/WhatsApp modules | Preserved but excluded from build/runtime. New explicit fake adapters cannot invoke them. |
|
||||
| `agente/` | Inspected, untouched, excluded from build and Compose. Factory automation remains out of scope. |
|
||||
| Root `schema.sql`, `requirements.txt`, `.env.exemplo` | Historical only; local runtime uses a separate `dtf_local` PostgreSQL schema, dependencies, and `.env.example`. No migration of prototype data. |
|
||||
| README/API docs | Legacy content preserved under explicit notices pointing to active local instructions. |
|
||||
|
||||
## Deferred and practical limits
|
||||
|
||||
This is a working local development milestone, not the completed three-week production
|
||||
MVP. Real payments/webhooks, freight providers, Tiny/Olist, WhatsApp, production
|
||||
R2, production account verification/recovery/security, automatic final print-file
|
||||
generation, scheduled/offsite backups and a production restore runbook, and production
|
||||
activation remain undone. Deployment definitions and automation now exist, but
|
||||
their gate correctly prevents use with the local-only application. No factory agent, hot folders, FlexiPRINT, VPN,
|
||||
machine dashboards, label automation, or new automatic pre-flight were added.
|
||||
|
||||
Browser artwork analysis already in the Site is retained as advisory prototype
|
||||
behavior. The backend never adopts its prices, lengths or grades as approved.
|
||||
The temporary manual quote step resolves that trust boundary locally; its role
|
||||
in production needs an explicit product decision. Final files are manually
|
||||
prepared and approved by operators; the test `.cdr` fixture is text, not printable
|
||||
artwork. Cart recovery is browser-local, subject to quota, and does not reconstruct
|
||||
the active artwork editor in place. Fake delivery receipts
|
||||
mean only that the local adapter recorded an event, never that a person received
|
||||
a message or an ERP order was created.
|
||||
|
||||
The multipart transport supports up to 5 GiB, but this local scanner can release
|
||||
only files up to 128 MiB. Larger uploads remain blocked. The scanner has no
|
||||
external network route and uses signatures bundled in its pinned image; rebuild
|
||||
or replace that image before signatures exceed the seven-day alert threshold.
|
||||
|
||||
The 2026-09-15 exact-runtime Python audit reported no known dependency findings.
|
||||
All 26 installed packages are transitively pinned with artifact hashes; scheduled
|
||||
lock refresh and audit automation are still pending.
|
||||
Trivy HIGH/CRITICAL image reports are retained in `output/security/`: API 46
|
||||
(44 Debian records without fixes plus two third-party-SBOM package records absent
|
||||
from the runtime), Site 5, refreshed PostgreSQL 31, pinned MinIO 108, and pinned
|
||||
ClamAV 0. Counts are scanner observations, not exploitability determinations.
|
||||
MinIO was not upgraded over the preserved object volume; its old pinned release
|
||||
is a material localhost-only limitation. See `SECURITY_REPORT.md`.
|
||||
|
||||
The Site retains its pre-existing external fonts/logo/PDF.js 3.11.174 references.
|
||||
The known eval advisory is mitigated by the existing `isEvalSupported:false` call;
|
||||
the old CDN dependency still needs a planned upgrade or vendored/pinned
|
||||
replacement. No new production service references were added. The `.git` directory is unavailable
|
||||
as a working Git repository in this workspace, so changes are delivered as local
|
||||
files without a commit or Git diff.
|
||||
|
||||
## Exact next step
|
||||
|
||||
The localhost security closeout is complete; continue local product work without
|
||||
treating these controls as production approval. Before any staging or production
|
||||
connection, complete `PRODUCTION_INPUTS.md` and pass the network-disabled readiness
|
||||
gate, including the acceptance flow,
|
||||
production authority for length/grade, and freight platform, origin, packaging and
|
||||
policy. The verified local database/clean-object bundle addresses the local
|
||||
recovery gap but is neither scheduled nor offsite and should be created while local
|
||||
writes are idle. Remaining foundations include production backup/restore design,
|
||||
container-image remediation/upgrade decisions, and account verification/recovery
|
||||
design. The current `compose.staging.yaml` validates inputs only and cannot deploy
|
||||
the application or contact providers.
|
||||
Use the checked-in `deploy/` package as the target deployment contract rather
|
||||
than creating another production stack. First add the actual separate staging application runtime, beginning with S3
|
||||
adapter contract tests for direct multipart upload, private downloads, CORS and
|
||||
retention using injected staging credentials. Add real provider adapters only
|
||||
after their contracts are confirmed; payment activation requires signed,
|
||||
idempotent webhooks. Implement Docker-secret file loading, resolve or formally
|
||||
accept image findings, rehearse production restore, and make the release preflight
|
||||
pass without weakening it. The local runtime intentionally refuses production values.
|
||||
366
LOCAL_SETUP.md
Normal file
366
LOCAL_SETUP.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# DTF local development
|
||||
|
||||
Read `CONTEXT.md` first. The current runtime lives in `local/`; older portal,
|
||||
Kanban, agent, requirements, and SQL files are historical prototypes.
|
||||
|
||||
## Start
|
||||
|
||||
Install Docker Engine/Desktop with Compose v2 or later. Docker must be running
|
||||
and your account must have permission to use it. The initial build downloads
|
||||
public container images and Python packages; running integrations are local.
|
||||
|
||||
From this repository directory:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
No `.env` is required: Compose has the same disposable defaults as `.env.example`.
|
||||
To customize, copy `.env.example` to `.env` and edit it. Never use real credentials.
|
||||
For a detached start with readiness verification:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d --wait
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
| Component | Local URL / port | Purpose |
|
||||
|---|---|---|
|
||||
| Site DTF | http://localhost:8080 | Existing Site with local checkout bridge |
|
||||
| Customer portal | http://localhost:8080/portal.html | Local account, order history, corrections and files |
|
||||
| Kanban | http://localhost:8081 | Quote review, orders, movement, original downloads |
|
||||
| API health | http://localhost:8000/health | Checks PostgreSQL and MinIO |
|
||||
| MinIO S3 | http://localhost:9000 | Direct signed multipart uploads and downloads |
|
||||
| MinIO console | http://localhost:9001 | Inspect private local storage |
|
||||
| PostgreSQL | `db:5432`, internal only | Shared persistent data |
|
||||
| ClamAV | `scanner:3310`, internal only | Isolated malware scanner with bundled signatures |
|
||||
| Worker | `worker:8002/health`, internal only | Mock outbox delivery, scanning and combined health |
|
||||
|
||||
Use `localhost` consistently; mixing it with `127.0.0.1` creates a different
|
||||
browser session. Published ports bind only to `127.0.0.1`.
|
||||
|
||||
Kanban default login: `operator` / `local-operator-only`.
|
||||
MinIO default login: `dtf_local` / `local-storage-only`.
|
||||
These are public, disposable development values, not real credentials.
|
||||
Interactive API documentation is disabled; `/docs` and `/redoc` are not local
|
||||
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
|
||||
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
|
||||
artwork with the original product controls, repetitions, and mixed cart.
|
||||
3. Use test CNPJ `11.222.333/0001-81`, phone `11999999999`, email
|
||||
`local-test@example.test`, and pickup. For simulated freight, select delivery,
|
||||
enter an eight-digit CEP, and click quote. The default mock price is R$15.00.
|
||||
4. Click the Site checkout button. Files upload directly to MinIO, remain
|
||||
quarantined until ClamAV returns `clean`, and then become eligible for a quote.
|
||||
The local banner displays a quote ID awaiting commercial review.
|
||||
5. Open Kanban and log in. In **Cotações**, download the original if needed,
|
||||
confirm/correct total metres and grade, tick the manual confirmation, and
|
||||
click **Aprovar cotação**. For the fixture keep 1.01 m and grade 0.
|
||||
6. Return to the Site, click **Atualizar pedido local**, inspect the authoritative
|
||||
server total, then click **Criar pedido pago local**. No real payment occurs.
|
||||
7. Refresh Kanban. The paid order starts in **Arte recebida**. Move it using
|
||||
the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**,
|
||||
select the manually prepared final files for every item, enter a review note,
|
||||
tick the confirmation and click **Aprovar arquivos finais**. Use the harmless
|
||||
fixture again only for this local test; no printable file is generated.
|
||||
Continue through **Fila de impressão →
|
||||
Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can
|
||||
return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally.
|
||||
8. Inspect **Histórico** and **Eventos locais de integração**. Worker receipts
|
||||
should say recorded locally. Download links expire after five minutes;
|
||||
request another link from Kanban when needed.
|
||||
|
||||
The manual quote step is necessary to enforce the backend trust boundary while
|
||||
automatic pre-flight is deferred. Browser measurements and grade are proposals.
|
||||
Only an authenticated operator can approve them; the server derives prices and
|
||||
freight. Payment accepts a quote ID only. Approved quotes are immutable for
|
||||
24 hours, and repeated/concurrent payment requests return the same order.
|
||||
This local review step does not settle the future automated production checkout.
|
||||
Malware scanning is a separate safety gate and does not inspect dimensions,
|
||||
resolution, colors, printability, or any other pre-flight property.
|
||||
|
||||
The existing client-side previews/analysis remain unchanged and advisory. No new
|
||||
automatic print pre-flight or artwork validation runs on upload; malware scanning
|
||||
is the separate gate described above. Original downloads and manually approved
|
||||
final files are separately labeled. Never print the local text fixture.
|
||||
|
||||
## Customer accounts, corrections, and cart recovery
|
||||
|
||||
Open **Minha conta** on the Site, or `/portal.html`. Create a local account with
|
||||
a test CNPJ, phone, email and password of at least 12 characters. Passwords use
|
||||
the current stronger scrypt format; legacy local hashes are verified and upgraded
|
||||
on successful login. Sessions are stored in PostgreSQL, expire after
|
||||
seven days, and are revoked on logout. Login/registration attempts are limited.
|
||||
No email service is used: email verification and password recovery remain absent.
|
||||
|
||||
Registration associates only the current guest session's uploads, quotes and
|
||||
orders with the new account. Signing in from another browser restores access to
|
||||
that account's orders. Matching an email or CNPJ never grants access to an order.
|
||||
Guests can still order and track within their current session.
|
||||
|
||||
In the portal, **Ver detalhes e arquivos** displays status history, correction
|
||||
reasons, files and expiry dates. When Kanban requests **Correção**, upload corrected
|
||||
files against the relevant item and add a note. The order stays in correction
|
||||
until the operator reviews it. The old final set is invalidated; the operator
|
||||
must upload/approve another complete set before re-entering the queue. Concurrent
|
||||
or repeated submissions with a stale order version are rejected.
|
||||
|
||||
The browser saves unfinished cart items and their File blobs in IndexedDB for
|
||||
24 hours. Reloading restores them as cart rows; remove/replace a row to alter its
|
||||
artwork settings, or add more products. Existing calculations are preserved and
|
||||
delivery must be requoted. Payment clears the saved cart. Cart recovery is local
|
||||
to the browser, not a cross-device artwork library; very large files can exceed
|
||||
browser storage capacity, which is reported visibly. Expired browser entries are
|
||||
removed the next time the Site opens. Server retention does not erase downloaded
|
||||
files or copies stored by the browser/operating system.
|
||||
Logout also clears local checkout keys and IndexedDB File blobs across open Site
|
||||
tabs. Operator authentication uses an expiring, revocable HttpOnly session;
|
||||
browser storage never retains the operator password or a Basic-auth credential.
|
||||
|
||||
## Automated checks
|
||||
|
||||
Pricing parity requires Python 3.10+ and Node 22+ on the host, no package install:
|
||||
|
||||
```bash
|
||||
python3 -m unittest local.test_pricing -v
|
||||
```
|
||||
|
||||
This executes the real pricing constants/functions extracted from `dtf-site.html`
|
||||
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
|
||||
```
|
||||
|
||||
It checks direct two-part upload/resume, missing parts, session isolation,
|
||||
private downloads and byte identity, customer validation, tampered totals,
|
||||
operator corrections, all four products, freight, immutable quotes, concurrent
|
||||
payment retries, state rules, history, and durable fake integration receipts.
|
||||
Each run leaves one clearly labeled test order and an approximately 8 MiB object.
|
||||
The workflow test also checks guest-to-account migration, cross-session login,
|
||||
revoked sessions, ownership isolation, correction submissions, final revision
|
||||
invalidation, secure downloads, and mandatory final-file approval before queueing.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
`local.scanning_test` stores an EICAR fixture as `SECURITY-EICAR.cdr`; ClamAV
|
||||
must reject it, and quote/download gates must remain closed. The rejected fixture
|
||||
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
|
||||
```
|
||||
|
||||
Set `CHROME_BIN` if Chrome is not at `/usr/bin/google-chrome-stable`. The test
|
||||
uses a separate temporary browser profile, walks through the actual Site and
|
||||
Kanban controls, and saves screenshots to `output/local/`. It leaves its local
|
||||
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
|
||||
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.
|
||||
|
||||
Only fake integration adapters and `s3-local` storage are accepted. Startup fails
|
||||
if a production adapter/environment or nonlocal S3 endpoint is selected.
|
||||
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
|
||||
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
|
||||
gates. Rejected/error objects expire within three days. The worker deletes
|
||||
original bytes within seven days of
|
||||
manual final artwork approval, and final/correction bytes by 30 days from the
|
||||
order's first upload. Expired files remain visible as metadata but cannot be
|
||||
downloaded. Retention runs once a minute; MinIO lifecycle is a backstop.
|
||||
|
||||
Upload retries resume completed parts. The saved cart retains files when browser
|
||||
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.
|
||||
|
||||
## Local backup and restore check
|
||||
|
||||
```bash
|
||||
python3 -m local.backup create-and-verify
|
||||
```
|
||||
|
||||
This writes a private four-file bundle in `backups/` (ignored by Git and Docker
|
||||
builds): a PostgreSQL custom-format dump, a gzip object archive, a JSON manifest,
|
||||
and the manifest's SHA-256 sidecar. The object archive includes only complete,
|
||||
unexpired files that ClamAV has marked `clean`; pending, rejected, errored, expired,
|
||||
and purged objects remain excluded. Run the command while uploads and retention are
|
||||
idle so the database dump and subsequent object selection describe the same local
|
||||
state.
|
||||
|
||||
Verification restores the dump into a newly created UUID-named database and the
|
||||
object bytes under a UUID-named `originals/restore-verification/` MinIO prefix. It
|
||||
checks database counts, bundle hashes, every archived object's hash, and the bytes
|
||||
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
|
||||
`backups/...manifest.json` path. Legacy database-only `.dump` backups remain
|
||||
verifiable. Scheduling, offsite copies, and a production restore runbook remain
|
||||
unfinished.
|
||||
|
||||
After building the current image, test retention with synthetic files:
|
||||
|
||||
```bash
|
||||
docker compose exec -T api python -m local.retention_test
|
||||
```
|
||||
|
||||
This checks that expired bytes are removed while unexpired files survive. It
|
||||
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
|
||||
```
|
||||
|
||||
`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
|
||||
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
|
||||
initialization jobs must exit successfully. API health queries PostgreSQL and
|
||||
MinIO; worker health fails if its database-processing loop, scanning thread, or
|
||||
live ClamAV PING is unavailable. Both web gateways expose `/health`. MinIO
|
||||
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
|
||||
```
|
||||
|
||||
Exit status 1 means attention is required. Review blocked artwork, rate limits,
|
||||
failed logins, scanner availability, and signature age. Immediately after the
|
||||
security tests, expected synthetic login failures/rate limits and
|
||||
`SECURITY-EICAR.cdr` rejections will trigger the alert; confirm names and test
|
||||
timing before classifying them as expected. Unexpected failures, scanner errors,
|
||||
stale signatures (more than seven days), or non-test rejected files require
|
||||
investigation. Events are retained for 30 days. See `SECURITY_REPORT.md`.
|
||||
|
||||
If a host port is occupied, change `SITE_PORT`, `KANBAN_PORT`, or `API_PORT` in
|
||||
`.env` and recreate the stack. MinIO currently reserves 9000/9001. If Docker says
|
||||
permission denied, fix Docker access or use your OS-approved Docker workflow.
|
||||
If image/package download fails, check development internet access. A warning
|
||||
about missing Buildx can fall back to Docker's classic builder; install your
|
||||
platform's Buildx plugin for the supported modern build path.
|
||||
|
||||
If API is unhealthy, inspect its logs and check local adapter settings. Database
|
||||
and MinIO credentials initialize persistent volumes only once; changing values
|
||||
later also requires updating the existing database/storage account deliberately.
|
||||
If an upload fails, keep the session and click checkout again. If parts expired,
|
||||
reselect the file to start another upload. For a stale Kanban move, refresh;
|
||||
the backend rejects stale versions instead of overwriting another operator.
|
||||
|
||||
The Site retains its existing remote visual assets (fonts/logo) and optional
|
||||
PDF.js 3.11.174 CDN dependency. Its known eval-based advisory is mitigated in
|
||||
the current call with `isEvalSupported:false`; the old CDN dependency is still
|
||||
an upgrade/vendor-pinning limitation, not a claim that it is current. The local
|
||||
API, manual fixture flow, storage, payment,
|
||||
freight, Kanban, and mock integrations work without these external services.
|
||||
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`
|
||||
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
|
||||
```
|
||||
|
||||
The generator downloads public package metadata in a disposable container and
|
||||
does not modify the host Python environment. Review the resolved versions and run
|
||||
the exact-runtime audit plus regression suite before accepting an updated lock.
|
||||
|
||||
## Staging readiness gate
|
||||
|
||||
`compose.staging.yaml` is intentionally not a staging deployment. It runs only a
|
||||
network-disabled validator for non-secret decisions. After completing
|
||||
`PRODUCTION_INPUTS.md`, copy `staging/staging.env.example` to the ignored
|
||||
`staging/staging.env`, enter non-secret metadata, and run:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.staging.yaml run --rm readiness
|
||||
```
|
||||
|
||||
A pass does not authorize deployment or prove provider access. The real staging
|
||||
composition and external secret injection still require approved provider
|
||||
contracts and owners. See `staging/README.md`.
|
||||
|
||||
## Next production connection step
|
||||
|
||||
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
|
||||
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
|
||||
confirmed contracts and provider idempotency/delivery handling. Do not simply
|
||||
change the local fake flags or point this Compose file at production.
|
||||
|
||||
## Production delivery package
|
||||
|
||||
The repository now includes a separate Docker Swarm and Gitea Actions delivery
|
||||
package in `deploy/` and `.gitea/workflows/`. It is not used by this localhost
|
||||
Compose stack and does not alter its volumes. Production images run as
|
||||
unprivileged users and install the hash-locked Python runtime. Gitea publishes
|
||||
`latest` for the normal Portainer webhook plus the full commit SHA for rollback.
|
||||
The stack expects pre-provisioned external Swarm secrets and an external
|
||||
PostgreSQL volume. Only Site and Kanban ports are published for the existing TLS
|
||||
reverse proxy; API, database, worker, and scanner remain private.
|
||||
|
||||
Run the source-only gate without credentials:
|
||||
|
||||
```bash
|
||||
python3 deploy/production_preflight.py --source-only
|
||||
```
|
||||
|
||||
It must remain blocked while `local/` 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
|
||||
still require the Gitea scan/test gates and explicit approvals.
|
||||
121
PORTAINER.md
Normal file
121
PORTAINER.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Portainer deployment
|
||||
|
||||
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,
|
||||
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.
|
||||
|
||||
Repository variables:
|
||||
|
||||
- `REGISTRY_HOST` — normally `gitea.blyzer.com.br`.
|
||||
- `REGISTRY_OWNER` — normally `blyzer`.
|
||||
- `PYTHON_BASE_IMAGE`, `NGINX_BASE_IMAGE`, `TRIVY_IMAGE` — approved immutable
|
||||
`@sha256:` image references.
|
||||
|
||||
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.
|
||||
|
||||
## 2. One-time Portainer resources
|
||||
|
||||
Use a Docker Swarm environment. Create a dedicated production PostgreSQL volume
|
||||
and set its name as `POSTGRES_VOLUME`. Label its Swarm node
|
||||
`dtf_database=true`. Do not reuse the localhost Compose volume.
|
||||
|
||||
Create each application credential under **Secrets**. Use versioned names such
|
||||
as `dtf_prod_database_url_v1`, then place only those names in the corresponding
|
||||
`*_SECRET` stack variables. The stack expects distinct secrets for:
|
||||
|
||||
- runtime and administrator database URLs;
|
||||
- database administrator and application-role passwords;
|
||||
- R2 access key and secret key;
|
||||
- operator password;
|
||||
- Mercado Pago token and webhook secret;
|
||||
- Tiny/Olist token;
|
||||
- WhatsApp token.
|
||||
|
||||
Credential values must never be entered into Git, `portainer.env`, or Gitea
|
||||
workflow variables.
|
||||
|
||||
## 3. Create the stack once
|
||||
|
||||
In Portainer select **Stacks → Add stack → Git repository**:
|
||||
|
||||
- Name: `dtf-cloud`
|
||||
- Repository: this Gitea repository
|
||||
- Reference: `main`
|
||||
- Compose path: `deploy/stack.yaml`
|
||||
- Registry: the private `gitea.blyzer.com.br` registry
|
||||
- Re-pull image: enabled
|
||||
- Automatic update: webhook
|
||||
|
||||
Load a completed copy of `deploy/portainer.env.example` as the stack's
|
||||
non-secret environment variables. Do not load the example unchanged: `TBD` and
|
||||
zero digests are deliberate blockers. Keep `IMAGE_TAG=latest` for normal
|
||||
webhook deployments.
|
||||
|
||||
Before entering those values in Portainer, validate the completed ignored file:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. deploy/portainer.env
|
||||
set +a
|
||||
python3 deploy/production_preflight.py
|
||||
```
|
||||
|
||||
The public reverse proxy should send the Site hostname to `SITE_PORT` and the
|
||||
Kanban hostname to `KANBAN_PORT`. `/api/` and `/portal.html` are served through
|
||||
the Site gateway. Preserve the original Host header. Do not expose PostgreSQL,
|
||||
API, worker, or ClamAV. Restrict the
|
||||
two published web ports at the host firewall so only the reverse proxy can use
|
||||
them, and terminate HTTPS at the proxy.
|
||||
|
||||
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
|
||||
|
||||
Push to `main`. Gitea validates, tests, scans, publishes these images, and calls
|
||||
the webhook:
|
||||
|
||||
```text
|
||||
gitea.blyzer.com.br/blyzer/dtf-api:latest
|
||||
gitea.blyzer.com.br/blyzer/dtf-api:<full-commit-sha>
|
||||
gitea.blyzer.com.br/blyzer/dtf-web:latest
|
||||
gitea.blyzer.com.br/blyzer/dtf-web:<full-commit-sha>
|
||||
```
|
||||
|
||||
In Portainer, verify the new image digests, service health, `db-init` result, and
|
||||
the public `/health` endpoints. Back up PostgreSQL and R2 before changes that
|
||||
affect stored data or retention.
|
||||
|
||||
## 5. Rollback
|
||||
|
||||
In the Portainer stack variables, change `IMAGE_TAG` from `latest` to the full
|
||||
SHA of the last known-good Gitea commit and redeploy. This rolls back Site, API,
|
||||
Kanban, and worker code; it does not undo database migrations or restore data.
|
||||
After recovery and a corrected release, return `IMAGE_TAG` to `latest`.
|
||||
|
||||
## Current blocker
|
||||
|
||||
This is the final deployment shape, but it is not authorized for production
|
||||
today. Production adapters, Docker-secret file loading, provider sandbox tests,
|
||||
current clean base images, approved inputs, and a production restore rehearsal
|
||||
are still required. Do not bypass `deploy/production_preflight.py` or the Gitea
|
||||
scan gates to make a deployment run.
|
||||
62
PRODUCTION_INPUTS.md
Normal file
62
PRODUCTION_INPUTS.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# DTF staging and production input checklist
|
||||
|
||||
Status: input worksheet only. Nothing in this document authorizes a real
|
||||
integration, production deployment, or use of production credentials.
|
||||
|
||||
Do not put passwords, tokens, webhook secrets, private keys, customer data, or
|
||||
provider recovery codes in this repository. Record only the credential owner and
|
||||
the approved secret-injection location. Test every provider in an isolated staging
|
||||
or sandbox environment before production activation.
|
||||
|
||||
## Decisions required before staging implementation
|
||||
|
||||
| Area | Required decision or evidence | Owner | Status/date |
|
||||
|---|---|---|---|
|
||||
| Artwork acceptance | Decide whether customer-entered length and quality grade are accepted directly, manually approved, or verified by another defined process. Name the commercial authority and rejection/correction flow. | | |
|
||||
| Checkout | Approve the exact transition from quote to payment, including quote expiry, customer confirmation, cancellation, refund, and correction rules. | | |
|
||||
| Infrastructure | Confirm VPS capacity, operating system, domain/subdomain, DNS owner, TLS termination, Portainer access, Gitea Actions path, and deployment/rollback owner. | | |
|
||||
| Cloudflare R2 | Provide a staging bucket and endpoint, CORS policy, retention/lifecycle rules, narrowly scoped credential owner, storage budget, and restore/rollback acceptance test. | | |
|
||||
| PostgreSQL | Define the managed/self-hosted choice, encryption and access policy, backup destination, schedule, retention, restore objective, and named restore-test owner. | | |
|
||||
| Malware signatures | Approve how ClamAV signatures are refreshed without unrestricted scanner egress, plus stale-signature and scanner-outage response. | | |
|
||||
| Freight | Select the source-of-truth platform, staging access, origin address/postal code, services/carriers, packaging weight/dimensions by DTF length, pickup rules, and pass-through/subsidy/free-shipping policy. | | |
|
||||
| Mercado Pago | Provide sandbox account ownership, webhook administration, approved event/status mapping, refund/cancellation policy, reconciliation owner, and secret-injection location. | | |
|
||||
| Tiny/Olist | Confirm API version/endpoints, staging access, order/product/customer mappings, tag/marker behavior, idempotency key, rate limits, retry rules, and reconciliation owner. | | |
|
||||
| WhatsApp | Select the provider, sending number owner, opt-in/legal basis, approved templates for the four agreed events, staging access, retry/delivery-failure policy, and support owner. | | |
|
||||
| Customer accounts | Select email verification and password-recovery provider/flow, session policy, privacy/contact requirements, and customer-support ownership. | | |
|
||||
| Operators | Define named operator provisioning/removal, roles, MFA expectation, emergency access, and periodic access review. Shared production credentials are not acceptable. | | |
|
||||
| Monitoring | Name alert recipients and escalation windows for authentication abuse, malware detections, stale signatures, queue failures, provider failures, backup failures, capacity, and downtime. | | |
|
||||
| Security findings | Record remediation or explicit risk acceptance for the current MinIO, PostgreSQL, Nginx, Debian, and PDF.js findings before a production-readiness review. | | |
|
||||
|
||||
## Evidence required before production activation
|
||||
|
||||
- A separate staging composition/deployment with no production secrets and no
|
||||
route from local fake integrations to real provider accounts.
|
||||
- Server-authoritative pricing regression results, including all four product
|
||||
modes, discounts, assembly charges, minimum length, rounding, and freight.
|
||||
- Direct multipart R2 tests for resume, exact part sizes, private access, CORS,
|
||||
expiry, abort, malware quarantine, secure download, and retention.
|
||||
- Signed and idempotent Mercado Pago webhook tests, including duplicate, delayed,
|
||||
invalid, cancelled, and refunded events. No payment may be created before freight
|
||||
is final.
|
||||
- Idempotent Tiny/Olist and WhatsApp outbox tests covering retry, duplicate delivery,
|
||||
rate limiting, permanent failure, and operator reconciliation.
|
||||
- Account verification/recovery, operator access, TLS, cookie, Host/origin, audit,
|
||||
alert, and incident-response checks in the target architecture.
|
||||
- A scheduled, encrypted, offsite PostgreSQL/object backup and a documented restore
|
||||
rehearsal. The verified localhost bundle is useful evidence, not this production
|
||||
control.
|
||||
- A recorded go/no-go review signed by the business owner, operations owner, and
|
||||
technical owner, with rollback contacts and a support window.
|
||||
|
||||
## Safe implementation order after inputs are approved
|
||||
|
||||
1. Complete the non-secret metadata and pass the network-disabled readiness gate.
|
||||
2. Create the actual separate staging composition and external secret-injection path.
|
||||
3. Validate private R2 multipart storage, malware release gates, downloads,
|
||||
retention, backup, and restore.
|
||||
4. Add freight quotation because its final value is required before payment.
|
||||
5. Add Mercado Pago sandbox checkout and authenticated idempotent webhooks.
|
||||
6. Add Tiny/Olist through the existing outbox/idempotency boundary.
|
||||
7. Add only the four agreed WhatsApp notification events.
|
||||
8. Run the complete functional, security, dependency, image, recovery, and manual
|
||||
acceptance suite in staging before any production activation.
|
||||
662
README.md
Normal file
662
README.md
Normal file
@@ -0,0 +1,662 @@
|
||||
# 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)
|
||||
> and [Kanban](http://localhost:8081). Optional configuration: copy `.env.example`
|
||||
> to `.env`. Follow [LOCAL_SETUP.md](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).
|
||||
> Everything below is preserved historical prototype documentation, not the active
|
||||
> setup or delivery specification. Do not run its production integrations or agent.
|
||||
|
||||
> Documentação para o TI. Reúne o contexto do projeto, as decisões já tomadas,
|
||||
> a arquitetura, o código e o que ainda depende de resposta.
|
||||
> Base: conversas de 29 e 30/08/2026 com Marcus.
|
||||
|
||||
---
|
||||
|
||||
## Por que este projeto existe
|
||||
|
||||
A sala de DTF tem 6 máquinas a 20 m/h. Em dois turnos, das 5h às 20h, isso dá
|
||||
**37.800 metros/mês de capacidade**. A produção real de maio a agosto de 2026
|
||||
ficou em **11.605 metros/mês** — 31% de ocupação. O pico foi junho, com 12.373 m.
|
||||
|
||||
**Não falta máquina. Falta a porta ficar aberta.**
|
||||
|
||||
O que limita a venda é o horário em que conseguimos receber e responder. O
|
||||
pedido que chega às 17h30 espera até as 5h da manhã, porque não há ninguém para
|
||||
receber o arquivo, tratar e mandar imprimir. O cliente de DTF compra por
|
||||
rapidez, e a rapidez tem duas metades: receber e produzir. Hoje as duas param
|
||||
juntas às 20h.
|
||||
|
||||
### O que muda com o projeto
|
||||
|
||||
| | Hoje · 18k m | Com 24h · 36k m |
|
||||
|---|---|---|
|
||||
| Custo por metro | R$ 9,43 | R$ 7,46 |
|
||||
| Receita | R$ 268.200 | R$ 536.400 |
|
||||
| **Resultado** | R$ 77.011 | **R$ 225.032** |
|
||||
| Margem | 28,7% | 42,0% |
|
||||
|
||||
**Ganho de R$ 148 mil/mês, R$ 1,78 milhão/ano**, com a folha subindo 20%
|
||||
(R$ 6.333). Cada metro acima de 18 mil rende R$ 8,76 de margem de contribuição —
|
||||
insumo e manutenção custam R$ 4,94 e não mudam; folha, fixo e administrativo já
|
||||
estão pagos.
|
||||
|
||||
Contra isso, a infraestrutura do projeto custa **R$ 160 a 450 por mês**.
|
||||
|
||||
### Os quatro furos do fluxo atual
|
||||
|
||||
1. **A arte entra por WhatsApp** — canal que não registra nada auditável
|
||||
2. **O número do pedido é digitado à mão** no nome do arquivo; se errar, a arte
|
||||
some no servidor e ninguém descobre até o cliente cobrar
|
||||
3. **O status vive numa pasta de rede** — o Tiny sabe do pedido no começo e no
|
||||
fim, no meio ninguém sabe
|
||||
4. **Ninguém mede o tempo de espera** entre etapas
|
||||
|
||||
O item 4 é o que mais importa. Sem ele, não dá para saber se o gargalo é máquina
|
||||
ou designer — e essa resposta decide se vale montar o terceiro turno.
|
||||
|
||||
---
|
||||
|
||||
Três componentes que fazem a sala de DTF receber arte 24 horas e medir o próprio tempo.
|
||||
|
||||
> **Comece pelo `TAREFAS.md`** — ordem de execução com critério de aceite.
|
||||
> Depois `ESPECIFICACAO.md` — ele traz as fases de implementação, as
|
||||
> funcionalidades uma a uma, e o que mudou depois das reuniões com os designers e
|
||||
> a sala em 02/09/2026. Este README cobre a arquitetura.
|
||||
|
||||
```
|
||||
TAREFAS.md o que fazer, em ordem, com critério de aceite
|
||||
ESPECIFICACAO.md as funcionalidades uma a uma e o que mudou nas reuniões
|
||||
API.md contratos de todos os endpoints
|
||||
schema.sql banco completo, nuvem e local
|
||||
README.md arquitetura e decisões (este arquivo)
|
||||
|
||||
portal/ FastAPI na nuvem · recebe a arte, valida, repete, fatia, carimba
|
||||
agente/ script na fábrica · traz o arquivo aprovado para o servidor
|
||||
kanban/ FastAPI local · a sala move o card, o Tiny recebe o marcador
|
||||
static/kanban.html front já conectado à API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## O que cada um faz
|
||||
|
||||
**portal** — aberto na internet, roda em VPS. O Tiny avisa por webhook que
|
||||
nasceu um pedido; o portal cria um link com token e manda no WhatsApp. O cliente
|
||||
sobe o arquivo direto para o storage, sem passar pelo servidor. O robô valida,
|
||||
empilha as repetições, fatia em partes de no máximo 15 m e carimba a última.
|
||||
|
||||
**agente** — roda como serviço no servidor da fábrica. Busca o que foi aprovado
|
||||
e grava na pasta `00_ARTE_RECEBIDA`. Se a internet cair, ele para e o portal
|
||||
continua recebendo; quando voltar, baixa o acumulado.
|
||||
|
||||
**kanban** — tela da sala, só na rede interna. Mover o card grava o movimento,
|
||||
move o arquivo entre as pastas e enfileira o marcador no Tiny e o WhatsApp.
|
||||
|
||||
---
|
||||
|
||||
## Regras do processo, em código
|
||||
|
||||
| Regra | Onde está |
|
||||
|---|---|
|
||||
| Área útil 57 × 97 cm (30 mm de rodapé) | `preflight.AREA_UTIL_CM` |
|
||||
| DPI efetivo mínimo 150, ideal 300 | `preflight.validar()` |
|
||||
| Largura máxima 57 cm | `preflight.validar()` |
|
||||
| Repetição da arte | `preflight.empilhar()` |
|
||||
| Montagem empilhada de vários arquivos | `preflight.montar_folha()` |
|
||||
| Normalização por natureza do arquivo | `preflight.normalizar()` |
|
||||
| CDR entra sem validar, etiqueta "conferir" | `preflight.FORMATOS_SEM_VALIDACAO` |
|
||||
| Máximo 20 m por arquivo | `preflight.fatiar()` |
|
||||
| Carimbo: QR 20 mm + texto, só preto | `preflight.carimbar()` |
|
||||
| Um carimbo por pedido, na última parte | `preflight.processar()` |
|
||||
|
||||
**A regra que mais pega arquivo ruim é o DPI efetivo.** O valor gravado no
|
||||
cabeçalho mente com frequência — o cliente escala a imagem e o programa mantém
|
||||
"300". O que vale é `pixels ÷ (centímetros comprados ÷ 2,54)`.
|
||||
|
||||
---
|
||||
|
||||
## Decisões já tomadas
|
||||
|
||||
Não precisam ser rediscutidas. Estão implementadas no código.
|
||||
|
||||
| Assunto | Decisão | Por quê |
|
||||
|---|---|---|
|
||||
| Chave do pedido | **número do Tiny** | é o que a empresa inteira já usa |
|
||||
| Gatilho do link | **webhook do Tiny**, não polling | o link sai no segundo em que o pedido nasce |
|
||||
| Hospedagem do portal | **fora da fábrica** | se a internet cair às 3h, o cliente sobe do mesmo jeito |
|
||||
| Hospedagem do kanban | **rede interna** | é tela de operação, funciona sem internet |
|
||||
| Upload | **direto para o storage**, URL pré-assinada | 200 MB passando pelo VPS derrubaria o processo |
|
||||
| Limite do site | VNDA não aceita 200 MB | por isso o portal é externo |
|
||||
| Faixa do rodapé | **30 mm** → área útil 57 × 97 cm | cabe o QR de 20 mm com folga |
|
||||
| QR | **20 mm**, leitura por **celular** | não haverá coletor; celular lê com folga |
|
||||
| Conteúdo do QR | **URL do card no kanban** | a revisão bipa e cai na tela do pedido |
|
||||
| Carimbo | **um por pedido**, na última parte | é ela que fecha o pedido |
|
||||
| Cor do carimbo | **só canal preto** | sem branco de apoio: economiza a tinta mais cara |
|
||||
| Repetição | **campo no portal**, robô empilha | tira do designer o trabalho mais braçal |
|
||||
| Limite por arquivo | **20 metros** | 100 m viram 5 arquivos |
|
||||
| Tamanho do upload | **5 GB** | é o que os PCs da sala aguentam abrir |
|
||||
| Retenção | **30 dias** | depois apaga do storage |
|
||||
| Montagem | **empilhada**, sem encaixe lado a lado | são artes de 1 m em sequência |
|
||||
| CDR | entra com etiqueta **conferir** | a licença do Corel é de estação, não de servidor |
|
||||
| Movimento | **no kanban**, pasta espelha | sem watchdog: uma peça a menos para manter |
|
||||
| Qualidade na mensagem | **% e DPI** juntos | um para leigo, outro para quem é do ramo |
|
||||
| Marcadores no Tiny | **3, não 7** | o kanban é a fonte de verdade |
|
||||
| Avisos ao cliente | **3 + correção** | sete viraria spam e ele para de ler |
|
||||
| Designer de madrugada | **não haverá** | a noite imprime o que já estava tratado |
|
||||
|
||||
### O que o robô NÃO faz
|
||||
|
||||
Tratar arte continua sendo trabalho do designer: remover fundo quando o cliente
|
||||
não removeu, corrigir cor para o perfil da impressora, e julgar o que não presta
|
||||
mesmo passando na validação técnica — degradê que vira mancha branca, traço fino
|
||||
que some na aplicação.
|
||||
|
||||
O robô filtra o que **nem deveria chegar** ao designer: DPI baixo, fundo não
|
||||
removido, largura acima de 57 cm, arquivo corrompido. Isso volta ao cliente
|
||||
automaticamente e não consome minuto de arte nenhum.
|
||||
|
||||
**Isso gera o número que decide o terceiro turno:** quanto do tempo do designer é
|
||||
retrabalho de arquivo ruim, e quanto é trabalho de verdade. O kanban mede isso na
|
||||
primeira semana, olhando o tempo entre `Arte recebida` e `Arte tratada`.
|
||||
|
||||
### Referência de mercado
|
||||
|
||||
O **dtflexprint.com.br**, de Salvador, já opera este modelo: upload no próprio
|
||||
produto, pré-flight automático, sem receber arquivo por WhatsApp. **Cobram
|
||||
R$ 55,00 pela mesma unidade de 57 × 100 cm que vendemos a R$ 14,90.**
|
||||
|
||||
A guerra de preço é local; o mercado não é. Depois de resolver a dinâmica de
|
||||
horário, o passo seguinte é logística nacional.
|
||||
|
||||
---
|
||||
|
||||
## Fase 1 — limpeza dos marcadores do Tiny
|
||||
|
||||
Primeira entrega do projeto. Não depende de nada e pode começar hoje.
|
||||
|
||||
### Regra que não pode ser violada
|
||||
|
||||
**Limpar a lista de sugestão, não o histórico dos pedidos.** Apagar marcador de
|
||||
pedido já fechado destrói o pouco de dado que existe sobre o passado. O que
|
||||
atrapalha é a lista de opções aparecer poluída na hora de marcar um pedido novo.
|
||||
|
||||
### O que sai da lista
|
||||
|
||||
Todas as variações de horário e duração digitadas à mão. Existem dezenas, quase
|
||||
todas com um ou dois pedidos:
|
||||
|
||||
```
|
||||
inicio 14:45 inicio13:34 inicio 12:38hr inicio 930 INICIO 15:09
|
||||
11:10 15:30hr 1hrs 3h 3hr
|
||||
2hrs 1h30min +20m +30min de correcao
|
||||
+1hr (correcao)
|
||||
```
|
||||
|
||||
Alguém na sala está tentando registrar tempo de máquina há meses, à mão, sem
|
||||
padrão nenhum. **O kanban passa a fazer isso sozinho e melhor**, com carimbo
|
||||
automático de entrada e saída em cada etapa. Esse trabalho manual deixa de
|
||||
existir — e é bom dizer isso à sala, porque é esforço real que estava sendo
|
||||
desperdiçado.
|
||||
|
||||
### O que fica
|
||||
|
||||
| Marcador | Pedidos | Para quê |
|
||||
|---|---|---|
|
||||
| `fila de impressao` | 2.004 | coluna do kanban |
|
||||
| `CORRECAO DTF` | 116 | coluna do kanban |
|
||||
| `Maq 1` … `Maq 6` | 3.406 | histórico de máquina |
|
||||
| `multiempresa`, `VALE`, `Troca`, `BOLETO NEXSTAR SICCOB` | — | outra natureza, não mexer |
|
||||
|
||||
### O que nasce
|
||||
|
||||
`DTF-PRODUCAO` e `DTF-PRONTO`. Só dois — ver a seção de marcadores acima.
|
||||
|
||||
### Atenção: os marcadores de duração NÃO são poluição
|
||||
|
||||
```
|
||||
30 min → 2.430 pedidos
|
||||
1 hora → 833 pedidos
|
||||
3 horas → 40 pedidos
|
||||
```
|
||||
|
||||
Estes são padronizados e parecem ser a **estimativa de minutos de máquina do
|
||||
pedido** — exatamente o dado que o kanban precisa para calcular fila contra
|
||||
capacidade.
|
||||
|
||||
Hoje o código estima esse tempo pelos metros, a 20 m/h
|
||||
(`kanban/main.py`, campo `minutos_maquina`). Mas arte complexa pode demorar mais
|
||||
que arte simples do mesmo tamanho.
|
||||
|
||||
**Confirmar com o Thales antes de apagar:** esses marcadores são estimativa de
|
||||
tempo de máquina ou outra coisa? Se forem estimativa e refletirem algo que a
|
||||
sala sabe e o metro não captura, viram **campo no card**, não marcador — e o
|
||||
cálculo de capacidade fica mais preciso que a conta por metragem.
|
||||
|
||||
---
|
||||
|
||||
## Como o arquivo chega na máquina
|
||||
|
||||
**Desenho novo, decidido em 30/08/2026.** O PC central deixa de ser passagem
|
||||
obrigatória e vira posto de validação e exceção.
|
||||
|
||||
### Antes
|
||||
|
||||
```
|
||||
PC central → abre o arquivo, aplica o SPOT à mão, salva de novo na pasta
|
||||
↓
|
||||
PC da máquina → alguém pega o arquivo e toca no software
|
||||
```
|
||||
|
||||
Todos os arquivos passavam por uma máquina e uma pessoa. Se ela saía, a fila
|
||||
parava. Às 3h da manhã, não havia ninguém.
|
||||
|
||||
### Agora
|
||||
|
||||
```
|
||||
portal → robô (valida · monta · SPOT · carimba) → fila única
|
||||
↓
|
||||
operador clica "puxar próximo" na máquina
|
||||
↓
|
||||
arquivo cai na hot folder 30_MAQ1 … 30_MAQ6
|
||||
↓
|
||||
FlexiPRINT processa sozinho de lá
|
||||
```
|
||||
|
||||
O operador abre no Flexi, roda, e arrasta o card para Finalizado. **Um clique no
|
||||
começo, um arraste no fim.** Nunca escolhe pedido, nunca procura arquivo, nunca
|
||||
decide ordem.
|
||||
|
||||
### Por que puxada e não empurro
|
||||
|
||||
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.**
|
||||
|
||||
Três regras que sustentam isso:
|
||||
|
||||
| Regra | Por quê |
|
||||
|---|---|
|
||||
| Reserva expira em **3 min** | ajustado pela sala: 15 era demais |
|
||||
| Devolver põe no **topo**, não no fim | o pedido já esperou uma vez |
|
||||
| **Um pedido por vez** | a sala preferiu assim a puxar lote de trabalho |
|
||||
|
||||
Ver `/api/puxar` e `/api/devolver` em `kanban/main.py`.
|
||||
|
||||
### O que sobra para o PC central
|
||||
|
||||
Validação, exceções e o SPOT manual dos casos que a regra automática não cobre.
|
||||
Deixa de estar no caminho crítico de todo pedido.
|
||||
|
||||
---
|
||||
|
||||
## A validar com os designers e a sala — SPOT
|
||||
|
||||
O canal branco passa a ser gerado pelo robô. A regra base é medível e cabe em
|
||||
poucas linhas, mas existem variações que **precisam ser confirmadas antes de
|
||||
codificar**.
|
||||
|
||||
### O que já é regra, não julgamento
|
||||
|
||||
**Choke** (contração do branco) evita que o branco apareça na borda quando o
|
||||
registro sai levemente fora. O problema é que em traço fino ele come a linha:
|
||||
um filete de 0,4 mm a 300 DPI tem ~5 px; tirando 2 de cada lado sobra 1, e o
|
||||
branco quase some.
|
||||
|
||||
Isso é medível. O robô mede a espessura de cada elemento e aplica choke
|
||||
proporcional — **na mesma arte, texto grosso encolhe e filete fino não**:
|
||||
|
||||
| Espessura do elemento | Choke |
|
||||
|---|---|
|
||||
| acima de 2 mm | 2 px |
|
||||
| 1 a 2 mm | 1 px |
|
||||
| abaixo de 1 mm | nenhum |
|
||||
|
||||
### O que precisa ser validado
|
||||
|
||||
| Ponto | Pergunta para a sala |
|
||||
|---|---|
|
||||
| **Valores do choke** | 2 px acima de 2 mm está certo, ou vocês usam outro número? |
|
||||
| **Limite do traço fino** | abaixo de quanto vocês nunca aplicam contração? |
|
||||
| **Branco em degradê** | onde a arte tem transparência parcial, o branco fica meio transparente. Vocês ajustam? Como? |
|
||||
| **Densidade do branco** | arte escura pede mais branco que arte clara? Isso é ajustado hoje? |
|
||||
| **Tipo de tecido** | o branco muda conforme o cliente vai aplicar em algodão ou poliéster? |
|
||||
| **Casos que nunca dão certo no automático** | quais? |
|
||||
|
||||
### Como conduzir a conversa
|
||||
|
||||
Não perguntar "o que vocês fazem". Pedir **cinco arquivos**:
|
||||
|
||||
- dois que sempre saem bem no automático
|
||||
- dois que sempre precisam de ajuste
|
||||
- um que já deu errado
|
||||
|
||||
Com esses cinco dá para descobrir se a regra cabe em três linhas de código ou se
|
||||
há julgamento de verdade envolvido.
|
||||
|
||||
**Aposta:** 80% dos casos cabem na regra de espessura. Os 20% restantes viram
|
||||
uma etiqueta `spot manual` no card e vão para o PC central como **exceção, não
|
||||
como padrão**.
|
||||
|
||||
**Isso decide a madrugada:** se o SPOT manual for exceção, o turno da noite roda
|
||||
no automático e deixa as exceções para o dia. Não precisa de designer de plantão.
|
||||
|
||||
---
|
||||
|
||||
## Insumos: tudo que passa pelo depósito já está medido
|
||||
|
||||
O aproveitamento do filme sai das transferências para o depósito de impressão no
|
||||
Tiny. O mesmo caminho serve para todo o resto:
|
||||
|
||||
| Insumo | Consumo teórico por 100 m | O que o desvio revela |
|
||||
|---|---|---|
|
||||
| Filme | 100 m comprados → 90 úteis | acerto, prova de cor, refile, ponta |
|
||||
| Tinta | 1,5 L | purga excessiva, vazamento, apontamento errado |
|
||||
| Poliamida | 2,5 kg | idem |
|
||||
| Peças de máquina | — | custo por metro, sem teórico |
|
||||
|
||||
O sistema sabe quantos metros imprimiu, então calcula o teórico e compara com o
|
||||
transferido. Tinta a 1,8 L por 100 m significa 20% de desvio — hoje ninguém
|
||||
saberia.
|
||||
|
||||
---
|
||||
|
||||
## FlexiPRINT · hot folder confirmado, arquitetura a definir
|
||||
|
||||
A documentação oficial da SAi confirma: **o Flexi tem hot folder**, uma pasta
|
||||
por dispositivo de saída, monitorada continuamente. Arquivo copiado ou movido
|
||||
para dentro entra na fila automaticamente. Por padrão fica em
|
||||
`C:\Program Files\[Software]\Jobs`.
|
||||
|
||||
A versão 21 traz dois recursos relevantes:
|
||||
|
||||
- **"RIP only" ao receber na hot folder** — processa o arquivo sem esperar a
|
||||
máquina ficar livre
|
||||
- **Opções melhoradas de branco para DTF** e ferramenta de fundo transparente
|
||||
|
||||
### O problema de memória
|
||||
|
||||
Relato da sala: **o PC que roda a máquina não aguenta preparar outro arquivo ao
|
||||
mesmo tempo** — o RIP consome memória demais. É por isso que existe o PC
|
||||
central hoje.
|
||||
|
||||
Duas arquiteturas possíveis, e a escolha depende do que a licença de vocês
|
||||
permite:
|
||||
|
||||
**A · Production Manager centralizado.** Se o Flexi permitir gerenciar a fila de
|
||||
várias impressoras a partir de um PC só, o **PC central vira servidor de RIP** e
|
||||
os PCs das máquinas ficam leves — só transmitem dados para a impressora.
|
||||
Resolve o problema sem trocar computador.
|
||||
|
||||
**B · RIP distribuído.** Cada PC ripa o próprio trabalho. Aí o "RIP only" ajuda,
|
||||
porque o arquivo é processado enquanto a máquina ainda roda o anterior — mas o
|
||||
PC precisa aguentar as duas coisas.
|
||||
|
||||
**Levantamento pendente com Thales e Alexandre** (ver `dtf-roteiro-reuniao.md`,
|
||||
bloco E2):
|
||||
|
||||
- edição e versão do Flexi **em cada máquina** — se forem diferentes, o
|
||||
comportamento da hot folder varia entre elas
|
||||
- a hot folder aceita pasta de rede ou só local?
|
||||
- existe "RIP only" na versão instalada?
|
||||
- o Production Manager centraliza várias impressoras?
|
||||
- o Flexi gera o canal branco a partir da transparência?
|
||||
- as licenças são por PC ou flutuantes?
|
||||
|
||||
**Enquanto isso não fecha, não programe a fase 4.** O desenho da hot folder
|
||||
depende de a pasta de rede funcionar.
|
||||
|
||||
---
|
||||
|
||||
## Instalação
|
||||
|
||||
### Portal (VPS Ubuntu)
|
||||
|
||||
```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
|
||||
cp .env.exemplo .env # preencher
|
||||
cd portal && uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Nginx faz proxy para a 8000 e o certbot cuida do TLS em
|
||||
`arte.dropstaratacado.com.br`.
|
||||
|
||||
### Agente (servidor da fábrica)
|
||||
|
||||
Windows, como serviço:
|
||||
|
||||
```
|
||||
nssm install DtfAgente "C:\Python312\python.exe" "C:\dtf\agente.py"
|
||||
nssm set DtfAgente AppEnvironmentExtra BASE_PORTAL=... AGENTE_TOKEN=...
|
||||
nssm start DtfAgente
|
||||
```
|
||||
|
||||
Linux: usar `agente/dtf-agente.service`.
|
||||
|
||||
### Kanban (servidor da fábrica)
|
||||
|
||||
```bash
|
||||
cd kanban && uvicorn main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
O front é o protótipo `dtf-portal-kanban.html`, apontando para `/api/quadro`
|
||||
e `/api/mover`. Colocar em `kanban/static/kanban.html`.
|
||||
|
||||
---
|
||||
|
||||
## Estrutura de pastas no servidor
|
||||
|
||||
```
|
||||
\\servidor\DTF\
|
||||
00_ARTE_RECEBIDA 10_ARTE_TRATADA 20_FILA_IMPRESSAO
|
||||
30_IMPRIMINDO 40_CORRECAO 50_APLICACAO
|
||||
60_FINALIZADO _ERRO _log
|
||||
```
|
||||
|
||||
O banco é a fonte de verdade e a pasta é espelho. Se divergirem, o banco ganha.
|
||||
|
||||
---
|
||||
|
||||
## Tabelas
|
||||
|
||||
| Tabela | Onde | Para quê |
|
||||
|---|---|---|
|
||||
| `pedido` | nuvem | número do Tiny, cliente, token e validade |
|
||||
| `arte` | nuvem | arquivo, DPI, status, motivo da recusa |
|
||||
| `parte` | nuvem + local | cada arquivo gerado pelo fatiamento |
|
||||
| `card` | local | posição atual no kanban |
|
||||
| **`movimento`** | local | **de onde saem todos os números** |
|
||||
| `job` | local | fila de marcador e WhatsApp, com retentativa |
|
||||
|
||||
### Normalização: o cliente manda o que quiser
|
||||
|
||||
Não existe "formato certo" único — normalizar tudo para um só jogaria fora o
|
||||
melhor de cada tipo. A regra é por **natureza do conteúdo**:
|
||||
|
||||
| Chega como | Sai como | Por quê |
|
||||
|---|---|---|
|
||||
| PDF, AI, SVG, EPS | **PDF**, mantendo vetor | rasterizar aqui é perder qualidade |
|
||||
| PNG, TIF, PSD, PSB | **TIF** com alfa, LZW | é o que a sala já usa |
|
||||
| CDR | não converte | sem Corel no servidor |
|
||||
|
||||
**O original é sempre preservado.** Pedido dos designers: se a conversão sair
|
||||
ruim num caso, eles voltam ao arquivo do cliente.
|
||||
|
||||
**Resolve o limite dos 5 metros.** Os designers relatam abrir PDF no Corel para
|
||||
não rasterizar, exportar, abrir no Photoshop — e esbarrar em 5 metros. O limite
|
||||
não é do Photoshop: é do formato **PSD**, que trava em 30.000 px por dimensão,
|
||||
o que a 150 DPI dá 5,08 m. PSB vai a 300.000 px, ou 50 metros. Em PDF vetorial
|
||||
não há esse limite.
|
||||
|
||||
**Melhor ainda:** PDF vetorial pode ir do portal direto para a hot folder. O
|
||||
FlexiPRINT rasteriza na resolução exata da máquina, na hora de imprimir —
|
||||
qualidade melhor que qualquer caminho manual, e uma etapa a menos.
|
||||
|
||||
**CMYK vira RGB na normalização.** Arquivo em CMYK é uma das causas de cor
|
||||
errada no DTF: o cliente monta em CMYK, a máquina trabalha em RGB, e a cor muda.
|
||||
|
||||
### Retrabalho: meta por causa, não meta única
|
||||
|
||||
SKU `CRRMP.TX.100CM`. A Mayana abre e classifica a causa; Thales ou Alexandre
|
||||
autorizam, porque o retrabalho da casa entra na meta e na remuneração deles.
|
||||
**A causa fica travada depois de aberta** — com indicador atrelado a bônus,
|
||||
haveria incentivo para reclassificar falha de máquina como culpa do cliente.
|
||||
Quem discorda contesta, e a contestação fica registrada.
|
||||
|
||||
| Causa | Responsável | Hoje | Meta |
|
||||
|---|---|---|---|
|
||||
| Falha de impressão | Thales e Alexandre | 1,8% | 0,4% |
|
||||
| Perfil de cor | Thales e Alexandre | — | 0,4% |
|
||||
| Erro de tratamento | designers | 1,1% | 0,4% |
|
||||
| Erro de pedido | comercial | — | 0,4% |
|
||||
| Arte do cliente | — | 0,9% | fora da meta |
|
||||
| **Total da casa** | | **2,9%** | **1,6%** |
|
||||
|
||||
**Perfil de cor virou causa própria.** Cor errada é o retrabalho mais comum de
|
||||
DTF e quase nunca é defeito de máquina. Separando de "falha de impressão", dá
|
||||
para saber quanto é arte em CMYK, quanto é monitor do cliente e quanto é perfil
|
||||
da impressora desatualizado — só o último é da casa.
|
||||
|
||||
**Coluna nova: aprovação de cor.** Antes de imprimir o pedido inteiro, sai uma
|
||||
amostra, foto pelo WhatsApp e o cliente aprova. Dispara sozinho em arquivo acima
|
||||
de 10 m, primeiro pedido do cliente, ou cor fora do gamut CMYK. Custa
|
||||
centímetros de filme e evita metros de reposição.
|
||||
|
||||
**Não existe coluna de aplicação.** A sala só imprime o filme — quem aplica na
|
||||
peça é o cliente.
|
||||
|
||||
Meta única de 1,5% penalizaria o designer por falha de máquina e exigiria
|
||||
derrubar 64% do retrabalho de uma vez. Meta inatingível empurra para
|
||||
reclassificar causa, não para reduzir defeito. Ver `META_POR_CAUSA` e
|
||||
`/api/relatorio/retrabalho`.
|
||||
|
||||
### Os marcadores de duração são estimativa de máquina
|
||||
|
||||
Confirmado pela sala em 02/09/2026. `30 min` (2.430 pedidos), `1 hora` (833) e
|
||||
`3 horas` (40) **não são lixo de cadastro** — são a estimativa de quanto tempo o
|
||||
trabalho leva na máquina.
|
||||
|
||||
**Não somem: viram o campo `minutos_maquina` no card.** É esse número que o
|
||||
kanban usa para calcular fila contra capacidade do dia. Sem ele, a estimativa
|
||||
sai dos metros a 20 m/h — mas arte complexa demora mais que arte simples do
|
||||
mesmo tamanho, e a sala sabe qual é qual.
|
||||
|
||||
**Quem preenche:** o sistema sugere pelo tamanho; quem trata a arte ajusta só
|
||||
quando foge do normal.
|
||||
|
||||
| Marcador | Destino |
|
||||
|---|---|
|
||||
| `inicio 14:45` e variações | **some** — o sistema carimba a hora do movimento real |
|
||||
| `Maq 1` … `Maq 6` | vira etiqueta no card, sai do Tiny |
|
||||
| `fila de impressao` | vira coluna do kanban |
|
||||
| `CORRECAO DTF` | continua no Tiny — o comercial precisa ver |
|
||||
| **`30 min` · `1 hora` · `3 horas`** | **vira campo `minutos_maquina`** |
|
||||
|
||||
### Marcadores no Tiny: três, não sete
|
||||
|
||||
O kanban é a fonte de verdade da operação. O Tiny recebe só o estágio grosso,
|
||||
para quem não abre o kanban — comercial no telefone, expedição, celular:
|
||||
|
||||
| Movimento no kanban | Marcador no Tiny |
|
||||
|---|---|
|
||||
| recebida · tratada · fila · aplicação | nenhum |
|
||||
| entra em **Imprimindo** | `DTF-PRODUCAO` |
|
||||
| vai para **Correção** | `CORRECAO DTF` |
|
||||
| **Finalizado** | `DTF-PRONTO` |
|
||||
|
||||
Três chamadas por pedido em vez de sete. A 213 pedidos/dia, 640 requisições
|
||||
em vez de 1.500 — folga no limite da API e menos job para monitorar.
|
||||
A máquina que imprimiu fica só no kanban.
|
||||
|
||||
### Artes guardadas para recompra
|
||||
|
||||
Prefixo separado no storage: `artes-cliente/{numero_cliente}/`, retenção de
|
||||
**12 meses** — contra os 30 dias do arquivo de trabalho. Guarda-se só a **arte
|
||||
tratada**, não o original: é menor e é a que vai para a máquina.
|
||||
|
||||
O cliente acessa por um link permanente dele (token de cliente, não de pedido),
|
||||
numa página "minhas artes", e pede reimpressão com um clique — que abre pedido
|
||||
novo no Tiny. Resolve o caso de quem quer a mesma arte de dois meses atrás.
|
||||
|
||||
### Abrir o arquivo pelo kanban · sem instalar nada
|
||||
|
||||
**Decisão de 30/08/2026: nada instalado nos PCs da sala. Só a tela.**
|
||||
|
||||
Navegador não lança programa externo — é bloqueio de segurança, sem contorno.
|
||||
Então o desenho é:
|
||||
|
||||
1. **Miniatura no card** — o operador confere a arte sem sair da tela
|
||||
2. **Botão copia o caminho** — cola no FlexiPRINT
|
||||
3. **File System Access API** — Chrome ou Edge pede permissão à pasta de rede
|
||||
uma vez; a partir daí o kanban lê e move arquivo direto do navegador
|
||||
|
||||
O item 3 tem uma consequência boa: **a movimentação de arquivo sai do backend.**
|
||||
O `mover_arquivos()` em `kanban/main.py` vira opcional — quem move é a própria
|
||||
tela. Menos código no servidor e menos chance de o estado divergir.
|
||||
|
||||
O que continua manual: colar o caminho no Flexi. Uma tecla a mais que o ideal.
|
||||
Handler local resolveria, mas exige instalação em cada máquina — descartado.
|
||||
|
||||
### Aproveitamento do filme: sai do Tiny, sem apontamento novo
|
||||
|
||||
A Altus transfere o insumo para um depósito de impressão antes de rodar, porque
|
||||
também **revende insumo** — o depósito separa consumo interno de revenda.
|
||||
|
||||
Isso significa que o consumo de filme **já é registrado hoje**. O painel só lê a
|
||||
movimentação desse depósito e divide os metros faturados pelos metros
|
||||
transferidos. Nenhum gesto novo para a sala.
|
||||
|
||||
Ver `DEPOSITO_IMPRESSAO` e `tiny.transferido_para_deposito()`.
|
||||
|
||||
`movimento` é a mais importante. Dela saem tempo por etapa, fila por máquina,
|
||||
taxa de retrabalho e ocupação real — os números que hoje não existem em lugar
|
||||
nenhum. Ver `/api/relatorio/etapas`.
|
||||
|
||||
---
|
||||
|
||||
## Decisões que dependem de você, Wagner
|
||||
|
||||
1. **Servidor da fábrica é Windows ou Linux?** Muda como o agente vira serviço.
|
||||
2. **A conta do Tiny já tem aplicação na API v3?** Precisamos de client id e secret.
|
||||
3. **Confirmar na documentação do Tiny** o endpoint de marcadores e o limite de
|
||||
requisições por minuto. `tiny.py` isola isso — se mudar, muda só ali.
|
||||
4. **O número do WhatsApp do DTF migra para a API oficial da Meta**, ou fica com
|
||||
provedor tipo Z-API? A oficial exige template aprovado para mensagem iniciada
|
||||
por nós, mas não corre risco de bloqueio.
|
||||
5. **Latência da integração VNDA → Tiny.** É ela que define quanto tempo passa
|
||||
entre a compra e o link chegar no cliente. Se for alta, o argumento de
|
||||
rapidez do projeto perde força.
|
||||
6. **Retenção dos arquivos: 90 dias.** Configurar regra de ciclo de vida no
|
||||
bucket. A arte é propriedade do cliente.
|
||||
|
||||
---
|
||||
|
||||
## Segurança
|
||||
|
||||
- Token do link: UUID v4, 7 dias, um por pedido
|
||||
- HTTPS obrigatório no portal, Let's Encrypt com renovação automática
|
||||
- Kanban **sem porta aberta para fora** — se precisar de acesso remoto, VPN
|
||||
- ClamAV no arquivo recebido antes de liberar para o agente
|
||||
- Dump diário do Postgres para o storage
|
||||
- Heartbeat do agente a cada 5 min; sem sinal por 15 min, alertar o TI
|
||||
|
||||
Serviço silencioso parado é pior que erro barulhento — ninguém percebe até o
|
||||
cliente cobrar.
|
||||
|
||||
---
|
||||
|
||||
## Ordem de entrega
|
||||
|
||||
| Fase | Entrega | Já dá valor sozinha? |
|
||||
|---|---|---|
|
||||
| 1 | Limpar marcadores — ver seção própria | **sim**: destrava o kanban e some com trabalho manual da sala |
|
||||
| 2 | Portal + robô + storage no ar | **sim**: recebimento 24h e recusa automática |
|
||||
| 3 | Agente rodando como serviço | **sim**: arquivo cai na pasta sozinho |
|
||||
| 4 | Kanban + Tiny + WhatsApp | é o que transforma movimento em número |
|
||||
|
||||
As fases 2 e 3 entregam sozinhas. A fase 4 é onde a medição começa.
|
||||
Binary file not shown.
175
SECURITY_REPORT.md
Normal file
175
SECURITY_REPORT.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Local security closeout
|
||||
|
||||
Date: 2026-09-15
|
||||
|
||||
## Scope and conclusion
|
||||
|
||||
This report covers the localhost-only Site, Portal/API, Kanban, PostgreSQL,
|
||||
MinIO, ClamAV, and fake integration worker. It does not approve production use
|
||||
or real Mercado Pago, R2, Tiny/Olist, WhatsApp, freight, or other providers.
|
||||
Malware scanning is a quarantine control; it is not artwork or print pre-flight.
|
||||
|
||||
The local stack is healthy and its security regressions pass. Existing orders
|
||||
and the named PostgreSQL/MinIO volumes were preserved. Open dependency and image
|
||||
findings remain and are recorded below; there is no claim of zero vulnerabilities,
|
||||
complete OWASP compliance, or production readiness.
|
||||
|
||||
## Implemented controls
|
||||
|
||||
- Loopback-only published ports, local-only adapter guards, internal API/worker/
|
||||
database/scanner network, Host checks, cross-origin write rejection, CSP,
|
||||
frame denial, MIME sniffing protection, and no interactive API documentation.
|
||||
- Escaped untrusted filenames and browser regression coverage with CSP bypassed.
|
||||
- Expiring, revocable, hashed HttpOnly operator sessions. No Basic credentials or
|
||||
operator password are retained in browser storage.
|
||||
- Seven-day revocable customer sessions, stronger scrypt password hashes, legacy
|
||||
verification and upgrade, comparable missing-account password work, and login
|
||||
throttling.
|
||||
- Upload extension/size/count quotas, storage quotas, private UUID object keys,
|
||||
exact signed multipart `Content-Length`, completion/ownership checks, and
|
||||
five-minute signed downloads.
|
||||
- Files start `pending`. Only `clean` files may be quoted, approved, paid,
|
||||
downloaded, attached as final files, or moved into queue/printing states.
|
||||
Unknown, scanner error, unsafe/encrypted, over-limit, and malware outcomes fail
|
||||
closed. Rejected/error files expire within three days.
|
||||
- Restricted PostgreSQL and MinIO runtime identities provisioned separately from
|
||||
administrator credentials. Containers use read-only filesystems/capability
|
||||
drops where compatible.
|
||||
- Structured redacted security logs plus a 30-day PostgreSQL event table. Worker
|
||||
health includes outbox progress, scan-thread liveness, and a live ClamAV PING.
|
||||
The alert summary also reports signature age.
|
||||
- Logout revokes server sessions and clears checkout keys and IndexedDB File blobs
|
||||
across open Site tabs.
|
||||
- Production delivery definitions use unprivileged application/web users,
|
||||
read-only filesystems, dropped capabilities, external Swarm secrets, private
|
||||
service networking, immutable base images, commit-SHA rollback tags,
|
||||
health-monitored rolling updates, and automatic application rollback. A
|
||||
fail-closed source/configuration gate prevents the current local-only runtime
|
||||
from being released as production.
|
||||
|
||||
## Verified checks
|
||||
|
||||
All results below were observed against the final application source. Smoke and
|
||||
workflow were repeated after the refreshed PostgreSQL 17 image was activated.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Pricing parity against Site JavaScript | Pass: all 4,444 comparisons plus invalid inputs |
|
||||
| Security regression | Pass: headers, Host/origin, sessions, quotas, multipart signing, throttling |
|
||||
| Real ClamAV EICAR regression | Pass: rejected and blocked from download/quote; clean control released |
|
||||
| Smoke workflow | Pass: multipart, ownership, all modes, pricing/freight, payment idempotency, states/history |
|
||||
| Customer/final-file workflow | Pass: identity, revocation, corrections, secure downloads, invalidation and queue gate |
|
||||
| Runtime security | Pass: PostgreSQL/MinIO least privilege, hashes, quarantine and scanner failure behavior |
|
||||
| Retention | Pass: expired bytes removed, live bytes preserved, metadata retained |
|
||||
| Browser workflow | Pass: end-to-end flow, filename XSS probe, no stored operator password, logout blob cleanup |
|
||||
| Dependency lock | Pass: 26 exact packages, SHA-256 hashes on every entry, direct-pin parity, hash-enforced image build, `pip check` |
|
||||
| Staging readiness gate | Pass with synthetic non-secret metadata in a network-disabled container; unsafe/incomplete regression cases rejected |
|
||||
| Production delivery definitions | Pass: unit/syntax/YAML checks, Swarm render, immutable-base image builds, non-root read-only web runtime and Host rejection |
|
||||
| Production source preflight | Expected block: local-only/fake adapters and Docker secret-file loading are not production implementations |
|
||||
| Production ClamAV runtime | Pass: UID 100:101, read-only filesystem, no capabilities/no-new-privileges, live PONG |
|
||||
| Repository secret scan | Pass: no HIGH/CRITICAL Trivy secret findings; release workflow enforces the same gate |
|
||||
|
||||
## Malware-scanner operations
|
||||
|
||||
The scanner image is digest-pinned and has no external network route. It uses
|
||||
the signatures bundled into that image. On closeout it reported ClamAV
|
||||
`1.5.4/28122/Sun Sep 13 06:26:25 2026`; the summary calculated 31.8 hours of age,
|
||||
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.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
docker compose exec -T api python -m local.security_status
|
||||
```
|
||||
|
||||
An exit status of 1 requires review. At closeout, attention was expected because
|
||||
the regression suite produced two rate-limit alerts, 20 synthetic failed operator
|
||||
logins, and two rejected `SECURITY-EICAR.cdr` fixtures. Both rejected fixtures
|
||||
expire on 2026-09-17. ClamAV was available and its signatures were not stale.
|
||||
Do not automatically dismiss later alerts: verify filename, timestamp, test run,
|
||||
scanner availability, and signature age. Treat non-test rejected files, scanner
|
||||
errors, unexplained authentication bursts, or stale signatures as incidents.
|
||||
|
||||
## Dependency and image audit
|
||||
|
||||
`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`.
|
||||
`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
|
||||
unfinished.
|
||||
|
||||
Trivy JSON reports are in `output/security/`. HIGH/CRITICAL results after the
|
||||
available custom-image package upgrades were:
|
||||
|
||||
| Image | Findings | Qualification |
|
||||
|---|---:|---|
|
||||
| API | 46 HIGH | 44 Debian records had no fix; Trivy's two Python records named `msgpack` and `setuptools`, which `pip list` confirmed are absent from the runtime. Trivy warned that the third-party SBOM may be inaccurate. |
|
||||
| Site | 5 HIGH | Nginx package records with fixes listed by Trivy, but the current official `nginx:1.28-alpine` image/repository did not supply them. |
|
||||
| PostgreSQL 17 | 30 HIGH, 1 CRITICAL | Nine Alpine library records and 22 records in `/usr/local/bin/gosu`; presence is confirmed, reachability through this local stack is not. |
|
||||
| MinIO | 102 HIGH, 6 CRITICAL | Findings span the MinIO and `mc` binaries and two OS packages. A verified local clean-object archive now exists, but the old pinned release was not changed without a tested version-migration and rollback plan. |
|
||||
| ClamAV | 0 HIGH/CRITICAL | This scan result is not a guarantee that no vulnerability exists. |
|
||||
| Production API validation image | 55 HIGH, 3 CRITICAL | 44 records had no fix. Fourteen listed fixes, but the `msgpack` and `setuptools` records came from a third-party SBOM and both packages were confirmed absent with `pip list`; the remaining fixed records are Debian packages. |
|
||||
| Production web validation image | 52 HIGH, 2 CRITICAL | All 54 records listed fixed Alpine versions. A current approved Nginx base digest must replace the locally available validation base before release. |
|
||||
|
||||
Scanner presence is evidence, while exploitability/reachability requires separate
|
||||
analysis. MinIO and the remaining base-image findings block any production-readiness
|
||||
claim even though ports are loopback-only here. The production validation reports
|
||||
are `production-api-container-audit.json` and
|
||||
`production-web-container-audit.json`; both make the release workflow's
|
||||
HIGH/CRITICAL gate fail. Counts reflect the 2026-09-15 Trivy database and can
|
||||
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
|
||||
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.
|
||||
|
||||
The gate currently identifies real blockers: production adapters and authenticated
|
||||
payment webhooks are absent, the runtime intentionally rejects production/R2,
|
||||
Docker secret-file configuration is not implemented, approvals are unset, and
|
||||
the existing image findings are unresolved. These checks must be satisfied by
|
||||
implementation and review, not by replacing the checks with permissive values.
|
||||
|
||||
## PDF.js review
|
||||
|
||||
The Site loads version 3.11.174 from a versioned CDN URL. That version is affected
|
||||
by [GHSA-wgrm-67xf-hhpq](https://github.com/mozilla/pdf.js/security/advisories/GHSA-wgrm-67xf-hhpq),
|
||||
whose documented workaround is `isEvalSupported:false`; the existing Site call
|
||||
already sets that value, so the known eval path is mitigated and is not reported
|
||||
as unmitigated. The newer
|
||||
[GHSA-hq66-cqwq-w95j](https://github.com/mozilla/pdf.js/security/advisories/GHSA-hq66-cqwq-w95j)
|
||||
affects versions from 5.6.83 up to the patched 6.2.108 and therefore does not
|
||||
include 3.11.174.
|
||||
|
||||
Version 3.11.174 is nevertheless old, remotely loaded, and not a satisfactory
|
||||
long-term dependency posture. Plan a compatibility-tested upgrade and preferably
|
||||
vendor or integrity-pin the asset. Keep script execution disabled and do not
|
||||
weaken CSP merely to support a preview.
|
||||
|
||||
## Before any staging or production work
|
||||
|
||||
- Resolve or formally accept current MinIO, PostgreSQL, Nginx, and Debian image
|
||||
findings. Use the verified local clean-object bundle to rehearse a MinIO
|
||||
migration and rollback; it is not a scheduled, offsite, or production backup.
|
||||
- Schedule controlled dependency-lock refreshes and exact-runtime audits; review
|
||||
and test every resulting version change.
|
||||
- Establish a signature update/rebuild process that works without giving the
|
||||
scanner an unrestricted external network route.
|
||||
- Replace disposable local secrets; add TLS, production session/cookie settings,
|
||||
account verification/recovery, centralized monitoring, and complete backup/
|
||||
restore procedures.
|
||||
- Re-run every security, malware, workflow, browser, retention, dependency, and
|
||||
image check in the target staging architecture.
|
||||
212
TAREFAS.md
Normal file
212
TAREFAS.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Tarefas — em ordem de execução
|
||||
|
||||
> Cada tarefa tem **critério de aceite**: o que precisa acontecer para ela ser
|
||||
> considerada pronta. Sem isso, "terminei" vira discussão.
|
||||
>
|
||||
> A ordem importa: as três primeiras não dependem de decisão de ninguém.
|
||||
|
||||
---
|
||||
|
||||
## Bloco A · Pode começar hoje
|
||||
|
||||
Nada aqui depende de resposta pendente.
|
||||
|
||||
### A1 · Limpar os marcadores do Tiny
|
||||
**Aceite:** a lista de sugestão de marcadores só mostra os padrões. Os pedidos
|
||||
antigos mantêm o histórico intacto.
|
||||
|
||||
- Criar `DTF-PRODUCAO`, `DTF-PRONTO`, `DTF-PROVA-COR`
|
||||
- Remover da lista de sugestão: `inicio 14:45`, `inicio13:34`, `1hrs`, `3h`,
|
||||
`+30min de correcao` e as demais variações digitadas à mão
|
||||
- **Não apagar do histórico dos pedidos** — só da lista de opções
|
||||
- **Preservar** `30 min`, `1 hora`, `3 horas`: são estimativa de máquina
|
||||
|
||||
### A2 · Contratar a infraestrutura
|
||||
**Aceite:** `https://arte.dropstaratacado.com.br` responde com certificado
|
||||
válido, e um arquivo de teste sobe e desce do storage.
|
||||
|
||||
- VPS Linux 2 vCPU / 4 GB · Hetzner, Contabo ou Hostinger
|
||||
- Storage S3 compatível · Cloudflare R2 ou Backblaze B2
|
||||
- Subdomínio + Let's Encrypt
|
||||
- Bucket `dtf-artes` com ciclo de vida de 30 dias
|
||||
- Prefixo `artes-cliente/` com 12 meses
|
||||
- CORS liberado só para o domínio do portal
|
||||
|
||||
### A3 · Criar o banco
|
||||
**Aceite:** `schema.sql` roda sem erro no Postgres e no SQLite.
|
||||
|
||||
- Postgres na nuvem · SQLite na fábrica
|
||||
- Dump diário do Postgres para o storage
|
||||
|
||||
### A4 · Provisionar acessos
|
||||
**Aceite:** um token do Tiny obtido por código, e um WhatsApp de teste enviado.
|
||||
|
||||
- Aplicação na API v3 do Tiny · client id e secret
|
||||
- **Confirmar na documentação** o endpoint de marcadores e o limite de req/min
|
||||
- WhatsApp: definir Meta oficial ou provedor
|
||||
|
||||
---
|
||||
|
||||
## Bloco B · Portal — entrega sozinho
|
||||
|
||||
Ao fim deste bloco a Altus **já recebe arte 24 horas**, mesmo sem kanban.
|
||||
|
||||
### B1 · Webhook e link
|
||||
**Aceite:** um pedido criado no Tiny gera link e chega no WhatsApp em menos de
|
||||
1 minuto.
|
||||
|
||||
- `POST /webhook/tiny` → cria pedido, token de 7 dias, dispara WhatsApp
|
||||
- Idempotente: pedido repetido não duplica
|
||||
- **Medir a latência VNDA → Tiny** e registrar
|
||||
|
||||
### B2 · Página de upload
|
||||
**Aceite:** um arquivo de 5 GB sobe sem derrubar o VPS.
|
||||
|
||||
- URL pré-assinada, upload em partes
|
||||
- Campo de repetição com prévia da conta
|
||||
- Gabarito 57 × 97 cm para download
|
||||
- Rate limit de 20 req/min por token
|
||||
|
||||
### B3 · Pré-flight
|
||||
**Aceite:** dos cinco arquivos que os designers separarem, o robô decide igual
|
||||
ao que eles decidiriam.
|
||||
|
||||
- `preflight.py` está pronto — integrar e testar
|
||||
- Normalização: vetor → PDF, raster → TIF, CMYK → RGB, **original preservado**
|
||||
- Repetição, fatiamento em 20 m, carimbo com QR de 20 mm
|
||||
- CDR entra sem validar, etiqueta "conferir"
|
||||
- **`carimbar()` precisa de teste visual impresso** antes de valer
|
||||
|
||||
### B4 · Mensagens
|
||||
**Aceite:** as seis mensagens chegam com o texto certo, e a de correção pede
|
||||
resposta.
|
||||
|
||||
- Lembrete de 1 hora sem arte
|
||||
- Recusa com motivo em português
|
||||
- Aprovação com **% e DPI**, metragem, tempo e horário previsto
|
||||
- Em produção, correção, finalizado
|
||||
|
||||
### B5 · Antivírus
|
||||
**Aceite:** um EICAR de teste é barrado e não chega ao agente.
|
||||
|
||||
- ClamAV em segundo plano, sem travar a fila
|
||||
|
||||
---
|
||||
|
||||
## Bloco C · Agente
|
||||
|
||||
### C1 · Serviço na fábrica
|
||||
**Aceite:** derrubar a internet por 10 minutos e religar — o agente baixa o
|
||||
acumulado sozinho, sem duplicar nada.
|
||||
|
||||
- Windows via NSSM ou Linux via systemd
|
||||
- Webhook + polling de 60 s
|
||||
- Download em arquivo temporário, renomeia só quando completa
|
||||
- Verificação de SHA-256
|
||||
- Idempotente pelo `arte_id`
|
||||
|
||||
### C2 · Monitoramento
|
||||
**Aceite:** matar o processo dispara alerta em até 15 minutos.
|
||||
|
||||
- Heartbeat a cada 5 min
|
||||
- Alerta ao TI se sumir
|
||||
|
||||
---
|
||||
|
||||
## Bloco D · Kanban como aba do PCP
|
||||
|
||||
### D1 · Quadro
|
||||
**Aceite:** dois operadores clicando `puxar` ao mesmo tempo — só um pega.
|
||||
|
||||
- 7 colunas · **sem coluna de aplicação**
|
||||
- Cronômetro por card, em tempo real
|
||||
- Front pronto em `kanban/static/kanban.html`
|
||||
- `?maq=4` abre filtrado na máquina
|
||||
|
||||
### D2 · Puxar e devolver
|
||||
**Aceite:** puxar e não mover em 3 minutos devolve o pedido à fila sozinho.
|
||||
|
||||
- Reserva de **3 min** · **um pedido por vez**
|
||||
- Devolver volta ao **topo**
|
||||
- Círculo vermelho na máquina ocupada, botão desabilitado
|
||||
|
||||
### D3 · Movimento e integração
|
||||
**Aceite:** desligar o Tiny, mover cards, religar — os marcadores entram sozinhos
|
||||
e nenhum movimento se perde.
|
||||
|
||||
- Gravar o movimento **antes** de qualquer chamada externa
|
||||
- Fila de jobs com retentativa em 1, 5 e 30 min
|
||||
- Três marcadores no Tiny, não sete
|
||||
- Arquivo acompanha o card entre as pastas
|
||||
|
||||
### D4 · Estimativa de máquina
|
||||
**Aceite:** o card sugere os minutos e aceita ajuste manual.
|
||||
|
||||
- Campo `minutos_maquina`, sugerido por `metros/20*60`
|
||||
- Importar os marcadores `30 min`, `1 hora`, `3 horas` dos pedidos existentes
|
||||
|
||||
### D5 · Retrabalho
|
||||
**Aceite:** a causa não pode ser alterada depois de aberta, nem pelo autorizador.
|
||||
|
||||
- Abertura com causa obrigatória e evidência
|
||||
- Alçada automática · reincidência sobe nível
|
||||
- Contestação registrada sem alterar a causa
|
||||
- **Meta ainda não definida** — `META_POR_CAUSA` isolado
|
||||
|
||||
### D6 · Painel e relatórios
|
||||
**Aceite:** os quatro relatórios respondem com dados reais depois de uma semana.
|
||||
|
||||
- Filtros de período
|
||||
- `/api/relatorio/etapas`, `/impressao`, `/aproveitamento`, `/retrabalho`
|
||||
- Aproveitamento lendo as transferências do depósito no Tiny
|
||||
|
||||
---
|
||||
|
||||
## Bloco E · Só depois de medir
|
||||
|
||||
**Duas semanas com o kanban rodando antes de tocar nisto.**
|
||||
|
||||
### E1 · Decidir o terceiro turno
|
||||
Depende de: metros/hora reais, tempo entre etapas, e quanto do tempo do designer
|
||||
é retrabalho de arquivo ruim.
|
||||
|
||||
### E2 · SPOT automático
|
||||
Depende do teste em `dtf-teste-branco-automatico.pdf`.
|
||||
|
||||
### E3 · PC central como servidor de RIP
|
||||
Depende de: configuração atual, custo da licença (é **por PC**), e memória que o
|
||||
RIP consome.
|
||||
|
||||
---
|
||||
|
||||
## Bloqueios · o que trava qual tarefa
|
||||
|
||||
| Tarefa | Espera por | De quem |
|
||||
|---|---|---|
|
||||
| B3 · pré-flight | os cinco arquivos de exemplo | designers |
|
||||
| B3 · regras de choke | valor do choke e espessura mínima | designers |
|
||||
| D5 · meta | meta geral ou por causa? | Marcus |
|
||||
| E1 · terceiro turno | metros/hora reais | sala |
|
||||
| E2 · SPOT | resultado do teste | sala |
|
||||
| E3 · RIP central | config e licença do PC central | Wagner |
|
||||
|
||||
**Nada no bloco A, B1, B2, C e D1 a D4 está bloqueado.** Dá para chegar até o
|
||||
kanban funcionando sem nenhuma dessas respostas.
|
||||
|
||||
---
|
||||
|
||||
## Ordem sugerida de trabalho
|
||||
|
||||
```
|
||||
semana 1 A1 · A2 · A3 · A4
|
||||
semana 2 B1 · B2
|
||||
semana 3 B3 · B4 · B5 ← portal no ar, recebendo 24h
|
||||
semana 4 C1 · C2 ← arquivo caindo na pasta sozinho
|
||||
semana 5 D1 · D2
|
||||
semana 6 D3 · D4
|
||||
semana 7 D5 · D6 ← kanban completo
|
||||
semana 8+ medir · depois E
|
||||
```
|
||||
|
||||
**Ao fim da semana 3 a Altus já recebe arte de madrugada e recusa arquivo ruim
|
||||
automaticamente**, sem nada ter mudado na sala. É o primeiro ganho real.
|
||||
141
agente/agente.py
Normal file
141
agente/agente.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
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()
|
||||
14
agente/dtf-agente.service
Normal file
14
agente/dtf-agente.service
Normal file
@@ -0,0 +1,14 @@
|
||||
[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
|
||||
20
compose.staging.yaml
Normal file
20
compose.staging.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
name: dtf-staging-readiness
|
||||
|
||||
# This composition root is only a network-disabled readiness gate. It does not
|
||||
# deploy the application or connect to R2, Mercado Pago, freight, Tiny/Olist,
|
||||
# WhatsApp, a production database, or any other external service.
|
||||
services:
|
||||
readiness:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: local/Dockerfile
|
||||
command: python -m local.staging_readiness /config/staging.env
|
||||
volumes:
|
||||
- ./staging/staging.env:/config/staging.env:ro
|
||||
network_mode: none
|
||||
read_only: true
|
||||
tmpfs: [/tmp]
|
||||
init: true
|
||||
pids_limit: 64
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
168
compose.yaml
Normal file
168
compose.yaml
Normal file
@@ -0,0 +1,168 @@
|
||||
name: ${COMPOSE_PROJECT_NAME:-dtf-cloud}
|
||||
x-app: &app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: local/Dockerfile
|
||||
environment: &environment
|
||||
APP_ENV: ${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_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}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
OPERATOR_USER: ${OPERATOR_USER:-operator}
|
||||
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only}
|
||||
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
|
||||
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:-fake}
|
||||
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
|
||||
WHATSAPP_ADAPTER: ${WHATSAPP_ADAPTER:-fake}
|
||||
STORAGE_ADAPTER: ${STORAGE_ADAPTER:-s3-local}
|
||||
MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500}
|
||||
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-5368709120}
|
||||
UPLOAD_PART_BYTES: ${UPLOAD_PART_BYTES:-8388608}
|
||||
STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:-53687091200}
|
||||
OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:-10737418240}
|
||||
MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10}
|
||||
SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728}
|
||||
networks: [local]
|
||||
init: true
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
read_only: true
|
||||
tmpfs: [/tmp]
|
||||
pids_limit: 128
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "10m", max-file: "3"}
|
||||
services:
|
||||
scanner:
|
||||
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]
|
||||
mem_limit: 3g
|
||||
pids_limit: 128
|
||||
security_opt: [no-new-privileges:true]
|
||||
healthcheck:
|
||||
test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"]
|
||||
start_period: 60s
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
db-init:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: local/Dockerfile
|
||||
command: python -m local.bootstrap
|
||||
environment:
|
||||
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}
|
||||
networks: [local]
|
||||
depends_on:
|
||||
db: {condition: service_healthy}
|
||||
storage-init:
|
||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
entrypoint: [/bin/sh, /init.sh]
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-local-storage-only}
|
||||
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}
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-dtf_local}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-dtf_local}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-database-only}
|
||||
volumes: [postgres-data:/var/lib/postgresql/data]
|
||||
networks: [local]
|
||||
healthcheck:
|
||||
test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
storage:
|
||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
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"]
|
||||
volumes: [storage-data:/data]
|
||||
networks: [local, edge]
|
||||
healthcheck:
|
||||
test: [CMD, curl, -f, http://localhost:9000/minio/health/ready]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
api:
|
||||
<<: *app
|
||||
command: uvicorn local.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}
|
||||
healthcheck:
|
||||
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
worker:
|
||||
<<: *app
|
||||
command: python -m local.worker
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
scanner: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health')"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
site:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: local/Dockerfile.web
|
||||
environment:
|
||||
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000}
|
||||
ports: ["127.0.0.1:${SITE_PORT:-8080}:80", "127.0.0.1:${API_PORT:-8000}:81"]
|
||||
networks: [local, edge]
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1/health]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
kanban:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: local/Dockerfile.web
|
||||
environment:
|
||||
WEB_INDEX: kanban.html
|
||||
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000}
|
||||
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
|
||||
networks: [local, edge]
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
healthcheck:
|
||||
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1/health]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
volumes:
|
||||
postgres-data:
|
||||
storage-data:
|
||||
networks:
|
||||
local:
|
||||
internal: true
|
||||
edge:
|
||||
18
deploy/Dockerfile.api
Normal file
18
deploy/Dockerfile.api
Normal file
@@ -0,0 +1,18 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
ARG PYTHON_BASE_IMAGE
|
||||
FROM ${PYTHON_BASE_IMAGE}
|
||||
|
||||
ARG VCS_REF=unknown
|
||||
LABEL org.opencontainers.image.title="DTF Portal/API" \
|
||||
org.opencontainers.image.revision="$VCS_REF" \
|
||||
org.opencontainers.image.source="DTF System repository"
|
||||
|
||||
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
|
||||
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"]
|
||||
22
deploy/Dockerfile.web
Normal file
22
deploy/Dockerfile.web
Normal file
@@ -0,0 +1,22 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
ARG PYTHON_BASE_IMAGE
|
||||
ARG NGINX_BASE_IMAGE
|
||||
FROM ${PYTHON_BASE_IMAGE} AS policy
|
||||
WORKDIR /build
|
||||
COPY dtf-site.html /build/dtf-site.html
|
||||
COPY local /build/local
|
||||
COPY deploy /build/deploy
|
||||
ENV NGINX_TEMPLATE=/build/deploy/nginx.conf.template
|
||||
RUN python local/compile_web.py
|
||||
|
||||
FROM ${NGINX_BASE_IMAGE}
|
||||
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/
|
||||
USER 101:101
|
||||
EXPOSE 8080
|
||||
46
deploy/PRODUCTION_CHECKLIST.md
Normal file
46
deploy/PRODUCTION_CHECKLIST.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Production release checklist
|
||||
|
||||
Every item is required. A checked box records reviewed evidence; it is not a
|
||||
substitute for `production_preflight.py`, CI, staging acceptance, or change approval.
|
||||
|
||||
## Application and external contracts
|
||||
|
||||
- [ ] `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.
|
||||
- [ ] Docker secret `*_FILE` loading is implemented and tested without logging values.
|
||||
- [ ] Production account verification/recovery and the length/grade authority flow
|
||||
are approved.
|
||||
- [ ] No factory automation or automatic print pre-flight was added by inference.
|
||||
|
||||
## Platform and recovery
|
||||
|
||||
- [ ] DNS/TLS proxy routes and firewall rules are approved; only Site and Kanban
|
||||
have published web ports.
|
||||
- [ ] External, versioned Swarm secrets exist and match the configured names.
|
||||
- [ ] The PostgreSQL volume is encrypted/backed up and pinned to the labeled node.
|
||||
- [ ] Scheduled offsite database/object backups and an isolated restore rehearsal pass.
|
||||
- [ ] ClamAV signatures are current and have a controlled update/rebuild process.
|
||||
- [ ] Central alerts, logs, clocks, capacity, and on-call ownership are verified.
|
||||
|
||||
## Release and deploy
|
||||
|
||||
- [ ] Protected Gitea runner, `main` branch, registry permissions, and variables are reviewed.
|
||||
- [ ] Base, database, and scanner images use approved immutable digests; application
|
||||
SHA tags remain pullable and the digest resolved by each deployment is recorded.
|
||||
- [ ] Full regressions and production preflight pass on the exact release commit.
|
||||
- [ ] HIGH/CRITICAL Trivy findings are fixed or formally risk-accepted with evidence.
|
||||
- [ ] Staging acceptance passes with provider sandboxes and production-like topology.
|
||||
- [ ] Four production approval variables equal `approved` only after their reviews.
|
||||
- [ ] The generated `docker stack config` contains no secret values or placeholders.
|
||||
- [ ] Health probes, monitoring, rollback, and a backup restore are observed in staging.
|
||||
- [ ] The Portainer webhook redeploys the one `dtf-cloud` stack and post-deploy
|
||||
HTTPS health probes pass.
|
||||
|
||||
## Rollback
|
||||
|
||||
- [ ] Previous application image digests remain pullable.
|
||||
- [ ] Portainer rollback by full commit `IMAGE_TAG` has been rehearsed; it does
|
||||
not roll back the database.
|
||||
- [ ] Database migration forward/restore ownership and maintenance procedure are approved.
|
||||
16
deploy/README.md
Normal file
16
deploy/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Production deployment files
|
||||
|
||||
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.
|
||||
- `Dockerfile.api` and `Dockerfile.web` — prebuilt registry images.
|
||||
- `portainer.env.example` — non-secret Portainer variables.
|
||||
- `production_preflight.py` — fail-closed application/configuration validator.
|
||||
- `PRODUCTION_CHECKLIST.md` — production evidence checklist.
|
||||
|
||||
The current application remains deliberately blocked from production because
|
||||
real adapters and Docker-secret file loading are absent and current validation
|
||||
base images have unresolved HIGH/CRITICAL findings. No real provider or
|
||||
production service has been configured or contacted.
|
||||
1
deploy/__init__.py
Normal file
1
deploy/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Production deployment validation package."""
|
||||
31
deploy/nginx.conf.template
Normal file
31
deploy/nginx.conf.template
Normal file
@@ -0,0 +1,31 @@
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;
|
||||
server {
|
||||
listen 8080;
|
||||
server_name ${PUBLIC_HOST};
|
||||
if ($host != ${PUBLIC_HOST}) { return 400; }
|
||||
root /usr/share/nginx/html;
|
||||
index ${WEB_INDEX};
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
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;
|
||||
|
||||
location = /health { access_log off; return 200 'ok'; }
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=100 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
client_max_body_size 2m;
|
||||
}
|
||||
location / { try_files $uri $uri/ =404; }
|
||||
}
|
||||
51
deploy/portainer.env.example
Normal file
51
deploy/portainer.env.example
Normal file
@@ -0,0 +1,51 @@
|
||||
# Non-secret Portainer stack variables. Never put credential values in this file.
|
||||
PRODUCTION_DEPLOY_ENABLED=TBD
|
||||
PRODUCTION_INPUTS_APPROVED=TBD
|
||||
PRODUCTION_SECURITY_REVIEW_APPROVED=TBD
|
||||
PRODUCTION_RESTORE_REHEARSED=TBD
|
||||
|
||||
API_IMAGE=gitea.blyzer.com.br/blyzer/dtf-api
|
||||
WEB_IMAGE=gitea.blyzer.com.br/blyzer/dtf-web
|
||||
IMAGE_TAG=latest
|
||||
POSTGRES_IMAGE=postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
CLAMAV_IMAGE=clamav/clamav@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
PUBLIC_ORIGIN=https://dtf.example.invalid
|
||||
PUBLIC_HOST=dtf.example.invalid
|
||||
KANBAN_HOST=kanban-dtf.example.invalid
|
||||
SITE_PORT=8080
|
||||
KANBAN_PORT=8081
|
||||
|
||||
R2_ENDPOINT=TBD
|
||||
R2_PUBLIC_ENDPOINT=TBD
|
||||
R2_BUCKET=TBD
|
||||
|
||||
POSTGRES_DB=dtf
|
||||
POSTGRES_USER=dtf_admin
|
||||
APP_DB_USER=dtf_app
|
||||
POSTGRES_VOLUME=TBD
|
||||
OPERATOR_USER=TBD
|
||||
|
||||
PAYMENT_ADAPTER=TBD
|
||||
FREIGHT_ADAPTER=TBD
|
||||
TINY_ADAPTER=TBD
|
||||
WHATSAPP_ADAPTER=TBD
|
||||
STORAGE_QUOTA_BYTES=TBD
|
||||
OWNER_UPLOAD_QUOTA_BYTES=TBD
|
||||
MAX_PENDING_UPLOADS=10
|
||||
MAX_UPLOAD_BYTES=5368709120
|
||||
UPLOAD_PART_BYTES=8388608
|
||||
SCAN_MAX_BYTES=134217728
|
||||
|
||||
# Names of external Portainer/Docker Swarm secrets, never their values.
|
||||
DATABASE_URL_SECRET=TBD
|
||||
DATABASE_ADMIN_URL_SECRET=TBD
|
||||
DB_ADMIN_PASSWORD_SECRET=TBD
|
||||
APP_DB_PASSWORD_SECRET=TBD
|
||||
R2_ACCESS_KEY_ID_SECRET=TBD
|
||||
R2_SECRET_ACCESS_KEY_SECRET=TBD
|
||||
OPERATOR_PASSWORD_SECRET=TBD
|
||||
PAYMENT_TOKEN_SECRET=TBD
|
||||
PAYMENT_WEBHOOK_SECRET=TBD
|
||||
TINY_TOKEN_SECRET=TBD
|
||||
WHATSAPP_TOKEN_SECRET=TBD
|
||||
159
deploy/production_preflight.py
Normal file
159
deploy/production_preflight.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Fail closed until the application and non-secret production inputs are ready."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
APPROVALS = (
|
||||
'PRODUCTION_DEPLOY_ENABLED',
|
||||
'PRODUCTION_INPUTS_APPROVED',
|
||||
'PRODUCTION_SECURITY_REVIEW_APPROVED',
|
||||
'PRODUCTION_RESTORE_REHEARSED',
|
||||
)
|
||||
REQUIRED = (
|
||||
'API_IMAGE', 'WEB_IMAGE', 'POSTGRES_IMAGE', 'CLAMAV_IMAGE',
|
||||
'IMAGE_TAG', 'PUBLIC_ORIGIN', 'PUBLIC_HOST', 'KANBAN_HOST',
|
||||
'SITE_PORT', 'KANBAN_PORT',
|
||||
'R2_ENDPOINT', 'R2_PUBLIC_ENDPOINT', 'R2_BUCKET',
|
||||
'POSTGRES_DB', 'POSTGRES_USER', 'APP_DB_USER', 'POSTGRES_VOLUME', 'OPERATOR_USER',
|
||||
'STORAGE_QUOTA_BYTES', 'OWNER_UPLOAD_QUOTA_BYTES', 'MAX_UPLOAD_BYTES',
|
||||
'UPLOAD_PART_BYTES', 'MAX_PENDING_UPLOADS', 'SCAN_MAX_BYTES',
|
||||
'PAYMENT_ADAPTER', 'FREIGHT_ADAPTER', 'TINY_ADAPTER', 'WHATSAPP_ADAPTER',
|
||||
'DATABASE_URL_SECRET', 'DATABASE_ADMIN_URL_SECRET',
|
||||
'DB_ADMIN_PASSWORD_SECRET', 'APP_DB_PASSWORD_SECRET',
|
||||
'R2_ACCESS_KEY_ID_SECRET', 'R2_SECRET_ACCESS_KEY_SECRET',
|
||||
'OPERATOR_PASSWORD_SECRET', 'PAYMENT_TOKEN_SECRET',
|
||||
'PAYMENT_WEBHOOK_SECRET', 'TINY_TOKEN_SECRET', 'WHATSAPP_TOKEN_SECRET',
|
||||
)
|
||||
IMMUTABLE_IMAGE = re.compile(r'^[a-z0-9][a-z0-9._:/-]*@sha256:([0-9a-f]{64})$')
|
||||
IMAGE_REPOSITORY = re.compile(
|
||||
r'^[a-z0-9][a-z0-9.-]*(?::[0-9]{1,5})?(?:/[a-z0-9][a-z0-9._-]*)+$')
|
||||
DNS = re.compile(r'^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
|
||||
NAME = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,127}$')
|
||||
SOURCE_BLOCKERS = {
|
||||
'local/adapters.py': (
|
||||
'This runtime only supports APP_ENV=local',
|
||||
'Only local S3 storage is supported',
|
||||
),
|
||||
'local/app.py': (
|
||||
"allowed_hosts=['localhost', '127.0.0.1']",
|
||||
"'environment': 'local'",
|
||||
'payment = FakePayment()',
|
||||
),
|
||||
'local/worker.py': (
|
||||
"adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def source_errors(root=ROOT):
|
||||
errors = []
|
||||
for relative, markers in SOURCE_BLOCKERS.items():
|
||||
text = (root / relative).read_text()
|
||||
for marker in markers:
|
||||
if marker in text:
|
||||
errors.append(f'{relative} remains local-only: {marker}')
|
||||
secrets_module = root / 'local' / 'secrets.py'
|
||||
if not secrets_module.exists() or 'DATABASE_URL_FILE' not in secrets_module.read_text():
|
||||
errors.append('local runtime does not load the production Docker secret *_FILE settings')
|
||||
return errors
|
||||
|
||||
|
||||
def config_errors(values):
|
||||
errors = []
|
||||
for name in APPROVALS:
|
||||
if values.get(name) != 'approved':
|
||||
errors.append(f'{name} must equal approved')
|
||||
for name in REQUIRED:
|
||||
value = values.get(name, '')
|
||||
if not value or value.lower() in {'tbd', 'todo', 'replace-me', 'unconfirmed'}:
|
||||
errors.append(f'{name} is missing or unresolved')
|
||||
for name in ('API_IMAGE', 'WEB_IMAGE'):
|
||||
if not IMAGE_REPOSITORY.fullmatch(values.get(name, '')):
|
||||
errors.append(f'{name} must be a lowercase registry repository without a tag')
|
||||
for name in ('POSTGRES_IMAGE', 'CLAMAV_IMAGE'):
|
||||
match = IMMUTABLE_IMAGE.fullmatch(values.get(name, ''))
|
||||
if not match or match.group(1) == '0' * 64:
|
||||
errors.append(f'{name} must be an immutable non-placeholder digest reference')
|
||||
image_tag = values.get('IMAGE_TAG', '')
|
||||
if image_tag != 'latest' and not re.fullmatch(r'(?:[0-9a-f]{40}|[0-9a-f]{64})', image_tag):
|
||||
errors.append('IMAGE_TAG must be latest or a full commit SHA published by Gitea')
|
||||
origin = urlparse(values.get('PUBLIC_ORIGIN', ''))
|
||||
if (origin.scheme != 'https' or not origin.hostname or origin.path not in ('', '/')
|
||||
or origin.params or origin.query or origin.fragment):
|
||||
errors.append('PUBLIC_ORIGIN must be an HTTPS origin without a path')
|
||||
for name in ('PUBLIC_HOST', 'KANBAN_HOST'):
|
||||
if not DNS.fullmatch(values.get(name, '')):
|
||||
errors.append(f'{name} must be a valid lowercase DNS hostname')
|
||||
if origin.hostname and values.get('PUBLIC_HOST') != origin.hostname:
|
||||
errors.append('PUBLIC_ORIGIN hostname must equal PUBLIC_HOST')
|
||||
for name in ('R2_ENDPOINT', 'R2_PUBLIC_ENDPOINT'):
|
||||
endpoint = urlparse(values.get(name, ''))
|
||||
host = endpoint.hostname or ''
|
||||
if (endpoint.scheme != 'https' or not host.endswith('.r2.cloudflarestorage.com')
|
||||
or endpoint.path not in ('', '/') or endpoint.query or endpoint.fragment):
|
||||
errors.append(f'{name} must be an HTTPS Cloudflare R2 S3 API endpoint')
|
||||
bucket = values.get('R2_BUCKET', '')
|
||||
if bucket and not re.fullmatch(r'[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]', bucket):
|
||||
errors.append('R2_BUCKET must be a valid S3 bucket name')
|
||||
for name in ('PAYMENT_ADAPTER', 'FREIGHT_ADAPTER', 'TINY_ADAPTER', 'WHATSAPP_ADAPTER'):
|
||||
if values.get(name, '').lower() in {'', 'fake', 'mock', 'local'}:
|
||||
errors.append(f'{name} must select an approved non-fake implementation')
|
||||
for name in REQUIRED:
|
||||
if name.endswith('_SECRET') and values.get(name) and not NAME.fullmatch(values[name]):
|
||||
errors.append(f'{name} must name a pre-provisioned external Swarm secret')
|
||||
secret_names = [values.get(name, '') for name in REQUIRED if name.endswith('_SECRET')]
|
||||
populated_secret_names = [name for name in secret_names if name]
|
||||
if len(populated_secret_names) != len(set(populated_secret_names)):
|
||||
errors.append('Every production secret setting must use a distinct external Swarm secret')
|
||||
if values.get('POSTGRES_USER') == values.get('APP_DB_USER'):
|
||||
errors.append('Database administrator and runtime user must differ')
|
||||
if values.get('PUBLIC_HOST') == values.get('KANBAN_HOST'):
|
||||
errors.append('Site and Kanban hosts must differ')
|
||||
numeric = {}
|
||||
for name in (
|
||||
'STORAGE_QUOTA_BYTES', 'OWNER_UPLOAD_QUOTA_BYTES', 'MAX_UPLOAD_BYTES',
|
||||
'UPLOAD_PART_BYTES', 'MAX_PENDING_UPLOADS', 'SCAN_MAX_BYTES'):
|
||||
try:
|
||||
numeric[name] = int(values.get(name, '0'))
|
||||
if numeric[name] <= 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
errors.append(f'{name} must be a positive integer')
|
||||
if numeric.get('OWNER_UPLOAD_QUOTA_BYTES', 0) > numeric.get('STORAGE_QUOTA_BYTES', 0):
|
||||
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')
|
||||
ports = {}
|
||||
for name in ('SITE_PORT', 'KANBAN_PORT'):
|
||||
try:
|
||||
ports[name] = int(values.get(name, '0'))
|
||||
if not 1024 <= ports[name] <= 65535:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
errors.append(f'{name} must be an unprivileged TCP port from 1024 to 65535')
|
||||
if ports.get('SITE_PORT') == ports.get('KANBAN_PORT'):
|
||||
errors.append('SITE_PORT and KANBAN_PORT must differ')
|
||||
return errors
|
||||
|
||||
|
||||
def main(source_only=False):
|
||||
errors = source_errors()
|
||||
if not source_only:
|
||||
errors.extend(config_errors(os.environ))
|
||||
if errors:
|
||||
print('BLOCKED: production preflight failed:')
|
||||
for error in errors:
|
||||
print(f'- {error}')
|
||||
return 2
|
||||
print('PASS: production source and non-secret deployment metadata passed preflight.')
|
||||
print('This does not replace staging acceptance, image scanning, or human approval.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) > 2 or (len(sys.argv) == 2 and sys.argv[1] != '--source-only'):
|
||||
raise SystemExit('usage: python deploy/production_preflight.py [--source-only]')
|
||||
raise SystemExit(main(len(sys.argv) == 2))
|
||||
309
deploy/stack.yaml
Normal file
309
deploy/stack.yaml
Normal file
@@ -0,0 +1,309 @@
|
||||
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
|
||||
OPERATOR_USER: ${OPERATOR_USER:?set OPERATOR_USER}
|
||||
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
|
||||
98
deploy/test_production_preflight.py
Normal file
98
deploy/test_production_preflight.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import unittest
|
||||
|
||||
from .production_preflight import config_errors, source_errors
|
||||
|
||||
|
||||
def valid_config():
|
||||
digest = '1' * 64
|
||||
values = {
|
||||
'PRODUCTION_DEPLOY_ENABLED': 'approved',
|
||||
'PRODUCTION_INPUTS_APPROVED': 'approved',
|
||||
'PRODUCTION_SECURITY_REVIEW_APPROVED': 'approved',
|
||||
'PRODUCTION_RESTORE_REHEARSED': 'approved',
|
||||
'API_IMAGE': 'registry.example.com/dropstar/dtf-api',
|
||||
'WEB_IMAGE': 'registry.example.com/dropstar/dtf-web',
|
||||
'IMAGE_TAG': 'latest',
|
||||
'POSTGRES_IMAGE': f'postgres@sha256:{digest}',
|
||||
'CLAMAV_IMAGE': f'clamav/clamav@sha256:{digest}',
|
||||
'PUBLIC_ORIGIN': 'https://dtf.example.com',
|
||||
'PUBLIC_HOST': 'dtf.example.com',
|
||||
'KANBAN_HOST': 'kanban-dtf.example.com',
|
||||
'SITE_PORT': '8080',
|
||||
'KANBAN_PORT': '8081',
|
||||
'R2_ENDPOINT': 'https://account.r2.cloudflarestorage.com',
|
||||
'R2_PUBLIC_ENDPOINT': 'https://account.r2.cloudflarestorage.com',
|
||||
'R2_BUCKET': 'dtf-production-artwork',
|
||||
'POSTGRES_DB': 'dtf',
|
||||
'POSTGRES_USER': 'dtf_admin',
|
||||
'APP_DB_USER': 'dtf_app',
|
||||
'POSTGRES_VOLUME': 'dtf-postgres-data',
|
||||
'OPERATOR_USER': 'dtf-operator',
|
||||
'STORAGE_QUOTA_BYTES': '53687091200',
|
||||
'OWNER_UPLOAD_QUOTA_BYTES': '10737418240',
|
||||
'MAX_UPLOAD_BYTES': '5368709120',
|
||||
'UPLOAD_PART_BYTES': '8388608',
|
||||
'MAX_PENDING_UPLOADS': '10',
|
||||
'SCAN_MAX_BYTES': '134217728',
|
||||
'PAYMENT_ADAPTER': 'mercado-pago',
|
||||
'FREIGHT_ADAPTER': 'approved-freight',
|
||||
'TINY_ADAPTER': 'tiny-olist',
|
||||
'WHATSAPP_ADAPTER': 'approved-whatsapp',
|
||||
}
|
||||
for name in (
|
||||
'DATABASE_URL_SECRET', 'DATABASE_ADMIN_URL_SECRET',
|
||||
'DB_ADMIN_PASSWORD_SECRET', 'APP_DB_PASSWORD_SECRET',
|
||||
'R2_ACCESS_KEY_ID_SECRET', 'R2_SECRET_ACCESS_KEY_SECRET',
|
||||
'OPERATOR_PASSWORD_SECRET', 'PAYMENT_TOKEN_SECRET',
|
||||
'PAYMENT_WEBHOOK_SECRET', 'TINY_TOKEN_SECRET', 'WHATSAPP_TOKEN_SECRET',
|
||||
):
|
||||
values[name] = 'dtf_prod_' + name.lower()
|
||||
return values
|
||||
|
||||
|
||||
class ProductionPreflightTests(unittest.TestCase):
|
||||
def test_structurally_complete_metadata_passes(self):
|
||||
self.assertEqual(config_errors(valid_config()), [])
|
||||
|
||||
def test_mutable_images_and_fake_adapters_fail(self):
|
||||
values = valid_config()
|
||||
values['API_IMAGE'] = 'registry.example.com/dropstar/dtf-api:latest'
|
||||
values['PAYMENT_ADAPTER'] = 'fake'
|
||||
errors = config_errors(values)
|
||||
self.assertTrue(any('API_IMAGE' in error for error in errors))
|
||||
self.assertTrue(any('PAYMENT_ADAPTER' in error for error in errors))
|
||||
|
||||
def test_image_tag_and_public_ports_are_validated(self):
|
||||
values = valid_config()
|
||||
values['IMAGE_TAG'] = 'main'
|
||||
values['KANBAN_PORT'] = values['SITE_PORT']
|
||||
errors = config_errors(values)
|
||||
self.assertTrue(any('IMAGE_TAG' in error for error in errors))
|
||||
self.assertTrue(any('must differ' in error for error in errors))
|
||||
|
||||
def test_approval_and_secret_name_are_enforced(self):
|
||||
values = valid_config()
|
||||
values['PRODUCTION_DEPLOY_ENABLED'] = 'yes'
|
||||
values['DATABASE_URL_SECRET'] = '../unsafe'
|
||||
errors = config_errors(values)
|
||||
self.assertTrue(any('PRODUCTION_DEPLOY_ENABLED' in error for error in errors))
|
||||
self.assertTrue(any('DATABASE_URL_SECRET' in error for error in errors))
|
||||
|
||||
def test_current_application_is_explicitly_blocked(self):
|
||||
errors = source_errors()
|
||||
self.assertTrue(any('local/adapters.py remains local-only' in error for error in errors))
|
||||
self.assertTrue(any('local/app.py remains local-only' in error for error in errors))
|
||||
|
||||
def test_secret_reuse_and_incoherent_limits_are_rejected(self):
|
||||
values = valid_config()
|
||||
values['PAYMENT_TOKEN_SECRET'] = values['TINY_TOKEN_SECRET']
|
||||
values['OWNER_UPLOAD_QUOTA_BYTES'] = str(int(values['STORAGE_QUOTA_BYTES']) + 1)
|
||||
values['SCAN_MAX_BYTES'] = str(int(values['MAX_UPLOAD_BYTES']) + 1)
|
||||
errors = config_errors(values)
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
BIN
dtf-organograma-sistema.pdf
Normal file
BIN
dtf-organograma-sistema.pdf
Normal file
Binary file not shown.
205
dtf-organograma-ti.html
Normal file
205
dtf-organograma-ti.html
Normal file
@@ -0,0 +1,205 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DTF — arquitetura para o TI</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:#0E1013;--card:#191C21;--card2:#1F232A;--linha:#2C3039;--tx:#E9E7E2;--fraco:#8A8F98;
|
||||
--ciano:#00A6C4;--roxo:#7C6BD1;--magenta:#C8256B;--verde:#3E9B62;--alerta:#C9531F;--amarelo:#D9A400}
|
||||
*{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.55;-webkit-font-smoothing:antialiased}
|
||||
h1,h2,h3{font-family:"Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
|
||||
.faixa{height:5px;background:linear-gradient(90deg,var(--ciano) 0 34%,var(--roxo) 34% 67%,var(--magenta) 67% 100%)}
|
||||
.wrap{max-width:1180px;margin:0 auto;padding:0 20px 70px}
|
||||
header{padding:34px 0 18px}
|
||||
.eb{font-size:10.5px;letter-spacing:.26em;text-transform:uppercase;color:var(--ciano);margin-bottom:10px}
|
||||
h1{font-weight:700;font-size:clamp(26px,4.6vw,44px);line-height:1.03}
|
||||
h1 span{color:var(--fraco)}
|
||||
.lead{color:var(--fraco);max-width:70ch;margin-top:10px}
|
||||
.mapa{margin:24px 0 0;overflow-x:auto}
|
||||
svg{width:100%;min-width:960px;height:auto}
|
||||
.zona{fill:#14161A;stroke:var(--linha);stroke-dasharray:4 3}
|
||||
.zt{font-family:"IBM Plex Mono",monospace;font-size:10px;fill:var(--fraco);letter-spacing:.2em;text-transform:uppercase}
|
||||
.no rect{fill:var(--card);stroke:var(--linha);cursor:pointer;transition:stroke .15s,fill .15s}
|
||||
.no:hover rect{stroke:var(--ciano)}
|
||||
.no[aria-current="true"] rect{fill:var(--card2);stroke-width:2}
|
||||
.no text{pointer-events:none}
|
||||
.nn{font-family:"Space Grotesk",sans-serif;font-size:13.5px;font-weight:700;fill:var(--tx)}
|
||||
.nq{font-family:"IBM Plex Mono",monospace;font-size:9.5px;fill:var(--fraco)}
|
||||
.lig{stroke:var(--linha);fill:none;marker-end:url(#s)}
|
||||
.lb{font-family:"IBM Plex Mono",monospace;font-size:9.5px;fill:var(--fraco)}
|
||||
.det{margin-top:26px;background:var(--card);border:1px solid var(--linha);border-left:3px solid var(--acc,var(--ciano));border-radius:9px;padding:22px 24px}
|
||||
.det h2{font-size:22px;margin-bottom:2px}
|
||||
.det .sub{color:var(--acc,var(--ciano));font-size:12.5px;margin-bottom:16px}
|
||||
.det p{margin-bottom:12px;max-width:74ch}
|
||||
pre{background:var(--card2);border:1px solid var(--linha);border-radius:7px;padding:13px 15px;overflow-x:auto;font-size:12.5px;line-height:1.6;margin:12px 0;color:#C9CDD4}
|
||||
pre b{color:var(--ciano);font-weight:500} pre i{color:var(--fraco);font-style:normal} pre u{color:var(--amarelo);text-decoration:none}
|
||||
.blocos{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px;margin-top:14px}
|
||||
.b{background:var(--card2);border:1px solid var(--linha);border-radius:7px;padding:13px 15px}
|
||||
.b h3{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--fraco);margin-bottom:6px;font-family:"IBM Plex Mono",monospace}
|
||||
.b ul{list-style:none} .b li{padding:3px 0 3px 15px;position:relative;font-size:13px}
|
||||
.b li::before{content:"·";position:absolute;left:4px;color:var(--acc,var(--ciano));font-weight:700}
|
||||
.aten{margin-top:14px;border-left:2px solid var(--alerta);background:#22150F;padding:12px 16px;border-radius:0 6px 6px 0;font-size:13.5px}
|
||||
.aten b{color:var(--alerta);display:block;margin-bottom:3px;font-family:"Space Grotesk",sans-serif}
|
||||
code{background:var(--card2);border:1px solid var(--linha);border-radius:4px;padding:1px 6px;font-size:12.5px;color:var(--ciano)}
|
||||
.dica{color:var(--fraco);font-size:12px;margin-top:10px}
|
||||
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="faixa"></div>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="eb">Documento técnico · Wagner · agosto 2026</div>
|
||||
<h1>Arquitetura do DTF 24h<br><span>três zonas, oito componentes</span></h1>
|
||||
<p class="lead">Clique em qualquer caixa para ver o que ela faz, em que linguagem, o que acontece se cair e o que ainda depende de resposta. O código completo está em <code>dtf-sistema.zip</code>.</p>
|
||||
</header>
|
||||
|
||||
<div class="mapa">
|
||||
<svg viewBox="0 0 1120 380" role="img" aria-label="Arquitetura do sistema DTF">
|
||||
<defs><marker id="s" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">
|
||||
<path d="M1 1L9 5L1 9" fill="none" stroke="#2C3039" stroke-width="1.6"/></marker></defs>
|
||||
|
||||
<rect class="zona" x="10" y="26" width="330" height="120" rx="8"/>
|
||||
<text class="zt" x="26" y="46">Terceiros</text>
|
||||
<rect class="zona" x="356" y="26" width="380" height="230" rx="8"/>
|
||||
<text class="zt" x="372" y="46">Nuvem · VPS + storage</text>
|
||||
<rect class="zona" x="752" y="26" width="358" height="330" rx="8"/>
|
||||
<text class="zt" x="768" y="46">Fábrica · rede interna</text>
|
||||
|
||||
<path class="lig" d="M200 84 H424"/><text class="lb" x="250" y="78">webhook</text>
|
||||
<path class="lig" d="M584 110 V140"/>
|
||||
<path class="lig" d="M584 196 V226"/>
|
||||
<path class="lig" d="M700 168 H850"/><text class="lb" x="730" y="162">HTTPS</text>
|
||||
<path class="lig" d="M916 200 V232"/>
|
||||
<path class="lig" d="M916 288 V318"/>
|
||||
<path class="lig" d="M850 262 H620 M620 262 V88 M620 88 H460" style="stroke-dasharray:3 3"/>
|
||||
<text class="lb" x="636" y="180">marcador</text>
|
||||
<path class="lig" d="M424 116 H200" style="stroke-dasharray:3 3"/><text class="lb" x="252" y="132">WhatsApp</text>
|
||||
|
||||
<g class="no" data-i="0" tabindex="0"><rect x="26" y="58" width="174" height="52" rx="8"/>
|
||||
<text class="nn" x="42" y="82">Tiny · Olist</text><text class="nq" x="42" y="98">pedido nasce aqui</text></g>
|
||||
<g class="no" data-i="1" tabindex="0"><rect x="26" y="116" width="174" height="24" rx="6"/>
|
||||
<text class="nn" x="42" y="133" style="font-size:12px">WhatsApp API</text></g>
|
||||
|
||||
<g class="no" data-i="2" tabindex="0"><rect x="424" y="58" width="260" height="52" rx="8" style="stroke:#00A6C4"/>
|
||||
<text class="nn" x="442" y="82">Portal · FastAPI</text><text class="nq" x="442" y="98">token, link, pré-assinada</text></g>
|
||||
<g class="no" data-i="3" tabindex="0"><rect x="424" y="140" width="260" height="56" rx="8" style="stroke:#00A6C4"/>
|
||||
<text class="nn" x="442" y="164">Robô · pyvips</text><text class="nq" x="442" y="182">valida, repete, fatia, carimba</text></g>
|
||||
<g class="no" data-i="4" tabindex="0"><rect x="424" y="226" width="124" height="24" rx="6"/>
|
||||
<text class="nn" x="440" y="243" style="font-size:12px">PostgreSQL</text></g>
|
||||
<g class="no" data-i="5" tabindex="0"><rect x="560" y="226" width="124" height="24" rx="6"/>
|
||||
<text class="nn" x="576" y="243" style="font-size:12px">Storage S3</text></g>
|
||||
|
||||
<g class="no" data-i="6" tabindex="0"><rect x="850" y="142" width="200" height="52" rx="8" style="stroke:#7C6BD1"/>
|
||||
<text class="nn" x="866" y="166">Agente</text><text class="nq" x="866" y="182">baixa e grava na pasta</text></g>
|
||||
<g class="no" data-i="7" tabindex="0"><rect x="850" y="232" width="200" height="52" rx="8"/>
|
||||
<text class="nn" x="866" y="256">Pastas do servidor</text><text class="nq" x="866" y="272">00 … 60 · espelho</text></g>
|
||||
<g class="no" data-i="8" tabindex="0"><rect x="850" y="318" width="200" height="52" rx="8" style="stroke:#C8256B"/>
|
||||
<text class="nn" x="866" y="342">Kanban · FastAPI</text><text class="nq" x="866" y="358">SQLite · fonte de verdade</text></g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="det" id="det"></div>
|
||||
<p class="dica">Setas do teclado navegam entre os componentes.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const C=[
|
||||
{t:"Tiny · Olist",s:"Sistema que já existe · fonte do número do pedido",acc:"var(--fraco)",
|
||||
p:["O número do Tiny é a chave de tudo. O portal só cria o link depois que o pedido existe lá.","<b>Gatilho é webhook, não polling.</b> O link sai no segundo em que o pedido nasce."],
|
||||
code:`<b>POST</b> /webhook/tiny <i>header: x-token</i>
|
||||
{ "numero":"48213", "cliente":"...", "telefone":"...", "metros":2.4 }`,
|
||||
blocos:[["Marcadores que o sistema escreve",["DTF-PRODUCAO — entrou na máquina","CORRECAO DTF — voltou","DTF-PRONTO — finalizado"]],
|
||||
["Marcadores que ele preserva",["multiempresa, VALE, Troca","BOLETO NEXSTAR SICCOB","Maq 1 a 6 (histórico)"]]],
|
||||
aten:["Confirmar antes de subir","Endpoint exato de marcadores na API v3, limite de requisições por minuto e se o refresh token expira. Tudo isolado em <code>tiny.py</code> — se mudar, muda só ali."]},
|
||||
|
||||
{t:"WhatsApp API",s:"Meta oficial ou provedor · três avisos ao cliente",acc:"var(--fraco)",
|
||||
p:["Aprovada, em produção e finalizada. Mais correção, que é a única que pede resposta. Sete avisos viraria spam e o cliente pararia de ler."],
|
||||
blocos:[["Meta oficial",["Sem risco de bloqueio do número","Exige template aprovado","R$ 100 a 300/mês"]],
|
||||
["Z-API ou Evolution",["Sobe rápido, sem template","Risco de bloqueio","Mais barato"]]],
|
||||
aten:["Decisão pendente","O número do DTF migra para a API oficial ou fica com provedor? Muda o custo e o risco."]},
|
||||
|
||||
{t:"Portal · FastAPI",s:"Python 3.12 · VPS Linux · aberto na internet",acc:"var(--ciano)",
|
||||
p:["Cria o token do link, entrega a URL pré-assinada e recebe o aviso de que o upload terminou.","<b>Fica fora da fábrica de propósito.</b> Se a internet da Altus cair às 3h da manhã, o cliente sobe do mesmo jeito e o agente baixa quando voltar."],
|
||||
code:`<b>GET</b> /arte/{token} <i>página</i>
|
||||
<b>POST</b> /api/arte/{token}/url <i>URL pré-assinada</i>
|
||||
<b>POST</b> /api/arte/{token}/pronto <i>dispara o robô</i>
|
||||
<b>GET</b> /api/artes <i>o agente busca aqui</i>`,
|
||||
blocos:[["Por que pré-assinada",["200 MB pelo VPS derrubaria o processo","O navegador fala direto com o storage","Validade de 1 hora"]],
|
||||
["Token do link",["UUID v4, 7 dias","Um por pedido, nunca reaproveitado","Rate limit de 20 req/min"]]]},
|
||||
|
||||
{t:"Robô · pyvips",s:"Pré-flight, repetição, fatiamento e carimbo",acc:"var(--ciano)",
|
||||
p:["pyvips lê PNG e TIFF de 200 MB em streaming, sem carregar na memória. É o que permite rodar num VPS de 4 GB.","<b>A regra que mais pega arquivo ruim é o DPI efetivo.</b> O valor gravado no cabeçalho mente — o cliente escala a imagem e o programa mantém 300."],
|
||||
code:`dpi = img.height / (cm_uteis / 2.54) <i># não confie no metadado</i>
|
||||
<b>se</b> dpi < 150: recusa
|
||||
<b>se</b> repeticoes > 1: empilhar()
|
||||
partes = fatiar(limite_m=<u>15</u>)
|
||||
carimbar(partes[-1]) <i># só na última</i>`,
|
||||
blocos:[["Recusa",["Sem canal alfa","DPI efetivo abaixo de 150","Largura acima de 57 cm","JPEG ou arquivo corrompido"]],
|
||||
["Aceita com aviso",["DPI entre 150 e 300","Traço abaixo de 0,4 mm","Bordas com alfa parcial"]]],
|
||||
aten:["carimbar() precisa de teste visual","A lógica está certa, mas posicionamento de texto com pyvips sempre pede ajuste olhando o resultado impresso."]},
|
||||
|
||||
{t:"PostgreSQL",s:"Na nuvem · pedidos, artes, partes e jobs",acc:"var(--ciano)",
|
||||
p:["Quatro tabelas: <code>pedido</code>, <code>arte</code>, <code>parte</code> e <code>job</code>.","<b>A fila de jobs é tabela, não broker.</b> <code>SELECT … FOR UPDATE SKIP LOCKED</code> resolve. Redis e Celery são complexidade sem retorno neste volume."],
|
||||
blocos:[["Retentativa",["1 min, 5 min, 30 min","Depois desiste e alerta","O log local grava independente"]],
|
||||
["Backup",["Dump diário para o storage","SQLite local no backup do servidor"]]]},
|
||||
|
||||
{t:"Storage S3",s:"Cloudflare R2 ou Backblaze B2",acc:"var(--ciano)",
|
||||
p:["Escolhido por não cobrar saída de dados — o agente baixa tudo que sobe, e isso sairia caro em provedor que tarifa egress.","<b>Retenção de 90 dias.</b> A arte é propriedade do cliente. Regra de ciclo de vida no bucket faz a limpeza sozinha."],
|
||||
aten:["Antivírus antes de liberar","ClamAV no arquivo recebido antes de o agente baixar. É arquivo de terceiro entrando na rede."]},
|
||||
|
||||
{t:"Agente",s:"Python · serviço no servidor da fábrica",acc:"var(--roxo)",
|
||||
p:["Busca o que foi aprovado e grava em <code>00_ARTE_RECEBIDA</code>. Baixa em arquivo temporário e só renomeia quando completa — o kanban nunca vê arquivo pela metade.","Trata queda de internet como situação normal, não como erro. Para, e volta quando a conexão voltar."],
|
||||
code:`webhook do portal + polling de 60 s <i>(rede de segurança)</i>
|
||||
baixa → confere SHA-256 → insere no SQLite → marca baixada
|
||||
heartbeat a cada 5 min`,
|
||||
blocos:[["Windows",["nssm install DtfAgente","Variáveis via AppEnvironmentExtra"]],
|
||||
["Linux",["dtf-agente.service","Restart=always"]]],
|
||||
aten:["Serviço silencioso parado é pior que erro barulhento","Sem heartbeat por 15 minutos, o portal precisa alertar o TI. Ninguém percebe um agente morto até o cliente cobrar."]},
|
||||
|
||||
{t:"Pastas do servidor",s:"Espelho, não fonte de verdade",acc:"var(--fraco)",
|
||||
p:["Nove pastas numeradas para ordenar sozinhas no explorador. O arquivo acompanha o card.","<b>Se a pasta e o banco divergirem, o banco ganha.</b> Um watchdog opcional captura quem move arquivo direto pelo Explorer e corrige o banco."],
|
||||
code:`00_ARTE_RECEBIDA 10_ARTE_TRATADA 20_FILA_IMPRESSAO
|
||||
30_IMPRIMINDO 40_CORRECAO 50_APLICACAO
|
||||
60_FINALIZADO _ERRO _log`},
|
||||
|
||||
{t:"Kanban · FastAPI",s:"SQLite local · rede interna, sem porta para fora",acc:"var(--magenta)",
|
||||
p:["Mover o card grava o movimento, move o arquivo e enfileira o marcador e o WhatsApp. Ninguém abre o Tiny para atualizar nada.","<b>O movimento é gravado antes de qualquer integração externa.</b> Se o Tiny estiver fora do ar, a medição de tempo já está salva."],
|
||||
code:`<b>POST</b> /api/mover {arte_id, para, usuario, maquina}
|
||||
<b>GET</b> /api/quadro <i>cards com tempo parado em tempo real</i>
|
||||
<b>GET</b> /p/{pedido} <i>o QR do carimbo cai aqui</i>
|
||||
<b>GET</b> /api/relatorio/etapas`,
|
||||
blocos:[["Tabela movimento",["de, para, entrou, saiu, segundos","usuário e máquina","é dela que saem todos os números"]],
|
||||
["O que ela responde",["Tempo médio por etapa","Fila por máquina","Taxa de retrabalho","Se o gargalo é máquina ou designer"]]],
|
||||
aten:["O front ainda não está conectado","O protótipo usa dados fixos. Precisa trocar por chamadas a /api/quadro e /api/mover. Meia hora de trabalho."]}
|
||||
];
|
||||
const nos=[...document.querySelectorAll('.no')], det=document.getElementById('det');
|
||||
function ver(i){
|
||||
const c=C[i];
|
||||
det.style.setProperty('--acc',c.acc);
|
||||
let h=`<h2>${c.t}</h2><div class="sub">${c.s}</div>`+c.p.map(p=>`<p>${p}</p>`).join('');
|
||||
if(c.code) h+=`<pre>${c.code}</pre>`;
|
||||
if(c.blocos) h+='<div class="blocos">'+c.blocos.map(b=>
|
||||
`<div class="b"><h3>${b[0]}</h3><ul>${b[1].map(x=>`<li>${x}</li>`).join('')}</ul></div>`).join('')+'</div>';
|
||||
if(c.aten) h+=`<div class="aten"><b>${c.aten[0]}</b>${c.aten[1]}</div>`;
|
||||
det.innerHTML=h;
|
||||
nos.forEach((n,j)=>n.setAttribute('aria-current',j===i?'true':'false'));
|
||||
}
|
||||
nos.forEach(n=>{
|
||||
n.addEventListener('click',()=>ver(+n.dataset.i));
|
||||
n.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){ver(+n.dataset.i);e.preventDefault();}});
|
||||
});
|
||||
document.addEventListener('keydown',e=>{
|
||||
const at=nos.findIndex(n=>n.getAttribute('aria-current')==='true');
|
||||
if(e.key==='ArrowRight'&&at<nos.length-1){nos[at+1].focus();ver(at+1);e.preventDefault();}
|
||||
if(e.key==='ArrowLeft'&&at>0){nos[at-1].focus();ver(at-1);e.preventDefault();}
|
||||
});
|
||||
ver(0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2819
dtf-site.html
Normal file
2819
dtf-site.html
Normal file
File diff suppressed because it is too large
Load Diff
557
kanban/main.py
Normal file
557
kanban/main.py
Normal file
@@ -0,0 +1,557 @@
|
||||
"""
|
||||
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")
|
||||
271
kanban/static/kanban.html
Normal file
271
kanban/static/kanban.html
Normal file
@@ -0,0 +1,271 @@
|
||||
<!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> ')+'</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>
|
||||
89
kanban/tiny.py
Normal file
89
kanban/tiny.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
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()
|
||||
68
kanban/whats.py
Normal file
68
kanban/whats.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
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))
|
||||
11
local/Dockerfile
Normal file
11
local/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
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
|
||||
13
local/Dockerfile.web
Normal file
13
local/Dockerfile.web
Normal file
@@ -0,0 +1,13 @@
|
||||
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/
|
||||
1
local/__init__.py
Normal file
1
local/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
127
local/adapters.py
Normal file
127
local/adapters.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Local-only composition root. No production provider implementations/imports."""
|
||||
import os
|
||||
from typing import Protocol
|
||||
from urllib.parse import urlparse
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
def require_local():
|
||||
if os.environ.get('APP_ENV') != 'local':
|
||||
raise RuntimeError('This runtime only supports APP_ENV=local')
|
||||
for name in ('PAYMENT', 'FREIGHT', 'TINY', 'WHATSAPP'):
|
||||
if os.environ.get(f'{name}_ADAPTER') != 'fake':
|
||||
raise RuntimeError(f'{name} must use the fake adapter')
|
||||
if os.environ.get('STORAGE_ADAPTER') != 's3-local':
|
||||
raise RuntimeError('Only local S3 storage is supported')
|
||||
for name in ('S3_ENDPOINT', 'S3_PUBLIC_ENDPOINT'):
|
||||
endpoint = urlparse(os.environ[name])
|
||||
if endpoint.scheme != 'http' or endpoint.hostname not in ('storage', 'localhost', '127.0.0.1'):
|
||||
raise RuntimeError(f'{name} must point to local MinIO')
|
||||
|
||||
class PaymentAdapter(Protocol):
|
||||
def pay(self, quote_id: str, total_cents: int) -> dict: ...
|
||||
|
||||
class FakePayment:
|
||||
def pay(self, quote_id: str, total_cents: int) -> dict:
|
||||
return {'provider': 'fake', 'id': f'local-{quote_id}',
|
||||
'status': 'paid', 'total_cents': total_cents}
|
||||
|
||||
class FreightAdapter(Protocol):
|
||||
def quote(self, service: str, postal_code: str) -> dict: ...
|
||||
|
||||
class FakeFreight:
|
||||
def quote(self, service: str, postal_code: str) -> dict:
|
||||
if service == 'pickup':
|
||||
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}
|
||||
if service != 'mock-standard' or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit():
|
||||
raise ValueError('Select pickup or a mock quote with an eight-digit CEP')
|
||||
cents = int(os.environ.get('MOCK_FREIGHT_CENTS', '1500'))
|
||||
if cents < 0:
|
||||
raise ValueError('Invalid mock freight configuration')
|
||||
return {'provider': 'fake', 'service': service, 'postal_code': postal_code,
|
||||
'total_cents': cents, 'description': 'Local simulated freight'}
|
||||
|
||||
class EventAdapter(Protocol):
|
||||
def deliver(self, event_key: str, payload: dict) -> dict: ...
|
||||
|
||||
class FakeTiny:
|
||||
def deliver(self, event_key: str, payload: dict) -> dict:
|
||||
return {'provider': 'fake-tiny', 'reference': f"LOCAL-{payload['number']}",
|
||||
'event_key': event_key, 'status': 'recorded-locally'}
|
||||
|
||||
class FakeWhatsApp:
|
||||
def deliver(self, event_key: str, payload: dict) -> dict:
|
||||
return {'provider': 'fake-whatsapp', 'event_key': event_key,
|
||||
'event': payload['event'], 'status': 'recorded-locally'}
|
||||
|
||||
class ObjectStorage(Protocol):
|
||||
def begin(self, key: str) -> str: ...
|
||||
def parts(self, key: str, upload_id: str) -> list: ...
|
||||
def part_url(self, key: str, upload_id: str, part: int, size: int) -> str: ...
|
||||
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 health(self): ...
|
||||
def discard(self, key: str, upload_id: str, complete: bool): ...
|
||||
|
||||
class LocalS3Storage:
|
||||
def __init__(self):
|
||||
config = Config(signature_version='s3v4', s3={'addressing_style': 'path'},
|
||||
connect_timeout=3, read_timeout=10, retries={'max_attempts': 2})
|
||||
self.client = boto3.client('s3', endpoint_url=os.environ['S3_ENDPOINT'], config=config)
|
||||
self.public = boto3.client('s3', endpoint_url=os.environ['S3_PUBLIC_ENDPOINT'], config=config)
|
||||
self.bucket = os.environ['S3_BUCKET']
|
||||
|
||||
def initialize(self):
|
||||
try:
|
||||
self.client.head_bucket(Bucket=self.bucket)
|
||||
except ClientError as exc:
|
||||
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
|
||||
raise
|
||||
self.client.create_bucket(Bucket=self.bucket)
|
||||
self.client.put_bucket_lifecycle_configuration(Bucket=self.bucket, LifecycleConfiguration={
|
||||
'Rules': [{'ID': 'local-artwork-retention', 'Status': 'Enabled', 'Filter': {'Prefix': ''},
|
||||
'Expiration': {'Days': 30}, 'AbortIncompleteMultipartUpload': {'DaysAfterInitiation': 1}}]})
|
||||
|
||||
def health(self):
|
||||
self.client.head_bucket(Bucket=self.bucket)
|
||||
|
||||
def begin(self, key):
|
||||
return self.client.create_multipart_upload(Bucket=self.bucket, Key=key,
|
||||
ContentType='application/octet-stream')['UploadId']
|
||||
|
||||
def parts(self, key, upload_id):
|
||||
result = []
|
||||
for page in self.client.get_paginator('list_parts').paginate(
|
||||
Bucket=self.bucket, Key=key, UploadId=upload_id):
|
||||
result.extend(page.get('Parts', []))
|
||||
return result
|
||||
|
||||
def part_url(self, key, upload_id, part, size):
|
||||
return self.public.generate_presigned_url('upload_part', Params={
|
||||
'Bucket': self.bucket, 'Key': key, 'UploadId': upload_id, 'PartNumber': part,
|
||||
'ContentLength': size}, ExpiresIn=900)
|
||||
|
||||
def complete(self, key, upload_id, parts):
|
||||
self.client.complete_multipart_upload(Bucket=self.bucket, Key=key, UploadId=upload_id,
|
||||
MultipartUpload={'Parts': [{'PartNumber': p['PartNumber'], 'ETag': p['ETag']} for p in parts]})
|
||||
|
||||
def size(self, key):
|
||||
return self.client.head_object(Bucket=self.bucket, Key=key)['ContentLength']
|
||||
|
||||
def download(self, key, name):
|
||||
from urllib.parse import quote
|
||||
return self.public.generate_presigned_url('get_object', Params={
|
||||
'Bucket': self.bucket, 'Key': key,
|
||||
'ResponseContentDisposition': "attachment; filename*=UTF-8''" + quote(name, safe=''),
|
||||
'ResponseContentType': 'application/octet-stream'}, ExpiresIn=300)
|
||||
|
||||
def discard(self, key, upload_id, complete):
|
||||
if not complete:
|
||||
try:
|
||||
self.client.abort_multipart_upload(Bucket=self.bucket, Key=key, UploadId=upload_id)
|
||||
except ClientError as exc:
|
||||
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
|
||||
raise
|
||||
self.client.delete_object(Bucket=self.bucket, Key=key)
|
||||
341
local/app.py
Normal file
341
local/app.py
Normal file
@@ -0,0 +1,341 @@
|
||||
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 .adapters import FakeFreight, FakePayment, LocalS3Storage, require_local
|
||||
from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
|
||||
from .pricing import price
|
||||
from .auth import owner, session_row, new_session, operator, throttle, audit, rate_limit
|
||||
from .scanning import require_clean
|
||||
|
||||
require_local()
|
||||
storage = LocalS3Storage()
|
||||
payment = FakePayment()
|
||||
freight = FakeFreight()
|
||||
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 Local Portal/API', lifespan=lifespan, docs_url=None, redoc_url=None)
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=['localhost', '127.0.0.1'])
|
||||
|
||||
@app.post('/api/operator/login')
|
||||
def operator_login(body: OperatorLogin, request: Request, response: Response):
|
||||
throttle('operator:'+body.username, request)
|
||||
valid_user = secrets.compare_digest(body.username.encode(), os.environ['OPERATOR_USER'].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')
|
||||
raise HTTPException(401, 'Invalid local 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(), body.username))
|
||||
response.set_cookie('dtf_operator', token, httponly=True, samesite='strict', path='/api/operator', max_age=28800)
|
||||
audit('operator_login_success', operator=body.username)
|
||||
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, 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 != 'http://'+request.headers.get('host','')):
|
||||
audit('cross_origin_rejected')
|
||||
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)
|
||||
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': 'local', 'storage': 'minio', 'integrations': 'fake'}
|
||||
|
||||
@app.get('/api/session')
|
||||
def session(request: Request, response: Response):
|
||||
try:
|
||||
session_id = owner(request)
|
||||
except HTTPException:
|
||||
rate_limit('guest-sessions', 'local-stack', 120, 900)
|
||||
with db.connect() as c:
|
||||
session_id = new_session(c, response)
|
||||
return {'environment': 'local', '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 local 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)):
|
||||
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)
|
||||
93
local/auth.py
Normal file
93
local/auth.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Local customer passwords and revocable database sessions. No email service."""
|
||||
import hashlib
|
||||
import secrets
|
||||
import logging
|
||||
import json
|
||||
from uuid import UUID, uuid4
|
||||
from fastapi import HTTPException, Request
|
||||
from .db import connect
|
||||
|
||||
def password_hash(password, salt=None):
|
||||
salt = salt or secrets.token_hex(16)
|
||||
digest = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5).hex()
|
||||
return f'scrypt-v2${salt}${digest}'
|
||||
|
||||
def password_matches(password, stored):
|
||||
try:
|
||||
version, salt, digest = stored.split('$')
|
||||
if version not in ('scrypt', 'scrypt-v2'): return False
|
||||
actual = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8,
|
||||
p=1 if version == 'scrypt' else 5).hex()
|
||||
return secrets.compare_digest(actual, digest)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
DUMMY_PASSWORD_HASH = password_hash('invalid-account-password', '00'*16)
|
||||
|
||||
def session_row(request):
|
||||
try:
|
||||
sid = UUID(request.cookies.get('dtf_session', ''))
|
||||
except ValueError:
|
||||
raise HTTPException(401, 'Start a local session first')
|
||||
with connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(401, 'Session expired; sign in or start a new session')
|
||||
return row
|
||||
|
||||
def owner(request: Request):
|
||||
return session_row(request)['owner']
|
||||
|
||||
def new_session(c, response, identity=None):
|
||||
sid = uuid4()
|
||||
identity = identity or uuid4()
|
||||
c.execute('INSERT INTO dtf_local.sessions(id,owner) VALUES(%s,%s)', (sid,identity))
|
||||
response.set_cookie('dtf_session', str(sid), httponly=True, samesite='strict', max_age=86400*7)
|
||||
return identity
|
||||
|
||||
def transfer_guest(c, previous, identity):
|
||||
# Successful authentication can claim only the current guest browser's records.
|
||||
if c.execute('SELECT id FROM dtf_local.accounts WHERE id=%s', (previous['owner'],)).fetchone():
|
||||
return
|
||||
for table in ('uploads','quotes','orders'):
|
||||
c.execute(f'UPDATE dtf_local.{table} SET owner=%s WHERE owner=%s', (identity,previous['owner']))
|
||||
c.execute('DELETE FROM dtf_local.sessions WHERE owner=%s', (previous['owner'],))
|
||||
|
||||
def audit(event, **fields):
|
||||
# Only explicit metadata: never cookies, passwords, signed URLs or request bodies.
|
||||
logging.getLogger('dtf.security').warning(json.dumps({'event':event, **fields}, sort_keys=True))
|
||||
try:
|
||||
from psycopg.types.json import Jsonb
|
||||
with connect() as c:
|
||||
c.execute('INSERT INTO dtf_local.security_events(event,details) VALUES(%s,%s)',(event,Jsonb(fields)))
|
||||
except Exception:
|
||||
# Logging must still work during a DB outage without recursively auditing itself.
|
||||
logging.getLogger('dtf.security').error('Security event persistence unavailable')
|
||||
|
||||
def rate_limit(scope, identity, limit, seconds=900):
|
||||
key = hashlib.sha256((scope+'|'+identity).encode()).hexdigest()
|
||||
with connect() as c:
|
||||
row = c.execute("""INSERT INTO dtf_local.login_attempts(key,attempts) VALUES(%s,1)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
attempts=CASE WHEN dtf_local.login_attempts.started_at < now()-%s*interval '1 second' THEN 1 ELSE LEAST(dtf_local.login_attempts.attempts+1,1000000) END,
|
||||
started_at=CASE WHEN dtf_local.login_attempts.started_at < now()-%s*interval '1 second' THEN now() ELSE dtf_local.login_attempts.started_at END
|
||||
RETURNING attempts""", (key,seconds,seconds)).fetchone()
|
||||
if row['attempts'] > limit:
|
||||
audit('rate_limit', scope=scope)
|
||||
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
|
||||
|
||||
def throttle(email, request):
|
||||
# Independent account and source buckets prevent bypass by rotating emails.
|
||||
rate_limit('auth-source', request.client.host if request.client else 'local', 60)
|
||||
rate_limit('auth-account', email, 10)
|
||||
|
||||
def operator(request: Request):
|
||||
token = request.cookies.get('dtf_operator', '')
|
||||
if not token or len(token)>128:
|
||||
raise HTTPException(401, 'Sign in to the local Kanban')
|
||||
digest = hashlib.sha256(token.encode()).hexdigest()
|
||||
with connect() as c:
|
||||
row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(401, 'Operator session expired')
|
||||
return row['username']
|
||||
128
local/backup.py
Normal file
128
local/backup.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Local PostgreSQL and clean-object backup with isolated restore verification."""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from uuid import uuid4
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
BACKUPS = ROOT / 'backups'
|
||||
|
||||
def docker(script, *args, **kwargs):
|
||||
return subprocess.run(['docker','compose','exec','-T','db','sh','-c',script,'sh',*args],
|
||||
cwd=ROOT,check=True,**kwargs)
|
||||
|
||||
def checksum(path):
|
||||
digest=hashlib.sha256()
|
||||
with path.open('rb') as stream:
|
||||
for block in iter(lambda:stream.read(1048576),b''):digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
def compose_exec(service, *command, **kwargs):
|
||||
return subprocess.run(['docker','compose','exec','-T',service,*command],
|
||||
cwd=ROOT,check=True,**kwargs)
|
||||
|
||||
def create():
|
||||
BACKUPS.mkdir(mode=0o700,exist_ok=True)
|
||||
prefix='dtf-'+datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')+'-'+uuid4().hex[:8]
|
||||
database=BACKUPS/(prefix+'.dump')
|
||||
objects=BACKUPS/(prefix+'.objects.tar.gz')
|
||||
manifest_path=BACKUPS/(prefix+'.manifest.json')
|
||||
sidecar=BACKUPS/(prefix+'.manifest.sha256')
|
||||
created=[]
|
||||
try:
|
||||
with database.open('xb') as stream:
|
||||
created.append(database);database.chmod(0o600)
|
||||
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',
|
||||
stdout=stream,stderr=subprocess.PIPE)
|
||||
summaries=[line.removeprefix('DTF_BACKUP_SUMMARY ') for line in
|
||||
result.stderr.decode().splitlines() if line.startswith('DTF_BACKUP_SUMMARY ')]
|
||||
if len(summaries)!=1:
|
||||
raise RuntimeError('Object backup did not return a valid summary')
|
||||
manifest={'format':'dtf-local-backup-v2',
|
||||
'created_at':datetime.now(timezone.utc).isoformat(),
|
||||
'database':{'file':database.name,'sha256':checksum(database)},
|
||||
'objects':{'file':objects.name,'sha256':checksum(objects),
|
||||
**json.loads(summaries[0])}}
|
||||
manifest_path.write_text(json.dumps(manifest,indent=2,sort_keys=True)+'\n')
|
||||
created.append(manifest_path);manifest_path.chmod(0o600)
|
||||
sidecar.write_text(checksum(manifest_path)+'\n')
|
||||
created.append(sidecar);sidecar.chmod(0o600)
|
||||
except Exception:
|
||||
for path in reversed(created):path.unlink(missing_ok=True)
|
||||
raise
|
||||
print(manifest_path.relative_to(ROOT))
|
||||
return manifest_path
|
||||
|
||||
def verify_database(path):
|
||||
if checksum(path)!=path.with_suffix('.sha256').read_text().strip():
|
||||
raise ValueError('Backup checksum mismatch')
|
||||
database='dtf_verify_'+uuid4().hex
|
||||
docker('createdb -U "$POSTGRES_USER" "$1"',database)
|
||||
try:
|
||||
with path.open('rb') as stream:
|
||||
docker('pg_restore -U "$POSTGRES_USER" -d "$1" --exit-on-error --no-owner --no-privileges',database,stdin=stream)
|
||||
result=docker('psql -U "$POSTGRES_USER" -d "$1" -At -v ON_ERROR_STOP=1 -c "SELECT json_build_object(\'orders\',(SELECT count(*) FROM dtf_local.orders),\'uploads\',(SELECT count(*) FROM dtf_local.uploads),\'accounts\',(SELECT count(*) FROM dtf_local.accounts),\'history\',(SELECT count(*) FROM dtf_local.movements));"',database,capture_output=True,text=True)
|
||||
print('PASS: backup restored to isolated database; restored counts '+result.stdout.strip())
|
||||
finally:
|
||||
# Only the freshly created UUID-named verification database is removed.
|
||||
docker('dropdb -U "$POSTGRES_USER" "$1"',database)
|
||||
print('Removed temporary verification database. Active database was untouched.')
|
||||
|
||||
def bundle_file(manifest_path, name):
|
||||
if not isinstance(name,str) or Path(name).name!=name:
|
||||
raise ValueError('Backup manifest contains an invalid filename')
|
||||
path=(manifest_path.parent/name).resolve()
|
||||
if path.parent!=BACKUPS.resolve():
|
||||
raise ValueError('Backup manifest references a file outside backups/')
|
||||
return path
|
||||
|
||||
def verify(path):
|
||||
path=path.resolve()
|
||||
if path.parent!=BACKUPS.resolve():
|
||||
raise ValueError('Choose a backup generated in this repository backups/ directory')
|
||||
if path.suffix=='.dump':
|
||||
return verify_database(path)
|
||||
if not path.name.endswith('.manifest.json'):
|
||||
raise ValueError('Choose a v2 .manifest.json or legacy .dump backup')
|
||||
sidecar=path.with_name(path.name.removesuffix('.json')+'.sha256')
|
||||
if checksum(path)!=sidecar.read_text().strip():
|
||||
raise ValueError('Backup manifest checksum mismatch')
|
||||
manifest=json.loads(path.read_text())
|
||||
if manifest.get('format')!='dtf-local-backup-v2':
|
||||
raise ValueError('Unsupported backup format')
|
||||
database=bundle_file(path,manifest.get('database',{}).get('file'))
|
||||
objects=bundle_file(path,manifest.get('objects',{}).get('file'))
|
||||
if checksum(database)!=manifest['database'].get('sha256') or checksum(objects)!=manifest['objects'].get('sha256'):
|
||||
raise ValueError('Backup bundle checksum mismatch')
|
||||
# Reuse the legacy database verifier with a temporary matching sidecar.
|
||||
legacy_sidecar=database.with_suffix('.sha256')
|
||||
had_legacy=legacy_sidecar.exists()
|
||||
previous=legacy_sidecar.read_bytes() if had_legacy else None
|
||||
legacy_sidecar.write_text(manifest['database']['sha256']+'\n');legacy_sidecar.chmod(0o600)
|
||||
try:
|
||||
verify_database(database)
|
||||
finally:
|
||||
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)
|
||||
print('PASS: combined database and clean-object backup verified. Active data was untouched.')
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('command',choices=['create','verify','create-and-verify'])
|
||||
parser.add_argument('path',nargs='?',type=Path,
|
||||
help='v2 manifest printed by create, or a legacy .dump')
|
||||
args=parser.parse_args()
|
||||
if args.command=='verify':
|
||||
if not args.path:parser.error('verify requires the path printed by create')
|
||||
verify(args.path)
|
||||
else:
|
||||
path=create()
|
||||
if args.command=='create-and-verify':verify(path)
|
||||
25
local/bootstrap.py
Normal file
25
local/bootstrap.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""One-shot schema/role setup. Only this job receives database admin credentials."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
def main():
|
||||
role = os.environ['APP_DB_USER']
|
||||
with psycopg.connect(os.environ['DATABASE_ADMIN_URL']) as c:
|
||||
admin, database = c.execute('SELECT current_user,current_database()').fetchone()
|
||||
if role == admin:
|
||||
raise RuntimeError('Application and database administrator must differ')
|
||||
if not c.execute('SELECT 1 FROM pg_roles WHERE rolname=%s', (role,)).fetchone():
|
||||
c.execute(sql.SQL('CREATE ROLE {} LOGIN').format(sql.Identifier(role)))
|
||||
c.execute(sql.SQL('ALTER ROLE {} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD {}').format(
|
||||
sql.Identifier(role), sql.Literal(os.environ['APP_DB_PASSWORD'])))
|
||||
c.execute(Path(__file__).with_name('schema.sql').read_text())
|
||||
c.execute(sql.SQL('REVOKE CREATE ON SCHEMA public FROM PUBLIC'))
|
||||
c.execute(sql.SQL('GRANT CONNECT ON DATABASE {} TO {}').format(sql.Identifier(database),sql.Identifier(role)))
|
||||
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)))
|
||||
print('Local schema migrated; runtime role has DML only.')
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
140
local/browser_test.mjs
Normal file
140
local/browser_test.mjs
Normal file
@@ -0,0 +1,140 @@
|
||||
// Dependency-free Chrome DevTools smoke test. Node 22+ and Chrome required.
|
||||
import {spawn} from 'node:child_process';
|
||||
import {mkdtemp,readFile,writeFile,mkdir} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {resolve} from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
try {
|
||||
for(const line of (await readFile('.env','utf8')).split('\n')) {
|
||||
if(!line.startsWith('#') && line.includes('=')) {
|
||||
const i=line.indexOf('=');process.env[line.slice(0,i)]??=line.slice(i+1);
|
||||
}
|
||||
}
|
||||
}catch(e){if(e.code!=='ENOENT')throw e;}
|
||||
const profile=await mkdtemp(tmpdir()+'/dtf-browser-');
|
||||
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
|
||||
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
|
||||
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
|
||||
],{stdio:['ignore','ignore','pipe']});
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
let stderr='';chrome.stderr.on('data',data=>stderr+=data.toString());
|
||||
const clients=[];
|
||||
async function waitFor(fn,description,timeout=30000){const end=Date.now()+timeout;while(Date.now()<end){if(await fn())return;await pause(200);}throw new Error('Timeout: '+description);}
|
||||
class Page {
|
||||
constructor(ws){this.ws=ws;this.next=0;this.pending=new Map();this.errors=[];
|
||||
ws.onmessage=event=>{const d=JSON.parse(event.data);if(d.id){const p=this.pending.get(d.id);if(p){this.pending.delete(d.id);d.error?p.reject(d.error):p.resolve(d.result);}}else if(d.method==='Runtime.exceptionThrown')this.errors.push(d.params.exceptionDetails);};
|
||||
}
|
||||
call(method,params={}){return new Promise((resolve,reject)=>{const id=++this.next;this.pending.set(id,{resolve,reject});this.ws.send(JSON.stringify({id,method,params}));});}
|
||||
async eval(expression){const result=await this.call('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(result.exceptionDetails)throw new Error(JSON.stringify(result.exceptionDetails));return result.result.value;}
|
||||
async click(selector){await this.eval(`document.querySelector(${JSON.stringify(selector)}).click()`);}
|
||||
async fill(selector,value,event='input'){await this.eval(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(value)};el.dispatchEvent(new Event(${JSON.stringify(event)},{bubbles:true}));})()`);}
|
||||
async text(){return this.eval('document.body.innerText');}
|
||||
async screenshot(path){const result=await this.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});await writeFile(path,Buffer.from(result.data,'base64'));}
|
||||
}
|
||||
try{
|
||||
let port;
|
||||
await waitFor(async()=>{try{port=Number((await readFile(profile+'/DevToolsActivePort','utf8')).split('\n')[0]);return true;}catch{return false;}},'Chrome startup');
|
||||
async function page(url){
|
||||
const tab=await (await fetch('http://localhost:'+port+'/json/new?'+encodeURIComponent(url),{method:'PUT'})).json();
|
||||
const ws=new WebSocket(tab.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
|
||||
const p=new Page(ws);clients.push(p);await p.call('Runtime.enable');await p.call('Page.enable');
|
||||
await p.call('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
await waitFor(()=>p.eval('document.readyState === "complete"'),'page load');return p;
|
||||
}
|
||||
const site=await page('http://localhost:'+(process.env.SITE_PORT||8080));
|
||||
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'checkout bridge');
|
||||
// Prove escaping itself, independently of the CSP's second line of defense.
|
||||
await site.call('Page.setBypassCSP',{enabled:true});
|
||||
await site.eval(`(()=>{window.xssProbe=0;abrir('file');sel([new File(['test'],'<img src=x onerror=window.xssProbe=1>.cdr')]);})()`);
|
||||
await pause(500);
|
||||
assert.equal(await site.eval('window.xssProbe'),0);
|
||||
assert.equal(await site.eval('document.querySelectorAll("#lista img[onerror]").length'),0);
|
||||
assert.ok((await site.text()).includes('<img src=x onerror=window.xssProbe=1>.cdr'));
|
||||
await site.eval(`(()=>{recusa([new File(['test'],'bad.<img src=x onerror=window.xssProbe=1>')]);})()`);
|
||||
await pause(200);assert.equal(await site.eval('window.xssProbe'),0);
|
||||
await site.call('Page.setBypassCSP',{enabled:false});
|
||||
await site.call('Page.reload');
|
||||
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe');
|
||||
await site.click('[data-modo="file"]');
|
||||
await site.click('[data-cam="tabela"]');
|
||||
const root=await site.call('DOM.getDocument');
|
||||
const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'});
|
||||
await site.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('local/fixtures/local-test.cdr')]});
|
||||
await waitFor(()=>site.eval('!!document.querySelector("[data-comp]")'),'manual length field');
|
||||
await site.fill('[data-comp]','1.01','change');
|
||||
await waitFor(()=>site.eval('!!itemAtual'),'cart calculation');
|
||||
assert.equal(await site.eval('itemAtual.total'),21.89);
|
||||
await site.fill('#fCnpj','11222333000181');
|
||||
await site.fill('#fZap','11999999999');
|
||||
await site.fill('#fMail','local-browser@example.test');
|
||||
await pause(800);
|
||||
await site.call('Page.reload');
|
||||
await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery');
|
||||
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
|
||||
assert.equal(await site.eval('pedido[0].total'),21.89);
|
||||
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
|
||||
await site.click('#bPagar');
|
||||
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
|
||||
const qid=await site.eval('localStorage.getItem("dtf-quote")');
|
||||
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
|
||||
await kanban.fill('#user',process.env.OPERATOR_USER||'operator');
|
||||
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
|
||||
await kanban.eval('document.getElementById("login").requestSubmit()');
|
||||
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
|
||||
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
|
||||
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
|
||||
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
|
||||
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
|
||||
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Atualizar pedido local").click()');
|
||||
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed');
|
||||
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido pago local").click()');
|
||||
await waitFor(async()=> (await site.text()).includes('Nenhuma cobrança real.'),'local payment');
|
||||
await kanban.click('#refresh');
|
||||
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card');
|
||||
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
|
||||
// Click real transition buttons, including rerender after each move.
|
||||
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
|
||||
if(state==='fil'){
|
||||
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent==='Arquivos de produção').click();})()`);
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls');
|
||||
const doc=await kanban.call('DOM.getDocument');
|
||||
const input=await kanban.call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:`[data-order="${oid}"] [data-final-item]`});
|
||||
await kanban.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('local/fixtures/local-test.cdr')]});
|
||||
await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file');
|
||||
await kanban.eval(`(()=>{const form=document.querySelector('[data-order="${oid}"] form');form.querySelector('[type=checkbox]').click();form.requestSubmit();})()`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval');
|
||||
}
|
||||
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent===${JSON.stringify('→ '+title)}).click();})()`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state);
|
||||
}
|
||||
// Reload proves the board is persisted on the backend.
|
||||
await kanban.call('Page.reload');
|
||||
await waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board');
|
||||
await mkdir('output/local',{recursive:true});
|
||||
await site.eval('window.scrollTo({top:0,behavior:"instant"})');
|
||||
await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position');
|
||||
await site.screenshot('output/local/site.png');
|
||||
await kanban.screenshot('output/local/kanban.png');
|
||||
const portal=await page('http://localhost:'+(process.env.SITE_PORT||8080)+'/portal.html?order='+oid);
|
||||
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking');
|
||||
await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999');
|
||||
await portal.fill('#register-email','browser-'+Date.now()+'@example.test');
|
||||
await portal.fill('#register-password','local-browser-password-123');
|
||||
await portal.eval('document.getElementById("register").requestSubmit()');
|
||||
await waitFor(()=>portal.eval('document.getElementById("auth").hidden'),'customer registration');
|
||||
assert.ok((await portal.text()).includes('Finalizado'));
|
||||
await portal.screenshot('output/local/portal.png');
|
||||
// A logout must clear draft file blobs and metadata, including other open Site tabs.
|
||||
await portal.eval(`(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onsuccess=()=>resolve(r.result);r.onerror=reject;});await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').put({items:[new File(['private'],'private.cdr')],expires:Date.now()+86400000},'security-fixture');tx.oncomplete=resolve;tx.onerror=reject;});db.close();})()`);
|
||||
await portal.click('#logout');
|
||||
await waitFor(()=>portal.eval('document.getElementById("logout").hidden'),'customer logout');
|
||||
let stored;
|
||||
await waitFor(async()=>{stored=await portal.eval(`(async()=>{try{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});const n=await new Promise((resolve,reject)=>{const r=db.transaction('cart').objectStore('cart').count();r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});db.close();return n;}catch{return -1;}})()`);return stored>=0;},'IndexedDB available after Clear-Site-Data');
|
||||
assert.equal(stored,0);
|
||||
assert.deepEqual(portal.errors,[]);
|
||||
assert.deepEqual(site.errors,[]);assert.deepEqual(kanban.errors,[]);
|
||||
console.log('PASS: browser Site upload → operator quote → local paid order → all main Kanban states → reload persistence. Order '+oid);
|
||||
console.log('Screenshots: output/local/site.png and output/local/kanban.png');
|
||||
console.log('PASS: filename XSS escaping with CSP bypassed, no stored operator password, logout clears browser file blobs.');
|
||||
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1500));process.exitCode=1;}
|
||||
finally{for(const p of clients)p.ws.close();chrome.kill();}
|
||||
15
local/clamd.conf
Normal file
15
local/clamd.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
Foreground yes
|
||||
LogTime yes
|
||||
DatabaseDirectory /var/lib/clamav
|
||||
TCPSocket 3310
|
||||
TCPAddr 0.0.0.0
|
||||
MaxThreads 2
|
||||
MaxQueue 8
|
||||
StreamMaxLength 128M
|
||||
MaxFileSize 128M
|
||||
MaxScanSize 256M
|
||||
MaxScanTime 120000
|
||||
AlertExceedsMax yes
|
||||
AlertEncrypted yes
|
||||
ScanPDF yes
|
||||
ScanArchive yes
|
||||
15
local/compile_web.py
Normal file
15
local/compile_web.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""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)))
|
||||
191
local/customer.py
Normal file
191
local/customer.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""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
|
||||
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')
|
||||
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)
|
||||
12
local/db.py
Normal file
12
local/db.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
def connect():
|
||||
return psycopg.connect(os.environ['DATABASE_URL'], row_factory=dict_row)
|
||||
|
||||
def initialize():
|
||||
with connect() as c:
|
||||
c.execute(Path(__file__).with_name('schema.sql').read_text())
|
||||
|
||||
24
local/dependency_audit.py
Normal file
24
local/dependency_audit.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Audit exact installed API packages, not a host-dependent re-resolution.
|
||||
|
||||
Install pip-audit in an isolated environment; pass its executable as argv[1].
|
||||
Only package names/versions are sent to the public vulnerability database.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if __name__=='__main__':
|
||||
packages=json.loads(subprocess.check_output(['docker','compose','exec','-T','api','pip','list','--format=json'],text=True))
|
||||
requirements='\n'.join(p['name']+'=='+p['version'] for p in packages)+'\n'
|
||||
destination=Path('output/security');destination.mkdir(parents=True,exist_ok=True)
|
||||
(destination/'runtime-requirements.txt').write_text(requirements)
|
||||
result=subprocess.run([sys.argv[1] if len(sys.argv)>1 else 'pip-audit','--no-deps','--disable-pip',
|
||||
'--progress-spinner','off','-r',str(destination/'runtime-requirements.txt'),
|
||||
'-f','json','-o',str(destination/'python-audit.json')])
|
||||
if (destination/'python-audit.json').exists():
|
||||
data=json.loads((destination/'python-audit.json').read_text())
|
||||
for package in data['dependencies']:
|
||||
for vulnerability in package.get('vulns',[]):
|
||||
print(package['name'],package['version'],vulnerability['id'],'fix:',','.join(vulnerability['fix_versions']))
|
||||
sys.exit(result.returncode)
|
||||
4
local/fixtures/local-test.cdr
Normal file
4
local/fixtures/local-test.cdr
Normal file
@@ -0,0 +1,4 @@
|
||||
DTF LOCAL UPLOAD TEST ONLY
|
||||
This is a harmless text fixture with a .cdr extension, not printable artwork.
|
||||
Use the manual-length path: 1.01 metres, grade 0, pickup = BRL 21.89.
|
||||
It intentionally requires no artwork parsing or automatic pre-flight.
|
||||
13
local/lock_dependencies.sh
Executable file
13
local/lock_dependencies.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
--env HOME=/tmp \
|
||||
--env CUSTOM_COMPILE_COMMAND=./local/lock_dependencies.sh \
|
||||
--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'
|
||||
101
local/models.py
Normal file
101
local/models.py
Normal file
@@ -0,0 +1,101 @@
|
||||
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):
|
||||
username: str = Field(min_length=1, max_length=100)
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
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)
|
||||
34
local/nginx.conf.template
Normal file
34
local/nginx.conf.template
Normal file
@@ -0,0 +1,34 @@
|
||||
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; }
|
||||
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;
|
||||
location = /health { access_log off; return 200 'ok'; }
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=100 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $http_host;
|
||||
client_max_body_size 2m;
|
||||
}
|
||||
location / { try_files $uri $uri/ =404; }
|
||||
}
|
||||
server {
|
||||
listen 81;
|
||||
server_name localhost;
|
||||
if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; }
|
||||
client_max_body_size 2m;
|
||||
location / {
|
||||
limit_req zone=api_limit burst=100 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
}
|
||||
27
local/pricing.py
Normal file
27
local/pricing.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Exact approved Site DTF price ladders; integer BRL cents at the boundary.
|
||||
|
||||
Quantity/grade must be supplied by an authenticated commercial reviewer.
|
||||
This module does not inspect artwork or perform pre-flight.
|
||||
"""
|
||||
from decimal import Decimal, ROUND_CEILING
|
||||
|
||||
TIERS = {
|
||||
'file': [(90,1490),(75,1620),(60,1750),(40,1870),(0,1990)],
|
||||
'avulsa': [(90,2490),(75,2620),(60,2750),(40,2870),(0,2990)],
|
||||
'uvfile': [(90,6990),(75,7390),(60,7790),(40,8190),(0,8590)],
|
||||
'uv': [(90,8390),(75,8790),(60,9190),(40,9590),(0,9990)],
|
||||
}
|
||||
|
||||
def price(mode: str, metres: str, grade: int) -> dict:
|
||||
length = Decimal(str(metres))
|
||||
if mode not in TIERS or not length.is_finite() or not 0 < length <= 12000:
|
||||
raise ValueError('Invalid product or length')
|
||||
if isinstance(grade, bool) or not isinstance(grade, int) or not 0 <= grade <= 100:
|
||||
raise ValueError('Grade must be an integer between 0 and 100')
|
||||
billed = max(Decimal('1'), (length * 10).to_integral_value(rounding=ROUND_CEILING) / 10)
|
||||
unit = next(cents for minimum, cents in TIERS[mode] if grade >= minimum)
|
||||
return {'mode': mode, 'metres': str(length), 'grade': grade,
|
||||
'billed_metres': str(billed), 'unit_cents': unit,
|
||||
'total_cents': int(billed * unit),
|
||||
'discount_cents': int(billed * (TIERS[mode][-1][1] - unit))}
|
||||
|
||||
316
local/requirements.lock
Normal file
316
local/requirements.lock
Normal file
@@ -0,0 +1,316 @@
|
||||
#
|
||||
# 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
|
||||
7
local/requirements.txt
Normal file
7
local/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
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
|
||||
34
local/retention_test.py
Normal file
34
local/retention_test.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Run inside the API container. Creates only synthetic retention fixtures."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
from botocore.exceptions import ClientError
|
||||
from .adapters import LocalS3Storage
|
||||
from .db import connect
|
||||
from .worker import cleanup
|
||||
|
||||
def run():
|
||||
storage=LocalS3Storage()
|
||||
ids=[]
|
||||
for expired in (True,False):
|
||||
uid=uuid4();ids.append(uid);key=f'originals/{uid}'
|
||||
upload=storage.begin(key)
|
||||
result=storage.client.upload_part(Bucket=storage.bucket,Key=key,UploadId=upload,PartNumber=1,Body=b'LOCAL RETENTION TEST')
|
||||
storage.complete(key,upload,[{'PartNumber':1,'ETag':result['ETag']}])
|
||||
expiry=datetime.now(timezone.utc)+timedelta(days=-1 if expired else 1)
|
||||
with connect() as c:
|
||||
c.execute('INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,complete,expires_at) VALUES(%s,%s,%s,%s,%s,%s,true,%s)',
|
||||
(uid,uuid4(),'LOCAL-RETENTION-TEST.txt',len(b'LOCAL RETENTION TEST'),key,upload,expiry))
|
||||
cleanup()
|
||||
with connect() as c:
|
||||
assert c.execute('SELECT purged_at FROM dtf_local.uploads WHERE id=%s',(ids[0],)).fetchone()['purged_at']
|
||||
assert not c.execute('SELECT purged_at FROM dtf_local.uploads WHERE id=%s',(ids[1],)).fetchone()['purged_at']
|
||||
try:storage.size(f'originals/{ids[0]}');raise AssertionError('Expired bytes still exist')
|
||||
except ClientError as exc:assert exc.response['ResponseMetadata']['HTTPStatusCode']==404
|
||||
assert storage.size(f'originals/{ids[1]}')==len(b'LOCAL RETENTION TEST')
|
||||
# Clean only the other fixture just created above, leaving metadata intact.
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.uploads SET expires_at=now()-interval '1 second' WHERE id=%s",(ids[1],))
|
||||
cleanup()
|
||||
print('PASS: expired bytes removed, unexpired bytes preserved, upload metadata retained. Synthetic fixture bytes cleaned up.')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
42
local/runtime_security_test.py
Normal file
42
local/runtime_security_test.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Run inside API container: permissions, hashing and fail-closed scanner unit checks."""
|
||||
import hashlib
|
||||
from unittest.mock import patch
|
||||
from botocore.exceptions import ClientError
|
||||
from .db import connect
|
||||
from .adapters import LocalS3Storage
|
||||
from .auth import password_hash, password_matches
|
||||
from .scanning import ClamAV, require_clean
|
||||
from fastapi import HTTPException
|
||||
|
||||
def run():
|
||||
with connect() as c:
|
||||
role=c.execute('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user').fetchone()
|
||||
assert not any(role.values()),role
|
||||
assert not c.execute("SELECT has_schema_privilege(current_user,'dtf_local','CREATE') AS allowed").fetchone()['allowed']
|
||||
storage=LocalS3Storage()
|
||||
storage.health()
|
||||
visible={bucket['Name'] for bucket in storage.client.list_buckets()['Buckets']}
|
||||
assert visible=={storage.bucket},visible
|
||||
for call in (lambda:storage.client.get_bucket_policy(Bucket=storage.bucket),
|
||||
lambda:storage.client.get_object(Bucket=storage.bucket,Key='outside-runtime-prefix/test.cdr')):
|
||||
try:call();raise AssertionError('Runtime storage credentials have excessive privilege')
|
||||
except ClientError as error:assert error.response['ResponseMetadata']['HTTPStatusCode']==403
|
||||
password='test-password-for-hash'
|
||||
salt='00'*16
|
||||
old='scrypt$'+salt+'$'+hashlib.scrypt(password.encode(),salt=bytes.fromhex(salt),n=16384,r=8,p=1).hex()
|
||||
assert password_matches(password,old)
|
||||
new=password_hash(password)
|
||||
assert new.startswith('scrypt-v2$') and password_matches(password,new)
|
||||
assert not password_matches('wrong',new)
|
||||
for state in ('pending','error','rejected'):
|
||||
try:require_clean({'complete':True,'scan_state':state});raise AssertionError('Unscanned file released')
|
||||
except HTTPException as error:assert error.status_code==409
|
||||
require_clean({'complete':True,'scan_state':'clean'})
|
||||
assert ClamAV().ping() and ClamAV().version().startswith('ClamAV ')
|
||||
assert ClamAV().scan(None,134217729)[0]=='rejected'
|
||||
with patch('local.scanning.socket.create_connection',side_effect=OSError('offline')):
|
||||
try:ClamAV().scan(None,1);raise AssertionError('Offline scanner returned success')
|
||||
except OSError:pass
|
||||
print('PASS: runtime DB/S3 least privilege, legacy/current password hashes, quarantine states and scanner size/offline behavior')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
81
local/scanning.py
Normal file
81
local/scanning.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""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
|
||||
|
||||
def require_clean(row):
|
||||
if not row['complete'] or row['scan_state'] != 'clean':
|
||||
raise HTTPException(409, 'Artwork is quarantined until the malware scan succeeds')
|
||||
|
||||
class ClamAV:
|
||||
def command(self, command, timeout=2):
|
||||
with socket.create_connection(('scanner',3310),timeout=timeout) as sock:
|
||||
sock.settimeout(timeout)
|
||||
sock.sendall(b'z'+command+b'\0')
|
||||
reply=b''
|
||||
while b'\0' not in reply and len(reply)<4096:
|
||||
block=sock.recv(4096)
|
||||
if not block:break
|
||||
reply+=block
|
||||
return reply.rstrip(b'\0\n')
|
||||
|
||||
def ping(self):
|
||||
return self.command(b'PING') == b'PONG'
|
||||
|
||||
def version(self):
|
||||
return self.command(b'VERSION').decode('utf-8','replace')
|
||||
|
||||
def scan(self, stream, size):
|
||||
if size > min(134217728, int(os.environ.get('SCAN_MAX_BYTES','134217728'))):
|
||||
return 'rejected', 'File exceeds the local malware scan limit'
|
||||
with socket.create_connection(('scanner',3310),timeout=10) as sock:
|
||||
sock.settimeout(150)
|
||||
sock.sendall(b'zINSTREAM\0')
|
||||
sent=0
|
||||
for chunk in stream.iter_chunks(chunk_size=65536):
|
||||
sent+=len(chunk)
|
||||
if sent>size: return 'rejected','Stored file size changed'
|
||||
sock.sendall(struct.pack('!I',len(chunk))+chunk)
|
||||
if sent!=size:return 'rejected','Stored file size changed'
|
||||
sock.sendall(b'\0\0\0\0')
|
||||
reply=b''
|
||||
while b'\0' not in reply and len(reply)<4096:
|
||||
block=sock.recv(4096)
|
||||
if not block:break
|
||||
reply+=block
|
||||
result=reply.rstrip(b'\0\n')
|
||||
if result==b'stream: OK':return 'clean',None
|
||||
if result.endswith(b' FOUND'):return 'rejected','Malware or unsafe scan condition detected'
|
||||
return 'error','Scanner could not verify this file'
|
||||
|
||||
def scan_one(storage, scanner=None):
|
||||
scanner=scanner or ClamAV()
|
||||
with connect() as c:
|
||||
row=c.execute('''SELECT * FROM dtf_local.uploads WHERE complete AND purged_at IS NULL
|
||||
AND expires_at>now() AND scan_state IN ('pending','error') AND scan_after<=now()
|
||||
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1''').fetchone()
|
||||
if not row:return False
|
||||
try:
|
||||
stream=storage.client.get_object(Bucket=storage.bucket,Key=row['object_key'])['Body']
|
||||
try:state,reason=scanner.scan(stream,row['size'])
|
||||
finally:stream.close()
|
||||
except Exception:
|
||||
state,reason='error','Local malware scanner unavailable; file remains blocked'
|
||||
c.execute("""UPDATE dtf_local.uploads SET scan_state=%s,scan_reason=%s,scanned_at=now(),
|
||||
scan_after=now()+interval '1 minute',
|
||||
expires_at=CASE WHEN %s IN ('rejected','error') THEN LEAST(expires_at,now()+interval '3 days') ELSE expires_at END
|
||||
WHERE id=%s""",(state,reason,state,row['id']))
|
||||
audit('artwork_scan', upload=str(row['id']), result=state)
|
||||
return True
|
||||
|
||||
def scan_loop(storage):
|
||||
while True:
|
||||
try:
|
||||
if scan_one(storage):continue
|
||||
except Exception:
|
||||
audit('scanner_worker_error')
|
||||
time.sleep(1)
|
||||
17
local/scanning_test.py
Normal file
17
local/scanning_test.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Harmless EICAR anti-malware test and blocked download/quote regressions."""
|
||||
from uuid import uuid4
|
||||
from .smoke_test import Client, upload_bytes
|
||||
|
||||
def run():
|
||||
customer=Client();customer.call('/session')
|
||||
# Standard antivirus test string, not executable malware.
|
||||
marker=b'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'
|
||||
uid=upload_bytes(customer,marker,name='SECURITY-EICAR.cdr',expected_scan='rejected')
|
||||
customer.call('/operator/uploads/'+uid+'/download',operator=True,expected=409)
|
||||
customer.call('/quotes',{'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'security@example.test'},
|
||||
'items':[{'mode':'file','metres':'1','grade':0,'uploads':[uid]}],'freight':{'service':'pickup'}},expected=409)
|
||||
clean=upload_bytes(customer,b'Harmless local artwork fixture',name='SECURITY-CLEAN.cdr')
|
||||
customer.call('/operator/uploads/'+clean+'/download',operator=True)
|
||||
print('PASS: real ClamAV detects EICAR; rejected artwork cannot be downloaded or quoted; clean artwork is released.')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
81
local/schema.sql
Normal file
81
local/schema.sql
Normal file
@@ -0,0 +1,81 @@
|
||||
-- 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)
|
||||
);
|
||||
31
local/security_status.py
Normal file
31
local/security_status.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Local alert triage: prints redacted counts; exits 1 when attention is required."""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from .db import connect
|
||||
from .scanning import ClamAV
|
||||
|
||||
def scanner_status():
|
||||
try:
|
||||
version=ClamAV().version()
|
||||
signature_text=version.split('/',2)[2]
|
||||
signature_at=datetime.strptime(signature_text,'%a %b %d %H:%M:%S %Y').replace(tzinfo=timezone.utc)
|
||||
age_hours=max(0,(datetime.now(timezone.utc)-signature_at).total_seconds()/3600)
|
||||
return {'available':True,'version':version,'signature_age_hours':round(age_hours,1),
|
||||
'signatures_stale':age_hours>24*7}
|
||||
except Exception:
|
||||
return {'available':False,'version':None,'signature_age_hours':None,'signatures_stale':True}
|
||||
|
||||
def status():
|
||||
with connect() as c:
|
||||
rows=c.execute("""SELECT event,count(*) AS count FROM dtf_local.security_events
|
||||
WHERE created_at>now()-interval '24 hours' GROUP BY event ORDER BY event""").fetchall()
|
||||
blocked=c.execute("SELECT scan_state,count(*) AS count FROM dtf_local.uploads WHERE purged_at IS NULL AND scan_state IN ('error','rejected') GROUP BY scan_state").fetchall()
|
||||
counts={r['event']:r['count'] for r in rows}
|
||||
scanner=scanner_status()
|
||||
attention=bool(blocked or not scanner['available'] or scanner['signatures_stale'] or
|
||||
counts.get('rate_limit',0) or counts.get('scanner_worker_error',0) or
|
||||
counts.get('operator_login_failed',0)+counts.get('customer_login_failed',0)>=10)
|
||||
return {'attention_required':attention,'scanner':scanner,'last_24h':counts,'blocked_artwork':blocked}
|
||||
|
||||
if __name__=='__main__':
|
||||
result=status();print(json.dumps(result,indent=2));raise SystemExit(1 if result['attention_required'] else 0)
|
||||
66
local/security_test.py
Normal file
66
local/security_test.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Non-destructive localhost security regressions. Leaves tiny test upload reservations."""
|
||||
import base64
|
||||
import os
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from uuid import uuid4
|
||||
from .smoke_test import Client, BASE
|
||||
|
||||
def raw(path, expected, headers=None, body=None):
|
||||
request=Request(BASE+path, data=body, headers=headers or {})
|
||||
try:
|
||||
with urlopen(request,timeout=10) as response:
|
||||
assert response.status==expected
|
||||
return response.headers
|
||||
except HTTPError as error:
|
||||
assert error.code==expected,(path,error.code,expected)
|
||||
return error.headers
|
||||
|
||||
def run():
|
||||
headers=raw('/',200)
|
||||
policy=headers['Content-Security-Policy']
|
||||
assert "script-src-attr 'none'" in policy and "frame-ancestors 'none'" in policy
|
||||
assert "'sha256-" in policy and "object-src 'none'" in policy
|
||||
raw('/api/health',400,{'Host':'attacker.invalid'})
|
||||
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'https://attacker.invalid'},b'{}')
|
||||
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}')
|
||||
print('PASS: CSP, frame protection, Host and cross-origin rejection')
|
||||
|
||||
operator=Client()
|
||||
credentials={'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}
|
||||
encoded=base64.b64encode((credentials['username']+':'+credentials['password']).encode()).decode()
|
||||
raw('/api/operator/board',401,{'Authorization':'Basic '+encoded})
|
||||
operator.call('/operator/login',credentials)
|
||||
token=next(c for c in operator.jar if c.name=='dtf_operator')
|
||||
assert token.has_nonstandard_attr('HttpOnly') and token.get_nonstandard_attr('SameSite')=='strict'
|
||||
assert token.path=='/api/operator'
|
||||
operator.call('/operator/board')
|
||||
replay=Client();replay.jar.set_cookie(token)
|
||||
operator.call('/operator/logout',{})
|
||||
replay.call('/operator/board',expected=401)
|
||||
print('PASS: Basic rejected; HttpOnly scoped operator session; server-side logout revocation')
|
||||
|
||||
client=Client();client.call('/session')
|
||||
client.call('/uploads',{'name':'payload.html','size':1},expected=422)
|
||||
uid=client.call('/uploads',{'name':'SECURITY-PART.cdr','size':3})['id']
|
||||
url=client.call('/uploads/'+uid+'/parts/1',{})['url']
|
||||
assert 'content-length' in parse_qs(urlparse(url).query)['X-Amz-SignedHeaders'][0]
|
||||
try:
|
||||
urlopen(Request(url,data=b'toolong',method='PUT'),timeout=10)
|
||||
raise AssertionError('Signed part accepted wrong length')
|
||||
except HTTPError as error:assert error.code==403,error.code
|
||||
with urlopen(Request(url,data=b'abc',method='PUT'),timeout=10) as response:assert response.status==200
|
||||
client.call('/uploads/'+uid+'/complete',{})
|
||||
count=int(os.environ.get('MAX_PENDING_UPLOADS','10'))
|
||||
for i in range(count):client.call('/uploads',{'name':'SECURITY-PENDING.cdr','size':1})
|
||||
client.call('/uploads',{'name':'SECURITY-OVER-LIMIT.cdr','size':1},expected=429)
|
||||
print('PASS: extension allowlist, exact multipart Content-Length signature, pending upload quota')
|
||||
|
||||
# Unique identity avoids locking out the real local operator.
|
||||
attacker=Client();username='test-'+uuid4().hex
|
||||
for _ in range(10):attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=401)
|
||||
attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=429)
|
||||
print('PASS: operator login throttling (only synthetic account bucket exhausted)')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
147
local/smoke_test.py
Normal file
147
local/smoke_test.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Local stack integration checks; creates and retains clearly named test orders.
|
||||
|
||||
Run: python3 -m local.smoke_test. Standard library only. Honors .env/environment.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import hashlib
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import build_opener, HTTPCookieProcessor, Request, urlopen
|
||||
from uuid import uuid4
|
||||
|
||||
if Path('.env').exists():
|
||||
for line in Path('.env').read_text().splitlines():
|
||||
if line.strip() and not line.startswith('#') and '=' in line:
|
||||
key,value=line.split('=',1)
|
||||
os.environ.setdefault(key,value)
|
||||
BASE='http://localhost:'+os.environ.get('SITE_PORT','8080')
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.jar=http.cookiejar.CookieJar()
|
||||
self.opener=build_opener(HTTPCookieProcessor(self.jar))
|
||||
self.operator_client=None
|
||||
def call(self,path,body=None,operator=False,expected=200):
|
||||
headers={'Content-Type':'application/json'}
|
||||
if operator:
|
||||
if self.operator_client is None:
|
||||
self.operator_client=Client()
|
||||
self.operator_client.call('/operator/login',{'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
|
||||
return self.operator_client.call(path,body,expected=expected)
|
||||
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),headers=headers)
|
||||
try:
|
||||
with self.opener.open(request,timeout=30) as response:
|
||||
assert response.status==expected,(path,response.status,expected)
|
||||
return json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code!=expected:raise AssertionError((path,exc.code,exc.read().decode())) from exc
|
||||
return json.load(exc)
|
||||
|
||||
def wait_scan(client, uid, operator=False, expected='clean'):
|
||||
prefix='/operator/uploads' if operator else '/uploads'
|
||||
deadline=time.monotonic()+150
|
||||
while time.monotonic()<deadline:
|
||||
state=client.call(prefix+'/'+uid,operator=operator)
|
||||
if state['scan_state'] in ('clean','rejected','error'):
|
||||
assert state['scan_state']==expected,state
|
||||
return state
|
||||
time.sleep(0.5)
|
||||
raise AssertionError('Malware scan did not finish')
|
||||
|
||||
def upload_bytes(client, content, name='LOCAL-TEST.cdr', order_id=None, expected_scan='clean'):
|
||||
operator=order_id is not None
|
||||
prefix='/operator/uploads' if operator else '/uploads'
|
||||
start='/operator/orders/'+order_id+'/uploads' if operator else prefix
|
||||
data=client.call(start,{'name':name,'size':len(content)},operator=operator)
|
||||
uid=data['id'];size=data['part_bytes']
|
||||
for offset in range(0,len(content),size):
|
||||
url=client.call(prefix+'/'+uid+'/parts/'+str(offset//size+1),{},operator=operator)['url']
|
||||
with urlopen(Request(url,data=content[offset:offset+size],method='PUT'),timeout=30) as response:
|
||||
assert response.status==200
|
||||
client.call(prefix+'/'+uid+'/complete',{},operator=operator)
|
||||
wait_scan(client,uid,operator,expected_scan)
|
||||
return uid
|
||||
|
||||
def run():
|
||||
client=Client();other=Client()
|
||||
config=client.call('/session');other.call('/session')
|
||||
assert client.call('/health')['integrations']=='fake'
|
||||
client.call('/operator/board',expected=401)
|
||||
block=config['part_bytes'];content=b'DTF local multipart test\n'+b'x'*block
|
||||
uid=client.call('/uploads',{'name':'LOCAL-SMOKE-ONLY.cdr','size':len(content)})['id']
|
||||
other.call('/uploads/'+uid,expected=404)
|
||||
other.call('/uploads/'+uid+'/parts/1',{},expected=404)
|
||||
signed=client.call('/uploads/'+uid+'/parts/1',{})['url']
|
||||
with urlopen(Request(signed,data=content[:block],method='PUT'),timeout=30) as response:assert response.status==200
|
||||
assert client.call('/uploads/'+uid)['parts']==[1]
|
||||
client.call('/uploads/'+uid+'/complete',{},expected=409)
|
||||
signed=client.call('/uploads/'+uid+'/parts/2',{})['url']
|
||||
with urlopen(Request(signed,data=content[block:],method='PUT'),timeout=30) as response:assert response.status==200
|
||||
client.call('/uploads/'+uid+'/complete',{})
|
||||
client.call('/uploads/'+uid+'/complete',{})
|
||||
wait_scan(client,uid)
|
||||
client.call('/uploads/'+uid+'/parts/1',{},expected=409)
|
||||
download=client.call('/operator/uploads/'+uid+'/download',operator=True)
|
||||
with urlopen(download['url'],timeout=30) as response:assert hashlib.sha256(response.read()).digest()==hashlib.sha256(content).digest()
|
||||
unsigned=download['url'].split('?')[0]
|
||||
try:urlopen(unsigned,timeout=10);raise AssertionError('Bucket must be private')
|
||||
except HTTPError as exc:assert exc.code==403
|
||||
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
|
||||
|
||||
items=[{'mode':m,'metres':'2.75','grade':90,'uploads':[uid]} for m in ('file','avulsa','uvfile','uv')]
|
||||
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
|
||||
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}}
|
||||
client.call('/quotes',{**draft,'total_cents':1},expected=422)
|
||||
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
|
||||
quote=client.call('/quotes',draft)
|
||||
assert client.call('/quotes',draft)['id']==quote['id']
|
||||
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=409)
|
||||
qid=quote['id']
|
||||
other.call('/quotes/'+qid,expected=404)
|
||||
client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
|
||||
client.call('/operator/quotes/'+qid+'/approve',{'items':items},expected=401)
|
||||
# Reviewer corrects a browser-supplied grade and length. Browser values are proposals.
|
||||
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
|
||||
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
|
||||
assert approved['items'][0]['total_cents']==2189
|
||||
assert approved['total_cents']==2189+6972+19572+23492+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
|
||||
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
|
||||
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
|
||||
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
|
||||
# Concurrent retries must produce precisely one payment/order/outbox pair.
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
paid=list(executor.map(lambda _:client.call('/orders/dev-paid',{'quote_id':qid}),range(4)))
|
||||
assert len({p['id'] for p in paid})==1
|
||||
order=paid[0];oid=order['id']
|
||||
assert order['payment']['status']=='paid' and order['snapshot']==approved
|
||||
board=client.call('/operator/board',operator=True)
|
||||
assert len([o for o in board['orders'] if o['quote_id']==qid])==1
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':0},operator=True,expected=409)
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'cor','version':0},operator=True,expected=422)
|
||||
version=0
|
||||
for state in ('cor','rec','tra','fil','imp','fin'):
|
||||
if state=='fil':
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
|
||||
files=[{'item_index':i,'upload_id':upload_bytes(client,b'LOCAL FINAL FIXTURE '+str(i).encode(),order_id=oid)} for i in range(4)]
|
||||
result=client.call('/operator/orders/'+oid+'/final-files',{'version':version,'files':files,'note':'Local test manual final-file approval'},operator=True)
|
||||
version=result['version']
|
||||
moved=client.call('/operator/orders/'+oid+'/move',{'state':state,'version':version,'reason':'Local test correction' if state=='cor' else ''},operator=True)
|
||||
version+=1;assert moved['version']==version
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409)
|
||||
assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6
|
||||
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
|
||||
deadline=time.monotonic()+30
|
||||
while time.monotonic()<deadline:
|
||||
events=[e for e in client.call('/operator/board',operator=True)['events'] if e['payload']['order_id']==oid]
|
||||
if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
|
||||
time.sleep(1)
|
||||
else:raise AssertionError('Mock outbox did not drain')
|
||||
assert len({e['event_key'] for e in events})==8
|
||||
print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.")
|
||||
return oid
|
||||
|
||||
if __name__=='__main__':run()
|
||||
100
local/staging_readiness.py
Normal file
100
local/staging_readiness.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Validate non-secret staging decisions without contacting external services."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
REQUIRED = (
|
||||
'APP_ENV',
|
||||
'STAGING_APPROVED_BY',
|
||||
'STAGING_PUBLIC_ORIGIN',
|
||||
'STAGING_S3_ENDPOINT',
|
||||
'STAGING_S3_BUCKET',
|
||||
'STAGING_DATABASE_MODE',
|
||||
'STAGING_SECRET_SOURCE',
|
||||
'STAGING_BACKUP_DESTINATION',
|
||||
'STAGING_FREIGHT_PROVIDER',
|
||||
'STAGING_PAYMENT_PROVIDER',
|
||||
'STAGING_ERP_PROVIDER',
|
||||
'STAGING_WHATSAPP_PROVIDER',
|
||||
'STAGING_ALERT_OWNER',
|
||||
'STAGING_ROLLBACK_OWNER',
|
||||
)
|
||||
FORBIDDEN_NAME = re.compile(
|
||||
r'(PASSWORD|TOKEN|SECRET|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL)', re.IGNORECASE)
|
||||
PLACEHOLDERS = {'', 'todo', 'tbd', 'replace-me', 'changeme', 'unconfirmed'}
|
||||
LOCAL_HOSTS = {'localhost', '127.0.0.1', '::1', 'storage', 'db'}
|
||||
|
||||
|
||||
def read_config(path: Path) -> dict[str, str]:
|
||||
values = {}
|
||||
for number, raw in enumerate(path.read_text().splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
if '=' not in line:
|
||||
raise ValueError(f'{path}:{number}: expected NAME=value')
|
||||
name, value = line.split('=', 1)
|
||||
name = name.strip()
|
||||
if not re.fullmatch(r'[A-Z][A-Z0-9_]*', name):
|
||||
raise ValueError(f'{path}:{number}: invalid setting name')
|
||||
if name in values:
|
||||
raise ValueError(f'{path}:{number}: duplicate setting {name}')
|
||||
if name != 'STAGING_SECRET_SOURCE' and FORBIDDEN_NAME.search(name):
|
||||
raise ValueError(f'{path}:{number}: secrets must not be stored in this file ({name})')
|
||||
values[name] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def validate(values: dict[str, str]) -> list[str]:
|
||||
errors = []
|
||||
for name in REQUIRED:
|
||||
if values.get(name, '').lower() in PLACEHOLDERS:
|
||||
errors.append(f'{name} is not decided')
|
||||
if values.get('APP_ENV') != 'staging':
|
||||
errors.append('APP_ENV must be staging')
|
||||
for name in ('STAGING_PUBLIC_ORIGIN', 'STAGING_S3_ENDPOINT'):
|
||||
endpoint = urlparse(values.get(name, ''))
|
||||
if endpoint.scheme != 'https' or not endpoint.hostname:
|
||||
errors.append(f'{name} must be an absolute HTTPS URL')
|
||||
elif endpoint.hostname.lower() in LOCAL_HOSTS:
|
||||
errors.append(f'{name} must not point to localhost or a Compose service')
|
||||
if endpoint.path not in ('', '/') or endpoint.params or endpoint.query or endpoint.fragment:
|
||||
errors.append(f'{name} must not include a path, query, or fragment')
|
||||
storage_host = urlparse(values.get('STAGING_S3_ENDPOINT', '')).hostname or ''
|
||||
if storage_host and not storage_host.endswith('.r2.cloudflarestorage.com'):
|
||||
errors.append('STAGING_S3_ENDPOINT must be a Cloudflare R2 S3 API endpoint')
|
||||
bucket = values.get('STAGING_S3_BUCKET', '')
|
||||
if bucket and not re.fullmatch(r'[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]', bucket):
|
||||
errors.append('STAGING_S3_BUCKET is not a valid S3 bucket name')
|
||||
for name in ('STAGING_FREIGHT_PROVIDER', 'STAGING_PAYMENT_PROVIDER',
|
||||
'STAGING_ERP_PROVIDER', 'STAGING_WHATSAPP_PROVIDER'):
|
||||
if values.get(name, '').lower() in {'fake', 'local', 'mock'}:
|
||||
errors.append(f'{name} cannot select a local fake provider')
|
||||
if values.get('STAGING_DATABASE_MODE') not in {'managed', 'dedicated-container'}:
|
||||
errors.append('STAGING_DATABASE_MODE must be managed or dedicated-container')
|
||||
if values.get('STAGING_SECRET_SOURCE') in {'env-file', 'repository', '.env'}:
|
||||
errors.append('STAGING_SECRET_SOURCE must be an external secret-injection mechanism')
|
||||
return errors
|
||||
|
||||
|
||||
def main(path: Path) -> int:
|
||||
try:
|
||||
errors = validate(read_config(path))
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f'BLOCKED: {exc}')
|
||||
return 2
|
||||
if errors:
|
||||
print('BLOCKED: staging inputs are incomplete or unsafe:')
|
||||
for error in errors:
|
||||
print(f'- {error}')
|
||||
return 2
|
||||
print('PASS: non-secret staging inputs are complete and structurally safe.')
|
||||
print('No provider was contacted. This check does not authorize deployment.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit('usage: python -m local.staging_readiness PATH')
|
||||
raise SystemExit(main(Path(sys.argv[1])))
|
||||
47
local/static/cart.js
Normal file
47
local/static/cart.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h. */
|
||||
(() => {
|
||||
let database,scope,timer,restoring=true;
|
||||
let signedOut=false;
|
||||
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];});
|
||||
const notice=document.createElement('p');notice.style.cssText='font:13px system-ui;color:#56616d;padding:8px 20px';
|
||||
document.getElementById('carr').prepend(notice);
|
||||
function transaction(mode,fn){return new Promise((resolve,reject)=>{const tx=database.transaction('cart',mode);const request=fn(tx.objectStore('cart'));let result;request.onsuccess=()=>result=request.result;tx.oncomplete=()=>resolve(result);tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error);});}
|
||||
async function save(){
|
||||
if(signedOut||restoring||!database||!scope)return;
|
||||
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
try{
|
||||
if(!items.length){await transaction('readwrite',store=>store.delete(scope));return;}
|
||||
await transaction('readwrite',store=>store.put({items,customer:{...cliente},delivery:{...entrega},expires:Date.now()+86400000},scope));
|
||||
notice.textContent='Carrinho salvo neste navegador por 24 horas. Você pode remover itens e adicionar outros após recarregar.';
|
||||
}catch(error){notice.textContent='Não foi possível salvar o carrinho neste navegador. Mantenha esta página aberta até enviar o pedido.';}
|
||||
}
|
||||
window.dtfClearCart=async()=>{clearTimeout(timer);if(database&&scope)await transaction('readwrite',store=>store.delete(scope));notice.textContent='';};
|
||||
window.addEventListener('dtf-cart-changed',()=>{clearTimeout(timer);timer=setTimeout(save,400);});
|
||||
window.addEventListener('pagehide',()=>{clearTimeout(timer);save();});
|
||||
setInterval(async()=>{
|
||||
if(!database||signedOut)return;
|
||||
try{const keys=await transaction('readonly',s=>s.getAllKeys());for(const key of keys){const value=await transaction('readonly',s=>s.get(key));if(value&&value.expires<Date.now())await transaction('readwrite',s=>s.delete(key));}}catch{/* Browser may close storage during navigation. */}
|
||||
},60000);
|
||||
(async()=>{
|
||||
try{
|
||||
scope=(await window.dtfSessionReady).cart_scope;
|
||||
database=await new Promise((resolve,reject)=>{const req=indexedDB.open('dtf-local-cart',1);req.onupgradeneeded=()=>req.result.createObjectStore('cart');req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);});
|
||||
const records=await transaction('readonly',store=>store.getAllKeys());
|
||||
for(const key of records){const value=await transaction('readonly',store=>store.get(key));if(value.expires<Date.now())await transaction('readwrite',store=>store.delete(key));}
|
||||
const saved=await transaction('readonly',store=>store.get(scope));
|
||||
if(saved && !pedido.length && !itemAtual){
|
||||
pedido=saved.items;cliente=saved.customer;entrega=saved.delivery;
|
||||
// Freight must be quoted again; restored browser values are never final.
|
||||
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;}
|
||||
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
|
||||
$('cepIn').value=entrega.cep;
|
||||
$('atual').style.display='none';pintaEntrega();
|
||||
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
|
||||
}else{
|
||||
const account=await window.dtfApi('/account/me');
|
||||
if(account.customer && !cliente.mail){cliente={...account.customer};for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];pintaEntrega();}
|
||||
}
|
||||
}catch(error){notice.textContent='Recuperação de carrinho indisponível; o pedido ainda pode ser feito nesta sessão.';}
|
||||
finally{restoring=false;}
|
||||
})();
|
||||
})();
|
||||
126
local/static/checkout.js
Normal file
126
local/static/checkout.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */
|
||||
(() => {
|
||||
const box = document.createElement('section');
|
||||
box.style.cssText = 'position:relative;z-index:20;background:#152026;color:#e6f4f7;padding:12px 24px;font:14px system-ui;border-bottom:2px solid #00b8da';
|
||||
box.innerHTML = '<b>Ambiente local · pagamento simulado</b> <span id="local-status" role="status"></span><div id="local-actions"></div>';
|
||||
document.body.prepend(box);
|
||||
const status = document.getElementById('local-status');
|
||||
const actions = document.getElementById('local-actions');
|
||||
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 data = await response.json();
|
||||
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 simulado local: '+rs(entrega.valor)+'. Nenhuma transportadora foi consultada.';
|
||||
pintaEntrega();
|
||||
} catch(error) { $('cepMsg').textContent = error.message; }
|
||||
};
|
||||
window.dtfCheckout = async () => {
|
||||
if (busy) return;
|
||||
if (draftId) { await refresh(); box.scrollIntoView(); 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();
|
||||
box.scrollIntoView({behavior:'smooth'});
|
||||
} 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.');
|
||||
button('Criar pedido pago local',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 local #'+order.number+' pago e disponível no Kanban.');
|
||||
await refresh();
|
||||
} catch(error) { message(error.message); event.target.disabled=false; }
|
||||
});
|
||||
} else if (quote.status==='paid') {
|
||||
message('Pedido local #'+quote.order.number+' pago · etapa: '+quote.order.state+'. Nenhuma cobrança real.');
|
||||
button('Novo pedido local',()=>{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 local e tentar de novo',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
||||
}
|
||||
}
|
||||
// Explicit refresh avoids replacing focused payment controls during interaction.
|
||||
const refreshButton = document.createElement('button');
|
||||
refreshButton.textContent='Atualizar pedido local';
|
||||
refreshButton.style.cssText='margin-left:12px;padding:6px;cursor:pointer';
|
||||
refreshButton.onclick=refresh;
|
||||
box.append(refreshButton);
|
||||
const portalLink=document.createElement('a');portalLink.href='/portal.html';portalLink.textContent='Minha conta e pedidos';
|
||||
portalLink.style.cssText='color:#e6f4f7;margin-left:16px;text-decoration:underline';box.append(portalLink);
|
||||
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
|
||||
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
|
||||
refresh();
|
||||
})();
|
||||
21
local/static/kanban.html
Normal file
21
local/static/kanban.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DTF · Kanban local</title>
|
||||
<style>
|
||||
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#a1a6af;--ciano:#00B8DA}
|
||||
*{box-sizing:border-box}body{background:var(--bg);color:var(--tx);font:14px/1.5 system-ui,sans-serif;padding:20px;margin:0}
|
||||
h1{font-size:24px;margin:0}h2{font-size:18px}header{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin-bottom:20px}
|
||||
.aviso{background:#152026;border-left:3px solid var(--ciano);padding:12px;color:#b9cbd2;margin:16px 0}
|
||||
button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padding:8px;background:var(--card2);color:var(--tx)}
|
||||
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px}
|
||||
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px}
|
||||
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
|
||||
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
||||
</style></head><body>
|
||||
<header><h1>Kanban DTF</h1><span class="meta">Desenvolvimento local</span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
|
||||
<div class="aviso">Pagamentos, Tiny/Olist, WhatsApp e frete são simulados. Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
|
||||
<form id="login"><label>Usuário <input id="user" autocomplete="username" required></label><label>Senha local <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
||||
<p id="status" role="status"></p>
|
||||
<section id="reviews"></section><div id="kan"></div>
|
||||
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
|
||||
<script src="/upload.js"></script><script src="/kanban.js"></script></body></html>
|
||||
107
local/static/kanban.js
Normal file
107
local/static/kanban.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/* 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',{username:$('user').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);
|
||||
}
|
||||
11
local/static/portal.html
Normal file
11
local/static/portal.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Dropstar · Meus pedidos DTF</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#20252d;font:15px/1.5 system-ui,sans-serif}header{background:white;border-bottom:1px solid #ddd;padding:20px max(20px,calc((100% - 1100px)/2));display:flex;gap:24px;align-items:center;flex-wrap:wrap}.brand{color:#ef8500;font-size:24px;font-style:italic;font-weight:900}a{color:#8a4e00}main{max-width:1100px;margin:24px auto;padding:0 20px}h1{font-size:28px}h2{font-size:21px}h3{font-size:17px}.notice{background:#fff1d4;border-left:4px solid #ffa900;padding:12px 16px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}.card{background:white;border:1px solid #ddd;border-radius:10px;padding:20px;margin:16px 0}.card p{overflow-wrap:anywhere}.muted{color:#596471}label{display:block;margin:10px 0}input,textarea,button{font:inherit;border:1px solid #9aa0a8;border-radius:6px;padding:10px}input:not([type=file]),textarea{width:100%}button{background:#ffa900;border-color:#ffa900;cursor:pointer}button:disabled{opacity:.6}form{margin:10px 0}.actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center}#message{min-height:24px;color:#9b3800}li{margin:8px 0}.tag{background:#eef2f6;padding:5px 10px;border-radius:6px}details{margin:12px 0}[hidden]{display:none!important}
|
||||
</style></head><body>
|
||||
<header><a class="brand" href="/">DROPSTAR</a><a href="/">Enviar arte</a><span>Minha conta · DTF</span><button id="logout" hidden>Sair</button></header>
|
||||
<main><h1>Meus pedidos DTF</h1><p class="notice">Ambiente de desenvolvimento local. Pagamentos e notificações são simulados. Use apenas dados de teste.</p><p id="message" role="status"></p>
|
||||
<div id="account"></div><section id="auth" class="grid">
|
||||
<form id="login" class="card"><h2>Entrar</h2><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
||||
<form id="register" class="card"><h2>Criar conta local</h2><label>CNPJ <input id="cnpj" required></label><label>WhatsApp <input id="phone" required></label><label>E-mail <input id="register-email" type="email" autocomplete="email" required></label><label>Senha (12 caracteres ou mais) <input id="register-password" type="password" minlength="12" maxlength="128" autocomplete="new-password" required></label><button>Criar conta</button><p class="muted">Pedidos desta sessão serão associados à sua conta. Não há envio de e-mail nem recuperação de senha nesta versão local.</p></form>
|
||||
</section><div class="actions"><h2>Acompanhamento</h2><button id="refresh">Atualizar pedidos</button></div><p id="guest" class="muted"></p><section id="quotes"></section><section id="orders"></section></main>
|
||||
<script src="/privacy.js"></script><script src="/upload.js"></script><script src="/portal.js"></script></body></html>
|
||||
76
local/static/portal.js
Normal file
76
local/static/portal.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const $=id=>document.getElementById(id);
|
||||
const node=(tag,text)=>{const el=document.createElement(tag);if(text!==undefined)el.textContent=text;return el;};
|
||||
const money=c=>(c/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
|
||||
let scope,states={};
|
||||
async function api(path,body){const r=await fetch('/api'+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 informados.');error.status=r.status;throw error;}return d;}
|
||||
function button(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('message').textContent=e.message;}finally{b.disabled=false;}};return b;}
|
||||
function forgetCheckout(){for(const k of ['dtf-quote','dtf-request-key','dtf-request-body'])localStorage.removeItem(k);}
|
||||
async function load(){
|
||||
try{
|
||||
scope=(await api('/session')).cart_scope;
|
||||
const account=await api('/account/me');
|
||||
$('auth').hidden=!!account.customer;$('logout').hidden=!account.customer;
|
||||
$('account').textContent=account.customer?'Conta: '+account.customer.mail:'';
|
||||
$('guest').textContent=account.customer?'':'Você está vendo apenas os pedidos desta sessão. Crie uma conta para acessar seus pedidos em outro navegador.';
|
||||
const data=await api('/customer/orders');states=data.states;
|
||||
$('quotes').replaceChildren();
|
||||
for(const quote of data.quotes){
|
||||
const card=node('article');card.className='card';card.append(node('h3','Cotação '+quote.id.slice(0,8)));
|
||||
card.append(node('p',quote.approved?'Total aprovado: '+money(quote.approved.total_cents):'Aguardando conferência de metragem e nota.'));
|
||||
const link=node('a','Abrir cotação no Site');link.href='/?quote='+quote.id;card.append(link);$('quotes').append(card);
|
||||
}
|
||||
$('orders').replaceChildren();
|
||||
if(!data.orders.length)$('orders').append(node('p','Nenhum pedido pago nesta conta ou sessão.'));
|
||||
for(const order of data.orders){
|
||||
const card=node('article');card.className='card';card.id='order-'+order.id;
|
||||
card.append(node('h3','Pedido #'+order.number),node('p',states[order.state]+' · '+money(order.snapshot.total_cents)+' · pagamento local'));
|
||||
card.append(node('p',new Date(order.created_at).toLocaleString('pt-BR')));
|
||||
const content=node('div');
|
||||
card.append(button('Ver detalhes e arquivos',()=>details(order.id,content)),content);$('orders').append(card);
|
||||
if(new URLSearchParams(location.search).get('order')===order.id)await details(order.id,content);
|
||||
}
|
||||
}catch(error){$('message').textContent=error.message;}
|
||||
}
|
||||
async function details(id,container){
|
||||
const order=await api('/customer/orders/'+id);container.replaceChildren();
|
||||
for(const [i,item] of order.snapshot.items.entries())container.append(node('p',(i+1)+'. '+item.mode+' · '+item.billed_metres+' m · nota '+item.grade+' · '+money(item.total_cents)));
|
||||
const history=node('ol');
|
||||
for(const h of order.history)history.append(node('li',new Date(h.created_at).toLocaleString('pt-BR')+' · '+states[h.to_state]+(h.reason?' — '+h.reason:'')));
|
||||
container.append(history);
|
||||
for(const file of order.files){
|
||||
const row=node('p',(file.kind==='final'?'Arquivo final':'Correção')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':!file.active?'substituído':'disponível até '+new Date(file.expires_at).toLocaleDateString('pt-BR')));
|
||||
if(file.active&&!file.expired)row.append(button('Baixar',async()=>{const d=await api('/customer/orders/'+id+'/files/'+file.id+'/download');const link=node('a');link.href=d.url;link.download=d.name;link.referrerPolicy='no-referrer';link.click();}));
|
||||
container.append(row);
|
||||
}
|
||||
if(order.state==='cor'){
|
||||
const form=node('form');form.append(node('h3','Enviar arte corrigida'));
|
||||
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);const input=node('input');input.type='file';input.multiple=true;input.dataset.item=index;label.append(input);form.append(label);return input;});
|
||||
const note=node('textarea');note.placeholder='Descreva a correção';note.required=true;note.maxLength=1000;form.append(note);
|
||||
const submit=node('button','Enviar correção');submit.type='submit';form.append(submit);
|
||||
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
|
||||
const files=[];for(const [index,input] of inputs.entries())for(const file of input.files)files.push({item_index:index,upload_id:await dtfUpload(file,{api,scope:scope+':correction:'+id+':'+order.version,progress:text=>$('message').textContent=text})});
|
||||
if(!files.length)throw new Error('Selecione pelo menos um arquivo corrigido.');
|
||||
await api('/customer/orders/'+id+'/corrections',{version:order.version,files,note:note.value});
|
||||
$('message').textContent='Correção enviada. Aguarde a conferência da equipe.';await details(id,container);
|
||||
}catch(error){$('message').textContent=error.message;}finally{submit.disabled=false;}};
|
||||
container.append(form);
|
||||
}
|
||||
}
|
||||
async function migrateGuestCart(previous,next){
|
||||
if(previous===next)return;
|
||||
const database=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});
|
||||
await new Promise((resolve,reject)=>{const tx=database.transaction('cart','readwrite'),store=tx.objectStore('cart'),req=store.get(previous);req.onsuccess=()=>{if(req.result){store.put(req.result,next);store.delete(previous);}};tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);});
|
||||
database.close();
|
||||
}
|
||||
async function authenticate(path,body){
|
||||
const previous=scope,guest=!(await api('/account/me')).customer;
|
||||
await api(path,body);forgetCheckout();$('message').textContent='Acesso confirmado.';
|
||||
const next=(await api('/session')).cart_scope;
|
||||
if(guest)try{await migrateGuestCart(previous,next);}catch{$('message').textContent='Acesso confirmado. Não foi possível transferir o carrinho salvo neste navegador.';}
|
||||
await load();$('password').value='';$('register-password').value='';
|
||||
}
|
||||
$('login').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/login',{email:$('email').value,password:$('password').value});}catch(e){$('message').textContent=e.message;}};
|
||||
$('register').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/register',{customer:{cnpj:$('cnpj').value,zap:$('phone').value,mail:$('register-email').value},password:$('register-password').value});}catch(e){$('message').textContent=e.message;}};
|
||||
$('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.reload();}catch(error){$('message').textContent='Não foi possível concluir a limpeza local. Feche as abas do Site e limpe os dados deste site no navegador.';}};
|
||||
$('refresh').onclick=load;
|
||||
load();
|
||||
15
local/static/privacy.js
Normal file
15
local/static/privacy.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/* Clear draft files and metadata across Site tabs when signing out. */
|
||||
(() => {
|
||||
const signal='dtf-privacy-reset';
|
||||
function notify(){window.dispatchEvent(new Event('dtf-private-data-cleared'));}
|
||||
window.dtfForgetPrivateData=async()=>{
|
||||
notify();
|
||||
for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);
|
||||
localStorage.setItem(signal,crypto.randomUUID());
|
||||
const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});
|
||||
try{await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').clear();tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);});}finally{db.close();}
|
||||
};
|
||||
window.addEventListener('storage',event=>{
|
||||
if(event.key===signal && event.newValue){notify();location.reload();}
|
||||
});
|
||||
})();
|
||||
30
local/static/upload.js
Normal file
30
local/static/upload.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/* Shared direct multipart transport for customer originals/corrections and operator finals. */
|
||||
window.dtfUpload = async (file, {api, prefix='/uploads', startPath=prefix, progress=()=>{}, scope='guest', resume=true}) => {
|
||||
const samples=new Blob([file.slice(0,65536),file.slice(Math.max(0,file.size-65536))]);
|
||||
const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await samples.arrayBuffer())),b=>b.toString(16).padStart(2,'0')).join('');
|
||||
const key='dtf-upload:'+JSON.stringify([scope,file.name,file.size,file.lastModified,hash]);
|
||||
let id=resume?localStorage.getItem(key):null,state;
|
||||
if(id){try{state=await api(prefix+'/'+id);}catch(error){if(![404,410].includes(error.status))throw error;id=null;}}
|
||||
if(!id){state=await api(startPath,{name:file.name,size:file.size});id=state.id;if(resume)localStorage.setItem(key,id);}
|
||||
async function waitForScan(){
|
||||
for(let attempt=0;attempt<150;attempt++){
|
||||
const checked=await api(prefix+'/'+id);
|
||||
if(checked.scan_state==='clean')return id;
|
||||
if(['rejected','error'].includes(checked.scan_state))throw new Error(checked.scan_reason||'Arquivo bloqueado pela verificação de segurança.');
|
||||
progress('Verificando segurança de '+file.name+'…');
|
||||
await new Promise(resolve=>setTimeout(resolve,1000));
|
||||
}
|
||||
throw new Error('Verificação de segurança pendente. Tente novamente para consultar o resultado.');
|
||||
}
|
||||
if(state.complete)return waitForScan();
|
||||
const done=new Set(state.parts||[]),size=state.part_bytes;
|
||||
for(let offset=0,part=1;offset<file.size;offset+=size,part++){
|
||||
if(done.has(part))continue;
|
||||
progress('Enviando '+file.name+' · parte '+part+'/'+Math.ceil(file.size/size));
|
||||
const signed=await api(prefix+'/'+id+'/parts/'+part,{});
|
||||
const response=await fetch(signed.url,{method:'PUT',body:file.slice(offset,offset+size)});
|
||||
if(!response.ok)throw new Error('Upload interrompido. Tente novamente para retomar.');
|
||||
}
|
||||
await api(prefix+'/'+id+'/complete',{});
|
||||
return waitForScan();
|
||||
};
|
||||
24
local/storage-init.sh
Normal file
24
local/storage-init.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
case "$S3_BUCKET" in *[!a-z0-9.-]*|'') echo 'Invalid local bucket name' >&2; exit 1;; esac
|
||||
if [ "$MINIO_ROOT_USER" = "$S3_APP_USER" ]; then echo 'Runtime storage user must not be root' >&2; exit 1; fi
|
||||
# Disposable local secrets are passed via environment, never traced/logged.
|
||||
mc alias set local http://storage:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null
|
||||
mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null
|
||||
mc anonymous set none "local/$S3_BUCKET" >/dev/null
|
||||
mc admin user add local "$S3_APP_USER" "$S3_APP_PASSWORD" >/dev/null
|
||||
# Use shell builtins only: the minimal MinIO image does not ship sed/cat.
|
||||
policy=''
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
while [ "${line#*dtf-local-artwork}" != "$line" ]; do
|
||||
policy="$policy${line%%dtf-local-artwork*}$S3_BUCKET"
|
||||
line=${line#*dtf-local-artwork}
|
||||
done
|
||||
policy="$policy$line
|
||||
"
|
||||
done < /policy.json
|
||||
printf '%s' "$policy" > /tmp/policy.json
|
||||
mc admin policy create local dtf-artwork /tmp/policy.json >/dev/null
|
||||
mc admin policy attach local dtf-artwork --user "$S3_APP_USER" >/dev/null
|
||||
mc ilm import "local/$S3_BUCKET" < /lifecycle.json
|
||||
echo 'Local storage runtime account provisioned.'
|
||||
1
local/storage-lifecycle.json
Normal file
1
local/storage-lifecycle.json
Normal file
@@ -0,0 +1 @@
|
||||
{"Rules":[{"ID":"local-artwork-retention","Status":"Enabled","Filter":{"Prefix":""},"Expiration":{"Days":30},"AbortIncompleteMultipartUpload":{"DaysAfterInitiation":1}}]}
|
||||
7
local/storage-policy.json
Normal file
7
local/storage-policy.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{"Effect":"Allow","Action":["s3:ListBucket","s3:ListBucketMultipartUploads","s3:GetBucketLocation"],"Resource":["arn:aws:s3:::dtf-local-artwork"]},
|
||||
{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],"Resource":["arn:aws:s3:::dtf-local-artwork/originals/*"]}
|
||||
]
|
||||
}
|
||||
192
local/storage_backup.py
Normal file
192
local/storage_backup.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Stream clean MinIO artwork to an archive and verify it via an isolated prefix."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from .adapters import LocalS3Storage, require_local
|
||||
from .db import connect
|
||||
|
||||
CHUNK = 8 * 1024 * 1024
|
||||
MAX_OBJECT = min(128 * 1024 * 1024, int(os.environ.get('SCAN_MAX_BYTES', '134217728')))
|
||||
MAX_TOTAL = int(os.environ.get('STORAGE_QUOTA_BYTES', '53687091200'))
|
||||
MAX_OBJECTS = 100000
|
||||
|
||||
|
||||
class DigestReader:
|
||||
def __init__(self, stream):
|
||||
self.stream = stream
|
||||
self.digest = hashlib.sha256()
|
||||
self.size = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
block = self.stream.read(size)
|
||||
if block:
|
||||
self.digest.update(block)
|
||||
self.size += len(block)
|
||||
return block
|
||||
|
||||
|
||||
def add_bytes(archive, name, content):
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(content)
|
||||
info.mtime = int(datetime.now(timezone.utc).timestamp())
|
||||
info.mode = 0o600
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
|
||||
|
||||
def export_archive():
|
||||
require_local()
|
||||
storage = LocalS3Storage()
|
||||
with connect() as c:
|
||||
rows = c.execute("""SELECT id,object_key,size,created_at,expires_at
|
||||
FROM dtf_local.uploads WHERE complete AND purged_at IS NULL
|
||||
AND expires_at>now() AND scan_state='clean' ORDER BY object_key""").fetchall()
|
||||
if len(rows) > MAX_OBJECTS:
|
||||
raise RuntimeError('Object count exceeds the backup safety limit')
|
||||
expected_total = sum(row['size'] for row in rows)
|
||||
if expected_total > MAX_TOTAL:
|
||||
raise RuntimeError('Object bytes exceed the backup safety limit')
|
||||
|
||||
objects = []
|
||||
with tarfile.open(fileobj=sys.stdout.buffer, mode='w|gz', compresslevel=6) as archive:
|
||||
for index, row in enumerate(rows):
|
||||
if row['size'] <= 0 or row['size'] > MAX_OBJECT:
|
||||
raise RuntimeError('A clean object has an invalid backup size')
|
||||
response = storage.client.get_object(Bucket=storage.bucket, Key=row['object_key'])
|
||||
if response['ContentLength'] != row['size']:
|
||||
response['Body'].close()
|
||||
raise RuntimeError('Stored object size differs from database metadata')
|
||||
reader = DigestReader(response['Body'])
|
||||
name = f'objects/{index:08d}'
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = row['size']
|
||||
info.mtime = int(row['created_at'].timestamp())
|
||||
info.mode = 0o600
|
||||
try:
|
||||
archive.addfile(info, reader)
|
||||
finally:
|
||||
response['Body'].close()
|
||||
if reader.size != row['size']:
|
||||
raise RuntimeError('Object changed while the backup was streaming')
|
||||
objects.append({'archive_name': name, 'upload_id': str(row['id']),
|
||||
'object_key': row['object_key'], 'size': row['size'],
|
||||
'sha256': reader.digest.hexdigest(),
|
||||
'expires_at': row['expires_at'].isoformat()})
|
||||
manifest = {'format': 'dtf-object-backup-v1',
|
||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
||||
'source_bucket': storage.bucket, 'objects': objects,
|
||||
'object_count': len(objects), 'total_bytes': expected_total}
|
||||
add_bytes(archive, 'manifest.json', json.dumps(manifest, sort_keys=True).encode())
|
||||
print('DTF_BACKUP_SUMMARY '+json.dumps({'object_count': len(objects),
|
||||
'total_bytes': expected_total}), file=sys.stderr)
|
||||
|
||||
|
||||
def upload_member(storage, source, size, key):
|
||||
upload = storage.client.create_multipart_upload(
|
||||
Bucket=storage.bucket, Key=key, ContentType='application/octet-stream')['UploadId']
|
||||
parts = []
|
||||
digest = hashlib.sha256()
|
||||
remaining = size
|
||||
try:
|
||||
number = 1
|
||||
while remaining:
|
||||
block = source.read(min(CHUNK, remaining))
|
||||
if not block:
|
||||
raise ValueError('Archive object ended before its declared size')
|
||||
digest.update(block)
|
||||
result = storage.client.upload_part(Bucket=storage.bucket, Key=key,
|
||||
UploadId=upload, PartNumber=number, Body=block, ContentLength=len(block))
|
||||
parts.append({'PartNumber': number, 'ETag': result['ETag']})
|
||||
remaining -= len(block)
|
||||
number += 1
|
||||
storage.client.complete_multipart_upload(Bucket=storage.bucket, Key=key,
|
||||
UploadId=upload, MultipartUpload={'Parts': parts})
|
||||
except Exception:
|
||||
try:
|
||||
storage.client.abort_multipart_upload(
|
||||
Bucket=storage.bucket, Key=key, UploadId=upload)
|
||||
except Exception:
|
||||
# Preserve the original upload/read error; the verification-prefix
|
||||
# cleanup below still removes any completed temporary object.
|
||||
pass
|
||||
raise
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def object_digest(storage, key):
|
||||
response = storage.client.get_object(Bucket=storage.bucket, Key=key)
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
try:
|
||||
for block in response['Body'].iter_chunks(chunk_size=1024 * 1024):
|
||||
digest.update(block)
|
||||
size += len(block)
|
||||
finally:
|
||||
response['Body'].close()
|
||||
return size, digest.hexdigest()
|
||||
|
||||
|
||||
def verify_archive():
|
||||
require_local()
|
||||
storage = LocalS3Storage()
|
||||
verification = uuid4().hex
|
||||
restored = []
|
||||
observed = []
|
||||
manifest = None
|
||||
total = 0
|
||||
try:
|
||||
with tarfile.open(fileobj=sys.stdin.buffer, mode='r|gz') as archive:
|
||||
for member in archive:
|
||||
if not member.isfile():
|
||||
raise ValueError('Backup archive contains a non-file member')
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
raise ValueError('Backup archive member cannot be read')
|
||||
if member.name == 'manifest.json':
|
||||
if manifest is not None or member.size > 1024 * 1024:
|
||||
raise ValueError('Invalid object-backup manifest')
|
||||
manifest = json.loads(source.read().decode())
|
||||
continue
|
||||
expected_name = f'objects/{len(observed):08d}'
|
||||
if member.name != expected_name or len(observed) >= MAX_OBJECTS:
|
||||
raise ValueError('Unexpected object-backup member')
|
||||
if member.size <= 0 or member.size > MAX_OBJECT:
|
||||
raise ValueError('Object-backup member exceeds safety limits')
|
||||
total += member.size
|
||||
if total > MAX_TOTAL:
|
||||
raise ValueError('Object-backup total exceeds safety limits')
|
||||
key = f'originals/restore-verification/{verification}/{len(observed):08d}'
|
||||
digest = upload_member(storage, source, member.size, key)
|
||||
restored.append(key)
|
||||
observed.append({'archive_name': member.name, 'size': member.size,
|
||||
'sha256': digest})
|
||||
if not manifest or manifest.get('format') != 'dtf-object-backup-v1':
|
||||
raise ValueError('Object-backup manifest is missing or unsupported')
|
||||
expected = manifest.get('objects')
|
||||
if manifest.get('object_count') != len(observed) or manifest.get('total_bytes') != total:
|
||||
raise ValueError('Object-backup summary does not match archive contents')
|
||||
if not isinstance(expected, list) or len(expected) != len(observed):
|
||||
raise ValueError('Object-backup manifest count does not match')
|
||||
for actual, recorded, key in zip(observed, expected, restored):
|
||||
for field in ('archive_name', 'size', 'sha256'):
|
||||
if actual[field] != recorded.get(field):
|
||||
raise ValueError('Object-backup checksum manifest does not match')
|
||||
restored_size, restored_hash = object_digest(storage, key)
|
||||
if restored_size != actual['size'] or restored_hash != actual['sha256']:
|
||||
raise ValueError('Restored verification object differs from archive')
|
||||
print(f'PASS: {len(observed)} clean objects ({total} bytes) restored and hashed in an isolated prefix.')
|
||||
finally:
|
||||
for key in restored:
|
||||
storage.client.delete_object(Bucket=storage.bucket, Key=key)
|
||||
print('Removed temporary verification objects. Active artwork was untouched.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in ('export', 'verify'):
|
||||
raise SystemExit('usage: python -m local.storage_backup export|verify')
|
||||
export_archive() if sys.argv[1] == 'export' else verify_archive()
|
||||
46
local/test_dependency_lock.py
Normal file
46
local/test_dependency_lock.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NAME_VERSION = re.compile(r'^([A-Za-z0-9_.-]+)==([^\s\\]+)', re.MULTILINE)
|
||||
|
||||
|
||||
def normalized(name):
|
||||
return re.sub(r'[-_.]+', '-', name).lower()
|
||||
|
||||
|
||||
class DependencyLockTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.direct_text = (ROOT / 'local/requirements.txt').read_text()
|
||||
cls.lock_text = (ROOT / 'local/requirements.lock').read_text()
|
||||
|
||||
def test_every_direct_pin_matches_lock(self):
|
||||
direct = {normalized(name): version for name, version in
|
||||
NAME_VERSION.findall(self.direct_text)}
|
||||
locked = {normalized(name): version for name, version in
|
||||
NAME_VERSION.findall(self.lock_text)}
|
||||
self.assertTrue(direct)
|
||||
self.assertEqual({name: locked.get(name) for name in direct}, direct)
|
||||
|
||||
def test_every_locked_package_has_sha256_hash(self):
|
||||
matches = list(NAME_VERSION.finditer(self.lock_text))
|
||||
self.assertTrue(matches)
|
||||
for index, match in enumerate(matches):
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(self.lock_text)
|
||||
block = self.lock_text[match.start():end]
|
||||
hashes = re.findall(r'--hash=sha256:([0-9a-f]{64})(?:\s|\\)', block)
|
||||
self.assertTrue(hashes, f'{match.group(1)} has no SHA-256 artifact hash')
|
||||
|
||||
def test_image_build_requires_the_lock_and_hashes(self):
|
||||
dockerfile = (ROOT / 'local/Dockerfile').read_text()
|
||||
self.assertIn('requirements.lock', dockerfile)
|
||||
self.assertIn('--require-hashes -r local/requirements.lock', dockerfile)
|
||||
|
||||
def test_reproducible_generator_is_recorded(self):
|
||||
self.assertIn('./local/lock_dependencies.sh', self.lock_text[:300])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
40
local/test_pricing.py
Normal file
40
local/test_pricing.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Run from repository root: python3 -m unittest local.test_pricing -v."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import unittest
|
||||
from .pricing import price, TIERS
|
||||
|
||||
class PricingTests(unittest.TestCase):
|
||||
def test_every_grade_against_actual_site_javascript(self):
|
||||
source = Path('dtf-site.html').read_text()
|
||||
tiers = source.split('const FAIXAS=')[1].split('\n};',1)[0]+'\n}'
|
||||
calculator = source.split('const cobrar=')[1].split(';',1)[0]
|
||||
js = f'const FAIXAS={tiers};const MINIMO_M=1;const cobrar={calculator};'
|
||||
js += '''const results=[];for(const mode of Object.keys(FAIXAS))for(let grade=0;grade<=100;grade++)
|
||||
for(const metres of [0.01,0.99,1,1.01,1.1,2.75,2.8,2.8000000000000003,19.99,60,12000]){
|
||||
const unit=FAIXAS[mode].find(x=>grade>=x[0])[1];
|
||||
results.push([mode,String(metres),grade,cobrar(metres),Math.round(unit*100),Math.round(cobrar(metres)*unit*100)]);
|
||||
}process.stdout.write(JSON.stringify(results));'''
|
||||
cases = json.loads(subprocess.check_output(['node','-e',js],text=True))
|
||||
for mode,metres,grade,billed,unit,total in cases:
|
||||
with self.subTest(mode=mode,metres=metres,grade=grade):
|
||||
result=price(mode,metres,grade)
|
||||
self.assertEqual(float(result['billed_metres']),billed)
|
||||
self.assertEqual(result['unit_cents'],unit)
|
||||
self.assertEqual(result['total_cents'],total)
|
||||
|
||||
def test_assembly_is_included_at_every_tier(self):
|
||||
for grade in range(101):
|
||||
self.assertEqual(price('avulsa','1',grade)['total_cents']-price('file','1',grade)['total_cents'],1000)
|
||||
self.assertEqual(price('uv','1',grade)['total_cents']-price('uvfile','1',grade)['total_cents'],1400)
|
||||
|
||||
def test_invalid_inputs(self):
|
||||
for value in ('0','-1','NaN','Infinity','12001'):
|
||||
with self.assertRaises(ValueError):price('file',value,90)
|
||||
for grade in (-1,101,True,89.5):
|
||||
with self.assertRaises(ValueError):price('file','1',grade)
|
||||
with self.assertRaises(ValueError):price('other','1',90)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
56
local/test_staging_readiness.py
Normal file
56
local/test_staging_readiness.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from .staging_readiness import read_config, validate
|
||||
|
||||
|
||||
VALID = '''
|
||||
APP_ENV=staging
|
||||
STAGING_APPROVED_BY=business-and-technical-owners
|
||||
STAGING_PUBLIC_ORIGIN=https://staging.example.invalid
|
||||
STAGING_S3_ENDPOINT=https://example.r2.cloudflarestorage.com
|
||||
STAGING_S3_BUCKET=dtf-staging-artwork
|
||||
STAGING_DATABASE_MODE=dedicated-container
|
||||
STAGING_SECRET_SOURCE=portainer-secrets
|
||||
STAGING_BACKUP_DESTINATION=separate-encrypted-r2-bucket
|
||||
STAGING_FREIGHT_PROVIDER=provider-sandbox
|
||||
STAGING_PAYMENT_PROVIDER=mercado-pago-sandbox
|
||||
STAGING_ERP_PROVIDER=tiny-olist-sandbox
|
||||
STAGING_WHATSAPP_PROVIDER=provider-sandbox
|
||||
STAGING_ALERT_OWNER=operations-team
|
||||
STAGING_ROLLBACK_OWNER=technical-team
|
||||
'''
|
||||
|
||||
|
||||
class StagingReadinessTests(unittest.TestCase):
|
||||
def parse(self, text):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'staging.env'
|
||||
path.write_text(text)
|
||||
return read_config(path)
|
||||
|
||||
def test_complete_non_secret_metadata_passes(self):
|
||||
self.assertEqual(validate(self.parse(VALID)), [])
|
||||
|
||||
def test_local_endpoint_and_fake_provider_are_blocked(self):
|
||||
values = self.parse(VALID.replace(
|
||||
'https://example.r2.cloudflarestorage.com', 'http://localhost:9000'
|
||||
).replace('provider-sandbox', 'fake', 1))
|
||||
errors = validate(values)
|
||||
self.assertTrue(any('STAGING_S3_ENDPOINT' in error for error in errors))
|
||||
self.assertTrue(any('STAGING_FREIGHT_PROVIDER' in error for error in errors))
|
||||
|
||||
def test_secret_named_setting_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, 'secrets must not be stored'):
|
||||
self.parse(VALID + 'MERCADO_PAGO_ACCESS_TOKEN=do-not-store-this\n')
|
||||
|
||||
def test_undecided_values_are_blocked(self):
|
||||
errors = validate(self.parse(VALID.replace(
|
||||
'STAGING_APPROVED_BY=business-and-technical-owners',
|
||||
'STAGING_APPROVED_BY=TBD')))
|
||||
self.assertIn('STAGING_APPROVED_BY is not decided', errors)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
77
local/worker.py
Normal file
77
local/worker.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Transactional outbox worker. Fake receipts persist; no messages leave the stack."""
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from psycopg.types.json import Jsonb
|
||||
from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_local
|
||||
from .db import connect
|
||||
from .scanning import ClamAV, scan_loop
|
||||
|
||||
require_local()
|
||||
adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}
|
||||
last_tick = 0.0
|
||||
last_cleanup = 0.0
|
||||
storage = LocalS3Storage()
|
||||
scan_thread = None
|
||||
|
||||
def cleanup():
|
||||
"""Delete only expired object bytes; retain order/file metadata and history."""
|
||||
with connect() as c:
|
||||
rows = c.execute("""SELECT * FROM dtf_local.uploads WHERE purged_at IS NULL
|
||||
AND (expires_at<=now() OR (NOT complete AND created_at<now()-interval '1 day'))
|
||||
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 50""").fetchall()
|
||||
for row in rows:
|
||||
storage.discard(row['object_key'],row['multipart_id'],row['complete'])
|
||||
c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s', (row['id'],))
|
||||
c.execute('DELETE FROM dtf_local.sessions WHERE expires_at<=now()')
|
||||
c.execute('DELETE FROM dtf_local.operator_sessions WHERE expires_at<=now()')
|
||||
c.execute("DELETE FROM dtf_local.login_attempts WHERE started_at<now()-interval '1 day'")
|
||||
c.execute("DELETE FROM dtf_local.security_events WHERE created_at<now()-interval '30 days'")
|
||||
|
||||
def tick():
|
||||
global last_tick
|
||||
with connect() as c:
|
||||
job = c.execute('SELECT * FROM dtf_local.outbox WHERE delivered_at IS NULL AND available_at <= now() ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1').fetchone()
|
||||
if job:
|
||||
try:
|
||||
receipt = adapters[job['provider']].deliver(job['event_key'], job['payload'])
|
||||
c.execute('UPDATE dtf_local.outbox SET delivered_at=now(), receipt=%s, attempts=attempts+1, last_error=NULL WHERE id=%s', (Jsonb(receipt),job['id']))
|
||||
except Exception as exc:
|
||||
delay = min(1800, 2**min(job['attempts']+1, 10))
|
||||
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 loop():
|
||||
global last_cleanup
|
||||
while True:
|
||||
try:
|
||||
tick()
|
||||
if time.monotonic()-last_cleanup > 60:
|
||||
cleanup()
|
||||
last_cleanup = time.monotonic()
|
||||
except Exception:
|
||||
logging.exception('Local worker tick failed')
|
||||
time.sleep(1)
|
||||
|
||||
class Health(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
scanner = False
|
||||
try:
|
||||
scanner = bool(scan_thread and scan_thread.is_alive() and ClamAV().ping())
|
||||
except Exception:
|
||||
pass
|
||||
healthy = time.monotonic()-last_tick < 15 and scanner
|
||||
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())
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
scan_thread = threading.Thread(target=scan_loop,args=(storage,),daemon=True)
|
||||
scan_thread.start()
|
||||
threading.Thread(target=loop, daemon=True).start()
|
||||
HTTPServer(('0.0.0.0',8002),Health).serve_forever()
|
||||
73
local/workflow_test.py
Normal file
73
local/workflow_test.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Customer identity, correction and final-file trust boundaries against local stack."""
|
||||
from uuid import uuid4
|
||||
from urllib.request import urlopen
|
||||
from .smoke_test import Client, upload_bytes
|
||||
|
||||
def run():
|
||||
customer=Client();other=Client();customer.call('/session');other.call('/session')
|
||||
uid=upload_bytes(customer,b'LOCAL ORIGINAL ONLY')
|
||||
item={'mode':'file','metres':'1.01','grade':0,'uploads':[uid]}
|
||||
profile={'cnpj':'11222333000181','zap':'11999999999','mail':'workflow-'+uuid4().hex[:8]+'@example.test'}
|
||||
q=customer.call('/quotes',{'request_key':str(uuid4()),'customer':profile,'items':[item],'freight':{'service':'pickup'}})
|
||||
customer.call('/operator/quotes/'+q['id']+'/approve',{'items':[item]},operator=True)
|
||||
order=customer.call('/orders/dev-paid',{'quote_id':q['id']});oid=order['id']
|
||||
before=list(customer.jar)[0].value
|
||||
password='local-test-password-'+uuid4().hex
|
||||
customer.call('/account/register',{'customer':profile,'password':password})
|
||||
assert customer.call('/account/me')['customer']['mail']==profile['mail']
|
||||
assert customer.call('/customer/orders')['orders'][0]['id']==oid
|
||||
# Email/CNPJ do not grant ownership; only current guest session is migrated.
|
||||
other.call('/customer/orders/'+oid,expected=404)
|
||||
other.call('/account/login',{'email':profile['mail'],'password':'wrong-password'},expected=401)
|
||||
revoked=Client()
|
||||
import http.cookiejar
|
||||
cookie=http.cookiejar.Cookie(0,'dtf_session',before,None,False,'localhost.local',False,False,'/',True,False,None,True,None,None,{},False)
|
||||
revoked.jar.set_cookie(cookie)
|
||||
revoked.call('/customer/orders',expected=401)
|
||||
account_scope=customer.call('/session')['cart_scope']
|
||||
cookie.value=account_scope;revoked.jar.set_cookie(cookie)
|
||||
revoked.call('/customer/orders',expected=401)
|
||||
other.call('/account/login',{'email':profile['mail'],'password':password})
|
||||
assert other.call('/customer/orders/'+oid)['id']==oid
|
||||
print('PASS: registration claims only current guest records, cross-session account login, revoked sessions, owner UUID is not a credential')
|
||||
|
||||
def move(state,version):
|
||||
return customer.call('/operator/orders/'+oid+'/move',{'state':state,'version':version,'reason':'Please replace the artwork' if state=='cor' else ''},operator=True)['version']
|
||||
version=move('tra',0)
|
||||
customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
|
||||
final_id=upload_bytes(customer,b'LOCAL FINAL VERSION ONE',order_id=oid)
|
||||
customer.call('/uploads/'+final_id,expected=404)
|
||||
body={'version':version,'files':[{'item_index':0,'upload_id':final_id}],'note':'Manually checked final'}
|
||||
customer.call('/operator/orders/'+oid+'/final-files',body,expected=401)
|
||||
version=customer.call('/operator/orders/'+oid+'/final-files',body,operator=True)['version']
|
||||
detail=customer.call('/customer/orders/'+oid)
|
||||
final=detail['files'][0]
|
||||
link=customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download')
|
||||
assert urlopen(link['url']).read()==b'LOCAL FINAL VERSION ONE'
|
||||
guest=Client();guest.call('/session');guest.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
|
||||
version=move('fil',version);version=move('imp',version);version=move('cor',version)
|
||||
customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
|
||||
correction_id=upload_bytes(customer,b'LOCAL CORRECTED ORIGINAL')
|
||||
payload={'version':version,'files':[{'item_index':0,'upload_id':correction_id}],'note':'Replaced the artwork as requested'}
|
||||
guest.call('/customer/orders/'+oid+'/corrections',payload,expected=404)
|
||||
customer.call('/customer/orders/'+oid+'/corrections',{**payload,'version':0},expected=409)
|
||||
version=customer.call('/customer/orders/'+oid+'/corrections',payload)['version']
|
||||
files=customer.call('/operator/orders/'+oid+'/files',operator=True)
|
||||
assert any(f['kind']=='correction' and f['active'] and f['upload_id']==correction_id for f in files)
|
||||
version=move('tra',version)
|
||||
customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
|
||||
new_final=upload_bytes(customer,b'LOCAL FINAL VERSION TWO',order_id=oid)
|
||||
version=customer.call('/operator/orders/'+oid+'/final-files',{'version':version,'files':[{'item_index':0,'upload_id':new_final}],'note':'Checked corrected final'},operator=True)['version']
|
||||
for state in ('fil','imp','fin'):version=move(state,version)
|
||||
detail=other.call('/customer/orders/'+oid)
|
||||
assert detail['state']=='fin'
|
||||
assert sum(f['active'] and f['kind']=='final' for f in detail['files'])==1
|
||||
assert any(h['reason']=='Please replace the artwork' for h in detail['history'])
|
||||
print('PASS: final-file gate, final revisions, secure customer downloads, correction history/uploads, old final invalidation and reapproval')
|
||||
old_cookie=list(other.jar)[0].value
|
||||
other.call('/account/logout',{})
|
||||
cookie.value=old_cookie;revoked.jar.set_cookie(cookie);revoked.call('/customer/orders',expected=401)
|
||||
other.call('/session');assert other.call('/customer/orders')['orders']==[]
|
||||
print('PASS: logout revokes server session and signed-out visitors cannot see account orders')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
BIN
output/local/kanban.png
Normal file
BIN
output/local/kanban.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
output/local/portal.png
Normal file
BIN
output/local/portal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
output/local/site.png
Normal file
BIN
output/local/site.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
BIN
output/pdf/dtf-plano-producao-e-roadmap.pdf
Normal file
BIN
output/pdf/dtf-plano-producao-e-roadmap.pdf
Normal file
Binary file not shown.
11040
output/security/api-container-audit.json
Normal file
11040
output/security/api-container-audit.json
Normal file
File diff suppressed because it is too large
Load Diff
39625
output/security/minio-container-audit.json
Normal file
39625
output/security/minio-container-audit.json
Normal file
File diff suppressed because it is too large
Load Diff
6387
output/security/postgres-container-audit.json
Normal file
6387
output/security/postgres-container-audit.json
Normal file
File diff suppressed because it is too large
Load Diff
11836
output/security/production-api-container-audit.json
Normal file
11836
output/security/production-api-container-audit.json
Normal file
File diff suppressed because it is too large
Load Diff
7452
output/security/production-web-container-audit.json
Normal file
7452
output/security/production-web-container-audit.json
Normal file
File diff suppressed because it is too large
Load Diff
1
output/security/python-audit.json
Normal file
1
output/security/python-audit.json
Normal file
@@ -0,0 +1 @@
|
||||
{"dependencies": [{"name": "annotated-doc", "version": "0.0.5", "vulns": []}, {"name": "annotated-types", "version": "0.8.0", "vulns": []}, {"name": "anyio", "version": "4.15.1", "vulns": []}, {"name": "boto3", "version": "1.38.23", "vulns": []}, {"name": "botocore", "version": "1.38.46", "vulns": []}, {"name": "certifi", "version": "2026.7.22", "vulns": []}, {"name": "click", "version": "8.5.0", "vulns": []}, {"name": "fastapi", "version": "0.141.1", "vulns": []}, {"name": "h11", "version": "0.16.0", "vulns": []}, {"name": "httpcore", "version": "1.0.9", "vulns": []}, {"name": "httpx", "version": "0.28.1", "vulns": []}, {"name": "idna", "version": "3.19", "vulns": []}, {"name": "jmespath", "version": "1.1.0", "vulns": []}, {"name": "pip", "version": "26.2.1", "vulns": []}, {"name": "psycopg", "version": "3.2.9", "vulns": []}, {"name": "psycopg-binary", "version": "3.2.9", "vulns": []}, {"name": "pydantic", "version": "2.13.5", "vulns": []}, {"name": "pydantic-core", "version": "2.46.5", "vulns": []}, {"name": "python-dateutil", "version": "2.9.0.post0", "vulns": []}, {"name": "s3transfer", "version": "0.13.1", "vulns": []}, {"name": "six", "version": "1.17.0", "vulns": []}, {"name": "starlette", "version": "1.6.0", "vulns": []}, {"name": "typing-extensions", "version": "4.16.0", "vulns": []}, {"name": "typing-inspection", "version": "0.4.4", "vulns": []}, {"name": "urllib3", "version": "2.7.0", "vulns": []}, {"name": "uvicorn", "version": "0.34.2", "vulns": []}], "fixes": []}
|
||||
26
output/security/runtime-requirements.txt
Normal file
26
output/security/runtime-requirements.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.15.1
|
||||
boto3==1.38.23
|
||||
botocore==1.38.46
|
||||
certifi==2026.7.22
|
||||
click==8.5.0
|
||||
fastapi==0.141.1
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
idna==3.19
|
||||
jmespath==1.1.0
|
||||
pip==26.2.1
|
||||
psycopg==3.2.9
|
||||
psycopg-binary==3.2.9
|
||||
pydantic==2.13.5
|
||||
pydantic_core==2.46.5
|
||||
python-dateutil==2.9.0.post0
|
||||
s3transfer==0.13.1
|
||||
six==1.17.0
|
||||
starlette==1.6.0
|
||||
typing_extensions==4.16.0
|
||||
typing-inspection==0.4.4
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.34.2
|
||||
2138
output/security/scanner-container-audit.json
Normal file
2138
output/security/scanner-container-audit.json
Normal file
File diff suppressed because it is too large
Load Diff
4081
output/security/site-container-audit.json
Normal file
4081
output/security/site-container-audit.json
Normal file
File diff suppressed because it is too large
Load Diff
322
portal/main.py
Normal file
322
portal/main.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Portal de recebimento de arte — roda na nuvem, aberto 24 horas.
|
||||
|
||||
Fluxo:
|
||||
0. Tiny avisa por webhook que nasceu um pedido de DTF
|
||||
1. portal cria token e manda o link no WhatsApp
|
||||
2. cliente abre o link e pede uma URL pré-assinada
|
||||
3. o navegador envia o arquivo DIRETO para o storage (não passa por aqui)
|
||||
4. cliente avisa que terminou -> robô valida, repete, fatia e carimba
|
||||
5. o agente da fábrica busca o que foi aprovado
|
||||
|
||||
Rodar:
|
||||
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import boto3
|
||||
from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Field, Session, SQLModel, create_engine, select
|
||||
|
||||
import preflight
|
||||
import tiny
|
||||
import whats
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
DB = os.environ["DATABASE_URL"]
|
||||
BUCKET = os.environ["S3_BUCKET"]
|
||||
BASE_PORTAL = os.environ["BASE_PORTAL"] # https://arte.dropstaratacado.com.br
|
||||
BASE_KANBAN = os.environ["BASE_KANBAN"] # http://servidor:8080
|
||||
TOKEN_TINY = os.environ["WEBHOOK_TOKEN"] # segredo combinado com o Tiny
|
||||
TOKEN_AGENTE = os.environ["AGENTE_TOKEN"]
|
||||
VALIDADE_TOKEN_DIAS = 7
|
||||
MAX_GB = 5 # limite do que os PCs da sala aguentam abrir
|
||||
MAX_ARQUIVOS = 10
|
||||
|
||||
engine = create_engine(DB)
|
||||
s3 = boto3.client("s3", endpoint_url=os.environ["S3_ENDPOINT"])
|
||||
app = FastAPI(title="Portal de arte DTF")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tabelas
|
||||
# --------------------------------------------------------------------------
|
||||
class Pedido(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
numero_tiny: str = Field(index=True, unique=True)
|
||||
cliente: str
|
||||
metros: float # metros comprados por arte
|
||||
token: str = Field(index=True, unique=True)
|
||||
token_expira: datetime
|
||||
criado_em: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Arte(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
pedido_id: int = Field(foreign_key="pedido.id", index=True)
|
||||
arquivo: str
|
||||
bytes: int = 0
|
||||
repeticoes: int = 1
|
||||
status: str = "recebida" # recebida | aprovada | recusada
|
||||
motivo: str = ""
|
||||
avisos: str = ""
|
||||
dpi_efetivo: float = 0
|
||||
largura_cm: float = 0
|
||||
metros_totais: float = 0
|
||||
baixada: bool = False
|
||||
criado_em: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Parte(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
arte_id: int = Field(foreign_key="arte.id", index=True)
|
||||
ordem: int
|
||||
metros: float
|
||||
arquivo: str
|
||||
carimbada: bool = False
|
||||
|
||||
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def sessao():
|
||||
with Session(engine) as s:
|
||||
yield s
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 0. Webhook do Tiny — pedido novo
|
||||
# --------------------------------------------------------------------------
|
||||
class PedidoTiny(BaseModel):
|
||||
numero: str
|
||||
cliente: str
|
||||
telefone: str
|
||||
metros: float
|
||||
|
||||
|
||||
@app.post("/webhook/tiny")
|
||||
def pedido_novo(
|
||||
p: PedidoTiny,
|
||||
background: BackgroundTasks,
|
||||
x_token: str = Header(default=""),
|
||||
s: Session = Depends(sessao),
|
||||
):
|
||||
if x_token != TOKEN_TINY:
|
||||
raise HTTPException(401, "token inválido")
|
||||
|
||||
existente = s.exec(select(Pedido).where(Pedido.numero_tiny == p.numero)).first()
|
||||
if existente:
|
||||
return {"ok": True, "ja_existia": True}
|
||||
|
||||
pedido = Pedido(
|
||||
numero_tiny=p.numero,
|
||||
cliente=p.cliente,
|
||||
metros=p.metros,
|
||||
token=uuid.uuid4().hex,
|
||||
token_expira=datetime.now(timezone.utc) + timedelta(days=VALIDADE_TOKEN_DIAS),
|
||||
)
|
||||
s.add(pedido)
|
||||
s.commit()
|
||||
s.refresh(pedido)
|
||||
|
||||
link = f"{BASE_PORTAL}/arte/{pedido.token}"
|
||||
background.add_task(whats.enviar_link, p.telefone, p.numero, link)
|
||||
return {"ok": True, "link": link}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1 e 2. Página e URL pré-assinada
|
||||
# --------------------------------------------------------------------------
|
||||
def _pedido_por_token(token: str, s: Session) -> Pedido:
|
||||
p = s.exec(select(Pedido).where(Pedido.token == token)).first()
|
||||
if not p:
|
||||
raise HTTPException(404, "link inválido")
|
||||
if p.token_expira < datetime.now(timezone.utc):
|
||||
raise HTTPException(410, "link expirado — fale com o atendimento")
|
||||
return p
|
||||
|
||||
|
||||
@app.get("/arte/{token}", response_class=HTMLResponse)
|
||||
def pagina(token: str, s: Session = Depends(sessao)):
|
||||
_pedido_por_token(token, s)
|
||||
return open("static/portal.html", encoding="utf-8").read()
|
||||
|
||||
|
||||
@app.get("/api/arte/{token}")
|
||||
def dados(token: str, s: Session = Depends(sessao)):
|
||||
p = _pedido_por_token(token, s)
|
||||
return {
|
||||
"pedido": p.numero_tiny,
|
||||
"cliente": p.cliente,
|
||||
"metros": p.metros,
|
||||
"area_util_cm": preflight.AREA_UTIL_CM,
|
||||
"limite_arquivo_m": preflight.LIMITE_ARQUIVO_M,
|
||||
"max_gb": MAX_GB,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/arte/{token}/url")
|
||||
def url_upload(token: str, nome: str, s: Session = Depends(sessao)):
|
||||
p = _pedido_por_token(token, s)
|
||||
n = len(s.exec(select(Arte).where(Arte.pedido_id == p.id)).all())
|
||||
if n >= MAX_ARQUIVOS:
|
||||
raise HTTPException(400, f"máximo de {MAX_ARQUIVOS} arquivos por pedido")
|
||||
|
||||
chave = f"{p.numero_tiny}/{uuid.uuid4().hex}_{nome}"
|
||||
url = s3.generate_presigned_url(
|
||||
"put_object",
|
||||
Params={"Bucket": BUCKET, "Key": chave},
|
||||
ExpiresIn=3600,
|
||||
)
|
||||
return {"url": url, "chave": chave}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Terminou o upload -> roda o pré-flight
|
||||
# --------------------------------------------------------------------------
|
||||
class Pronto(BaseModel):
|
||||
chave: str
|
||||
repeticoes: int = 1
|
||||
|
||||
|
||||
@app.post("/api/arte/{token}/pronto")
|
||||
def pronto(
|
||||
token: str,
|
||||
body: Pronto,
|
||||
background: BackgroundTasks,
|
||||
s: Session = Depends(sessao),
|
||||
):
|
||||
p = _pedido_por_token(token, s)
|
||||
arte = Arte(pedido_id=p.id, arquivo=body.chave, repeticoes=max(1, body.repeticoes))
|
||||
s.add(arte)
|
||||
s.commit()
|
||||
s.refresh(arte)
|
||||
background.add_task(processar, arte.id)
|
||||
return {"arte_id": arte.id, "status": "processando"}
|
||||
|
||||
|
||||
def processar(arte_id: int):
|
||||
"""Baixa do storage, valida, repete, fatia, carimba e devolve as partes."""
|
||||
with Session(engine) as s:
|
||||
arte = s.get(Arte, arte_id)
|
||||
pedido = s.get(Pedido, arte.pedido_id)
|
||||
|
||||
local = f"/tmp/{arte.id}.png"
|
||||
s3.download_file(BUCKET, arte.arquivo, local)
|
||||
arte.bytes = os.path.getsize(local)
|
||||
|
||||
r = preflight.processar(
|
||||
caminho=local,
|
||||
cm_comprados=pedido.metros * 100,
|
||||
repeticoes=arte.repeticoes,
|
||||
pedido=pedido.numero_tiny,
|
||||
base_kanban=BASE_KANBAN,
|
||||
pasta_saida="/tmp",
|
||||
)
|
||||
|
||||
arte.dpi_efetivo = r.dpi_efetivo
|
||||
arte.largura_cm = r.largura_cm
|
||||
arte.metros_totais = r.metros_totais
|
||||
arte.avisos = " | ".join(r.avisos)
|
||||
|
||||
if not r.aprovado:
|
||||
arte.status, arte.motivo = "recusada", r.motivo
|
||||
s.add(arte)
|
||||
s.commit()
|
||||
whats.enviar_recusa(pedido, arte.motivo)
|
||||
return
|
||||
|
||||
for i, caminho in enumerate(r.partes, start=1):
|
||||
chave = f"{pedido.numero_tiny}/partes/{os.path.basename(caminho)}"
|
||||
s3.upload_file(caminho, BUCKET, chave)
|
||||
s.add(Parte(
|
||||
arte_id=arte.id,
|
||||
ordem=i,
|
||||
metros=min(preflight.LIMITE_ARQUIVO_M,
|
||||
r.metros_totais - preflight.LIMITE_ARQUIVO_M * (i - 1)),
|
||||
arquivo=chave,
|
||||
carimbada=(i == len(r.partes)),
|
||||
))
|
||||
|
||||
arte.status = "aprovada"
|
||||
s.add(arte)
|
||||
s.commit()
|
||||
|
||||
tiny.marcador(pedido.numero_tiny, "DTF-RECEBIDA")
|
||||
whats.enviar_aprovacao(pedido, r)
|
||||
avisar_agente()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. O agente busca o que foi aprovado
|
||||
# --------------------------------------------------------------------------
|
||||
@app.get("/api/artes")
|
||||
def para_baixar(x_token: str = Header(default=""), s: Session = Depends(sessao)):
|
||||
if x_token != TOKEN_AGENTE:
|
||||
raise HTTPException(401, "token inválido")
|
||||
|
||||
artes = s.exec(
|
||||
select(Arte).where(Arte.status == "aprovada", Arte.baixada == False) # noqa: E712
|
||||
).all()
|
||||
saida = []
|
||||
for a in artes:
|
||||
p = s.get(Pedido, a.pedido_id)
|
||||
partes = s.exec(select(Parte).where(Parte.arte_id == a.id)).all()
|
||||
saida.append({
|
||||
"arte_id": a.id,
|
||||
"pedido": p.numero_tiny,
|
||||
"cliente": p.cliente,
|
||||
"metros": a.metros_totais,
|
||||
"partes": [
|
||||
{
|
||||
"ordem": pt.ordem,
|
||||
"metros": pt.metros,
|
||||
"url": s3.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": BUCKET, "Key": pt.arquivo},
|
||||
ExpiresIn=3600,
|
||||
),
|
||||
"nome": os.path.basename(pt.arquivo),
|
||||
}
|
||||
for pt in sorted(partes, key=lambda x: x.ordem)
|
||||
],
|
||||
})
|
||||
return saida
|
||||
|
||||
|
||||
@app.post("/api/artes/{arte_id}/baixada")
|
||||
def marcar_baixada(
|
||||
arte_id: int, x_token: str = Header(default=""), s: Session = Depends(sessao)
|
||||
):
|
||||
if x_token != TOKEN_AGENTE:
|
||||
raise HTTPException(401, "token inválido")
|
||||
a = s.get(Arte, arte_id)
|
||||
a.baixada = True
|
||||
s.add(a)
|
||||
s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def avisar_agente():
|
||||
"""Webhook para o agente na fábrica. O polling de 60s é a rede de segurança."""
|
||||
import httpx
|
||||
|
||||
url = os.environ.get("AGENTE_WEBHOOK")
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
httpx.post(url, timeout=5, headers={"x-token": TOKEN_AGENTE})
|
||||
except Exception:
|
||||
pass # o polling pega
|
||||
|
||||
|
||||
@app.get("/saude")
|
||||
def saude():
|
||||
return {"ok": True, "em": datetime.now(timezone.utc).isoformat()}
|
||||
415
portal/preflight.py
Normal file
415
portal/preflight.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Pré-flight do DTF: valida a arte, repete, fatia e carimba.
|
||||
|
||||
Regras acordadas com Marcus em 29/08/2026:
|
||||
- unidade de venda: 57 x 100 cm
|
||||
- faixa reservada no rodapé: 30 mm -> área útil 57 x 97 cm
|
||||
- carimbo: QR de 20 mm + texto na mesma altura, só canal preto
|
||||
- limite por arquivo gerado: 15 metros
|
||||
- o carimbo vai apenas na ÚLTIMA parte do pedido
|
||||
|
||||
Dependências: pyvips, qrcode, pillow
|
||||
apt install libvips-tools
|
||||
pip install pyvips qrcode pillow
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
import pyvips
|
||||
import qrcode
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Constantes do processo
|
||||
# --------------------------------------------------------------------------
|
||||
LARGURA_MAX_CM = 57.0
|
||||
FAIXA_RODAPE_MM = 30.0 # reservada para o carimbo
|
||||
ALTURA_UNIDADE_CM = 100.0
|
||||
AREA_UTIL_CM = ALTURA_UNIDADE_CM - FAIXA_RODAPE_MM / 10 # 97 cm
|
||||
|
||||
DPI_MINIMO = 150 # abaixo disso recusa
|
||||
DPI_IDEAL = 300 # entre 150 e 300 aceita com aviso
|
||||
LIMITE_ARQUIVO_M = 20.0 # nenhum arquivo gerado passa disso
|
||||
|
||||
QR_MM = 20.0 # leitura por celular
|
||||
ZONA_LIMPA_MM = 3.0 # margem branca ao redor do QR
|
||||
|
||||
FORMATOS_OK = {".png", ".tif", ".tiff", ".psd", ".psb", ".pdf", ".ai", ".svg", ".eps"}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Normalização — o cliente manda o que quiser, o designer recebe padronizado
|
||||
# --------------------------------------------------------------------------
|
||||
# Não existe "formato certo" único: normalizar tudo para um só jogaria fora o
|
||||
# melhor de cada tipo. A regra é por NATUREZA do conteúdo.
|
||||
#
|
||||
# vetor -> PDF, mantendo vetor (rasterizar aqui é perder qualidade)
|
||||
# pixel -> TIF com transparência (é o que a sala já usa)
|
||||
#
|
||||
# Isso resolve o vai-e-volta que os designers relatam hoje: abrir PDF no Corel
|
||||
# para não rasterizar, exportar, abrir no Photoshop, e esbarrar no limite de
|
||||
# 5 metros do PSD.
|
||||
#
|
||||
# O limite de 5 m NÃO é do Photoshop: é do formato PSD, que trava em 30.000 px
|
||||
# por dimensão — a 150 DPI dá 5,08 m. PSB vai a 300.000 px, ou 50 metros.
|
||||
# Em PDF vetorial não há esse limite; em TIF o teto é 4 GB por arquivo.
|
||||
VETORIAIS = {".pdf", ".ai", ".svg", ".eps"}
|
||||
RASTER = {".png", ".tif", ".tiff", ".psd", ".psb"}
|
||||
FORMATOS_RECUSA = {".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
|
||||
# CDR entra com etiqueta "conferir": pula o pré-flight e vai direto ao designer.
|
||||
# A licença do CorelDRAW é de estação, não cobre servidor processando arquivo.
|
||||
FORMATOS_SEM_VALIDACAO = {".cdr"}
|
||||
|
||||
CM_POR_POLEGADA = 2.54
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Resultado
|
||||
# --------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class Resultado:
|
||||
aprovado: bool
|
||||
motivo: str = "" # mensagem ao cliente quando recusado
|
||||
avisos: list[str] = field(default_factory=list)
|
||||
dpi_efetivo: float = 0.0
|
||||
largura_cm: float = 0.0
|
||||
altura_cm: float = 0.0
|
||||
metros_totais: float = 0.0
|
||||
conferir_manual: bool = False # CDR: entra sem validar
|
||||
natureza: str = "" # vetor | raster | conferir
|
||||
partes: list[str] = field(default_factory=list) # caminhos gerados
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. Validação
|
||||
# --------------------------------------------------------------------------
|
||||
def validar(caminho: str, cm_comprados: float) -> Resultado:
|
||||
"""
|
||||
cm_comprados = altura em centímetros que o cliente pagou por UMA arte.
|
||||
Ex.: comprou 1 metro -> 100.
|
||||
"""
|
||||
r = Resultado(aprovado=False)
|
||||
ext = os.path.splitext(caminho)[1].lower()
|
||||
|
||||
if ext in FORMATOS_SEM_VALIDACAO:
|
||||
r.aprovado = True
|
||||
r.conferir_manual = True
|
||||
r.avisos.append("CDR não passa pelo pré-flight — vai direto para o designer.")
|
||||
return r
|
||||
|
||||
if ext in FORMATOS_RECUSA:
|
||||
r.motivo = (
|
||||
f"Arquivo {ext.upper().lstrip('.')} não guarda transparência. "
|
||||
"Envie PNG ou TIFF com fundo transparente."
|
||||
)
|
||||
return r
|
||||
|
||||
if ext not in FORMATOS_OK:
|
||||
r.motivo = "Formato não aceito. Envie PNG ou TIFF com fundo transparente."
|
||||
return r
|
||||
|
||||
try:
|
||||
# access sequential: lê em streaming, não carrega 200 MB na memória
|
||||
img = pyvips.Image.new_from_file(caminho, access="sequential")
|
||||
except Exception:
|
||||
r.motivo = "Não conseguimos abrir o arquivo. Ele pode estar corrompido."
|
||||
return r
|
||||
|
||||
# --- canal alfa ---
|
||||
if img.bands < 4 or not img.hasalpha():
|
||||
r.motivo = (
|
||||
"A arte está sem fundo transparente. Todo fundo não removido "
|
||||
"sai impresso em branco na estampa."
|
||||
)
|
||||
return r
|
||||
|
||||
# --- DPI efetivo: a regra que mais pega arquivo ruim ---
|
||||
# O DPI gravado no cabeçalho mente com frequência (o cliente escala a
|
||||
# imagem e o programa mantém "300"). O que vale é pixel dividido pela
|
||||
# medida que ele comprou.
|
||||
area_util_cm = cm_comprados - FAIXA_RODAPE_MM / 10
|
||||
r.dpi_efetivo = img.height / (area_util_cm / CM_POR_POLEGADA)
|
||||
r.altura_cm = area_util_cm
|
||||
r.largura_cm = img.width / r.dpi_efetivo * CM_POR_POLEGADA
|
||||
|
||||
if r.dpi_efetivo < DPI_MINIMO:
|
||||
r.motivo = (
|
||||
f"A resolução real da arte é de {r.dpi_efetivo:.0f} DPI no tamanho "
|
||||
f"que você comprou. Precisamos de pelo menos {DPI_MINIMO} DPI — "
|
||||
"o ideal é 300. Reenvie em maior resolução."
|
||||
)
|
||||
return r
|
||||
|
||||
if r.dpi_efetivo < DPI_IDEAL:
|
||||
r.avisos.append(
|
||||
f"Resolução de {r.dpi_efetivo:.0f} DPI. Funciona, mas detalhes "
|
||||
"finos podem perder definição."
|
||||
)
|
||||
|
||||
# --- largura física ---
|
||||
if r.largura_cm > LARGURA_MAX_CM + 0.2: # 2 mm de tolerância
|
||||
r.motivo = (
|
||||
f"A arte tem {r.largura_cm:.1f} cm de largura. O máximo é "
|
||||
f"{LARGURA_MAX_CM:.0f} cm. Use o gabarito 57 × 97 cm."
|
||||
)
|
||||
return r
|
||||
|
||||
# --- bordas com alfa parcial (sombras suaves) ---
|
||||
alfa = img[img.bands - 1]
|
||||
parcial = ((alfa > 10) & (alfa < 245)).avg() / 255.0
|
||||
if parcial > 0.15:
|
||||
r.avisos.append(
|
||||
"A arte tem muitas bordas suaves. O branco de apoio pode sair "
|
||||
"irregular nessas áreas."
|
||||
)
|
||||
|
||||
r.aprovado = True
|
||||
return r
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1b. Normalização
|
||||
# --------------------------------------------------------------------------
|
||||
def normalizar(caminho: str, pasta_saida: str, pedido: str) -> tuple[str, str]:
|
||||
"""
|
||||
Converte o que o cliente mandou para o formato de trabalho da sala.
|
||||
|
||||
Devolve (caminho_normalizado, natureza).
|
||||
|
||||
O ORIGINAL É SEMPRE PRESERVADO. Se a conversão sair ruim num caso
|
||||
específico, o designer volta ao arquivo do cliente — pedido dos próprios
|
||||
designers, e é barato perto de refazer o trabalho.
|
||||
"""
|
||||
ext = os.path.splitext(caminho)[1].lower()
|
||||
|
||||
if ext in FORMATOS_SEM_VALIDACAO: # CDR: sem Corel no servidor
|
||||
return caminho, "conferir"
|
||||
|
||||
if ext in VETORIAIS:
|
||||
# mantém vetor: nada de rasterizar. O FlexiPRINT rasteriza na resolução
|
||||
# da máquina, na hora de imprimir — melhor que qualquer caminho manual.
|
||||
destino = os.path.join(pasta_saida, f"{pedido}_norm.pdf")
|
||||
if ext == ".pdf":
|
||||
shutil.copy(caminho, destino)
|
||||
else:
|
||||
# AI, SVG e EPS convertem para PDF preservando o vetor
|
||||
subprocess.run(
|
||||
["inkscape", caminho, "--export-type=pdf",
|
||||
f"--export-filename={destino}", "--export-text-to-path"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
return destino, "vetor"
|
||||
|
||||
# raster: TIF com alfa, sem compressão com perda
|
||||
destino = os.path.join(pasta_saida, f"{pedido}_norm.tif")
|
||||
img = pyvips.Image.new_from_file(caminho, access="sequential")
|
||||
if not img.hasalpha():
|
||||
img = img.bandjoin(255)
|
||||
img = _para_rgb(img)
|
||||
img.tiffsave(destino, compression="lzw", predictor="horizontal")
|
||||
return destino, "raster"
|
||||
|
||||
|
||||
def _para_rgb(img: pyvips.Image) -> pyvips.Image:
|
||||
"""
|
||||
CMYK vira RGB. Arquivo em CMYK é uma das causas de cor errada no DTF —
|
||||
o cliente monta em CMYK, a máquina trabalha em RGB, e a cor muda.
|
||||
"""
|
||||
if img.interpretation in ("cmyk", "b-w"):
|
||||
return img.colourspace("srgb")
|
||||
return img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Repetição
|
||||
# --------------------------------------------------------------------------
|
||||
def empilhar(caminho: str, repeticoes: int, destino: str) -> str:
|
||||
"""Concatena N cópias da arte na vertical, sem espaço entre elas."""
|
||||
if repeticoes <= 1:
|
||||
return caminho
|
||||
img = pyvips.Image.new_from_file(caminho, access="sequential")
|
||||
empilhado = pyvips.Image.arrayjoin([img] * repeticoes, across=1)
|
||||
empilhado.write_to_file(destino)
|
||||
return destino
|
||||
|
||||
|
||||
def montar_folha(caminhos: list[str], destino: str) -> str:
|
||||
"""
|
||||
Junta vários arquivos numa folha só, EMPILHADOS na ordem que subiram.
|
||||
|
||||
Decisão de 30/08/2026: empilhamento simples, sem encaixe lado a lado.
|
||||
São artes de 1 metro em sequência — não é arte única e não há otimização
|
||||
de largura. O cliente vê a prévia antes de fechar o pedido.
|
||||
"""
|
||||
if len(caminhos) == 1:
|
||||
return caminhos[0]
|
||||
imgs = [pyvips.Image.new_from_file(c, access="sequential") for c in caminhos]
|
||||
largura = max(i.width for i in imgs)
|
||||
# centraliza as artes mais estreitas, mantendo a largura da folha
|
||||
ajustadas = [
|
||||
i if i.width == largura
|
||||
else i.embed((largura - i.width) // 2, 0, largura, i.height)
|
||||
for i in imgs
|
||||
]
|
||||
pyvips.Image.arrayjoin(ajustadas, across=1).write_to_file(destino)
|
||||
return destino
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. Fatiamento
|
||||
# --------------------------------------------------------------------------
|
||||
def quantas_partes(metros_totais: float) -> int:
|
||||
return max(1, math.ceil(metros_totais / LIMITE_ARQUIVO_M))
|
||||
|
||||
|
||||
def fatiar(caminho: str, metros_totais: float, prefixo: str) -> list[str]:
|
||||
"""
|
||||
Corta em partes de no máximo 15 metros.
|
||||
1 m x 20 -> 15 + 5 (2 arquivos)
|
||||
100 m -> 15 x 6 + 10 (7 arquivos)
|
||||
"""
|
||||
n = quantas_partes(metros_totais)
|
||||
if n == 1:
|
||||
return [caminho]
|
||||
|
||||
img = pyvips.Image.new_from_file(caminho, access="random")
|
||||
px_por_metro = img.height / metros_totais
|
||||
partes, topo, restante = [], 0, metros_totais
|
||||
|
||||
for i in range(n):
|
||||
m = min(LIMITE_ARQUIVO_M, restante)
|
||||
altura = int(round(m * px_por_metro))
|
||||
altura = min(altura, img.height - topo)
|
||||
saida = f"{prefixo}_p{i + 1}.png"
|
||||
img.crop(0, topo, img.width, altura).write_to_file(saida)
|
||||
partes.append(saida)
|
||||
topo += altura
|
||||
restante -= m
|
||||
|
||||
return partes
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Carimbo
|
||||
# --------------------------------------------------------------------------
|
||||
def _qr_vips(conteudo: str, lado_px: int) -> pyvips.Image:
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M, # aguenta 15% de dano
|
||||
box_size=1,
|
||||
border=0,
|
||||
)
|
||||
qr.add_data(conteudo)
|
||||
qr.make(fit=True)
|
||||
m = qr.get_matrix()
|
||||
n = len(m)
|
||||
|
||||
# matriz -> imagem 1 bit -> escala para o tamanho pedido
|
||||
dados = bytes(0 if v else 255 for linha in m for v in linha)
|
||||
img = pyvips.Image.new_from_memory(dados, n, n, 1, "uchar")
|
||||
return img.resize(lado_px / n, kernel="nearest")
|
||||
|
||||
|
||||
def carimbar(
|
||||
caminho: str,
|
||||
pedido: str,
|
||||
metros: float,
|
||||
url_kanban: str,
|
||||
dpi: float = 300.0,
|
||||
quando: datetime | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Escreve o carimbo na faixa de 30 mm do rodapé.
|
||||
QR de 20 mm com a URL do card + número, metragem e data ao lado.
|
||||
Só canal preto — sem branco de apoio, para não gastar a tinta mais cara.
|
||||
"""
|
||||
quando = quando or datetime.now()
|
||||
img = pyvips.Image.new_from_file(caminho, access="random")
|
||||
px_mm = dpi / CM_POR_POLEGADA / 10
|
||||
|
||||
qr_px = int(QR_MM * px_mm)
|
||||
margem = int(ZONA_LIMPA_MM * px_mm)
|
||||
faixa_px = int(FAIXA_RODAPE_MM * px_mm)
|
||||
|
||||
# fundo branco só sob o QR, para o celular ler com contraste
|
||||
fundo = pyvips.Image.black(qr_px + margem * 2, qr_px + margem * 2) + 255
|
||||
qr = _qr_vips(url_kanban, qr_px)
|
||||
bloco = fundo.insert(qr, margem, margem)
|
||||
|
||||
# texto alinhado à altura do QR
|
||||
texto = (
|
||||
f"<span size='{int(qr_px*0.28)*1000}' weight='bold'>{pedido}</span>\n"
|
||||
f"<span size='{int(qr_px*0.19)*1000}'>{metros:.2f} m</span>\n"
|
||||
f"<span size='{int(qr_px*0.15)*1000}' foreground='#555555'>"
|
||||
f"{quando:%d/%m · %H:%M}</span>"
|
||||
).replace(".", ",")
|
||||
txt = pyvips.Image.text(texto, dpi=int(dpi), align="low")
|
||||
|
||||
# base da faixa: transparente, o carimbo entra em preto
|
||||
base = pyvips.Image.black(img.width, faixa_px, bands=4)
|
||||
y_qr = max(0, (faixa_px - bloco.height) // 2)
|
||||
base = base.insert(bloco.bandjoin(255), margem, y_qr, expand=False)
|
||||
base = base.insert(
|
||||
txt.bandjoin(255) if txt.bands < 4 else txt,
|
||||
margem * 2 + bloco.width,
|
||||
max(0, (faixa_px - txt.height) // 2),
|
||||
expand=False,
|
||||
)
|
||||
|
||||
final = pyvips.Image.arrayjoin([img, base], across=1)
|
||||
saida = caminho.replace(".png", "_carimbado.png")
|
||||
final.write_to_file(saida)
|
||||
return saida
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. Orquestração — é isso que o portal chama
|
||||
# --------------------------------------------------------------------------
|
||||
def processar(
|
||||
caminho: str,
|
||||
cm_comprados: float,
|
||||
repeticoes: int,
|
||||
pedido: str,
|
||||
base_kanban: str,
|
||||
pasta_saida: str,
|
||||
) -> Resultado:
|
||||
# 0. normaliza antes de tudo: o designer nunca mais converte à mão
|
||||
caminho, natureza = normalizar(caminho, pasta_saida, pedido)
|
||||
if natureza == "conferir":
|
||||
r = Resultado(aprovado=True, conferir_manual=True)
|
||||
r.avisos.append("CDR: sem validação automática, vai direto ao designer.")
|
||||
r.partes = [caminho]
|
||||
return r
|
||||
|
||||
r = validar(caminho, cm_comprados)
|
||||
r.natureza = natureza
|
||||
if not r.aprovado:
|
||||
return r
|
||||
|
||||
r.metros_totais = cm_comprados / 100 * repeticoes
|
||||
prefixo = os.path.join(pasta_saida, pedido)
|
||||
|
||||
empilhado = empilhar(caminho, repeticoes, f"{prefixo}_full.png")
|
||||
partes = fatiar(empilhado, r.metros_totais, prefixo)
|
||||
|
||||
# o carimbo vai só na última parte: é ela que fecha o pedido
|
||||
partes[-1] = carimbar(
|
||||
partes[-1],
|
||||
pedido=pedido,
|
||||
metros=r.metros_totais,
|
||||
url_kanban=f"{base_kanban}/p/{pedido}",
|
||||
dpi=r.dpi_efetivo,
|
||||
)
|
||||
r.partes = partes
|
||||
return r
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# conferência rápida da regra de fatiamento
|
||||
for m in (20, 15, 16, 45, 100):
|
||||
print(f"{m:>4} m -> {quantas_partes(m)} arquivo(s)")
|
||||
89
portal/tiny.py
Normal file
89
portal/tiny.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user