Files
wanderandClaude Opus 5 382567b929 Tela de login: credencial única, sem banco
Toda rota passa por um before_request que redireciona para /login quem não
tem sessão — inclusive os POSTs, então /exportar e /salvar-sd sem cookie não
chegam ao handler. Ficam de fora só a própria entrada e /static (a tela de
login precisa da folha de estilo).

Credencial única em constante (admin@iasis.com.br / admin123), comparada com
compare_digest nos dois campos e com a mesma mensagem para usuário errado e
senha errada — dizer qual dos dois falhou entrega metade da credencial. Sem
banco, sem cadastro e sem recuperação de senha: a app é ferramenta interna de
uso individual, e isto existe para o upload e o writeback não ficarem abertos
a quem alcançar a porta quando ela roda hospedada.

SECRET_KEY não tem default assado na imagem — chave fixa numa imagem vale para
toda cópia dela. Sem a variável, o servidor sorteia uma por processo e o login
cai a cada restart, que é a mesma vida útil que a sessão da SD já tem; é seguro
porque o gunicorn roda com -w 1. APP_USUARIO/APP_SENHA seguem a convenção do
Dockerfile: default lá, compose só espelha.

O Sair encerra a sessão do navegador sem descartar a SD em memória — é a mesma
máquina, o mesmo processo. E o teste_e2e faz login uma vez no test client,
senão todo request dele voltaria 302.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 02:44:04 -03:00

569 lines
23 KiB
Python

