Files
dtf-system/portal/main.py
Cauê Faleiros 98c951d374
Some checks failed
Validate, publish and deploy / validate (push) Successful in 2m2s
Validate, publish and deploy / publish-and-deploy (push) Failing after 8s
first commit
2026-09-15 16:42:34 -03:00

323 lines
10 KiB
Python

"""
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()}