Add 'gerenciador/' from commit '34cc9c75a57dedbf61a6e6a1ba1f35f76e72434b'
git-subtree-dir: gerenciador git-subtree-mainline:f4f546c928git-subtree-split:34cc9c75a5
This commit is contained in:
@@ -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=
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
@@ -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"]
|
||||
@@ -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`.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 "",
|
||||
}
|
||||
@@ -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.")
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
@@ -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()}
|
||||
@@ -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")
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# app/routers/__init__.py
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]}
|
||||
@@ -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")
|
||||
@@ -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 |
@@ -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");
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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 %}
|
||||
@@ -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">«</a>
|
||||
<a class="botao" href="/clientes?pagina={{ pagina - 1 }}{{ qs_base }}" aria-label="Página anterior">‹</a>
|
||||
{% endif %}
|
||||
{% for p in janela_paginas %}
|
||||
{% if p is none %}
|
||||
<span class="paginacao-reticencias">…</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">›</a>
|
||||
<a class="botao" href="/clientes?pagina={{ total_paginas }}{{ qs_base }}" aria-label="Última página">»</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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).
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.3
|
||||
httpx==0.27.2
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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) == ""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 "…" 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&pagina=2&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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user