#!/usr/bin/env python3
"""
servidor.py — a interface do fluxo enviar SD.md → corrigir o que falta → exportar.
python3 app/servidor.py # http://127.0.0.1:5000
O fluxo é curto de propósito: o upload lê o SD.md, o exportador o analisa
contra o contrato da carga (CONTRATO-JSON-V2.md) e a tela mostra o resumo do
que vai sair. Se falta algo, aparece um formulário SÓ com os campos pendentes;
corrigido, o botão Exportar libera. Nada mais.
A INVARIANTE desta aplicação, e o critério pelo qual ela deve ser julgada:
nenhuma rota entrega um caminho de arquivo ao exporta_sd.py, e nenhuma
chama o main() dele. A exportação chama montar() com o dicionário DA
SESSÃO — o mesmo objeto que o formulário editou.
Duas consequências. Primeira: "upload → download automático" é impossível por
construção — não existe caminho de código do /upload até o send_file; o único
que gera JSON é o /exportar, e ele só roda depois de um clique do usuário.
Segunda: o /exportar não confia no botão nem no estado da sessão — chama
montar(), que reprova sozinho se houver pendência. Um POST direto não passa por
botão nenhum.
Estado em memória de processo, uma SD por vez: é ferramenta local de uso
individual. Reiniciar o servidor descarta a sessão — o SD.md de origem continua
intacto no disco, e nada é gravado sem ação explícita (Salvar SD.md).
Na frente de tudo isso há uma tela de login com credencial única, fixa na
configuração — sem banco, sem cadastro, sem recuperação de senha. Ela não
divide dados entre pessoas (a sessão da SD continua sendo uma só, do processo);
serve para que upload, writeback e download não fiquem abertos a quem alcançar
a porta quando a app roda hospedada.
"""
from __future__ import annotations
import hmac
import io
import os
import secrets
import sys
from datetime import date
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from flask import (Flask, redirect, render_template, request, send_file, session,
url_for)
import escrita_sd
import sessao as S
from exporta_sd import ExportacaoReprovada, Pendencia, montar, serializar
from regras_sd import (ESTADOS_SD, TIPOS_IMPORTAVEIS, carregar_canonico, definir_secao,
ler_sd_texto, secao)
app = Flask(__name__)
# Chave YAML que existe com valor nulo — `processo_sei: null`, `linha_os: null` —
# renderizava como o texto "None" no campo do formulário, e voltava do POST como
# a STRING "None". Um "Salvar SD.md" depois disso gravaria `linha_os: "None"`.
# Vale para todo o template de uma vez, e não campo a campo.
app.jinja_env.finalize = lambda v: "" if v is None else v
# Dias da janela, para a tabela de prévia — a mesma subtração que a carga faz
# para derivar semanas (e, a partir delas, a UST).
app.jinja_env.globals["dias"] = lambda a, b: (date.fromisoformat(b) - date.fromisoformat(a)).days
# Uma SD por vez, em memória de processo. Ver docstring.
ATUAL: dict = {"sessao": None, "aviso": None, "erro": None}
# ---------------------------------------------------------------------------
# Porta de entrada
# ---------------------------------------------------------------------------
# Credencial única, fixa no código. Não existe banco, cadastro nem recuperação
# de senha: a app é ferramenta interna de uso individual, e a autenticação aqui
# serve para não deixar o upload e o Salvar SD.md abertos a quem alcançar a
# porta. As variáveis de ambiente existem só para trocar a credencial no deploy
# sem editar código — os defaults abaixo são a credencial acordada.
USUARIO = os.environ.get("APP_USUARIO", "admin@iasis.com.br").strip().lower()
SENHA = os.environ.get("APP_SENHA", "admin123")
# Assina o cookie de sessão. Sem SECRET_KEY no ambiente, sorteia uma por
# processo: reiniciar o servidor derruba o login — a mesma vida útil que a
# sessão da SD já tem (ver docstring), então nada se perde além do login.
# Vale porque o gunicorn roda com -w 1 (ver Dockerfile); com mais workers,
# cada um assinaria com uma chave diferente e SECRET_KEY passaria a ser
# obrigatória.
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
# Rotas que respondem sem login: a tela de entrada (`login`), o POST que a
# submete (`entrar` — endpoint com outro nome, e sem ele o próprio login bate
# no portão) e `static`, porque a tela de entrada precisa da folha de estilo.
LIVRES = {"login", "entrar", "static"}
def autenticado() -> bool:
return session.get("autenticado") is True
@app.before_request
def exigir_login():
"""O portão. Roda antes de qualquer rota — inclusive dos POSTs, então um
POST direto em /exportar ou /salvar-sd sem cookie não chega ao handler."""
if request.endpoint in LIVRES or autenticado():
return None
return redirect(url_for("login"))
def credencial_confere(email: str, senha: str) -> bool:
"""compare_digest nos dois campos: comparação de tempo constante, e sempre
os dois — sair mais cedo no e-mail errado contaria qual dos dois falhou."""
ok_email = hmac.compare_digest(email.strip().lower(), USUARIO)
ok_senha = hmac.compare_digest(senha, SENHA)
return ok_email and ok_senha
@app.get("/login")
def login():
if autenticado():
return redirect(url_for("inicio"))
return render_template("login.html")
@app.post("/login")
def entrar():
email = request.form.get("email", "")
senha = request.form.get("senha", "")
if not credencial_confere(email, senha):
# Renderiza a própria tela com o erro, em vez de redirecionar: o
# redirect exigiria carregar a mensagem em estado global, e a app já
# tem uma sessão global só — duas abas se contaminariam.
# Mensagem única para e-mail errado e senha errada, de propósito.
return render_template("login.html", email=email,
erro="Usuário ou senha inválidos."), 401
session.clear()
session["autenticado"] = True
return redirect(url_for("inicio"))
@app.post("/sair")
def sair():
"""Encerra a sessão do navegador. Não mexe na SD em memória: quem entrar
depois continua de onde parou — é a mesma máquina, o mesmo processo."""
session.clear()
return redirect(url_for("login"))
def canonico():
return carregar_canonico()
# ---------------------------------------------------------------------------
# Conversão formulário → dados
# ---------------------------------------------------------------------------
# Tipo YAML de cada chave do frontmatter que o formulário pode editar. O que não
# está aqui é texto.
TIPO_DO_CAMPO = {
"n": "int", "semanas": "int", "ust": "int",
"inicio": "data", "fim": "data", "data_abertura": "data",
}
def _limpo(v) -> str:
return (v or "").strip()
def _como_valor(texto: str, tipo: str):
"""Converte o texto do formulário para o tipo do YAML.
Quando não converte, devolve o texto cru DE PROPÓSITO: quem reclama de data
ilegível ou semana não-inteira é o exportador, com mensagem própria e
apontando o entregável. Silenciar aqui — virando None, ou recusando o post
— tiraria do usuário a única explicação que ele ia receber.
"""
if tipo == "int":
try:
return int(texto)
except ValueError:
return texto
if tipo == "data":
try:
return date.fromisoformat(texto)
except ValueError:
return texto
return texto
def _aplicar(mapa, chave: str, texto: str, tipo: str = "texto") -> None:
"""Grava a chave no CommentedMap, ou a remove quando o campo vem vazio.
Campo esvaziado vira chave AUSENTE, e não string vazia: é assim que o
exportador entende "campo faltante" — e, para `semanas`/`ust`/`linha_os`,
é como o usuário diz "apague a declaração e derive". Só escreve quando o
valor mudou de fato — reatribuir um valor idêntico é oportunidade gratuita
de perder formatação e comentário de linha no writeback.
"""
atual = mapa.get(chave)
if not texto:
if chave in mapa and atual is not None:
del mapa[chave]
return
if isinstance(atual, str) and atual.strip() == texto.strip():
return
novo = _como_valor(texto, tipo)
if atual != novo or chave not in mapa:
mapa[chave] = novo
def _linhas(texto: str) -> list[str]:
return [l.strip() for l in (texto or "").splitlines() if l.strip()]
def _aplicar_lista(mapa, chave: str, texto: str) -> None:
itens = _linhas(texto)
atual = mapa.get(chave)
if not itens:
if chave in mapa and atual:
del mapa[chave]
return
if isinstance(atual, list) and [str(x).strip() for x in atual] == itens:
return
mapa[chave] = itens
def _artefatos_de_texto(texto: str) -> list[dict]:
"""Uma linha por artefato, no formato `nome | AAAA-MM-DD`."""
saida = []
for linha in _linhas(texto):
nome, sep, data = linha.partition("|")
art = {"nome": nome.strip()}
if sep and data.strip():
art["data"] = _como_valor(data.strip(), "data")
saida.append(art)
return saida
def _texto_de_artefatos(artefatos) -> str:
linhas = []
for a in (artefatos or []):
if isinstance(a, dict):
d = a.get("data")
linhas.append(f"{a.get('nome', '')} | {d}" if d else str(a.get("nome", "")))
else:
linhas.append(str(a))
return "\n".join(linhas)
def aplicar_formulario(sess: S.Sessao, form) -> None:
"""Escreve as edições NO OBJETO DA SESSÃO. É a única mutação dos dados.
Percorre as chaves que VIERAM no POST, e só elas. O formulário só traz os
campos pendentes; um laço sobre uma lista fixa de campos leria os ausentes
como "apagados" e o primeiro Salvar removeria do YAML tudo o que não estava
pendente. A chave segue Pendencia.chave: `sd__campo`, `e{i}__campo`,
`corpo__campo`.
"""
d = sess.dados
for chave in form.keys():
escopo, sep, campo = chave.partition("__")
if not sep or not campo:
continue
bruto = form.get(chave, "")
texto = _limpo(bruto)
if escopo == "sd":
_aplicar(d, campo, texto, TIPO_DO_CAMPO.get(campo, "texto"))
elif escopo == "corpo":
if campo == "objetivo":
sess.corpo = definir_secao(sess.corpo, "1", texto.replace("\r\n", "\n"))
elif escopo.startswith("e") and escopo[1:].isdigit():
i = int(escopo[1:])
ents = d.get("entregaveis") or []
if i >= len(ents) or not isinstance(ents[i], dict):
continue
e = ents[i]
if campo in ("atividades", "criterios_aceite"):
_aplicar_lista(e, campo, bruto)
elif campo == "artefatos":
novos = _artefatos_de_texto(bruto)
if novos:
if _texto_de_artefatos(e.get("artefatos")).strip() != _texto_de_artefatos(novos).strip():
e["artefatos"] = novos
elif "artefatos" in e and e.get("artefatos"):
del e["artefatos"]
else:
_aplicar(e, campo, texto, TIPO_DO_CAMPO.get(campo, "texto"))
# ---------------------------------------------------------------------------
# Da pendência ao controle do formulário
# ---------------------------------------------------------------------------
# Decisão de APRESENTAÇÃO, e por isso mora aqui e não no exportador: ele diz
# qual campo falhou, não com que controle se edita.
ROTULO = {
"sd": "Identificador da SD", "titulo": "Título", "po_responsavel": "PO responsável",
"versao": "Versão do documento", "estado": "Estado da SD", "data_abertura": "Data de abertura",
"objetivo": "Objetivo (Seção 1 do SD.md)",
"n": "Número (n)", "nome": "Nome", "tipo": "Tipo", "item": "Item",
"inicio": "Início", "fim": "Fim", "semanas": "Semanas declaradas", "ust": "UST declarada",
"linha_os": "Linha de OS — copie da tela da OS no sistema, formato {OS}-L{n}",
"atividades": "Backlog — um por linha",
"criterios_aceite": "Critérios de aceite — um por linha",
"artefatos": "Artefatos — nome | AAAA-MM-DD, um por linha",
}
TEXTAREA = {"objetivo", "atividades", "criterios_aceite", "artefatos"}
# Sem controle na tela: o conserto é estrutural e se faz no SD.md.
SO_MENSAGEM = {"entregaveis", "dependencias", "secao5", "alocacoes_desvio"}
PLACEHOLDER = {"inicio": "AAAA-MM-DD", "fim": "AAAA-MM-DD", "data_abertura": "AAAA-MM-DD",
"versao": "V1", "sd": "P2·SD8", "linha_os": "1090-L1"}
def _valor_atual(sess: S.Sessao, p: Pendencia):
if p.escopo == "corpo":
return secao(sess.corpo or "", "1") or "" if p.campo == "objetivo" else ""
fonte = sess.dados if p.escopo == "sd" else (sess.dados.get("entregaveis") or [])[p.indice]
if not isinstance(fonte, dict):
return ""
v = fonte.get(p.campo)
if p.campo == "artefatos":
return _texto_de_artefatos(v)
if p.campo in ("atividades", "criterios_aceite"):
return "\n".join(str(x) for x in (v or []))
return "" if v is None else v
def _opcoes(p: Pendencia) -> list | None:
if p.campo == "estado":
return list(ESTADOS_SD)
if p.campo == "tipo":
return list(TIPOS_IMPORTAVEIS)
if p.campo == "item":
return list(p.opcoes or [])
return None
def montar_formulario(sess: S.Sessao) -> list[dict]:
"""Agrupa as pendências por alvo (SD, cada entregável, corpo), um controle
por campo. Uma pendência de `semanas` traz junto `inicio` e `fim`: a
correção pode ser em qualquer um dos três, e a pessoa decide qual."""
grupos: dict[tuple, dict] = {}
for p in sess.pendencias:
if p.escopo == "entregavel":
gk = ("entregavel", p.indice)
titulo = f"Entregável {p.n}" if p.n not in (None, "") else f"Entregável #{p.indice + 1}"
elif p.escopo == "corpo":
gk, titulo = ("corpo", None), "Corpo do documento"
else:
gk, titulo = ("sd", None), "Cabeçalho da SD"
g = grupos.setdefault(gk, {"titulo": titulo, "campos": {}})
# Semanas, início e fim são um só problema visto de três lados: a janela
# que dá as semanas. Pendência em qualquer um traz os outros para a tela.
campos = [p.campo]
if p.campo in ("semanas", "inicio", "fim"):
campos = [c for c in ("semanas", "inicio", "fim") if c == p.campo or c != "semanas"]
for campo in campos:
c = g["campos"].get(campo)
if c is None:
chave = Pendencia(p.escopo, campo, "", indice=p.indice, n=p.n).chave
c = g["campos"][campo] = {
"chave": chave, "campo": campo,
"rotulo": ROTULO.get(campo, campo),
"controle": ("mensagem" if campo in SO_MENSAGEM
else "textarea" if campo in TEXTAREA
else "select" if _opcoes(Pendencia(p.escopo, campo, "")) is not None
else "texto"),
"valor": _valor_atual(sess, Pendencia(p.escopo, campo, "", indice=p.indice)),
"opcoes": _opcoes(p) if campo == p.campo else _opcoes(Pendencia(p.escopo, campo, "")),
"placeholder": PLACEHOLDER.get(campo, ""),
"mensagens": [], "derivado": None,
}
if campo == p.campo:
c["mensagens"].append(p.mensagem)
if p.derivado is not None:
c["derivado"] = p.derivado
return [{"titulo": g["titulo"], "campos": list(g["campos"].values())}
for g in grupos.values()]
# ---------------------------------------------------------------------------
# Rotas
# ---------------------------------------------------------------------------
@app.get("/")
def inicio():
if ATUAL["sessao"] is None:
return render_template("upload.html", estado=S.SEM_ARQUIVO,
rotulo=S.ROTULO_ESTADO[S.SEM_ARQUIVO],
erro=ATUAL.pop("erro", None))
return redirect(url_for("revisar"))
@app.post("/upload")
def upload():
arq = request.files.get("arquivo")
if arq is None or not arq.filename:
ATUAL["erro"] = "Nenhum arquivo selecionado."
return redirect(url_for("inicio"))
if not arq.filename.lower().endswith(".md"):
ATUAL["erro"] = (f"“{arq.filename}” não é um arquivo .md. "
"A SD é um Markdown com frontmatter YAML.")
return redirect(url_for("inicio"))
bruto = arq.read()
if not bruto.strip():
ATUAL["erro"] = f"“{arq.filename}” está vazio."
return redirect(url_for("inicio"))
try:
texto = bruto.decode("utf-8")
except UnicodeDecodeError:
ATUAL["erro"] = (f"“{arq.filename}” não está em UTF-8. "
"Salve o arquivo como UTF-8 e envie de novo.")
return redirect(url_for("inicio"))
try:
# preservar_comentarios=True: é este objeto que o writeback regrava, e os
# comentários do frontmatter carregam decisão de contrato.
dados, corpo = ler_sd_texto(texto, preservar_comentarios=True)
except Exception as exc:
ATUAL["erro"] = f"Não consegui ler “{arq.filename}”: {exc}"
return redirect(url_for("inicio"))
sess = S.Sessao(nome_arquivo=arq.filename, dados=dados, corpo=corpo)
sess.caminho_origem = escrita_sd.destino_provavel(str(dados.get("sd", "")))
ATUAL["sessao"] = sess
ATUAL["erro"] = None
reavaliar(sess)
# Redireciona para a tela de revisão — NUNCA para um download. É a única
# saída desta rota.
return redirect(url_for("revisar"))
def reavaliar(sess: S.Sessao) -> None:
try:
sess.reavaliar(canonico())
except Exception as exc:
sess.ultimo_erro = f"A análise falhou: {type(exc).__name__} — {exc}"
sess.pendencias = [Pendencia("sd", "entregaveis", f"a análise estourou: {exc}")]
sess.estado = S.COM_PENDENCIAS
@app.get("/revisar")
def revisar():
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
# A prévia lista os entregáveis pelo `n`, e a adoção da tabela é por índice
# no frontmatter: traduz para a tela marcar a origem na linha certa.
ents = sess.dados.get("entregaveis") or []
da_tabela = {ents[i].get("n"): linha for i, linha in sess.linhas_da_tabela.items()
if i < len(ents) and isinstance(ents[i], dict)}
return render_template(
"revisar.html",
s=sess,
rotulo=sess.rotulo_estado(),
formulario=montar_formulario(sess),
previa=sess.previa or {},
linhas_da_tabela=da_tabela,
aviso=ATUAL.pop("aviso", None),
)
@app.post("/revisar")
def salvar_revisao():
"""Aplica as correções e reavalia. Não exporta — só o /exportar exporta."""
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
sess.ultimo_erro = None
try:
aplicar_formulario(sess, request.form)
except Exception as exc:
sess.ultimo_erro = f"Não consegui aplicar as correções: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
reavaliar(sess)
return redirect(url_for("revisar"))
@app.post("/exportar")
def exportar():
"""A ÚNICA rota que gera JSON — e montar() reprova sozinho se houver pendência."""
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
sess.ultimo_erro = None
try:
# montar() recebe o DICIONÁRIO DA SESSÃO — o mesmo que o formulário
# editou. Nenhum caminho de arquivo, nenhuma releitura do SD.md.
payload = montar(sess.dados, sess.corpo, canonico())
texto = serializar(payload)
except ExportacaoReprovada as exc:
# Reavaliado no servidor: um POST direto não passa por botão nenhum.
sess.pendencias = exc.pendencias
sess.estado = S.COM_PENDENCIAS
sess.ultimo_erro = (f"Exportação recusada — {len(exc.pendencias)} pendência(s). "
"Nada foi baixado: um arquivo que o importador rejeita não é "
"uma exportação.")
return redirect(url_for("revisar"))
except Exception as exc:
sess.ultimo_erro = f"A geração do JSON falhou: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
sess.marcar_exportada()
nome = f"{str(sess.dados.get('sd', 'sd')).replace('·', '-')}.json"
return send_file(io.BytesIO(texto.encode("utf-8")), mimetype="application/json",
as_attachment=True, download_name=nome)
@app.get("/salvar-sd")
def salvar_sd_preview():
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
destino = Path(request.args.get("destino") or (sess.caminho_origem or ""))
try:
texto = escrita_sd.render_sd(sess.dados, sess.corpo)
except Exception as exc:
sess.ultimo_erro = f"Não consegui montar o SD.md: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
return render_template(
"salvar.html", s=sess, destino=str(destino) if str(destino) else "",
diff=escrita_sd.diff_contra(destino, texto) if str(destino) else "",
no_repositorio=bool(str(destino)) and escrita_sd.dentro_do_repositorio(destino),
rotulo=sess.rotulo_estado(),
)
@app.post("/salvar-sd")
def salvar_sd():
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
destino = _limpo(request.form.get("destino"))
if not destino:
sess.ultimo_erro = "Informe o caminho de destino para salvar o SD.md."
return redirect(url_for("revisar"))
if request.form.get("confirmo") != "sim":
sess.ultimo_erro = "Gravação cancelada: a confirmação não foi marcada."
return redirect(url_for("revisar"))
try:
escrita_sd.gravar(Path(destino), escrita_sd.render_sd(sess.dados, sess.corpo))
except Exception as exc:
sess.ultimo_erro = f"Não consegui gravar {destino}: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
ATUAL["aviso"] = f"SD.md gravado em {destino}."
return redirect(url_for("revisar"))
@app.post("/reiniciar")
def reiniciar():
ATUAL["sessao"] = None
ATUAL["aviso"] = None
ATUAL["erro"] = None
return redirect(url_for("inicio"))
if __name__ == "__main__":
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
print(" Exportação da SD em http://127.0.0.1:5000")
app.run(host="127.0.0.1", port=5000, debug=False)