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:
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
escrita_sd.py — regrava o SD.md preservando os comentários do frontmatter.
|
||||
|
||||
Por que round-trip e não yaml.safe_dump: os comentários do frontmatter carregam
|
||||
decisão de contrato, não enfeite. O campo `estado` da P2·SD10 tem quatro linhas
|
||||
de correção datada ("CORREÇÃO 17/08/2026 (gestão do contrato): o protótipo
|
||||
navegável está EM CRIAÇÃO, não em produção como registrado em 11/08"). Um dump
|
||||
comum apagaria isso sem deixar rastro, e o histórico da decisão morreria num
|
||||
clique de "Salvar".
|
||||
|
||||
Nada aqui grava sozinho: quem chama mostra o diff primeiro e pede confirmação.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
from caminhos import PROJETOS, RAIZ # noqa: E402
|
||||
|
||||
|
||||
def _yaml_rt():
|
||||
from ruamel.yaml import YAML
|
||||
y = YAML()
|
||||
y.preserve_quotes = True
|
||||
# Largura efetivamente infinita: o padrão do ruamel é 80 e requebraria
|
||||
# linhas longas que hoje estão inteiras, produzindo um diff cheio de ruído
|
||||
# em campos que ninguém tocou.
|
||||
y.width = 4096
|
||||
return y
|
||||
|
||||
|
||||
def render_sd(dados, corpo: str) -> str:
|
||||
"""Monta o texto completo do SD.md: frontmatter + prosa, verbatim."""
|
||||
buf = io.StringIO()
|
||||
_yaml_rt().dump(dados, buf)
|
||||
frontmatter = buf.getvalue()
|
||||
if not frontmatter.endswith("\n"):
|
||||
frontmatter += "\n"
|
||||
corpo = corpo or ""
|
||||
if corpo and not corpo.startswith("\n"):
|
||||
corpo = "\n" + corpo
|
||||
if corpo and not corpo.endswith("\n"):
|
||||
corpo += "\n"
|
||||
return f"---\n{frontmatter}---\n{corpo}"
|
||||
|
||||
|
||||
def destino_provavel(sd_id: str) -> Path | None:
|
||||
"""O SD.md do repositório cujo campo `sd:` casa com o da sessão.
|
||||
|
||||
Só devolve caminho quando há UMA correspondência. Duas seria ambiguidade, e
|
||||
escolher sozinho o arquivo que vai ser sobrescrito não é decisão de código.
|
||||
"""
|
||||
if not sd_id:
|
||||
return None
|
||||
import yaml
|
||||
achados = []
|
||||
for f in sorted(PROJETOS.glob("*/sds/*/SD.md")):
|
||||
try:
|
||||
linhas = f.read_text(encoding="utf-8").splitlines()
|
||||
if not linhas or linhas[0].strip() != "---":
|
||||
continue
|
||||
fim = next((i for i, l in enumerate(linhas[1:], 1) if l.strip() == "---"), None)
|
||||
if fim is None:
|
||||
continue
|
||||
fm = yaml.safe_load("\n".join(linhas[1:fim])) or {}
|
||||
except Exception:
|
||||
continue
|
||||
if str(fm.get("sd", "")).strip() == str(sd_id).strip():
|
||||
achados.append(f)
|
||||
return achados[0] if len(achados) == 1 else None
|
||||
|
||||
|
||||
def diff_contra(destino: Path, texto_novo: str) -> str:
|
||||
atual = destino.read_text(encoding="utf-8") if destino.exists() else ""
|
||||
return "".join(difflib.unified_diff(
|
||||
atual.splitlines(keepends=True),
|
||||
texto_novo.splitlines(keepends=True),
|
||||
fromfile=f"a/{destino.name} (no disco)",
|
||||
tofile=f"b/{destino.name} (da sessão)",
|
||||
n=3,
|
||||
)) or "(sem diferenças)"
|
||||
|
||||
|
||||
def dentro_do_repositorio(destino: Path) -> bool:
|
||||
try:
|
||||
destino.resolve().relative_to(RAIZ)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def gravar(destino: Path, texto: str) -> None:
|
||||
destino.parent.mkdir(parents=True, exist_ok=True)
|
||||
destino.write_text(texto, encoding="utf-8")
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
#!/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).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
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, 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}
|
||||
|
||||
|
||||
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"))
|
||||
return render_template(
|
||||
"revisar.html",
|
||||
s=sess,
|
||||
rotulo=sess.rotulo_estado(),
|
||||
formulario=montar_formulario(sess),
|
||||
previa=sess.previa or {},
|
||||
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)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
sessao.py — a fonte única da SD entre o upload e o download.
|
||||
|
||||
O problema que esta camada resolve: a SD é carregada uma vez, vive em memória,
|
||||
é editada e reavaliada — e o exportador recebe ESSE objeto, não um caminho de
|
||||
arquivo. Sem isto, a correção feita no formulário nunca chegaria ao JSON.
|
||||
|
||||
O que a sessão sabe: os dados (frontmatter, como CommentedMap do ruamel para o
|
||||
writeback preservar comentários), a prosa, e o resultado da última análise do
|
||||
exportador — a prévia do payload e a lista de pendências. Pendência vazia é a
|
||||
única condição para exportar, e ela é reavaliada no servidor a cada POST: o
|
||||
botão desabilitado é conforto de tela.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
from exporta_sd import Pendencia, analisar # noqa: E402
|
||||
from regras_sd import Canonico # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Estados — os que a tela precisa distinguir, e nenhum a mais
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEM_ARQUIVO = "sem_arquivo"
|
||||
COM_PENDENCIAS = "com_pendencias"
|
||||
PRONTA = "pronta"
|
||||
EXPORTADA = "exportada"
|
||||
|
||||
ROTULO_ESTADO = {
|
||||
SEM_ARQUIVO: "Arquivo não enviado",
|
||||
COM_PENDENCIAS: "Com pendências",
|
||||
PRONTA: "Pronta para exportar",
|
||||
EXPORTADA: "JSON exportado",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sessao:
|
||||
nome_arquivo: str
|
||||
dados: dict # CommentedMap do ruamel — FONTE ÚNICA, mutada pelo formulário
|
||||
corpo: str
|
||||
estado: str = COM_PENDENCIAS
|
||||
pendencias: list[Pendencia] = field(default_factory=list)
|
||||
previa: dict | None = None
|
||||
caminho_origem: Path | None = None
|
||||
ultimo_erro: str | None = None
|
||||
exportada_em: str | None = None
|
||||
|
||||
def reavaliar(self, canon: Canonico) -> None:
|
||||
"""Roda o exportador em modo de análise sobre os dados COMO ESTÃO.
|
||||
|
||||
É grátis e instantâneo; roda a cada edição. Uma edição depois da
|
||||
exportação volta a sessão para pronta/pendente: o que foi exportado era
|
||||
outra versão dos dados, e a tela não pode dizer "exportada" sobre esta.
|
||||
"""
|
||||
self.previa, self.pendencias = analisar(self.dados, self.corpo, canon)
|
||||
self.estado = PRONTA if not self.pendencias else COM_PENDENCIAS
|
||||
self.exportada_em = None
|
||||
|
||||
@property
|
||||
def pode_exportar(self) -> bool:
|
||||
return not self.pendencias
|
||||
|
||||
def marcar_exportada(self) -> None:
|
||||
self.estado = EXPORTADA
|
||||
self.exportada_em = datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
def rotulo_estado(self) -> str:
|
||||
return ROTULO_ESTADO.get(self.estado, self.estado)
|
||||
@@ -0,0 +1,358 @@
|
||||
/* Public Sans/IBM Plex Mono são carregadas via <link> em base.html, não por
|
||||
@import aqui — @import serializa a busca (baixa o CSS, só então descobre e
|
||||
busca a fonte) e travaria a primeira renderização enquanto tenta resolver
|
||||
fonts.googleapis.com; o <link> deixa isso paralelo e falha mais barato numa
|
||||
rede restrita. A pilha de fallback abaixo é system-ui: sem internet, o app
|
||||
fica como antes, só sem o toque final. */
|
||||
|
||||
/* Tema claro — o padrão histórico da aplicação. Toda cor é token: o tema escuro
|
||||
mais abaixo só redefine estes valores, e nenhuma regra do arquivo repete uma
|
||||
cor crua. Cor crua no meio das regras é o que faz o tema escuro nascer com um
|
||||
botão branco sobre fundo branco. */
|
||||
:root {
|
||||
--fonte: "Public Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--fonte-mono: "IBM Plex Mono", ui-monospace, Consolas, monospace;
|
||||
|
||||
--tinta: #1b1e25;
|
||||
/* O par obrigatório de --tinta: o que se escreve EM CIMA dela quando ela é
|
||||
fundo (pastilha do estado atual, botão primário). Fixar #fff aqui daria
|
||||
branco sobre claro no tema escuro, onde --tinta é a cor clara. */
|
||||
--tinta-contraste: #ffffff;
|
||||
--fraco: #5a6270;
|
||||
--linha: #dde2e8;
|
||||
--fundo: #f1f3f6;
|
||||
--papel: #ffffff;
|
||||
/* Superfície aninhada — um nível acima do fundo, um abaixo do cartão.
|
||||
Usada em itens de lista dentro de cartão (achado, bloco-tr) para que
|
||||
eles se leiam como "dentro" do cartão, não como outro cartão. */
|
||||
--papel-2: #f6f7f9;
|
||||
--sombra: 0 1px 2px rgba(23, 27, 35, 0.06), 0 4px 10px rgba(23, 27, 35, 0.05);
|
||||
|
||||
--erro: #b3251d;
|
||||
--erro-fundo: #fdedec;
|
||||
--erro-borda: #f3c7c2;
|
||||
--ok: #1b7a4d;
|
||||
--ok-fundo: #eaf7f0;
|
||||
--ok-borda: #c2e8d3;
|
||||
--alerta: #92600a;
|
||||
--alerta-fundo: #fdf4e0;
|
||||
--alerta-borda: #efd9a0;
|
||||
|
||||
/* Linguagem de proveniência: tudo que o script decidiu por regra fica em
|
||||
azul; tudo que exige julgamento (do agente, ou do humano confirmando)
|
||||
fica em roxo. As mesmas duas cores aparecem na borda do cartão da
|
||||
camada, no selo do cabeçalho e na caixa "Rodar o auditor-sd" — o mesmo
|
||||
par se repete em qualquer lugar onde a pergunta é "isto foi provado ou
|
||||
foi julgado?". */
|
||||
--script: #2f5fa8;
|
||||
--script-fundo: #eaf1fb;
|
||||
--script-borda: #c8dbf3;
|
||||
--agente: #6d3fa0;
|
||||
--agente-fundo: #f3ecfa;
|
||||
--agente-borda: #ddc7ef;
|
||||
|
||||
--diff-fundo: #1a1d23;
|
||||
--diff-tinta: #e6e9ef;
|
||||
--foco: #2f5fa8;
|
||||
}
|
||||
|
||||
/* Tema escuro. Só tokens — nenhuma regra estrutural muda.
|
||||
Grafite/azul-acinzentado, não preto puro: o fundo mais escuro daqui
|
||||
(--fundo) ainda tem luminosidade perceptível, e o cartão (--papel) é
|
||||
sempre um degrau mais claro que ele, nunca igual. As cores semânticas do
|
||||
tema claro (#b3251d, #1b7a4d, #92600a) foram escolhidas para contrastar
|
||||
com papel branco e desaparecem sobre fundo escuro; aqui elas sobem em
|
||||
luminosidade, e os fundos correspondentes descem para tons de vinho,
|
||||
verde-musgo e âmbar escuros — nunca preto com uma tinta de cor jogada
|
||||
em cima. */
|
||||
:root[data-tema="escuro"] {
|
||||
--tinta: #e9ebf1;
|
||||
--tinta-contraste: #14171d;
|
||||
--fraco: #9aa1b0;
|
||||
--linha: #383e4b;
|
||||
--fundo: #1a1d23;
|
||||
--papel: #23272f;
|
||||
--papel-2: #2a2f39;
|
||||
--sombra: 0 1px 0 rgba(0, 0, 0, 0.35);
|
||||
|
||||
--erro: #ff9891;
|
||||
--erro-fundo: #3a1f20;
|
||||
--erro-borda: #5c2d2c;
|
||||
--ok: #7ad9a6;
|
||||
--ok-fundo: #16302a;
|
||||
--ok-borda: #275943;
|
||||
--alerta: #f0c368;
|
||||
--alerta-fundo: #362a17;
|
||||
--alerta-borda: #5c4726;
|
||||
|
||||
--script: #89b1f4;
|
||||
--script-fundo: #1f2b3d;
|
||||
--script-borda: #33455f;
|
||||
--agente: #cda3ef;
|
||||
--agente-fundo: #2b2338;
|
||||
--agente-borda: #453a5c;
|
||||
|
||||
--diff-fundo: #14171c;
|
||||
--diff-tinta: #dfe3ea;
|
||||
--foco: #89b1f4;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 15.5px/1.55 var(--fonte);
|
||||
color: var(--tinta);
|
||||
background: var(--fundo);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::selection { background: var(--script-borda); color: var(--tinta); }
|
||||
|
||||
a { color: var(--script); }
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--foco);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
header {
|
||||
background: var(--papel);
|
||||
border-bottom: 1px solid var(--linha);
|
||||
padding: 1.1rem 1.75rem 0.85rem;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 1.05rem; margin: 0; font-weight: 600; letter-spacing: -0.005em;
|
||||
}
|
||||
.cabecalho { display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 1rem; margin-bottom: 0.75rem; }
|
||||
#alternar-tema {
|
||||
font-size: 1.05rem; line-height: 1; padding: 0.35rem 0.65rem; flex: none;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.estados { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.pastilha {
|
||||
font-size: 0.72rem; font-weight: 500; padding: 0.25rem 0.6rem 0.25rem 0.55rem;
|
||||
border-radius: 999px; background: var(--papel-2); color: var(--fraco);
|
||||
border: 1px solid var(--linha); transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.pastilha.atual {
|
||||
background: var(--tinta); color: var(--tinta-contraste);
|
||||
border-color: var(--tinta); font-weight: 600;
|
||||
}
|
||||
/* Etapa da auditoria ocupada pelo bypass de teste: cor de alerta, e não a tinta
|
||||
cheia da etapa cumprida. A trilha não deve parecer completa quando não está. */
|
||||
.pastilha.atual.bypass {
|
||||
background: var(--alerta-fundo); color: var(--alerta); border-color: var(--alerta-borda);
|
||||
}
|
||||
/* Tudo depois da etapa atual ainda não aconteceu — traço pontilhado e sem
|
||||
preenchimento, só pela posição na lista (irmão depois de .atual), sem
|
||||
precisar de uma classe nova vinda do Jinja para cada pastilha. */
|
||||
.pastilha.atual ~ .pastilha {
|
||||
background: transparent; border-style: dashed; color: var(--fraco); opacity: 0.8;
|
||||
}
|
||||
|
||||
main { max-width: 1180px; margin: 0 auto; padding: 1.5rem 1.75rem 4.5rem; }
|
||||
footer { max-width: 1180px; margin: 0 auto; padding: 0 1.75rem 2rem; color: var(--fraco);
|
||||
font-size: 0.8rem; }
|
||||
footer p { margin: 0.3rem 0; }
|
||||
|
||||
h2 { font-size: 1.2rem; margin: 0 0 0.4rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||
h3 { font-size: 0.98rem; margin: 0 0 0.5rem; font-weight: 600; }
|
||||
h4 { font-size: 0.85rem; margin: 1.1rem 0 0.5rem; font-weight: 600; }
|
||||
|
||||
.topo { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
|
||||
|
||||
.cartao {
|
||||
background: var(--papel); border: 1px solid var(--linha); border-radius: 10px;
|
||||
padding: 1.15rem 1.3rem; margin-bottom: 1.1rem; box-shadow: var(--sombra);
|
||||
/* O link do achado da Fase 1 rola até aqui — sem a margem, o topo da seção
|
||||
encosta na borda da janela e o cabeçalho da seção fica colado. */
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
.fase3 { border-top: 3px solid var(--tinta); }
|
||||
|
||||
.duas-colunas { display: grid; grid-template-columns: 1fr 1fr; gap: 1.1rem; }
|
||||
@media (max-width: 960px) { .duas-colunas { grid-template-columns: 1fr; } }
|
||||
|
||||
.camada-script { border-top: 3px solid var(--script); }
|
||||
.camada-agente { border-top: 3px solid var(--agente); }
|
||||
.selo {
|
||||
float: right; font-size: 0.66rem; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: var(--fraco); background: var(--papel-2);
|
||||
border: 1px solid var(--linha); border-radius: 999px; padding: 0.15rem 0.55rem;
|
||||
}
|
||||
/* O selo herda a cor da camada onde mora — nunca precisa de uma classe própria
|
||||
no template, porque a pergunta que ele responde ("provado ou julgado?") já
|
||||
está respondida pela seção que o envolve. */
|
||||
.camada-script .selo { background: var(--script-fundo); color: var(--script); border-color: var(--script-borda); }
|
||||
.camada-agente .selo { background: var(--agente-fundo); color: var(--agente); border-color: var(--agente-borda); }
|
||||
|
||||
.faixa { padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1.1rem; font-size: 0.9rem; }
|
||||
.faixa.erro { background: var(--erro-fundo); border: 1px solid var(--erro-borda); color: var(--erro); }
|
||||
.faixa.aviso { background: var(--ok-fundo); border: 1px solid var(--ok-borda); color: var(--ok); }
|
||||
.faixa.alerta { background: var(--alerta-fundo); border: 1px solid var(--alerta-borda); color: var(--alerta); }
|
||||
|
||||
.nota { color: var(--fraco); font-size: 0.83rem; margin: 0.35rem 0; }
|
||||
.limitacao { color: var(--alerta); }
|
||||
.verde { color: var(--ok); font-weight: 500; }
|
||||
.vermelho { color: var(--erro); font-weight: 500; }
|
||||
.alerta-texto { color: var(--alerta); font-weight: 500; }
|
||||
|
||||
ul.achados, ul.bloqueios { list-style: none; padding: 0; margin: 0.6rem 0 0; }
|
||||
ul.achados li {
|
||||
font-size: 0.85rem; padding: 0.5rem 0.6rem; border-left: 3px solid var(--linha);
|
||||
border-radius: 0 6px 6px 0; margin-bottom: 0.35rem; background: var(--papel-2);
|
||||
}
|
||||
/* Com a Fase 2 fora da tela o painel da Fase 1 ocupa a largura toda, e uma
|
||||
coluna só de achados deixava metade da linha vazia. Grade, e não `columns`:
|
||||
`columns` quebra um item no meio entre uma coluna e outra. */
|
||||
ul.achados.em-colunas {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 0 0.7rem; align-items: start;
|
||||
}
|
||||
@media (max-width: 760px) { ul.achados.em-colunas { grid-template-columns: 1fr; } }
|
||||
li.nivel-erro { border-left-color: var(--erro); }
|
||||
li.nivel-aviso { border-left-color: var(--alerta); }
|
||||
.tag { font-weight: 700; font-size: 0.68rem; margin-right: 0.4rem; letter-spacing: 0.03em; }
|
||||
.nivel-erro .tag { color: var(--erro); }
|
||||
.nivel-aviso .tag { color: var(--alerta); }
|
||||
.regra { font-family: var(--fonte-mono); font-size: 0.74rem;
|
||||
color: var(--fraco); margin-right: 0.4rem; }
|
||||
.ent { font-size: 0.72rem; background: var(--linha); border-radius: 4px;
|
||||
padding: 0.05rem 0.35rem; margin-right: 0.35rem; }
|
||||
.ir-para { font-size: 0.72rem; background: var(--linha); color: var(--tinta);
|
||||
border-radius: 4px; padding: 0.05rem 0.35rem; margin-right: 0.35rem;
|
||||
text-decoration: none; white-space: nowrap; transition: background 0.15s ease, color 0.15s ease; }
|
||||
.ir-para:hover { background: var(--tinta); color: var(--tinta-contraste); }
|
||||
|
||||
ul.bloqueios li { font-size: 0.85rem; padding: 0.4rem 0.7rem 0.4rem 1rem;
|
||||
border-left: 3px solid var(--erro); border-radius: 0 6px 6px 0;
|
||||
margin-bottom: 0.35rem; background: var(--erro-fundo); }
|
||||
|
||||
ol.checklist { font-size: 0.83rem; color: var(--fraco); padding-left: 1.2rem; }
|
||||
ol.checklist li { margin-bottom: 0.2rem; }
|
||||
|
||||
label { display: block; margin-bottom: 0.7rem; font-size: 0.82rem; color: var(--fraco); font-weight: 500; }
|
||||
label.radio { display: inline-flex; align-items: center; gap: 0.35rem; margin-right: 1rem;
|
||||
color: var(--tinta); font-size: 0.82rem; font-weight: 400; }
|
||||
input[type=text], input:not([type]), select, textarea {
|
||||
display: block; width: 100%; margin-top: 0.3rem; padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--linha); border-radius: 7px; font: inherit;
|
||||
font-size: 0.88rem; color: var(--tinta); background: var(--papel);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
input[type=text]:hover, input:not([type]):hover, select:hover, textarea:hover {
|
||||
border-color: var(--fraco);
|
||||
}
|
||||
input[type=text]:focus, input:not([type]):focus, select:focus, textarea:focus {
|
||||
border-color: var(--foco);
|
||||
outline: 2px solid var(--foco); outline-offset: 1px;
|
||||
}
|
||||
input[type=checkbox], input[type=radio] { accent-color: var(--tinta); }
|
||||
::placeholder { color: var(--fraco); opacity: 0.7; }
|
||||
input[type=file] {
|
||||
display: block; width: 100%; margin: 0.3rem 0 0.9rem; font: inherit;
|
||||
font-size: 0.85rem; color: var(--fraco);
|
||||
}
|
||||
input[type=file]::file-selector-button {
|
||||
font: inherit; font-size: 0.85rem; font-weight: 500; padding: 0.5rem 1rem;
|
||||
border-radius: 7px; border: 1px solid var(--linha); background: var(--papel);
|
||||
color: var(--tinta); cursor: pointer; margin-right: 0.7rem;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
input[type=file]::file-selector-button:hover { border-color: var(--fraco); }
|
||||
textarea { resize: vertical; }
|
||||
textarea.mono { font-family: var(--fonte-mono); font-size: 0.8rem; }
|
||||
|
||||
.grade { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 0.9rem; }
|
||||
.grade-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 0.9rem; }
|
||||
@media (max-width: 760px) { .grade-3 { grid-template-columns: 1fr; } }
|
||||
|
||||
button, .botao {
|
||||
font: inherit; font-size: 0.85rem; font-weight: 500; padding: 0.5rem 1rem; border-radius: 7px;
|
||||
border: 1px solid var(--linha); background: var(--papel); color: var(--tinta);
|
||||
cursor: pointer; text-decoration: none; display: inline-block;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.05s ease;
|
||||
}
|
||||
button:hover, .botao:hover { border-color: var(--fraco); }
|
||||
button:active, .botao:active { transform: translateY(1px); }
|
||||
button.primario { background: var(--tinta); color: var(--tinta-contraste);
|
||||
border-color: var(--tinta); }
|
||||
button.primario:hover { background: var(--fraco); border-color: var(--fraco); }
|
||||
button.primario[disabled] { background: var(--papel-2); color: var(--fraco);
|
||||
border-color: var(--linha); cursor: not-allowed; }
|
||||
button.primario[disabled]:hover { background: var(--papel-2); border-color: var(--linha); }
|
||||
button.discreto, .botao.discreto { color: var(--fraco); background: transparent; border-color: transparent; }
|
||||
button.discreto:hover, .botao.discreto:hover { color: var(--tinta); background: var(--papel-2); border-color: var(--linha); }
|
||||
.acoes { display: flex; gap: 0.65rem; align-items: center; flex-wrap: wrap; margin-top: 0.9rem; }
|
||||
.acoes form { margin: 0; }
|
||||
/* Salvar / Voltar ao Topo no fim de cada seção. A barra grudada no rodapé saiu:
|
||||
o "Salvar edições e revalidar" mora agora na Fase 3, no topo da tela, e um
|
||||
terceiro lugar para o mesmo botão só faria o usuário procurar. */
|
||||
.rodape-secao { border-top: 1px solid var(--linha); padding-top: 0.7rem;
|
||||
margin-top: 1rem; }
|
||||
|
||||
.entregavel { border-left: 3px solid var(--linha); }
|
||||
.achado-item { border: 1px solid var(--linha); border-radius: 8px; padding: 0.7rem 0.8rem;
|
||||
margin-bottom: 0.55rem; background: var(--papel-2); }
|
||||
.achado-item.pendente { border-color: var(--alerta-borda); background: var(--alerta-fundo); }
|
||||
.texto-achado { margin: 0 0 0.45rem; font-size: 0.85rem; }
|
||||
.opcoes { margin-bottom: 0.45rem; }
|
||||
|
||||
.bloco-tr { border-left: 3px solid var(--script); background: var(--script-fundo);
|
||||
padding: 0.7rem 0.9rem; border-radius: 0 8px 8px 0; margin-bottom: 0.65rem; }
|
||||
.bloco-tr .tr-item { margin: 0 0 0.35rem; font-size: 0.88rem; }
|
||||
ul.aderencia { margin: 0.35rem 0 0; padding-left: 1.1rem; font-size: 0.83rem; }
|
||||
ul.aderencia li { margin-bottom: 0.2rem; }
|
||||
|
||||
pre.diff {
|
||||
background: var(--diff-fundo); color: var(--diff-tinta); padding: 0.9rem 1rem;
|
||||
border-radius: 8px; overflow-x: auto; font-family: var(--fonte-mono);
|
||||
font-size: 0.78rem; line-height: 1.5; max-height: 480px;
|
||||
}
|
||||
|
||||
code { font-family: var(--fonte-mono); font-size: 0.85em;
|
||||
background: var(--papel-2); border: 1px solid var(--linha);
|
||||
padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
details summary { cursor: pointer; font-size: 0.85rem; margin-bottom: 0.5rem; font-weight: 500; }
|
||||
|
||||
.agente { border: 1px dashed var(--agente-borda); background: var(--agente-fundo);
|
||||
border-radius: 8px; padding: 0.8rem 0.9rem; margin-bottom: 0.9rem; }
|
||||
.agente form { display: flex; align-items: center; gap: 0.65rem; flex-wrap: wrap; }
|
||||
.agente .nota { margin: 0; flex: 1 1 16rem; }
|
||||
/* A bifurcação Auditar / Não Auditar. Fica acima do painel do agente, no ponto
|
||||
do fluxo em que a decisão é tomada, e visualmente separada dele: não é parte
|
||||
da auditoria, é a alternativa a ela. */
|
||||
.decisao-fase2 { border: 1px dashed var(--alerta-borda); border-radius: 8px;
|
||||
padding: 0.8rem 0.9rem; margin-bottom: 0.9rem; background: var(--alerta-fundo); }
|
||||
.decisao-fase2 form { display: flex; align-items: center; gap: 0.65rem; flex-wrap: wrap; }
|
||||
.decisao-fase2 .nota, .decisao-fase2 p { margin: 0; flex: 1 1 16rem; }
|
||||
|
||||
.parecer-meta { margin-top: 0.7rem; border-top: 1px solid var(--linha); padding-top: 0.6rem; }
|
||||
.parecer-meta p { font-size: 0.83rem; margin: 0.35rem 0; }
|
||||
|
||||
/* ---- Fluxo curto: pendências e resumo ---------------------------------- */
|
||||
.pendencia { border-top: 1px solid var(--linha); padding-top: 0.7rem; margin-top: 0.7rem; }
|
||||
.pendencia:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
|
||||
.pendencia .texto-achado { color: var(--alerta); }
|
||||
.pendencia .tag { color: var(--alerta); }
|
||||
.pendencia label .nota { display: inline; font-weight: 400; }
|
||||
|
||||
.resumo { gap: 0.6rem 0.9rem; margin-bottom: 0.9rem; }
|
||||
.resumo > div { display: flex; flex-direction: column; font-size: 0.9rem; }
|
||||
.resumo .nota { margin: 0 0 0.1rem; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
.tabela-rolavel { overflow-x: auto; }
|
||||
table.entregaveis { width: 100%; border-collapse: collapse; font-size: 0.83rem; }
|
||||
table.entregaveis th, table.entregaveis td {
|
||||
text-align: left; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--linha);
|
||||
vertical-align: top;
|
||||
}
|
||||
table.entregaveis th { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--fraco); font-weight: 600; }
|
||||
table.entregaveis td.num { font-family: var(--fonte-mono); font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block titulo %}Exportação da SD{% endblock %}</title>
|
||||
<!-- Antes da folha de estilo, e não no fim do body: aplicado depois, o tema
|
||||
claro aparece por um quadro inteiro antes de escurecer. Sem tema gravado,
|
||||
vale a preferência do sistema. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem("tema");
|
||||
if (t !== "claro" && t !== "escuro") {
|
||||
t = matchMedia("(prefers-color-scheme: dark)").matches ? "escuro" : "claro";
|
||||
}
|
||||
document.documentElement.dataset.tema = t;
|
||||
} catch (e) {
|
||||
document.documentElement.dataset.tema = "claro";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="cabecalho">
|
||||
<h1>Solicitação de Demanda — enviar · corrigir · exportar</h1>
|
||||
<button type="button" id="alternar-tema" class="discreto" title="Alternar tema"
|
||||
aria-label="Alternar tema claro e escuro">◐</button>
|
||||
</div>
|
||||
<div class="estados">
|
||||
{# Três etapas, porque são três as que existem: o arquivo chega, ou está
|
||||
pronto ou tem pendência, e sai. "Com pendências" e "Pronta" ocupam a
|
||||
mesma posição — são os dois resultados possíveis da mesma análise. #}
|
||||
<span class="pastilha {{ 'atual' if estado_atual == 'sem_arquivo' }}">1 Arquivo</span>
|
||||
{% if estado_atual == 'com_pendencias' %}
|
||||
<span class="pastilha atual bypass">2 Com pendências</span>
|
||||
{% else %}
|
||||
<span class="pastilha {{ 'atual' if estado_atual == 'pronta' }}">2 Pronta para exportar</span>
|
||||
{% endif %}
|
||||
<span class="pastilha {{ 'atual' if estado_atual == 'exportada' }}">3 JSON exportado</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="topo">
|
||||
{% if erro %}<div class="faixa erro"><strong>Erro.</strong> {{ erro }}</div>{% endif %}
|
||||
{% if aviso %}<div class="faixa aviso">{{ aviso }}</div>{% endif %}
|
||||
{% block conteudo %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Regras da carga: <code>CONTRATO-JSON-V2.md</code> ·
|
||||
exportação: <code>scripts/exporta_sd.py</code> (função <code>montar</code>) ·
|
||||
OS e linha: <code>linha_os</code> declarado no SD.md, conferido pelo sistema na carga.</p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.getElementById("alternar-tema").addEventListener("click", function () {
|
||||
var raiz = document.documentElement;
|
||||
var novo = raiz.dataset.tema === "escuro" ? "claro" : "escuro";
|
||||
raiz.dataset.tema = novo;
|
||||
try { localStorage.setItem("tema", novo); } catch (e) {}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
{% set estado_atual = s.estado %}
|
||||
{% set erro = s.ultimo_erro %}
|
||||
{% block titulo %}{{ s.dados.get('sd', 'SD') }} — exportação{% endblock %}
|
||||
|
||||
{% block conteudo %}
|
||||
|
||||
<div class="topo">
|
||||
<div>
|
||||
<h2>{{ s.dados.get('sd', '(sem identificador)') }} — {{ s.dados.get('titulo', '') }}</h2>
|
||||
<p class="nota">Origem: <code>{{ s.nome_arquivo }}</code> ·
|
||||
estado: <strong>{{ rotulo }}</strong>
|
||||
{% if s.exportada_em %} · exportada em {{ s.exportada_em }}{% endif %}</p>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('reiniciar') }}">
|
||||
<button type="submit" class="discreto">Enviar outra SD</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ================= Ações =================
|
||||
No topo: é daqui que sai a única ação que gera arquivo. O botão fica
|
||||
desabilitado com pendência por conforto; o que garante é o servidor —
|
||||
montar() reprova sozinho no /exportar. -->
|
||||
<section class="cartao fase3">
|
||||
{% if s.pendencias %}
|
||||
<p class="vermelho">{{ s.pendencias|length }} pendência(s) impedem a exportação.
|
||||
Corrija abaixo e salve — ou edite o SD.md e envie de novo.</p>
|
||||
{% else %}
|
||||
<p class="verde">Tudo o que a carga exige está presente e fecha: datas, semanas,
|
||||
linha de OS, status e vocabulário. UST, tarifa e valor são calculados pelo
|
||||
sistema na carga. O JSON sai com a estrutura de sempre.</p>
|
||||
{% endif %}
|
||||
<div class="acoes">
|
||||
<form method="post" action="{{ url_for('exportar') }}">
|
||||
<button type="submit" class="primario" {{ 'disabled' if not s.pode_exportar }}>
|
||||
Exportar JSON
|
||||
</button>
|
||||
</form>
|
||||
{% if s.pendencias %}
|
||||
<button type="submit" form="form-pendencias" class="primario">Salvar correções e conferir de novo</button>
|
||||
{% endif %}
|
||||
<a class="botao discreto" href="{{ url_for('salvar_sd_preview') }}">Salvar SD.md…</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================= Pendências =================
|
||||
Um controle por campo pendente, e nenhum a mais. Os nomes seguem
|
||||
Pendencia.chave (sd__campo · e{i}__campo · corpo__campo); o servidor só
|
||||
toca no que vem no POST, então campo fora daqui não é alterado. -->
|
||||
{% if s.pendencias %}
|
||||
<form method="post" action="{{ url_for('salvar_revisao') }}" class="formulario" id="form-pendencias">
|
||||
{% for grupo in formulario %}
|
||||
<section class="cartao entregavel" id="grupo-{{ loop.index }}">
|
||||
<h3>{{ grupo.titulo }}</h3>
|
||||
{% for c in grupo.campos %}
|
||||
<div class="pendencia">
|
||||
{% for m in c.mensagens %}
|
||||
<p class="texto-achado"><span class="tag">PENDENTE</span> {{ m }}</p>
|
||||
{% endfor %}
|
||||
{% if c.controle == 'mensagem' %}
|
||||
<p class="nota limitacao">Este conserto é no <code>SD.md</code>: corrija o arquivo e envie de novo.</p>
|
||||
{% elif c.controle == 'textarea' %}
|
||||
<label>{{ c.rotulo }}
|
||||
<textarea name="{{ c.chave }}" rows="{{ 5 if c.campo == 'objetivo' else 3 }}">{{ c.valor }}</textarea></label>
|
||||
{% elif c.controle == 'select' %}
|
||||
<label>{{ c.rotulo }}
|
||||
<select name="{{ c.chave }}">
|
||||
<option value="">—</option>
|
||||
{% for o in c.opcoes %}
|
||||
<option value="{{ o }}" {{ 'selected' if c.valor|string == o|string }}>{{ o }}</option>
|
||||
{% endfor %}
|
||||
</select></label>
|
||||
{% else %}
|
||||
<label>{{ c.rotulo }}
|
||||
{% if c.derivado is not none %}<span class="nota"> — pela regra da carga: <strong>{{ c.derivado }}</strong>; em branco, deriva</span>{% endif %}
|
||||
<input name="{{ c.chave }}" value="{{ c.valor }}" placeholder="{{ c.placeholder }}"></label>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- ================= Resumo do que vai sair =================
|
||||
Derivado do payload como ele está. Com pendência, aparecem os buracos
|
||||
(linha vazia, UST vazia) — é a prévia, não o arquivo. -->
|
||||
<section class="cartao camada-script">
|
||||
<h3>O que o JSON vai conter <span class="selo">derivado do SD.md</span></h3>
|
||||
{% set sd = previa.sd or {} %}
|
||||
<div class="grade resumo">
|
||||
<div><span class="nota">Número</span><strong>{{ sd.numero_sequencial }}</strong></div>
|
||||
<div><span class="nota">Projeto</span><strong>{{ (sd.projeto or {}).codigo }}</strong> {{ (sd.projeto or {}).nome }}</div>
|
||||
<div><span class="nota">PO responsável</span><strong>{{ (sd.po_responsavel or {}).nome or '—' }}</strong></div>
|
||||
<div><span class="nota">Status da SD</span><strong>{{ sd.status or '—' }}</strong></div>
|
||||
<div><span class="nota">Período</span><strong>{{ sd.inicio or '—' }} → {{ sd.fim or '—' }}</strong></div>
|
||||
<div><span class="nota">Itens</span><strong>{{ (sd.itens_contratuais or [])|join(', ') or '—' }}</strong></div>
|
||||
<div><span class="nota">Entregáveis</span><strong>{{ (previa.entregaveis or [])|length }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="tabela-rolavel">
|
||||
<table class="entregaveis">
|
||||
<thead>
|
||||
<tr><th>n</th><th>Entregável</th><th>Tipo</th><th>Item</th><th>Linha OS</th>
|
||||
<th>Início</th><th>Fim</th><th>Dias</th><th>Sem.</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in previa.entregaveis or [] %}
|
||||
<tr>
|
||||
<td class="num">{{ e.n }}</td>
|
||||
<td>{{ e.titulo }}</td>
|
||||
<td>{{ e.tipo_entrega }}</td>
|
||||
<td>{{ e.item }}</td>
|
||||
<td class="{{ 'vermelho' if not e.ordem_servico.linha }}">{{ e.ordem_servico.linha or '—' }}</td>
|
||||
<td class="num">{{ e.data_inicio or '—' }}</td>
|
||||
<td class="num">{{ e.data_prevista_termino or '—' }}</td>
|
||||
<td class="num">{% if e.data_inicio and e.data_prevista_termino %}{{ dias(e.data_inicio, e.data_prevista_termino) }}{% else %}—{% endif %}</td>
|
||||
<td class="num">{{ e.numero_semanas if e.numero_semanas is not none else '—' }}</td>
|
||||
<td>{{ e.status or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="nota">Semanas = max(1, ⌈dias ÷ 7⌉) sobre as datas, teto de 28 dias. UST, tarifa e
|
||||
valor <strong>não saem daqui</strong>: o sistema os calcula na carga a partir de datas, item
|
||||
e tipo, com as tabelas vigentes. Linha de OS é o <code>linha_os</code> declarado no SD.md;
|
||||
quem confere se a OS e a linha existem é o sistema de gestão, na carga.</p>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% set estado_atual = s.estado %}
|
||||
{% set erro = s.ultimo_erro %}
|
||||
{% block titulo %}Salvar SD.md{% endblock %}
|
||||
|
||||
{% block conteudo %}
|
||||
<section class="cartao">
|
||||
<h2>Salvar as edições no SD.md</h2>
|
||||
<p>O frontmatter é regravado em <em>round-trip</em>: comentários, ordem das
|
||||
chaves e aspas são preservados. Listas reconstruídas pelo formulário
|
||||
(critérios, artefatos, dependências) perdem comentários internos, se houver.</p>
|
||||
|
||||
{% if no_repositorio %}
|
||||
<div class="faixa alerta">
|
||||
<strong>Este destino está dentro do repositório.</strong> A prosa gravada é a
|
||||
da <em>sessão</em>, que veio do arquivo enviado — se o SD.md do repositório
|
||||
tiver um corpo diferente, ele será substituído. Confira no diff abaixo.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('salvar_sd') }}">
|
||||
<label>Caminho de destino
|
||||
<input name="destino" value="{{ destino }}" size="100"
|
||||
placeholder="caminho completo do SD.md" required></label>
|
||||
{% if not destino %}
|
||||
<p class="nota">Não encontrei no repositório um <code>SD.md</code> cujo campo
|
||||
<code>sd:</code> case sozinho com o desta sessão. Informe o caminho.</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>Diferenças</h3>
|
||||
<pre class="diff">{{ diff }}</pre>
|
||||
|
||||
<label class="radio">
|
||||
<input type="checkbox" name="confirmo" value="sim" required>
|
||||
Confirmo a gravação neste caminho.</label>
|
||||
|
||||
<div class="acoes">
|
||||
<button type="submit" class="primario">Gravar SD.md</button>
|
||||
<a class="botao discreto" href="{{ url_for('revisar') }}">Voltar sem gravar</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% set estado_atual = 'sem_arquivo' %}
|
||||
{% block titulo %}Enviar SD.md{% endblock %}
|
||||
|
||||
{% block conteudo %}
|
||||
<section class="cartao">
|
||||
<h2>Enviar o SD.md</h2>
|
||||
<p>Selecione o <code>SD.md</code> da Solicitação de Demanda. O arquivo é lido
|
||||
e conferido contra as regras da carga do sistema de gestão. Se estiver
|
||||
completo, a próxima tela mostra o resumo e libera o botão
|
||||
<em>Exportar JSON</em>; se faltar algo, ela abre um formulário só com os
|
||||
campos a corrigir. Nada é exportado neste passo.</p>
|
||||
|
||||
<form method="post" action="{{ url_for('upload') }}" enctype="multipart/form-data">
|
||||
<input type="file" name="arquivo" accept=".md" required>
|
||||
<button type="submit" class="primario">Enviar e conferir</button>
|
||||
</form>
|
||||
|
||||
<p class="nota">Somente arquivos <code>.md</code> com frontmatter YAML. O arquivo
|
||||
enviado não é alterado: as correções ficam na sessão, e só voltam ao disco se
|
||||
você pedir explicitamente em <em>Salvar SD.md</em>.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user