Commit inicial: app de exportação da SD + deploy Docker/Coolify

Aplicação Flask (enviar SD.md -> corrigir pendências -> exportar JSON),
scripts de contrato (scripts/) e infraestrutura de deploy (Dockerfile,
docker-compose.yml) para VPS via Coolify.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:07:38 -03:00
co-authored by Claude Sonnet 5
commit 0d4fdd2533
29 changed files with 4268 additions and 0 deletions
+402
View File
@@ -0,0 +1,402 @@
#!/usr/bin/env python3
"""
teste_e2e.py — o fluxo inteiro, do upload ao download, sem servidor rodando.
Usa o test client do Flask: exercita exatamente as rotas de app/servidor.py,
sem porta aberta e sem depender de o servidor estar de pé.
O que esta suíte existe para provar, na ordem:
1. upload NUNCA baixa nada — o download só sai do /exportar, depois de um
clique, e só quando o exportador não tem pendência;
2. SD com pendência é recusada no servidor mesmo por POST direto, e a tela
mostra formulário SÓ com os campos pendentes;
3. a correção feita no formulário chega ao JSON — e o JSON é byte-idêntico
ao do exporta_sd.py na linha de comando sobre um SD.md com a mesma
correção;
4. campo não pendente não é tocado por um Salvar (o formulário só manda o
que está pendente, e o servidor só toca no que vem).
Roda assim:
python3 scripts/teste_e2e.py
0 = tudo passou; 1 = alguma checagem falhou
"""
from __future__ import annotations
import html as H
import json
import re
import subprocess
import sys
import tempfile
from io import BytesIO
from pathlib import Path
RAIZ = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RAIZ / "scripts"))
sys.path.insert(0, str(RAIZ / "app"))
from caminhos import PROJETOS # noqa: E402
from regras_sd import ler_sd_texto, secao # noqa: E402
import servidor # noqa: E402
FALHAS: list[str] = []
CLIENTE = servidor.app.test_client()
SD8 = PROJETOS / "P2-vacinacao/sds/SD8-mvp-vacina-em-dia/SD.md"
SD10 = PROJETOS / "P2-vacinacao/sds/SD10-crie/SD.md"
SD20 = PROJETOS / "P4-cancer-de-mama/sds/SD20-siscan-d1/SD.md"
SD24 = PROJETOS / "P5-portal-rmds/sds/SD24-mvp-institucional/SD.md"
SD31 = PROJETOS / "P7-assistencia-farmaceutica/sds/SD31-fundacao-da-base-de-dispensacao/SD.md"
def ck(cond, desc):
print(f" {'ok ' if cond else 'FALHA'} {desc}")
if not cond:
FALHAS.append(desc)
def post(rota, dados=None, **kw):
return CLIENTE.post(rota, data=dados or {}, follow_redirects=True, **kw)
def texto(resp):
return resp.get_data(as_text=True)
def enviar(caminho: Path, nome: str | None = None, conteudo: bytes | None = None):
dados = {"arquivo": (BytesIO(conteudo if conteudo is not None else caminho.read_bytes()),
nome or caminho.name)}
return post("/upload", dados, content_type="multipart/form-data")
def pagina():
return texto(CLIENTE.get("/revisar", follow_redirects=True))
def reiniciar():
post("/reiniciar")
def campos_do_form(pag: str) -> dict:
"""Relê o formulário de pendências como o navegador o reenviaria."""
campos = {}
for m in re.finditer(r'<input[^>]*name="([^"]+)"[^>]*>', pag):
tag = m.group(0)
if any(t in tag for t in ('type="radio"', 'type="checkbox"', 'type="file"')):
continue
v = re.search(r'value="([^"]*)"', tag)
campos[m.group(1)] = H.unescape(v.group(1)) if v else ""
for m in re.finditer(r'<textarea[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', pag, re.S):
campos[m.group(1)] = H.unescape(m.group(2))
for m in re.finditer(r'<select[^>]*name="([^"]+)"[^>]*>(.*?)</select>', pag, re.S):
sel = re.search(r'<option value="([^"]*)"[^>]*selected', m.group(2))
campos[m.group(1)] = H.unescape(sel.group(1)) if sel else ""
return campos
def estado_na_tela(pag: str) -> str:
"""O estado que a tela DECLARA ("estado: <strong>…</strong>"). A trilha do
cabeçalho repete os nomes de todos os estados, então procurar o texto solto
daria positivo sempre."""
m = re.search(r"estado: <strong>([^<]+)</strong>", pag)
return m.group(1) if m else ""
def baixou(resp) -> bool:
return "attachment" in resp.headers.get("Content-Disposition", "")
def exportar_pela_cli(sd_md: Path) -> str:
with tempfile.TemporaryDirectory() as tmp:
r = subprocess.run([sys.executable, str(RAIZ / "scripts/exporta_sd.py"), str(sd_md),
"--dir", tmp], capture_output=True, text=True, encoding="utf-8")
saidas = list(Path(tmp).glob("*.json"))
if not saidas:
return f"(CLI não gerou JSON: {r.stderr.strip()})"
return saidas[0].read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
def preencher_linhas(pag: str, linha: str = "1090-L1") -> str:
"""Responde no formulário todas as pendências de `linha_os` com a mesma
linha — o que a pessoa faria copiando da tela da OS. Devolve a página."""
c = campos_do_form(pag)
for k in c:
if k.endswith("__linha_os"):
c[k] = linha
return texto(post("/revisar", c))
def sd_pronta():
print("\n1. SD31: só falta a linha de OS; preenchida, o clique exporta — e nada sai antes dele")
reiniciar()
r = enviar(SD31)
ck(not baixou(r), "o upload NÃO devolve download — devolve a tela de revisão")
pag = texto(r)
ck(estado_na_tela(pag) == "Com pendências" and "4 pendência(s)" in pag,
"estado: com pendências — as 4 linhas de OS que o SD.md não declara")
c = campos_do_form(pag)
ck(set(c) == {f"e{i}__linha_os" for i in range(4)},
f"o formulário traz SÓ os quatro campos de linha de OS, como texto ({sorted(c)})")
ck("<select" not in pag, "sem select de candidatas: não há de-para local, a pessoa copia da tela da OS")
ck(not baixou(post("/exportar")), "POST /exportar direto não baixa nada enquanto falta a linha")
pag = preencher_linhas(pag)
ck(estado_na_tela(pag) == "Pronta para exportar", "preenchidas, estado: pronta para exportar")
ck("PENDENTE" not in pag and 'id="form-pendencias"' not in pag,
"sem pendência não há formulário nenhum na tela")
ck("1090-L1" in pag, "a linha de OS declarada aparece no resumo")
ck("Exportar JSON" in pag and "disabled" not in pag.split("Exportar JSON")[0][-200:],
"o botão Exportar está habilitado")
r = post("/exportar")
ck(baixou(r) and "P7-SD31.json" in r.headers.get("Content-Disposition", ""), "download entregue")
baixado = texto(r)
p = json.loads(baixado)
ck(p["sd"]["codigo"] == "P7·SD31", "é o JSON da SD certa")
ck(all(e["ordem_servico"]["linha"] == "1090-L1" for e in p["entregaveis"]),
"todo entregável saiu com a linha de OS preenchida")
ck(p["sd"]["status"] == "Planejado" and all(e["status"] == "Rascunho" for e in p["entregaveis"]),
"status nos rótulos do contrato")
ck(estado_na_tela(pagina()) == "JSON exportado", "a tela registra a exportação")
# A mesma régua da CLI: grava o SD.md corrigido (Salvar SD.md) e exporta
# esse arquivo pela linha de comando — tem de dar os mesmos bytes.
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
ck(alvo.exists() and alvo.read_text(encoding="utf-8").count("linha_os: 1090-L1") == 4,
"o SD.md gravado leva as quatro linhas declaradas")
ck(baixado == exportar_pela_cli(alvo), "bytes idênticos aos do exporta_sd.py sobre o SD.md gravado")
def pendencia_de_semanas():
print("\n2. SD24: semanas declaradas ≠ datas → pendente; corrigido no formulário, exporta")
reiniciar()
pag = texto(enviar(SD24))
ck(estado_na_tela(pag) == "Com pendências", "estado: com pendências")
ck("18 dias" in pag and "3 semana" in pag and "declara 1" in pag,
"a pendência diz os dois números — derivado e declarado — e aponta o entregável")
c = campos_do_form(pag)
ck(set(c) == {"e3__semanas", "e3__inicio", "e3__fim"},
f"o formulário traz SÓ o entregável pendente: semanas + as datas dele ({sorted(c)})")
r = post("/exportar")
ck(not baixou(r) and "Exportação recusada" in texto(r),
"POST /exportar direto é recusado pelo servidor, com a razão")
c["e3__semanas"] = "3"
pag = texto(post("/revisar", c))
ck(estado_na_tela(pag) == "Pronta para exportar", "corrigidas as semanas, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
e4 = p["entregaveis"][3]
ck(e4["numero_semanas"] == 3 and e4["ust"] is None and e4["valor_entregavel"] is None,
"o JSON leva 3 semanas; UST e valor saem null para o importador derivar")
ck(p["sd"]["nome"] == "MVP Institucional e Protótipos do Portal RMDS",
"o título — que NÃO estava no formulário — continua intacto")
# Apagar a declaração também resolve: em branco, deriva.
reiniciar()
c = campos_do_form(texto(enviar(SD24)))
c["e3__semanas"] = ""
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar",
"semanas em branco = 'derive das datas' — também destranca")
def teto_de_28_dias():
print("\n3. SD8: janela de 31 dias e duas divergências de semanas")
reiniciar()
pag = texto(enviar(SD8))
ck("3 pendência(s)" in pag, "três pendências")
ck("31 dias" in pag and "teto de 28 dias" in pag, "o teto de 28 dias é nomeado, com os dias reais")
c = campos_do_form(pag)
ck(set(c) == {"e1__semanas", "e1__inicio", "e1__fim", "e2__semanas", "e2__inicio", "e2__fim",
"e7__inicio", "e7__fim"},
f"formulário só dos entregáveis 2, 3 e 8 — janela inteira de cada um ({sorted(c)})")
c["e1__semanas"] = "2"
c["e2__semanas"] = "1"
c["e7__fim"] = "2026-06-30" # 28 dias → 4 semanas, o que o SD já declarava
pag = texto(post("/revisar", c))
ck(estado_na_tela(pag) == "Pronta para exportar", "corrigidos os três, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
e8 = p["entregaveis"][7]
ck(e8["data_prevista_termino"] == "2026-06-30" and e8["numero_semanas"] == 4 and e8["ust"] is None,
"o entregável 8 saiu com a data corrigida e 4 semanas; a UST é do importador")
ck(p["entregaveis"][1]["numero_semanas"] == 2 and p["entregaveis"][2]["numero_semanas"] == 1,
"os entregáveis 2 e 3 saíram com as semanas das datas")
ck(p["sd"]["status"] == "Em andamento" and e8["status"] == "Documentado",
"estado `documentada` → sd.status 'Em andamento', entregável 'Documentado'")
ck(p["entregaveis"][0]["alocacoes"][0]["perfil"] == "Especialista de Inteligência (Cientista de Dados)",
"o alias do perfil saiu traduzido para o nome do cadastro")
def po_e_objetivo():
print("\n4. SD10: PO em branco e Seção 1 ausente → formulário com os dois; corrigidos, exporta")
reiniciar()
pag = texto(enviar(SD10))
c = campos_do_form(pag)
ck(set(c) == {"sd__po_responsavel", "corpo__objetivo"} | {f"e{i}__linha_os" for i in range(5)},
f"PO, objetivo e as cinco linhas de OS no formulário — nada mais ({sorted(c)})")
ck("[a designar]" not in pag and "[a redigir" not in pag, "nenhum placeholder inventado na tela")
c["sd__po_responsavel"] = "Guilherme Bernardino"
c["corpo__objetivo"] = "Permitir à equipe do CRIE manter as regras de elegibilidade sem deploy."
for i in range(5):
c[f"e{i}__linha_os"] = "1090-L1" if i == 2 else "2587-L3" # n3 é I-02; os demais, I-05
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar", "preenchidos, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
ck(p["sd"]["po_responsavel"]["nome"] == "Guilherme Bernardino", "o PO chega ao JSON")
ck(p["sd"]["objetivo"].startswith("Permitir à equipe do CRIE"), "o objetivo chega ao JSON")
corpo = servidor.ATUAL["sessao"].corpo
ck(secao(corpo, "1") is not None and corpo.index("## 1.") < corpo.index("## 5."),
"a Seção 1 foi criada no corpo, antes da Seção 5")
ck([e["ordem_servico"]["linha"] for e in p["entregaveis"]]
== ["2587-L3", "2587-L3", "1090-L1", "2587-L3", "2587-L3"],
"cada entregável saiu com a linha que a pessoa declarou")
def sem_datas():
print("\n5. SD20: sem datas, e PO 'PENDENTE' — reprovada, nada baixa")
reiniciar()
pag = texto(enviar(SD20))
ck("11 pendência(s)" in pag, "onze pendências: PO + 5 × (início, fim)")
ck("convenção do repositório" in pag, "o PO 'PENDENTE — ...' é reconhecido como não-nome")
ck(not baixou(post("/exportar")), "POST /exportar direto não baixa nada")
def dado_invalido():
print("\n6. Dado inválido vira mensagem na tela, não traceback no console")
reiniciar()
c = campos_do_form(texto(enviar(SD24)))
c["e3__inicio"] = "31/13/2026"
pag = texto(post("/revisar", c))
ck("ilegível" in pag and "Traceback" not in pag, "data ilegível é pendência nomeada")
c = campos_do_form(pag)
c["e3__inicio"] = "2026-07-13"
c["e3__fim"] = "2026-07-10"
ck("anterior ao início" in texto(post("/revisar", c)), "fim antes do início é pendência nomeada")
def entrada_ruim():
print("\n7. Entrada ruim: uma mensagem específica para cada caso")
for nome, conteudo, esperado, desc in (
("notas.txt", b"nao sou markdown", "não é um arquivo .md", "extensão errada"),
("vazio.md", b" \n", "está vazio", "arquivo vazio"),
("semfm.md", "# só prosa\n".encode(), "primeira linha deve ser", "sem frontmatter"),
("aberto.md", "---\nsd: X\n\n# sem fecho\n".encode(), "não fechado", "frontmatter não fechado"),
("lista.md", "---\n- a\n- b\n---\n".encode(), "não é um mapeamento", "frontmatter que não é mapa"),
("latin.md", "---\nsd: Vacinação\n---\n".encode("latin-1"), "não está em UTF-8", "fora de UTF-8"),
):
reiniciar()
ck(esperado in texto(enviar(Path(nome), nome, conteudo)), desc)
reiniciar()
pag = texto(enviar(Path("minima.md"), "minima.md",
"---\nsd: X\nestado: voando\nentregaveis: []\n---\n# t\n".encode()))
ck("fora da notação" in pag and "não tem entregáveis" in pag and "voando" in pag,
"frontmatter mínimo: identificador, estado e entregáveis reprovados com nome")
ck("Este conserto é no" in pag, "pendência estrutural aponta para o SD.md, sem controle na tela")
def writeback():
print("\n8. Salvar SD.md: correção gravada, comentários preservados, prosa reanexada")
reiniciar()
c = campos_do_form(texto(enviar(SD10)))
c["sd__po_responsavel"] = "PO de teste"
c["corpo__objetivo"] = "Objetivo de teste."
post("/revisar", c)
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
ck(alvo.exists(), "arquivo gravado")
txt = alvo.read_text(encoding="utf-8")
ck("CORREÇÃO 17/08/2026" in txt and "protótipo navegável está EM CRIAÇÃO" in txt,
"o comentário datado do campo `estado` sobreviveu inteiro")
ck("_corpo" not in txt, "nenhuma chave _corpo dentro do frontmatter")
ck('po_responsavel: PO de teste' in txt or 'po_responsavel: "PO de teste"' in txt, "o PO foi gravado")
ck("## 1. Objetivo" in txt and "## 5. Enquadramento no Termo de Referência" in txt,
"a Seção 1 nova e a prosa original estão no arquivo")
d, _ = ler_sd_texto(txt)
ck(d["po_responsavel"] == "PO de teste" and len(d["entregaveis"]) == 5,
"o arquivo gravado reparseia com os mesmos entregáveis")
reiniciar()
enviar(SD10)
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo)})
ck(not alvo.exists(), "sem a caixa de confirmação marcada, nada é gravado")
def linha_de_os_no_formulario():
print("\n9. Linha de OS: só o formato é conferido aqui; existência é do banco, na carga")
sd_md = ("---\n"
'sd: "P9·SD99"\nprojeto: "P9 · Teste"\ntitulo: "Sintética"\n'
'po_responsavel: "Alguém"\nversao: "V1"\nestado: rascunho\n'
"entregaveis:\n"
' - n: 1\n nome: "Um"\n tipo: Descoberta\n item: I-02\n'
' linha_os: "L1"\n inicio: 2026-09-01\n fim: 2026-09-11\n'
"---\n# T\n\n## 1. Objetivo\n\nAlgo.\n").encode("utf-8")
reiniciar()
pag = texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md))
ck("fora do formato" in pag and "1090-L1" in pag, "linha mal formada: pendência que mostra o formato")
ck('<input name="e0__linha_os"' in pag and "<select" not in pag, "campo de texto, sem lista local")
c = campos_do_form(pag)
c["e0__linha_os"] = "9999-L7"
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar",
"linha bem formada que o repositório não conhece PASSA — quem confere existência é o banco")
p = json.loads(texto(post("/exportar")))
ck(p["entregaveis"][0]["ordem_servico"]["linha"] == "9999-L7", "o que a pessoa declarou vai no JSON")
reiniciar()
c = campos_do_form(texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md)))
c["e0__linha_os"] = ""
pag = texto(post("/revisar", c))
ck("`linha_os` ausente" in pag and not baixou(post("/exportar")),
"em branco não há fallback nenhum: continua pendente e não exporta")
ck("linha_os" not in servidor.ATUAL["sessao"].dados["entregaveis"][0],
"e a chave `linha_os` saiu do frontmatter, em vez de virar string vazia")
def editar_depois_de_exportar():
print("\n10. Editar depois de exportar desfaz o rótulo 'exportada'")
reiniciar()
preencher_linhas(texto(enviar(SD31)))
post("/exportar")
ck(estado_na_tela(pagina()) == "JSON exportado", "exportada")
post("/revisar", {"sd__titulo": "outro título"})
pag = pagina()
ck(estado_na_tela(pag) == "Pronta para exportar",
"a edição volta a sessão para 'pronta' — o que foi exportado era outra versão")
ck("outro título" in pag, "e a edição foi aplicada")
def main() -> int:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
sd_pronta()
pendencia_de_semanas()
teto_de_28_dias()
po_e_objetivo()
sem_datas()
dado_invalido()
entrada_ruim()
writeback()
linha_de_os_no_formulario()
editar_depois_de_exportar()
print()
if FALHAS:
print(f"{len(FALHAS)} falha(s):")
for f in FALHAS:
print(" ·", f)
return 1
print("ponta a ponta: tudo passou.")
return 0
if __name__ == "__main__":
sys.exit(main())