Add 'gerenciador/' from commit '34cc9c75a57dedbf61a6e6a1ba1f35f76e72434b'

git-subtree-dir: gerenciador
git-subtree-mainline: f4f546c928
git-subtree-split: 34cc9c75a5
This commit is contained in:
gti
2026-09-29 14:43:12 -03:00
84 changed files with 8906 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Copie para .env e preencha antes de rodar (nunca commitar o .env real).
ADMIN_USER=admin
ADMIN_PASSWORD=SUBSTITUA_POR_UMA_SENHA_FORTE
# Chave usada para assinar o cookie de sessão de login (app/session.py).
# Sem isso definido, uma chave aleatória é gerada em memória a cada start
# do processo — funciona, mas todo restart derruba as sessões ativas
# (força login de novo). Em produção, defina um valor fixo e comprido.
SESSION_SECRET=SUBSTITUA_POR_UM_VALOR_ALEATORIO_COMPRIDO
# Banco de dev (mesmo container do projeto n8n / contatos-chave-web):
# DB_HOST=host.docker.internal (padrao — para rodar via docker-compose.yml,
# que executa este app dentro de um container)
# DB_HOST=127.0.0.1 (se este app rodar direto na maquina, fora do Docker)
DB_HOST=host.docker.internal
DB_PORT=3307
DB_NAME=gtisoftw_clientes_dev
DB_USER=root
DB_PASSWORD=devpassword123
# Endpoint interno read-only GET /internal/fiscal/clients (app/internal_fiscal.py):
# o GTI Fiscal espelha o cadastro de clientes daqui em vez de receber credencial
# MySQL. Token dedicado, compartilhado só com o FISCAL_INTERNAL_API_TOKEN do
# Fiscal — nunca o SESSION_SECRET, a chave privada do SSO ou senha de admin.
# Vazio = endpoint desligado (503).
FISCAL_INTERNAL_API_TOKEN=
# Opcional: integração de LEITURA com a Data Table "pagamentos_processados"
# do n8n, para mostrar os envios automáticos de chave na tela /log. Gere a
# chave em Configurações > API dentro do n8n (devn8n.gtigrupo.com.br).
# Se deixar em branco, a tela /log só mostra o log de ações do painel.
N8N_API_URL=
N8N_API_KEY=
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.venv/
.env
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
# Não roda como root dentro do container — reduz o estrago de uma
# eventual RCE (mesmo sem vulnerabilidade conhecida hoje, é uma
# mitigação de baixo custo e sem efeito colateral aqui, já que a app não
# precisa gravar nada no filesystem além de escutar a porta 8000, que não
# é privilegiada).
RUN useradd --no-create-home --shell /usr/sbin/nologin painel
USER painel
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+77
View File
@@ -0,0 +1,77 @@
# painel-clientes-web
Painel administrativo (FastAPI + Jinja2) para gestão de `CLIENTE`, `CLIENTE_CONTATO` e geração manual de chave de licenciamento no MySQL do sistema legado.
Proteções: login por sessão em toda rota (cookie assinado — ver seção "Login e permissões" abaixo), CSRF via cookie de duplo envio (`app/csrf.py` — todo formulário que muda estado embute o mesmo token do cookie `csrf_token`, definido pelo middleware em `app/main.py`) e a lista de clientes é paginada (100 por página), com busca e ordenação por código/razão/fantasia/CNPJ.
`CODIGO` de cliente não aceita duplicado (bloqueio duro, sem bypass) e o formulário de novo cliente já sugere o próximo código sequencial (editável). Só `CODIGO`, `CNPJ`, `RAZAO` e `FANTASIA` são obrigatórios — os campos de contrato (implantação, mensalidade, juros, datas) podem ficar em branco e recebem default (0,00 / data de hoje).
`CLIENTE` tem uma coluna `ATIVO` (criada no banco de dev — **precisa ser criada manualmente em produção também**, `ALTER TABLE` completo no início de `app/db/clientes.py`) que controla o filtro de status da listagem (Ativos/Inativos/Todos, padrão Ativos). Sem essa coluna, a listagem quebra com erro 500. O `DEFAULT 1` do `ALTER TABLE` é intencional: todo cliente cadastrado antes da migração nasce como Ativo.
A rota `/log` mostra o histórico de ações do painel (tabela `LOG_PAINEL`, criada no banco de dev — **precisa ser criada manualmente em produção também**, DDL no início de `app/db/log.py`) e, se `N8N_API_URL`/`N8N_API_KEY` estiverem configuradas (`.env.example`), os últimos envios automáticos de chave via WhatsApp (lidos da Data Table `pagamentos_processados` do n8n, somente leitura).
## Login e permissões
O painel usa login por sessão (cookie assinado, `app/session.py`) — não é
mais HTTP Basic Auth. Defina `SESSION_SECRET` no `.env` (`.env.example`
tem o exemplo); sem isso, uma chave é gerada em memória a cada start do
processo, o que derruba todas as sessões ativas a cada restart (funciona,
só incomoda).
Cada funcionário (`FUNCIONARIO_PAINEL`, criada no banco de dev — **precisa
ser criada manualmente em produção também**, DDL no início de
`app/db/funcionarios.py`) tem um perfil: **admin** (gerencia outros
funcionários em `/funcionarios`: cadastra, edita, redefine senha,
ativa/desativa, promove/rebaixa perfil) ou **padrão** (sem acesso a
`/funcionarios`). Se `FUNCIONARIO_PAINEL` já existir de uma instalação
anterior sem a coluna `PERFIL`, rode o `ALTER TABLE FUNCIONARIO_PAINEL ADD
COLUMN PERFIL ...` (DDL completo em `app/db/funcionarios.py`) — **precisa
ser rodado manualmente em produção também**; sem essa coluna, todo login
falha com erro 500 (a query de autenticação seleciona `PERFIL`, que não
existe na tabela antiga). O `DEFAULT 'ADMIN'` desse `ALTER TABLE` é
intencional: todo funcionário cadastrado antes da migração vira admin
automaticamente. O sistema nunca deixa ficar sem nenhum admin ativo —
rebaixar ou desativar o último admin é bloqueado.
Qualquer usuário logado troca a própria senha em **Minha conta**
(`/minha-conta`), pedindo a senha atual antes de trocar.
`ADMIN_USER`/`ADMIN_PASSWORD` (`.env.example`) continuam funcionando como
conta de bootstrap, sempre com perfil admin — é com ela que você entra a
primeira vez para cadastrar o primeiro funcionário, e serve de plano B se
a tabela ficar vazia. Login checa primeiro o admin do `.env`, depois
`FUNCIONARIO_PAINEL` (só contas com `ATIVO=1`).
## Rodando localmente (sem Docker)
1. Instale as dependências de desenvolvimento:
```
pip install -r requirements-dev.txt
```
2. Defina as variáveis de ambiente necessárias (`ADMIN_USER`, `ADMIN_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `SESSION_SECRET` — veja `.env.example`) diretamente no shell antes de subir a aplicação. `SESSION_SECRET` é opcional em dev — sem ela, uma chave aleatória é gerada em memória a cada start (sessões caem a cada restart); defina um valor fixo em produção. **Importante:** apesar de `python-dotenv` estar nas dependências, nada no código chama `load_dotenv()` hoje — um arquivo `.env` sozinho NÃO é lido automaticamente fora do Docker. Exporte as variáveis manualmente (ex.: `set -a; source .env; set +a` no bash, ou `$env:...` no PowerShell) ou defina-as por outro meio.
3. Rode a aplicação:
```
uvicorn app.main:app --reload
```
4. Rodando direto na máquina (fora de container), use `DB_HOST=127.0.0.1` (porta `3307`, a mesma do container de dev `licenciamento-mysql-dev`).
## Rodando os testes
```
pytest -v
```
Os testes usam defaults definidos em `tests/conftest.py` (equivalentes ao `.env.example`) e incluem testes de integração que precisam do container MySQL de dev (`licenciamento-mysql-dev`, `127.0.0.1:3307`) rodando.
## Rodando via Docker
1. Copie o arquivo de exemplo de variáveis de ambiente e edite `ADMIN_PASSWORD` (e demais valores, se necessário):
```
cp .env.example .env
```
2. Suba o container:
```
docker compose up -d --build
```
O `docker-compose.yml` já usa `env_file: .env` e mapeia `host.docker.internal` para o host (via `extra_hosts`), então o valor padrão `DB_HOST=host.docker.internal` do `.env.example` funciona também em hosts Docker Linux, não só no Docker Desktop (Windows/Mac).
3. A aplicação fica disponível em `http://localhost:8010`.
View File
+29
View File
@@ -0,0 +1,29 @@
"""
Versão dos arquivos estáticos (CSS/JS) usada como cache-busting nos links
de app/templates/base.html (?v=...). Sem isso, um navegador que já tinha
carregado /static/estilo.css antes de um deploy pode continuar servindo a
cópia antiga em cache — a URL não muda, então não há sinal pro navegador
buscar de novo — mesmo numa aba anônima aberta antes do deploy terminar.
Calculada uma única vez, na inicialização do processo, a partir do mtime
mais recente entre os arquivos em app/static/. Cada novo deploy recria o
container com os arquivos atualizados (mtime novo), então o valor muda
sozinho a cada implantação, sem precisar editar nada aqui.
"""
import os
_DIR_STATIC = os.path.join(os.path.dirname(__file__), "static")
def _calcular_versao() -> str:
arquivos = [
os.path.join(_DIR_STATIC, nome)
for nome in os.listdir(_DIR_STATIC)
if os.path.isfile(os.path.join(_DIR_STATIC, nome))
]
if not arquivos:
return "0"
return str(int(max(os.path.getmtime(caminho) for caminho in arquivos)))
ASSET_VERSAO = _calcular_versao()
+97
View File
@@ -0,0 +1,97 @@
"""
Login por sessão. Duas fontes de credencial, checadas nessa ordem em
autenticar_credenciais() (chamada tanto por POST /login quanto,
indiretamente, por quem valida o cookie de sessão):
1. ADMIN_USER/ADMIN_PASSWORD (variáveis de ambiente) — conta única de
bootstrap, útil para o primeiro acesso (cadastrar o primeiro
funcionário em /funcionarios) e como plano B se a tabela de
funcionários ficar vazia/inacessível. Continua exigindo as duas
variáveis preenchidas (nunca autentica credencial vazia). Sempre
perfil ADMIN, não depende do banco.
2. FUNCIONARIO_PAINEL (app/db/funcionarios.py) — conta individual por
funcionário, com senha em hash (app/senha.py). Só autentica se ATIVO=1.
Perfil vem da coluna PERFIL ("ADMIN" ou "PADRAO").
A credencial em si só é usada em POST /login (app/routers/auth.py), que
troca por um cookie de sessão assinado (app/session.py) — as rotas do
painel não recebem usuário/senha a cada requisição, só o cookie.
"""
import os
import secrets
from dataclasses import dataclass
from fastapi import Depends, HTTPException, Request, status
from . import session
from .db import funcionarios as db_funcionarios
from .senha import verificar_senha
@dataclass
class UsuarioLogado:
login: str
nome: str
perfil: str # "ADMIN" ou "PADRAO"
def __str__(self) -> str:
return self.nome
@property
def eh_admin(self) -> bool:
return self.perfil == "ADMIN"
class NaoAutenticado(Exception):
def __init__(self, caminho_original: str = "/"):
self.caminho_original = caminho_original
def autenticar_credenciais(usuario: str, senha: str) -> UsuarioLogado | None:
usuario_admin = os.environ.get("ADMIN_USER", "")
senha_admin = os.environ.get("ADMIN_PASSWORD", "")
if usuario_admin and senha_admin:
usuario_ok = secrets.compare_digest(usuario, usuario_admin)
senha_ok = secrets.compare_digest(senha, senha_admin)
if usuario_ok and senha_ok:
return UsuarioLogado(login=usuario_admin, nome=usuario_admin, perfil="ADMIN")
funcionario = db_funcionarios.obter_por_login(usuario)
if funcionario and funcionario["ATIVO"] and verificar_senha(senha, funcionario["SENHA_HASH"]):
return UsuarioLogado(login=funcionario["LOGIN"], nome=funcionario["NOME"], perfil=funcionario["PERFIL"])
return None
def exigir_login(request: Request) -> UsuarioLogado:
"""
Decodifica o cookie de sessão e, para logins que existem em
FUNCIONARIO_PAINEL, reconfirma ATIVO/PERFIL contra o banco a cada
requisição — o cookie assinado tem validade de até 12h e não é
revogado, então sem essa checagem desativar ou rebaixar um funcionário
não teria efeito imediato (continuaria com acesso até o cookie
expirar). A conta de bootstrap (ADMIN_USER do .env) não tem linha em
FUNCIONARIO_PAINEL e continua sempre admin via variável de ambiente,
sem consulta ao banco.
"""
token = request.cookies.get(session.SESSION_COOKIE_NAME)
dados = session.ler_token(token)
if dados is None:
raise NaoAutenticado(caminho_original=request.url.path)
usuario_admin = os.environ.get("ADMIN_USER", "")
if usuario_admin and dados["login"] == usuario_admin:
return UsuarioLogado(login=dados["login"], nome=dados["nome"], perfil="ADMIN")
funcionario = db_funcionarios.obter_por_login(dados["login"])
if funcionario is None or not funcionario["ATIVO"]:
raise NaoAutenticado(caminho_original=request.url.path)
return UsuarioLogado(login=funcionario["LOGIN"], nome=funcionario["NOME"], perfil=funcionario["PERFIL"])
def exigir_admin(usuario: UsuarioLogado = Depends(exigir_login)) -> UsuarioLogado:
if not usuario.eh_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Só administradores acessam esta página.")
return usuario
+57
View File
@@ -0,0 +1,57 @@
"""
Consulta de CNPJ na BrasilAPI (https://brasilapi.com.br/api/cnpj/v1) para
preencher automaticamente o formulário de cliente. Fonte pública/gratuita,
sem autenticação, mantida pela comunidade a partir de dados abertos da
Receita Federal — não é a API oficial da Receita (que não tem consulta
pública direta por CNPJ).
Só faz sentido para CNPJ (empresa) — CPF é dado pessoal e não tem consulta
pública, então o chamador nunca deve passar um CPF aqui (ver
app/routers/clientes.py, que checa o tipo via normalizar_documento antes).
Degrada de forma segura: qualquer falha (CNPJ não encontrado, rede fora do
ar, formato que a BrasilAPI ainda não reconhece — o novo CNPJ alfanumérico,
vigente desde 31/07/2026, pode não estar coberto por todo provedor ainda)
retorna None. O formulário simplesmente não preenche nada sozinho nesse caso.
"""
import httpx
_BASE_URL = "https://brasilapi.com.br/api/cnpj/v1"
def consultar_cnpj(cnpj_normalizado: str) -> dict | None:
"""
cnpj_normalizado: 14 caracteres (12 alfanuméricos + 2 dígitos), já sem
máscara — ver app/normalize.py. Retorna um dict só com os campos que
existem em CLIENTE, prontos para preencher o formulário, ou None se não
encontrou/a consulta falhou.
"""
if len(cnpj_normalizado) != 14:
return None
try:
resposta = httpx.get(f"{_BASE_URL}/{cnpj_normalizado}", timeout=8.0)
if resposta.status_code != 200:
return None
dados = resposta.json()
except (httpx.HTTPError, ValueError):
return None
if not isinstance(dados, dict):
return None
logradouro = dados.get("logradouro") or ""
numero = dados.get("numero") or ""
endereco = f"{logradouro}, {numero}" if numero else logradouro
return {
"RAZAO": dados.get("razao_social") or "",
"FANTASIA": dados.get("nome_fantasia") or dados.get("razao_social") or "",
"ENDERECO": endereco[:45], # CLIENTE.ENDERECO é varchar(45)
"CIDADE": dados.get("municipio") or "",
"BAIRRO": dados.get("bairro") or "",
"UF": dados.get("uf") or "",
"CEP": (dados.get("cep") or "")[:10],
"TELEFONE": dados.get("ddd_telefone_1") or "",
"EMAIL": dados.get("email") or "",
}
+44
View File
@@ -0,0 +1,44 @@
"""
Proteção CSRF via "double-submit cookie": o middleware registrado em
app/main.py garante que todo navegador que acessa o painel tenha um cookie
"csrf_token" (gerado uma vez, aleatório, HttpOnly). Todo formulário que
muda estado (POST) embute esse mesmo valor como campo oculto — os
templates leem de request.state.csrf_token, que o middleware preenche a
cada requisição. Toda rota POST que muda estado usa Depends(exigir_csrf),
que confere se o valor enviado no formulário bate com o valor do cookie.
Por quê isso barra CSRF: um site hostil consegue forçar o navegador da
vítima a mandar um POST para o painel (o cookie vai junto automaticamente),
mas não consegue LER o valor do cookie para replicá-lo como campo oculto do
formulário forjado — só JavaScript rodando na própria origem do painel
consegue (e não roda em outra origem, por causa do same-origin policy).
"""
import secrets
from fastapi import HTTPException, Request, status
CSRF_COOKIE_NAME = "csrf_token"
def gerar_token() -> str:
return secrets.token_urlsafe(32)
def token_do_contexto(request: Request) -> str:
"""
Lê o token que o middleware colocou em request.state.csrf_token.
Retorna "" se o middleware não estiver montado (por exemplo, testes que
sobem só um router isolado, sem app/main.py) — nesse caso o formulário
simplesmente não terá csrf_token preenchido, e exigir_csrf abaixo vai
rejeitar o POST a menos que o teste monte o cookie/campo manualmente.
"""
return getattr(request.state, "csrf_token", "")
async def exigir_csrf(request: Request) -> None:
"""Dependency para toda rota POST que muda estado."""
form = await request.form()
token_formulario = str(form.get(CSRF_COOKIE_NAME, ""))
token_cookie = request.cookies.get(CSRF_COOKIE_NAME, "")
if not token_cookie or not token_formulario or not secrets.compare_digest(token_formulario, token_cookie):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Token CSRF ausente ou inválido.")
View File
+31
View File
@@ -0,0 +1,31 @@
"""
Leitura somente-leitura de Clientes_Chaves (tabela legada — ver
licenciamento-n8n/docs/MAPEAMENTO_BANCO.md seção 3). O painel NUNCA escreve
aqui; só mostra status para conferência.
"""
from .connection import get_connection
MESES = [f"M{i}" for i in range(1, 13)]
def status_chaves(codigo: str) -> dict | None:
conn = get_connection()
try:
with conn.cursor() as cur:
colunas = ", ".join(MESES)
cur.execute(
f"SELECT ID, Cliente, {colunas} FROM Clientes_Chaves WHERE ID = %s",
(codigo,),
)
linha = cur.fetchone()
finally:
conn.close()
if linha is None:
return None
return {
"codigo": linha["ID"],
"cliente": linha["Cliente"],
"meses": {mes: (linha[mes] or None) for mes in MESES},
}
+267
View File
@@ -0,0 +1,267 @@
"""
CRUD completo de CLIENTE. Única tabela cadastral (ID autoincrement é a PK
técnica; CODIGO é o identificador de negócio usado pelo resto do sistema —
ver licenciamento-n8n/docs/MAPEAMENTO_BANCO.md).
CLIENTE.ATIVO (TINYINT(1) NOT NULL DEFAULT 1) controla se o cliente
aparece no filtro padrão da listagem ("Ativos"). Se CLIENTE já existir de
uma instalação anterior sem essa coluna, rode:
ALTER TABLE CLIENTE
ADD COLUMN ATIVO TINYINT(1) NOT NULL DEFAULT 1
AFTER DIA_VENCIMENTO;
DEFAULT 1 é intencional: todo cliente cadastrado antes da migração nasce
como Ativo — decisão do projeto (quem administra desativa manualmente os
que sabe que não são mais, ver
docs/superpowers/specs/2026-08-20-status-ativo-clientes-design.md).
Cadastros/edições novos sempre informam ATIVO explicitamente (vindo do
formulário), nunca dependem desse default.
"""
import pymysql
from .connection import get_connection
CAMPOS_CLIENTE = [
"CODIGO", "CNPJ", "RAZAO", "FANTASIA", "IE", "ENDERECO", "CIDADE",
"BAIRRO", "UF", "CONTATO", "TELEFONE", "CELULAR", "CEP", "IMPLANTACAO",
"MENSALIDADE", "JUROS_MORA", "JUROS_MULTA", "DATA_CONTRATO", "EMAIL",
"OBS", "VENCIMENTO_CONTRATO", "DIA_VENCIMENTO", "ATIVO",
]
_COLUNAS_SELECT = ", ".join(["ID", *CAMPOS_CLIENTE])
# Whitelist de colunas aceitas para ordenar a listagem — nunca interpolar
# o valor de "ordenar_por" vindo da URL direto no SQL sem passar por aqui.
ORDENACOES_VALIDAS = {
"codigo": "CODIGO",
"razao": "RAZAO",
"fantasia": "FANTASIA",
"cnpj": "CNPJ",
}
ORDENACAO_PADRAO = "razao"
# Idem para a direção — nunca interpolar "asc"/"desc" vindo da URL sem
# passar por aqui.
DIRECOES_VALIDAS = {"asc": "ASC", "desc": "DESC"}
DIRECAO_PADRAO = "asc"
class ClienteComDependenciasError(Exception):
"""Levantada ao tentar excluir um CLIENTE que ainda tem CLIENTE_CONTATO."""
def listar_clientes(
busca: str | None = None,
limite: int = 100,
offset: int = 0,
ordenar_por: str = ORDENACAO_PADRAO,
direcao: str = DIRECAO_PADRAO,
filtro_ativo: int | None = 1,
) -> list[dict]:
coluna = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO])
direcao_sql = DIRECOES_VALIDAS.get(direcao, DIRECOES_VALIDAS[DIRECAO_PADRAO])
# A busca é restrita à mesma coluna escolhida em "ordenar_por" — selecionar
# "Código" busca só em CODIGO, não em todos os campos de uma vez.
# filtro_ativo: 1 = só ativos, 0 = só inativos, None = sem filtro (todos).
condicoes = []
params: list = []
if busca:
condicoes.append(f"{coluna} LIKE %s")
params.append(f"%{busca}%")
if filtro_ativo is not None:
condicoes.append("ATIVO = %s")
params.append(filtro_ativo)
where_sql = f"WHERE {' AND '.join(condicoes)} " if condicoes else ""
conn = get_connection()
try:
with conn.cursor() as cur:
sql = (
f"SELECT {_COLUNAS_SELECT} FROM CLIENTE {where_sql}"
f"ORDER BY {coluna} {direcao_sql} LIMIT %s OFFSET %s"
)
cur.execute(sql, [*params, limite, offset])
return cur.fetchall()
finally:
conn.close()
def listar_clientes_para_fiscal() -> list[dict]:
"""Para o endpoint interno GET /internal/fiscal/clients (app/internal_fiscal.py):
o Fiscal espelha TODOS os clientes (ativos e inativos — inativo continua
aparecendo em histórico do lado de lá), nunca só uma página. Payload
mínimo: ID é a PK técnica estável (CODIGO é editável, não serve de
identidade — mesma razão de FUNCIONARIO_PAINEL.ID no SSO). CNPJ (só dígitos) entra só para a busca/identificação no seletor do
Fiscal; sem endereço/contato."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT ID, CODIGO, CNPJ, COALESCE(NULLIF(FANTASIA, ''), RAZAO) AS NOME, ATIVO "
"FROM CLIENTE ORDER BY ID"
)
return [
{"external_client_id": row["ID"], "code": row["CODIGO"], "display_name": row["NOME"], "cnpj": "".join(ch for ch in (row["CNPJ"] or "") if ch.isdigit()) or None, "active": bool(row["ATIVO"])}
for row in cur.fetchall()
]
finally:
conn.close()
def proximo_codigo_sugerido() -> str:
"""
Sugere o próximo CODIGO sequencial (maior CODIGO numérico + 1, com zeros
à esquerda até 6 dígitos). É só uma sugestão pré-preenchida no formulário
de novo cliente — o campo continua editável, e codigo_em_uso() ainda
confere unicidade de verdade antes de gravar.
"""
conn = get_connection()
try:
with conn.cursor() as cur:
# CODIGO >= 900000 é a faixa reservada para dados de teste deste
# painel (ver tests/test_db_clientes.py) — excluída aqui para a
# sugestão não disparar lá em cima se sobrar alguma linha de teste.
cur.execute(
"SELECT MAX(CAST(CODIGO AS UNSIGNED)) AS maior FROM CLIENTE "
"WHERE CODIGO REGEXP '^[0-9]+$' AND CAST(CODIGO AS UNSIGNED) < 900000"
)
maior = cur.fetchone()["maior"]
finally:
conn.close()
proximo = (maior or 0) + 1
return str(proximo).zfill(6)
def contar_clientes(busca: str | None = None, ordenar_por: str = ORDENACAO_PADRAO, filtro_ativo: int | None = 1) -> int:
coluna = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO])
condicoes = []
params: list = []
if busca:
condicoes.append(f"{coluna} LIKE %s")
params.append(f"%{busca}%")
if filtro_ativo is not None:
condicoes.append("ATIVO = %s")
params.append(filtro_ativo)
where_sql = f"WHERE {' AND '.join(condicoes)}" if condicoes else ""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) AS total FROM CLIENTE {where_sql}", params)
return cur.fetchone()["total"]
finally:
conn.close()
def obter_cliente(cliente_id: int) -> dict | None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(f"SELECT {_COLUNAS_SELECT} FROM CLIENTE WHERE ID = %s", (cliente_id,))
return cur.fetchone()
finally:
conn.close()
def cnpj_em_uso(documento_normalizado: str, ignorar_id: int | None = None) -> bool:
"""
True se já existe outro CLIENTE com esse mesmo CNPJ/CPF (o mesmo campo
CLIENTE.CNPJ aceita os dois — ver app/normalize.py). Compara ignorando
pontuação em vez de igualdade exata porque CLIENTE.CNPJ tem
formatação inconsistente em registros legados (alguns com máscara,
outros sem — ver licenciamento-n8n/docs/ANALISE_CLIENTE_IPREAL.md);
sem normalizar dos dois lados, um cadastro novo (sempre gravado sem
máscara) não bateria com um legado que ainda tem pontos/traços,
deixando passar o duplicado. `documento_normalizado` já chega sem
máscara (normalizar_documento); a normalização abaixo só precisa
tratar o lado do banco.
"""
conn = get_connection()
try:
with conn.cursor() as cur:
sql = (
"SELECT ID FROM CLIENTE WHERE "
"UPPER(REPLACE(REPLACE(REPLACE(REPLACE(CNPJ, '.', ''), '/', ''), '-', ''), ' ', '')) = %s"
)
params = [documento_normalizado]
if ignorar_id is not None:
sql += " AND ID != %s"
params.append(ignorar_id)
cur.execute(sql, params)
return cur.fetchone() is not None
finally:
conn.close()
def codigo_em_uso(codigo: str, ignorar_id: int | None = None) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
sql = "SELECT ID FROM CLIENTE WHERE CODIGO = %s"
params = [codigo]
if ignorar_id is not None:
sql += " AND ID != %s"
params.append(ignorar_id)
cur.execute(sql, params)
return cur.fetchone() is not None
finally:
conn.close()
def criar_cliente(dados: dict) -> int:
colunas = ", ".join(CAMPOS_CLIENTE)
marcadores = ", ".join(["%s"] * len(CAMPOS_CLIENTE))
valores = [dados[campo] for campo in CAMPOS_CLIENTE]
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(f"INSERT INTO CLIENTE ({colunas}) VALUES ({marcadores})", valores)
return cur.lastrowid
finally:
conn.close()
def atualizar_cliente(cliente_id: int, dados: dict) -> None:
atribuicoes = ", ".join(f"{campo} = %s" for campo in CAMPOS_CLIENTE)
valores = [dados[campo] for campo in CAMPOS_CLIENTE]
valores.append(cliente_id)
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(f"UPDATE CLIENTE SET {atribuicoes} WHERE ID = %s", valores)
finally:
conn.close()
def excluir_cliente(cliente_id: int) -> bool:
"""True se a linha realmente existia (cur.rowcount > 0) — um ID
inexistente não gera erro nenhum do MySQL, então sem checar rowcount
o chamador não tem como distinguir "excluiu" de "não tinha nada pra
excluir"."""
conn = get_connection()
try:
with conn.cursor() as cur:
try:
cur.execute("DELETE FROM CLIENTE WHERE ID = %s", (cliente_id,))
except pymysql.err.IntegrityError as exc:
raise ClienteComDependenciasError(
"Este cliente tem contatos cadastrados (CLIENTE_CONTATO) — remova-os antes de excluir."
) from exc
return cur.rowcount > 0
finally:
conn.close()
def alternar_ativo(cliente_id: int) -> bool:
"""True se a linha existia (cur.rowcount > 0) — mesma lógica de
excluir_cliente/redefinir_senha pra distinguir "alternou" de "não
tinha nada pra alternar". UPDATE ... SET ATIVO = 1 - ATIVO inverte o
valor atomicamente, sem precisar de um SELECT antes."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("UPDATE CLIENTE SET ATIVO = 1 - ATIVO WHERE ID = %s", (cliente_id,))
return cur.rowcount > 0
finally:
conn.close()
+22
View File
@@ -0,0 +1,22 @@
"""
Única porta de entrada para conexão MySQL do painel. Todo módulo em
app/db/*.py chama get_connection() daqui — nunca abre pymysql.connect()
por conta própria.
"""
import os
import pymysql
import pymysql.cursors
def get_connection() -> pymysql.connections.Connection:
"""Abre uma conexão nova por requisição (escala MVP; sem pool)."""
return pymysql.connect(
host=os.environ["DB_HOST"],
port=int(os.environ.get("DB_PORT", "3306")),
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
database=os.environ["DB_NAME"],
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
)
+83
View File
@@ -0,0 +1,83 @@
"""
CRUD de CLIENTE_CONTATO — relacionado a CLIENTE por CLIENTE_ID (FK real,
ON DELETE RESTRICT ON UPDATE CASCADE). Substitui CLIENTE_CONTATO_CHAVE
(superseded — ver licenciamento-n8n/docs/CANDIDATO_CLIENTE_CONTATO.md).
"""
from .connection import get_connection
def listar_contatos_por_cliente(cliente_id: int) -> list[dict]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT ID, NOME, TELEFONE, EMAIL FROM CLIENTE_CONTATO "
"WHERE CLIENTE_ID = %s ORDER BY ID",
(cliente_id,),
)
return list(cur.fetchall())
finally:
conn.close()
def contato_duplicado(cliente_id: int, telefone: str, ignorar_id: int | None = None) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
sql = "SELECT ID FROM CLIENTE_CONTATO WHERE CLIENTE_ID = %s AND TELEFONE = %s"
params = [cliente_id, telefone]
if ignorar_id is not None:
sql += " AND ID != %s"
params.append(ignorar_id)
cur.execute(sql, params)
return cur.fetchone() is not None
finally:
conn.close()
def inserir_contato(cliente_id: int, nome: str | None, telefone: str, email: str | None = None) -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO CLIENTE_CONTATO (CLIENTE_ID, NOME, TELEFONE, EMAIL) VALUES (%s, %s, %s, %s)",
(cliente_id, nome, telefone, email),
)
return cur.lastrowid
finally:
conn.close()
def atualizar_contato(
contato_id: int, cliente_id: int, nome: str | None, telefone: str, email: str | None = None
) -> bool:
"""True se realmente existia um CLIENTE_CONTATO com esse ID nesse
cliente (cur.rowcount > 0) — um ID inexistente, ou de outro cliente,
não gera erro nenhum do MySQL (WHERE simplesmente não bate com
nada), então sem checar rowcount o chamador não tem como saber que
não alterou nada."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE CLIENTE_CONTATO SET NOME = %s, TELEFONE = %s, EMAIL = %s "
"WHERE ID = %s AND CLIENTE_ID = %s",
(nome, telefone, email, contato_id, cliente_id),
)
return cur.rowcount > 0
finally:
conn.close()
def excluir_contato(contato_id: int, cliente_id: int) -> bool:
"""True se uma linha foi de fato excluída — ver atualizar_contato."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM CLIENTE_CONTATO WHERE ID = %s AND CLIENTE_ID = %s",
(contato_id, cliente_id),
)
return cur.rowcount > 0
finally:
conn.close()
+153
View File
@@ -0,0 +1,153 @@
"""
CRUD de FUNCIONARIO_PAINEL — contas individuais de funcionário para login
no painel (substitui/complementa o ADMIN_USER/ADMIN_PASSWORD único).
A tabela já existe no banco de dev. Em qualquer outro ambiente, crie-a
primeiro com:
CREATE TABLE FUNCIONARIO_PAINEL (
ID INT UNSIGNED NOT NULL AUTO_INCREMENT,
NOME VARCHAR(80) NOT NULL,
LOGIN VARCHAR(40) NOT NULL,
SENHA_HASH VARCHAR(255) NOT NULL,
PERFIL ENUM('ADMIN','PADRAO') NOT NULL DEFAULT 'ADMIN',
ATIVO TINYINT(1) NOT NULL DEFAULT 1,
CRIADO_EM DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (ID),
UNIQUE KEY UQ_FUNCIONARIO_PAINEL_LOGIN (LOGIN)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
Se a tabela já existir sem a coluna PERFIL (ambientes criados antes dessa
funcionalidade), rode:
ALTER TABLE FUNCIONARIO_PAINEL
ADD COLUMN PERFIL ENUM('ADMIN','PADRAO') NOT NULL DEFAULT 'ADMIN'
AFTER SENHA_HASH;
O DEFAULT 'ADMIN' do ALTER TABLE é intencional: todo funcionário cadastrado
antes dessa migração vira admin automaticamente (decisão do projeto — ver
docs/superpowers/specs/2026-08-13-permissoes-login-sessao-design.md).
Cadastros novos sempre informam PERFIL explicitamente (vindo do
formulário), nunca dependem desse default.
Nunca excluído de verdade — "excluir" aqui é alternar ATIVO para 0
(mesma lógica de nunca apagar histórico usada no resto do projeto).
"""
from .connection import get_connection
from ..senha import gerar_hash
def listar_funcionarios() -> list[dict]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT ID, NOME, LOGIN, PERFIL, ATIVO, CRIADO_EM FROM FUNCIONARIO_PAINEL ORDER BY NOME"
)
return cur.fetchall()
finally:
conn.close()
def obter_funcionario(funcionario_id: int) -> dict | None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT ID, NOME, LOGIN, PERFIL, ATIVO, CRIADO_EM FROM FUNCIONARIO_PAINEL WHERE ID = %s",
(funcionario_id,),
)
return cur.fetchone()
finally:
conn.close()
def obter_por_login(login: str) -> dict | None:
"""Usado pelo login (app/auth.py) — inclui SENHA_HASH, ao contrário das outras consultas."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT ID, NOME, LOGIN, SENHA_HASH, PERFIL, ATIVO FROM FUNCIONARIO_PAINEL WHERE LOGIN = %s",
(login,),
)
return cur.fetchone()
finally:
conn.close()
def login_em_uso(login: str, ignorar_id: int | None = None) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
sql = "SELECT ID FROM FUNCIONARIO_PAINEL WHERE LOGIN = %s"
params = [login]
if ignorar_id is not None:
sql += " AND ID != %s"
params.append(ignorar_id)
cur.execute(sql, params)
return cur.fetchone() is not None
finally:
conn.close()
def criar_funcionario(nome: str, login: str, senha: str, perfil: str) -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO FUNCIONARIO_PAINEL (NOME, LOGIN, SENHA_HASH, PERFIL, ATIVO) VALUES (%s, %s, %s, %s, 1)",
(nome, login, gerar_hash(senha), perfil),
)
return cur.lastrowid
finally:
conn.close()
def atualizar_funcionario(funcionario_id: int, nome: str, login: str, perfil: str) -> None:
"""Nome/login/perfil — troca de senha é redefinir_senha()."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE FUNCIONARIO_PAINEL SET NOME = %s, LOGIN = %s, PERFIL = %s WHERE ID = %s",
(nome, login, perfil, funcionario_id),
)
finally:
conn.close()
def redefinir_senha(funcionario_id: int, nova_senha: str) -> bool:
"""True se o ID realmente existia (cur.rowcount > 0) — sem essa
checagem um ID inexistente fazia um UPDATE que não afeta nenhuma
linha, sem erro nenhum do MySQL, e o chamador registrava "sucesso"
no log mesmo sem ter mudado a senha de ninguém."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE FUNCIONARIO_PAINEL SET SENHA_HASH = %s WHERE ID = %s",
(gerar_hash(nova_senha), funcionario_id),
)
return cur.rowcount > 0
finally:
conn.close()
def alternar_ativo(funcionario_id: int) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("UPDATE FUNCIONARIO_PAINEL SET ATIVO = 1 - ATIVO WHERE ID = %s", (funcionario_id,))
finally:
conn.close()
def contar_admins_ativos() -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) AS total FROM FUNCIONARIO_PAINEL WHERE PERFIL = 'ADMIN' AND ATIVO = 1")
return cur.fetchone()["total"]
finally:
conn.close()
+80
View File
@@ -0,0 +1,80 @@
"""
Log de ações do painel (LOG_PAINEL) — uma linha por ação que muda estado
(criar/editar/excluir cliente, adicionar/editar/excluir contato), sucesso
ou erro. Escrita feita pelos routers via `registrar(...)`, nunca
sobrescrita/editada depois — é histórico, não estado.
A tabela já existe no banco de dev. Em qualquer outro ambiente (produção),
crie-a primeiro com:
CREATE TABLE LOG_PAINEL (
ID INT UNSIGNED NOT NULL AUTO_INCREMENT,
CRIADO_EM DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
USUARIO VARCHAR(60) NOT NULL,
ACAO VARCHAR(40) NOT NULL,
ENTIDADE VARCHAR(20) NOT NULL,
ENTIDADE_ID VARCHAR(20) DEFAULT NULL,
SUCESSO TINYINT(1) NOT NULL,
DETALHE VARCHAR(500) DEFAULT NULL,
PRIMARY KEY (ID),
KEY IDX_LOG_PAINEL_CRIADO_EM (CRIADO_EM)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
"""
from .connection import get_connection
def registrar(
usuario: str,
acao: str,
entidade: str,
entidade_id: str | None,
sucesso: bool,
detalhe: str | None = None,
) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO LOG_PAINEL (USUARIO, ACAO, ENTIDADE, ENTIDADE_ID, SUCESSO, DETALHE) "
"VALUES (%s, %s, %s, %s, %s, %s)",
(usuario, acao, entidade, entidade_id, 1 if sucesso else 0, detalhe),
)
finally:
conn.close()
def listar(limite: int = 100, offset: int = 0) -> list[dict]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT ID, CRIADO_EM, USUARIO, ACAO, ENTIDADE, ENTIDADE_ID, SUCESSO, DETALHE "
"FROM LOG_PAINEL ORDER BY CRIADO_EM DESC, ID DESC LIMIT %s OFFSET %s",
(limite, offset),
)
return cur.fetchall()
finally:
conn.close()
def contar() -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) AS total FROM LOG_PAINEL")
return cur.fetchone()["total"]
finally:
conn.close()
def limpar() -> None:
"""Apaga todo o histórico de LOG_PAINEL. Quem chama (o router) grava
uma entrada nova logo depois disso, registrando quem limpou — nunca
chamar isso sem seguir com um registrar() depois, senão o rastro da
própria limpeza se perde."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM LOG_PAINEL")
finally:
conn.close()
+56
View File
@@ -0,0 +1,56 @@
"""
Algoritmo de geração de chave (reativação e instalação), reverso-projetado
do sistema legado (Delphi). Fica só aqui no backend — NUNCA deve voltar a
ser exposto em JavaScript de página, porque isso permite qualquer pessoa
com acesso ao painel extrair o algoritmo e gerar chaves fora do sistema.
Verificado byte-a-byte contra 3 exemplos reais de chave de instalação
(cliente 000001, ALUGUEL): byte inicial 0xDC -> "DC45092C",
0x2E -> "2E35083D", 0x3D -> "3D25090C" (ver testes).
"""
import secrets
CHAVE_INTERNA = (
"YUQL23KL23DF90WI5E1JAS467NMCXXL6JAOAUWWMCL0AOMM4A4VZYW9KHJUI2347"
"EJHJKDF3424SKL K3LAKDJSL9RTIKJ"
)
def _cifrar(texto: str, byte_inicial: int) -> list[int]:
"""Cifra por feedback de byte anterior + XOR com CHAVE_INTERNA rotativa.
Retorna a lista de bytes cifrados (sem o byte_inicial na frente)."""
anterior = byte_inicial
cifrados = []
for i, ch in enumerate(texto):
valor = (ord(ch) + anterior) % 255
cifrado = valor ^ ord(CHAVE_INTERNA[i % len(CHAVE_INTERNA)])
cifrados.append(cifrado)
anterior = cifrado
return cifrados
def gerar_reativacao(codigo_cliente: str, mes: int, ano: int, byte_inicial: int | None = None) -> str:
"""Chave de reativação — mês/cliente/ano cifrados por inteiro (22 caracteres hex)."""
cliente = str(codigo_cliente).zfill(6)
texto = str(mes).zfill(2) + cliente + str(ano)[-2:]
aleatorio = byte_inicial if byte_inicial is not None else secrets.randbelow(256)
cifrados = _cifrar(texto, aleatorio)
return "".join(f"{v:02X}" for v in [aleatorio, *cifrados])
def gerar_instalacao(codigo_cliente: str, modalidade: str, byte_inicial: int | None = None) -> str:
"""Chave de instalação — só a modalidade ("30"=ALUGUEL / "00"=COMPRA) é
cifrada; o valor derivado do código do cliente (cliente*19+31) entra sem
cifrar, no meio dos 6 caracteres cifrados (3 antes + 3 depois)."""
try:
cliente_num = int(str(codigo_cliente))
except ValueError:
cliente_num = 0
valor_cliente = cliente_num * 19 + 31
texto_modalidade = "30" if modalidade == "ALUGUEL" else "00"
aleatorio = byte_inicial if byte_inicial is not None else secrets.randbelow(256)
cifrados = _cifrar(texto_modalidade, aleatorio)
criptografado = "".join(f"{v:02X}" for v in [aleatorio, *cifrados])
parte1, parte2 = criptografado[0:3], criptografado[3:6]
return f"{parte1}{valor_cliente}{parte2}"
+66
View File
@@ -0,0 +1,66 @@
"""
Endpoint interno server-to-server, read-only, para o GTI Fiscal espelhar o
cadastro de clientes. GTI Gerenciador continua sendo a fonte única da
verdade: o Fiscal NUNCA recebe credencial MySQL direta (nem lê a tabela
CLIENTE por fora) — só chama esta rota, autenticada por um bearer token
dedicado (FISCAL_INTERNAL_API_TOKEN), nunca reaproveitado de SESSION_SECRET,
da chave privada do SSO, de senha de admin ou de token de Agent.
Rate limit em memória por IP de origem — mesmo espírito de app/rate_limit.py
(container único, sem load balancer; ver ali o aviso sobre proxy reverso).
"""
from __future__ import annotations
import hmac
import os
import time
from fastapi import APIRouter, Header, HTTPException, Request
from .db.clientes import listar_clientes_para_fiscal
router = APIRouter()
MAX_TENTATIVAS = 10
JANELA_SEGUNDOS = 5 * 60
_falhas: dict[str, list[float]] = {}
def _token_configurado() -> bool:
return bool(os.environ.get("FISCAL_INTERNAL_API_TOKEN"))
def _token_valido(fornecido: str | None) -> bool:
esperado = os.environ.get("FISCAL_INTERNAL_API_TOKEN", "")
if not esperado or not fornecido:
return False
return hmac.compare_digest(fornecido, esperado)
def _bloqueado(origem: str, agora: float | None = None) -> bool:
momento = agora if agora is not None else time.time()
limite = momento - JANELA_SEGUNDOS
return len([t for t in _falhas.get(origem, []) if t > limite]) >= MAX_TENTATIVAS
def _registrar_falha(origem: str, agora: float | None = None) -> None:
momento = agora if agora is not None else time.time()
limite = momento - JANELA_SEGUNDOS
recentes = [t for t in _falhas.get(origem, []) if t > limite]
recentes.append(momento)
_falhas[origem] = recentes
@router.get("/internal/fiscal/clients")
def listar_clientes_fiscal(request: Request, authorization: str | None = Header(default=None)):
origem = request.client.host if request.client else "unknown"
if _bloqueado(origem):
raise HTTPException(status_code=429, detail="muitas tentativas")
if not _token_configurado():
# Sem token configurado = integração desligada, nunca "aberto por padrão".
raise HTTPException(status_code=503, detail="FISCAL_INTERNAL_API_TOKEN não configurado")
fornecido = authorization.removeprefix("Bearer ").strip() if authorization and authorization.startswith("Bearer ") else None
if not _token_valido(fornecido):
_registrar_falha(origem)
raise HTTPException(status_code=401, detail="token inválido ou ausente")
return {"clients": listar_clientes_para_fiscal()}
+119
View File
@@ -0,0 +1,119 @@
"""
Painel de Clientes — monta os routers de CLIENTE, CLIENTE_CONTATO, o
gerador de chave manual, login/logoff, funcionários, minha conta e log.
Ver docs/superpowers/specs/2026-08-13-painel-clientes-web-design.md e
docs/superpowers/specs/2026-08-13-permissoes-login-sessao-design.md.
"""
from fastapi import Depends, FastAPI, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from . import session
from .asset_versao import ASSET_VERSAO
from .auth import NaoAutenticado, UsuarioLogado, exigir_login
from .csrf import CSRF_COOKIE_NAME, gerar_token
from . import internal_fiscal
from .routers import auth, chave_manual, clientes, contatos, funcionarios, log, minha_conta, sso
app = FastAPI(title="Painel de Clientes", docs_url=None, redoc_url=None, openapi_url=None)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router)
app.include_router(clientes.router)
app.include_router(contatos.router)
app.include_router(chave_manual.router)
app.include_router(funcionarios.router)
app.include_router(minha_conta.router)
app.include_router(log.router)
app.include_router(internal_fiscal.router)
app.include_router(sso.router)
@app.exception_handler(NaoAutenticado)
def _redirecionar_para_login(request: Request, exc: NaoAutenticado):
return RedirectResponse(url=f"/login?next={exc.caminho_original}", status_code=303)
@app.middleware("http")
async def csrf_cookie_middleware(request: Request, call_next):
"""
Garante que todo navegador que acessa o painel tenha um cookie
csrf_token (gerado uma vez, HttpOnly). Disponibiliza o valor em
request.state.csrf_token para os templates embutirem em formulários —
ver app/csrf.py para a validação do lado do POST.
"""
token_existente = request.cookies.get(CSRF_COOKIE_NAME)
precisa_definir_cookie = token_existente is None
request.state.csrf_token = token_existente or gerar_token()
response = await call_next(request)
if precisa_definir_cookie:
response.set_cookie(
CSRF_COOKIE_NAME, request.state.csrf_token, httponly=True, samesite="strict",
# Secure só quando a requisição já chegou em HTTPS — ver o
# comentário equivalente em app/routers/auth.py sobre o
# cookie de sessão (mesma razão, mesmo cuidado com proxy).
secure=request.url.scheme == "https",
)
return response
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
"""
Cabeçalhos de resposta que não dependem de nenhuma lógica de negócio,
só reduzem a superfície de ataque do navegador: impedem o painel de
ser carregado dentro de um <iframe> de outro site (clickjacking),
impedem o navegador de tentar "adivinhar" um Content-Type diferente
do declarado, e restringem de onde a página pode carregar recursos.
CSP liberado para 'self' e 'unsafe-inline': os templates usam <script>
inline (tema, sidebar, máscaras do formulário de cliente) sem nonce, e
não carregam nada de CDN externo — então 'unsafe-inline' aqui não abre
mão de proteção nenhuma que já não estivesse ausente antes (o objetivo
desta CSP é bloquear script/estilo/frame de origem EXTERNA, o vetor
mais comum de um XSS injetado virar efeito prático, ex.: exfiltrar
cookie pra outro domínio ou carregar um script de terceiro).
"""
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
# Uma rota pode definir a própria CSP, mais restrita (hoje só /sso/fiscal,
# que precisa de form-action para a origem do Fiscal); as demais recebem esta.
if "content-security-policy" not in response.headers:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
@app.middleware("http")
async def carregar_usuario_middleware(request: Request, call_next):
"""
Decodifica o cookie de sessão (se houver) e guarda o resultado em
request.state.usuario — só para os templates (base.html) decidirem o
que mostrar na navegação (ex: esconder "Funcionários" de quem não é
admin). NÃO é o mecanismo de proteção das rotas — isso continua sendo
Depends(exigir_login)/Depends(exigir_admin) em cada router.
"""
token = request.cookies.get(session.SESSION_COOKIE_NAME)
dados = session.ler_token(token)
request.state.usuario = (
UsuarioLogado(login=dados["login"], nome=dados["nome"], perfil=dados["perfil"]) if dados else None
)
request.state.asset_versao = ASSET_VERSAO
return await call_next(request)
@app.get("/")
def raiz(usuario: UsuarioLogado = Depends(exigir_login)):
return RedirectResponse(url="/clientes")
+52
View File
@@ -0,0 +1,52 @@
"""
Cliente HTTP mínimo para ler a Data Table "pagamentos_processados" do n8n —
o histórico de envios automáticos de chave de reativação via WhatsApp,
mantido pelo workflow "Pagamento Confirmado Asaas..." (ver
licenciamento-n8n/docs/). Cada linha é um pagamento processado, nunca
sobrescrita — é o histórico que o sistema legado nunca teve.
Requer N8N_API_URL e N8N_API_KEY no ambiente (uma API key gerada em
Configurações > API do n8n, com o escopo de leitura de linhas de Data
Table). Se não estiverem configuradas, ou se a chamada falhar por
qualquer motivo (rede, chave errada, endpoint diferente do esperado),
listar_envios() devolve [] — a tela de log mostra um aviso em vez de
quebrar. Este módulo NUNCA escreve na Data Table, só lê.
"""
import os
import httpx
DATA_TABLE_ID = "bJHhFUDwFJ30YJVt" # pagamentos_processados
def configurado() -> bool:
return bool(os.environ.get("N8N_API_URL")) and bool(os.environ.get("N8N_API_KEY"))
def listar_envios(limite: int = 50) -> list[dict]:
"""
Últimas linhas de pagamentos_processados, mais recentes primeiro.
Lista vazia se a integração não estiver configurada ou a chamada falhar.
"""
if not configurado():
return []
base = os.environ["N8N_API_URL"].rstrip("/")
url = f"{base}/api/v1/data-tables/{DATA_TABLE_ID}/rows"
try:
resposta = httpx.get(
url,
headers={"X-N8N-API-KEY": os.environ["N8N_API_KEY"], "accept": "application/json"},
params={"limit": limite, "sortBy": "data_processamento:desc"},
timeout=5.0,
)
resposta.raise_for_status()
corpo = resposta.json()
except (httpx.HTTPError, ValueError):
return []
if isinstance(corpo, dict):
return corpo.get("data") or []
if isinstance(corpo, list):
return corpo
return []
+119
View File
@@ -0,0 +1,119 @@
"""
Funções de normalização e validação de documento (CPF/CNPJ) e telefone.
Formato de CNPJ alfanumérico vigente desde 31/07/2026 (Instrução Normativa
RFB nº 2.229/2024, Nota Técnica COCAD/SUARA/RFB nº 49/2024): 14 posições,
sendo as 12 primeiras alfanuméricas (0-9, A-Z — raiz + filial) e as 2
últimas numéricas (dígitos verificadores, módulo 11 sobre valor ASCII − 48
de cada caractere). CNPJs numéricos antigos continuam válidos e geram o
mesmo DV pela fórmula nova (retrocompatível).
`normalizar_documento` valida só o FORMATO (tamanho/charset) — é o mesmo
contrato já usado no workflow n8n (node "05 - Normalizar CNPJ") e não deve
divergir dele. `validar_dv_cpf`/`validar_dv_cnpj` são checagens ADICIONAIS
de dígito verificador, usadas pela UI como aviso (não bloqueio) — o legado
aceita documentos com DV matematicamente inválido.
"""
import re
_RE_NAO_ALFANUM = re.compile(r"[^0-9A-Z]")
_RE_CPF = re.compile(r"^[0-9]{11}$")
_RE_CNPJ = re.compile(r"^[0-9A-Z]{12}[0-9]{2}$")
_RE_NAO_DIGITO = re.compile(r"[^0-9]")
def normalizar_documento(bruto: str | None) -> tuple[str, bool, str | None]:
"""
Normaliza CPF/CNPJ: maiúsculas, só dígitos e letras A-Z (remove pontos,
barras, hífens, espaços).
CPF: 11 dígitos numéricos.
CNPJ: 12 caracteres alfanuméricos (0-9, A-Z) + 2 dígitos verificadores
numéricos.
Retorna (documento_normalizado, formato_valido, tipo) onde tipo é "CPF",
"CNPJ" ou None se inválido.
"""
limpo = _RE_NAO_ALFANUM.sub("", (bruto or "").upper())
eh_cpf = bool(_RE_CPF.match(limpo))
eh_cnpj = bool(_RE_CNPJ.match(limpo))
tipo = "CPF" if eh_cpf else ("CNPJ" if eh_cnpj else None)
return limpo, (eh_cpf or eh_cnpj), tipo
def normalizar_telefone(bruto: str | None) -> str:
"""Remove tudo que não for dígito (espaços, parênteses, traços, "+")."""
return _RE_NAO_DIGITO.sub("", bruto or "")
_RE_NAO_LETRA = re.compile(r"[^A-Z]")
def normalizar_telefone_com_ddi(bruto: str | None) -> str:
"""
Remove máscara (normalizar_telefone) e garante o DDI 55 embutido no
início — usado para TELEFONE/CELULAR de CLIENTE, que passam a ser
gravados sempre com DDI, mesmo que o usuário não digite (mesmo padrão
que CLIENTE_CONTATO.TELEFONE já usa para envio de WhatsApp).
Um valor que já começa com "55" é tratado como já tendo DDI (não
duplica), independente do tamanho — garante idempotência mesmo em
round-trips (ex.: reenvio do formulário já normalizado). Qualquer
outro valor não vazio recebe "55" na frente. Vazio continua vazio
(campo não obrigatório).
"""
digitos = normalizar_telefone(bruto)
if not digitos:
return ""
if digitos.startswith("55"):
return digitos
return "55" + digitos
def normalizar_uf(bruto: str | None) -> str:
"""Só letras, maiúsculas, no máximo 2 caracteres."""
limpo = _RE_NAO_LETRA.sub("", (bruto or "").upper())
return limpo[:2]
def normalizar_cep(bruto: str | None) -> str:
"""Só dígitos, no máximo 8 caracteres (sem traço)."""
return _RE_NAO_DIGITO.sub("", bruto or "")[:8]
_PESOS_CPF_DV1 = [10, 9, 8, 7, 6, 5, 4, 3, 2]
_PESOS_CPF_DV2 = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2]
_PESOS_CNPJ_DV1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
_PESOS_CNPJ_DV2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
def _digito_verificador(valores: list[int], pesos: list[int]) -> int:
soma = sum(v * p for v, p in zip(valores, pesos))
resto = soma % 11
return 0 if resto < 2 else 11 - resto
def validar_dv_cpf(cpf_normalizado: str) -> bool:
"""Confere os 2 dígitos verificadores de um CPF já normalizado (11 dígitos)."""
digitos = [int(c) for c in cpf_normalizado]
dv1 = _digito_verificador(digitos[:9], _PESOS_CPF_DV1)
dv2 = _digito_verificador(digitos[:9] + [dv1], _PESOS_CPF_DV2)
return digitos[9] == dv1 and digitos[10] == dv2
def _valor_caractere(c: str) -> int:
"""Valor ASCII − 48: dígitos 0-9 valem 0-9, letras A-Z valem 17-42."""
return ord(c) - 48
def validar_dv_cnpj(cnpj_normalizado: str) -> bool:
"""
Confere os 2 dígitos verificadores de um CNPJ já normalizado (12
alfanuméricos + 2 numéricos). Funciona tanto para CNPJ numérico antigo
quanto para o novo formato alfanumérico (mesma fórmula, retrocompatível).
"""
base = [_valor_caractere(c) for c in cnpj_normalizado[:12]]
dv_informado = [int(c) for c in cnpj_normalizado[12:14]]
dv1 = _digito_verificador(base, _PESOS_CNPJ_DV1)
dv2 = _digito_verificador(base + [dv1], _PESOS_CNPJ_DV2)
return dv_informado[0] == dv1 and dv_informado[1] == dv2
+52
View File
@@ -0,0 +1,52 @@
"""
Limitador de tentativas de login por IP de origem — proteção simples
contra força bruta de senha em POST /login (app/routers/auth.py), que é
uma rota pré-autenticação e portanto alcançável por qualquer visitante.
Em memória (dict por processo), no mesmo espírito de app/session.py (chave
de assinatura em memória) e app/db/connection.py (sem pool): este painel
roda como container único, sem múltiplas réplicas atrás de um load
balancer, então não precisa de armazenamento compartilhado (Redis etc.)
para isso funcionar. Reinício do processo zera o histórico — efeito
colateral aceito, mesma lógica do SESSION_SECRET em memória.
Se um dia o painel passar a rodar atrás de um proxy reverso, a origem
(hoje request.client.host, lida em app/routers/auth.py) precisa passar a
vir de X-Forwarded-For validado contra uma lista de proxies confiáveis —
sem isso, toda tentativa apareceria vindo do IP do proxy e o limite
passaria a valer para todo mundo junto (um IP externo malicioso poderia
bloquear logins de todo mundo).
"""
import time
MAX_TENTATIVAS = 5
JANELA_SEGUNDOS = 5 * 60 # 5 minutos
_falhas: dict[str, list[float]] = {}
def _recentes(origem: str, momento: float) -> list[float]:
limite = momento - JANELA_SEGUNDOS
return [t for t in _falhas.get(origem, []) if t > limite]
def registrar_falha(origem: str, agora: float | None = None) -> None:
"""Chamado a cada tentativa de login com credencial inválida."""
momento = agora if agora is not None else time.time()
recentes = _recentes(origem, momento)
recentes.append(momento)
_falhas[origem] = recentes
def bloqueado(origem: str, agora: float | None = None) -> bool:
"""True quando essa origem já teve MAX_TENTATIVAS falhas nos últimos
JANELA_SEGUNDOS — POST /login deve recusar antes mesmo de checar a
credencial enviada."""
momento = agora if agora is not None else time.time()
return len(_recentes(origem, momento)) >= MAX_TENTATIVAS
def limpar(origem: str) -> None:
"""Chamado após um login bem-sucedido — não faz sentido continuar
contando falhas antigas contra quem acabou de provar a senha certa."""
_falhas.pop(origem, None)
+1
View File
@@ -0,0 +1 @@
# app/routers/__init__.py
+100
View File
@@ -0,0 +1,100 @@
"""
Rotas de login/logoff. O painel não usa mais HTTP Basic Auth (ver
app/auth.py) — quem não está logado é redirecionado para GET /login pelo
exception handler de NaoAutenticado (registrado em app/main.py).
O cookie de sessão só sai com o atributo Secure quando a requisição já
chegou em HTTPS (request.url.scheme) — hoje a produção ainda serve só
HTTP puro (sem proxy reverso com TLS na frente), então forçar Secure
sempre quebraria o login (o navegador nunca devolveria o cookie). Assim
que um TLS for colocado na frente, o cookie passa a sair Secure
automaticamente, sem precisar mexer aqui de novo. Se um proxy reverso
for adicionado, ele precisa repassar o esquema original (cabeçalho
X-Forwarded-Proto + ProxyHeadersMiddleware/--proxy-headers do uvicorn),
senão request.url.scheme sempre lê "http" mesmo com o navegador em HTTPS.
"""
from typing import Optional
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import rate_limit, session
from ..auth import UsuarioLogado, autenticar_credenciais, exigir_login
from ..csrf import exigir_csrf, token_do_contexto
from .clientes import POR_PAGINA_COOKIE
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _origem(request: Request) -> str:
"""IP de quem fez a requisição, usado como chave do limitador de
tentativas (app/rate_limit.py). "desconhecido" só ocorre em contexto
de teste sem client configurado — nesse caso todas as tentativas sem
IP caem na mesma chave, o que é aceitável (ambiente de teste, não
produção)."""
return request.client.host if request.client else "desconhecido"
def _proximo_seguro(next_: Optional[str]) -> str:
"""Só aceita um path interno (começa com "/", sem "//") — nunca uma URL
externa, para não virar um open redirect via POST /login?next=..."""
if next_ and next_.startswith("/") and not next_.startswith(("//", "/\\")):
return next_
return "/clientes"
@router.get("/login", response_class=HTMLResponse)
def form_login(request: Request, next: Optional[str] = None):
return templates.TemplateResponse(
request, "login.html", {"csrf_token": token_do_contexto(request), "next": next or ""}
)
@router.post("/login", dependencies=[Depends(exigir_csrf)])
def login(
request: Request,
usuario: str = Form(...),
senha: str = Form(...),
next: Optional[str] = Form(None),
):
origem = _origem(request)
if rate_limit.bloqueado(origem):
return templates.TemplateResponse(
request,
"login.html",
{
"csrf_token": token_do_contexto(request), "next": next or "",
"erro": "Muitas tentativas de login. Aguarde alguns minutos e tente novamente.",
},
status_code=429,
)
usuario_logado = autenticar_credenciais(usuario, senha)
if usuario_logado is None:
rate_limit.registrar_falha(origem)
return templates.TemplateResponse(
request,
"login.html",
{"csrf_token": token_do_contexto(request), "next": next or "", "erro": "Usuário ou senha inválidos."},
)
rate_limit.limpar(origem)
destino = _proximo_seguro(next)
resposta = RedirectResponse(url=destino, status_code=303)
token = session.criar_token(usuario_logado.login, usuario_logado.nome, usuario_logado.perfil)
resposta.set_cookie(
session.SESSION_COOKIE_NAME, token, httponly=True, samesite="lax",
secure=request.url.scheme == "https",
)
return resposta
@router.post("/logout", dependencies=[Depends(exigir_csrf)])
def logout(usuario: UsuarioLogado = Depends(exigir_login)):
resposta = RedirectResponse(url="/login", status_code=303)
resposta.delete_cookie(session.SESSION_COOKIE_NAME)
resposta.delete_cookie(POR_PAGINA_COOKIE)
return resposta
+60
View File
@@ -0,0 +1,60 @@
"""
Calculadora de chave de reativação/instalação, para uso manual (reenvio/
correção pontual). O algoritmo em si (app/gerador_chave.py) roda só aqui
no backend — a página não recebe mais nada além do resultado já pronto,
pra ninguém conseguir extrair o algoritmo pelo código-fonte da página.
NUNCA grava em Clientes_Chaves.
"""
import re
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from ..auth import exigir_login
from ..csrf import exigir_csrf, token_do_contexto
from ..gerador_chave import gerar_instalacao, gerar_reativacao
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
_RE_CODIGO = re.compile(r"^[0-9]{1,6}$")
_RE_REFERENCIA = re.compile(r"^\d{4}-\d{2}$")
@router.get("/gerador-chave", response_class=HTMLResponse)
def pagina(request: Request, usuario: str = Depends(exigir_login)):
return templates.TemplateResponse(request, "gerador_chave.html", {"csrf_token": token_do_contexto(request)})
@router.post("/gerador-chave/gerar", dependencies=[Depends(exigir_csrf)])
def gerar(
tipo: str = Form(...),
codigo: str = Form(...),
referencia: str = Form(""),
modalidade: str = Form(""),
usuario: str = Depends(exigir_login),
):
codigo = codigo.strip()
if not _RE_CODIGO.match(codigo):
return JSONResponse({"erro": "Código do cliente deve ter até 6 dígitos numéricos."}, status_code=400)
codigo_formatado = codigo.zfill(6)
if tipo == "reativacao":
if not _RE_REFERENCIA.match(referencia):
return JSONResponse({"erro": "Selecione o mês/ano de referência."}, status_code=400)
ano_str, mes_str = referencia.split("-")
mes, ano = int(mes_str), int(ano_str)
if not (1 <= mes <= 12):
return JSONResponse({"erro": "Mês fora do intervalo 1-12."}, status_code=400)
if not (2000 <= ano <= 2100):
return JSONResponse({"erro": "Ano inválido (use entre 2000 e 2100)."}, status_code=400)
chave = gerar_reativacao(codigo_formatado, mes, ano)
elif tipo == "instalacao":
if modalidade not in ("ALUGUEL", "COMPRA"):
return JSONResponse({"erro": "Modalidade inválida."}, status_code=400)
chave = gerar_instalacao(codigo_formatado, modalidade)
else:
return JSONResponse({"erro": "Tipo de chave inválido."}, status_code=400)
return JSONResponse({"chave": chave})
+425
View File
@@ -0,0 +1,425 @@
# app/routers/clientes.py
"""
Rotas de gestão de CLIENTE: listar/buscar/ordenar (paginado), criar,
editar, ver detalhe (contatos + status de Clientes_Chaves), excluir.
"""
import datetime
import math
from typing import Optional
from urllib.parse import urlencode
import pymysql
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import cnpj_lookup
from ..auth import exigir_login
from ..csrf import exigir_csrf, token_do_contexto
from ..db import chaves as db_chaves
from ..db import clientes as db_clientes
from ..db import contatos as db_contatos
from ..db import log as db_log
from ..normalize import (
normalizar_documento,
normalizar_telefone_com_ddi,
normalizar_uf,
normalizar_cep,
validar_dv_cpf,
validar_dv_cnpj,
)
router = APIRouter(prefix="/clientes")
templates = Jinja2Templates(directory="app/templates")
OPCOES_POR_PAGINA = [5, 10, 15, 20, 30, 40, 50, 100]
POR_PAGINA_PADRAO = 15
POR_PAGINA_COOKIE = "clientes_por_pagina"
# Únicos campos que o formulário exige preenchidos — os demais (contrato,
# valores, datas) podem ficar em branco e recebem um default sensato
# (ver _aplicar_defaults_opcionais) em vez de bloquear o cadastro.
CAMPOS_OBRIGATORIOS = ["CODIGO", "CNPJ", "RAZAO", "FANTASIA"]
_CAMPOS_DECIMAIS_OPCIONAIS = ["IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA"]
_CAMPOS_DATA_OPCIONAIS = ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"]
FILTROS_STATUS_VALIDOS = {"ativos": 1, "inativos": 0, "todos": None}
STATUS_PADRAO = "ativos"
def _redirect_com_mensagem(url: str, mensagem: str, tipo: str = "success") -> RedirectResponse:
separador = "&" if "?" in url else "?"
return RedirectResponse(url + separador + urlencode({"mensagem": mensagem, "mensagem_tipo": tipo}), status_code=303)
def _ler_form_cliente(form: dict) -> dict:
return {campo: (form.get(campo) or "").strip() for campo in db_clientes.CAMPOS_CLIENTE}
def _normalizar_documento_e_telefones(dados: dict) -> None:
"""
Grava CNPJ/CPF sempre sem máscara, TELEFONE/CELULAR sempre com DDI 55
embutido, UF em maiúsculas (2 letras) e CEP só com dígitos — evita que
CLIENTE fique com formatos inconsistentes convivendo. Só afeta
escritas novas feitas por este painel; registros legados só passam a
ficar normalizados quando forem reeditados e salvos aqui.
"""
dados["CNPJ"] = normalizar_documento(dados["CNPJ"])[0]
dados["TELEFONE"] = normalizar_telefone_com_ddi(dados["TELEFONE"])
dados["CELULAR"] = normalizar_telefone_com_ddi(dados["CELULAR"])
dados["UF"] = normalizar_uf(dados["UF"])
dados["CEP"] = normalizar_cep(dados["CEP"])
def _aplicar_defaults_opcionais(dados: dict) -> None:
"""
IMPLANTACAO/MENSALIDADE/JUROS_MORA/JUROS_MULTA/DATA_CONTRATO/
VENCIMENTO_CONTRATO não são mais exigidos no formulário — se vierem em
branco, gravamos um valor neutro (0,00 / data de hoje) em vez de
bloquear o cadastro. Quem cadastra pode ajustar depois.
"""
for campo in _CAMPOS_DECIMAIS_OPCIONAIS:
if not dados.get(campo):
dados[campo] = "0.00"
hoje = datetime.date.today().isoformat()
for campo in _CAMPOS_DATA_OPCIONAIS:
if not dados.get(campo):
dados[campo] = hoje
def _campos_obrigatorios_faltando(dados: dict) -> list[str]:
return [campo for campo in CAMPOS_OBRIGATORIOS if not dados.get(campo)]
def _resolver_por_pagina(por_pagina_query: int | None, cookie_valor: str | None) -> int:
"""
Decide o tamanho de página efetivo: query string (se for um dos
valores de OPCOES_POR_PAGINA) -> cookie clientes_por_pagina (se for um
valor válido) -> padrão (15). Nunca deixa passar um valor fora de
OPCOES_POR_PAGINA, mesmo que venha adulterado na URL ou no cookie.
"""
if por_pagina_query in OPCOES_POR_PAGINA:
return por_pagina_query
try:
cookie_int = int(cookie_valor) if cookie_valor is not None else None
except ValueError:
cookie_int = None
if cookie_int in OPCOES_POR_PAGINA:
return cookie_int
return POR_PAGINA_PADRAO
def _janela_paginas(pagina: int, total_paginas: int) -> list[int | None]:
"""
Lista de números de página a mostrar na paginação, com None
representando uma reticência ("…"). Até 7 páginas, mostra todas. Acima
disso, mostra a primeira, a última, a página atual e uma vizinha de
cada lado, preenchendo os buracos com None. Ex.: página 5 de 20 ->
[1, None, 4, 5, 6, None, 20].
"""
if total_paginas <= 7:
return list(range(1, total_paginas + 1))
nucleo = {1, total_paginas, pagina}
for vizinha in (pagina - 1, pagina + 1):
if 1 <= vizinha <= total_paginas:
nucleo.add(vizinha)
ordenado = sorted(nucleo)
resultado: list[int | None] = []
anterior = None
for pag in ordenado:
if anterior is not None and pag - anterior > 1:
resultado.append(None)
resultado.append(pag)
anterior = pag
return resultado
def _validar_documento(dados: dict, confirmar_dv: bool) -> dict:
"""
Confere o dígito verificador do documento em CLIENTE.CNPJ (o mesmo
campo aceita CPF ou CNPJ, ver app/normalize.py). CNPJ com DV inválido
bloqueia sempre — não existe "cadastrar mesmo assim" para CNPJ. CPF
com DV inválido mantém o fluxo já existente: aviso + confirmação.
Retorna um dict para mesclar no contexto do template: {} se o
documento está ok (ou é indeterminado), ou uma das chaves de erro que
cliente_form.html já sabe renderizar.
"""
documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"])
if not formato_valido:
return {}
if tipo == "CNPJ":
if not validar_dv_cnpj(documento_normalizado):
return {"erro_cnpj_invalido": True}
return {}
if not validar_dv_cpf(documento_normalizado) and not confirmar_dv:
return {"aviso_dv_invalido": True}
return {}
@router.get("", response_class=HTMLResponse)
def listar(
request: Request,
busca: Optional[str] = None,
ordenar_por: str = db_clientes.ORDENACAO_PADRAO,
direcao: str = db_clientes.DIRECAO_PADRAO,
pagina: int = 1,
por_pagina: Optional[int] = None,
status: str = STATUS_PADRAO,
mensagem: Optional[str] = None,
mensagem_tipo: Optional[str] = None,
usuario: str = Depends(exigir_login),
):
if ordenar_por not in db_clientes.ORDENACOES_VALIDAS:
ordenar_por = db_clientes.ORDENACAO_PADRAO
if direcao not in db_clientes.DIRECOES_VALIDAS:
direcao = db_clientes.DIRECAO_PADRAO
if status not in FILTROS_STATUS_VALIDOS:
status = STATUS_PADRAO
filtro_ativo = FILTROS_STATUS_VALIDOS[status]
por_pagina_efetivo = _resolver_por_pagina(por_pagina, request.cookies.get(POR_PAGINA_COOKIE))
pagina = max(pagina, 1)
total = db_clientes.contar_clientes(busca=busca or None, ordenar_por=ordenar_por, filtro_ativo=filtro_ativo)
total_paginas = max(math.ceil(total / por_pagina_efetivo), 1)
pagina = min(pagina, total_paginas)
offset = (pagina - 1) * por_pagina_efetivo
clientes = db_clientes.listar_clientes(
busca=busca or None, limite=por_pagina_efetivo, offset=offset, ordenar_por=ordenar_por,
direcao=direcao, filtro_ativo=filtro_ativo,
)
primeiro_item = offset + 1 if total > 0 else 0
ultimo_item = min(offset + por_pagina_efetivo, total)
redirecionar_para = request.url.path + (f"?{request.url.query}" if request.url.query else "")
resposta = templates.TemplateResponse(
request,
"clientes_lista.html",
{
"clientes": clientes, "busca": busca, "ordenar_por": ordenar_por, "direcao": direcao,
"mensagem": mensagem, "mensagem_tipo": mensagem_tipo, "status": status,
"pagina": pagina, "total_paginas": total_paginas, "total": total,
"por_pagina": por_pagina_efetivo, "opcoes_por_pagina": OPCOES_POR_PAGINA,
"primeiro_item": primeiro_item, "ultimo_item": ultimo_item,
"janela_paginas": _janela_paginas(pagina, total_paginas),
"csrf_token": token_do_contexto(request),
"redirecionar_para": redirecionar_para,
},
)
if por_pagina in OPCOES_POR_PAGINA:
# Só regrava o cookie quando a query string trouxer um valor válido
# (não um valor herdado do cookie que já estava lá) — evita ficar
# reescrevendo o mesmo Set-Cookie em toda requisição à toa.
resposta.set_cookie(
POR_PAGINA_COOKIE, str(por_pagina), samesite="lax",
secure=request.url.scheme == "https", httponly=True,
)
return resposta
@router.get("/novo", response_class=HTMLResponse)
def form_novo(request: Request, usuario: str = Depends(exigir_login)):
dados = {c: "" for c in db_clientes.CAMPOS_CLIENTE}
dados["CODIGO"] = db_clientes.proximo_codigo_sugerido()
dados["ATIVO"] = "1"
return templates.TemplateResponse(
request,
"cliente_form.html",
{
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"campos_obrigatorios": CAMPOS_OBRIGATORIOS, "csrf_token": token_do_contexto(request),
},
)
@router.post("/novo", dependencies=[Depends(exigir_csrf)])
async def criar(request: Request, usuario: str = Depends(exigir_login)):
form = dict((await request.form()))
dados = _ler_form_cliente(form)
_normalizar_documento_e_telefones(dados)
confirmar_dv = form.get("confirmar_dv_invalido") == "1"
contexto_base = {
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"campos_obrigatorios": CAMPOS_OBRIGATORIOS, "csrf_token": token_do_contexto(request),
}
faltando = _campos_obrigatorios_faltando(dados)
if faltando:
return templates.TemplateResponse(
request, "cliente_form.html", {**contexto_base, "erro_campos_obrigatorios": faltando}
)
if db_clientes.codigo_em_uso(dados["CODIGO"]):
db_log.registrar(usuario, "criar", "cliente", dados["CODIGO"], False, "código já em uso")
return templates.TemplateResponse(
request, "cliente_form.html", {**contexto_base, "erro_codigo_duplicado": True}
)
if dados["CNPJ"] and db_clientes.cnpj_em_uso(dados["CNPJ"]):
db_log.registrar(usuario, "criar", "cliente", dados["CODIGO"], False, "CNPJ/CPF já cadastrado")
return templates.TemplateResponse(
request, "cliente_form.html", {**contexto_base, "erro_cnpj_duplicado": True}
)
extra_erro = _validar_documento(dados, confirmar_dv)
if extra_erro:
return templates.TemplateResponse(request, "cliente_form.html", {**contexto_base, **extra_erro})
_aplicar_defaults_opcionais(dados)
try:
novo_id = db_clientes.criar_cliente(dados)
except (pymysql.err.DataError, pymysql.err.IntegrityError) as exc:
db_log.registrar(usuario, "criar", "cliente", dados["CODIGO"], False, str(exc))
return templates.TemplateResponse(
request, "cliente_form.html", {**contexto_base, "dados": dados, "erro_gravacao": str(exc)}
)
db_log.registrar(usuario, "criar", "cliente", dados["CODIGO"], True)
return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.")
@router.get("/consulta-cnpj/{documento}")
def consulta_cnpj(documento: str, usuario: str = Depends(exigir_login)):
"""
Usado via JS pelo formulário de cliente (ver cliente_form.html) para
preencher automaticamente Razão/Fantasia/Endereço/etc. ao digitar um
CNPJ. Só consulta para CNPJ (nunca CPF — dado pessoal, sem consulta
pública). Ver app/cnpj_lookup.py para a fonte (BrasilAPI).
"""
documento_normalizado, formato_valido, tipo = normalizar_documento(documento)
if not formato_valido or tipo != "CNPJ":
return JSONResponse({"encontrado": False}, status_code=404)
dados = cnpj_lookup.consultar_cnpj(documento_normalizado)
if dados is None:
return JSONResponse({"encontrado": False}, status_code=404)
return {"encontrado": True, "dados": dados}
@router.get("/{cliente_id}", response_class=HTMLResponse)
def detalhe(
request: Request,
cliente_id: int,
mensagem: Optional[str] = None,
mensagem_tipo: Optional[str] = None,
usuario: str = Depends(exigir_login),
):
cliente = db_clientes.obter_cliente(cliente_id)
if cliente is None:
return _redirect_com_mensagem("/clientes", "Cliente não encontrado.", "danger")
contatos = db_contatos.listar_contatos_por_cliente(cliente_id)
status = db_chaves.status_chaves(cliente["CODIGO"])
return templates.TemplateResponse(
request,
"cliente_detalhe.html",
{
"cliente": cliente, "contatos": contatos, "status_chaves": status,
"mensagem": mensagem, "mensagem_tipo": mensagem_tipo,
"csrf_token": token_do_contexto(request),
},
)
@router.get("/{cliente_id}/editar", response_class=HTMLResponse)
def form_editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)):
cliente = db_clientes.obter_cliente(cliente_id)
if cliente is None:
return _redirect_com_mensagem("/clientes", "Cliente não encontrado.", "danger")
return templates.TemplateResponse(
request,
"cliente_form.html",
{
"cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE,
"campos_obrigatorios": CAMPOS_OBRIGATORIOS, "csrf_token": token_do_contexto(request),
},
)
@router.post("/{cliente_id}/editar", dependencies=[Depends(exigir_csrf)])
async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)):
if db_clientes.obter_cliente(cliente_id) is None:
# Sem essa checagem, um cliente_id inexistente (ex.: URL editada à
# mão, ou um duplo submit depois de já ter sido excluído em outra
# aba) caía direto no UPDATE — que não afeta nenhuma linha, não
# levanta erro nenhum do MySQL, e ainda assim registrava "editar
# ... sucesso" no log antes de redirecionar pra uma página de
# detalhe que também vai dizer "cliente não encontrado".
return _redirect_com_mensagem("/clientes", "Cliente não encontrado.", "danger")
form = dict((await request.form()))
dados = _ler_form_cliente(form)
_normalizar_documento_e_telefones(dados)
confirmar_dv = form.get("confirmar_dv_invalido") == "1"
contexto_base = {
"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"campos_obrigatorios": CAMPOS_OBRIGATORIOS, "csrf_token": token_do_contexto(request),
}
faltando = _campos_obrigatorios_faltando(dados)
if faltando:
return templates.TemplateResponse(
request, "cliente_form.html", {**contexto_base, "erro_campos_obrigatorios": faltando}
)
if db_clientes.codigo_em_uso(dados["CODIGO"], ignorar_id=cliente_id):
db_log.registrar(usuario, "editar", "cliente", str(cliente_id), False, "código já em uso")
return templates.TemplateResponse(
request, "cliente_form.html", {**contexto_base, "erro_codigo_duplicado": True}
)
if dados["CNPJ"] and db_clientes.cnpj_em_uso(dados["CNPJ"], ignorar_id=cliente_id):
db_log.registrar(usuario, "editar", "cliente", str(cliente_id), False, "CNPJ/CPF já cadastrado")
return templates.TemplateResponse(
request, "cliente_form.html", {**contexto_base, "erro_cnpj_duplicado": True}
)
extra_erro = _validar_documento(dados, confirmar_dv)
if extra_erro:
return templates.TemplateResponse(request, "cliente_form.html", {**contexto_base, **extra_erro})
_aplicar_defaults_opcionais(dados)
try:
db_clientes.atualizar_cliente(cliente_id, dados)
except (pymysql.err.DataError, pymysql.err.IntegrityError) as exc:
db_log.registrar(usuario, "editar", "cliente", str(cliente_id), False, str(exc))
return templates.TemplateResponse(
request, "cliente_form.html", {**contexto_base, "dados": dados, "erro_gravacao": str(exc)}
)
db_log.registrar(usuario, "editar", "cliente", str(cliente_id), True)
return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.")
@router.post("/{cliente_id}/excluir", dependencies=[Depends(exigir_csrf)])
def excluir(cliente_id: int, usuario: str = Depends(exigir_login)):
try:
excluido = db_clientes.excluir_cliente(cliente_id)
except db_clientes.ClienteComDependenciasError as exc:
db_log.registrar(usuario, "excluir", "cliente", str(cliente_id), False, str(exc))
return _redirect_com_mensagem(f"/clientes/{cliente_id}", str(exc), "danger")
if not excluido:
db_log.registrar(usuario, "excluir", "cliente", str(cliente_id), False, "cliente não encontrado")
return _redirect_com_mensagem("/clientes", "Cliente não encontrado.", "danger")
db_log.registrar(usuario, "excluir", "cliente", str(cliente_id), True)
return _redirect_com_mensagem("/clientes", "Cliente excluído.")
@router.post("/{cliente_id}/alternar-ativo", dependencies=[Depends(exigir_csrf)])
def alternar_ativo(cliente_id: int, redirecionar_para: str = Form(""), usuario: str = Depends(exigir_login)):
destino = redirecionar_para if redirecionar_para.startswith("/clientes") else "/clientes"
alternado = db_clientes.alternar_ativo(cliente_id)
if not alternado:
db_log.registrar(usuario, "alternar_ativo", "cliente", str(cliente_id), False, "cliente não encontrado")
return _redirect_com_mensagem(destino, "Cliente não encontrado.", "danger")
db_log.registrar(usuario, "alternar_ativo", "cliente", str(cliente_id), True)
return _redirect_com_mensagem(destino, "Status do cliente atualizado.")
+98
View File
@@ -0,0 +1,98 @@
"""
Rotas de gestão de CLIENTE_CONTATO — sempre no contexto de um cliente
(cliente_id na URL). Redireciona de volta para /clientes/{cliente_id}
(a tela de detalhe do cliente, montada na Task 8).
TELEFONE é gravado sempre com o DDI 55 embutido (normalizar_telefone_com_ddi,
mesma normalização usada em CLIENTE.TELEFONE/CELULAR — ver app/normalize.py)
porque é esse valor que o workflow de envio automático de chave por
WhatsApp lê direto de CLIENTE_CONTATO.TELEFONE, sem nenhuma normalização
própria (node "10 - Validar Telefones" do workflow n8n só faz
`String(telefone)`, sem adicionar DDI). Um contato cadastrado aqui sem o
55 embutido nunca recebe a chave automaticamente — silencioso, sem erro
visível no painel.
"""
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form
from fastapi.responses import RedirectResponse
from ..auth import exigir_login
from ..csrf import exigir_csrf
from ..db import clientes as db_clientes
from ..db import contatos as db_contatos
from ..db import log as db_log
from ..normalize import normalizar_telefone_com_ddi
router = APIRouter(prefix="/clientes")
def _redirect_com_mensagem(cliente_id: int, mensagem: str, tipo: str = "success") -> RedirectResponse:
query = urlencode({"mensagem": mensagem, "mensagem_tipo": tipo})
return RedirectResponse(f"/clientes/{cliente_id}?{query}", status_code=303)
@router.post("/{cliente_id}/contatos", dependencies=[Depends(exigir_csrf)])
def adicionar(
cliente_id: int,
nome: str = Form(""),
telefone: str = Form(...),
email: str = Form(""),
usuario: str = Depends(exigir_login),
):
if db_clientes.obter_cliente(cliente_id) is None:
return _redirect_com_mensagem(cliente_id, "Cliente não encontrado.", "danger")
telefone_normalizado = normalizar_telefone_com_ddi(telefone)
if not telefone_normalizado:
return _redirect_com_mensagem(cliente_id, "Telefone obrigatório.", "danger")
if db_contatos.contato_duplicado(cliente_id, telefone_normalizado):
db_log.registrar(usuario, "adicionar", "contato", str(cliente_id), False, "telefone duplicado")
return _redirect_com_mensagem(cliente_id, f"O telefone {telefone_normalizado} já está cadastrado para este cliente.", "warning")
db_contatos.inserir_contato(cliente_id, nome.strip() or None, telefone_normalizado, email.strip() or None)
db_log.registrar(usuario, "adicionar", "contato", str(cliente_id), True)
return _redirect_com_mensagem(cliente_id, "Contato adicionado.")
@router.post("/{cliente_id}/contatos/{contato_id}/editar", dependencies=[Depends(exigir_csrf)])
def editar(
cliente_id: int,
contato_id: int,
nome: str = Form(""),
telefone: str = Form(...),
email: str = Form(""),
usuario: str = Depends(exigir_login),
):
telefone_normalizado = normalizar_telefone_com_ddi(telefone)
if not telefone_normalizado:
return _redirect_com_mensagem(cliente_id, "Telefone obrigatório.", "danger")
if db_contatos.contato_duplicado(cliente_id, telefone_normalizado, ignorar_id=contato_id):
db_log.registrar(usuario, "editar", "contato", str(contato_id), False, "telefone duplicado")
return _redirect_com_mensagem(cliente_id, f"O telefone {telefone_normalizado} já está cadastrado para este cliente.", "warning")
atualizado = db_contatos.atualizar_contato(
contato_id, cliente_id, nome.strip() or None, telefone_normalizado, email.strip() or None
)
if not atualizado:
# ID de contato que não existe (ou pertence a outro cliente — a
# query já é escopada por CLIENTE_ID) — sem essa checagem o log
# registrava "sucesso" mesmo sem nenhuma linha alterada.
db_log.registrar(usuario, "editar", "contato", str(contato_id), False, "contato não encontrado")
return _redirect_com_mensagem(cliente_id, "Contato não encontrado.", "danger")
db_log.registrar(usuario, "editar", "contato", str(contato_id), True)
return _redirect_com_mensagem(cliente_id, "Contato atualizado.")
@router.post("/{cliente_id}/contatos/{contato_id}/excluir", dependencies=[Depends(exigir_csrf)])
def excluir(cliente_id: int, contato_id: int, usuario: str = Depends(exigir_login)):
excluido = db_contatos.excluir_contato(contato_id, cliente_id)
if not excluido:
db_log.registrar(usuario, "excluir", "contato", str(contato_id), False, "contato não encontrado")
return _redirect_com_mensagem(cliente_id, "Contato não encontrado.", "danger")
db_log.registrar(usuario, "excluir", "contato", str(contato_id), True)
return _redirect_com_mensagem(cliente_id, "Contato excluído.")
+194
View File
@@ -0,0 +1,194 @@
"""
Rotas de gestão de FUNCIONARIO_PAINEL: listar, criar (com senha e perfil),
editar nome/login/perfil, redefinir senha, ativar/desativar (nunca exclui
de verdade). Router inteiro protegido por exigir_admin — só administradores
acessam qualquer rota daqui (ver app/auth.py).
"""
from typing import Optional
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from ..auth import UsuarioLogado, exigir_admin
from ..csrf import exigir_csrf, token_do_contexto
from ..db import funcionarios as db_funcionarios
from ..db import log as db_log
PERFIS_VALIDOS = {"ADMIN", "PADRAO"}
router = APIRouter(prefix="/funcionarios", dependencies=[Depends(exigir_admin)])
templates = Jinja2Templates(directory="app/templates")
def _redirect_com_mensagem(mensagem: str, tipo: str = "success") -> RedirectResponse:
from urllib.parse import urlencode
return RedirectResponse(f"/funcionarios?{urlencode({'mensagem': mensagem, 'mensagem_tipo': tipo})}", status_code=303)
@router.get("", response_class=HTMLResponse)
def listar(
request: Request,
mensagem: Optional[str] = None,
mensagem_tipo: Optional[str] = None,
usuario: UsuarioLogado = Depends(exigir_admin),
):
funcionarios = db_funcionarios.listar_funcionarios()
return templates.TemplateResponse(
request,
"funcionarios_lista.html",
{
"funcionarios": funcionarios, "csrf_token": token_do_contexto(request),
"mensagem": mensagem, "mensagem_tipo": mensagem_tipo,
},
)
@router.get("/novo", response_class=HTMLResponse)
def form_novo(request: Request, usuario: UsuarioLogado = Depends(exigir_admin)):
return templates.TemplateResponse(
request, "funcionario_form.html", {"funcionario": None, "csrf_token": token_do_contexto(request)}
)
@router.post("/novo", dependencies=[Depends(exigir_csrf)])
def criar(
request: Request,
nome: str = Form(...),
login: str = Form(...),
senha: str = Form(...),
confirmar_senha: str = Form(...),
perfil: str = Form(...),
usuario: UsuarioLogado = Depends(exigir_admin),
):
nome = nome.strip()
login = login.strip()
erro = None
if not nome or not login or not senha:
erro = "Preencha nome, login e senha."
elif senha != confirmar_senha:
erro = "As senhas digitadas não coincidem."
elif perfil not in PERFIS_VALIDOS:
erro = "Perfil inválido."
elif db_funcionarios.login_em_uso(login):
erro = f"O login \"{login}\" já está em uso."
if erro:
return templates.TemplateResponse(
request,
"funcionario_form.html",
{
"funcionario": None, "erro": erro, "dados": {"NOME": nome, "LOGIN": login, "PERFIL": perfil},
"csrf_token": token_do_contexto(request),
},
)
novo_id = db_funcionarios.criar_funcionario(nome, login, senha, perfil)
db_log.registrar(str(usuario), "criar", "funcionario", str(novo_id), True)
return _redirect_com_mensagem("Funcionário cadastrado.")
@router.get("/{funcionario_id}/editar", response_class=HTMLResponse)
def form_editar(request: Request, funcionario_id: int, usuario: UsuarioLogado = Depends(exigir_admin)):
funcionario = db_funcionarios.obter_funcionario(funcionario_id)
if funcionario is None:
return _redirect_com_mensagem("Funcionário não encontrado.", "danger")
return templates.TemplateResponse(
request, "funcionario_form.html", {"funcionario": funcionario, "csrf_token": token_do_contexto(request)}
)
@router.post("/{funcionario_id}/editar", dependencies=[Depends(exigir_csrf)])
def editar(
request: Request,
funcionario_id: int,
nome: str = Form(...),
login: str = Form(...),
perfil: str = Form(...),
usuario: UsuarioLogado = Depends(exigir_admin),
):
nome = nome.strip()
login = login.strip()
erro = None
if not nome or not login:
erro = "Preencha nome e login."
elif perfil not in PERFIS_VALIDOS:
erro = "Perfil inválido."
elif db_funcionarios.login_em_uso(login, ignorar_id=funcionario_id):
erro = f"O login \"{login}\" já está em uso."
else:
atual = db_funcionarios.obter_funcionario(funcionario_id)
if atual is None:
erro = "Funcionário não encontrado."
else:
rebaixando_ultimo_admin = (
atual["PERFIL"] == "ADMIN"
and atual["ATIVO"] == 1
and perfil == "PADRAO"
and db_funcionarios.contar_admins_ativos() <= 1
)
if rebaixando_ultimo_admin:
erro = "Não é possível — este é o único administrador ativo."
if erro:
return templates.TemplateResponse(
request,
"funcionario_form.html",
{
"funcionario": {"ID": funcionario_id}, "erro": erro,
"dados": {"NOME": nome, "LOGIN": login, "PERFIL": perfil},
"csrf_token": token_do_contexto(request),
},
)
db_funcionarios.atualizar_funcionario(funcionario_id, nome, login, perfil)
db_log.registrar(str(usuario), "editar", "funcionario", str(funcionario_id), True)
return _redirect_com_mensagem("Funcionário atualizado.")
@router.post("/{funcionario_id}/redefinir-senha", dependencies=[Depends(exigir_csrf)])
def redefinir_senha(
funcionario_id: int,
nova_senha: str = Form(...),
confirmar_nova_senha: str = Form(...),
usuario: UsuarioLogado = Depends(exigir_admin),
):
if not nova_senha or nova_senha != confirmar_nova_senha:
db_log.registrar(str(usuario), "redefinir_senha", "funcionario", str(funcionario_id), False, "senhas não coincidem")
return _redirect_com_mensagem("As senhas digitadas não coincidem.", "danger")
atualizado = db_funcionarios.redefinir_senha(funcionario_id, nova_senha)
if not atualizado:
db_log.registrar(str(usuario), "redefinir_senha", "funcionario", str(funcionario_id), False, "funcionário não encontrado")
return _redirect_com_mensagem("Funcionário não encontrado.", "danger")
db_log.registrar(str(usuario), "redefinir_senha", "funcionario", str(funcionario_id), True)
return _redirect_com_mensagem("Senha redefinida.")
@router.post("/{funcionario_id}/alternar-ativo", dependencies=[Depends(exigir_csrf)])
def alternar_ativo(funcionario_id: int, usuario: UsuarioLogado = Depends(exigir_admin)):
atual = db_funcionarios.obter_funcionario(funcionario_id)
if atual is None:
# Sem essa checagem, um ID inexistente caía direto no UPDATE (que
# não afeta nenhuma linha, sem erro do MySQL) e ainda registrava
# "sucesso" no log.
db_log.registrar(str(usuario), "alternar_ativo", "funcionario", str(funcionario_id), False, "funcionário não encontrado")
return _redirect_com_mensagem("Funcionário não encontrado.", "danger")
if atual["ATIVO"] == 1:
# Só desativações (ATIVO 1 -> 0) de um admin passam pela checagem —
# reativar (0 -> 1) nunca reduz a contagem de admins ativos.
vai_desativar_ultimo_admin = atual["PERFIL"] == "ADMIN" and db_funcionarios.contar_admins_ativos() <= 1
if vai_desativar_ultimo_admin:
db_log.registrar(
str(usuario), "alternar_ativo", "funcionario", str(funcionario_id), False, "único administrador ativo"
)
return _redirect_com_mensagem("Não é possível desativar — este é o único administrador ativo.", "danger")
db_funcionarios.alternar_ativo(funcionario_id)
db_log.registrar(str(usuario), "alternar_ativo", "funcionario", str(funcionario_id), True)
return _redirect_com_mensagem("Status do funcionário atualizado.")
+59
View File
@@ -0,0 +1,59 @@
"""
Rota /log: histórico de ações do painel (LOG_PAINEL, app/db/log.py) e,
quando configurado, os últimos envios automáticos de chave via WhatsApp
(Data Table pagamentos_processados do n8n, app/n8n_client.py).
Mostra só os 20 registros de LOG_PAINEL mais recentes — sem paginação.
Administradores têm um botão para limpar o histórico inteiro
(POST /log/limpar); db_log.limpar() apaga tudo e o router grava, logo em
seguida, uma única entrada de auditoria registrando quem limpou e quando
— por isso o histórico nunca fica sem nenhuma explicação do que
aconteceu com ele.
"""
from typing import Optional
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import n8n_client
from ..auth import UsuarioLogado, exigir_admin, exigir_login
from ..csrf import exigir_csrf, token_do_contexto
from ..db import log as db_log
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
LIMITE_ACOES = 20
def _redirect_com_mensagem(mensagem: str, tipo: str = "success") -> RedirectResponse:
return RedirectResponse(f"/log?{urlencode({'mensagem': mensagem, 'mensagem_tipo': tipo})}", status_code=303)
@router.get("/log", response_class=HTMLResponse)
def pagina(
request: Request,
mensagem: Optional[str] = None,
mensagem_tipo: Optional[str] = None,
usuario: UsuarioLogado = Depends(exigir_login),
):
acoes = db_log.listar(limite=LIMITE_ACOES)
envios = n8n_client.listar_envios(limite=50)
return templates.TemplateResponse(
request,
"log.html",
{
"acoes": acoes, "envios": envios, "envios_configurado": n8n_client.configurado(),
"eh_admin": usuario.eh_admin, "csrf_token": token_do_contexto(request),
"mensagem": mensagem, "mensagem_tipo": mensagem_tipo,
},
)
@router.post("/log/limpar", dependencies=[Depends(exigir_csrf)])
def limpar(usuario: UsuarioLogado = Depends(exigir_admin)):
db_log.limpar()
db_log.registrar(str(usuario), "limpar", "log", None, True)
return _redirect_com_mensagem("Log limpo.")
+75
View File
@@ -0,0 +1,75 @@
"""
Tela "Minha conta" — qualquer usuário logado (admin ou padrão) troca a
própria senha aqui, sem precisar de um admin. Pede a senha atual antes de
trocar, mesmo já estando logado (proteção extra contra sessão esquecida
aberta em máquina compartilhada).
A conta de bootstrap (ADMIN_USER/ADMIN_PASSWORD do .env) não tem linha em
FUNCIONARIO_PAINEL — não há senha para trocar pelo painel; a tela avisa
que a troca é editando o .env do servidor.
"""
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from ..auth import UsuarioLogado, exigir_login
from ..csrf import exigir_csrf, token_do_contexto
from ..db import funcionarios as db_funcionarios
from ..db import log as db_log
from ..senha import verificar_senha
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/minha-conta", response_class=HTMLResponse)
def form_minha_conta(request: Request, usuario: UsuarioLogado = Depends(exigir_login)):
funcionario = db_funcionarios.obter_por_login(usuario.login)
return templates.TemplateResponse(
request,
"minha_conta.html",
{
"usuario_atual": usuario, "eh_conta_env": funcionario is None,
"csrf_token": token_do_contexto(request),
},
)
@router.post("/minha-conta/senha", dependencies=[Depends(exigir_csrf)])
def trocar_senha(
request: Request,
senha_atual: str = Form(...),
nova_senha: str = Form(...),
confirmar_nova_senha: str = Form(...),
usuario: UsuarioLogado = Depends(exigir_login),
):
funcionario = db_funcionarios.obter_por_login(usuario.login)
erro = None
if funcionario is None:
erro = "Esta conta é configurada por variável de ambiente (.env) — troque a senha lá, não por aqui."
elif not verificar_senha(senha_atual, funcionario["SENHA_HASH"]):
erro = "A senha atual digitada não confere."
elif not nova_senha or nova_senha != confirmar_nova_senha:
erro = "A nova senha e a confirmação não coincidem."
if erro:
return templates.TemplateResponse(
request,
"minha_conta.html",
{
"usuario_atual": usuario, "eh_conta_env": funcionario is None, "erro": erro,
"csrf_token": token_do_contexto(request),
},
)
db_funcionarios.redefinir_senha(funcionario["ID"], nova_senha)
db_log.registrar(str(usuario), "trocar_senha", "funcionario", str(funcionario["ID"]), True)
return templates.TemplateResponse(
request,
"minha_conta.html",
{
"usuario_atual": usuario, "eh_conta_env": False, "mensagem": "Senha alterada com sucesso.",
"csrf_token": token_do_contexto(request),
},
)
+77
View File
@@ -0,0 +1,77 @@
"""
GET /sso/fiscal — ponto de entrada do item "Fiscal" da sidebar. Com o
funcionário logado e ativo, emite a asserção (app/sso.py) e a entrega ao
callback do Fiscal num POST auto-submetido; sem sessão, manda para o login
normal e volta para cá depois.
Só roda por HTTPS (acesso pelo domínio público, atrás do NPM): por HTTP
redireciona para SSO_GERENCIADOR_URL_PUBLICA. A conta de bootstrap do .env
não tem linha em FUNCIONARIO_PAINEL, portanto nenhum ID estável para virar
identidade no Fiscal — é recusada (o Fiscal tem o próprio admin de emergência).
"""
import os
from urllib.parse import urlencode
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import sso
from ..auth import NaoAutenticado, exigir_login
from ..db import funcionarios as db_funcionarios
from ..db import log as db_log
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _pagina_erro(mensagem: str, status_code: int) -> HTMLResponse:
return HTMLResponse(
f'<!doctype html><meta charset="utf-8"><title>GTI Fiscal</title>'
f'<p style="font-family:system-ui;padding:24px">{mensagem} <a href="/">Voltar ao painel</a></p>',
status_code=status_code,
)
@router.get("/sso/fiscal")
def iniciar_sso_fiscal(request: Request, next: str | None = None, state: str | None = None):
destino = sso.proximo_seguro(next)
if not sso.configurado():
return _pagina_erro("O acesso ao GTI Fiscal não está configurado neste painel.", 503)
# Sem state o fluxo não começou no Fiscal (ex.: clique na sidebar): manda
# para o login do Fiscal, que cria o state e devolve para cá.
if not sso.estado_valido(state):
return RedirectResponse(sso.url_login_fiscal(destino), status_code=303)
parametros = urlencode({"state": state, "next": destino})
if request.url.scheme != "https":
publica = os.environ.get("SSO_GERENCIADOR_URL_PUBLICA", "").rstrip("/")
if publica.startswith("https://"):
return RedirectResponse(f"{publica}/sso/fiscal?{parametros}", status_code=303)
return _pagina_erro("O acesso ao GTI Fiscal só funciona pelo endereço HTTPS do painel.", 400)
try:
usuario = exigir_login(request)
except NaoAutenticado:
return RedirectResponse(f"/login?{urlencode({'next': f'/sso/fiscal?{parametros}'})}", status_code=303)
funcionario = db_funcionarios.obter_por_login(usuario.login)
if funcionario is None:
db_log.registrar(str(usuario), "sso_fiscal", "funcionario", usuario.login, False, "conta de bootstrap não usa SSO")
return _pagina_erro("A conta de bootstrap não acessa o GTI Fiscal — entre com seu usuário de funcionário.", 403)
assercao = sso.emitir_assercao(funcionario, nonce=state)
db_log.registrar(str(usuario), "sso_fiscal", "funcionario", str(funcionario["ID"]), True)
resposta = templates.TemplateResponse(
request, "sso_fiscal.html", {"acao": sso.url_callback(), "assercao": assercao, "proximo": destino}
)
# A CSP padrão (main.py) só permite form-action 'self'; esta página precisa
# postar exatamente para a origem do Fiscal e para nenhum outro lugar.
resposta.headers["Content-Security-Policy"] = (
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
f"form-action {sso.origem_callback()}; frame-ancestors 'none'; base-uri 'none'"
)
resposta.headers["Cache-Control"] = "no-store"
resposta.headers["Referrer-Policy"] = "no-referrer"
return resposta
+31
View File
@@ -0,0 +1,31 @@
"""
Hash de senha de funcionário com scrypt (hashlib da biblioteca padrão —
sem dependência extra tipo bcrypt/passlib). Formato armazenado:
"scrypt$<salt em hex>$<hash em hex>".
"""
import hashlib
import secrets
_N, _R, _P = 2**14, 8, 1
_TAMANHO_HASH = 32
def gerar_hash(senha: str) -> str:
salt = secrets.token_bytes(16)
resultado = hashlib.scrypt(senha.encode("utf-8"), salt=salt, n=_N, r=_R, p=_P, dklen=_TAMANHO_HASH)
return f"scrypt${salt.hex()}${resultado.hex()}"
def verificar_senha(senha: str, hash_armazenado: str) -> bool:
"""Nunca lança — hash malformado ou senha errada só retorna False."""
try:
algoritmo, salt_hex, hash_hex = hash_armazenado.split("$")
if algoritmo != "scrypt":
return False
salt = bytes.fromhex(salt_hex)
esperado = bytes.fromhex(hash_hex)
except (ValueError, AttributeError):
return False
calculado = hashlib.scrypt(senha.encode("utf-8"), salt=salt, n=_N, r=_R, p=_P, dklen=len(esperado))
return secrets.compare_digest(calculado, esperado)
+68
View File
@@ -0,0 +1,68 @@
"""
Token de sessão de login — cookie "painel_session" (ver app/auth.py e
app/routers/auth.py). Stateless: nada gravado no banco, a validade inteira
depende da assinatura HMAC-SHA256 com SESSION_SECRET (app/main.py define a
variável de ambiente; se ausente, um valor aleatório é gerado em memória no
start do processo — reiniciar sem SESSION_SECRET fixo derruba as sessões
ativas, forçando login de novo, efeito colateral aceito).
Formato do token: "<payload em base64url>.<assinatura HMAC em hex>"
Payload (antes de codificar): JSON com login, nome, perfil e validade
("exp", timestamp Unix).
Nunca lança exceção para entrada inválida — ler_token() sempre devolve
None nesse caso, para o chamador (app/auth.py) tratar como "não logado".
"""
import base64
import hashlib
import hmac
import json
import os
import secrets
import time
SESSION_COOKIE_NAME = "painel_session"
VALIDADE_SEGUNDOS = 12 * 60 * 60 # 12 horas
_SECRET_EM_MEMORIA = secrets.token_bytes(32)
def _chave_secreta() -> bytes:
configurada = os.environ.get("SESSION_SECRET", "")
if configurada:
return configurada.encode("utf-8")
return _SECRET_EM_MEMORIA
def _assinar(payload_b64: bytes) -> str:
return hmac.new(_chave_secreta(), payload_b64, hashlib.sha256).hexdigest()
def criar_token(login: str, nome: str, perfil: str, agora: float | None = None) -> str:
momento = agora if agora is not None else time.time()
payload = {"login": login, "nome": nome, "perfil": perfil, "exp": momento + VALIDADE_SEGUNDOS}
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
assinatura = _assinar(payload_b64)
return payload_b64.decode("ascii") + "." + assinatura
def ler_token(token: str | None, agora: float | None = None) -> dict | None:
if not token or "." not in token:
return None
payload_b64_str, assinatura_recebida = token.rsplit(".", 1)
payload_b64 = payload_b64_str.encode("ascii")
assinatura_esperada = _assinar(payload_b64)
if not hmac.compare_digest(assinatura_recebida, assinatura_esperada):
return None
try:
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
except (ValueError, UnicodeDecodeError):
return None
momento = agora if agora is not None else time.time()
if momento > payload.get("exp", 0):
return None
return {"login": payload["login"], "nome": payload["nome"], "perfil": payload["perfil"]}
+101
View File
@@ -0,0 +1,101 @@
"""
SSO do painel para o GTI Fiscal: o painel é a autoridade da identidade do
funcionário e entrega ao Fiscal uma asserção assinada, de vida curta.
- Assinatura Ed25519 (JWT "EdDSA"). A chave privada existe só aqui
(SSO_FISCAL_CHAVE_PRIVADA, semente de 32 bytes em base64url); o Fiscal só
tem a chave pública, então não consegue emitir asserções.
- Claims: iss/aud fixos, sub = "funcionario:<ID>" (o ID de
FUNCIONARIO_PAINEL — o LOGIN é editável, então não serve de identificador),
iat/nbf/exp (60 s), jti único (o Fiscal grava para recusar replay) e nonce
= state do Fiscal (amarra a asserção ao navegador que iniciou o fluxo).
- Nenhuma senha, hash ou cookie deste painel sai daqui: só nome, login e
perfil, que o Fiscal usa para exibir e para autorização futura.
- A asserção vai no corpo de um POST auto-submetido (routers/sso.py), nunca
na URL: não aparece em log de proxy, histórico do navegador nem Referer.
SSO_FISCAL_CHAVE_PRIVADA é exclusiva do SSO — não reaproveitar SESSION_SECRET
nem nenhuma outra credencial.
"""
import base64
import os
import re
import time
import uuid
from urllib.parse import urlencode, urlsplit
import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
EMISSOR = "gti-gerenciador"
AUDIENCIA = "gti-fiscal"
VALIDADE_SEGUNDOS = 60
TAMANHO_MAXIMO_NEXT = 512
def configurado() -> bool:
return bool(os.environ.get("SSO_FISCAL_CHAVE_PRIVADA")) and bool(os.environ.get("SSO_FISCAL_CALLBACK_URL"))
def url_callback() -> str:
"""URL do callback do Fiscal, vinda só de configuração (nunca da requisição).
Recusa qualquer coisa que não seja https://host/caminho, sem credenciais."""
url = os.environ["SSO_FISCAL_CALLBACK_URL"]
partes = urlsplit(url)
if partes.scheme != "https" or not partes.hostname or partes.username or partes.password or partes.query or partes.fragment:
raise RuntimeError("SSO_FISCAL_CALLBACK_URL precisa ser https://host/caminho")
return url
def origem_callback() -> str:
partes = urlsplit(url_callback())
return f"{partes.scheme}://{partes.netloc}"
def estado_valido(valor: str | None) -> bool:
"""state gerado pelo Fiscal (cookie do navegador que iniciou o fluxo); volta
assinado como "nonce", e o Fiscal confere contra o cookie — assim ninguém
consegue empurrar a própria asserção para o navegador de outra pessoa."""
return bool(valor) and bool(re.fullmatch(r"[A-Za-z0-9_\-]{16,128}", valor))
def url_login_fiscal(proximo: str) -> str:
return f"{origem_callback()}/login?{urlencode({'next': proximo})}"
def proximo_seguro(valor: str | None) -> str:
"""Só caminho relativo dentro do Fiscal. Qualquer outra coisa vira "/"."""
if (
not valor
or len(valor) > TAMANHO_MAXIMO_NEXT
or not valor.startswith("/")
or valor.startswith("//")
or "\\" in valor
or any(ord(c) < 32 or ord(c) == 127 for c in valor)
):
return "/"
return valor
def _chave_privada() -> Ed25519PrivateKey:
bruto = os.environ["SSO_FISCAL_CHAVE_PRIVADA"].strip()
semente = base64.urlsafe_b64decode(bruto + "=" * (-len(bruto) % 4))
return Ed25519PrivateKey.from_private_bytes(semente)
def emitir_assercao(funcionario: dict, nonce: str, agora: float | None = None) -> str:
momento = int(agora if agora is not None else time.time())
claims = {
"iss": EMISSOR,
"aud": AUDIENCIA,
"sub": f"funcionario:{funcionario['ID']}",
"iat": momento,
"nbf": momento,
"exp": momento + VALIDADE_SEGUNDOS,
"jti": str(uuid.uuid4()),
"nonce": nonce,
"login": funcionario["LOGIN"],
"name": funcionario["NOME"],
"perfil": funcionario["PERFIL"],
}
return jwt.encode(claims, _chave_privada(), algorithm="EdDSA")
+318
View File
@@ -0,0 +1,318 @@
/* app/static/estilo.css — tokens de cor e componentes compartilhados do painel */
:root {
--cor-fundo: #f7f7f9;
--cor-superficie: #ffffff;
--cor-superficie-alt: #f1f1f4;
--cor-texto: #1c1c1f;
--cor-texto-secundario: #6b6b74;
--cor-borda: #e4e4e9;
--cor-acento: #F5821F;
--cor-acento-contraste: #17171a;
--cor-acento-suave: #fff1e4;
--cor-acento-texto: #B3510C;
--cor-sucesso: #15803d;
--cor-sucesso-fundo: #dcfce7;
--cor-perigo: #b91c1c;
--cor-perigo-fundo: #fee2e2;
--cor-aviso: #b45309;
--cor-aviso-fundo: #fef3c7;
--sombra-card: 0 1px 2px rgba(0,0,0,0.05), 0 1px 8px rgba(0,0,0,0.04);
--transicao: 0.16s ease;
}
@media (prefers-color-scheme: dark) {
:root:not([data-tema="claro"]) {
--cor-fundo: #121214;
--cor-superficie: #1c1c1f;
--cor-superficie-alt: #232326;
--cor-texto: #f4f4f5;
--cor-texto-secundario: #9a9aa2;
--cor-borda: #2e2e33;
--cor-acento-contraste: #17171a;
--cor-acento-suave: rgba(245,130,31,0.16);
--cor-acento-texto: #F5821F;
--cor-sucesso: #4ade80;
--cor-sucesso-fundo: rgba(74,222,128,0.14);
--cor-perigo: #f87171;
--cor-perigo-fundo: rgba(248,113,113,0.14);
--cor-aviso: #fbbf24;
--cor-aviso-fundo: rgba(251,191,36,0.14);
--sombra-card: 0 1px 2px rgba(0,0,0,0.3), 0 1px 8px rgba(0,0,0,0.25);
}
}
:root[data-tema="escuro"] {
--cor-fundo: #121214;
--cor-superficie: #1c1c1f;
--cor-superficie-alt: #232326;
--cor-texto: #f4f4f5;
--cor-texto-secundario: #9a9aa2;
--cor-borda: #2e2e33;
--cor-acento-contraste: #17171a;
--cor-acento-suave: rgba(245,130,31,0.16);
--cor-acento-texto: #F5821F;
--cor-sucesso: #4ade80;
--cor-sucesso-fundo: rgba(74,222,128,0.14);
--cor-perigo: #f87171;
--cor-perigo-fundo: rgba(248,113,113,0.14);
--cor-aviso: #fbbf24;
--cor-aviso-fundo: rgba(251,191,36,0.14);
--sombra-card: 0 1px 2px rgba(0,0,0,0.3), 0 1px 8px rgba(0,0,0,0.25);
}
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
margin: 0;
background: var(--cor-fundo);
color: var(--cor-texto);
transition: background var(--transicao), color var(--transicao);
}
h1, h2, h3 { font-weight: 700; line-height: 1.25; }
h1 { font-size: 1.5rem; margin: 0 0 16px; }
h2 { font-size: 1.15rem; margin: 28px 0 12px; }
a { color: var(--cor-acento-texto); }
/* ---------- layout raiz: sidebar + área de conteúdo ---------- */
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 240px;
flex-shrink: 0;
background: var(--cor-superficie);
border-right: 1px solid var(--cor-borda);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
transition: width var(--transicao), transform var(--transicao);
}
.sidebar-topo {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 16px;
border-bottom: 1px solid var(--cor-borda);
}
.sidebar-logo { width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0; }
.sidebar-titulo { font-weight: 700; white-space: nowrap; overflow: hidden; }
.sidebar-nav { flex: 1; padding: 12px 8px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
.sidebar-nav a {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 8px;
color: var(--cor-texto); text-decoration: none; white-space: nowrap;
transition: background var(--transicao), color var(--transicao);
}
.sidebar-nav a:hover { background: var(--cor-superficie-alt); }
.sidebar-nav a.ativo { background: var(--cor-acento-suave); color: var(--cor-acento-texto); font-weight: 600; }
.sidebar-nav svg { flex-shrink: 0; width: 20px; height: 20px; }
.sidebar-nav span { overflow: hidden; text-overflow: ellipsis; }
.sidebar-rodape {
border-top: 1px solid var(--cor-borda);
padding: 12px 8px;
display: flex; flex-direction: column; gap: 8px;
}
.sidebar-usuario { padding: 6px 12px; font-size: 0.85rem; color: var(--cor-texto-secundario); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-rodape .form-logout { margin: 0; }
.botao-tema, .botao-recolher, .botao-sair, .topbar-menu-btn {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 10px 12px; border-radius: 8px; border: none;
background: transparent; color: var(--cor-texto); cursor: pointer;
font: inherit; font-weight: 400; margin: 0; text-align: left;
}
.botao-tema:hover, .botao-recolher:hover, .botao-sair:hover { background: var(--cor-superficie-alt); }
.botao-tema svg, .botao-recolher svg, .botao-sair svg { flex-shrink: 0; width: 20px; height: 20px; }
/* Sidebar recolhida (desktop): só ícones */
.sidebar.recolhida { width: 64px; }
.sidebar.recolhida .sidebar-titulo,
.sidebar.recolhida .sidebar-nav span,
.sidebar.recolhida .sidebar-usuario,
.sidebar.recolhida .botao-tema span,
.sidebar.recolhida .botao-recolher span,
.sidebar.recolhida .botao-sair span { display: none; }
.sidebar-backdrop {
display: none;
position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 40;
}
.conteudo-area { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar-mobile {
display: none;
align-items: center; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--cor-borda);
background: var(--cor-superficie);
}
.topbar-menu-btn { width: auto; padding: 8px; }
.topbar-titulo { font-weight: 700; }
.conteudo { padding: 24px; max-width: 1080px; width: 100%; margin: 0 auto; }
/* ---------- mobile: sidebar vira gaveta ---------- */
@media (max-width: 900px) {
.sidebar {
position: fixed; top: 0; bottom: 0; left: 0; z-index: 50;
transform: translateX(-100%);
}
.sidebar.aberta { transform: translateX(0); }
.sidebar.recolhida { width: 240px; } /* recolher não se aplica no mobile */
.sidebar.recolhida .sidebar-titulo,
.sidebar.recolhida .sidebar-nav span,
.sidebar.recolhida .sidebar-usuario,
.sidebar.recolhida .botao-tema span,
.sidebar.recolhida .botao-recolher span,
.sidebar.recolhida .botao-sair span { display: inline; }
.sidebar-backdrop.aberta { display: block; }
.topbar-mobile { display: flex; }
.conteudo { padding: 16px; }
}
/* ---------- componentes ---------- */
.cartao {
background: var(--cor-superficie);
border: 1px solid var(--cor-borda);
border-radius: 12px;
box-shadow: var(--sombra-card);
padding: 20px;
margin-bottom: 20px;
}
.tabela-wrap { overflow-x: auto; border: 1px solid var(--cor-borda); border-radius: 12px; background: var(--cor-superficie); box-shadow: var(--sombra-card); }
/*
* min-width (não width) faz a tabela continuar esticando pra preencher o
* cartão em telas largas (comportamento de sempre), mas permite crescer
* além disso quando o conteúdo não cabe — em telas estreitas (celular) o
* .tabela-wrap acima assume a rolagem horizontal, em vez do texto quebrar
* letra por letra pra caber à força numa coluna espremida.
*/
table { min-width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--cor-borda); }
th { white-space: nowrap; }
td { overflow-wrap: anywhere; }
tr:last-child td { border-bottom: none; }
th a { color: inherit; text-decoration: none; }
th a:hover { color: var(--cor-acento); }
input, select {
padding: 9px 10px; border-radius: 8px; border: 1px solid var(--cor-borda);
background: var(--cor-fundo); color: var(--cor-texto); width: 100%;
}
input:focus-visible, select:focus-visible, a:focus-visible, button:focus-visible {
outline: 2px solid var(--cor-acento); outline-offset: 2px;
}
label { display: block; margin-top: 10px; margin-bottom: 4px; font-size: 0.85rem; color: var(--cor-texto-secundario); }
button, a.botao {
display: inline-block; margin-top: 14px; padding: 10px 16px;
border: 1px solid transparent; border-radius: 8px;
background: var(--cor-acento); color: var(--cor-acento-contraste);
font-weight: 700; cursor: pointer; text-decoration: none; text-align: center;
white-space: nowrap;
}
a.botao { background: transparent; border-color: var(--cor-borda); color: var(--cor-texto); font-weight: 400; margin-right: 6px; }
a.botao:hover { border-color: var(--cor-acento); color: var(--cor-acento); }
.mensagem { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; }
.mensagem.success { background: var(--cor-sucesso-fundo); color: var(--cor-sucesso); }
.mensagem.danger { background: var(--cor-perigo-fundo); color: var(--cor-perigo); }
.mensagem.warning { background: var(--cor-aviso-fundo); color: var(--cor-aviso); }
.badge { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 0.78rem; font-weight: 600; }
.badge-acento { background: var(--cor-acento-suave); color: var(--cor-acento-texto); }
.badge-neutro { background: var(--cor-superficie-alt); color: var(--cor-texto-secundario); }
.badge-sucesso { background: var(--cor-sucesso-fundo); color: var(--cor-sucesso); }
.grid-form { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.grid-form-largura-total { grid-column: 1 / -1; }
@media (max-width: 640px) {
.grid-form { grid-template-columns: 1fr; }
}
.paginacao { display: flex; align-items: center; gap: 12px; justify-content: center; margin-top: 16px; }
.paginacao-rodape { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.paginacao-rodape .paginacao { margin-top: 0; }
.paginacao-por-pagina { display: flex; align-items: center; gap: 8px; }
.paginacao-por-pagina label { margin: 0; white-space: nowrap; }
.paginacao-atual { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 32px; padding: 0 6px; border-radius: 6px; background: var(--cor-acento); color: var(--cor-acento-texto); font-weight: 600; }
.paginacao-reticencias { padding: 0 4px; color: var(--cor-texto-secundario); }
.chave-feedback { margin-left: 6px; font-size: 0.85rem; color: var(--cor-sucesso); }
.chave-feedback-erro { color: var(--cor-perigo); }
.botao-gerar-chave { margin-left: 6px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
.status-dot { display: inline-block; width: 12px; height: 12px; padding: 0; margin: 0; border: none; border-radius: 50%; cursor: pointer; vertical-align: middle; }
.status-dot-ativo { background: var(--cor-sucesso); }
.status-dot-inativo { background: var(--cor-perigo); }
.secao-log { margin-bottom: 20px; }
.secao-log summary {
display: flex; align-items: center; gap: 10px; cursor: pointer;
list-style: none; padding: 10px 0; user-select: none;
}
.secao-log summary::-webkit-details-marker { display: none; }
.secao-log summary::before {
content: "▸"; display: inline-block; color: var(--cor-texto-secundario);
transition: transform 0.15s ease;
}
.secao-log[open] summary::before { transform: rotate(90deg); }
.secao-log-titulo { font-size: 1.17em; font-weight: 700; }
.botao-toggle-log {
margin-top: 0; padding: 6px 12px; font-size: 0.85rem; font-weight: 400;
background: transparent; border: 1px solid var(--cor-borda); color: var(--cor-texto);
}
.botao-toggle-log:hover { border-color: var(--cor-acento); color: var(--cor-acento); }
.secao-log-conteudo { padding-top: 4px; }
/*
* table-layout:fixed + min-width faz as colunas respeitarem exatamente as
* larguras abaixo (necessário pro Abrir/Gerar chave não quebrar linha —
* ver commit da tarefa de UI da tabela). Em telas estreitas (celular), o
* min-width impede a tabela de ser espremida a ponto de zerar a largura
* das colunas percentuais — o .tabela-wrap (overflow-x: auto) assume a
* rolagem horizontal nesse caso, em vez do texto quebrar letra por letra.
* Nas telas largas (desktop) o min-width nunca entra em jogo, então o
* visual de lá continua idêntico.
*/
.table-clientes { table-layout: fixed; min-width: 990px; }
.table-clientes th.col-codigo, .table-clientes td.col-codigo,
.table-clientes th.col-cnpj, .table-clientes td.col-cnpj,
.table-clientes th.col-status, .table-clientes td.col-status {
text-align: center;
}
.table-clientes th.col-acoes, .table-clientes td.col-acoes { text-align: right; }
.table-clientes td.col-codigo, .table-clientes td.col-cnpj {
font-variant-numeric: tabular-nums;
}
.table-clientes td.col-cnpj { white-space: nowrap; }
.table-clientes col.col-codigo { width: 90px; }
.table-clientes col.col-razao, .table-clientes col.col-fantasia { width: 220px; }
.table-clientes col.col-cnpj { width: 160px; }
.table-clientes col.col-status { width: 70px; }
.table-clientes col.col-acoes { width: 230px; }
.table-clientes th, .table-clientes td { padding: 7px 14px; }
.botao.botao-tabela { margin-top: 0; padding: 7px 12px; font-size: 0.875rem; vertical-align: middle; }
.table-clientes td.col-acoes { white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+39
View File
@@ -0,0 +1,39 @@
// app/static/sidebar.js — abre/fecha a gaveta da sidebar no mobile e
// recolhe/expande no desktop, persistindo a preferência de recolher.
(function () {
var sidebar = document.getElementById("sidebar");
var backdrop = document.getElementById("sidebar-backdrop");
var btnMenuMobile = document.getElementById("btn-menu-mobile");
var btnRecolher = document.getElementById("botao-recolher");
if (!sidebar) return;
function abrirGaveta() {
sidebar.classList.add("aberta");
if (backdrop) backdrop.classList.add("aberta");
}
function fecharGaveta() {
sidebar.classList.remove("aberta");
if (backdrop) backdrop.classList.remove("aberta");
}
if (btnMenuMobile) {
btnMenuMobile.addEventListener("click", function () {
if (sidebar.classList.contains("aberta")) fecharGaveta();
else abrirGaveta();
});
}
if (backdrop) backdrop.addEventListener("click", fecharGaveta);
sidebar.querySelectorAll("a").forEach(function (link) {
link.addEventListener("click", fecharGaveta);
});
if (btnRecolher) {
if (localStorage.getItem("sidebarRecolhida") === "1") {
sidebar.classList.add("recolhida");
}
btnRecolher.addEventListener("click", function () {
var recolhida = sidebar.classList.toggle("recolhida");
localStorage.setItem("sidebarRecolhida", recolhida ? "1" : "0");
});
}
})();
+17
View File
@@ -0,0 +1,17 @@
// app/static/tema.js — alterna e persiste o tema claro/escuro do painel.
(function () {
var botao = document.getElementById("botao-tema");
if (!botao) return;
function temaAtual() {
var salvo = localStorage.getItem("tema");
if (salvo === "claro" || salvo === "escuro") return salvo;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "escuro" : "claro";
}
botao.addEventListener("click", function () {
var novo = temaAtual() === "escuro" ? "claro" : "escuro";
document.documentElement.setAttribute("data-tema", novo);
localStorage.setItem("tema", novo);
});
})();
+94
View File
@@ -0,0 +1,94 @@
<!-- app/templates/base.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titulo %}Painel de Clientes{% endblock %}</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/estilo.css?v={{ request.state.asset_versao }}">
<script>
(function () {
var tema = localStorage.getItem("tema");
if (tema === "claro" || tema === "escuro") {
document.documentElement.setAttribute("data-tema", tema);
}
})();
</script>
</head>
<body>
<div class="layout">
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-topo">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="sidebar-logo">
<span class="sidebar-titulo">Painel de Clientes</span>
</div>
<nav class="sidebar-nav">
<a href="/clientes" class="{{ 'ativo' if request.url.path.startswith('/clientes') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
<span>Clientes</span>
</a>
<a href="/gerador-chave" class="{{ 'ativo' if request.url.path.startswith('/gerador-chave') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/></svg>
<span>Gerador de chave</span>
</a>
{% if request.state.usuario and request.state.usuario.eh_admin %}
<a href="/funcionarios" class="{{ 'ativo' if request.url.path.startswith('/funcionarios') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="7" r="4"/><path d="M2 21v-2a5 5 0 0 1 5-5h4a5 5 0 0 1 5 5v2M17 11l2 2 4-4"/></svg>
<span>Funcionários</span>
</a>
{% endif %}
<a href="/log" class="{{ 'ativo' if request.url.path.startswith('/log') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4"/><path d="M9 15h6M9 11h10M9 7h4"/></svg>
<span>Log</span>
</a>
<a href="/sso/fiscal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<span>Fiscal</span>
</a>
{% if request.state.usuario %}
<a href="/minha-conta" class="{{ 'ativo' if request.url.path.startswith('/minha-conta') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>
<span>Minha conta</span>
</a>
{% endif %}
</nav>
<div class="sidebar-rodape">
{% if request.state.usuario %}
<div class="sidebar-usuario">{{ request.state.usuario.nome }}</div>
<button type="button" class="botao-tema" id="botao-tema" aria-label="Alternar tema claro/escuro">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<span>Tema claro/escuro</span>
</button>
<form method="post" action="/logout" class="form-logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token or request.state.csrf_token }}">
<button type="submit" class="botao-sair">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>
<span>Sair</span>
</button>
</form>
{% endif %}
<button type="button" class="botao-recolher" id="botao-recolher" aria-label="Recolher menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19l-7-7 7-7M18 19l-7-7 7-7"/></svg>
<span>Recolher</span>
</button>
</div>
</aside>
<div class="conteudo-area">
<header class="topbar-mobile">
<button type="button" class="topbar-menu-btn" id="btn-menu-mobile" aria-label="Abrir menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:22px;height:22px"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
<span class="topbar-titulo">{{ self.titulo() }}</span>
</header>
<main class="conteudo">
{% if mensagem %}<div class="mensagem {{ mensagem_tipo or 'success' }}">{{ mensagem }}</div>{% endif %}
{% block conteudo %}{% endblock %}
</main>
</div>
</div>
<script src="/static/sidebar.js?v={{ request.state.asset_versao }}" defer></script>
<script src="/static/tema.js?v={{ request.state.asset_versao }}" defer></script>
</body>
</html>
@@ -0,0 +1,69 @@
<!-- app/templates/cliente_detalhe.html -->
{% extends "base.html" %}
{% block titulo %}{{ cliente.RAZAO }}{% endblock %}
{% block conteudo %}
<h1>{{ cliente.FANTASIA }} <small style="color:var(--cor-texto-secundario)">({{ cliente.CODIGO }})</small></h1>
<p style="color:var(--cor-texto-secundario)">{{ cliente.RAZAO }} — {{ cliente.CNPJ }}</p>
<p>
<a class="botao" href="/clientes/{{ cliente.ID }}/editar">Editar</a>
<form method="post" action="/clientes/{{ cliente.ID }}/excluir" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Excluir este cliente?')">Excluir</button>
</form>
</p>
<h2>Contatos</h2>
<div class="tabela-wrap">
<table>
<tr><th>Nome</th><th>Telefone</th><th>E-mail</th><th></th></tr>
{% for ct in contatos %}
<tr>
<td>{{ ct.NOME }}</td><td>{{ ct.TELEFONE }}</td><td>{{ ct.EMAIL or "" }}</td>
<td>
<details style="display:inline-block">
<summary class="botao" style="display:inline-block;cursor:pointer">Editar</summary>
<form method="post" action="/clientes/{{ cliente.ID }}/contatos/{{ ct.ID }}/editar" style="margin-top:8px">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="nome-{{ ct.ID }}">Nome</label>
<input type="text" id="nome-{{ ct.ID }}" name="nome" value="{{ ct.NOME or '' }}">
<label for="telefone-{{ ct.ID }}">Telefone</label>
<input type="text" id="telefone-{{ ct.ID }}" name="telefone" value="{{ ct.TELEFONE }}" required>
<label for="email-{{ ct.ID }}">E-mail</label>
<input type="text" id="email-{{ ct.ID }}" name="email" value="{{ ct.EMAIL or '' }}">
<button type="submit">Salvar</button>
</form>
</details>
<form method="post" action="/clientes/{{ cliente.ID }}/contatos/{{ ct.ID }}/excluir" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Excluir este contato?')">Excluir</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
<div class="cartao">
<form method="post" action="/clientes/{{ cliente.ID }}/contatos">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="nome">Nome</label>
<input type="text" id="nome" name="nome">
<label for="telefone">Telefone</label>
<input type="text" id="telefone" name="telefone" required>
<label for="email">E-mail</label>
<input type="text" id="email" name="email">
<button type="submit">Adicionar contato</button>
</form>
</div>
<h2>Status em Clientes_Chaves</h2>
{% if status_chaves %}
<div class="tabela-wrap">
<table>
<tr>{% for mes in status_chaves.meses %}<th>{{ mes }}</th>{% endfor %}</tr>
<tr>{% for mes, valor in status_chaves.meses.items() %}<td>{{ "✓" if valor else "—" }}</td>{% endfor %}</tr>
</table>
</div>
{% else %}
<p>Nenhuma linha em Clientes_Chaves para o código {{ cliente.CODIGO }}.</p>
{% endif %}
{% endblock %}
+229
View File
@@ -0,0 +1,229 @@
<!-- app/templates/cliente_form.html -->
{% extends "base.html" %}
{% block titulo %}{{ "Editar" if cliente else "Novo" }} cliente{% endblock %}
{% block conteudo %}
<h1>{{ "Editar" if cliente else "Novo" }} cliente</h1>
{% if erro_codigo_duplicado %}
<div class="mensagem danger">
O código {{ dados.CODIGO }} já está em uso por outro cliente — escolha outro código antes de salvar.
</div>
{% endif %}
{% if erro_cnpj_duplicado %}
<div class="mensagem danger">
O CNPJ/CPF {{ dados.CNPJ }} já está cadastrado para outro cliente.
</div>
{% endif %}
{% if aviso_dv_invalido %}
<div class="mensagem warning">
O dígito verificador do documento ({{ dados.CNPJ }}) não confere — confirme se digitou certo antes de salvar.
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for campo, valor in dados.items() %}<input type="hidden" name="{{ campo }}" value="{{ valor }}">{% endfor %}
<input type="hidden" name="confirmar_dv_invalido" value="1">
<button type="submit">Cadastrar mesmo assim</button>
</form>
</div>
{% endif %}
{% if erro_cnpj_invalido %}
<div class="mensagem danger">
O dígito verificador do CNPJ ({{ dados.CNPJ }}) não confere — corrija antes de salvar.
</div>
{% endif %}
{% if erro_gravacao %}
<div class="mensagem danger">
Não foi possível salvar: {{ erro_gravacao }}
</div>
{% endif %}
<div class="cartao">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid-form">
{% for campo in campos %}
{% set largura_total = campo in ["OBS"] %}
<div class="{{ 'grid-form-largura-total' if largura_total }}">
<label for="{{ campo }}">{{ campo }}{% if campo in campos_obrigatorios %} *{% endif %}</label>
{% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %}
<input type="date" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" {% if campo in campos_obrigatorios %}required{% endif %}>
{% elif campo in ["IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA"] %}
<input type="number" step="0.01" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" {% if campo in campos_obrigatorios %}required{% endif %}>
{% elif campo == "ATIVO" %}
<select id="{{ campo }}" name="{{ campo }}">
<option value="1" {% if (dados[campo] if dados else "1")|string == "1" %}selected{% endif %}>Ativo</option>
<option value="0" {% if (dados[campo] if dados else "1")|string == "0" %}selected{% endif %}>Inativo</option>
</select>
{% else %}
<input type="text" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" {% if campo in campos_obrigatorios %}required{% endif %}>
{% if campo == "CNPJ" %}
<small id="cnpj-status" style="display:block;color:var(--cor-texto-secundario);margin-top:4px"></small>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
<p><small>* campos obrigatórios. Os demais podem ficar em branco — implantação/mensalidade/juros ficam 0,00 e as datas de contrato ficam com a data de hoje até serem ajustadas.</small></p>
<button type="submit" id="botao-salvar">Salvar</button>
</form>
</div>
<script>
(function () {
// Ao sair do campo CNPJ, busca os dados na Receita (via BrasilAPI, ver
// app/cnpj_lookup.py) e preenche os campos abaixo — só os que estiverem
// vazios, nunca sobrescreve o que já foi digitado. Não faz nada para CPF
// (a rota /clientes/consulta-cnpj só responde para CNPJ).
var $cnpj = document.getElementById('CNPJ');
var $status = document.getElementById('cnpj-status');
if (!$cnpj) return;
var $botaoSalvar = document.getElementById('botao-salvar');
// Mesma fórmula de dígito verificador de app/normalize.py
// (_digito_verificador + validar_dv_cnpj) — módulo 11 sobre valor ASCII
// − 48 de cada caractere, suporta CNPJ numérico e o alfanumérico
// vigente desde 31/07/2026.
var PESOS_CNPJ_DV1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
var PESOS_CNPJ_DV2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
function digitoVerificador(valores, pesos) {
var soma = 0;
for (var i = 0; i < valores.length; i++) soma += valores[i] * pesos[i];
var resto = soma % 11;
return resto < 2 ? 0 : 11 - resto;
}
function valorCaractere(c) {
return c.charCodeAt(0) - 48;
}
function validarDvCnpj(cnpj14) {
var base = [];
for (var i = 0; i < 12; i++) base.push(valorCaractere(cnpj14[i]));
var dvInformado = [parseInt(cnpj14[12], 10), parseInt(cnpj14[13], 10)];
var dv1 = digitoVerificador(base, PESOS_CNPJ_DV1);
var dv2 = digitoVerificador(base.concat([dv1]), PESOS_CNPJ_DV2);
return dvInformado[0] === dv1 && dvInformado[1] === dv2;
}
function detectarEValidarDocumento() {
var limpo = normalizarCnpj($cnpj.value);
if ($botaoSalvar) $botaoSalvar.disabled = false;
if (/^[0-9]{11}$/.test(limpo)) {
if ($status) {
$status.textContent = 'CPF detectado.';
$status.style.color = 'var(--cor-texto-secundario)';
}
return;
}
if (/^[0-9A-Z]{12}[0-9]{2}$/.test(limpo)) {
if (validarDvCnpj(limpo)) {
if ($status) {
$status.textContent = 'CNPJ detectado.';
$status.style.color = 'var(--cor-texto-secundario)';
}
} else {
if ($status) {
$status.textContent = 'CNPJ inválido (dígito verificador não confere).';
$status.style.color = 'var(--cor-perigo)';
}
if ($botaoSalvar) $botaoSalvar.disabled = true;
}
return;
}
// Tamanho ainda incompleto/indeterminado: não bloqueia, não rotula.
if ($status) $status.textContent = '';
}
$cnpj.addEventListener('input', detectarEValidarDocumento);
detectarEValidarDocumento();
function aplicarMascaraTelefone(elemento, digitosAposDdd) {
elemento.addEventListener('input', function () {
var digitos = elemento.value.replace(/\D/g, '');
// DDI 55 colado: detecta por tamanho total (12 ou 13 dígitos), não
// pelo tamanho esperado do campo específico — um número de 12
// dígitos colado no campo de celular ainda deve ter o 55 removido.
if ((digitos.length === 12 || digitos.length === 13) && digitos.slice(0, 2) === '55') {
digitos = digitos.slice(2);
}
digitos = digitos.slice(0, digitosAposDdd);
var ddd = digitos.slice(0, 2);
var resto = digitos.slice(2);
var meio = resto.slice(0, digitosAposDdd - 6);
var fim = resto.slice(digitosAposDdd - 6);
var formatado = '';
if (ddd) formatado += '(' + ddd;
if (ddd.length === 2) formatado += ') ';
formatado += meio;
if (fim) formatado += '-' + fim;
elemento.value = formatado;
});
}
var $telefone = document.getElementById('TELEFONE');
var $celular = document.getElementById('CELULAR');
// TELEFONE: DDD(2) + 8 dígitos = 10 dígitos após o DDI.
if ($telefone) aplicarMascaraTelefone($telefone, 10);
// CELULAR: DDD(2) + 9 dígitos (com o 9 na frente) = 11 dígitos após o DDI.
if ($celular) aplicarMascaraTelefone($celular, 11);
var $uf = document.getElementById('UF');
if ($uf) {
$uf.addEventListener('input', function () {
$uf.value = $uf.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2);
});
}
var $cep = document.getElementById('CEP');
if ($cep) {
$cep.addEventListener('input', function () {
var digitos = $cep.value.replace(/\D/g, '').slice(0, 8);
$cep.value = digitos.length > 5 ? digitos.slice(0, 5) + '-' + digitos.slice(5) : digitos;
});
}
var CAMPOS_PARA_PREENCHER = ['RAZAO', 'FANTASIA', 'ENDERECO', 'CIDADE', 'BAIRRO', 'UF', 'CEP', 'TELEFONE', 'EMAIL'];
function normalizarCnpj(valor) {
return (valor || '').toUpperCase().replace(/[^0-9A-Z]/g, '');
}
async function buscarCnpj() {
var limpo = normalizarCnpj($cnpj.value);
if (limpo.length !== 14) return;
if (limpo.length === 14 && !validarDvCnpj(limpo)) return;
if ($status) $status.textContent = 'Buscando dados do CNPJ...';
try {
var resposta = await fetch('/clientes/consulta-cnpj/' + encodeURIComponent(limpo));
var corpo = await resposta.json();
if (!resposta.ok || !corpo.encontrado) {
if ($status) $status.textContent = 'CNPJ não encontrado na Receita — preencha manualmente.';
return;
}
var preencheu = false;
CAMPOS_PARA_PREENCHER.forEach(function (campo) {
var $input = document.getElementById(campo);
if ($input && !$input.value && corpo.dados[campo]) {
$input.value = corpo.dados[campo];
$input.dispatchEvent(new Event('input'));
preencheu = true;
}
});
if ($status) {
$status.textContent = preencheu
? 'Dados preenchidos automaticamente a partir do CNPJ — confira antes de salvar.'
: 'CNPJ encontrado (campos já preenchidos não foram alterados).';
}
} catch (erro) {
if ($status) $status.textContent = '';
}
}
$cnpj.addEventListener('blur', buscarCnpj);
})();
</script>
{% endblock %}
@@ -0,0 +1,181 @@
<!-- app/templates/clientes_lista.html -->
{% extends "base.html" %}
{% block titulo %}Clientes{% endblock %}
{% block conteudo %}
<h1>Clientes</h1>
<div class="cartao">
<form method="get" action="/clientes">
<label for="ordenar_por">Buscar/ordenar por</label>
<select id="ordenar_por" name="ordenar_por">
<option value="razao" {% if ordenar_por == "razao" %}selected{% endif %}>Razão social</option>
<option value="codigo" {% if ordenar_por == "codigo" %}selected{% endif %}>Código</option>
<option value="fantasia" {% if ordenar_por == "fantasia" %}selected{% endif %}>Fantasia</option>
<option value="cnpj" {% if ordenar_por == "cnpj" %}selected{% endif %}>CNPJ</option>
</select>
<input type="text" name="busca" value="{{ busca or '' }}" placeholder="Buscar só no campo escolhido acima">
<label for="status">Status</label>
<select id="status" name="status">
<option value="ativos" {% if status == "ativos" %}selected{% endif %}>Ativos</option>
<option value="inativos" {% if status == "inativos" %}selected{% endif %}>Desativados ou inativos</option>
<option value="todos" {% if status == "todos" %}selected{% endif %}>Todos</option>
</select>
<button type="submit">Buscar</button>
</form>
</div>
<p><a class="botao" href="/clientes/novo">+ Novo cliente</a></p>
{% set busca_qs = ('&busca=' + (busca | urlencode)) if busca else '' %}
{% set qs_base = ('&ordenar_por=' + ordenar_por + '&direcao=' + direcao + '&por_pagina=' + (por_pagina | string) + '&status=' + status + busca_qs) | safe %}
<div class="tabela-wrap">
<table class="table-clientes">
<colgroup>
<col class="col-codigo">
<col class="col-razao">
<col class="col-fantasia">
<col class="col-cnpj">
<col class="col-status">
<col class="col-acoes">
</colgroup>
<tr>
{% set colunas_tabela = [("codigo", "Código", "col-codigo"), ("razao", "Razão", "col-razao"), ("fantasia", "Fantasia", "col-fantasia"), ("cnpj", "CNPJ", "col-cnpj")] %}
{% for campo, rotulo, classe_coluna in colunas_tabela %}
{% set nova_direcao = "desc" if (ordenar_por == campo and direcao == "asc") else "asc" %}
<th class="{{ classe_coluna }}">
<a href="/clientes?ordenar_por={{ campo }}&direcao={{ nova_direcao }}&pagina=1&por_pagina={{ por_pagina }}&status={{ status }}{{ busca_qs }}">
{{ rotulo }}{% if ordenar_por == campo %}{{ " ▲" if direcao == "asc" else " ▼" }}{% endif %}
</a>
</th>
{% endfor %}
<th class="col-status">Status</th>
<th class="col-acoes"></th>
</tr>
{% for c in clientes %}
<tr>
<td class="col-codigo">{{ c.CODIGO }}</td>
<td class="col-razao">{{ c.RAZAO }}</td>
<td class="col-fantasia">{{ c.FANTASIA }}</td>
<td class="col-cnpj">{{ c.CNPJ }}</td>
<td class="col-status">
<form method="post" action="/clientes/{{ c.ID }}/alternar-ativo" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirecionar_para" value="{{ redirecionar_para }}">
<button
type="submit"
class="status-dot {{ 'status-dot-ativo' if c.ATIVO else 'status-dot-inativo' }}"
title="{{ 'Ativo — clique para desativar' if c.ATIVO else 'Inativo — clique para ativar' }}"
onclick='return confirm({{ ((("Desativar" if c.ATIVO else "Reativar") + " o cliente " + c.RAZAO + "?")) | tojson }})'
>
<span class="sr-only">{{ "Ativo" if c.ATIVO else "Inativo" }}</span>
</button>
</form>
</td>
<td class="col-acoes">
<a class="botao botao-tabela" href="/clientes/{{ c.ID }}">Abrir</a>
<button type="button" class="botao botao-tabela botao-gerar-chave" data-codigo="{{ c.CODIGO }}" onclick="gerarChaveReativacao(this)">Gerar chave</button>
<span class="chave-feedback" aria-live="polite"></span>
</td>
</tr>
{% endfor %}
</table>
</div>
<div class="paginacao-rodape">
<form method="get" action="/clientes" class="paginacao-por-pagina">
<input type="hidden" name="ordenar_por" value="{{ ordenar_por }}">
<input type="hidden" name="direcao" value="{{ direcao }}">
<input type="hidden" name="pagina" value="1">
<input type="hidden" name="status" value="{{ status }}">
{% if busca %}<input type="hidden" name="busca" value="{{ busca }}">{% endif %}
<label for="por_pagina">Linhas por página</label>
<select id="por_pagina" name="por_pagina" onchange="this.form.submit()">
{% for opcao in opcoes_por_pagina %}
<option value="{{ opcao }}" {% if opcao == por_pagina %}selected{% endif %}>{{ opcao }}</option>
{% endfor %}
</select>
</form>
<span>Exibindo {{ primeiro_item }} a {{ ultimo_item }} de {{ total }} clientes</span>
{% if total_paginas and total_paginas > 1 %}
<div class="paginacao">
{% if pagina > 1 %}
<a class="botao" href="/clientes?pagina=1{{ qs_base }}" aria-label="Primeira página">&laquo;</a>
<a class="botao" href="/clientes?pagina={{ pagina - 1 }}{{ qs_base }}" aria-label="Página anterior">&lsaquo;</a>
{% endif %}
{% for p in janela_paginas %}
{% if p is none %}
<span class="paginacao-reticencias">&hellip;</span>
{% elif p == pagina %}
<span class="paginacao-atual">{{ p }}</span>
{% else %}
<a class="botao" href="/clientes?pagina={{ p }}{{ qs_base }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if pagina < total_paginas %}
<a class="botao" href="/clientes?pagina={{ pagina + 1 }}{{ qs_base }}" aria-label="Próxima página">&rsaquo;</a>
<a class="botao" href="/clientes?pagina={{ total_paginas }}{{ qs_base }}" aria-label="Última página">&raquo;</a>
{% endif %}
</div>
{% endif %}
</div>
<script>
// Gera a chave de reativação do mês vigente pro cliente da linha
// clicada, reaproveitando o endpoint já usado pelo gerador manual
// (/gerador-chave/gerar) — o algoritmo de cifra roda só no backend
// (app/gerador_chave.py), esta página só manda código+mês/ano e
// copia o resultado.
const CSRF_TOKEN = "{{ csrf_token }}";
function _mesAnoAtual() {
const hoje = new Date();
return `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`;
}
async function gerarChaveReativacao(botao) {
const codigo = botao.dataset.codigo;
const feedback = botao.parentElement.querySelector('.chave-feedback');
botao.disabled = true;
feedback.textContent = '';
feedback.classList.remove('chave-feedback-erro');
const corpo = new URLSearchParams({
csrf_token: CSRF_TOKEN,
tipo: 'reativacao',
codigo: codigo,
referencia: _mesAnoAtual(),
});
let resposta, dados;
try {
resposta = await fetch('/gerador-chave/gerar', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: corpo,
});
dados = await resposta.json();
} catch (e) {
feedback.textContent = 'Falha de rede — tente de novo.';
feedback.classList.add('chave-feedback-erro');
botao.disabled = false;
return;
}
if (!resposta.ok) {
feedback.textContent = dados.erro || 'Não foi possível gerar a chave.';
feedback.classList.add('chave-feedback-erro');
botao.disabled = false;
return;
}
try {
await navigator.clipboard.writeText(dados.chave);
feedback.textContent = 'Copiado!';
} catch (e) {
feedback.textContent = 'Chave gerada, mas não copiou automaticamente.';
feedback.classList.add('chave-feedback-erro');
}
botao.disabled = false;
setTimeout(() => {
feedback.textContent = '';
feedback.classList.remove('chave-feedback-erro');
}, 1500);
}
</script>
{% endblock %}
@@ -0,0 +1,46 @@
<!-- app/templates/funcionario_form.html -->
{% extends "base.html" %}
{% block titulo %}{{ "Editar" if funcionario else "Novo" }} funcionário{% endblock %}
{% block conteudo %}
<h1>{{ "Editar" if funcionario else "Novo" }} funcionário</h1>
{% if erro %}
<div class="mensagem danger">{{ erro }}</div>
{% endif %}
<div class="cartao">
<form method="post" action="{{ '/funcionarios/' + funcionario.ID|string + '/editar' if funcionario else '/funcionarios/novo' }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid-form">
<div>
<label for="nome">Nome</label>
<input type="text" id="nome" name="nome" value="{{ dados.NOME if dados else (funcionario.NOME if funcionario else '') }}" required>
</div>
<div>
<label for="login">Login</label>
<input type="text" id="login" name="login" value="{{ dados.LOGIN if dados else (funcionario.LOGIN if funcionario else '') }}" required>
</div>
<div>
<label for="perfil">Perfil</label>
{% set perfil_atual = dados.PERFIL if dados else (funcionario.PERFIL if funcionario else 'PADRAO') %}
<select id="perfil" name="perfil">
<option value="PADRAO" {{ "selected" if perfil_atual == "PADRAO" else "" }}>Padrão</option>
<option value="ADMIN" {{ "selected" if perfil_atual == "ADMIN" else "" }}>Administrador</option>
</select>
</div>
{% if not funcionario %}
<div>
<label for="senha">Senha</label>
<input type="password" id="senha" name="senha" required>
</div>
<div>
<label for="confirmar_senha">Confirmar senha</label>
<input type="password" id="confirmar_senha" name="confirmar_senha" required>
</div>
{% endif %}
</div>
<button type="submit">Salvar</button>
</form>
</div>
{% if funcionario %}
<p><small>Para trocar a senha, use "Redefinir senha" na lista de funcionários.</small></p>
{% endif %}
{% endblock %}
@@ -0,0 +1,40 @@
<!-- app/templates/funcionarios_lista.html -->
{% extends "base.html" %}
{% block titulo %}Funcionários{% endblock %}
{% block conteudo %}
<h1>Funcionários</h1>
<p><a class="botao" href="/funcionarios/novo">+ Novo funcionário</a></p>
<div class="tabela-wrap">
<table>
<tr><th>Nome</th><th>Login</th><th>Perfil</th><th>Status</th><th></th></tr>
{% for f in funcionarios %}
<tr>
<td>{{ f.NOME }}</td>
<td>{{ f.LOGIN }}</td>
<td><span class="badge {{ 'badge-acento' if f.PERFIL == 'ADMIN' else 'badge-neutro' }}">{{ "Administrador" if f.PERFIL == "ADMIN" else "Padrão" }}</span></td>
<td><span class="badge {{ 'badge-sucesso' if f.ATIVO else 'badge-neutro' }}">{{ "Ativo" if f.ATIVO else "Inativo" }}</span></td>
<td>
<a class="botao" href="/funcionarios/{{ f.ID }}/editar">Editar</a>
<details style="display:inline-block">
<summary class="botao" style="display:inline-block;cursor:pointer">Redefinir senha</summary>
<form method="post" action="/funcionarios/{{ f.ID }}/redefinir-senha" style="margin-top:8px">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="nova_senha-{{ f.ID }}">Nova senha</label>
<input type="password" id="nova_senha-{{ f.ID }}" name="nova_senha" required>
<label for="confirmar_nova_senha-{{ f.ID }}">Confirmar nova senha</label>
<input type="password" id="confirmar_nova_senha-{{ f.ID }}" name="confirmar_nova_senha" required>
<button type="submit">Salvar senha</button>
</form>
</details>
<form method="post" action="/funcionarios/{{ f.ID }}/alternar-ativo" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('{{ "Desativar" if f.ATIVO else "Reativar" }} este funcionário?')">
{{ "Desativar" if f.ATIVO else "Reativar" }}
</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block titulo %}Gerador de Chave{% endblock %}
{% block conteudo %}
<div class="cartao" style="max-width:420px;margin:0 auto;">
<h1 style="text-align:center;">Gerador de Chave</h1>
<label for="tipo">Tipo de chave</label>
<select id="tipo">
<option value="reativacao">Reativação (mês/ano)</option>
<option value="instalacao">Instalação (aluguel/compra)</option>
</select>
<label for="codigo">Código do cliente</label>
<input type="text" id="codigo" placeholder="Ex: 000123" maxlength="6" inputmode="numeric">
<div id="campos-reativacao">
<label for="referencia">Mês/ano de referência</label>
<input type="month" id="referencia" min="2000-01" max="2100-12">
</div>
<div id="campos-instalacao" style="display:none;">
<label for="modalidade">Modalidade</label>
<select id="modalidade">
<option value="ALUGUEL">Aluguel</option>
<option value="COMPRA">Compra</option>
</select>
</div>
<button id="gerar">Gerar chave</button>
<div id="erro" style="color:var(--cor-perigo);margin-top:14px;display:none;"></div>
<div id="resultado" style="margin-top:20px;padding:14px;border-radius:8px;background:var(--cor-superficie-alt);border:1px solid var(--cor-borda);border-left:3px solid var(--cor-acento);word-break:break-all;font-family:'Courier New',monospace;color:var(--cor-acento-texto);display:none;"></div>
<button id="copiar" style="display:none;background:transparent;border:1px solid var(--cor-borda);color:var(--cor-texto-secundario);width:auto;padding:6px 10px;">Copiar</button>
</div>
<script>
// O algoritmo de geração de chave roda só no backend (app/gerador_chave.py)
// — esta página só manda os dados do formulário pro endpoint e mostra o
// resultado. Nunca reintroduza a lógica de cifra aqui: qualquer pessoa com
// acesso à página consegue ler o código-fonte JS pelo navegador.
const CSRF_TOKEN = "{{ csrf_token }}";
const $tipo = document.getElementById('tipo');
const $codigo = document.getElementById('codigo');
const $referencia = document.getElementById('referencia');
const $modalidade = document.getElementById('modalidade');
const $camposReativacao = document.getElementById('campos-reativacao');
const $camposInstalacao = document.getElementById('campos-instalacao');
const $erro = document.getElementById('erro');
const $resultado = document.getElementById('resultado');
const $copiar = document.getElementById('copiar');
$codigo.addEventListener('input', () => {
$codigo.value = $codigo.value.replace(/\D/g, '');
});
(function preencherMesAtual() {
const hoje = new Date();
$referencia.value = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`;
})();
$tipo.addEventListener('change', () => {
const ehReativacao = $tipo.value === 'reativacao';
$camposReativacao.style.display = ehReativacao ? 'block' : 'none';
$camposInstalacao.style.display = ehReativacao ? 'none' : 'block';
});
function mostrarErro(msg) {
$erro.textContent = msg;
$erro.style.display = 'block';
$resultado.style.display = 'none';
$copiar.style.display = 'none';
}
document.getElementById('gerar').addEventListener('click', async () => {
$erro.style.display = 'none';
const corpo = new URLSearchParams({
csrf_token: CSRF_TOKEN,
tipo: $tipo.value,
codigo: $codigo.value,
referencia: $referencia.value,
modalidade: $modalidade.value,
});
let resposta, dados;
try {
resposta = await fetch('/gerador-chave/gerar', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: corpo,
});
dados = await resposta.json();
} catch (e) {
return mostrarErro('Não foi possível falar com o servidor — tente de novo.');
}
if (!resposta.ok) return mostrarErro(dados.erro || 'Não foi possível gerar a chave.');
$resultado.textContent = dados.chave;
$resultado.style.display = 'block';
$copiar.style.display = 'inline-block';
});
$copiar.addEventListener('click', () => {
navigator.clipboard.writeText($resultado.textContent).then(() => {
$copiar.textContent = 'Copiado!';
setTimeout(() => { $copiar.textContent = 'Copiar'; }, 1500);
});
});
</script>
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
<!-- app/templates/log.html -->
{% extends "base.html" %}
{% block titulo %}Log{% endblock %}
{% block conteudo %}
<h1>Log</h1>
<details class="secao-log">
<summary>
<span class="secao-log-titulo">Ações no painel</span>
<button type="button" class="botao-toggle-log" aria-expanded="false">Mostrar</button>
</summary>
<div class="secao-log-conteudo">
{% if eh_admin %}
<form method="post" action="/log/limpar" style="margin-bottom:14px">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Limpar todo o histórico de ações? Isso não pode ser desfeito.')">Limpar log</button>
</form>
{% endif %}
{% if acoes %}
<div class="tabela-wrap">
<table>
<tr><th>Quando</th><th>Usuário</th><th>Ação</th><th>Entidade</th><th>Status</th><th>Detalhe</th></tr>
{% for a in acoes %}
<tr>
<td>{{ a.CRIADO_EM }}</td>
<td>{{ a.USUARIO }}</td>
<td>{{ a.ACAO }}</td>
<td>{{ a.ENTIDADE }}{% if a.ENTIDADE_ID %} #{{ a.ENTIDADE_ID }}{% endif %}</td>
<td><span class="badge {{ 'badge-sucesso' if a.SUCESSO else 'badge-neutro' }}">{{ "OK" if a.SUCESSO else "ERRO" }}</span></td>
<td>{{ a.DETALHE or "" }}</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p>Nenhuma ação registrada ainda.</p>
{% endif %}
</div>
</details>
<details class="secao-log">
<summary>
<span class="secao-log-titulo">Envios automáticos de chave (n8n)</span>
<button type="button" class="botao-toggle-log" aria-expanded="false">Mostrar</button>
</summary>
<div class="secao-log-conteudo">
{% if not envios_configurado %}
<div class="cartao">
<p>
Integração com o n8n não configurada — defina <code>N8N_API_URL</code> e
<code>N8N_API_KEY</code> no ambiente (ver <code>.env.example</code>) para ver
aqui os envios automáticos de chave feitos pelo workflow do n8n.
</p>
</div>
{% elif not envios %}
<p>Nenhum envio encontrado (ou não foi possível consultar o n8n agora).</p>
{% else %}
<div class="tabela-wrap">
<table>
<tr>
<th>Data</th><th>Cliente</th><th>Código</th><th>Competência</th><th>Status</th>
<th>Telefones enviados</th><th>Telefones com erro</th>
</tr>
{% for e in envios %}
<tr>
<td>{{ e.data_processamento }}</td>
<td>{{ e.nome_cliente }}</td>
<td>{{ e.codigo_cliente }}</td>
<td>{{ e.competencia }}</td>
<td>{{ e.status }}</td>
<td>{{ e.telefones_enviados }}</td>
<td>{{ e.telefones_com_erro or "" }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
</details>
<script>
document.querySelectorAll(".secao-log").forEach(function (secao) {
var botao = secao.querySelector(".botao-toggle-log");
var atualizar = function () {
var aberto = secao.hasAttribute("open");
botao.textContent = aberto ? "Ocultar" : "Mostrar";
botao.setAttribute("aria-expanded", aberto ? "true" : "false");
};
// Um <button> dentro de <summary> é ele mesmo um elemento interativo:
// o navegador não propaga o clique para o toggle nativo do <details>
// quando o clique começa em outro controle interativo. Por isso o
// toggle precisa ser feito manualmente aqui.
botao.addEventListener("click", function (evento) {
evento.preventDefault();
secao.open = !secao.open;
atualizar();
});
secao.addEventListener("toggle", atualizar);
atualizar();
});
</script>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
<!-- app/templates/login.html -->
<!DOCTYPE html>
<html lang="pt-BR" data-tema="escuro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Entrar — Painel de Clientes</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/estilo.css?v={{ request.state.asset_versao }}">
<style>
body {
display: flex; align-items: center; justify-content: center;
min-height: 100vh; padding: 16px;
background: radial-gradient(circle at top, var(--cor-superficie-alt), var(--cor-fundo));
}
.login-cartao { width: 100%; max-width: 360px; text-align: center; }
.login-logo { width: 64px; height: 64px; border-radius: 16px; margin-bottom: 12px; }
.login-cartao h1 { font-size: 1.3rem; margin-bottom: 4px; }
.login-subtitulo { color: var(--cor-texto-secundario); font-size: 0.9rem; margin: 0 0 20px; }
.login-cartao form { text-align: left; }
.login-cartao > form > button[type="submit"] { width: 100%; }
.campo-senha {
display: flex; align-items: center;
border: 1px solid var(--cor-borda); border-radius: 8px; background: var(--cor-fundo);
}
.campo-senha input {
flex: 1; min-width: 0; border: none; background: transparent;
}
.campo-senha input:focus-visible { outline: none; }
.campo-senha:focus-within { outline: 2px solid var(--cor-acento); outline-offset: 2px; }
.campo-senha .botao-ver-senha {
flex-shrink: 0; margin: 0; padding: 0 12px; width: auto; height: 100%;
background: transparent; border: none; color: var(--cor-texto-secundario);
cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.campo-senha .botao-ver-senha:hover { color: var(--cor-texto); }
.campo-senha .botao-ver-senha svg { width: 18px; height: 18px; }
</style>
</head>
<body>
<div class="cartao login-cartao">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="login-logo">
<h1>Painel de Clientes</h1>
<p class="login-subtitulo">Entre com suas credenciais para acessar o sistema</p>
{% if erro %}<div class="mensagem danger">{{ erro }}</div>{% endif %}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="next" value="{{ next }}">
<label for="usuario">Usuário</label>
<input type="text" id="usuario" name="usuario" required autofocus>
<label for="senha">Senha</label>
<div class="campo-senha">
<input type="password" id="senha" name="senha" required>
<button type="button" class="botao-ver-senha" id="botao-ver-senha" aria-label="Mostrar senha">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8Z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
<button type="submit">Entrar</button>
</form>
</div>
<script>
(function () {
var botao = document.getElementById('botao-ver-senha');
var campo = document.getElementById('senha');
if (!botao || !campo) return;
botao.addEventListener('click', function () {
var mostrando = campo.type === 'text';
campo.type = mostrando ? 'password' : 'text';
botao.setAttribute('aria-label', mostrando ? 'Mostrar senha' : 'Ocultar senha');
});
})();
</script>
</body>
</html>
@@ -0,0 +1,35 @@
<!-- app/templates/minha_conta.html -->
{% extends "base.html" %}
{% block titulo %}Minha conta{% endblock %}
{% block conteudo %}
<h1>Minha conta</h1>
<div class="cartao">
<p><strong>Nome:</strong> {{ usuario_atual.nome }}</p>
<p><strong>Login:</strong> {{ usuario_atual.login }}</p>
</div>
{% if erro %}<div class="mensagem danger">{{ erro }}</div>{% endif %}
{% if mensagem %}<div class="mensagem success">{{ mensagem }}</div>{% endif %}
{% if eh_conta_env %}
<div class="mensagem warning">
Esta conta é a conta de administrador configurada por variável de ambiente
(ADMIN_USER/ADMIN_PASSWORD no .env do servidor) — a senha se troca lá,
não existe uma linha correspondente na tabela de funcionários.
</div>
{% else %}
<h2>Trocar senha</h2>
<div class="cartao">
<form method="post" action="/minha-conta/senha">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="senha_atual">Senha atual</label>
<input type="password" id="senha_atual" name="senha_atual" required>
<label for="nova_senha">Nova senha</label>
<input type="password" id="nova_senha" name="nova_senha" required>
<label for="confirmar_nova_senha">Confirmar nova senha</label>
<input type="password" id="confirmar_nova_senha" name="confirmar_nova_senha" required>
<button type="submit">Salvar nova senha</button>
</form>
</div>
{% endif %}
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
<!-- app/templates/sso_fiscal.html — entrega a asserção ao Fiscal por POST (nunca na URL) -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Abrindo o GTI Fiscal…</title>
<style>
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #121214; color: #f4f4f5; }
p { color: #9a9aa2; }
button { padding: 10px 16px; border-radius: 8px; border: 0; background: #F5821F; color: #17171a; font-weight: 700; cursor: pointer; }
</style>
</head>
<body>
<form method="post" action="{{ acao }}" id="sso-fiscal">
<input type="hidden" name="assertion" value="{{ assercao }}">
<input type="hidden" name="next" value="{{ proximo }}">
<p>Abrindo o GTI Fiscal…</p>
<noscript><button type="submit">Continuar</button></noscript>
</form>
<script>document.getElementById("sso-fiscal").submit();</script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
services:
painel:
build: .
ports:
- "8010:8000"
env_file:
- .env
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
@@ -0,0 +1,698 @@
# GTI Gerenciador — Discovery Técnico (v1)
> **Nota crítica de escopo, apurada nesta missão**: "GTI Gerenciador" é o nome
> comercial/de produção do repositório `painel-clientes-web`. Não existe um
> projeto separado com esse nome — confirmado com o usuário após uma busca
> read-only que não encontrou nenhuma pasta `*gerenciador*` em `D:\PROJETOS`
> nem em `10.0.10.117`. **O sistema real é um painel de gestão de
> clientes/licenciamento** (cadastro de clientes, funcionários, geração de
> chave de reativação, log de auditoria) — **não** um gerenciador de frota/RMM
> com Agents Windows, heartbeat, jobs e blockers, como o briefing da missão
> presumia. As seções abaixo refletem o sistema real; onde o briefing pedia
> algo que não existe (ex.: módulo de Agents), isso é dito explicitamente, não
> inventado.
## 1. Identidade do repositório
```
PROJECT_NAME=painel-clientes-web ("GTI Gerenciador" em produção)
REPOSITORY_PATH=D:\PROJETOS\BLOQUEIO SGV\painel-clientes-web
CURRENT_BRANCH=master
HEAD_COMMIT=ce718f7ac22a583fd8c5d3a23415a19dcfc31291 (2026-08-20)
WORKING_TREE_STATUS=limpo (só ../.claude/ não rastreado, fora do repo)
GIT_REMOTE=nenhum configurado — repositório só local, nunca teve push
```
Estrutura principal (`app/`):
- `routers/`: auth, clientes, contatos, funcionarios, log, minha_conta, chave_manual
- `db/`: clientes, contatos, funcionarios, log, chaves, connection — SQL cru via PyMySQL, sem ORM
- `templates/`: Jinja2 server-rendered (base.html + uma página por rota)
- `static/`: CSS puro + JS vanilla inline
- `auth.py`, `csrf.py`, `session.py`, `senha.py`, `rate_limit.py`, `n8n_client.py`, `gerador_chave.py`, `cnpj_lookup.py`
- `docs/`, `docs/superpowers/specs|plans` — specs e planos de cada feature (fluxo de desenvolvimento assistido por IA usado neste projeto)
Não há pasta `frontend/`, `migrations/` (Alembic) ou `deployment/` separada — é
um monólito único, com deploy manual (não há `migrations/`; mudanças de schema
são `ALTER TABLE` manuais documentadas em docstring + README).
## 2. Stack da aplicação
```
BACKEND_FRAMEWORK=FastAPI
BACKEND_LANGUAGE=Python
BACKEND_VERSION=3.12 (imagem Docker python:3.12-slim)
FRONTEND_TYPE=server-rendered
FRONTEND_FRAMEWORK=Jinja2Templates (FastAPI)
FRONTEND_BUILD_CHAIN=nenhuma (sem bundler/npm — CSS/JS estáticos escritos à mão)
DATABASE=MySQL 5.7
ORM=nenhum (PyMySQL cru, SQL parametrizado manualmente)
MIGRATION_TOOL=nenhum (ALTER TABLE manual, documentado em docstring do módulo + README.md)
CACHE=nenhum
QUEUE=nenhuma
BACKGROUND_JOBS=nenhum (tudo síncrono, request/response)
WEBSERVER=uvicorn (ASGI, standalone — sem gunicorn na frente)
APPLICATION_SERVER=uvicorn[standard]==0.30.6
```
Dependências (`requirements.txt`, todas pinadas por auditoria de segurança de
2026-08-15): `fastapi==0.115.6`, `uvicorn[standard]==0.30.6`, `jinja2==3.1.6`,
`python-multipart==0.0.20`, `PyMySQL==1.1.1`, `python-dotenv==1.0.1`,
`httpx==0.27.2` (usado só para chamar a API do n8n).
**Classificação**: monolito único, server-rendered, sem frontend separado.
## 3. Funcionalidades atuais
| MODULE_NAME | PURPOSE | MAIN_ROUTES | MAIN_TABLES | DEPENDENCIES |
|---|---|---|---|---|
| Clientes | CRUD de clientes (CNPJ, contato, status ativo/inativo) | `/clientes`, `/clientes/{id}`, `/clientes/{id}/alternar-ativo` | `CLIENTE` | `cnpj_lookup.py` (consulta CNPJ externa) |
| Contatos | Contatos por cliente (nome/telefone/e-mail) | rotas aninhadas sob `/clientes/{id}` | `CLIENTE_CONTATO` | — |
| Funcionários | Usuários do painel (login/senha/perfil ADMIN\|PADRAO) | `/funcionarios` | `FUNCIONARIO_PAINEL` | `senha.py` (scrypt) |
| Gerador de chave | Gera chave de reativação/liberação (cifra própria) | `/gerador-chave`, `/gerador-chave/gerar` | — (sem persistência própria) | `gerador_chave.py` |
| Log | Auditoria de ações do painel + espelho de envios automáticos do n8n | `/log`, `/log/limpar` (admin) | `LOG_PAINEL` | `n8n_client.py` (lê Data Table do n8n via API, não escreve) |
| Autenticação | Login humano (admin de ambiente + funcionários) | `/login`, `/logout` | `FUNCIONARIO_PAINEL` + `ADMIN_USER`/`ADMIN_PASSWORD` (env) | `session.py`, `csrf.py` |
| Minha conta | Trocar a própria senha | `/minha-conta` | `FUNCIONARIO_PAINEL` | — |
**Não existe**: módulo de servidores, agentes, backups, monitoramento,
inventário ou "integrações" no sentido do briefing original — o sistema é
puramente sobre clientes/licenciamento, não sobre gestão de infraestrutura de
terceiros.
## 4. Autenticação e autorização
```
AUTH_MODEL=cookie de sessão assinado por HMAC-SHA256 (chave própria em session.py,
não é JWT de biblioteca) + cookie CSRF de double-submit
USER_TABLE=FUNCIONARIO_PAINEL (login, hash de senha, PERFIL)
ROLE_MODEL=dois perfis fixos: ADMIN e PADRAO (sem granularidade por permissão)
PERMISSION_MODEL=checagem direta de perfil nas rotas (Depends(exigir_admin) vs
Depends(exigir_login)) — não é RBAC configurável
```
- Existe também um **login "raiz" fora da tabela**: usuário/senha vindos de
`ADMIN_USER`/`ADMIN_PASSWORD` (variáveis de ambiente), comparação direta —
serve de acesso de emergência independente do banco.
- Senha de funcionário: `scrypt` (stdlib do Python, sem bcrypt/argon2/passlib).
- Sessão: HMAC-SHA256 sobre payload com `login`, `nome`, `perfil`, `exp` —
**validade de 12 horas**, sem refresh automático (expira, força novo login).
- CSRF: cookie `csrf_token` (HttpOnly) + campo hidden em todo formulário POST,
comparados no servidor (double-submit).
- Logout: limpa cookie de sessão.
**Reuso por GTI Fiscal**: o modelo de sessão (HMAC próprio) é simples e
replicável, mas está fortemente amarrado a este processo Python específico
(chave HMAC em memória do processo/env, sem endpoint de validação exposto
para outro serviço consumir). Reuso real exigiria **extrair isso para um
serviço de auth compartilhado** ou aceitar duplicar a lógica — não há hoje um
"SSO" ou endpoint `/auth/validar` pronto para outro backend chamar.
## 5. Banco de dados
```
DB_ENGINE=MySQL
DB_VERSION=5.7 (imagem Docker mysql:5.7)
DB_HOST_MODEL=container único (gtisoftw-mysql), sem replicação/cluster
DB_NAME=gtisoftw_clientes_dev (nome de dev usado também em produção — dívida
histórica de nomenclatura, não um ambiente de teste real)
MIGRATION_HEAD=n/a (sem Alembic/ferramenta — ALTER TABLE manual documentado)
```
Tabelas principais conhecidas: `CLIENTE`, `CLIENTE_CONTATO`,
`FUNCIONARIO_PAINEL`, `LOG_PAINEL` (+ tabelas do sistema legado
`licenciamento-n8n` que compartilham o mesmo banco, fora do escopo direto do
painel). Total de ~9 tabelas segundo o dump de referência mais recente
(`Banco para Prod/README-DEPLOY-PROXMOX.md`).
- Volume de dados: pequeno — ~209 clientes, 127 contatos (dump de referência).
- Pool/connections: sem pool — `PyMySQL` abre e fecha conexão por request
(`get_connection()`/`conn.close()` a cada operação), aceitável no volume
atual mas não escalaria bem sob concorrência alta.
- Backup: **não encontrei rotina de backup automatizada** no servidor (sem
cron de dump, sem script de backup do container) — só existe o
procedimento manual documentado em `Banco para Prod/README-DEPLOY-PROXMOX.md`
para popular/restaurar a partir de um dump.
- Persistência: volume Docker nomeado (`gtisoftw_mysql_data`, implícito pelo
compose de `Banco para Prod/`), não bind-mount.
```
CAN_GTI_FISCAL_SHARE_DATABASE=requires_analysis
```
| Opção | Vantagens | Riscos |
|---|---|---|
| A) Mesmo PostgreSQL, schemas separados | Simples de administrar (1 backup, 1 instância), sem novo processo de banco | GTI Fiscal quer **PostgreSQL**, este ambiente só roda **MySQL 5.7** — não é "mesmo Postgres", seria instalar um segundo motor de banco do zero |
| B) Bancos lógicos separados no mesmo motor | N/A aqui pelo mesmo motivo acima | — |
| C) PostgreSQL separado no mesmo host | Cada stack usa o motor que já foi desenhado para ela, sem gambiarra de schema | Mais um processo residente numa VM que já está com RAM apertada (ver §22) |
Dado que GTI Fiscal foi desenhado para PostgreSQL e este ambiente só tem
MySQL, a opção realista é **C) PostgreSQL próprio no mesmo host** (se houver
recursos — ver §22) — não há cenário realista de "compartilhar" o banco atual
entre os dois sistemas.
## 6. Ambiente de execução
```
HOSTNAME=srvbd
OS=Ubuntu 24.04.4 LTS
KERNEL=Linux 6.8.0-137-generic
VIRTUALIZATION_VISIBLE=kvm (systemd-detect-virt)
CONTAINER_OR_VM=VM (QEMU/KVM — "Standard PC (i440FX + PIIX, 1996)", vendor QEMU)
```
Confirmado como **VM dentro de um host Proxmox**: a pasta local
`Banco para Prod/README-DEPLOY-PROXMOX.md` (deste mesmo repositório) documenta
explicitamente o processo de deploy do banco "na VM/LXC do Proxmox" — este
servidor (10.0.10.117) é essa VM.
```
CPU_CORES=2
RAM_TOTAL=1.9 GiB
RAM_AVAILABLE=~1.2 GiB (742 MiB em uso, 1.0 GiB em buff/cache)
DISK_TOTAL=8.1 GiB (partição raiz)
DISK_FREE=1.4 GiB (82% em uso)
LOAD=0.08, 0.02, 0.01 (ociosa)
UPTIME=36 dias
```
Só leitura — nenhum comando de escrita executado.
## 7. Docker / containers
```
DOCKER_VERSION=29.7.2
COMPOSE_VERSION=v5.4.0
```
| NAME | IMAGE | STATUS | PORTS | NETWORKS |
|---|---|---|---|---|
| `painel-clientes-web-painel-1` | `painel-clientes-web-painel` (build local) | Up 4 semanas | `0.0.0.0:80->8000/tcp` | `default` + `bancoparaprod_default` (externa) |
| `gtisoftw-mysql` | `mysql:5.7` | Up 5 semanas | `0.0.0.0:3306->3306/tcp` | `bancoparaprod_default` (dedução — mesmo network externo usado pelo painel) |
`docker-compose.yml` do painel (em `/home/administrador/painel-clientes-web/`):
```yaml
services:
painel:
build: .
ports: ["80:8000"]
env_file: [.env]
networks: [default, bancoparaprod_default]
restart: unless-stopped
networks:
bancoparaprod_default:
external: true
```
O container do MySQL vem de um **compose separado** (`Banco para Prod/`,
network `bancoparaprod_default` criada por ele, consumida como externa pelo
painel) — os dois projetos/composes são independentes, unidos só pela network
Docker compartilhada. Sem healthcheck explícito em nenhum dos dois serviços.
## 8. Recursos usados atualmente
```
painel-clientes-web-painel-1: CPU 0.12% RAM 78.8 MiB
gtisoftw-mysql: CPU 0.07% RAM 199.4 MiB
```
```
CURRENT_RESOURCE_PRESSURE=baixa em CPU (2 vCPU quase ociosos); MODERADA em RAM
(só a folga não-cache é ~200-400MiB reais depois
de reservar o que o SO/buffers já usam) e ALTA em
disco (82% da partição raiz de 8GB já ocupado)
HEADROOM_ESTIMATE=CPU: folgado. RAM: ~1.2GiB "available" reportado pelo
kernel, mas isso conta cache reclamável — para uma
segunda aplicação Python + segundo banco (Postgres) a
margem real é apertada, não confortável. Disco: crítico
— só 1.4GiB livres, qualquer novo serviço com imagem
Docker própria (Postgres + Hub FastAPI) provavelmente
não cabe sem aumentar o disco da VM antes.
```
## 9. Portas e rede
| SERVICE | LISTEN_ADDRESS | PORT | EXPOSED_EXTERNALLY | PURPOSE |
|---|---|---|---|---|
| painel-clientes-web (uvicorn) | 0.0.0.0 | 80 | sim (direto, sem proxy na frente) | HTTP da aplicação |
| MySQL | 0.0.0.0 | 3306 | **sim** | Banco relacional |
| SSH | 0.0.0.0 | 22 | sim | Administração |
| systemd-resolved | 127.0.0.53/127.0.0.54 | 53 | não | DNS local |
Nenhuma porta foi aberta ou alterada — apenas listadas via `ss -tlnp`.
## 10. Acesso web
```
CURRENT_URL=http://10.0.10.117/ (IP direto, porta 80)
DOMAIN=nenhum domínio público configurado
HTTPS=não (confirma pendência já registrada na auditoria de segurança de
2026-08-15: "falta de TLS em produção é pendência aceita
conscientemente")
TLS_PROVIDER=n/a
```
Fluxo real: `Internet/rede interna → 10.0.10.117:80 → uvicorn (direto)`. Não
há DNS/Cloudflare nem qualquer camada intermediária.
## 11. Reverse proxy
```
REVERSE_PROXY_TYPE=NENHUM instalado (nginx/caddy/traefik ausentes no host)
REVERSE_PROXY_LOCATION=n/a
TLS_TERMINATION=n/a
PROXY_TARGET=n/a
```
Isso é uma lacuna relevante para a integração: hoje **não existe** onde
configurar `/fiscal` ou `fiscal.dominio` — seria preciso instalar um reverse
proxy do zero (Caddy é o mais barato em RAM para esta VM apertada) antes de
sequer cogitar rotear dois serviços por hostname/porta 80/443 únicos.
## 12. Firewall e exposição
```
PUBLICLY_EXPOSED_PORTS=22 (SSH), 80 (aplicação), 3306 (MySQL)
```
**MySQL está exposto publicamente** (`0.0.0.0:3306`), não só em loopback/rede
interna — isso já era uma pendência independente desta missão, mas relevante
para decidir se um segundo banco (Postgres do GTI Fiscal) repete o mesmo
padrão ou é mantido só em rede interna Docker. Não alterei nenhuma regra.
## 13. Backup
```
BACKUP_STATUS=inexistente como rotina automatizada
RESTORE_PROCEDURE=manual, documentado (Banco para Prod/README-DEPLOY-PROXMOX.md)
— restaura a partir de dump SQL versionado no repo local,
não a partir de um backup do estado de produção atual
LAST_BACKUP_IF_VISIBLE=não há evidência de nenhum backup de produção
rodado — dumps encontrados localmente são snapshots
pontuais feitos manualmente (ex.:
dump-gtisoftw_clientes_dev-20260806_104136.sql)
```
## 14. Logs / observabilidade
```
LOGGING_MODEL=stdout do container (docker logs), mais LOG_PAINEL (tabela de
auditoria de ações do painel, não logs técnicos)
MONITORING_MODEL=nenhum (sem Prometheus/Grafana/Loki/Zabbix — nada instalado
no host além do sysstat padrão do Ubuntu)
```
Eventos do GTI Fiscal (heartbeat, jobs, blockers) **não têm onde se integrar**
hoje — não existe stack de observabilidade centralizada nesta VM para
"encaixar" nele.
## 15. Serviços do sistema
- `systemd`: só serviços padrão do Ubuntu + Docker daemon. Nenhum serviço
systemd customizado para o painel (ele roda via `restart: unless-stopped`
do próprio Docker, não via unit systemd).
- `cron`: só as entradas padrão do Ubuntu (`cron.hourly/daily/weekly/monthly`
via anacron) — nenhum job customizado do projeto.
- Sem Windows services (host é Linux).
## 16. Deploy atual
```
DEPLOY_PROCEDURE=manual, via script Python ad-hoc (paramiko/SFTP) rodado pelo
desenvolvedor: empacota o repo local em .tar.gz (excluindo
docker-compose.yml e .env de produção), sobe por SFTP,
extrai por cima do diretório existente na VM, roda
"docker compose build" (com retry manual em caso de timeout
do registro Docker Hub) e "docker compose up -d". Migração
de schema (quando há) é um ALTER TABLE manual rodado à parte,
documentado no docstring do módulo Python afetado.
ROLLBACK_PROCEDURE=NÃO documentado/automatizado — não há tag de imagem
anterior preservada nem processo de reverter; reverter
exigiria checkout de um commit anterior local e repetir
o deploy manual.
```
Não há CI/CD, GitHub Actions, ou pipeline — é 100% manual, disparado por uma
pessoa a cada mudança.
## 17. Estrutura do frontend
- **Layout**: `app/templates/base.html` — sidebar fixa com navegação (ícones
SVG inline), área de conteúdo à direita, tema claro/escuro via
`localStorage` + atributo `data-tema` no `<html>`.
- **Templates**: um arquivo Jinja2 por página, todos estendendo `base.html`
via `{% block conteudo %}`. Sem componentização (sem includes/macros
reutilizáveis além do layout base).
- **CSS**: um único `app/static/estilo.css`, variáveis CSS para cores
(tema claro/escuro), sem framework (sem Tailwind/Bootstrap).
- **Identidade visual**: logo GTI (`logo-gti-256x256.png`), paleta escura por
padrão com acento laranja — a UI já é **rotulada "GTI"** na sidebar
("Painel de Clientes").
- **Auth nos templates**: `request.state.usuario` (setado por middleware)
controla o que aparece na sidebar (ex.: "Funcionários" só para admin).
```
CAN_ADD_FISCAL_MENU=sim, tecnicamente simples — é só adicionar um <a> na
sidebar de base.html com a mesma checagem de
request.state.usuario.eh_admin, IF o Fiscal virar rota
deste mesmo processo FastAPI (Opção A da seção 20)
CAN_REUSE_LAYOUT=sim, mas exigiria portar o CSS de tema claro/escuro e a
sidebar para dentro do stack do GTI Fiscal (que hoje
também é Jinja2 — compatível em princípio) SE a integração
for Opção C (backend separado, UI incorporada)
CAN_REUSE_AUTH=parcialmente — ver §4: o modelo é simples de replicar/portar,
mas não existe hoje um serviço de auth desacoplado para
"importar" sem duplicar código
```
## 18. APIs e integrações
Inventário de rotas por módulo (ver §3). Não há uma "API" no sentido REST
externo — todas as rotas retornam HTML (exceto `/gerador-chave/gerar`, que
retorna JSON, chamado via `fetch()` do próprio frontend do painel). Não há
autenticação por token/Bearer em lugar nenhum — só cookie de sessão.
Integração externa existente: `app/n8n_client.py` — chama a API REST de uma
instância n8n (`N8N_API_URL`/`N8N_API_KEY` via env) para **ler** (não
escrever) uma Data Table de envios automáticos de chave, exibida em `/log`.
É a única integração "tipo API" que este sistema tem.
**Não existe** conceito de `client`/`server`/`installation`/`agent` no
sentido do GTI Fiscal — o mais próximo é `CLIENTE` (empresa/licenciada), que
é uma entidade de **negócio/cobrança**, não de infraestrutura técnica (não
tem hostname, IP, SO, ou qualquer atributo de "servidor monitorado").
## 19. Modelo de cliente/servidor
```
CLIENTE = empresa licenciada (CODIGO, RAZAO, FANTASIA, CNPJ, ATIVO,
DIA_VENCIMENTO) — chave primária ID (int), CODIGO é o
identificador de negócio usado no gerador de chave
CLIENTE_CONTATO = contatos humanos daquele cliente (nome/telefone/e-mail),
FK CLIENTE_ID
```
Não existem `EMPRESA` (é o mesmo que `CLIENTE`), `SERVIDOR`, `INSTALAÇÃO`,
nem `AGENT` neste sistema.
Comparando com o GTI Fiscal (`tenant/client`, `installation_id`, `agent_id`,
`hostname`, contexto de empresa): a única sobreposição conceitual real é
**cliente/empresa** — `CLIENTE.CNPJ` poderia, em teoria, ser a chave de
correlação entre "quem é o cliente" nos dois sistemas (mesmo CNPJ = mesma
empresa), mas isso exigiria um design explícito de correlação (ex.: o Fiscal
guardar o `CLIENTE_ID` ou `CODIGO` deste sistema como referência externa) —
**não existe isso hoje**, seria trabalho novo de integração, não algo pronto
para reaproveitar.
## 20. Possível integração do GTI Fiscal
**Opção A — Mesmo processo/aplicação** (GTI Fiscal como módulo interno do
FastAPI existente): exigiria portar o Fiscal de FastAPI+PostgreSQL+Alembic
próprios para dentro deste monolito Python+MySQL+SQL-cru — descartaria a
stack já pronta do Fiscal (Postgres, migrations, auth de Agent via Bearer).
Alto retrabalho, alto acoplamento, alto risco de regressão no painel de
clientes hoje estável. **Não recomendado.**
**Opção B — Serviço separado na mesma máquina** (dois processos FastAPI
independentes, dois bancos, roteados por porta ou subdomínio): mais fiel à
arquitetura já pronta do Fiscal (Postgres, migrations Alembic, auth de Agent
por Bearer token — nada disso precisa mudar). Exige: instalar PostgreSQL
nesta VM, instalar um reverse proxy (inexistente hoje — §11), e — dado o
disco em 82% de uso (§22) — **provavelmente aumentar o disco da VM antes**.
**Opção C — Backend Fiscal separado + UI incorporada no Gerenciador**: o
Fiscal mantém seu backend/API de Agents isolado (bom para segurança — Agents
externos batendo Bearer token não precisam tocar no mesmo processo do
painel), mas a tela humana ("Resumo", "Agents/Servidores", "Blockers",
"Jobs") é servida a partir do processo do painel, chamando o backend do
Fiscal internamente (rede Docker) e renderizando com o mesmo layout Jinja2 já
existente (§17). Combina isolamento de falha (Fiscal fora do ar não derruba
o painel de clientes) com a experiência visual unificada que o usuário quer.
## 21. Recomendação
**Opção B para o backend (times/dados isolados) combinada com a fachada de UI
da Opção C**, na prática: dois processos FastAPI + dois bancos (MySQL do
painel, PostgreSQL do Fiscal) na mesma VM/rede Docker, sem misturar código
Python nem schemas — e, quando fizer sentido visualmente, o painel ganha um
item de menu "Fiscal" que ou (a) faz proxy/iframe para o serviço do Fiscal
rodando noutra porta/subpath via reverse proxy, ou (b) mais elegante: o
próprio Hub FastAPI do Fiscal serve suas páginas Jinja2 reaproveitando o CSS
do painel (arquivo estático compartilhado ou copiado), ficando visualmente
idêntico sem juntar os dois processos.
Critérios que pesaram:
- **Baixa manutenção / isolamento de falhas**: times/schemas separados
evitam que um bug do Fiscal (ex.: processamento de 500k produtos) afete o
painel de clientes em produção, que hoje é estável.
- **Segurança**: Agents Windows externos autenticam via Bearer contra o
backend do Fiscal — não faz sentido esse tráfego externo passar pelo mesmo
processo/sessão-cookie do painel administrativo interno.
- **Reaproveitamento real disponível**: só a **máquina** (VM), a **rede
Docker** e, superficialmente, o **CSS/layout** são reaproveitáveis hoje —
banco, auth e modelo de dados **não são**, então forçar Opção A jogaria
fora o trabalho já feito na stack do Fiscal sem ganho real.
- **Pré-requisito bloqueante**: nada disso cabe sem antes resolver o disco
(82% cheio) e decidir sobre reverse proxy — ver §22-23.
## 22. Capacidade da máquina
```
CAN_HOST_GTI_FISCAL_ON_SAME_MACHINE=false, no estado atual da VM (sem
aumentar disco/RAM primeiro)
```
Considerando a carga alvo do briefing (Hub FastAPI + PostgreSQL + 100 Agents
iniciais + 500k produtos + event logs + blockers + jobs) contra o que esta VM
tem hoje:
- **CPU**: 2 vCPU, carga atual quase zero — folga real, não é o gargalo.
- **RAM**: 1.9GiB total, dos quais ~940MiB já em uso por SO+containers
atuais. PostgreSQL sozinho, com um volume de 500k produtos + índices, tende
a querer bem mais que a folga restante para não sofrer com cache pequeno
(`shared_buffers`/`effective_cache_size` ficariam artificialmente baixos).
100 Agents com heartbeat periódico é tráfego leve por si, mas soma-se ao
processo do Hub. **Risco real de troca/swap sob carga.**
- **Disco**: 8.1GiB total, **1.4GiB livres (82% usado)**. Uma imagem
PostgreSQL + o próprio volume de dados (500k produtos, mesmo que
compactos, mais índices, mais WAL) muito provavelmente **não cabe** nesse
espaço livre atual, ainda mais somado à imagem Docker do Hub FastAPI. Este
é o **gargalo mais crítico e imediato**, antes mesmo de RAM.
```
NEEDS_PROXMOX_RESOURCE_INFO=true
```
O que falta, especificamente (só visível a partir do host Proxmox, não de
dentro da VM):
1. Quanto de CPU/RAM/disco físico está livre no host Proxmox para **aumentar**
esta VM (ou criar uma segunda) — de dentro da VM não dá pra saber se o
host tem folga para um `pvesm resize`/`qm resize` do disco de 8→20GB+, por
exemplo.
2. Política de backup/snapshot do Proxmox para esta VM (indiretamente
relevante para §13 — se o Proxmox já faz snapshot no nível de
hypervisor, isso muda a análise de "backup inexistente").
3. Se há outras VMs/LXCs no mesmo host Proxmox concorrendo pelos mesmos
recursos físicos (I/O de disco compartilhado principalmente).
## 23. Conflitos potenciais
```
PORT_CONFLICTS=nenhum imediato — Fiscal pode usar outra porta (8001, por
exemplo); porta 80 já está tomada pelo painel
DATABASE_CONFLICTS=nenhum — motores diferentes (MySQL vs PostgreSQL) não
colidem, mas cada um consome memória/disco próprios
NETWORK_CONFLICTS=nenhum — network Docker externa (bancoparaprod_default)
pode ser reaproveitada ou uma nova criada, sem colisão
DOMAIN_CONFLICTS=n/a — não há domínio configurado hoje para nenhum dos dois
DEPENDENCY_CONFLICTS=nenhum a nível de processo (processos Python isolados
em containers separados não compartilham
dependências) — mas concorrem por RAM/disco da
mesma VM (ver §22)
AUTH_CONFLICTS=nenhum tecnicamente (mecanismos diferentes, cookie de sessão
vs Bearer de Agent, não colidem) — mas também não se somam:
um usuário logado no painel não fica "logado" automaticamente
no Fiscal sem um esforço explícito de integração de auth
```
## 24. O que pode ser reutilizado
```
REUSABLE_COMPONENTS=
- Máquina/VM (10.0.10.117) — há folga de CPU, mas não de disco (ver §22)
- Rede Docker (bancoparaprod_default ou nova network dedicada)
- CSS/identidade visual (app/static/estilo.css + logo GTI) — copiável para
o Fiscal se ele também for Jinja2
- Padrão de deploy manual via SFTP/docker compose (mesmo processo humano já
usado, sem precisar inventar um novo fluxo de deploy)
- Conceito de correlação por CNPJ entre CLIENTE (aqui) e tenant/client (lá),
SE for desenhado explicitamente — não existe pronto
```
## 25. O que deve ficar separado
```
SHOULD_REMAIN_ISOLATED=
- Banco de dados (MySQL do painel vs PostgreSQL do Fiscal — motores
diferentes, sem cenário real de compartilhar)
- Autenticação de Agent (Bearer token) — não deve passar pelo mesmo
middleware de sessão-cookie do painel administrativo
- Migrations (Alembic do Fiscal vs ALTER TABLE manual do painel — misturar
processos de schema é fonte garantida de erro operacional)
- Processo do Hub FastAPI do Fiscal — mantê-lo como processo próprio
(Opção B/C) preserva o isolamento de falha que o painel de clientes tem
hoje (37 dias de uptime sem intervenção)
```
## 26. Segurança
Achados relevantes (todos já visíveis publicamente ou já documentados em
auditoria anterior deste mesmo projeto — nada novo exposto aqui):
- **MySQL exposto publicamente** na porta 3306 (`0.0.0.0`), não restrito a
rede interna/loopback — ver §12.
- **Sem TLS/HTTPS** em produção — tráfego HTTP puro na porta 80, incluindo
login (pendência já registrada conscientemente em memória de projeto
anterior — não uma novidade desta auditoria).
- `.env` de produção existe e não foi lido (só variáveis específicas
necessárias, via `grep` pontual, nunca o arquivo inteiro) — nenhuma senha,
token ou secret aparece neste relatório.
- Auth de funcionário usa `scrypt` (adequado); auth "raiz" via
`ADMIN_USER`/`ADMIN_PASSWORD` em variável de ambiente, comparação direta
(sem hashing) — aceitável para uma única conta de emergência, mas vale
nota para quem for desenhar auth compartilhada com o Fiscal.
- Nenhuma correção foi feita — só relatado, como pedido.
## 27. Artefato
Este arquivo: `docs/GTI_GERENCIADOR_DISCOVERY_V1.md`, dentro do repositório
`painel-clientes-web`. **Não commitado** — a missão pediu regime read-only;
o arquivo fica no working tree local para o usuário revisar e decidir se
versiona.
## 28. Confirmação — nada foi alterado
Nenhum código, banco, container, proxy, firewall ou configuração foi
modificado nesta missão. Todos os comandos no servidor foram de leitura
(`hostnamectl`, `uptime`, `free`, `df`, `docker ps`, `docker stats`, `ss`,
`systemctl list-units`, `find`, `ls`, `cat` de arquivos não sensíveis,
`crontab -l`). A única tentativa que exigiria credencial adicional (contar
tabelas via `mysql -uroot`) falhou por falta de acesso e não foi insistida.
---
## Relatório final (resumo estruturado)
```
STATUS=discovery concluído, read-only, nenhuma alteração feita
PROJECT_NAME=painel-clientes-web ("GTI Gerenciador" é o nome comercial —
confirmado com o usuário, não é um projeto separado)
REPOSITORY_PATH=D:\PROJETOS\BLOQUEIO SGV\painel-clientes-web
BRANCH=master
HEAD=ce718f7ac22a583fd8c5d3a23415a19dcfc31291
WORKTREE=limpo
BACKEND=FastAPI (Python 3.12) + PyMySQL, sem ORM
FRONTEND=Jinja2 server-rendered, sem build chain
DATABASE=MySQL 5.7 (gtisoftw_clientes_dev), sem migrations formais
AUTH_MODEL=cookie de sessão HMAC-SHA256 próprio (12h) + CSRF double-submit;
login de funcionário (FUNCIONARIO_PAINEL, scrypt) + login raiz
via env
HOSTNAME=srvbd
OS=Ubuntu 24.04.4 LTS
VIRTUALIZATION=KVM (VM dentro de Proxmox, confirmado por doc local do projeto)
CPU=2 vCPU (ocioso)
RAM=1.9GiB total (~940MiB em uso)
DISK_FREE=1.4GiB de 8.1GiB (82% usado — CRÍTICO)
DOCKER=29.7.2 / Compose v5.4.0
CONTAINERS=painel-clientes-web-painel-1 (up 4 semanas), gtisoftw-mysql (up 5 semanas)
CURRENT_URL=http://10.0.10.117/ (IP direto, sem domínio)
DOMAIN=nenhum
HTTPS=não
REVERSE_PROXY=nenhum instalado
PUBLIC_PORTS=22 (SSH), 80 (app), 3306 (MySQL — exposto publicamente)
BACKUP=inexistente como rotina automatizada; só restore manual documentado
MONITORING=inexistente (sem Prometheus/Grafana/Loki)
CLIENT_MODEL=CLIENTE (empresa licenciada, negócio/cobrança) — sem conceito
de servidor/instalação/agente técnico
SERVER_MODEL=não existe
AGENT_MODEL=não existe
CAN_REUSE_AUTH=parcial (modelo simples, mas não desacoplado/exposto para
outro serviço consumir sem duplicar código)
CAN_REUSE_CLIENT_MODEL=parcial (CNPJ como chave de correlação possível,
exigiria design novo de integração)
CAN_REUSE_SERVER_MODEL=n/a (não existe)
CAN_REUSE_LAYOUT=sim (CSS/Jinja2 portável se o Fiscal também for Jinja2)
CAN_HOST_GTI_FISCAL_ON_SAME_MACHINE=false no estado atual (disco em 82%,
RAM apertada) — viável DEPOIS de
aumentar disco (e provavelmente RAM)
da VM
NEEDS_PROXMOX_RESOURCE_INFO=true (capacidade física do host Proxmox para
redimensionar esta VM; política de
backup/snapshot no nível de hypervisor; outras
VMs concorrendo pelo mesmo host)
RECOMMENDED_INTEGRATION_MODE=B (backend separado, mesma VM/rede Docker) +
fachada visual estilo C (menu "Fiscal" no
painel, reaproveitando layout/CSS)
RECOMMENDATION_REASON=isolamento de falha e de auth (Agents Bearer não devem
passar pelo processo/sessão do painel), motores de
banco incompatíveis (MySQL vs Postgres) inviabilizam
Opção A, e só máquina+rede+CSS são reaproveitáveis de
verdade hoje
PORT_CONFLICTS=nenhum
DATABASE_CONFLICTS=nenhum (motores diferentes)
AUTH_CONFLICTS=nenhum tecnicamente, mas também nenhuma integração pronta
REUSABLE_COMPONENTS=VM/rede Docker, CSS/identidade visual, processo de
deploy manual já estabelecido
SHOULD_REMAIN_ISOLATED=banco de dados, auth de Agent, migrations, processo
do Hub FastAPI do Fiscal
GTI_FISCAL_INTEGRATION_PREREQUISITES=
1. Aumentar disco da VM (crítico — 82% usado hoje, não cabe Postgres+dados)
2. Confirmar/aumentar RAM disponível junto ao Proxmox
3. Instalar reverse proxy (Caddy é o mais leve) — hoje não existe nenhum
4. Decidir domínio/subdomínio (nenhum configurado hoje)
5. Desenhar (não implementar ainda) a correlação CLIENTE.CNPJ ↔ tenant do
Fiscal, se a intenção for cruzar dados de cliente entre os dois sistemas
6. Resolver a exposição pública do MySQL (3306) antes de repetir o padrão
com um segundo banco
RISKS=
- Disco cheio (82%) é bloqueante técnico imediato, não é só "atenção"
- MySQL público na 3306 é uma exposição já existente, independente desta
missão, mas relevante para não repetir o padrão
- Sem backup automatizado hoje — somar um segundo banco sem resolver isso
primeiro dobra a superfície de risco de perda de dados
- Sem TLS — Agents do Fiscal batendo HTTPS externo precisarão de reverse
proxy com certificado, que hoje não existe nesta VM
NEXT_STEP=levantar capacidade real do host Proxmox (item bloqueante
NEEDS_PROXMOX_RESOURCE_INFO) e decidir se a VM atual cresce ou se
uma segunda VM é criada especificamente para o Fiscal — só depois
disso faz sentido detalhar a Opção B/C escolhida em um plano de
implementação
VERDICT=Integração é viável e recomendável na forma B+C (backend separado,
UI visualmente unificada), mas NÃO deve começar antes de resolver
disco/RAM da VM e decidir sobre reverse proxy — do contrário, o
Fiscal nasce competindo por recursos com um painel de clientes que
hoje está estável e em produção real.
```
@@ -0,0 +1,235 @@
# Log: Seções Escondidas por Padrão — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fazer as duas seções da tela `/log` ("Ações no painel" e "Envios
automáticos de chave (n8n)") começarem fechadas ao carregar a página,
exigindo um clique no título ou em um botão "Mostrar" para expandir.
**Architecture:** Envolver cada seção em `<details class="secao-log">`
nativo do HTML (fechado por padrão, sem `open`), com um `<summary>` que
contém o título e um botão `Mostrar`/`Ocultar`. Um script inline pequeno
sincroniza o texto do botão e `aria-expanded` com o evento `toggle` nativo
do `<details>` — a abertura/fechamento em si não precisa de JavaScript,
só o texto do botão.
**Tech Stack:** Jinja2 (`app/templates/log.html`), CSS puro
(`app/static/estilo.css`), JavaScript vanilla inline, pytest +
`fastapi.testclient.TestClient` (`tests/test_router_log.py`).
## Global Constraints
- Nenhuma seção pode abrir automaticamente ao carregar a página (spec:
"Fora de escopo" — abrir automaticamente qualquer seção por padrão).
- Estado aberto/fechado não é persistido entre recarregamentos (sem
localStorage, cookie ou parâmetro de URL).
- Nenhum dado exibido, paginação, limite de 20 registros em "Ações no
painel" ou o botão "Limpar log" (visível só para admin) pode mudar de
comportamento — só o wrapper de exibição muda.
- A ordem das seções continua: "Ações no painel" primeiro, "Envios
automáticos de chave (n8n)" embaixo.
---
### Task 1: Seções `<details>` no template + estilo + teste
**Files:**
- Modify: `app/templates/log.html`
- Modify: `app/static/estilo.css`
- Test: `tests/test_router_log.py`
**Interfaces:**
- Consumes: contexto já passado pela rota `GET /log` em
`app/routers/log.py` (`acoes`, `envios`, `envios_configurado`, `eh_admin`,
`csrf_token`, `mensagem`, `mensagem_tipo`) — nenhuma mudança nesse router
nesta tarefa.
- Produces: nada consumido por outras tasks (task única).
- [ ] **Step 1: Escrever o teste que falha**
Adicionar ao final de `tests/test_router_log.py`:
```python
def test_pagina_log_secoes_comecam_fechadas(monkeypatch):
monkeypatch.setattr(router_log.db_log, "listar", lambda limite=20: [])
monkeypatch.setattr(router_log.n8n_client, "listar_envios", lambda limite=50: [])
monkeypatch.setattr(router_log.n8n_client, "configurado", lambda: False)
resposta = client.get("/log")
assert resposta.status_code == 200
texto = resposta.text
assert texto.count('<details class="secao-log">') == 2
assert "<details class=\"secao-log\" open>" not in texto
assert "<details class='secao-log' open>" not in texto
```
- [ ] **Step 2: Rodar o teste para confirmar que falha**
Run: `pytest tests/test_router_log.py::test_pagina_log_secoes_comecam_fechadas -v`
Expected: FAIL — o HTML atual não tem `<details class="secao-log">`.
- [ ] **Step 3: Reescrever `app/templates/log.html`**
Substituir o conteúdo do bloco `{% block conteudo %}` inteiro por:
```html
{% block conteudo %}
<h1>Log</h1>
<details class="secao-log">
<summary>
<span class="secao-log-titulo">Ações no painel</span>
<button type="button" class="botao-toggle-log" aria-expanded="false">Mostrar</button>
</summary>
<div class="secao-log-conteudo">
{% if eh_admin %}
<form method="post" action="/log/limpar" style="margin-bottom:14px">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Limpar todo o histórico de ações? Isso não pode ser desfeito.')">Limpar log</button>
</form>
{% endif %}
{% if acoes %}
<div class="tabela-wrap">
<table>
<tr><th>Quando</th><th>Usuário</th><th>Ação</th><th>Entidade</th><th>Status</th><th>Detalhe</th></tr>
{% for a in acoes %}
<tr>
<td>{{ a.CRIADO_EM }}</td>
<td>{{ a.USUARIO }}</td>
<td>{{ a.ACAO }}</td>
<td>{{ a.ENTIDADE }}{% if a.ENTIDADE_ID %} #{{ a.ENTIDADE_ID }}{% endif %}</td>
<td><span class="badge {{ 'badge-sucesso' if a.SUCESSO else 'badge-neutro' }}">{{ "OK" if a.SUCESSO else "ERRO" }}</span></td>
<td>{{ a.DETALHE or "" }}</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p>Nenhuma ação registrada ainda.</p>
{% endif %}
</div>
</details>
<details class="secao-log">
<summary>
<span class="secao-log-titulo">Envios automáticos de chave (n8n)</span>
<button type="button" class="botao-toggle-log" aria-expanded="false">Mostrar</button>
</summary>
<div class="secao-log-conteudo">
{% if not envios_configurado %}
<div class="cartao">
<p>
Integração com o n8n não configurada — defina <code>N8N_API_URL</code> e
<code>N8N_API_KEY</code> no ambiente (ver <code>.env.example</code>) para ver
aqui os envios automáticos de chave feitos pelo workflow do n8n.
</p>
</div>
{% elif not envios %}
<p>Nenhum envio encontrado (ou não foi possível consultar o n8n agora).</p>
{% else %}
<div class="tabela-wrap">
<table>
<tr>
<th>Data</th><th>Cliente</th><th>Código</th><th>Competência</th><th>Status</th>
<th>Telefones enviados</th><th>Telefones com erro</th>
</tr>
{% for e in envios %}
<tr>
<td>{{ e.data_processamento }}</td>
<td>{{ e.nome_cliente }}</td>
<td>{{ e.codigo_cliente }}</td>
<td>{{ e.competencia }}</td>
<td>{{ e.status }}</td>
<td>{{ e.telefones_enviados }}</td>
<td>{{ e.telefones_com_erro or "" }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
</details>
<script>
document.querySelectorAll(".secao-log").forEach(function (secao) {
var botao = secao.querySelector(".botao-toggle-log");
var atualizar = function () {
var aberto = secao.hasAttribute("open");
botao.textContent = aberto ? "Ocultar" : "Mostrar";
botao.setAttribute("aria-expanded", aberto ? "true" : "false");
};
secao.addEventListener("toggle", atualizar);
atualizar();
});
</script>
{% endblock %}
```
Notas importantes:
- O `<button>` fica dentro do `<summary>`: clicar nele já aciona o toggle
nativo do `<details>` (o clique borbulha até o `<summary>`), então não
precisa de `onclick` para abrir/fechar — só o listener do evento
`toggle` para trocar o texto.
- Nenhum `<details>` tem o atributo `open` — as duas começam fechadas.
- O botão "Limpar log" (dentro de "Ações no painel") continua exatamente
igual, só mudou de lugar (dentro do `<div class="secao-log-conteudo">`).
- [ ] **Step 4: Adicionar estilo em `app/static/estilo.css`**
Adicionar ao final do arquivo:
```css
.secao-log { margin-bottom: 20px; }
.secao-log summary {
display: flex; align-items: center; gap: 10px; cursor: pointer;
list-style: none; padding: 10px 0; user-select: none;
}
.secao-log summary::-webkit-details-marker { display: none; }
.secao-log summary::before {
content: "▸"; display: inline-block; color: var(--cor-texto-secundario);
transition: transform 0.15s ease;
}
.secao-log[open] summary::before { transform: rotate(90deg); }
.secao-log-titulo { font-size: 1.17em; font-weight: 700; }
.botao-toggle-log {
margin-top: 0; padding: 6px 12px; font-size: 0.85rem; font-weight: 400;
background: transparent; border: 1px solid var(--cor-borda); color: var(--cor-texto);
}
.botao-toggle-log:hover { border-color: var(--cor-acento); color: var(--cor-acento); }
.secao-log-conteudo { padding-top: 4px; }
```
- [ ] **Step 5: Rodar o teste para confirmar que passa**
Run: `pytest tests/test_router_log.py -v`
Expected: PASS em todos os testes do arquivo (incluindo os 6 já existentes
e o novo).
- [ ] **Step 6: Rodar a suíte completa**
Run: `pytest`
Expected: PASS em todos os testes do projeto (nenhum outro teste referencia
a estrutura HTML de `log.html`, então nada mais deve quebrar).
- [ ] **Step 7: Commit**
```bash
git add app/templates/log.html app/static/estilo.css tests/test_router_log.py
git commit -m "feat: secoes da tela de log comecam fechadas, com clique para abrir"
```
---
## Self-Review Notes
- **Spec coverage:** as duas seções ficam em `<details>` fechado (spec:
"estrutura"); título clicável com seta (spec: "controles — título");
botão Mostrar/Ocultar dentro do summary, toggle nativo sem duplicar lógica
(spec: "controles — botão"); nenhum estado persistido (spec: "estado não
é persistido"); ordem mantida; nenhuma mudança em dados/paginação/limite/
botão limpar (spec: "sem mudança de comportamento"). Teste cobre que as
duas seções carregam sem `open`.
- **Placeholder scan:** nenhum "TBD"/"implementar depois" — todo HTML, CSS
e JS estão completos e prontos para colar.
- **Type consistency:** não há funções/assinaturas cruzando tasks (task
única, sem mudança de router).
@@ -0,0 +1,249 @@
# Tabela de Clientes UI/UX Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ajustar largura de colunas, alinhamento/centralização e densidade
das linhas da tabela em `/clientes`, igual para os dois perfis de usuário,
sem alterar comportamento, dados ou permissões.
**Architecture:** Adicionar uma classe `table-clientes` só nessa tabela
(escopando o CSS pra não afetar outras telas), colunas com largura e
alinhamento fixos via CSS (`nth-child` ou classes por coluna), padding
vertical reduzido, e uma classe de botão mais compacta pras ações da linha.
**Tech Stack:** Jinja2 (`app/templates/clientes_lista.html`), CSS puro
(`app/static/estilo.css`), pytest + `fastapi.testclient.TestClient`
(`tests/test_router_clientes.py`).
## Global Constraints
- Nenhuma mudança de permissão/visibilidade por perfil (admin vs. padrão)
nesta tela — confirmado explicitamente fora de escopo.
- Nenhuma mudança em dados exibidos, busca, filtro de status, ordenação,
paginação, no toggle de status ou no botão "Gerar chave" — só a
apresentação visual muda.
- Os ajustes de CSS ficam restritos à classe `table-clientes` — não alterar
as regras genéricas `table`/`th`/`td` (usadas por `/funcionarios`,
`/log`, etc.).
- Larguras/alinhamento por coluna (da spec): Código centralizado/estreito,
Razão e Fantasia flexíveis à esquerda (dividindo o espaço igualmente),
CNPJ centralizado/fixo sem quebrar linha, Status centralizado/estreito,
Ações à direita.
---
### Task 1: Classe de escopo, larguras/alinhamento e densidade
**Files:**
- Modify: `app/templates/clientes_lista.html`
- Modify: `app/static/estilo.css`
- Test: `tests/test_router_clientes.py`
**Interfaces:**
- Consumes: contexto já passado pela rota `GET /clientes` em
`app/routers/clientes.py` (`clientes`, `ordenar_por`, `direcao`, `status`,
`busca`, `csrf_token`, `redirecionar_para`, paginação) — nenhuma mudança
nesse router nesta tarefa.
- Produces: nada consumido por outras tasks (task única).
- [ ] **Step 1: Escrever o teste que falha**
Adicionar ao final de `tests/test_router_clientes.py`:
```python
def test_listar_clientes_tabela_tem_classe_de_escopo():
resposta = client.get("/clientes")
assert resposta.status_code == 200
assert '<table class="table-clientes">' in resposta.text
```
- [ ] **Step 2: Rodar o teste para confirmar que falha**
Run: `pytest tests/test_router_clientes.py::test_listar_clientes_tabela_tem_classe_de_escopo -v`
Expected: FAIL — a tabela hoje é `<table>` sem classe.
- [ ] **Step 3: Editar `app/templates/clientes_lista.html`**
No bloco da tabela (linhas atuais 28-71), aplicar a classe `table-clientes`
na tag `<table>` e alinhar os `<th>`/`<td>` com classes de coluna. Substituir
o trecho:
```html
<div class="tabela-wrap">
<table>
<tr>
{% set colunas_tabela = [("codigo", "Código"), ("razao", "Razão"), ("fantasia", "Fantasia"), ("cnpj", "CNPJ")] %}
{% for campo, rotulo in colunas_tabela %}
{% set nova_direcao = "desc" if (ordenar_por == campo and direcao == "asc") else "asc" %}
<th>
<a href="/clientes?ordenar_por={{ campo }}&direcao={{ nova_direcao }}&pagina=1&por_pagina={{ por_pagina }}&status={{ status }}{{ busca_qs }}">
{{ rotulo }}{% if ordenar_por == campo %}{{ " ▲" if direcao == "asc" else " ▼" }}{% endif %}
</a>
</th>
{% endfor %}
<th>Status</th>
<th></th>
</tr>
{% for c in clientes %}
<tr>
<td>{{ c.CODIGO }}</td>
<td>{{ c.RAZAO }}</td>
<td>{{ c.FANTASIA }}</td>
<td>{{ c.CNPJ }}</td>
<td>
<form method="post" action="/clientes/{{ c.ID }}/alternar-ativo" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirecionar_para" value="{{ redirecionar_para }}">
<button
type="submit"
class="status-dot {{ 'status-dot-ativo' if c.ATIVO else 'status-dot-inativo' }}"
title="{{ 'Ativo — clique para desativar' if c.ATIVO else 'Inativo — clique para ativar' }}"
onclick='return confirm({{ ((("Desativar" if c.ATIVO else "Reativar") + " o cliente " + c.RAZAO + "?")) | tojson }})'
>
<span class="sr-only">{{ "Ativo" if c.ATIVO else "Inativo" }}</span>
</button>
</form>
</td>
<td>
<a class="botao" href="/clientes/{{ c.ID }}">Abrir</a>
<button type="button" class="botao botao-gerar-chave" data-codigo="{{ c.CODIGO }}" onclick="gerarChaveReativacao(this)">Gerar chave</button>
<span class="chave-feedback" aria-live="polite"></span>
</td>
</tr>
{% endfor %}
</table>
</div>
```
por:
```html
<div class="tabela-wrap">
<table class="table-clientes">
<tr>
{% set colunas_tabela = [("codigo", "Código", "col-codigo"), ("razao", "Razão", "col-razao"), ("fantasia", "Fantasia", "col-fantasia"), ("cnpj", "CNPJ", "col-cnpj")] %}
{% for campo, rotulo, classe_coluna in colunas_tabela %}
{% set nova_direcao = "desc" if (ordenar_por == campo and direcao == "asc") else "asc" %}
<th class="{{ classe_coluna }}">
<a href="/clientes?ordenar_por={{ campo }}&direcao={{ nova_direcao }}&pagina=1&por_pagina={{ por_pagina }}&status={{ status }}{{ busca_qs }}">
{{ rotulo }}{% if ordenar_por == campo %}{{ " ▲" if direcao == "asc" else " ▼" }}{% endif %}
</a>
</th>
{% endfor %}
<th class="col-status">Status</th>
<th class="col-acoes"></th>
</tr>
{% for c in clientes %}
<tr>
<td class="col-codigo">{{ c.CODIGO }}</td>
<td class="col-razao">{{ c.RAZAO }}</td>
<td class="col-fantasia">{{ c.FANTASIA }}</td>
<td class="col-cnpj">{{ c.CNPJ }}</td>
<td class="col-status">
<form method="post" action="/clientes/{{ c.ID }}/alternar-ativo" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirecionar_para" value="{{ redirecionar_para }}">
<button
type="submit"
class="status-dot {{ 'status-dot-ativo' if c.ATIVO else 'status-dot-inativo' }}"
title="{{ 'Ativo — clique para desativar' if c.ATIVO else 'Inativo — clique para ativar' }}"
onclick='return confirm({{ ((("Desativar" if c.ATIVO else "Reativar") + " o cliente " + c.RAZAO + "?")) | tojson }})'
>
<span class="sr-only">{{ "Ativo" if c.ATIVO else "Inativo" }}</span>
</button>
</form>
</td>
<td class="col-acoes">
<a class="botao botao-tabela" href="/clientes/{{ c.ID }}">Abrir</a>
<button type="button" class="botao botao-tabela botao-gerar-chave" data-codigo="{{ c.CODIGO }}" onclick="gerarChaveReativacao(this)">Gerar chave</button>
<span class="chave-feedback" aria-live="polite"></span>
</td>
</tr>
{% endfor %}
</table>
</div>
```
Notas:
- `colunas_tabela` ganhou um terceiro elemento (a classe da coluna) — o
`{% for %}` foi ajustado para desempacotar os 3 valores.
- `botao-tabela` foi adicionado junto de `botao` nos dois botões de ação
(não substitui `botao`, soma-se a ele — `botao-gerar-chave` continua
presente também).
- [ ] **Step 4: Adicionar CSS em `app/static/estilo.css`**
Adicionar ao final do arquivo:
```css
.table-clientes th.col-codigo, .table-clientes td.col-codigo,
.table-clientes th.col-cnpj, .table-clientes td.col-cnpj,
.table-clientes th.col-status, .table-clientes td.col-status {
text-align: center;
}
.table-clientes th.col-acoes, .table-clientes td.col-acoes { text-align: right; }
.table-clientes td.col-codigo, .table-clientes td.col-cnpj {
font-variant-numeric: tabular-nums;
}
.table-clientes td.col-cnpj { white-space: nowrap; }
.table-clientes col.col-codigo { width: 90px; }
.table-clientes col.col-razao, .table-clientes col.col-fantasia { width: 27%; }
.table-clientes col.col-cnpj { width: 160px; }
.table-clientes col.col-status { width: 70px; }
.table-clientes col.col-acoes { width: 230px; }
.table-clientes th, .table-clientes td { padding: 7px 14px; }
.botao-tabela { margin-top: 0; padding: 7px 12px; }
```
E, no `<table class="table-clientes">` do template (Step 3), adicionar um
`<colgroup>` logo após a abertura da tag, antes da primeira `<tr>`, para
que as larguras acima sejam aplicadas:
```html
<table class="table-clientes">
<colgroup>
<col class="col-codigo">
<col class="col-razao">
<col class="col-fantasia">
<col class="col-cnpj">
<col class="col-status">
<col class="col-acoes">
</colgroup>
<tr>
```
- [ ] **Step 5: Rodar o teste para confirmar que passa**
Run: `pytest tests/test_router_clientes.py -v`
Expected: PASS em todos os testes do arquivo (existentes + o novo).
- [ ] **Step 6: Rodar a suíte completa**
Run: `pytest`
Expected: PASS em todos os testes do projeto.
- [ ] **Step 7: Commit**
```bash
git add app/templates/clientes_lista.html app/static/estilo.css tests/test_router_clientes.py
git commit -m "feat: ajusta colunas, alinhamento e densidade da tabela de clientes"
```
---
## Self-Review Notes
- **Spec coverage:** classe de escopo `table-clientes` (spec: "escopo do
CSS"); larguras/alinhamento por coluna via `<colgroup>` + classes (spec:
tabela de larguras/alinhamento); cabeçalhos alinhados junto com as
colunas (spec: "cabeçalhos"); padding `10px→7px` e `.botao-tabela` sem
margem superior (spec: "densidade"); nenhuma mudança de dado/ordenação/
paginação/permissão (spec: "sem mudança de comportamento" e "fora de
escopo"). Teste cobre a classe de escopo aplicada.
- **Placeholder scan:** nenhum "TBD"/"implementar depois" — HTML e CSS
completos, prontos pra colar.
- **Type consistency:** task única, sem assinaturas cruzando tasks.
@@ -0,0 +1,66 @@
# Log: seções escondidas por padrão, com clique para abrir
## Contexto
A tela `/log` mostra duas tabelas: "Ações no painel" (histórico de ações do
painel, já limitado aos 20 registros mais recentes) e "Envios automáticos de
chave (n8n)" (log dos envios automáticos de chave via WhatsApp, sem limite).
Como ambas aparecem sempre expandidas, a tela fica grande e atrapalha quando
o usuário só quer olhar rapidamente um dos dois logs.
## Objetivo
Cada seção da tela `/log` deve começar **escondida** (fechada) ao carregar a
página, exigindo um clique para expandir. Isso facilita, por exemplo, olhar
só o log de envios de chave sem precisar rolar past o log inteiro do painel.
## Design
**Estrutura:** cada seção (`Ações no painel` e `Envios automáticos de chave
(n8n)`) é envolvida em um elemento `<details class="secao-log">` HTML
nativo, **fechado por padrão** (sem o atributo `open`). A ordem das seções
não muda: Ações no painel primeiro, Envios automáticos de chave embaixo.
**Controles (dois, redundantes, ambos alternam o mesmo estado):**
- O título da seção (dentro de `<summary>`) é clicável — clicar nele
abre/fecha a seção. Uma seta (▸ fechado / ▾ aberto) ao lado do título
indica visualmente o estado atual.
- Um botão no estilo dos demais botões do painel (`Mostrar` / `Ocultar`)
fica ao lado do título, dentro do mesmo `<summary>`. Como ele está dentro
do `<summary>`, clicar nele já aciona o toggle nativo do `<details>` —
não precisa de lógica de toggle separada. Um script pequeno escuta o
evento `toggle` de cada `<details>` e só atualiza o texto do botão
(`Mostrar`↔`Ocultar`) e o atributo `aria-expanded`, para acessibilidade.
**Estado não é persistido:** toda vez que a página `/log` é carregada, as
duas seções começam fechadas — não guarda se o usuário tinha deixado aberta
antes (sem localStorage, sem cookie, sem parâmetro de URL). Simplicidade,
sem necessidade adicional hoje.
**Sem mudança de comportamento:** os dados carregados por seção (limite de
20 em "Ações no painel", botão "Limpar log" restrito a administradores
dentro dessa seção, listagem de envios do n8n) continuam exatamente como
estão hoje — só o wrapper de exibição (fechado/aberto) muda.
## Fora de escopo
- Lembrar o estado aberto/fechado entre recarregamentos de página.
- Abrir automaticamente alguma seção por padrão (as duas começam fechadas).
- Qualquer mudança nos dados exibidos, na paginação, no limite de 20 ou no
botão de limpar log.
## Arquivos afetados
- `app/templates/log.html` — envolver cada seção em `<details>`/`<summary>`,
adicionar o botão Mostrar/Ocultar e a seta indicadora dentro do `summary`.
- `app/static/estilo.css` — estilo do `<details>`/`<summary>` (remoção do
marcador padrão do navegador, seta customizada que gira com `[open]`,
espaçamento do botão dentro do `summary`).
- Um pequeno `<script>` inline (ou incluído no bloco já existente do
template) para sincronizar o texto do botão com o evento `toggle`.
## Testes
- Teste de router (`tests/test_router_log.py`) verificando que a resposta
de `GET /log` contém os elementos `<details>` para as duas seções, sem o
atributo `open`, confirmando que ambas carregam fechadas por padrão.
@@ -0,0 +1,71 @@
# Tabela de Clientes: UI/UX — Colunas, Centralização e Densidade
## Contexto
A tabela em `/clientes` usa o estilo genérico de `table`/`th`/`td` do
projeto (compartilhado com outras telas como `/funcionarios` e `/log`).
Isso deixa a tabela de clientes com padding grande demais para o volume de
linhas que ela normalmente exibe, todas as colunas alinhadas à esquerda
sem distinção (mesmo colunas curtas como Código e Status), e a coluna CNPJ
estreita a ponto do valor quebrar em duas linhas.
## Objetivo
Melhorar o layout da tabela de `/clientes` — ajuste de largura de colunas,
alinhamento/centralização e densidade das linhas — igual para os dois
perfis de usuário (admin e padrão), já que hoje não existe distinção de
permissão nessa tela e este trabalho não introduz nenhuma.
## Fora de escopo
- Qualquer restrição de ação por perfil (admin vs. padrão) — confirmado
explicitamente que não é para mudar quem vê/usa o quê nesta tela.
- Mudança em dados exibidos, busca, filtro de status, paginação ou nas
ações em si (Abrir, Gerar chave, alternar status).
- Mudança no estilo genérico de `table`/`th`/`td` usado por outras telas
(`/funcionarios`, `/log`, etc.) — os ajustes ficam restritos a uma classe
específica da tabela de clientes, sem tocar nas regras compartilhadas.
## Design
**Escopo do CSS:** a tabela de `/clientes` ganha uma classe própria (ex.:
`table-clientes`) para que os ajustes de largura/alinhamento/densidade não
vazem para as demais tabelas do painel.
**Larguras e alinhamento por coluna:**
| Coluna | Largura | Alinhamento |
|---|---|---|
| Código | estreita e fixa | centralizado, `tabular-nums` |
| Razão | flexível (resto do espaço) | esquerda |
| Fantasia | flexível (resto do espaço) | esquerda |
| CNPJ | fixa, larga o bastante para não quebrar linha (18 caracteres) | centralizado, `tabular-nums`, `white-space: nowrap` |
| Status | estreita e fixa | centralizado |
| Ações (Abrir / Gerar chave) | fixa | direita |
Razão e Fantasia dividem o espaço restante igualmente entre si (cada uma
recebendo a mesma largura flexível), já que são as colunas de leitura mais
importantes da tabela.
**Cabeçalhos:** cada `<th>` reflete o alinhamento da coluna correspondente
(Código/CNPJ/Status centralizados, Razão/Fantasia à esquerda, Ações à
direita) — hoje todos os cabeçalhos ficam forçados à esquerda
independente do conteúdo da coluna.
**Densidade:** o padding vertical das células dessa tabela cai de `10px`
para `7px` (mantendo o padding horizontal), permitindo mais linhas visíveis
sem rolagem. Os botões de ação (Abrir / Gerar chave) ganham uma classe
específica de tabela que remove a margem superior de 14px do botão padrão
(pensada para formulários, não para dentro de células) e usa padding mais
compacto, mantendo o espaçamento consistente entre os dois botões.
**Sem mudança de comportamento:** os links de ordenação nos cabeçalhos
(`ordenar_por`/`direcao`), a bolinha de status clicável (toggle
ativo/inativo), o botão "Gerar chave" e a paginação continuam funcionando
exatamente como hoje — só a apresentação visual muda.
## Testes
- Teste de router (`tests/test_router_clientes.py`) verificando que a
tabela renderizada usa a classe `table-clientes` (confirma que o CSS
com escopo está sendo aplicado, sem checar detalhes visuais que testes
automatizados não conseguem verificar de forma confiável).
+3
View File
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = .
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.3.3
httpx==0.27.2
+22
View File
@@ -0,0 +1,22 @@
# Versões mínimas fixadas por segurança (ver auditoria de 2026-08-15):
# - fastapi 0.115.6 traz starlette >=0.40.0, que corrige um DoS por
# consumo de memória não limitado ao parsear multipart/form-data
# (GHSA-f96h-pmfr-66vw / equivalente ao CVE-2024-47874) — POST /login
# é uma rota pré-autenticação, então esse endpoint é alcançável por
# qualquer visitante, autenticado ou não.
# - python-multipart 0.0.9 tinha várias vulnerabilidades de DoS (boundary
# malformado, headers de parte sem limite, preambulo/epílogo sem
# limite) corrigidas em versões posteriores — pinado na última estável.
# - jinja2 3.1.6 corrige escapes do sandbox (baixo risco aqui, já que o
# painel não renderiza template vindo de entrada do usuário, mas sem
# custo de compatibilidade manter em dia).
fastapi==0.115.6
uvicorn[standard]==0.30.6
jinja2==3.1.6
python-multipart==0.0.20
PyMySQL==1.1.1
python-dotenv==1.0.1
httpx==0.27.2
# SSO para o GTI Fiscal (app/sso.py): asserção JWT assinada com Ed25519.
PyJWT==2.14.0
cryptography==50.0.1
View File
+16
View File
@@ -0,0 +1,16 @@
"""
Configuração compartilhada dos testes. Define defaults de conexão iguais ao
.env.example ANTES de qualquer módulo do app ser importado, para os testes
rodarem direto contra o container de dev (licenciamento-mysql-dev, porta
3307) sem exigir setup manual — quem rodar `pytest` só precisa ter esse
container de pé (docker-compose.yml de licenciamento-n8n/ ou Banco para Prod/).
"""
import os
os.environ.setdefault("ADMIN_USER", "admin")
os.environ.setdefault("ADMIN_PASSWORD", "teste123")
os.environ.setdefault("DB_HOST", "127.0.0.1")
os.environ.setdefault("DB_PORT", "3307")
os.environ.setdefault("DB_NAME", "gtisoftw_clientes_dev")
os.environ.setdefault("DB_USER", "root")
os.environ.setdefault("DB_PASSWORD", "devpassword123")
+164
View File
@@ -0,0 +1,164 @@
import pytest
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
from app import session
from app.auth import NaoAutenticado, UsuarioLogado, autenticar_credenciais, exigir_admin, exigir_login
from app.db import funcionarios as db_funcionarios
from app.db.connection import get_connection
app_teste = FastAPI()
@app_teste.get("/protegido")
def rota_protegida(usuario: UsuarioLogado = Depends(exigir_login)):
return {"usuario": str(usuario), "perfil": usuario.perfil}
@app_teste.get("/so-admin")
def rota_admin(usuario: UsuarioLogado = Depends(exigir_admin)):
return {"usuario": str(usuario)}
@app_teste.exception_handler(NaoAutenticado)
def _handler_nao_autenticado(request, exc):
raise HTTPException(status_code=401, detail="não autenticado")
client = TestClient(app_teste, follow_redirects=False)
def _cookie_sessao(login: str, nome: str, perfil: str) -> dict:
return {session.SESSION_COOKIE_NAME: session.criar_token(login, nome, perfil)}
def test_sem_cookie_retorna_401():
resposta = client.get("/protegido")
assert resposta.status_code == 401
def test_cookie_invalido_retorna_401():
resposta = client.get("/protegido", cookies={session.SESSION_COOKIE_NAME: "lixo"})
assert resposta.status_code == 401
def test_cookie_valido_retorna_dados_do_usuario():
resposta = client.get("/protegido", cookies=_cookie_sessao("admin", "Admin", "ADMIN"))
assert resposta.status_code == 200
assert resposta.json() == {"usuario": "Admin", "perfil": "ADMIN"}
def test_usuario_logado_str_retorna_nome():
usuario = UsuarioLogado(login="fulano", nome="Fulano da Silva", perfil="PADRAO")
assert str(usuario) == "Fulano da Silva"
def test_usuario_logado_eh_admin():
assert UsuarioLogado(login="a", nome="A", perfil="ADMIN").eh_admin is True
assert UsuarioLogado(login="a", nome="A", perfil="PADRAO").eh_admin is False
def test_exigir_admin_aceita_admin():
resposta = client.get("/so-admin", cookies=_cookie_sessao("admin", "Admin", "ADMIN"))
assert resposta.status_code == 200
def test_exigir_admin_rejeita_padrao(funcionario_teste):
# exigir_login agora reconfirma o perfil contra o banco, então o login
# precisa existir de fato em FUNCIONARIO_PAINEL (funcionario_teste é
# cadastrado com PERFIL="PADRAO" — ver fixture abaixo).
resposta = client.get("/so-admin", cookies=_cookie_sessao(funcionario_teste, "Fulano", "PADRAO"))
assert resposta.status_code == 403
def test_autenticar_credenciais_admin_env():
# ADMIN_USER/ADMIN_PASSWORD vêm de tests/conftest.py ("admin"/"teste123")
usuario = autenticar_credenciais("admin", "teste123")
assert usuario is not None
assert usuario.perfil == "ADMIN"
assert usuario.login == "admin"
def test_autenticar_credenciais_admin_env_senha_errada():
assert autenticar_credenciais("admin", "senha_errada") is None
def test_autenticar_credenciais_admin_env_vazio_nao_autentica_vazio(monkeypatch):
monkeypatch.setenv("ADMIN_USER", "")
monkeypatch.setenv("ADMIN_PASSWORD", "")
assert autenticar_credenciais("", "") is None
def _limpar_login(login: str) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM FUNCIONARIO_PAINEL WHERE LOGIN = %s", (login,))
finally:
conn.close()
@pytest.fixture
def funcionario_teste():
login = "teste.login.auth"
_limpar_login(login)
funcionario_id = db_funcionarios.criar_funcionario("Fulano de Teste", login, "senhaForte123", "PADRAO")
yield login
_limpar_login(login)
def test_autenticar_credenciais_funcionario_ativo(funcionario_teste):
usuario = autenticar_credenciais(funcionario_teste, "senhaForte123")
assert usuario is not None
assert usuario.nome == "Fulano de Teste"
assert usuario.perfil == "PADRAO"
def test_autenticar_credenciais_funcionario_senha_errada(funcionario_teste):
assert autenticar_credenciais(funcionario_teste, "senhaErrada") is None
def test_autenticar_credenciais_funcionario_inativo(funcionario_teste):
funcionario = db_funcionarios.obter_por_login(funcionario_teste)
db_funcionarios.alternar_ativo(funcionario["ID"]) # desativa
assert autenticar_credenciais(funcionario_teste, "senhaForte123") is None
def test_autenticar_credenciais_login_inexistente():
assert autenticar_credenciais("nao-existe-esse-login", "qualquer") is None
def test_exigir_login_autentica_funcionario_ativo(funcionario_teste):
resposta = client.get("/protegido", cookies=_cookie_sessao(funcionario_teste, "Fulano de Teste", "PADRAO"))
assert resposta.status_code == 200
assert resposta.json() == {"usuario": "Fulano de Teste", "perfil": "PADRAO"}
def test_exigir_login_revoga_sessao_ao_desativar_funcionario(funcionario_teste):
# Cookie "válido" (assinatura e validade OK) gerado antes de desativar.
cookies = _cookie_sessao(funcionario_teste, "Fulano de Teste", "PADRAO")
assert client.get("/protegido", cookies=cookies).status_code == 200
funcionario = db_funcionarios.obter_por_login(funcionario_teste)
db_funcionarios.alternar_ativo(funcionario["ID"]) # desativa
resposta = client.get("/protegido", cookies=cookies)
assert resposta.status_code == 401
def test_exigir_admin_usa_perfil_atual_do_banco_nao_do_cookie(funcionario_teste):
# Cookie diz ADMIN, mas o funcionário no banco é PADRAO (ex: foi
# rebaixado depois de logar) — exigir_admin precisa rejeitar.
cookies = _cookie_sessao(funcionario_teste, "Fulano de Teste", "ADMIN")
resposta = client.get("/so-admin", cookies=cookies)
assert resposta.status_code == 403
def test_exigir_login_conta_bootstrap_nao_consulta_banco(monkeypatch):
def _falha_se_chamado(login):
raise AssertionError("exigir_login não deveria consultar o banco para a conta de bootstrap")
monkeypatch.setattr(db_funcionarios, "obter_por_login", _falha_se_chamado)
resposta = client.get("/protegido", cookies=_cookie_sessao("admin", "Admin", "ADMIN"))
assert resposta.status_code == 200
assert resposta.json() == {"usuario": "Admin", "perfil": "ADMIN"}
+75
View File
@@ -0,0 +1,75 @@
import httpx
from app import cnpj_lookup
def test_documento_com_tamanho_errado_nao_consulta(monkeypatch):
chamou = {}
monkeypatch.setattr(cnpj_lookup.httpx, "get", lambda *a, **k: chamou.setdefault("ok", True))
assert cnpj_lookup.consultar_cnpj("123") is None
assert "ok" not in chamou
def test_consulta_com_sucesso_mapeia_campos(monkeypatch):
class _RespostaFalsa:
status_code = 200
def json(self):
return {
"razao_social": "EMPRESA TESTE LTDA",
"nome_fantasia": "EMPRESA TESTE",
"logradouro": "RUA TESTE",
"numero": "123",
"bairro": "CENTRO",
"municipio": "FEIRA DE SANTANA",
"uf": "BA",
"cep": "44000000",
"ddd_telefone_1": "7533334444",
"email": "contato@empresateste.com",
}
monkeypatch.setattr(cnpj_lookup.httpx, "get", lambda url, timeout=None: _RespostaFalsa())
resultado = cnpj_lookup.consultar_cnpj("11222333000181")
assert resultado == {
"RAZAO": "EMPRESA TESTE LTDA",
"FANTASIA": "EMPRESA TESTE",
"ENDERECO": "RUA TESTE, 123",
"CIDADE": "FEIRA DE SANTANA",
"BAIRRO": "CENTRO",
"UF": "BA",
"CEP": "44000000",
"TELEFONE": "7533334444",
"EMAIL": "contato@empresateste.com",
}
def test_consulta_nao_encontrada_retorna_none(monkeypatch):
class _RespostaFalsa:
status_code = 404
def json(self):
return {}
monkeypatch.setattr(cnpj_lookup.httpx, "get", lambda url, timeout=None: _RespostaFalsa())
assert cnpj_lookup.consultar_cnpj("11222333000181") is None
def test_falha_de_rede_retorna_none(monkeypatch):
def _get_com_erro(url, timeout=None):
raise httpx.ConnectError("falhou")
monkeypatch.setattr(cnpj_lookup.httpx, "get", _get_com_erro)
assert cnpj_lookup.consultar_cnpj("11222333000181") is None
def test_sem_nome_fantasia_usa_razao_social(monkeypatch):
class _RespostaFalsa:
status_code = 200
def json(self):
return {"razao_social": "EMPRESA TESTE LTDA", "nome_fantasia": None}
monkeypatch.setattr(cnpj_lookup.httpx, "get", lambda url, timeout=None: _RespostaFalsa())
resultado = cnpj_lookup.consultar_cnpj("11222333000181")
assert resultado["FANTASIA"] == "EMPRESA TESTE LTDA"
+57
View File
@@ -0,0 +1,57 @@
"""
Testes de integração. Clientes_Chaves é MyISAM, sem FK com CLIENTE — o
teste cria e limpa a linha diretamente por SQL.
"""
import pytest
from app.db import chaves as db_chaves
from app.db.connection import get_connection
CODIGO_TESTE = "999903"
def _linha_vazia(codigo: str, cliente: str) -> dict:
linha = {f"M{i}": "" for i in range(1, 13)}
linha["ID"] = codigo
linha["Cliente"] = cliente
# "0000-00-00" (o valor real usado no legado) é rejeitado pelo MySQL em modo estrito
# (NO_ZERO_DATE) — usamos uma data valida qualquer, só para satisfazer a coluna NOT NULL.
linha["Prorrogacao"] = "2099-12-31"
return linha
@pytest.fixture
def chave_teste():
conn = get_connection()
try:
with conn.cursor() as cur:
linha = _linha_vazia(CODIGO_TESTE, "TESTE PAINEL")
linha["M8"] = "0B62CF51CDCF3328107092"
colunas = ", ".join(linha.keys())
marcadores = ", ".join(["%s"] * len(linha))
cur.execute(
f"INSERT INTO Clientes_Chaves ({colunas}) VALUES ({marcadores})",
list(linha.values()),
)
finally:
conn.close()
yield CODIGO_TESTE
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM Clientes_Chaves WHERE ID = %s", (CODIGO_TESTE,))
finally:
conn.close()
def test_status_chaves_existente(chave_teste):
status = db_chaves.status_chaves(CODIGO_TESTE)
assert status is not None
assert status["codigo"] == CODIGO_TESTE
assert status["cliente"] == "TESTE PAINEL"
assert status["meses"]["M8"] == "0B62CF51CDCF3328107092"
assert status["meses"]["M1"] is None
def test_status_chaves_inexistente():
assert db_chaves.status_chaves("999999") is None
+247
View File
@@ -0,0 +1,247 @@
"""
Testes de integração contra o banco de dev (ver test_db_connection.py).
Usa CODIGO na faixa "999900"-"999999", reservada para dados de teste deste
painel (fora da faixa real de clientes, que é sequencial e baixa — ver
licenciamento-n8n/docs/MAPEAMENTO_BANCO.md). Cada teste limpa o que criou.
"""
import pytest
from app.db import clientes as db_clientes
from app.db.connection import get_connection
CLIENTE_TESTE = {
"CODIGO": "999901",
"CNPJ": "11222333000181",
"RAZAO": "TESTE PAINEL RAZAO",
"FANTASIA": "TESTE PAINEL FANTASIA",
"IE": "ISENTO",
"ENDERECO": "RUA TESTE, 1",
"CIDADE": "FEIRA DE SANTANA",
"BAIRRO": "CENTRO",
"UF": "BA",
"CONTATO": "FULANO TESTE",
"TELEFONE": "75999090000",
"CELULAR": "75999090000",
"CEP": "44000000",
"IMPLANTACAO": "0.00",
"MENSALIDADE": "100.00",
"JUROS_MORA": "0.00",
"JUROS_MULTA": "0.00",
"DATA_CONTRATO": "2026-01-01",
"EMAIL": "teste@example.com",
"OBS": "Criado por teste automatizado — apagar se sobrar.",
"VENCIMENTO_CONTRATO": "2027-01-01",
"DIA_VENCIMENTO": "10",
"ATIVO": "1",
}
def _limpar(codigo: str) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM CLIENTE WHERE CODIGO = %s", (codigo,))
finally:
conn.close()
@pytest.fixture
def cliente_id():
novo_id = db_clientes.criar_cliente(CLIENTE_TESTE)
yield novo_id
_limpar(CLIENTE_TESTE["CODIGO"])
def test_criar_e_obter_cliente(cliente_id):
cliente = db_clientes.obter_cliente(cliente_id)
assert cliente is not None
assert cliente["CODIGO"] == "999901"
assert cliente["RAZAO"] == "TESTE PAINEL RAZAO"
def test_obter_cliente_inexistente():
assert db_clientes.obter_cliente(999999999) is None
def test_listar_clientes_encontra_por_busca(cliente_id):
resultado = db_clientes.listar_clientes(busca="TESTE PAINEL RAZAO")
codigos = [c["CODIGO"] for c in resultado]
assert "999901" in codigos
def test_codigo_em_uso(cliente_id):
assert db_clientes.codigo_em_uso("999901") is True
assert db_clientes.codigo_em_uso("999901", ignorar_id=cliente_id) is False
assert db_clientes.codigo_em_uso("000000") is False
def test_cnpj_em_uso(cliente_id):
assert db_clientes.cnpj_em_uso("11222333000181") is True
assert db_clientes.cnpj_em_uso("11222333000181", ignorar_id=cliente_id) is False
assert db_clientes.cnpj_em_uso("99988877000166") is False
def test_cnpj_em_uso_ignora_pontuacao_de_registros_legados(cliente_id):
# CLIENTE.CNPJ tem formatação inconsistente em registros legados (ver
# licenciamento-n8n/docs/ANALISE_CLIENTE_IPREAL.md) — cnpj_em_uso
# precisa achar o duplicado mesmo se o legado ainda tem máscara.
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("UPDATE CLIENTE SET CNPJ = %s WHERE ID = %s", ("11.222.333/0001-81", cliente_id))
finally:
conn.close()
assert db_clientes.cnpj_em_uso("11222333000181") is True
def test_atualizar_cliente(cliente_id):
dados = dict(CLIENTE_TESTE)
dados["FANTASIA"] = "TESTE PAINEL FANTASIA EDITADA"
db_clientes.atualizar_cliente(cliente_id, dados)
cliente = db_clientes.obter_cliente(cliente_id)
assert cliente["FANTASIA"] == "TESTE PAINEL FANTASIA EDITADA"
def test_excluir_cliente_sem_dependencias(cliente_id):
assert db_clientes.excluir_cliente(cliente_id) is True
assert db_clientes.obter_cliente(cliente_id) is None
def test_excluir_cliente_inexistente_retorna_false():
assert db_clientes.excluir_cliente(999999999) is False
def test_listar_clientes_busca_e_restrita_a_coluna_escolhida(cliente_id):
# ordenar_por="codigo" restringe a busca à coluna CODIGO — o termo
# "TESTE PAINEL RAZAO" existe em RAZAO/FANTASIA, mas não deve aparecer
# aqui porque a busca não olha para essas colunas quando é "codigo".
resultado = db_clientes.listar_clientes(busca="TESTE PAINEL RAZAO", ordenar_por="codigo")
assert len(resultado) == 0
# Buscando pelo próprio código, com ordenar_por="codigo", encontra.
resultado = db_clientes.listar_clientes(busca="999901", ordenar_por="codigo")
assert resultado[0]["CODIGO"] == "999901"
def test_listar_clientes_ordenacao_invalida_usa_padrao(cliente_id):
# "ordenar_por" fora da whitelist não deve quebrar nem virar SQL arbitrário
# — cai no padrão (RAZAO).
resultado = db_clientes.listar_clientes(busca="TESTE PAINEL RAZAO", ordenar_por="ID; DROP TABLE CLIENTE")
assert any(c["CODIGO"] == "999901" for c in resultado)
def test_listar_clientes_direcao_asc_e_desc(cliente_id):
# Dois clientes de teste com códigos diferentes, mesma busca por RAZAO,
# pra comparar a ordem entre asc/desc de forma determinística.
outro_id = db_clientes.criar_cliente(dict(CLIENTE_TESTE, CODIGO="999905"))
try:
asc = db_clientes.listar_clientes(busca="TESTE PAINEL RAZAO", ordenar_por="codigo", direcao="asc")
desc = db_clientes.listar_clientes(busca="TESTE PAINEL RAZAO", ordenar_por="codigo", direcao="desc")
codigos_asc = [c["CODIGO"] for c in asc]
codigos_desc = [c["CODIGO"] for c in desc]
assert codigos_asc == sorted(codigos_asc)
assert codigos_desc == sorted(codigos_desc, reverse=True)
finally:
_limpar("999905")
def test_listar_clientes_direcao_invalida_usa_padrao_asc(cliente_id):
# Não deve quebrar nem virar SQL arbitrário — cai em ASC.
resultado = db_clientes.listar_clientes(
busca="999901", ordenar_por="codigo", direcao="ASC; DROP TABLE CLIENTE"
)
assert resultado[0]["CODIGO"] == "999901"
def test_proximo_codigo_sugerido_ignora_faixa_de_teste(cliente_id):
# CLIENTE_TESTE usa CODIGO "999901" (faixa reservada para teste, >= 900000)
# — a sugestão não pode considerar isso, senão qualquer sobra de dado de
# teste no banco faria a sugestão pular para a casa dos 999xxx.
sugerido = db_clientes.proximo_codigo_sugerido()
assert int(sugerido) < 900000
assert len(sugerido) == 6
assert db_clientes.codigo_em_uso(sugerido) is False
def test_listar_clientes_filtro_ativo_so_ativos(cliente_id):
inativo_id = db_clientes.criar_cliente(dict(CLIENTE_TESTE, CODIGO="999902", ATIVO="0"))
try:
resultado = db_clientes.listar_clientes(busca="TESTE PAINEL", filtro_ativo=1)
codigos = [c["CODIGO"] for c in resultado]
assert "999901" in codigos
assert "999902" not in codigos
finally:
_limpar("999902")
def test_listar_clientes_filtro_inativo_so_inativos(cliente_id):
inativo_id = db_clientes.criar_cliente(dict(CLIENTE_TESTE, CODIGO="999903", ATIVO="0"))
try:
resultado = db_clientes.listar_clientes(busca="TESTE PAINEL", filtro_ativo=0)
codigos = [c["CODIGO"] for c in resultado]
assert "999901" not in codigos
assert "999903" in codigos
finally:
_limpar("999903")
def test_listar_clientes_filtro_none_traz_todos(cliente_id):
inativo_id = db_clientes.criar_cliente(dict(CLIENTE_TESTE, CODIGO="999904", ATIVO="0"))
try:
resultado = db_clientes.listar_clientes(busca="TESTE PAINEL", filtro_ativo=None)
codigos = [c["CODIGO"] for c in resultado]
assert "999901" in codigos
assert "999904" in codigos
finally:
_limpar("999904")
def test_contar_clientes_respeita_filtro_ativo(cliente_id):
inativo_id = db_clientes.criar_cliente(dict(CLIENTE_TESTE, CODIGO="999907", ATIVO="0"))
try:
assert db_clientes.contar_clientes(busca="TESTE PAINEL", filtro_ativo=1) == 1
assert db_clientes.contar_clientes(busca="TESTE PAINEL", filtro_ativo=0) == 1
assert db_clientes.contar_clientes(busca="TESTE PAINEL", filtro_ativo=None) == 2
finally:
_limpar("999907")
def test_criar_cliente_grava_ativo(cliente_id):
cliente = db_clientes.obter_cliente(cliente_id)
assert cliente["ATIVO"] == 1
def test_alternar_ativo_inverte_o_valor(cliente_id):
assert db_clientes.obter_cliente(cliente_id)["ATIVO"] == 1
assert db_clientes.alternar_ativo(cliente_id) is True
assert db_clientes.obter_cliente(cliente_id)["ATIVO"] == 0
assert db_clientes.alternar_ativo(cliente_id) is True
assert db_clientes.obter_cliente(cliente_id)["ATIVO"] == 1
def test_alternar_ativo_id_inexistente_retorna_false():
assert db_clientes.alternar_ativo(999999999) is False
def test_excluir_cliente_com_contato_bloqueia(cliente_id):
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO CLIENTE_CONTATO (CLIENTE_ID, NOME, TELEFONE) VALUES (%s, %s, %s)",
(cliente_id, "Contato Teste", "75999090001"),
)
finally:
conn.close()
try:
with pytest.raises(db_clientes.ClienteComDependenciasError):
db_clientes.excluir_cliente(cliente_id)
finally:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM CLIENTE_CONTATO WHERE CLIENTE_ID = %s", (cliente_id,))
finally:
conn.close()
+17
View File
@@ -0,0 +1,17 @@
"""
Requer o container de dev rodando (licenciamento-mysql-dev, porta 3307 —
ver licenciamento-n8n/docs/configurar-banco-sistema.md). tests/conftest.py
já configura as env vars de conexão com os mesmos defaults do .env.example.
"""
from app.db.connection import get_connection
def test_conexao_abre_e_consulta():
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT 1 AS um")
row = cur.fetchone()
assert row == {"um": 1}
finally:
conn.close()
+67
View File
@@ -0,0 +1,67 @@
"""
Testes de integração. Cria um CLIENTE de teste (CODIGO 999902) só para
pendurar os contatos, e limpa tudo ao final.
"""
import pytest
from app.db import contatos as db_contatos
from app.db import clientes as db_clientes
from tests.test_db_clientes import CLIENTE_TESTE, _limpar
@pytest.fixture
def cliente_id():
dados = dict(CLIENTE_TESTE, CODIGO="999902")
novo_id = db_clientes.criar_cliente(dados)
yield novo_id
# Clean up contacts first due to FK constraint, then client
from app.db.connection import get_connection
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM CLIENTE_CONTATO WHERE CLIENTE_ID = %s", (novo_id,))
cur.execute("DELETE FROM CLIENTE WHERE ID = %s", (novo_id,))
finally:
conn.close()
def test_inserir_e_listar_contato(cliente_id):
contato_id = db_contatos.inserir_contato(cliente_id, "Fulano", "75999090001")
contatos = db_contatos.listar_contatos_por_cliente(cliente_id)
assert len(contatos) == 1
assert contatos[0]["ID"] == contato_id
assert contatos[0]["NOME"] == "Fulano"
assert contatos[0]["TELEFONE"] == "75999090001"
def test_contato_duplicado(cliente_id):
db_contatos.inserir_contato(cliente_id, "Fulano", "75999090001")
assert db_contatos.contato_duplicado(cliente_id, "75999090001") is True
assert db_contatos.contato_duplicado(cliente_id, "75999090002") is False
def test_contato_duplicado_ignora_proprio_id(cliente_id):
contato_id = db_contatos.inserir_contato(cliente_id, "Fulano", "75999090001")
assert db_contatos.contato_duplicado(cliente_id, "75999090001", ignorar_id=contato_id) is False
def test_atualizar_contato(cliente_id):
contato_id = db_contatos.inserir_contato(cliente_id, "Fulano", "75999090001")
assert db_contatos.atualizar_contato(contato_id, cliente_id, "Fulano Editado", "75999090003") is True
contatos = db_contatos.listar_contatos_por_cliente(cliente_id)
assert contatos[0]["NOME"] == "Fulano Editado"
assert contatos[0]["TELEFONE"] == "75999090003"
def test_atualizar_contato_inexistente_retorna_false(cliente_id):
assert db_contatos.atualizar_contato(999999, cliente_id, "Fulano", "75999090001") is False
def test_excluir_contato(cliente_id):
contato_id = db_contatos.inserir_contato(cliente_id, "Fulano", "75999090001")
assert db_contatos.excluir_contato(contato_id, cliente_id) is True
assert db_contatos.listar_contatos_por_cliente(cliente_id) == []
def test_excluir_contato_inexistente_retorna_false(cliente_id):
assert db_contatos.excluir_contato(999999, cliente_id) is False
+98
View File
@@ -0,0 +1,98 @@
"""Testes de integração contra o banco de dev (ver test_db_connection.py)."""
import pytest
from app.db import funcionarios as db_funcionarios
from app.db.connection import get_connection
LOGIN_TESTE = "teste.db.funcionarios"
def _limpar():
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM FUNCIONARIO_PAINEL WHERE LOGIN LIKE 'teste.db.funcionarios%'")
finally:
conn.close()
@pytest.fixture
def funcionario_id():
_limpar()
novo_id = db_funcionarios.criar_funcionario("Fulano Teste", LOGIN_TESTE, "senhaForte123", "PADRAO")
yield novo_id
_limpar()
def test_criar_e_obter_funcionario(funcionario_id):
f = db_funcionarios.obter_funcionario(funcionario_id)
assert f is not None
assert f["NOME"] == "Fulano Teste"
assert f["LOGIN"] == LOGIN_TESTE
assert f["ATIVO"] == 1
assert f["PERFIL"] == "PADRAO"
assert "SENHA_HASH" not in f # obter_funcionario não expõe o hash
def test_obter_por_login_inclui_senha_hash_e_perfil(funcionario_id):
f = db_funcionarios.obter_por_login(LOGIN_TESTE)
assert f is not None
assert "SENHA_HASH" in f
assert f["SENHA_HASH"] != "senhaForte123" # nunca em texto puro
assert f["PERFIL"] == "PADRAO"
def test_obter_por_login_inexistente(funcionario_id):
assert db_funcionarios.obter_por_login("nao-existe-esse-login-xyz") is None
def test_login_em_uso(funcionario_id):
assert db_funcionarios.login_em_uso(LOGIN_TESTE) is True
assert db_funcionarios.login_em_uso(LOGIN_TESTE, ignorar_id=funcionario_id) is False
assert db_funcionarios.login_em_uso("login-que-nao-existe") is False
def test_listar_funcionarios_inclui_o_criado(funcionario_id):
encontrados = {f["LOGIN"]: f for f in db_funcionarios.listar_funcionarios()}
assert LOGIN_TESTE in encontrados
assert encontrados[LOGIN_TESTE]["PERFIL"] == "PADRAO"
def test_atualizar_funcionario(funcionario_id):
novo_login = "teste.db.funcionarios.editado"
db_funcionarios.atualizar_funcionario(funcionario_id, "Fulano Editado", novo_login, "ADMIN")
f = db_funcionarios.obter_funcionario(funcionario_id)
assert f["NOME"] == "Fulano Editado"
assert f["LOGIN"] == novo_login
assert f["PERFIL"] == "ADMIN"
def test_redefinir_senha(funcionario_id):
hash_antes = db_funcionarios.obter_por_login(LOGIN_TESTE)["SENHA_HASH"]
db_funcionarios.redefinir_senha(funcionario_id, "outraSenha456")
hash_depois = db_funcionarios.obter_por_login(LOGIN_TESTE)["SENHA_HASH"]
assert hash_antes != hash_depois
def test_alternar_ativo(funcionario_id):
assert db_funcionarios.obter_funcionario(funcionario_id)["ATIVO"] == 1
db_funcionarios.alternar_ativo(funcionario_id)
assert db_funcionarios.obter_funcionario(funcionario_id)["ATIVO"] == 0
db_funcionarios.alternar_ativo(funcionario_id)
assert db_funcionarios.obter_funcionario(funcionario_id)["ATIVO"] == 1
def test_contar_admins_ativos(funcionario_id):
# funcionario_id é PADRAO (fixture acima) — não conta.
antes = db_funcionarios.contar_admins_ativos()
admin_id = db_funcionarios.criar_funcionario(
"Admin Teste", "teste.db.funcionarios.admin", "senhaForte123", "ADMIN"
)
try:
assert db_funcionarios.contar_admins_ativos() == antes + 1
db_funcionarios.alternar_ativo(admin_id) # desativa
assert db_funcionarios.contar_admins_ativos() == antes
finally:
_limpar()
+51
View File
@@ -0,0 +1,51 @@
import pytest
from app.db import log as db_log
from app.db.connection import get_connection
def _limpar_teste():
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM LOG_PAINEL WHERE USUARIO = 'teste-log'")
finally:
conn.close()
@pytest.fixture
def limpar():
_limpar_teste()
yield
_limpar_teste()
def test_registrar_e_listar(limpar):
db_log.registrar("teste-log", "criar", "cliente", "1", True)
db_log.registrar("teste-log", "excluir", "cliente", "1", False, "tem contato")
linhas = db_log.listar(limite=10)
linhas_teste = [l for l in linhas if l["USUARIO"] == "teste-log"]
assert len(linhas_teste) == 2
# ORDER BY CRIADO_EM DESC, ID DESC -> a mais recente (excluir) vem primeiro.
assert linhas_teste[0]["ACAO"] == "excluir"
assert linhas_teste[0]["SUCESSO"] == 0
assert linhas_teste[0]["DETALHE"] == "tem contato"
assert linhas_teste[1]["ACAO"] == "criar"
assert linhas_teste[1]["SUCESSO"] == 1
def test_contar(limpar):
antes = db_log.contar()
db_log.registrar("teste-log", "criar", "cliente", "1", True)
depois = db_log.contar()
assert depois == antes + 1
def test_limpar_apaga_tudo(limpar):
db_log.registrar("teste-log", "criar", "cliente", "1", True)
assert db_log.contar() > 0
db_log.limpar()
assert db_log.contar() == 0
+41
View File
@@ -0,0 +1,41 @@
from app.gerador_chave import gerar_instalacao, gerar_reativacao
def test_gerar_instalacao_bate_com_exemplo_real_byte_dc():
assert gerar_instalacao("000001", "ALUGUEL", byte_inicial=0xDC) == "DC45092C"
def test_gerar_instalacao_bate_com_exemplo_real_byte_2e():
assert gerar_instalacao("000001", "ALUGUEL", byte_inicial=0x2E) == "2E35083D"
def test_gerar_instalacao_bate_com_exemplo_real_byte_3d():
assert gerar_instalacao("000001", "ALUGUEL", byte_inicial=0x3D) == "3D25090C"
def test_gerar_instalacao_compra_usa_modalidade_00():
# Mesmo byte inicial, modalidade diferente -> cifrado diferente do ALUGUEL.
aluguel = gerar_instalacao("000001", "ALUGUEL", byte_inicial=0xDC)
compra = gerar_instalacao("000001", "COMPRA", byte_inicial=0xDC)
assert aluguel != compra
assert compra.startswith("DC") # byte inicial some no começo, sem cifrar
def test_gerar_instalacao_sem_byte_inicial_gera_aleatorio_valido():
chave = gerar_instalacao("000001", "ALUGUEL")
assert len(chave) == 8
assert chave[3:5].isdigit() # valor_cliente embutido no meio
def test_gerar_reativacao_e_deterministico_com_byte_fixo():
chave1 = gerar_reativacao("000123", 8, 2026, byte_inicial=0x10)
chave2 = gerar_reativacao("000123", 8, 2026, byte_inicial=0x10)
assert chave1 == chave2
assert len(chave1) == 22 # 11 bytes (1 aleatorio + 10 do texto) em hex
def test_gerar_reativacao_sem_byte_inicial_varia():
# Estatisticamente, gerar várias vezes sem byte fixo não deve sempre
# bater igual (chance de colisão é 1/256).
chaves = {gerar_reativacao("000123", 8, 2026) for _ in range(20)}
assert len(chaves) > 1
+81
View File
@@ -0,0 +1,81 @@
"""GET /internal/fiscal/clients — endpoint read-only server-to-server para o
GTI Fiscal espelhar o cadastro de clientes. Usa CODIGO na faixa de teste
"999950"-"999959" (ver tests/test_db_clientes.py) e limpa o que criou."""
import pytest
from fastapi.testclient import TestClient
from app.db import clientes as db_clientes
from app.db.connection import get_connection
from app.main import app
TOKEN = "teste-token-fiscal-interno-suficientemente-longo"
BASE = {
"CNPJ": "11222333000181", "RAZAO": "TESTE INTERNO RAZAO", "IE": "ISENTO",
"ENDERECO": "RUA TESTE, 1", "CIDADE": "FEIRA DE SANTANA", "BAIRRO": "CENTRO", "UF": "BA",
"CONTATO": "FULANO", "TELEFONE": "75999090000", "CELULAR": "75999090000", "CEP": "44000000",
"IMPLANTACAO": "0.00", "MENSALIDADE": "100.00", "JUROS_MORA": "0.00", "JUROS_MULTA": "0.00",
"DATA_CONTRATO": "2026-01-01", "EMAIL": "teste@example.com", "OBS": "teste automatizado",
"VENCIMENTO_CONTRATO": "2027-01-01", "DIA_VENCIMENTO": "10",
}
def _limpar(codigos):
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(f"DELETE FROM CLIENTE WHERE CODIGO IN ({','.join(['%s'] * len(codigos))})", codigos)
finally:
conn.close()
@pytest.fixture
def clientes_teste():
ativo_id = db_clientes.criar_cliente({**BASE, "CODIGO": "999950", "FANTASIA": "Ativo Fiscal Teste", "ATIVO": "1"})
inativo_id = db_clientes.criar_cliente({**BASE, "CODIGO": "999951", "FANTASIA": "", "RAZAO": "Inativo Fiscal Teste Razao", "ATIVO": "0"})
yield ativo_id, inativo_id
_limpar(["999950", "999951"])
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("FISCAL_INTERNAL_API_TOKEN", TOKEN)
from app import internal_fiscal
internal_fiscal._falhas.clear()
return TestClient(app)
def test_returns_active_and_inactive_clients_with_minimal_fields(client, clientes_teste):
ativo_id, inativo_id = clientes_teste
response = client.get("/internal/fiscal/clients", headers={"Authorization": f"Bearer {TOKEN}"})
assert response.status_code == 200
by_id = {row["external_client_id"]: row for row in response.json()["clients"]}
assert by_id[ativo_id] == {"external_client_id": ativo_id, "code": "999950", "display_name": "Ativo Fiscal Teste", "cnpj": "11222333000181", "active": True}
assert by_id[inativo_id] == {"external_client_id": inativo_id, "code": "999951", "display_name": "Inativo Fiscal Teste Razao", "cnpj": "11222333000181", "active": False}
# never leaks fields the Fiscal doesn't need
assert set(by_id[ativo_id]) == {"external_client_id", "code", "display_name", "cnpj", "active"}
def test_missing_token_rejected(client, clientes_teste):
assert client.get("/internal/fiscal/clients").status_code == 401
def test_wrong_token_rejected(client, clientes_teste):
response = client.get("/internal/fiscal/clients", headers={"Authorization": "Bearer wrong-token"})
assert response.status_code == 401
def test_endpoint_disabled_when_token_not_configured(monkeypatch):
monkeypatch.delenv("FISCAL_INTERNAL_API_TOKEN", raising=False)
from app import internal_fiscal
internal_fiscal._falhas.clear()
client = TestClient(app)
response = client.get("/internal/fiscal/clients", headers={"Authorization": "Bearer anything"})
assert response.status_code == 503
def test_rate_limited_after_repeated_invalid_attempts(client):
from app import internal_fiscal
for _ in range(internal_fiscal.MAX_TENTATIVAS):
assert client.get("/internal/fiscal/clients", headers={"Authorization": "Bearer wrong"}).status_code == 401
assert client.get("/internal/fiscal/clients", headers={"Authorization": "Bearer wrong"}).status_code == 429
+130
View File
@@ -0,0 +1,130 @@
from fastapi.testclient import TestClient
from app import session
from app.main import app
client = TestClient(app)
def _cookie_sessao_admin() -> dict:
return {session.SESSION_COOKIE_NAME: session.criar_token("admin", "admin", "ADMIN")}
def test_raiz_sem_login_redireciona_para_login():
resposta = client.get("/", follow_redirects=False)
assert resposta.status_code == 303
assert resposta.headers["location"] == "/login?next=/"
def test_docs_desabilitado_retorna_404():
assert client.get("/docs").status_code == 404
assert client.get("/redoc").status_code == 404
assert client.get("/openapi.json").status_code == 404
def test_raiz_redireciona_para_clientes_com_login(monkeypatch):
from app.routers import clientes as router_clientes
monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: [])
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1: 0)
resposta = client.get("/", cookies=_cookie_sessao_admin(), follow_redirects=False)
assert resposta.status_code == 307
assert resposta.headers["location"] == "/clientes"
def test_csrf_middleware_define_cookie_em_qualquer_resposta():
client.cookies.clear()
resposta = client.get("/docs")
assert resposta.status_code == 404
assert "csrf_token" in resposta.cookies
def test_csrf_bloqueia_post_com_token_que_nao_bate_com_cookie():
client.cookies.clear()
resposta = client.post(
"/clientes/1/excluir", cookies=_cookie_sessao_admin(), data={"csrf_token": "token-forjado-sem-cookie"}
)
assert resposta.status_code == 403
def test_csrf_libera_post_quando_token_bate_com_cookie(monkeypatch):
from app.routers import clientes as router_clientes
monkeypatch.setattr(router_clientes.db_clientes, "excluir_cliente", lambda cliente_id: None)
client.cookies.clear()
client.get("/docs") # só para o middleware definir o cookie csrf_token no client de teste
token_cookie = client.cookies.get("csrf_token")
assert token_cookie
cookies = {**_cookie_sessao_admin(), "csrf_token": token_cookie}
resposta = client.post(
"/clientes/1/excluir", cookies=cookies, data={"csrf_token": token_cookie}, follow_redirects=False
)
assert resposta.status_code == 303
def test_pagina_clientes_tem_csrf_token_preenchido_no_botao_sair(monkeypatch):
"""
Regressão: GET /clientes (a página onde todo login cai) não passava
csrf_token no contexto do template, então o formulário de logout em
base.html renderizava com value="" e POST /logout era rejeitado com
403 por app/csrf.py. base.html agora usa
"{{ csrf_token or request.state.csrf_token }}" como fallback.
"""
from app.routers import clientes as router_clientes
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: [],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1: 0)
client.cookies.clear()
resposta = client.get("/clientes", cookies=_cookie_sessao_admin())
assert resposta.status_code == 200
assert 'name="csrf_token" value=""' not in resposta.text
import re
match = re.search(r'name="csrf_token" value="([^"]*)"', resposta.text)
assert match is not None
assert len(match.group(1)) > 0
def test_logout_funciona_depois_de_visitar_clientes(monkeypatch):
"""
Regressão complementar: simula o fluxo real — visitar /clientes (onde
o cookie csrf_token é definido pelo middleware) e então enviar
POST /logout usando o csrf_token real do cookie do navegador, sem
passar csrf_token manualmente no contexto do template.
"""
from app.routers import clientes as router_clientes
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: [],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1: 0)
client.cookies.clear()
client.cookies.update(_cookie_sessao_admin())
resposta_clientes = client.get("/clientes")
assert resposta_clientes.status_code == 200
token_cookie = client.cookies.get("csrf_token")
assert token_cookie
resposta_logout = client.post("/logout", data={"csrf_token": token_cookie}, follow_redirects=False)
assert resposta_logout.status_code == 303
def test_estilo_css_tem_versao_de_cache_busting():
resposta = client.get("/login")
assert resposta.status_code == 200
import re
m = re.search(r'/static/estilo\.css\?v=(\d+)', resposta.text)
assert m is not None, "link do estilo.css deveria ter ?v=<versao>"
assert m.group(1) != "0"
+60
View File
@@ -0,0 +1,60 @@
import httpx
import pytest
from app import n8n_client
def test_nao_configurado_retorna_vazio(monkeypatch):
monkeypatch.delenv("N8N_API_URL", raising=False)
monkeypatch.delenv("N8N_API_KEY", raising=False)
assert n8n_client.configurado() is False
assert n8n_client.listar_envios() == []
def test_configurado_com_ambas_variaveis(monkeypatch):
monkeypatch.setenv("N8N_API_URL", "https://devn8n.exemplo.com")
monkeypatch.setenv("N8N_API_KEY", "chave-teste")
assert n8n_client.configurado() is True
def test_listar_envios_falha_de_rede_retorna_vazio(monkeypatch):
monkeypatch.setenv("N8N_API_URL", "https://devn8n.exemplo.com")
monkeypatch.setenv("N8N_API_KEY", "chave-teste")
def _get_com_erro(*args, **kwargs):
raise httpx.ConnectError("falhou")
monkeypatch.setattr(n8n_client.httpx, "get", _get_com_erro)
assert n8n_client.listar_envios() == []
def test_listar_envios_sucesso_retorna_linhas(monkeypatch):
monkeypatch.setenv("N8N_API_URL", "https://devn8n.exemplo.com/")
monkeypatch.setenv("N8N_API_KEY", "chave-teste")
linhas = [{"codigo_cliente": "000001", "status": "enviado"}]
class _RespostaFalsa:
def raise_for_status(self):
pass
def json(self):
return {"data": linhas}
capturado = {}
def _get_falso(url, headers=None, params=None, timeout=None):
capturado["url"] = url
capturado["headers"] = headers
capturado["params"] = params
return _RespostaFalsa()
monkeypatch.setattr(n8n_client.httpx, "get", _get_falso)
resultado = n8n_client.listar_envios(limite=10)
assert resultado == linhas
assert capturado["url"] == "https://devn8n.exemplo.com/api/v1/data-tables/bJHhFUDwFJ30YJVt/rows"
assert capturado["headers"]["X-N8N-API-KEY"] == "chave-teste"
# Regression guard: a API do n8n usa "limit", não "take" (achado real
# testando contra a instância de verdade — "take" retorna 400).
assert capturado["params"] == {"limit": 10, "sortBy": "data_processamento:desc"}
+124
View File
@@ -0,0 +1,124 @@
"""
Fixtures de CPF/CNPJ verificadas manualmente:
- CPF "11144477735": DV clássico módulo 11, exemplo público amplamente usado
em validadores de CPF.
- CNPJ numérico "11222333000181": exemplo público amplamente usado em
validadores de CNPJ.
- CNPJ alfanumérico "00000000E08G12": primeiro CNPJ alfanumérico realmente
emitido no Brasil (Banco do Bradil, 02/08/2026) — confirma que o algoritmo
bate com um caso real, não só um exemplo didático.
"""
from app.normalize import (
normalizar_documento,
normalizar_telefone,
validar_dv_cpf,
validar_dv_cnpj,
)
def test_normaliza_cpf_com_mascara():
doc, valido, tipo = normalizar_documento("111.444.777-35")
assert doc == "11144477735"
assert valido is True
assert tipo == "CPF"
def test_normaliza_cnpj_numerico_com_mascara():
doc, valido, tipo = normalizar_documento("11.222.333/0001-81")
assert doc == "11222333000181"
assert valido is True
assert tipo == "CNPJ"
def test_normaliza_cnpj_alfanumerico_com_mascara():
doc, valido, tipo = normalizar_documento("00.000.000/E08G-12")
assert doc == "00000000E08G12"
assert valido is True
assert tipo == "CNPJ"
def test_documento_invalido_tamanho_errado():
doc, valido, tipo = normalizar_documento("123")
assert valido is False
assert tipo is None
def test_documento_none():
doc, valido, tipo = normalizar_documento(None)
assert doc == ""
assert valido is False
assert tipo is None
def test_normaliza_telefone():
assert normalizar_telefone("(75) 99909-1780") == "75999091780"
assert normalizar_telefone(None) == ""
def test_validar_dv_cpf_correto():
assert validar_dv_cpf("11144477735") is True
def test_validar_dv_cpf_incorreto():
assert validar_dv_cpf("11144477734") is False
def test_validar_dv_cnpj_numerico_correto():
assert validar_dv_cnpj("11222333000181") is True
def test_validar_dv_cnpj_numerico_incorreto():
assert validar_dv_cnpj("11222333000180") is False
def test_validar_dv_cnpj_alfanumerico_correto():
# Primeiro CNPJ alfanumérico real emitido no Brasil.
assert validar_dv_cnpj("00000000E08G12") is True
def test_validar_dv_cnpj_alfanumerico_incorreto():
assert validar_dv_cnpj("00000000E08G13") is False
from app.normalize import normalizar_telefone_com_ddi, normalizar_uf, normalizar_cep
def test_normaliza_telefone_com_ddi_prefixa_55_quando_ausente():
assert normalizar_telefone_com_ddi("(75) 3333-4444") == "557533334444"
assert normalizar_telefone_com_ddi("(75) 99909-1780") == "5575999091780"
def test_normaliza_telefone_com_ddi_nao_duplica_55_ja_presente():
assert normalizar_telefone_com_ddi("55 75 99909-1780") == "5575999091780"
assert normalizar_telefone_com_ddi("5575333344440") != "555575333344440"
def test_normaliza_telefone_com_ddi_vazio_continua_vazio():
assert normalizar_telefone_com_ddi("") == ""
assert normalizar_telefone_com_ddi(None) == ""
def test_normaliza_telefone_com_ddi_e_idempotente():
"""Aplicar a função duas vezes seguidas nunca deve dobrar o DDI."""
casos = ["(75) 3333-4444", "(75) 99909-1780", "5575999091780", "3333-4444", ""]
for bruto in casos:
uma_vez = normalizar_telefone_com_ddi(bruto)
duas_vezes = normalizar_telefone_com_ddi(uma_vez)
assert duas_vezes == uma_vez, f"não é idempotente para {bruto!r}: {uma_vez!r} -> {duas_vezes!r}"
def test_normaliza_uf_maiusculas_e_trunca():
assert normalizar_uf("ba") == "BA"
assert normalizar_uf(" sp ") == "SP"
assert normalizar_uf("SAO") == "SA"
def test_normaliza_uf_remove_numeros_e_simbolos():
assert normalizar_uf("B4!") == "B"
assert normalizar_uf(None) == ""
def test_normaliza_cep_so_digitos_e_trunca():
assert normalizar_cep("44430-174") == "44430174"
assert normalizar_cep("44.430-174-extra") == "44430174"
assert normalizar_cep(None) == ""
+58
View File
@@ -0,0 +1,58 @@
from app import rate_limit
def _origem_unica() -> str:
"""Cada teste usa uma chave própria — evita que o dict em memória do
módulo (compartilhado entre testes) vaze estado de um teste pro outro."""
import uuid
return f"teste-{uuid.uuid4()}"
def test_nao_bloqueado_sem_nenhuma_falha():
assert rate_limit.bloqueado(_origem_unica()) is False
def test_nao_bloqueado_abaixo_do_limite():
origem = _origem_unica()
agora = 1_000_000.0
for _ in range(rate_limit.MAX_TENTATIVAS - 1):
rate_limit.registrar_falha(origem, agora=agora)
assert rate_limit.bloqueado(origem, agora=agora) is False
def test_bloqueado_ao_atingir_o_limite():
origem = _origem_unica()
agora = 1_000_000.0
for _ in range(rate_limit.MAX_TENTATIVAS):
rate_limit.registrar_falha(origem, agora=agora)
assert rate_limit.bloqueado(origem, agora=agora) is True
def test_falhas_fora_da_janela_nao_contam():
origem = _origem_unica()
agora = 1_000_000.0
fora_da_janela = agora - rate_limit.JANELA_SEGUNDOS - 1
for _ in range(rate_limit.MAX_TENTATIVAS):
rate_limit.registrar_falha(origem, agora=fora_da_janela)
assert rate_limit.bloqueado(origem, agora=agora) is False
def test_limpar_reseta_o_historico():
origem = _origem_unica()
agora = 1_000_000.0
for _ in range(rate_limit.MAX_TENTATIVAS):
rate_limit.registrar_falha(origem, agora=agora)
assert rate_limit.bloqueado(origem, agora=agora) is True
rate_limit.limpar(origem)
assert rate_limit.bloqueado(origem, agora=agora) is False
def test_origens_diferentes_nao_se_afetam():
origem_a, origem_b = _origem_unica(), _origem_unica()
agora = 1_000_000.0
for _ in range(rate_limit.MAX_TENTATIVAS):
rate_limit.registrar_falha(origem_a, agora=agora)
assert rate_limit.bloqueado(origem_a, agora=agora) is True
assert rate_limit.bloqueado(origem_b, agora=agora) is False
+132
View File
@@ -0,0 +1,132 @@
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from app import rate_limit, session
from app.auth import NaoAutenticado, UsuarioLogado, exigir_login
from app.routers import auth as router_auth
app_teste = FastAPI()
app_teste.include_router(router_auth.router)
@app_teste.exception_handler(NaoAutenticado)
def _handler(request, exc):
from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"/login?next={exc.caminho_original}", status_code=303)
@app_teste.get("/rota-protegida-teste")
def _rota_protegida(usuario: UsuarioLogado = Depends(exigir_login)):
return {"ok": True}
client = TestClient(app_teste, follow_redirects=False)
CSRF_TESTE = "teste-csrf-token"
client.cookies.set("csrf_token", CSRF_TESTE)
def test_get_login_mostra_formulario():
resposta = client.get("/login")
assert resposta.status_code == 200
assert "senha" in resposta.text.lower()
def test_post_login_credenciais_corretas_grava_cookie_e_redireciona():
resposta = client.post(
"/login", data={"usuario": "admin", "senha": "teste123", "next": "/clientes", "csrf_token": CSRF_TESTE}
)
assert resposta.status_code == 303
assert resposta.headers["location"] == "/clientes"
assert session.SESSION_COOKIE_NAME in resposta.cookies
def test_post_login_sem_next_redireciona_para_clientes():
resposta = client.post("/login", data={"usuario": "admin", "senha": "teste123", "csrf_token": CSRF_TESTE})
assert resposta.status_code == 303
assert resposta.headers["location"] == "/clientes"
def test_post_login_next_externo_e_ignorado_por_seguranca():
resposta = client.post(
"/login",
data={"usuario": "admin", "senha": "teste123", "next": "https://site-malicioso.com", "csrf_token": CSRF_TESTE},
)
assert resposta.status_code == 303
assert resposta.headers["location"] == "/clientes"
def test_post_login_next_com_barra_invertida_e_ignorado_por_seguranca():
# "/\evil.com" é tratado por navegadores modernos como equivalente a
# "//evil.com" (URL protocol-relative) — precisa ser bloqueado também.
resposta = client.post(
"/login",
data={"usuario": "admin", "senha": "teste123", "next": "/\\evil.com", "csrf_token": CSRF_TESTE},
)
assert resposta.status_code == 303
assert resposta.headers["location"] == "/clientes"
def test_post_login_credenciais_erradas_nao_grava_cookie():
resposta = client.post(
"/login", data={"usuario": "admin", "senha": "errada", "csrf_token": CSRF_TESTE}
)
assert resposta.status_code == 200
assert "inválid" in resposta.text.lower()
assert session.SESSION_COOKIE_NAME not in resposta.cookies
def test_get_rota_protegida_sem_login_redireciona_para_login():
# Testes anteriores deixaram um painel_session válido no cookie jar do
# client (persistente, como um navegador real) — limpa antes de simular
# a requisição de um visitante não autenticado.
client.cookies.delete(session.SESSION_COOKIE_NAME)
resposta = client.get("/rota-protegida-teste")
assert resposta.status_code == 303
assert resposta.headers["location"] == "/login?next=/rota-protegida-teste"
def test_post_login_apos_muitas_falhas_e_bloqueado_mesmo_com_senha_certa():
# TestClient reporta sempre a mesma origem ("testclient") — limpa
# antes pra não herdar a falha registrada em
# test_post_login_credenciais_erradas_nao_grava_cookie() acima, e
# limpa depois pra não vazar bloqueio pros testes seguintes deste
# arquivo (que também usam essa mesma origem "testclient").
rate_limit._falhas.pop("testclient", None)
try:
for _ in range(rate_limit.MAX_TENTATIVAS):
client.post("/login", data={"usuario": "admin", "senha": "errada", "csrf_token": CSRF_TESTE})
resposta = client.post(
"/login", data={"usuario": "admin", "senha": "teste123", "csrf_token": CSRF_TESTE}
)
assert resposta.status_code == 429
assert "muitas tentativas" in resposta.text.lower()
assert session.SESSION_COOKIE_NAME not in resposta.cookies
finally:
rate_limit._falhas.pop("testclient", None)
def test_post_logout_apaga_cookie_e_redireciona():
cookies = {session.SESSION_COOKIE_NAME: session.criar_token("admin", "Admin", "ADMIN")}
resposta = client.post("/logout", data={"csrf_token": CSRF_TESTE}, cookies=cookies)
assert resposta.status_code == 303
assert resposta.headers["location"] == "/login"
# cookie apagado: o Set-Cookie da resposta zera o valor
set_cookie = resposta.headers.get("set-cookie", "")
assert session.SESSION_COOKIE_NAME in set_cookie
def test_post_logout_apaga_cookie_de_preferencia_de_paginacao():
from app.routers.clientes import POR_PAGINA_COOKIE
cookies = {session.SESSION_COOKIE_NAME: session.criar_token("admin", "Admin", "ADMIN")}
resposta = client.post("/logout", data={"csrf_token": CSRF_TESTE}, cookies=cookies)
assert resposta.status_code == 303
set_cookie = resposta.headers.get("set-cookie", "")
assert POR_PAGINA_COOKIE in set_cookie
# confirma que o cookie foi de fato apagado (Max-Age=0), não só regravado
trecho_cookie = next(
parte for parte in resposta.headers.get_list("set-cookie") if POR_PAGINA_COOKIE in parte
)
assert "Max-Age=0" in trecho_cookie
@@ -0,0 +1,102 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.auth import exigir_login
from app.routers import chave_manual as router_chave_manual
app_teste = FastAPI()
app_teste.include_router(router_chave_manual.router)
app_teste.dependency_overrides[exigir_login] = lambda: "teste"
client = TestClient(app_teste)
CSRF_TESTE = "teste-csrf-token"
client.cookies.set("csrf_token", CSRF_TESTE)
def test_pagina_gerador_carrega():
resposta = client.get("/gerador-chave")
assert resposta.status_code == 200
assert "Gerador de Chave" in resposta.text
# O algoritmo NUNCA pode voltar a aparecer no HTML/JS da página — ele
# roda só no backend (app/gerador_chave.py) desde que essa exposição
# foi corrigida.
assert "CHAVE_INTERNA" not in resposta.text
assert "function gerarReativacao" not in resposta.text
assert "function gerarInstalacao" not in resposta.text
def test_pagina_gerador_nao_grava_nada():
# GET /gerador-chave não recebe csrf_token no cookie/form combinando
# com nada que grave — só há dois POSTs possíveis nesse router:
# /gerador-chave (não existe, 405) e /gerador-chave/gerar (não grava
# em Clientes_Chaves, só calcula e devolve).
resposta = client.post("/gerador-chave")
assert resposta.status_code == 405
def test_gerar_reativacao_com_sucesso():
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "reativacao", "codigo": "123", "referencia": "2026-08"},
)
assert resposta.status_code == 200
corpo = resposta.json()
assert len(corpo["chave"]) == 22
assert all(c in "0123456789ABCDEF" for c in corpo["chave"])
def test_gerar_instalacao_com_sucesso_bate_com_exemplo_conhecido():
# Não dá pra fixar o byte aleatório via HTTP, mas confere que o valor do
# cliente (1*19+31=50) aparece embutido no meio da chave, como o
# algoritmo real exige.
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "instalacao", "codigo": "000001", "modalidade": "ALUGUEL"},
)
assert resposta.status_code == 200
corpo = resposta.json()
assert len(corpo["chave"]) == 8
assert corpo["chave"][3:5] == "50"
def test_gerar_codigo_invalido_bloqueia():
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "instalacao", "codigo": "abc123", "modalidade": "ALUGUEL"},
)
assert resposta.status_code == 400
assert "6 dígitos" in resposta.json()["erro"]
def test_gerar_reativacao_sem_referencia_bloqueia():
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "reativacao", "codigo": "123", "referencia": ""},
)
assert resposta.status_code == 400
assert "mês/ano" in resposta.json()["erro"]
def test_gerar_instalacao_modalidade_invalida_bloqueia():
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "instalacao", "codigo": "123", "modalidade": "X"},
)
assert resposta.status_code == 400
assert "Modalidade" in resposta.json()["erro"]
def test_gerar_tipo_invalido_bloqueia():
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "outracoisa", "codigo": "123"},
)
assert resposta.status_code == 400
assert "Tipo de chave" in resposta.json()["erro"]
def test_gerar_sem_csrf_bloqueia():
resposta = client.post(
"/gerador-chave/gerar",
data={"tipo": "instalacao", "codigo": "123", "modalidade": "ALUGUEL"},
)
assert resposta.status_code == 403
+962
View File
@@ -0,0 +1,962 @@
"""
Testa só a camada HTTP: monta um FastAPI() com apenas este router, sobrescreve
exigir_login (sem exigir Basic Auth real) e faz monkeypatch das funções de
app.db.clientes usadas pelo router — não toca no banco de verdade.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.auth import exigir_login
from app.db.connection import get_connection
from app.routers import clientes as router_clientes
app_teste = FastAPI()
app_teste.include_router(router_clientes.router)
app_teste.dependency_overrides[exigir_login] = lambda: "teste"
client = TestClient(app_teste, follow_redirects=False)
# Este app de teste não tem o middleware de CSRF (só o main.py real o monta).
# Simula um navegador que já tem o cookie definido: os testes de POST
# precisam mandar o mesmo valor no campo oculto "csrf_token".
CSRF_TESTE = "teste-csrf-token"
client.cookies.set("csrf_token", CSRF_TESTE)
@pytest.fixture(autouse=True)
def _sem_log_real(monkeypatch):
"""
O router registra cada ação em LOG_PAINEL (app/db/log.py) — por padrão
isso bateria no banco real. Substitui por um no-op nos testes que não
são especificamente sobre o log (test_router_clientes não testa
app/db/log.py, isso já é coberto em tests/test_db_log.py).
"""
monkeypatch.setattr(router_clientes.db_log, "registrar", lambda *args, **kwargs: None)
@pytest.fixture(autouse=True)
def _limpar_cookies():
"""Limpa cookies entre testes para evitar compartilhamento de estado."""
client.cookies.clear()
client.cookies.set("csrf_token", CSRF_TESTE)
yield
# Limpa novamente após o teste
client.cookies.clear()
client.cookies.set("csrf_token", CSRF_TESTE)
def _form_valido(codigo: str = "000001") -> dict:
"""Monta um dict de formulário válido (todos os campos preenchidos)."""
dados = {campo: "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE}
dados.update(
{
"CODIGO": codigo,
"CNPJ": "11222333000181",
"IMPLANTACAO": "0",
"MENSALIDADE": "100",
"JUROS_MORA": "0",
"JUROS_MULTA": "0",
"DATA_CONTRATO": "2026-01-01",
"VENCIMENTO_CONTRATO": "2027-01-01",
"DIA_VENCIMENTO": "10",
"ATIVO": "1",
"csrf_token": CSRF_TESTE,
}
)
return dados
def test_resolver_por_pagina_usa_query_quando_valido():
assert router_clientes._resolver_por_pagina(50, None) == 50
def test_resolver_por_pagina_ignora_query_invalida_usa_cookie():
assert router_clientes._resolver_por_pagina(999, "30") == 30
def test_resolver_por_pagina_ignora_cookie_invalido_usa_padrao():
assert router_clientes._resolver_por_pagina(None, "abc") == 15
assert router_clientes._resolver_por_pagina(None, "999") == 15
def test_resolver_por_pagina_sem_query_nem_cookie_usa_padrao():
assert router_clientes._resolver_por_pagina(None, None) == 15
def test_janela_paginas_poucas_paginas_mostra_todas():
assert router_clientes._janela_paginas(1, 5) == [1, 2, 3, 4, 5]
assert router_clientes._janela_paginas(3, 7) == [1, 2, 3, 4, 5, 6, 7]
def test_janela_paginas_no_meio_com_reticencias_dos_dois_lados():
assert router_clientes._janela_paginas(5, 20) == [1, None, 4, 5, 6, None, 20]
def test_janela_paginas_primeira_pagina():
assert router_clientes._janela_paginas(1, 20) == [1, 2, None, 20]
def test_janela_paginas_ultima_pagina():
assert router_clientes._janela_paginas(20, 20) == [1, None, 19, 20]
def test_listar_clientes_por_pagina_padrao_e_15(monkeypatch):
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: capturado.setdefault("limite", limite) and [],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:3)
resposta = client.get("/clientes")
assert resposta.status_code == 200
assert capturado["limite"] == 15
def test_listar_clientes_por_pagina_da_query_e_usada_e_grava_cookie(monkeypatch):
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: capturado.setdefault("limite", limite) and [],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:3)
resposta = client.get("/clientes?por_pagina=50")
assert resposta.status_code == 200
assert capturado["limite"] == 50
assert resposta.cookies.get(router_clientes.POR_PAGINA_COOKIE) == "50"
def test_listar_clientes_usa_cookie_quando_sem_por_pagina_na_query(monkeypatch):
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: capturado.setdefault("limite", limite) and [],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:3)
resposta = client.get("/clientes", cookies={router_clientes.POR_PAGINA_COOKIE: "30"})
assert resposta.status_code == 200
assert capturado["limite"] == 30
def test_listar_clientes_por_pagina_invalida_na_query_cai_no_padrao_e_nao_grava_cookie(monkeypatch):
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: capturado.setdefault("limite", limite) and [],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:3)
resposta = client.get("/clientes?por_pagina=999")
assert resposta.status_code == 200
assert capturado["limite"] == 15
assert router_clientes.POR_PAGINA_COOKIE not in resposta.cookies
def test_listar_clientes_status_padrao_e_ativos(monkeypatch):
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: (capturado.__setitem__("filtro_ativo", filtro_ativo), [])[1],
)
monkeypatch.setattr(
router_clientes.db_clientes,
"contar_clientes",
lambda busca=None, ordenar_por="razao", filtro_ativo=1: 0,
)
resposta = client.get("/clientes")
assert resposta.status_code == 200
assert capturado["filtro_ativo"] == 1
def test_listar_clientes_status_inativos_usa_filtro_zero(monkeypatch):
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: (capturado.__setitem__("filtro_ativo", filtro_ativo), [])[1],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1: 0)
resposta = client.get("/clientes?status=inativos")
assert resposta.status_code == 200
assert capturado["filtro_ativo"] == 0
def test_listar_clientes_status_todos_sem_filtro(monkeypatch):
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: (capturado.__setitem__("filtro_ativo", filtro_ativo), [])[1],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1: 0)
resposta = client.get("/clientes?status=todos")
assert resposta.status_code == 200
assert capturado["filtro_ativo"] is None
def test_listar_clientes_status_invalido_cai_no_padrao(monkeypatch):
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: (capturado.__setitem__("filtro_ativo", filtro_ativo), [])[1],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1: 0)
resposta = client.get("/clientes?status=lixo_qualquer")
assert resposta.status_code == 200
assert capturado["filtro_ativo"] == 1
def test_listar_clientes(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: [
{"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Um", "FANTASIA": "Um", "CNPJ": "11222333000181"}
],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:1)
resposta = client.get("/clientes")
assert resposta.status_code == 200
assert "Cliente Um" in resposta.text
def test_listar_clientes_inclui_csrf_token_no_contexto(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: [],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:0)
resposta = client.get("/clientes")
assert resposta.status_code == 200
# Este app de teste não monta o middleware de CSRF (ver comentário no
# topo do arquivo), então token_do_contexto() retorna "" — o que
# importa aqui é que o template usa a variável sem quebrar e ela
# aparece no script embutido da página.
assert 'const CSRF_TOKEN = "";' in resposta.text
def test_listar_clientes_tem_botao_de_gerar_chave_por_linha(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: [
{"ID": 1, "CODIGO": "000042", "RAZAO": "Cliente Um", "FANTASIA": "Um", "CNPJ": "11222333000181"}
],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:1)
resposta = client.get("/clientes")
assert resposta.status_code == 200
assert 'data-codigo="000042"' in resposta.text
assert "gerarChaveReativacao(this)" in resposta.text
assert "/gerador-chave/gerar" in resposta.text
def test_listar_clientes_pagina_com_controles_de_paginacao(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: [
{"ID": i, "CODIGO": f"{i:06d}", "RAZAO": f"Cliente {i}", "FANTASIA": f"C{i}", "CNPJ": "11222333000181"}
for i in range(1, 6)
],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:250)
resposta = client.get("/clientes?pagina=2&por_pagina=100")
assert resposta.status_code == 200
assert "Exibindo 101 a 200 de 250 clientes" in resposta.text
assert "pagina=1&ordenar_por=razao&direcao=asc&por_pagina=100" in resposta.text
assert "pagina=3&ordenar_por=razao&direcao=asc&por_pagina=100" in resposta.text
def test_listar_clientes_footer_com_selecao_de_por_pagina(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: [
{"ID": i, "CODIGO": f"{i:06d}", "RAZAO": f"Cliente {i}", "FANTASIA": f"C{i}", "CNPJ": "1"}
for i in range(1, 16)
],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:98)
resposta = client.get("/clientes?pagina=1")
assert resposta.status_code == 200
assert 'value="15" selected' in resposta.text
assert "Exibindo 1 a 15 de 98 clientes" in resposta.text
# 98 / 15 = 7 paginas -> <=7, sem reticencias, todas visiveis
assert "pagina=7&ordenar_por=razao&direcao=asc&por_pagina=15" in resposta.text
def test_listar_clientes_paginacao_com_reticencias_quando_muitas_paginas(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", lambda **kwargs: [])
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:500)
# 500 / 5 = 100 paginas, pedindo a pagina 10 (no meio) -> tem reticencias
resposta = client.get("/clientes?pagina=10&por_pagina=5")
assert resposta.status_code == 200
assert "&hellip;" in resposta.text
def test_listar_clientes_ordenar_por_invalido_cai_no_padrao(monkeypatch):
capturado = {}
def _listar_clientes(busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1):
capturado["ordenar_por"] = ordenar_por
return []
monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", _listar_clientes)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:0)
client.get("/clientes?ordenar_por=coisa_invalida_qualquer")
assert capturado["ordenar_por"] == "razao"
client.get("/clientes?ordenar_por=codigo")
assert capturado["ordenar_por"] == "codigo"
def test_cabecalhos_da_lista_alternam_direcao(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes, "listar_clientes", lambda **kwargs: []
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:0)
# Sem ordenar_por/direcao na URL: padrão é razao/asc. Clicar de novo em
# "Razão" deve levar pra desc; clicar em "Código" (coluna diferente da
# atual) deve levar pra asc (não continua o desc de outra coluna).
resposta = client.get("/clientes")
assert 'ordenar_por=razao&direcao=desc' in resposta.text
assert 'ordenar_por=codigo&direcao=asc' in resposta.text
# Já ordenado por codigo/desc: clicar em "Código" de novo deve voltar pra asc.
resposta = client.get("/clientes?ordenar_por=codigo&direcao=desc")
assert 'ordenar_por=codigo&direcao=asc' in resposta.text
# E as outras colunas oferecem asc (primeira vez que seriam clicadas).
assert 'ordenar_por=razao&direcao=asc' in resposta.text
def test_busca_com_e_comercial_nao_quebra_links_de_paginacao_e_ordenacao(monkeypatch):
# "&" cru dentro de busca, sem urlencode, corta o resto da query string
# do link (ex.: "busca=A&B&pagina=2" vira busca="A", parâmetro solto
# "B", perdendo "pagina=2") — reprodução do bug corrigido em
# clientes_lista.html.
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: [
{"ID": i, "CODIGO": f"{i:06d}", "RAZAO": f"Cliente {i}", "FANTASIA": f"C{i}", "CNPJ": "1"}
for i in range(1, 6)
],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:250)
resposta = client.get("/clientes?pagina=2&busca=A%26B")
assert resposta.status_code == 200
assert "busca=A%26B" in resposta.text
assert "busca=A&B" not in resposta.text
def test_listar_clientes_direcao_invalida_cai_no_padrao(monkeypatch):
capturado = {}
def _listar_clientes(busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1):
capturado["direcao"] = direcao
return []
monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", _listar_clientes)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1:0)
client.get("/clientes?direcao=coisa_invalida")
assert capturado["direcao"] == "asc"
client.get("/clientes?direcao=desc")
assert capturado["direcao"] == "desc"
def test_consulta_cnpj_encontrado(monkeypatch):
monkeypatch.setattr(
router_clientes.cnpj_lookup,
"consultar_cnpj",
lambda cnpj: {"RAZAO": "EMPRESA TESTE LTDA", "FANTASIA": "EMPRESA TESTE"},
)
resposta = client.get("/clientes/consulta-cnpj/11222333000181")
assert resposta.status_code == 200
corpo = resposta.json()
assert corpo["encontrado"] is True
assert corpo["dados"]["RAZAO"] == "EMPRESA TESTE LTDA"
def test_consulta_cnpj_nao_encontrado(monkeypatch):
monkeypatch.setattr(router_clientes.cnpj_lookup, "consultar_cnpj", lambda cnpj: None)
resposta = client.get("/clientes/consulta-cnpj/11222333000181")
assert resposta.status_code == 404
assert resposta.json()["encontrado"] is False
def test_consulta_cnpj_rejeita_cpf(monkeypatch):
chamou = {}
monkeypatch.setattr(
router_clientes.cnpj_lookup, "consultar_cnpj", lambda cnpj: chamou.setdefault("ok", True)
)
# CPF válido (mesma fixture usada em test_normalize.py) — nunca deve
# chamar a consulta de CNPJ, é dado pessoal.
resposta = client.get("/clientes/consulta-cnpj/11144477735")
assert resposta.status_code == 404
assert "ok" not in chamou
def test_consulta_cnpj_rejeita_formato_invalido(monkeypatch):
chamou = {}
monkeypatch.setattr(
router_clientes.cnpj_lookup, "consultar_cnpj", lambda cnpj: chamou.setdefault("ok", True)
)
resposta = client.get("/clientes/consulta-cnpj/123")
assert resposta.status_code == 404
assert "ok" not in chamou
def test_form_novo_preenche_codigo_sugerido(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "proximo_codigo_sugerido", lambda: "000243")
resposta = client.get("/clientes/novo")
assert resposta.status_code == 200
assert 'value="000243"' in resposta.text
def test_form_novo_ativo_pre_selecionado(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "proximo_codigo_sugerido", lambda: "000243")
resposta = client.get("/clientes/novo")
assert resposta.status_code == 200
assert '<option value="1" selected>Ativo</option>' in resposta.text
def test_editar_cliente_ativo_zero_fica_selecionado(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"obter_cliente",
lambda cid: {**{c: "" for c in router_clientes.db_clientes.CAMPOS_CLIENTE}, "ID": cid, "ATIVO": 0},
)
resposta = client.get("/clientes/1/editar")
assert resposta.status_code == 200
assert '<option value="0" selected>Inativo</option>' in resposta.text
def test_listar_clientes_tem_select_de_status(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", lambda **kwargs: [])
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda **kwargs: 0)
resposta = client.get("/clientes")
assert resposta.status_code == 200
assert '<option value="ativos" selected>Ativos</option>' in resposta.text
assert '<option value="inativos" >Desativados ou inativos</option>' in resposta.text
assert '<option value="todos" >Todos</option>' in resposta.text
def test_listar_clientes_status_preservado_nos_links_de_paginacao(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda **kwargs: [
{"ID": i, "CODIGO": f"{i:06d}", "RAZAO": f"Cliente {i}", "FANTASIA": f"C{i}", "CNPJ": "1"}
for i in range(1, 6)
],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda **kwargs: 250)
resposta = client.get("/clientes?pagina=2&por_pagina=100&status=todos")
assert resposta.status_code == 200
assert "status=todos" in resposta.text
assert '<option value="todos" selected>Todos</option>' in resposta.text
def test_detalhe_cliente_mostra_formulario_de_edicao_de_contato(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"obter_cliente",
lambda cid: {"ID": cid, "CODIGO": "000001", "RAZAO": "Cliente Um", "FANTASIA": "Um", "CNPJ": "11222333000181"},
)
monkeypatch.setattr(
router_clientes.db_contatos,
"listar_contatos_por_cliente",
lambda cid: [{"ID": 7, "NOME": "Fulano", "TELEFONE": "75999090001", "EMAIL": None}],
)
monkeypatch.setattr(router_clientes.db_chaves, "status_chaves", lambda codigo: None)
resposta = client.get("/clientes/1")
assert resposta.status_code == 200
assert '/clientes/1/contatos/7/editar' in resposta.text
assert 'value="Fulano"' in resposta.text
assert 'value="75999090001"' in resposta.text
def test_criar_cliente_codigo_duplicado_bloqueia_sem_bypass(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: True)
criado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42
)
dados_form = _form_valido()
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 200
assert "já está em uso" in resposta.text
assert "ok" not in criado
# Não existe mais bypass: mesmo insistindo, sem trocar o código, continua bloqueado.
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 200
assert "já está em uso" in resposta.text
assert "ok" not in criado
def test_criar_cliente_campo_obrigatorio_faltando_bloqueia(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: False)
criado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42
)
dados_form = _form_valido()
dados_form["RAZAO"] = ""
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 200
assert "RAZAO" in resposta.text
assert "ok" not in criado
def test_criar_cliente_campos_administrativos_em_branco_usa_defaults(monkeypatch):
"""
IMPLANTACAO/MENSALIDADE/JUROS_MORA/JUROS_MULTA/DATA_CONTRATO/
VENCIMENTO_CONTRATO não são mais obrigatórios — em branco, o cliente é
criado mesmo assim, com defaults (0,00 / hoje).
"""
import datetime
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: False)
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: capturado.setdefault("dados", dados) and 42
)
dados_form = _form_valido()
for campo in ("IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA", "DATA_CONTRATO", "VENCIMENTO_CONTRATO"):
dados_form[campo] = ""
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 303
dados = capturado["dados"]
assert dados["IMPLANTACAO"] == "0.00"
assert dados["MENSALIDADE"] == "0.00"
assert dados["JUROS_MORA"] == "0.00"
assert dados["JUROS_MULTA"] == "0.00"
hoje = datetime.date.today().isoformat()
assert dados["DATA_CONTRATO"] == hoje
assert dados["VENCIMENTO_CONTRATO"] == hoje
def test_criar_cliente_normaliza_cnpj_e_telefones(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: False)
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: capturado.setdefault("dados", dados) and 42
)
dados_form = _form_valido()
dados_form.update(
{
"CNPJ": "11.222.333/0001-81",
"TELEFONE": "(75) 3333-4444",
"CELULAR": "(75) 99909-1780",
}
)
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 303
assert capturado["dados"]["CNPJ"] == "11222333000181"
assert capturado["dados"]["TELEFONE"] == "557533334444"
assert capturado["dados"]["CELULAR"] == "5575999091780"
def test_editar_cliente_normaliza_cnpj_e_telefones(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "obter_cliente", lambda cliente_id: {"ID": cliente_id})
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: False)
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"atualizar_cliente",
lambda cliente_id, dados: capturado.setdefault("dados", dados),
)
dados_form = _form_valido()
dados_form.update(
{
"CNPJ": "111.444.777-35",
"TELEFONE": "(75) 3333-4444",
"CELULAR": "(75) 99909-1780",
}
)
resposta = client.post("/clientes/1/editar", data=dados_form)
assert resposta.status_code == 303
assert capturado["dados"]["CNPJ"] == "11144477735"
assert capturado["dados"]["TELEFONE"] == "557533334444"
assert capturado["dados"]["CELULAR"] == "5575999091780"
def test_editar_cliente_codigo_duplicado_bloqueia_sem_bypass(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "obter_cliente", lambda cliente_id: {"ID": cliente_id})
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: True)
atualizado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"atualizar_cliente",
lambda cliente_id, dados: atualizado.setdefault("ok", True),
)
dados_form = _form_valido()
resposta = client.post("/clientes/1/editar", data=dados_form)
assert resposta.status_code == 200
assert "já está em uso" in resposta.text
assert "ok" not in atualizado
def test_criar_cliente_cnpj_duplicado_bloqueia(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: True)
criado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42
)
resposta = client.post("/clientes/novo", data=_form_valido())
assert resposta.status_code == 200
assert "já está cadastrado" in resposta.text
assert "ok" not in criado
def test_editar_cliente_cnpj_duplicado_bloqueia(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "obter_cliente", lambda cliente_id: {"ID": cliente_id})
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: True)
atualizado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"atualizar_cliente",
lambda cliente_id, dados: atualizado.setdefault("ok", True),
)
resposta = client.post("/clientes/1/editar", data=_form_valido())
assert resposta.status_code == 200
assert "já está cadastrado" in resposta.text
assert "ok" not in atualizado
def test_editar_cliente_inexistente_nao_finge_sucesso(monkeypatch):
# Sem a checagem de existência, isso caía direto no UPDATE (0 linhas
# afetadas, sem erro do MySQL) e ainda registrava "sucesso" no log.
monkeypatch.setattr(router_clientes.db_clientes, "obter_cliente", lambda cliente_id: None)
atualizado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"atualizar_cliente",
lambda cliente_id, dados: atualizado.setdefault("ok", True),
)
resposta = client.post("/clientes/999/editar", data=_form_valido())
assert resposta.status_code == 303
assert resposta.headers["location"].startswith("/clientes?")
assert "ok" not in atualizado
def test_excluir_cliente_inexistente_nao_finge_sucesso(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "excluir_cliente", lambda cliente_id: False)
resposta = client.post("/clientes/999/excluir", data={"csrf_token": CSRF_TESTE})
assert resposta.status_code == 303
assert resposta.headers["location"].startswith("/clientes?")
assert "danger" in resposta.headers["location"]
def test_excluir_cliente_com_dependencia_mostra_erro(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"obter_cliente",
lambda cliente_id: {"ID": cliente_id, "CODIGO": "000001", "RAZAO": "R", "FANTASIA": "F"},
)
monkeypatch.setattr(router_clientes.db_contatos, "listar_contatos_por_cliente", lambda cliente_id: [])
monkeypatch.setattr(router_clientes.db_chaves, "status_chaves", lambda codigo: None)
def _excluir(cliente_id):
raise router_clientes.db_clientes.ClienteComDependenciasError("tem contato")
monkeypatch.setattr(router_clientes.db_clientes, "excluir_cliente", _excluir)
resposta = client.post("/clientes/1/excluir", data={"csrf_token": CSRF_TESTE}, follow_redirects=True)
assert resposta.status_code == 200
assert "tem contato" in resposta.text
def test_alternar_ativo_inverte_e_redireciona_para_url_informada(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "alternar_ativo", lambda cliente_id: True)
resposta = client.post(
"/clientes/1/alternar-ativo",
data={"csrf_token": CSRF_TESTE, "redirecionar_para": "/clientes?status=todos&pagina=3"},
)
assert resposta.status_code == 303
assert resposta.headers["location"].startswith("/clientes?status=todos&pagina=3")
def test_alternar_ativo_redirecionar_para_invalido_cai_no_clientes_generico(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "alternar_ativo", lambda cliente_id: True)
resposta = client.post(
"/clientes/1/alternar-ativo",
data={"csrf_token": CSRF_TESTE, "redirecionar_para": "https://malicioso.example/"},
)
assert resposta.status_code == 303
assert resposta.headers["location"].startswith("/clientes?")
assert "malicioso" not in resposta.headers["location"]
def test_alternar_ativo_sem_redirecionar_para_cai_no_clientes_generico(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "alternar_ativo", lambda cliente_id: True)
resposta = client.post("/clientes/1/alternar-ativo", data={"csrf_token": CSRF_TESTE})
assert resposta.status_code == 303
assert resposta.headers["location"].startswith("/clientes?")
def test_alternar_ativo_id_inexistente_nao_finge_sucesso(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "alternar_ativo", lambda cliente_id: False)
resposta = client.post("/clientes/999/alternar-ativo", data={"csrf_token": CSRF_TESTE})
assert resposta.status_code == 303
assert "n%C3%A3o+encontrado" in resposta.headers["location"] or "nao encontrado" in resposta.headers["location"]
def test_listar_clientes_mostra_bolinha_de_status(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda **kwargs: [
{"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Ativo", "FANTASIA": "A", "CNPJ": "1", "ATIVO": 1},
{"ID": 2, "CODIGO": "000002", "RAZAO": "Cliente Inativo", "FANTASIA": "I", "CNPJ": "2", "ATIVO": 0},
],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda **kwargs: 2)
resposta = client.get("/clientes?status=todos")
assert resposta.status_code == 200
assert "status-dot-ativo" in resposta.text
assert "status-dot-inativo" in resposta.text
assert '/clientes/1/alternar-ativo' in resposta.text
assert '/clientes/2/alternar-ativo' in resposta.text
def test_listar_clientes_redirecionar_para_reflete_url_atual(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda **kwargs: [{"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Um", "FANTASIA": "A", "CNPJ": "1", "ATIVO": 1}],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda **kwargs: 1)
resposta = client.get("/clientes?status=inativos&pagina=2&por_pagina=50")
assert resposta.status_code == 200
assert 'value="/clientes?status=inativos&amp;pagina=2&amp;por_pagina=50"' in resposta.text
def _limpar_codigo(codigo: str) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM CLIENTE WHERE CODIGO = %s", (codigo,))
finally:
conn.close()
def test_criar_cliente_com_data_e_decimais_vazios_cria_com_defaults():
"""
Integração com o banco real de dev: DATA_CONTRATO/decimais deixaram de
ser obrigatórios — em branco, o router aplica defaults (0,00 / hoje)
antes de gravar, então o cliente é criado normalmente (303), não gera
erro nem 500.
"""
codigo = "999906"
dados_form = _form_valido(codigo)
for campo in ("IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA", "DATA_CONTRATO", "VENCIMENTO_CONTRATO"):
dados_form[campo] = ""
try:
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 303
finally:
_limpar_codigo(codigo)
def test_criar_cliente_com_dv_invalido_exige_confirmacao(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: False)
criado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42
)
dados_form = _form_valido()
dados_form["CNPJ"] = "11144477734" # CPF com DV inválido (1 dígito diferente do válido "...735")
# Sem confirmar: pede confirmação, não cria.
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 200
assert "não confere" in resposta.text
assert "ok" not in criado
# Confirmando: cria.
dados_form["confirmar_dv_invalido"] = "1"
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 303
assert criado.get("ok") is True
def test_criar_cliente_com_cnpj_dv_invalido_bloqueia_sempre(monkeypatch):
"""
Diferente de CPF: CNPJ com DV inválido bloqueia sempre, sem opção de
"cadastrar mesmo assim" — nem com confirmar_dv_invalido=1.
"""
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: False)
criado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42
)
dados_form = _form_valido()
dados_form["CNPJ"] = "11222333000180" # último dígito trocado: DV inválido
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 200
assert "não confere" in resposta.text
assert "ok" not in criado
# Mesmo confirmando, continua bloqueando (diferente do fluxo de CPF).
dados_form["confirmar_dv_invalido"] = "1"
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 200
assert "ok" not in criado
def test_editar_cliente_com_cnpj_dv_invalido_bloqueia(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "obter_cliente", lambda cliente_id: {"ID": cliente_id})
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: False)
atualizado = {}
monkeypatch.setattr(
router_clientes.db_clientes,
"atualizar_cliente",
lambda cliente_id, dados: atualizado.setdefault("ok", True),
)
dados_form = _form_valido()
dados_form["CNPJ"] = "11222333000180"
resposta = client.post("/clientes/1/editar", data=dados_form)
assert resposta.status_code == 200
assert "não confere" in resposta.text
assert "ok" not in atualizado
def test_criar_cliente_com_cpf_dv_invalido_continua_permitindo_confirmar(monkeypatch):
"""Regressão: CPF com DV inválido não muda de comportamento."""
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: False)
criado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42
)
dados_form = _form_valido()
dados_form["CNPJ"] = "11144477734" # CPF com DV inválido
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 200
assert "não confere" in resposta.text
assert "ok" not in criado
dados_form["confirmar_dv_invalido"] = "1"
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 303
assert criado.get("ok") is True
def test_criar_cliente_grava_telefone_com_ddi_55(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: False)
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: capturado.setdefault("dados", dados) and 42
)
dados_form = _form_valido()
dados_form.update({"TELEFONE": "(75) 3333-4444", "CELULAR": "(75) 99909-1780"})
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 303
assert capturado["dados"]["TELEFONE"] == "557533334444"
assert capturado["dados"]["CELULAR"] == "5575999091780"
def test_criar_cliente_telefone_ja_com_55_nao_duplica(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: False)
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: capturado.setdefault("dados", dados) and 42
)
dados_form = _form_valido()
dados_form["CELULAR"] = "5575999091780"
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 303
assert capturado["dados"]["CELULAR"] == "5575999091780"
def test_criar_cliente_grava_uf_maiuscula_e_cep_sem_traco(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False)
monkeypatch.setattr(router_clientes.db_clientes, "cnpj_em_uso", lambda documento, ignorar_id=None: False)
capturado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: capturado.setdefault("dados", dados) and 42
)
dados_form = _form_valido()
dados_form.update({"UF": "ba", "CEP": "44430-174"})
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 303
assert capturado["dados"]["UF"] == "BA"
assert capturado["dados"]["CEP"] == "44430174"
def test_listar_clientes_tabela_tem_classe_de_escopo(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc", filtro_ativo=1: [])
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao", filtro_ativo=1: 0)
resposta = client.get("/clientes")
assert resposta.status_code == 200
assert '<table class="table-clientes">' in resposta.text
+142
View File
@@ -0,0 +1,142 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.auth import exigir_login
from app.routers import contatos as router_contatos
from app.routers import clientes as router_clientes
app_teste = FastAPI()
app_teste.include_router(router_clientes.router)
app_teste.include_router(router_contatos.router)
app_teste.dependency_overrides[exigir_login] = lambda: "teste"
client = TestClient(app_teste, follow_redirects=False)
# Este app de teste não tem o middleware de CSRF (só o main.py real o monta).
# Simula um navegador que já tem o cookie definido: os testes de POST
# precisam mandar o mesmo valor no campo oculto "csrf_token".
CSRF_TESTE = "teste-csrf-token"
client.cookies.set("csrf_token", CSRF_TESTE)
@pytest.fixture(autouse=True)
def _sem_log_real(monkeypatch):
"""Os routers registram cada ação em LOG_PAINEL — sem no-op, bateria no banco real."""
monkeypatch.setattr(router_contatos.db_log, "registrar", lambda *args, **kwargs: None)
monkeypatch.setattr(router_clientes.db_log, "registrar", lambda *args, **kwargs: None)
def test_adicionar_contato_com_sucesso(monkeypatch):
monkeypatch.setattr(router_contatos.db_clientes, "obter_cliente", lambda cid: {"ID": cid, "CODIGO": "000001"})
monkeypatch.setattr(router_contatos.db_contatos, "contato_duplicado", lambda cid, tel, ignorar_id=None: False)
chamadas = {}
monkeypatch.setattr(
router_contatos.db_contatos,
"inserir_contato",
lambda cid, nome, tel, email=None: chamadas.setdefault("dados", (cid, nome, tel, email)),
)
resposta = client.post(
"/clientes/1/contatos", data={"nome": "Fulano", "telefone": "(75) 99909-0001", "csrf_token": CSRF_TESTE}
)
assert resposta.status_code == 303
# DDI 55 sempre embutido — é esse valor que o workflow n8n de envio de
# chave por WhatsApp lê direto de CLIENTE_CONTATO.TELEFONE, sem
# normalizar nada (ver comentário no topo de app/routers/contatos.py).
assert chamadas["dados"] == (1, "Fulano", "5575999090001", None)
def test_adicionar_contato_duplicado_avisa(monkeypatch):
monkeypatch.setattr(router_contatos.db_clientes, "obter_cliente", lambda cid: {"ID": cid, "CODIGO": "000001"})
monkeypatch.setattr(router_contatos.db_contatos, "contato_duplicado", lambda cid, tel, ignorar_id=None: True)
# Also mock the clientes router's db functions for the redirect
monkeypatch.setattr(router_clientes.db_clientes, "obter_cliente", lambda cid: {"ID": cid, "CODIGO": "000001", "RAZAO": "Cliente", "FANTASIA": "Cliente"})
monkeypatch.setattr(router_clientes.db_contatos, "listar_contatos_por_cliente", lambda cid: [])
monkeypatch.setattr(router_clientes.db_chaves, "status_chaves", lambda codigo: None)
resposta = client.post(
"/clientes/1/contatos",
data={"nome": "Fulano", "telefone": "75999090001", "csrf_token": CSRF_TESTE},
follow_redirects=True,
)
assert "já está cadastrado" in resposta.text
def test_editar_contato_com_sucesso(monkeypatch):
monkeypatch.setattr(router_contatos.db_contatos, "contato_duplicado", lambda cid, tel, ignorar_id=None: False)
chamadas = {}
monkeypatch.setattr(
router_contatos.db_contatos,
"atualizar_contato",
lambda contato_id, cid, nome, tel, email=None: chamadas.setdefault(
"dados", (contato_id, cid, nome, tel, email)
),
)
resposta = client.post(
"/clientes/1/contatos/5/editar",
data={
"nome": "Fulano Editado", "telefone": "(75) 99909-0002", "email": "fulano@example.com",
"csrf_token": CSRF_TESTE,
},
)
assert resposta.status_code == 303
assert chamadas["dados"] == (5, 1, "Fulano Editado", "5575999090002", "fulano@example.com")
def test_editar_contato_inexistente_avisa_e_nao_finge_sucesso(monkeypatch):
# atualizar_contato retorna False quando o WHERE (ID + CLIENTE_ID) não
# bate com nenhuma linha — ID errado, ou de outro cliente.
monkeypatch.setattr(router_contatos.db_contatos, "contato_duplicado", lambda cid, tel, ignorar_id=None: False)
monkeypatch.setattr(router_contatos.db_contatos, "atualizar_contato", lambda *a, **k: False)
registros = []
monkeypatch.setattr(
router_contatos.db_log, "registrar", lambda *args, **kwargs: registros.append(args)
)
resposta = client.post(
"/clientes/1/contatos/999/editar",
data={"nome": "Fulano", "telefone": "75999090002", "csrf_token": CSRF_TESTE},
follow_redirects=False,
)
assert resposta.status_code == 303
assert "mensagem_tipo=danger" in resposta.headers["location"]
assert registros[-1][4] is False # sucesso=False no log (args: usuario, acao, entidade, entidade_id, sucesso, detalhe)
def test_editar_contato_duplicado_avisa(monkeypatch):
monkeypatch.setattr(router_contatos.db_contatos, "contato_duplicado", lambda cid, tel, ignorar_id=None: True)
monkeypatch.setattr(router_clientes.db_clientes, "obter_cliente", lambda cid: {"ID": cid, "CODIGO": "000001", "RAZAO": "Cliente", "FANTASIA": "Cliente"})
monkeypatch.setattr(router_clientes.db_contatos, "listar_contatos_por_cliente", lambda cid: [])
monkeypatch.setattr(router_clientes.db_chaves, "status_chaves", lambda codigo: None)
resposta = client.post(
"/clientes/1/contatos/5/editar",
data={"nome": "Fulano", "telefone": "75999090001", "csrf_token": CSRF_TESTE},
follow_redirects=True,
)
assert "já está cadastrado" in resposta.text
def test_excluir_contato(monkeypatch):
monkeypatch.setattr(router_contatos.db_clientes, "obter_cliente", lambda cid: {"ID": cid, "CODIGO": "000001"})
chamadas = {}
monkeypatch.setattr(
router_contatos.db_contatos, "excluir_contato", lambda contato_id, cid: chamadas.setdefault("dados", (contato_id, cid))
)
resposta = client.post("/clientes/1/contatos/5/excluir", data={"csrf_token": CSRF_TESTE})
assert resposta.status_code == 303
assert chamadas["dados"] == (5, 1)
def test_excluir_contato_inexistente_avisa_e_nao_finge_sucesso(monkeypatch):
monkeypatch.setattr(router_contatos.db_contatos, "excluir_contato", lambda *a, **k: False)
registros = []
monkeypatch.setattr(
router_contatos.db_log, "registrar", lambda *args, **kwargs: registros.append(args)
)
resposta = client.post("/clientes/1/contatos/999/excluir", data={"csrf_token": CSRF_TESTE}, follow_redirects=False)
assert resposta.status_code == 303
assert "mensagem_tipo=danger" in resposta.headers["location"]
assert registros[-1][4] is False
@@ -0,0 +1,328 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.auth import UsuarioLogado, exigir_login
from app.routers import funcionarios as router_funcionarios
app_teste = FastAPI()
app_teste.include_router(router_funcionarios.router)
app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login="admin", nome="Admin", perfil="ADMIN")
client = TestClient(app_teste, follow_redirects=False)
CSRF_TESTE = "teste-csrf-token"
client.cookies.set("csrf_token", CSRF_TESTE)
@pytest.fixture(autouse=True)
def _sem_log_real(monkeypatch):
monkeypatch.setattr(router_funcionarios.db_log, "registrar", lambda *args, **kwargs: None)
@pytest.fixture(autouse=True)
def _admin_como_padrao(monkeypatch):
"""Todo teste roda como admin por padrão (override acima). Testes que
querem simular usuário padrão sobrescrevem de novo dentro do próprio
teste e restauram no fim via yield."""
yield
app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login="admin", nome="Admin", perfil="ADMIN")
def test_usuario_padrao_toma_403_em_qualquer_rota():
app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login="fulano", nome="Fulano", perfil="PADRAO")
resposta = client.get("/funcionarios")
assert resposta.status_code == 403
def test_listar_funcionarios(monkeypatch):
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"listar_funcionarios",
lambda: [{"ID": 1, "NOME": "Fulano", "LOGIN": "fulano", "PERFIL": "PADRAO", "ATIVO": 1, "CRIADO_EM": "2026-01-01"}],
)
resposta = client.get("/funcionarios")
assert resposta.status_code == 200
assert "Fulano" in resposta.text
def test_criar_funcionario_com_sucesso(monkeypatch):
monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False)
criado = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"criar_funcionario",
lambda nome, login, senha, perfil: criado.setdefault("dados", (nome, login, senha, perfil)) and 1,
)
resposta = client.post(
"/funcionarios/novo",
data={
"nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "senhaForte123",
"perfil": "PADRAO", "csrf_token": CSRF_TESTE,
},
)
assert resposta.status_code == 303
assert criado["dados"] == ("Fulano", "fulano", "senhaForte123", "PADRAO")
def test_criar_funcionario_senhas_diferentes_bloqueia(monkeypatch):
monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False)
chamou = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"criar_funcionario",
lambda nome, login, senha, perfil: chamou.setdefault("ok", True),
)
resposta = client.post(
"/funcionarios/novo",
data={
"nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "outraCoisa",
"perfil": "PADRAO", "csrf_token": CSRF_TESTE,
},
)
assert resposta.status_code == 200
assert "não coincidem" in resposta.text
assert "ok" not in chamou
def test_criar_funcionario_login_duplicado_bloqueia(monkeypatch):
monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: True)
chamou = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"criar_funcionario",
lambda nome, login, senha, perfil: chamou.setdefault("ok", True),
)
resposta = client.post(
"/funcionarios/novo",
data={
"nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "senhaForte123",
"perfil": "PADRAO", "csrf_token": CSRF_TESTE,
},
)
assert resposta.status_code == 200
assert "já está em uso" in resposta.text
assert "ok" not in chamou
def test_criar_funcionario_perfil_invalido_bloqueia(monkeypatch):
monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False)
chamou = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"criar_funcionario",
lambda nome, login, senha, perfil: chamou.setdefault("ok", True),
)
resposta = client.post(
"/funcionarios/novo",
data={
"nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "senhaForte123",
"perfil": "SUPERUSUARIO", "csrf_token": CSRF_TESTE,
},
)
assert resposta.status_code == 200
assert "ok" not in chamou
def test_editar_funcionario_com_sucesso(monkeypatch):
monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False)
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"obter_funcionario",
lambda fid: {"ID": fid, "NOME": "Fulano", "LOGIN": "fulano", "PERFIL": "PADRAO", "ATIVO": 1},
)
capturado = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"atualizar_funcionario",
lambda fid, nome, login, perfil: capturado.setdefault("dados", (fid, nome, login, perfil)),
)
resposta = client.post(
"/funcionarios/1/editar",
data={"nome": "Fulano Editado", "login": "fulano.editado", "perfil": "PADRAO", "csrf_token": CSRF_TESTE},
)
assert resposta.status_code == 303
assert capturado["dados"] == (1, "Fulano Editado", "fulano.editado", "PADRAO")
def test_editar_ultimo_admin_para_padrao_bloqueia(monkeypatch):
monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False)
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"obter_funcionario",
lambda fid: {"ID": fid, "NOME": "Admin", "LOGIN": "admin.db", "PERFIL": "ADMIN", "ATIVO": 1},
)
monkeypatch.setattr(router_funcionarios.db_funcionarios, "contar_admins_ativos", lambda: 1)
chamou = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"atualizar_funcionario",
lambda fid, nome, login, perfil: chamou.setdefault("ok", True),
)
resposta = client.post(
"/funcionarios/1/editar",
data={"nome": "Admin", "login": "admin.db", "perfil": "PADRAO", "csrf_token": CSRF_TESTE},
)
assert resposta.status_code == 200
assert "único administrador" in resposta.text
assert "ok" not in chamou
def test_editar_admin_para_padrao_com_outros_admins_permite(monkeypatch):
monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False)
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"obter_funcionario",
lambda fid: {"ID": fid, "NOME": "Admin", "LOGIN": "admin.db", "PERFIL": "ADMIN", "ATIVO": 1},
)
monkeypatch.setattr(router_funcionarios.db_funcionarios, "contar_admins_ativos", lambda: 2)
capturado = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"atualizar_funcionario",
lambda fid, nome, login, perfil: capturado.setdefault("dados", (fid, nome, login, perfil)),
)
resposta = client.post(
"/funcionarios/1/editar",
data={"nome": "Admin", "login": "admin.db", "perfil": "PADRAO", "csrf_token": CSRF_TESTE},
)
assert resposta.status_code == 303
assert capturado["dados"] == (1, "Admin", "admin.db", "PADRAO")
def test_editar_funcionario_inexistente_nao_finge_sucesso(monkeypatch):
monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False)
monkeypatch.setattr(router_funcionarios.db_funcionarios, "obter_funcionario", lambda fid: None)
chamou = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"atualizar_funcionario",
lambda fid, nome, login, perfil: chamou.setdefault("ok", True),
)
resposta = client.post(
"/funcionarios/999/editar",
data={"nome": "Fulano", "login": "fulano", "perfil": "PADRAO", "csrf_token": CSRF_TESTE},
)
assert resposta.status_code == 200
assert "não encontrado" in resposta.text
assert "ok" not in chamou
def test_redefinir_senha_com_sucesso(monkeypatch):
capturado = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"redefinir_senha",
lambda fid, senha: capturado.setdefault("dados", (fid, senha)),
)
resposta = client.post(
"/funcionarios/1/redefinir-senha",
data={"nova_senha": "novaSenha123", "confirmar_nova_senha": "novaSenha123", "csrf_token": CSRF_TESTE},
)
assert resposta.status_code == 303
assert capturado["dados"] == (1, "novaSenha123")
def test_redefinir_senha_nao_coincide_bloqueia(monkeypatch):
chamou = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios, "redefinir_senha", lambda fid, senha: chamou.setdefault("ok", True)
)
resposta = client.post(
"/funcionarios/1/redefinir-senha",
data={"nova_senha": "a", "confirmar_nova_senha": "b", "csrf_token": CSRF_TESTE},
follow_redirects=True,
)
assert "não coincidem" in resposta.text
assert "ok" not in chamou
def test_redefinir_senha_funcionario_inexistente_nao_finge_sucesso(monkeypatch):
monkeypatch.setattr(router_funcionarios.db_funcionarios, "redefinir_senha", lambda fid, senha: False)
resposta = client.post(
"/funcionarios/999/redefinir-senha",
data={"nova_senha": "novaSenha123", "confirmar_nova_senha": "novaSenha123", "csrf_token": CSRF_TESTE},
follow_redirects=True,
)
assert "não encontrado" in resposta.text
def test_alternar_ativo_funcionario_inexistente_nao_finge_sucesso(monkeypatch):
monkeypatch.setattr(router_funcionarios.db_funcionarios, "obter_funcionario", lambda fid: None)
chamou = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios, "alternar_ativo", lambda fid: chamou.setdefault("ok", True)
)
resposta = client.post(
"/funcionarios/999/alternar-ativo", data={"csrf_token": CSRF_TESTE}, follow_redirects=True
)
assert "não encontrado" in resposta.text
assert "ok" not in chamou
def test_alternar_ativo(monkeypatch):
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"obter_funcionario",
lambda fid: {"ID": fid, "NOME": "Fulano", "LOGIN": "fulano", "PERFIL": "PADRAO", "ATIVO": 1},
)
capturado = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios, "alternar_ativo", lambda fid: capturado.setdefault("id", fid)
)
resposta = client.post("/funcionarios/1/alternar-ativo", data={"csrf_token": CSRF_TESTE})
assert resposta.status_code == 303
assert capturado["id"] == 1
def test_desativar_ultimo_admin_ativo_bloqueia(monkeypatch):
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"obter_funcionario",
lambda fid: {"ID": fid, "NOME": "Admin", "LOGIN": "admin.db", "PERFIL": "ADMIN", "ATIVO": 1},
)
monkeypatch.setattr(router_funcionarios.db_funcionarios, "contar_admins_ativos", lambda: 1)
chamou = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios, "alternar_ativo", lambda fid: chamou.setdefault("ok", True)
)
resposta = client.post(
"/funcionarios/1/alternar-ativo", data={"csrf_token": CSRF_TESTE}, follow_redirects=True
)
assert "único administrador" in resposta.text
assert "ok" not in chamou
def test_reativar_funcionario_nao_passa_pela_checagem_de_ultimo_admin(monkeypatch):
# ATIVO indo de 0 para 1 (reativação) nunca reduz a contagem de admins
# ativos — não precisa nem chamar contar_admins_ativos().
monkeypatch.setattr(
router_funcionarios.db_funcionarios,
"obter_funcionario",
lambda fid: {"ID": fid, "NOME": "Admin", "LOGIN": "admin.db", "PERFIL": "ADMIN", "ATIVO": 0},
)
def _falha_se_chamado():
raise AssertionError("contar_admins_ativos não deveria ser chamado ao reativar")
monkeypatch.setattr(router_funcionarios.db_funcionarios, "contar_admins_ativos", _falha_se_chamado)
capturado = {}
monkeypatch.setattr(
router_funcionarios.db_funcionarios, "alternar_ativo", lambda fid: capturado.setdefault("id", fid)
)
resposta = client.post("/funcionarios/1/alternar-ativo", data={"csrf_token": CSRF_TESTE})
assert resposta.status_code == 303
assert capturado["id"] == 1
+134
View File
@@ -0,0 +1,134 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.auth import UsuarioLogado, exigir_login
from app.routers import log as router_log
app_teste = FastAPI()
app_teste.include_router(router_log.router)
app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login="admin", nome="Admin", perfil="ADMIN")
client = TestClient(app_teste, follow_redirects=False)
CSRF_TESTE = "teste-csrf-token"
client.cookies.set("csrf_token", CSRF_TESTE)
@pytest.fixture(autouse=True)
def _admin_como_padrao():
"""Todo teste roda como admin por padrão (override acima). Testes que
querem simular usuário padrão sobrescrevem de novo dentro do próprio
teste e restauram no fim via yield."""
yield
app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login="admin", nome="Admin", perfil="ADMIN")
@pytest.fixture(autouse=True)
def _sem_log_real(monkeypatch):
monkeypatch.setattr(router_log.db_log, "registrar", lambda *args, **kwargs: None)
def test_pagina_log_mostra_acoes_do_painel(monkeypatch):
capturado = {}
monkeypatch.setattr(
router_log.db_log,
"listar",
lambda limite=100: capturado.setdefault("limite", limite) and [
{
"ID": 1, "CRIADO_EM": "2026-08-13 10:00:00", "USUARIO": "admin", "ACAO": "criar",
"ENTIDADE": "cliente", "ENTIDADE_ID": "000001", "SUCESSO": 1, "DETALHE": None,
}
],
)
monkeypatch.setattr(router_log.n8n_client, "listar_envios", lambda limite=50: [])
monkeypatch.setattr(router_log.n8n_client, "configurado", lambda: False)
resposta = client.get("/log")
assert resposta.status_code == 200
assert "admin" in resposta.text
assert "criar" in resposta.text
assert "N8N_API_URL" in resposta.text # aviso de integração não configurada
assert capturado["limite"] == 20
def test_pagina_log_mostra_envios_quando_configurado(monkeypatch):
monkeypatch.setattr(router_log.db_log, "listar", lambda limite=20: [])
monkeypatch.setattr(
router_log.n8n_client,
"listar_envios",
lambda limite=50: [
{
"data_processamento": "2026-08-13T10:00:00", "nome_cliente": "Cliente Teste",
"codigo_cliente": "000001", "competencia": "2026-08", "status": "enviado",
"telefones_enviados": "5575999090000", "telefones_com_erro": "",
}
],
)
monkeypatch.setattr(router_log.n8n_client, "configurado", lambda: True)
resposta = client.get("/log")
assert resposta.status_code == 200
assert "Cliente Teste" in resposta.text
assert "enviado" in resposta.text
def test_pagina_log_admin_ve_botao_de_limpar(monkeypatch):
monkeypatch.setattr(router_log.db_log, "listar", lambda limite=20: [])
monkeypatch.setattr(router_log.n8n_client, "listar_envios", lambda limite=50: [])
monkeypatch.setattr(router_log.n8n_client, "configurado", lambda: False)
resposta = client.get("/log")
assert resposta.status_code == 200
assert "/log/limpar" in resposta.text
def test_pagina_log_usuario_padrao_nao_ve_botao_de_limpar(monkeypatch):
app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login="fulano", nome="Fulano", perfil="PADRAO")
monkeypatch.setattr(router_log.db_log, "listar", lambda limite=20: [])
monkeypatch.setattr(router_log.n8n_client, "listar_envios", lambda limite=50: [])
monkeypatch.setattr(router_log.n8n_client, "configurado", lambda: False)
resposta = client.get("/log")
assert resposta.status_code == 200
assert "/log/limpar" not in resposta.text
def test_limpar_log_admin_funciona(monkeypatch):
chamou = {}
monkeypatch.setattr(router_log.db_log, "limpar", lambda: chamou.setdefault("limpou", True))
resposta = client.post("/log/limpar", data={"csrf_token": CSRF_TESTE})
assert resposta.status_code == 303
assert resposta.headers["location"].startswith("/log?")
assert chamou.get("limpou") is True
def test_limpar_log_usuario_padrao_toma_403():
app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login="fulano", nome="Fulano", perfil="PADRAO")
resposta = client.post("/log/limpar", data={"csrf_token": CSRF_TESTE})
assert resposta.status_code == 403
def test_pagina_log_secoes_comecam_fechadas(monkeypatch):
monkeypatch.setattr(router_log.db_log, "listar", lambda limite=20: [])
monkeypatch.setattr(router_log.n8n_client, "listar_envios", lambda limite=50: [])
monkeypatch.setattr(router_log.n8n_client, "configurado", lambda: False)
resposta = client.get("/log")
assert resposta.status_code == 200
texto = resposta.text
assert texto.count('<details class="secao-log">') == 2
assert "<details class=\"secao-log\" open>" not in texto
assert "<details class='secao-log' open>" not in texto
def test_pagina_log_botao_toggle_tem_handler_de_clique_proprio(monkeypatch):
"""Regressão: um <button> dentro de <summary> não propaga o clique para
o toggle nativo do <details> — precisa de um listener de clique
explícito que abre/fecha a seção manualmente (ver log.html)."""
monkeypatch.setattr(router_log.db_log, "listar", lambda limite=20: [])
monkeypatch.setattr(router_log.n8n_client, "listar_envios", lambda limite=50: [])
monkeypatch.setattr(router_log.n8n_client, "configurado", lambda: False)
resposta = client.get("/log")
assert resposta.status_code == 200
assert 'botao.addEventListener("click"' in resposta.text
assert "secao.open = !secao.open" in resposta.text
@@ -0,0 +1,142 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.auth import UsuarioLogado, exigir_login
from app.routers import minha_conta as router_minha_conta
app_teste = FastAPI()
app_teste.include_router(router_minha_conta.router)
client = TestClient(app_teste, follow_redirects=False)
CSRF_TESTE = "teste-csrf-token"
client.cookies.set("csrf_token", CSRF_TESTE)
@pytest.fixture(autouse=True)
def _sem_log_real(monkeypatch):
monkeypatch.setattr(router_minha_conta.db_log, "registrar", lambda *args, **kwargs: None)
def _logar_como(login: str, nome: str, perfil: str = "PADRAO"):
app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login=login, nome=nome, perfil=perfil)
def test_get_minha_conta_mostra_nome_e_login(monkeypatch):
_logar_como("fulano", "Fulano da Silva")
monkeypatch.setattr(
router_minha_conta.db_funcionarios,
"obter_por_login",
lambda login: {"ID": 1, "NOME": "Fulano da Silva", "LOGIN": "fulano", "SENHA_HASH": "x", "PERFIL": "PADRAO", "ATIVO": 1},
)
resposta = client.get("/minha-conta")
assert resposta.status_code == 200
assert "Fulano da Silva" in resposta.text
assert "fulano" in resposta.text
def test_get_minha_conta_admin_env_mostra_aviso(monkeypatch):
import os
monkeypatch.setenv("ADMIN_USER", "admin")
_logar_como("admin", "admin")
monkeypatch.setattr(router_minha_conta.db_funcionarios, "obter_por_login", lambda login: None)
resposta = client.get("/minha-conta")
assert resposta.status_code == 200
assert ".env" in resposta.text
def test_post_trocar_senha_com_sucesso(monkeypatch):
_logar_como("fulano", "Fulano da Silva")
monkeypatch.setattr(
router_minha_conta.db_funcionarios,
"obter_por_login",
lambda login: {"ID": 1, "NOME": "Fulano da Silva", "LOGIN": "fulano", "SENHA_HASH": "hash-atual", "PERFIL": "PADRAO", "ATIVO": 1},
)
monkeypatch.setattr(router_minha_conta, "verificar_senha", lambda senha, hash_: True)
capturado = {}
monkeypatch.setattr(
router_minha_conta.db_funcionarios,
"redefinir_senha",
lambda fid, nova: capturado.setdefault("dados", (fid, nova)),
)
resposta = client.post(
"/minha-conta/senha",
data={
"senha_atual": "senhaAtual123", "nova_senha": "senhaNova456", "confirmar_nova_senha": "senhaNova456",
"csrf_token": CSRF_TESTE,
},
follow_redirects=True,
)
assert resposta.status_code == 200
assert capturado["dados"] == (1, "senhaNova456")
def test_post_trocar_senha_atual_errada_bloqueia(monkeypatch):
_logar_como("fulano", "Fulano da Silva")
monkeypatch.setattr(
router_minha_conta.db_funcionarios,
"obter_por_login",
lambda login: {"ID": 1, "NOME": "Fulano da Silva", "LOGIN": "fulano", "SENHA_HASH": "hash-atual", "PERFIL": "PADRAO", "ATIVO": 1},
)
monkeypatch.setattr(router_minha_conta, "verificar_senha", lambda senha, hash_: False)
chamou = {}
monkeypatch.setattr(
router_minha_conta.db_funcionarios, "redefinir_senha", lambda fid, nova: chamou.setdefault("ok", True)
)
resposta = client.post(
"/minha-conta/senha",
data={
"senha_atual": "senhaErrada", "nova_senha": "senhaNova456", "confirmar_nova_senha": "senhaNova456",
"csrf_token": CSRF_TESTE,
},
)
assert resposta.status_code == 200
assert "senha atual" in resposta.text.lower()
assert "ok" not in chamou
def test_post_trocar_senha_confirmacao_nao_bate_bloqueia(monkeypatch):
_logar_como("fulano", "Fulano da Silva")
monkeypatch.setattr(
router_minha_conta.db_funcionarios,
"obter_por_login",
lambda login: {"ID": 1, "NOME": "Fulano da Silva", "LOGIN": "fulano", "SENHA_HASH": "hash-atual", "PERFIL": "PADRAO", "ATIVO": 1},
)
monkeypatch.setattr(router_minha_conta, "verificar_senha", lambda senha, hash_: True)
chamou = {}
monkeypatch.setattr(
router_minha_conta.db_funcionarios, "redefinir_senha", lambda fid, nova: chamou.setdefault("ok", True)
)
resposta = client.post(
"/minha-conta/senha",
data={
"senha_atual": "senhaAtual123", "nova_senha": "senhaNova456", "confirmar_nova_senha": "outraCoisa",
"csrf_token": CSRF_TESTE,
},
)
assert resposta.status_code == 200
assert "não coincidem" in resposta.text
assert "ok" not in chamou
def test_post_trocar_senha_admin_env_bloqueia(monkeypatch):
_logar_como("admin", "admin")
monkeypatch.setattr(router_minha_conta.db_funcionarios, "obter_por_login", lambda login: None)
chamou = {}
monkeypatch.setattr(
router_minha_conta.db_funcionarios, "redefinir_senha", lambda fid, nova: chamou.setdefault("ok", True)
)
resposta = client.post(
"/minha-conta/senha",
data={
"senha_atual": "qualquer", "nova_senha": "senhaNova456", "confirmar_nova_senha": "senhaNova456",
"csrf_token": CSRF_TESTE,
},
)
assert resposta.status_code == 200
assert ".env" in resposta.text
assert "ok" not in chamou
+26
View File
@@ -0,0 +1,26 @@
from app.senha import gerar_hash, verificar_senha
def test_hash_confere_com_senha_correta():
h = gerar_hash("minhaSenha123")
assert verificar_senha("minhaSenha123", h) is True
def test_hash_rejeita_senha_errada():
h = gerar_hash("minhaSenha123")
assert verificar_senha("senhaErrada", h) is False
def test_hashes_de_senhas_iguais_sao_diferentes():
# Salt aleatório por chamada — dois hashes da mesma senha não são iguais.
h1 = gerar_hash("mesmaSenha")
h2 = gerar_hash("mesmaSenha")
assert h1 != h2
assert verificar_senha("mesmaSenha", h1) is True
assert verificar_senha("mesmaSenha", h2) is True
def test_hash_malformado_nao_lanca():
assert verificar_senha("qualquer", "isso-nao-e-um-hash-valido") is False
assert verificar_senha("qualquer", "") is False
assert verificar_senha("qualquer", "md5$abc$def") is False
+47
View File
@@ -0,0 +1,47 @@
import time
from app import session
def test_criar_e_ler_token_valido():
token = session.criar_token("fulano", "Fulano da Silva", "ADMIN")
dados = session.ler_token(token)
assert dados == {"login": "fulano", "nome": "Fulano da Silva", "perfil": "ADMIN"}
def test_ler_token_none_retorna_none():
assert session.ler_token(None) is None
def test_ler_token_vazio_retorna_none():
assert session.ler_token("") is None
def test_ler_token_sem_ponto_retorna_none():
assert session.ler_token("lixo-sem-formato") is None
def test_ler_token_assinatura_adulterada_retorna_none():
token = session.criar_token("fulano", "Fulano", "PADRAO")
payload, assinatura = token.rsplit(".", 1)
token_adulterado = payload + "." + ("0" if assinatura[0] != "0" else "1") + assinatura[1:]
assert session.ler_token(token_adulterado) is None
def test_ler_token_payload_adulterado_retorna_none():
token = session.criar_token("fulano", "Fulano", "PADRAO")
payload, assinatura = token.rsplit(".", 1)
token_adulterado = payload + "X." + assinatura
assert session.ler_token(token_adulterado) is None
def test_ler_token_expirado_retorna_none():
agora = time.time()
token = session.criar_token("fulano", "Fulano", "PADRAO", agora=agora - session.VALIDADE_SEGUNDOS - 1)
assert session.ler_token(token, agora=agora) is None
def test_ler_token_no_limite_da_validade_ainda_e_valido():
agora = time.time()
token = session.criar_token("fulano", "Fulano", "PADRAO", agora=agora - session.VALIDADE_SEGUNDOS + 5)
assert session.ler_token(token, agora=agora) is not None
+123
View File
@@ -0,0 +1,123 @@
import base64
import re
from urllib.parse import parse_qs, urlsplit
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat
from fastapi.testclient import TestClient
from app import session
from app.db import funcionarios as db_funcionarios
from app.db import log as db_log
from app.main import app
CALLBACK = "https://gtifiscal.gtigrupo.com.br/auth/sso/callback"
STATE = "estadoDoNavegador_0123456789"
ATIVO = {"ID": 7, "NOME": "Maria Teste", "LOGIN": "maria", "SENHA_HASH": "x", "PERFIL": "PADRAO", "ATIVO": 1}
@pytest.fixture
def chave(monkeypatch):
privada = Ed25519PrivateKey.generate()
semente = privada.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
monkeypatch.setenv("SSO_FISCAL_CHAVE_PRIVADA", base64.urlsafe_b64encode(semente).decode().rstrip("="))
monkeypatch.setenv("SSO_FISCAL_CALLBACK_URL", CALLBACK)
monkeypatch.setenv("SSO_GERENCIADOR_URL_PUBLICA", "https://gtigerenciador.gtigrupo.com.br")
monkeypatch.setattr(db_log, "registrar", lambda *a, **k: None)
return privada.public_key()
def _cliente(funcionario=ATIVO, logado=True, https=True, monkeypatch=None):
monkeypatch.setattr(db_funcionarios, "obter_por_login", lambda login: funcionario if funcionario and login == funcionario["LOGIN"] else None)
cliente = TestClient(app, base_url="https://testserver" if https else "http://testserver")
if logado:
login = funcionario["LOGIN"] if funcionario else "admin"
cliente.cookies.set(session.SESSION_COOKIE_NAME, session.criar_token(login, "Maria Teste", "PADRAO"))
return cliente
def _assercao(resposta):
return re.search(r'name="assertion" value="([^"]+)"', resposta.text).group(1)
def test_funcionario_ativo_recebe_formulario_com_assercao_valida(chave, monkeypatch):
resposta = _cliente(monkeypatch=monkeypatch).get("/sso/fiscal", params={"next": "/agents", "state": STATE})
assert resposta.status_code == 200
assert f'action="{CALLBACK}"' in resposta.text and 'name="next" value="/agents"' in resposta.text
claims = jwt.decode(_assercao(resposta), chave, algorithms=["EdDSA"], audience="gti-fiscal", issuer="gti-gerenciador", options={"require": ["exp", "iat", "nbf", "iss", "aud", "sub", "jti", "nonce"]})
assert claims["nonce"] == STATE
assert claims["sub"] == "funcionario:7" and claims["login"] == "maria" and claims["perfil"] == "PADRAO"
assert claims["exp"] - claims["iat"] == 60
assert "password" not in claims and "SENHA_HASH" not in str(claims)
csp = resposta.headers["content-security-policy"]
assert "form-action https://gtifiscal.gtigrupo.com.br;" in csp and "'self'" not in csp.split("form-action")[1].split(";")[0]
assert resposta.headers["cache-control"] == "no-store"
# A asserção nunca aparece na URL
assert "assertion" not in str(resposta.url)
def test_cada_emissao_tem_jti_diferente(chave, monkeypatch):
cliente = _cliente(monkeypatch=monkeypatch)
a, b = (jwt.decode(_assercao(cliente.get("/sso/fiscal", params={"state": STATE})), chave, algorithms=["EdDSA"], audience="gti-fiscal") for _ in range(2))
assert a["jti"] != b["jti"]
def test_sem_sessao_vai_para_login_e_volta_ao_sso(chave, monkeypatch):
resposta = _cliente(logado=False, monkeypatch=monkeypatch).get("/sso/fiscal", params={"next": "/jobs", "state": STATE}, follow_redirects=False)
assert resposta.status_code == 303
destino = urlsplit(resposta.headers["location"])
assert destino.path == "/login"
assert parse_qs(destino.query)["next"] == [f"/sso/fiscal?state={STATE}&next=%2Fjobs"]
def test_funcionario_inativo_nao_recebe_assercao(chave, monkeypatch):
resposta = _cliente(funcionario={**ATIVO, "ATIVO": 0}, monkeypatch=monkeypatch).get("/sso/fiscal", params={"state": STATE}, follow_redirects=False)
assert resposta.status_code == 303 and resposta.headers["location"].startswith("/login")
assert "assertion" not in resposta.text
def test_conta_de_bootstrap_nao_usa_sso(chave, monkeypatch):
monkeypatch.setenv("ADMIN_USER", "admin")
resposta = _cliente(funcionario=None, monkeypatch=monkeypatch).get("/sso/fiscal", params={"state": STATE}, follow_redirects=False)
assert resposta.status_code == 403 and "assertion" not in resposta.text
def test_http_redireciona_para_endereco_https_publico(chave, monkeypatch):
resposta = _cliente(https=False, monkeypatch=monkeypatch).get("/sso/fiscal", params={"next": "/blockers", "state": STATE}, follow_redirects=False)
assert resposta.status_code == 303
assert resposta.headers["location"] == f"https://gtigerenciador.gtigrupo.com.br/sso/fiscal?state={STATE}&next=%2Fblockers"
@pytest.mark.parametrize("malicioso", ["//evil.com/x", "https://evil.com", "javascript:alert(1)", "/\\evil.com", "data:text/html,x", "agents", "/a\nb"])
def test_next_malicioso_vira_raiz(chave, monkeypatch, malicioso):
resposta = _cliente(monkeypatch=monkeypatch).get("/sso/fiscal", params={"next": malicioso, "state": STATE})
assert 'name="next" value="/"' in resposta.text
def test_callback_so_https_configurado(chave, monkeypatch):
monkeypatch.setenv("SSO_FISCAL_CALLBACK_URL", "http://gtifiscal.gtigrupo.com.br/auth/sso/callback")
with pytest.raises(RuntimeError):
_cliente(monkeypatch=monkeypatch).get("/sso/fiscal", params={"state": STATE})
def test_sem_configuracao_nao_emite(monkeypatch):
monkeypatch.delenv("SSO_FISCAL_CHAVE_PRIVADA", raising=False)
monkeypatch.setattr(db_log, "registrar", lambda *a, **k: None)
resposta = _cliente(monkeypatch=monkeypatch).get("/sso/fiscal", params={"state": STATE})
assert resposta.status_code == 503
def test_outras_paginas_mantem_csp_padrao(chave, monkeypatch):
resposta = _cliente(monkeypatch=monkeypatch).get("/login")
assert "form-action 'self'" in resposta.headers["content-security-policy"]
@pytest.mark.parametrize("estado", [None, "curto", "com espaço e símbolos!!!!!!", "x" * 200])
def test_sem_state_valido_manda_para_o_login_do_fiscal(chave, monkeypatch, estado):
params = {"next": "/agents"} | ({"state": estado} if estado is not None else {})
resposta = _cliente(monkeypatch=monkeypatch).get("/sso/fiscal", params=params, follow_redirects=False)
assert resposta.status_code == 303
assert resposta.headers["location"] == "https://gtifiscal.gtigrupo.com.br/login?next=%2Fagents"
assert "assertion" not in resposta.text