first commit
This commit is contained in:
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()}
|
||||
Reference in New Issue
Block a user