From 143ad71822525ffb4da8500d30551941bc698cd5 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 09:39:28 -0300 Subject: [PATCH 01/87] chore: scaffold painel-clientes-web --- .env.example | 13 +++++++++++++ .gitignore | 4 ++++ Dockerfile | 7 +++++++ app/__init__.py | 0 docker-compose.yml | 8 ++++++++ pytest.ini | 3 +++ requirements-dev.txt | 3 +++ requirements.txt | 6 ++++++ tests/__init__.py | 0 tests/conftest.py | 16 ++++++++++++++++ 10 files changed, 60 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 docker-compose.yml create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0ad832f --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copie para .env e preencha antes de rodar (nunca commitar o .env real). + +ADMIN_USER=admin +ADMIN_PASSWORD=SUBSTITUA_POR_UMA_SENHA_FORTE + +# Banco de dev (mesmo container do projeto n8n / contatos-chave-web): +# DB_HOST=host.docker.internal (se este app tambem rodar em container) +# DB_HOST=127.0.0.1 (se este app rodar direto na maquina) +DB_HOST=127.0.0.1 +DB_PORT=3307 +DB_NAME=gtisoftw_clientes_dev +DB_USER=root +DB_PASSWORD=devpassword123 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc4f72a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..df5efa5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app ./app +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8e8d2a6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + painel: + build: . + ports: + - "8010:8000" + env_file: + - .env + restart: unless-stopped diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4584de7 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = . diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b918a15 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.3.3 +httpx==0.27.2 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b8e9fbe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +jinja2==3.1.4 +python-multipart==0.0.9 +PyMySQL==1.1.1 +python-dotenv==1.0.1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9bc1ab8 --- /dev/null +++ b/tests/conftest.py @@ -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") From 669e864141f95d895dc99b21f1eca63e751acb66 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 09:42:10 -0300 Subject: [PATCH 02/87] feat: normalizacao de documento + validacao de DV (CPF/CNPJ alfanumerico) --- app/normalize.py | 84 +++++++++++++++++++++++++++++++++++++++++ tests/test_normalize.py | 80 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 app/normalize.py create mode 100644 tests/test_normalize.py diff --git a/app/normalize.py b/app/normalize.py new file mode 100644 index 0000000..19943df --- /dev/null +++ b/app/normalize.py @@ -0,0 +1,84 @@ +""" +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 "") + + +_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 diff --git a/tests/test_normalize.py b/tests/test_normalize.py new file mode 100644 index 0000000..86e8f46 --- /dev/null +++ b/tests/test_normalize.py @@ -0,0 +1,80 @@ +""" +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 880329bb8e051c2e9f1652413323c0ce8f906bfc Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 09:45:26 -0300 Subject: [PATCH 03/87] feat: factory de conexao MySQL --- app/db/__init__.py | 0 app/db/connection.py | 22 ++++++++++++++++++++++ tests/test_db_connection.py | 17 +++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 app/db/__init__.py create mode 100644 app/db/connection.py create mode 100644 tests/test_db_connection.py diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/connection.py b/app/db/connection.py new file mode 100644 index 0000000..7614801 --- /dev/null +++ b/app/db/connection.py @@ -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, + ) diff --git a/tests/test_db_connection.py b/tests/test_db_connection.py new file mode 100644 index 0000000..f5ddef8 --- /dev/null +++ b/tests/test_db_connection.py @@ -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() From 2ecf86667883af1fcce75fe055725cdabdd77779 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 09:47:52 -0300 Subject: [PATCH 04/87] feat: CRUD de CLIENTE --- app/db/clientes.py | 104 ++++++++++++++++++++++++++++++++++++ tests/test_db_clientes.py | 109 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 app/db/clientes.py create mode 100644 tests/test_db_clientes.py diff --git a/app/db/clientes.py b/app/db/clientes.py new file mode 100644 index 0000000..c291904 --- /dev/null +++ b/app/db/clientes.py @@ -0,0 +1,104 @@ +""" +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). +""" +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", +] + +_COLUNAS_SELECT = ", ".join(["ID", *CAMPOS_CLIENTE]) + + +class ClienteComDependenciasError(Exception): + """Levantada ao tentar excluir um CLIENTE que ainda tem CLIENTE_CONTATO.""" + + +def listar_clientes(busca: str | None = None, limite: int = 100) -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + if busca: + sql = ( + f"SELECT {_COLUNAS_SELECT} FROM CLIENTE " + "WHERE CODIGO LIKE %s OR CNPJ LIKE %s OR RAZAO LIKE %s OR FANTASIA LIKE %s " + "ORDER BY RAZAO LIMIT %s" + ) + termo = f"%{busca}%" + cur.execute(sql, (termo, termo, termo, termo, limite)) + else: + cur.execute(f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY RAZAO LIMIT %s", (limite,)) + return cur.fetchall() + 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 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) -> None: + 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 + finally: + conn.close() diff --git a/tests/test_db_clientes.py b/tests/test_db_clientes.py new file mode 100644 index 0000000..456178e --- /dev/null +++ b/tests/test_db_clientes.py @@ -0,0 +1,109 @@ +""" +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", +} + + +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_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): + db_clientes.excluir_cliente(cliente_id) + assert db_clientes.obter_cliente(cliente_id) is None + + +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() + + with pytest.raises(db_clientes.ClienteComDependenciasError): + db_clientes.excluir_cliente(cliente_id) + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("DELETE FROM CLIENTE_CONTATO WHERE CLIENTE_ID = %s", (cliente_id,)) + finally: + conn.close() From 680b7433a44cf799f085cbb18fddedf5c07dc13b Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 09:51:13 -0300 Subject: [PATCH 05/87] fix: garantir limpeza de CLIENTE_CONTATO mesmo se assert falhar (test_excluir_cliente_com_contato_bloqueia) --- tests/test_db_clientes.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_db_clientes.py b/tests/test_db_clientes.py index 456178e..676e1e9 100644 --- a/tests/test_db_clientes.py +++ b/tests/test_db_clientes.py @@ -98,12 +98,13 @@ def test_excluir_cliente_com_contato_bloqueia(cliente_id): finally: conn.close() - with pytest.raises(db_clientes.ClienteComDependenciasError): - db_clientes.excluir_cliente(cliente_id) - - conn = get_connection() try: - with conn.cursor() as cur: - cur.execute("DELETE FROM CLIENTE_CONTATO WHERE CLIENTE_ID = %s", (cliente_id,)) + with pytest.raises(db_clientes.ClienteComDependenciasError): + db_clientes.excluir_cliente(cliente_id) finally: - conn.close() + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("DELETE FROM CLIENTE_CONTATO WHERE CLIENTE_ID = %s", (cliente_id,)) + finally: + conn.close() From fd4b749c87e9600a63a963578edc37e28320eea8 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 09:54:26 -0300 Subject: [PATCH 06/87] feat: CRUD de CLIENTE_CONTATO --- app/db/contatos.py | 75 +++++++++++++++++++++++++++++++++++++++ tests/test_db_contatos.py | 59 ++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 app/db/contatos.py create mode 100644 tests/test_db_contatos.py diff --git a/app/db/contatos.py b/app/db/contatos.py new file mode 100644 index 0000000..811ead6 --- /dev/null +++ b/app/db/contatos.py @@ -0,0 +1,75 @@ +""" +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 +) -> None: + 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), + ) + finally: + conn.close() + + +def excluir_contato(contato_id: int, cliente_id: int) -> None: + 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), + ) + finally: + conn.close() diff --git a/tests/test_db_contatos.py b/tests/test_db_contatos.py new file mode 100644 index 0000000..50f3495 --- /dev/null +++ b/tests/test_db_contatos.py @@ -0,0 +1,59 @@ +""" +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") + db_contatos.atualizar_contato(contato_id, cliente_id, "Fulano Editado", "75999090003") + contatos = db_contatos.listar_contatos_por_cliente(cliente_id) + assert contatos[0]["NOME"] == "Fulano Editado" + assert contatos[0]["TELEFONE"] == "75999090003" + + +def test_excluir_contato(cliente_id): + contato_id = db_contatos.inserir_contato(cliente_id, "Fulano", "75999090001") + db_contatos.excluir_contato(contato_id, cliente_id) + assert db_contatos.listar_contatos_por_cliente(cliente_id) == [] From f510a1e17e0be7293a22f0973b96250b6174ffe7 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 09:58:21 -0300 Subject: [PATCH 07/87] feat: leitura somente-leitura de status em Clientes_Chaves --- app/db/chaves.py | 31 +++++++++++++++++++++++ tests/test_db_chaves.py | 55 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 app/db/chaves.py create mode 100644 tests/test_db_chaves.py diff --git a/app/db/chaves.py b/app/db/chaves.py new file mode 100644 index 0000000..6577304 --- /dev/null +++ b/app/db/chaves.py @@ -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}, + } diff --git a/tests/test_db_chaves.py b/tests/test_db_chaves.py new file mode 100644 index 0000000..4a455a1 --- /dev/null +++ b/tests/test_db_chaves.py @@ -0,0 +1,55 @@ +""" +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 + 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 From e93a0f3d1d2002826b466439fa67a62ae27e0306 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 10:00:52 -0300 Subject: [PATCH 08/87] feat: login HTTP Basic --- app/auth.py | 27 +++++++++++++++++++++++++++ tests/test_auth.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 app/auth.py create mode 100644 tests/test_auth.py diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..9bbbc26 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,27 @@ +""" +Login simples via HTTP Basic Auth, usuário/senha vindos de variável de +ambiente. Sem cadastro de usuários, sem perfis, sem sessão/cookie. +""" +import os +import secrets + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +_security = HTTPBasic() + + +def exigir_login(credenciais: HTTPBasicCredentials = Depends(_security)) -> str: + usuario_esperado = os.environ.get("ADMIN_USER", "") + senha_esperada = os.environ.get("ADMIN_PASSWORD", "") + + usuario_ok = secrets.compare_digest(credenciais.username, usuario_esperado) + senha_ok = secrets.compare_digest(credenciais.password, senha_esperada) + + if not (usuario_ok and senha_ok): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuário ou senha inválidos.", + headers={"WWW-Authenticate": "Basic"}, + ) + return credenciais.username diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..20e1f84 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,38 @@ +import base64 + +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from app.auth import exigir_login + +app_teste = FastAPI() + + +@app_teste.get("/protegido") +def rota_protegida(usuario: str = Depends(exigir_login)): + return {"usuario": usuario} + + +client = TestClient(app_teste) + + +def _basic(usuario: str, senha: str) -> dict: + token = base64.b64encode(f"{usuario}:{senha}".encode()).decode() + return {"Authorization": f"Basic {token}"} + + +def test_sem_credenciais_retorna_401(): + resposta = client.get("/protegido") + assert resposta.status_code == 401 + + +def test_credenciais_erradas_retorna_401(): + resposta = client.get("/protegido", headers=_basic("admin", "senha_errada")) + assert resposta.status_code == 401 + + +def test_credenciais_corretas_retorna_200(): + # ADMIN_USER/ADMIN_PASSWORD vêm de tests/conftest.py ("admin"/"teste123") + resposta = client.get("/protegido", headers=_basic("admin", "teste123")) + assert resposta.status_code == 200 + assert resposta.json() == {"usuario": "admin"} From c7f2c5f92b5cc030306f6a5ee616e8021ab3892e Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 10:07:15 -0300 Subject: [PATCH 09/87] feat: rotas e telas de CLIENTE (listar, criar, editar, detalhe, excluir) --- app/routers/__init__.py | 1 + app/routers/clientes.py | 137 +++++++++++++++++++++++++++++ app/templates/base.html | 35 ++++++++ app/templates/cliente_detalhe.html | 31 +++++++ app/templates/cliente_form.html | 26 ++++++ app/templates/clientes_lista.html | 23 +++++ tests/test_router_clientes.py | 81 +++++++++++++++++ 7 files changed, 334 insertions(+) create mode 100644 app/routers/__init__.py create mode 100644 app/routers/clientes.py create mode 100644 app/templates/base.html create mode 100644 app/templates/cliente_detalhe.html create mode 100644 app/templates/cliente_form.html create mode 100644 app/templates/clientes_lista.html create mode 100644 tests/test_router_clientes.py diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..567b54d --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +# app/routers/__init__.py diff --git a/app/routers/clientes.py b/app/routers/clientes.py new file mode 100644 index 0000000..486e148 --- /dev/null +++ b/app/routers/clientes.py @@ -0,0 +1,137 @@ +# app/routers/clientes.py +""" +Rotas de gestão de CLIENTE: listar/buscar, criar, editar, ver detalhe +(contatos + status de Clientes_Chaves), excluir. +""" +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 ..auth import exigir_login +from ..db import chaves as db_chaves +from ..db import clientes as db_clientes +from ..db import contatos as db_contatos +from ..normalize import normalizar_documento, validar_dv_cpf, validar_dv_cnpj + +router = APIRouter(prefix="/clientes") +templates = Jinja2Templates(directory="app/templates") + + +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} + + +@router.get("", response_class=HTMLResponse) +def listar( + request: Request, + busca: Optional[str] = None, + mensagem: Optional[str] = None, + mensagem_tipo: Optional[str] = None, + usuario: str = Depends(exigir_login), +): + clientes = db_clientes.listar_clientes(busca=busca or None) + return templates.TemplateResponse( + "clientes_lista.html", + {"request": request, "clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo}, + ) + + +@router.get("/novo", response_class=HTMLResponse) +def form_novo(request: Request, usuario: str = Depends(exigir_login)): + return templates.TemplateResponse( + "cliente_form.html", + {"request": request, "cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE}, + ) + + +@router.post("/novo") +async def criar(request: Request, usuario: str = Depends(exigir_login)): + form = dict((await request.form())) + dados = _ler_form_cliente(form) + confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1" + + if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"]): + return templates.TemplateResponse( + "cliente_form.html", + {"request": request, "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, + ) + + _, formato_valido, tipo = normalizar_documento(dados["CNPJ"]) + dv_invalido = False + if formato_valido: + dv_invalido = not (validar_dv_cpf(dados["CNPJ"]) if tipo == "CPF" else validar_dv_cnpj(dados["CNPJ"])) + if dv_invalido and form.get("confirmar_dv_invalido") != "1": + return templates.TemplateResponse( + "cliente_form.html", + {"request": request, "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_dv_invalido": True}, + ) + + novo_id = db_clientes.criar_cliente(dados) + return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.") + + +@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( + "cliente_detalhe.html", + { + "request": request, "cliente": cliente, "contatos": contatos, "status_chaves": status, + "mensagem": mensagem, "mensagem_tipo": mensagem_tipo, + }, + ) + + +@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( + "cliente_form.html", + {"request": request, "cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE}, + ) + + +@router.post("/{cliente_id}/editar") +async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)): + form = dict((await request.form())) + dados = _ler_form_cliente(form) + confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1" + + if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"], ignorar_id=cliente_id): + return templates.TemplateResponse( + "cliente_form.html", + {"request": request, "cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, + ) + + db_clientes.atualizar_cliente(cliente_id, dados) + return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.") + + +@router.post("/{cliente_id}/excluir") +def excluir(cliente_id: int, usuario: str = Depends(exigir_login)): + try: + db_clientes.excluir_cliente(cliente_id) + except db_clientes.ClienteComDependenciasError as exc: + return _redirect_com_mensagem(f"/clientes/{cliente_id}", str(exc), "danger") + return _redirect_com_mensagem("/clientes", "Cliente excluído.") diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..aee3555 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,35 @@ + + + + + + {% block titulo %}Painel de Clientes{% endblock %} + + + + +
+ {% if mensagem %}
{{ mensagem }}
{% endif %} + {% block conteudo %}{% endblock %} +
+ + diff --git a/app/templates/cliente_detalhe.html b/app/templates/cliente_detalhe.html new file mode 100644 index 0000000..6aae177 --- /dev/null +++ b/app/templates/cliente_detalhe.html @@ -0,0 +1,31 @@ + +{% extends "base.html" %} +{% block titulo %}{{ cliente.RAZAO }}{% endblock %} +{% block conteudo %} +

{{ cliente.FANTASIA }} ({{ cliente.CODIGO }})

+

{{ cliente.RAZAO }} — {{ cliente.CNPJ }}

+

+ Editar +

+ +
+

+ +

Contatos

+ + + {% for ct in contatos %} + + {% endfor %} +
NomeTelefoneE-mail
{{ ct.NOME }}{{ ct.TELEFONE }}{{ ct.EMAIL or "" }}
+ +

Status em Clientes_Chaves

+ {% if status_chaves %} + + {% for mes in status_chaves.meses %}{% endfor %} + {% for mes, valor in status_chaves.meses.items() %}{% endfor %} +
{{ mes }}
{{ "✓" if valor else "—" }}
+ {% else %} +

Nenhuma linha em Clientes_Chaves para o código {{ cliente.CODIGO }}.

+ {% endif %} +{% endblock %} diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html new file mode 100644 index 0000000..015cb3a --- /dev/null +++ b/app/templates/cliente_form.html @@ -0,0 +1,26 @@ + +{% extends "base.html" %} +{% block titulo %}{{ "Editar" if cliente else "Novo" }} cliente{% endblock %} +{% block conteudo %} +

{{ "Editar" if cliente else "Novo" }} cliente

+ {% if aviso_codigo_duplicado %} +
+ O código {{ dados.CODIGO }} já está em uso por outro cliente. +
+ {% for campo, valor in dados.items() %}{% endfor %} + + +
+
+ {% endif %} + {% if aviso_dv_invalido %} +
O dígito verificador do documento ({{ dados.CNPJ }}) não confere — confirme se digitou certo antes de salvar.
+ {% endif %} +
+ {% for campo in campos %} + + + {% endfor %} + +
+{% endblock %} diff --git a/app/templates/clientes_lista.html b/app/templates/clientes_lista.html new file mode 100644 index 0000000..76ac82b --- /dev/null +++ b/app/templates/clientes_lista.html @@ -0,0 +1,23 @@ + +{% extends "base.html" %} +{% block titulo %}Clientes{% endblock %} +{% block conteudo %} +

Clientes

+
+ + +
+

+ Novo cliente

+ + + {% for c in clientes %} + + + + + + + + {% endfor %} +
CódigoRazãoFantasiaCNPJ
{{ c.CODIGO }}{{ c.RAZAO }}{{ c.FANTASIA }}{{ c.CNPJ }}Abrir
+{% endblock %} diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py new file mode 100644 index 0000000..16f9983 --- /dev/null +++ b/tests/test_router_clientes.py @@ -0,0 +1,81 @@ +""" +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. +""" +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.auth import exigir_login +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) + + +def test_listar_clientes(monkeypatch): + monkeypatch.setattr( + router_clientes.db_clientes, + "listar_clientes", + lambda busca=None, limite=100: [ + {"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Um", "FANTASIA": "Um", "CNPJ": "11222333000181"} + ], + ) + resposta = client.get("/clientes") + assert resposta.status_code == 200 + assert "Cliente Um" in resposta.text + + +def test_criar_cliente_codigo_duplicado_exige_confirmacao(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) or 42 + ) + + dados_form = {campo: "000001" if campo == "CODIGO" else "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} + dados_form.update( + { + "IMPLANTACAO": "0", + "MENSALIDADE": "100", + "JUROS_MORA": "0", + "JUROS_MULTA": "0", + "DATA_CONTRATO": "2026-01-01", + "VENCIMENTO_CONTRATO": "2027-01-01", + "DIA_VENCIMENTO": "10", + "CNPJ": "11222333000181", + } + ) + + # Sem "confirmar_codigo_duplicado": não deve criar, deve pedir confirmação. + 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 + + # Com confirmação: cria. + dados_form["confirmar_codigo_duplicado"] = "1" + resposta = client.post("/clientes/novo", data=dados_form) + assert resposta.status_code == 303 + assert criado.get("ok") is True + + +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", follow_redirects=True) + assert resposta.status_code == 200 + assert "tem contato" in resposta.text From 2c7bf429fcb991310359f9a1af6bcc9b5e488247 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 10:12:26 -0300 Subject: [PATCH 10/87] fix: DV usa valor normalizado, botao de confirmar DV invalido, TemplateResponse sem warning --- app/routers/clientes.py | 27 ++++++++++++++++++--------- app/templates/cliente_form.html | 9 ++++++++- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/app/routers/clientes.py b/app/routers/clientes.py index 486e148..bcec73c 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -39,16 +39,18 @@ def listar( ): clientes = db_clientes.listar_clientes(busca=busca or None) return templates.TemplateResponse( + request, "clientes_lista.html", - {"request": request, "clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo}, + {"clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo}, ) @router.get("/novo", response_class=HTMLResponse) def form_novo(request: Request, usuario: str = Depends(exigir_login)): return templates.TemplateResponse( + request, "cliente_form.html", - {"request": request, "cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE}, + {"cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE}, ) @@ -60,18 +62,22 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"]): return templates.TemplateResponse( + request, "cliente_form.html", - {"request": request, "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, + {"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, ) - _, formato_valido, tipo = normalizar_documento(dados["CNPJ"]) + documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"]) dv_invalido = False if formato_valido: - dv_invalido = not (validar_dv_cpf(dados["CNPJ"]) if tipo == "CPF" else validar_dv_cnpj(dados["CNPJ"])) + dv_invalido = not ( + validar_dv_cpf(documento_normalizado) if tipo == "CPF" else validar_dv_cnpj(documento_normalizado) + ) if dv_invalido and form.get("confirmar_dv_invalido") != "1": return templates.TemplateResponse( + request, "cliente_form.html", - {"request": request, "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_dv_invalido": True}, + {"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_dv_invalido": True}, ) novo_id = db_clientes.criar_cliente(dados) @@ -93,9 +99,10 @@ def detalhe( contatos = db_contatos.listar_contatos_por_cliente(cliente_id) status = db_chaves.status_chaves(cliente["CODIGO"]) return templates.TemplateResponse( + request, "cliente_detalhe.html", { - "request": request, "cliente": cliente, "contatos": contatos, "status_chaves": status, + "cliente": cliente, "contatos": contatos, "status_chaves": status, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo, }, ) @@ -107,8 +114,9 @@ def form_editar(request: Request, cliente_id: int, usuario: str = Depends(exigir if cliente is None: return _redirect_com_mensagem("/clientes", "Cliente não encontrado.", "danger") return templates.TemplateResponse( + request, "cliente_form.html", - {"request": request, "cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE}, + {"cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE}, ) @@ -120,8 +128,9 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"], ignorar_id=cliente_id): return templates.TemplateResponse( + request, "cliente_form.html", - {"request": request, "cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, + {"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, ) db_clientes.atualizar_cliente(cliente_id, dados) diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html index 015cb3a..d1e38da 100644 --- a/app/templates/cliente_form.html +++ b/app/templates/cliente_form.html @@ -14,7 +14,14 @@ {% endif %} {% if aviso_dv_invalido %} -
O dígito verificador do documento ({{ dados.CNPJ }}) não confere — confirme se digitou certo antes de salvar.
+
+ O dígito verificador do documento ({{ dados.CNPJ }}) não confere — confirme se digitou certo antes de salvar. +
+ {% for campo, valor in dados.items() %}{% endfor %} + + +
+
{% endif %}
{% for campo in campos %} From f1139e8b3adf5ccb4bd71a4a78dde3faaedfe981 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 10:25:20 -0300 Subject: [PATCH 11/87] feat: rotas de CLIENTE_CONTATO na tela de detalhe do cliente --- app/routers/contatos.py | 69 ++++++++++++++++++++++++++++++ app/templates/cliente_detalhe.html | 20 ++++++++- tests/test_router_contatos.py | 51 ++++++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 app/routers/contatos.py create mode 100644 tests/test_router_contatos.py diff --git a/app/routers/contatos.py b/app/routers/contatos.py new file mode 100644 index 0000000..008d4e2 --- /dev/null +++ b/app/routers/contatos.py @@ -0,0 +1,69 @@ +""" +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). +""" +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, Form +from fastapi.responses import RedirectResponse + +from ..auth import exigir_login +from ..db import clientes as db_clientes +from ..db import contatos as db_contatos +from ..normalize import normalizar_telefone + +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") +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(telefone) + if not telefone_normalizado: + return _redirect_com_mensagem(cliente_id, "Telefone obrigatório.", "danger") + + if db_contatos.contato_duplicado(cliente_id, telefone_normalizado): + 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) + return _redirect_com_mensagem(cliente_id, "Contato adicionado.") + + +@router.post("/{cliente_id}/contatos/{contato_id}/editar") +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(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): + return _redirect_com_mensagem(cliente_id, f"O telefone {telefone_normalizado} já está cadastrado para este cliente.", "warning") + + db_contatos.atualizar_contato(contato_id, cliente_id, nome.strip() or None, telefone_normalizado, email.strip() or None) + return _redirect_com_mensagem(cliente_id, "Contato atualizado.") + + +@router.post("/{cliente_id}/contatos/{contato_id}/excluir") +def excluir(cliente_id: int, contato_id: int, usuario: str = Depends(exigir_login)): + db_contatos.excluir_contato(contato_id, cliente_id) + return _redirect_com_mensagem(cliente_id, "Contato excluído.") diff --git a/app/templates/cliente_detalhe.html b/app/templates/cliente_detalhe.html index 6aae177..057bbfb 100644 --- a/app/templates/cliente_detalhe.html +++ b/app/templates/cliente_detalhe.html @@ -13,11 +13,27 @@

Contatos

- + {% for ct in contatos %} - + + + + {% endfor %}
NomeTelefoneE-mail
NomeTelefoneE-mail
{{ ct.NOME }}{{ ct.TELEFONE }}{{ ct.EMAIL or "" }}
{{ ct.NOME }}{{ ct.TELEFONE }}{{ ct.EMAIL or "" }} + + + +
+
+ + + + + + + +

Status em Clientes_Chaves

{% if status_chaves %} diff --git a/tests/test_router_contatos.py b/tests/test_router_contatos.py new file mode 100644 index 0000000..af3e724 --- /dev/null +++ b/tests/test_router_contatos.py @@ -0,0 +1,51 @@ +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) + + +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"}) + assert resposta.status_code == 303 + assert chamadas["dados"] == (1, "Fulano", "75999090001", 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"}, 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") + assert resposta.status_code == 303 + assert chamadas["dados"] == (5, 1) From 9ebe6b1c68f4fc277f9d1fff22343dc2c818a8ac Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 10:28:52 -0300 Subject: [PATCH 12/87] feat: pagina do gerador de chave manual (client-side, sem gravar no banco) --- app/routers/chave_manual.py | 19 +++++++ app/static/logo-gti-256x256.png | Bin 0 -> 12541 bytes app/templates/gerador_chave.html | 85 ++++++++++++++++++++++++++++++ tests/test_router_chave_manual.py | 24 +++++++++ 4 files changed, 128 insertions(+) create mode 100644 app/routers/chave_manual.py create mode 100644 app/static/logo-gti-256x256.png create mode 100644 app/templates/gerador_chave.html create mode 100644 tests/test_router_chave_manual.py diff --git a/app/routers/chave_manual.py b/app/routers/chave_manual.py new file mode 100644 index 0000000..56abdbe --- /dev/null +++ b/app/routers/chave_manual.py @@ -0,0 +1,19 @@ +""" +Calculadora de chave de reativação, para uso manual (reenvio/correção +pontual). 100% client-side (mesmo algoritmo gerarReativacao já usado no +workflow n8n e em Gerar chave WEB/index.html) — esta rota só serve a +página; NUNCA grava em Clientes_Chaves. +""" +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from ..auth import exigir_login + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/gerador-chave", response_class=HTMLResponse) +def pagina(request: Request, usuario: str = Depends(exigir_login)): + return templates.TemplateResponse(request, "gerador_chave.html", {}) diff --git a/app/static/logo-gti-256x256.png b/app/static/logo-gti-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..574db002abadd86b930f1d6fc3502cbd9941f749 GIT binary patch literal 12541 zcmeHtWmjB5v+ghq?jGFT-95NF1c%@R3$B9(3BiKP;1WEzI|SEY!QI{U&U?;Tcdh&F z{(#e8y1IAoy=w2>T~*Ih6{W5!hlWgq3;+Po6y!f@0st^j5e9$=gm$x-;+)V<)kU|d`g;HYLuSOFRg`fHc7UF(Tg*yYtRH zv26r{<@Wn@K%kKgKTtO|MwyBmOT8chP^a4l5&XA(%ILFT(#4ygsYiH6Kp6ACanqGC zh~YraFn`VnXU<$h(fIKiqq<4FxtPxlXps*Xqbk{NrNGpJT5;d>9b6* zomU>x4>!DCVqfz&Lj|>fK!gJUVA`n%QoBN_-=Eb*n-$@qns$UFJlP`K+hG`OU(95z zs>$fw-vwig^vkNhUw%zS&)Gz19rpNsL=vApv!l9IZ6(+Q1P8c-U@BxFzC*_AHY>d0 z!)m#j2~-O<5xhy>4`=oxKYm7HNwc2y=P0M1$sGS5hSVzI&s4Y%+P?9Ndx&;t7Z&-e={zA&0~30&Op7_q7b)ew7G0-#X%;76Z!V@Bgby1M@# zwc%H20c6LO0k-<{p(BX5lDE*6xu7#F2{(U(!TPVjjKvjK%MN5jOxpHlTD){j=wDf( z0j3#N3;g^t~X!XX5Sq)!I8kpp3Fvm@tw9cL8AZvSfS zVcIhl8MF45EO&ALjTzKEyGmrArW0^mx<6zSORfxNVA63|<{s7JmAi-oD}mzY-j_s7 zGq23VC6SU|7H48hcNiW>V1h{*=m5I!x9n)lej1(CZyM2s+|uDg`$fG;Y!tv}bya{} z_QI0CkYM*hAnd~eI&N8$=4KQubkB}JPw)FTy|G_n+P;6*C0_SG&N9NBI6p!! zKfT-Zkcg58)Zu8X$S_cYKTBUPpd4yfI(r3J+>*nw@Kz|}9yt_#qB?qqfZCjAU-c@& z0DBET23e^KWzITPz$fwno;2BHf7Tc2le0_TgD@{BPRbEZ(n*}o9nZS?jh0k{?cxL7 zl3{#lLmFh&3K>)&a^35;68gsFmHAvA1+BX=b_m#DG(f zR3t)ht0ocu+Ju2^fNbn_^_hnoz`VU+BU9w^d3zkOHsK3K`O~W(?-B8E*>w^Zk!wAp zPAbKxmN#WW|2P>aq<@Q>HWFuVKY`U8K;3t0yFPTx+UMCm&J^o*k{9yspK?|5H5(-E z2X_GV5DDLSGVMSUV23aGaG!8x;7jj6`*NOxJD{ZDxv432 zL&N8X=>lRkV{H?9<_HFoS6ozF5Dp{<2ZA3nIoi*Umw(C2qoyupqB!4%Dd0!-xZ6YU zD<{ZgLh_B4c!}HR<&fpg9X?3N*vCT6>UUFI6w6w=8Y>9^+-dV+Z}ndj2DzAs>8IA} z8kgIVp0nS?XUT_rwSl;SS`QEwv1tze!^WZI?y= ziWcZ;@RwdLu-}>-0A$qixlf-z@f=-#jhPSnm|*_+`|4RZaJ9?gw77lOLAS!BtI}*C zJSLSfx|beYY*&=Bn~iKw?zXR+c{j#WaVBRY$2HYX^LR+`Jk9vVMsHi8$vc>x6y`Nq zX!w#O2=;jF`-XSDKc30wGZJau_e6L1BQ7cy5e)@(%J$_2_LWz1xqB*iqh=%iPmRmN z8W|{_t#FNK{-%i=35_Lx2QXXac&MD3Nwg1&cN*i#y{tx^j0>`g;BJ}jWgd>)S2mkO zA{qVqN4?DV>htL@ojx&A5-gI6>wAowYm}S&1vrpIG$In|)o@9|%oN z^_)9Px&bz<6KuwGHW{$<GbY)uvU}VY$^^jIsp=v4VpC3HW!HMF4yB75T8KKGmBr{I{ zTQ8c3(@QsX5d!f%8n*KH-GpiGP~OMm$*!A&Wi(t=ZGLTc7VTQ?(fHixO+=CfINP@V zAp*yL)*J$*4u^>E`_2)&tCHsO!gzn)+@E<&m1QyCFS7iqH_w5}V3d9KTmo|}clitF zQ4l>qVL1T+yq$0U!jn8l*b;OB&)s)xCz<;vmve}|C81UeC@;qA7nsCf9Ha>1aC%TS zbG6EgF1~pp?|dc;BPP;{_mIPjO-9RtrAZg|n`rYo|Co6+sNCt7HH<2wl1`r^<2KNY zlDYfumD-%L+o$wP+j*}R2$Pkm;(XX*(-Ii05^FHiEP1Bsy+?>(xID;cU;Fza-m z`0(vssO_qpW_;+P21uf2XG*=Vt9^W7#rfIfpz;ogG-y1_z+MqpPX!3f7`s7@w|3lF z#Jjiks{njs$*nV;$(Nz3ay8-=fyNUuzQ}RYAN(%hw*5U%gIR*U1?k zT%-VuTA^z-c4_rJ3ExD`P+$b8Srv6qQv#HpvERb&;i{QMXmHnpR|jE4!Z5SX4ic!T z68-T5#DwHZ5PZ`5W{}5rJ3y^#FFF25`VZ*jV*dAM#oiHuO@a~F}POFnyVyuc=2ZIkkt1)w5r;uSD z7Ss-CFFhmHMWg|~t7gr1uC7S!OvJT6U~18kRZpfZpXV#pazuPypFIA~mb7`D9%U}J z?GD$@4BHg(kMnkNdKVGvLGaASVt2}`6&m&>#r17r=;<^4&X}2e-A~~)QIPEEM0p!( zYcm9_R zZn#xc!@s_x&)ABSi*3!b;_}QCJ=*`G22+d!ukc|)l!?FnhWA}bb2aN9;-i=Fwq=-W zW4J{(*8r=f$k4s53VXHo4}@A$Ji{Km#7|L%!vCG6ySzeYAGaL;kv|Juy6P*vTMh+~ z3^m}Y2uRJ|K)1a~hp*7e>vadf_;YG2*#&fw;^bAnG^^c!bfnB&c9i`j5+Sv+&4 zE@YV*h+w^7ofr6{S>nz0-_*BuLV?evt$IPX2lKE;dMVbWLmLvS^^96Fe#w!h!~EC# z2zIbm6Wt9P#5ZTt1xGTm`t3&NtU&d$Pj|~?Eq5!Su^#A+ijU)0`JpmUgY|Ou&zb%^ z>HTnb-1ChhD5=;lwJJ)~7oF(gGR$>5P`}w_HUiCTT~FN4dR3~*pn*t)mKXUam(G?b zCx5L`h$pV`dYrLRJ)#lj9xeXhIg9%9@56P`{_YjCqyuZWXcWLiA3mP+Z(2T997}11 ze+DKofz@B28PIV#lIlf(s=Jlf!4R8*lI`p*RD2-cnJB6KFR)M_al;oO#<3Q>@%A*z zPW>$oa5(++_)Q5%qGmPZTi7Ig<%Za5!K8ti9q%G4tVl@VM$5(@gbVdDK} zAHwu6Bqkx3sV*%WGlCuK?Cw}yd4Rk+S?1`M-$ys+?_*hFeR&_Qpx(#wRqP04BQ%U) z^Y>AloRo>_H#7Z@DI%D+n-rvk$u}-($X2o$ej?6TGI`=!<_rNv`9Fkv^0BN-xE!VLRui-> zP0T*Xw0KSk_i&7bH@?SIq=Nn*u4`gxUDw_~cEJ6yjMFfH7QGr0QhhQ)n}MY8)Eda! zGxRRxrXUR1Qj2HHXQgUQ$Fo(-+A0Y(4kSFSi|xeq#l_Y)e>5(3=J$tWhrp6Ar~Y$j zBPUr~u>+_x)I1)cLMBeudQY1T9#L@!jE)4;UW}V$c^cGW)#c?$4#}9^Qs9N;n?wOS z!XI*#x$*(BY34F&XK0D%;(_m^3n zDT7;~`bs&u_t2bwbW4TrrFvj0!kM)y2wGikxY+A9a^<-kF1SKySSZmuFHy^h*6pOG z1dHJ1Pi%ziPwmN1m|DO>@(!mVaUfS`#`%q^;Ea={%I91dSU-<*tct&aMDS!!hnUe) zv(c!fa2F%G@adZow473>(q(tJSOBrqUH0%2b*HL&OQ{@9eQznpmxgk^fjv?BFhIT) zmr(&fU0?8RR@L@7nHc^Ysi3Ig<4_zKj^Pb4HZal}h}iDh^sPkNMlKXg)D+8CO<(^39vX>$ z5`Uh}%Vzl)a?Km~Chp&*&Qq@6qXgnm`$ksK|2nYcv3EnrVzQPRX!MPO&5+NzmYFRK zfL$Z*E;1adh4j1NCVkUg6=doo*-HK66nv(nHCK^t8p6YTg519v6g(4>c7MJ;`sf`4 zfQr)MBe#3an$y)AHrSe7LG zY!zsB(g`ODkDHkrYgcN1`rAHm1g$Lu!(`~De%uNUh<{7x?)1Lii?ub-KKzSMA=8)b z9H2lGSbt97UxG5#ymPz7JLSy^YPS=)L2>)O&2IL_T!Q(T>!mJ_-K0G}<}ye||lVl>hB4Lw+1 zMya5MZ2kgF6hP z?AdJh?w_|O)*1|(3FDEZB(5VC*zWci9sK(Atze-7PKEs7JdU~`3=ZyAN!h5CrfCai zL~+9>*s{na-1fWjTo-4>(edK|U?!!)r#8e#X`s{7!7A8TzFu4M$UwGw^knpf;Q`?7BMO_E&x3PVeKj zTS!o~aYw7iwKXkCcL9H&81W}fBmc`@xMTkv(9q^Wo7b7Zuks&s05Uh<_mz4-6X~sMe z87Wzd{~=eVErDlBFQSy49V23vl;`H)by6^SSHQDwy$zzEZ`ddSJ0m3^<#gM;fI1*?yo2 zZ)?eT(9e?0qCq)@q8Fj=+ zeS4cPQTsM%k=tPf%p;vKCD5y`I1TmloO`{~dtf|-qM9+HMT=A*nKeuWuj zDEpM|cM`GuUHSY1L)2GyL=(y~Z&C0)e*XhJ3?@hk;M#NBZzvVN-CaMz1Z>-IE?+EA z7#sOW1R_!{yTOE-ZId7I_MT;@#SHAH))Zg<*yszZ&vm6lpU%Ctc&g;RhMJV{aWIQY zyYG68(3>(cYf%*lND6;7$go{J;_>Ch>2B-hPH-aUWI30lea;(acX?1{Sy@AxtNIC; z){gEfv(3|X{C57KE3sEJJQ&$md>qAhYl~c*3qi!A>}srWev78fp~n%sF+{gYzidY~ z3}6C724?;zX#+YOPe2V&*!rSHlZ`c|_oBz-!;CKRN%2>bt?B$Zw)a&MkNR%J%AcxV zy~XhT?lTWa4@|V2bciq_NEOfI_}m7Rdzg)+>|3gHK{UzupO;0XKD(k)$px29Beai3 zZPV8q9hp_bN#uV$0%eRJCB9mAW}Y~sepvj~6}+H!ucBO0S&4@PBlICf{$j8J_SUYa zlkL@D@eRrBy-ky;UHX>2+T&!Q#JODBZ1L@o2%>&Tzx+I|^E^aUXFXAGz-WH&Nin|K z=3ZG^YJ2M$1|Wl9i-(8fntoBaE5P7pIc973W5ZiBm?C@dzdh1v3S*lu7bR7oDLsYS ziG`Qj)J6CfEIdP_>CJ@N>1lrFHC(7JxSDXTvfF!DKMC>^8snZ8YUsI(em9A|?>}De z=Po2;Qm_boz8DHMF=U-~!_(JH-C>({ztiK|1AI|U5BWhM1yBPTzN?M4H&L2qCgP&< z&?|cT7_znZ(I>6F#_Y4>Vgo*bdqTyapr>I`7Pzysby#1C%v|{UH&+5?E_LuJ1SPYB z(EUkTEpl66u+=vYXL#C>|AgS!8(PtxQolJJqTl0R#)Co^&k+B^C z$0_oNQwiO(;&CkbB4ooYr!(YblZd=5$8?=lKb6jIhNqBxI|3!v1ChT4Z+b8#$kT5K zI*s%zRC)@T(>KdCIjJ!*%gmt6O=GeM8$c5Q@lX2`l?l-m+7fWV5E0k>L??9tNrK#^ zE-%Ppgp<0h1e86|ojC+$k?@$A)qU|G0b_Q5t?};8jL1(H={$i^DkqO6j)+ta&>A)j zZ84m97BS}eJqu(K8Nz#H&Pjh98dtqA|`@N?3k;pp{nBA7$ zC>TV?2QO8&dvTL@Kn1K5W%`+2E@lvZHnWx9&&bXfp{_K()z4f}Rq`10^s$j9;x7_MR`FltQ7$y*2xOV0udMx5dA#0q+hgXi{aL}O zs??Df#YgBS`=6Ju$N99qmsb72e1_mQO1PRiT{j#-Z-Lqe@wLsp@sYDqI$*nB)nx3o z2Jo{I>HAVbZ{4wG@M_dMDr7yVO=37q-9Y*q{I?4IPLeJS!N69uu~9{u!*S9 zJht!OqohKe-d{-vWc4g;H@}Xsnd&yvaPcsB&qYo$Mq?wvdD`?tG1ow z|8ce9y{)%Xy&2W^=BOKd-{_b~7k%z|MaYNcp@ z9v)HuTxZd1-oZY{P`|w?D@YU;WLt-R5rjnCyKlnDw0M8VfB3u<_fU`t+t(*fsAuY- z#LCX7#RLjJ8(LXe1q6sbz>|zl^XO<@FEo@dG&-;HK@oQ=fc3ySd?sqJ%fiBWU5a`M z`PK0SQH=?bD@yW{6$PHzY5bQ?zSR)@u`Wtf8L&DOGq(VT>_+sVlHpY7B=OK0mc<6) z?o^KqPb#qOBFdr<^ese{l*eo%EummAmQdLJPrO(Mwk5TO8akif&u?%rS15|LbxQl|eZG{_{nEkePr|nlOH8<=pXMVzYw77sUw;7NO3UNLww{Y&j zpW%^Culuua800sxPhRJ1t1Vwk%%1P;l1gsQmW~gT=<{I>BcU%lLr88-r%45SNXDyNVI}~+}5>vHkynU~CPc`x(X=3N&vNwB& z2}M9V`qzem9uixG#KJQ|rTeaJ~?3)L(I zUiE6BDSZ2KI7%nVjTftwtzjX#asq{lnXIoYv0U-S^`wnSlH8CbJZV zIEQ0Cn5X-*Hs^DbP?!N{ce1!~!5VK;HlZ+nUI$^AZe&F@6&wYWsRU3w&~BQMsBam@ zf7ho>-rvwyBw3Vagq+6h??|#4;r1(~=p3$@C2Sg>)7S?=R9!>ee>{AA=)_m)y^{R< zaG}Z(L40#n=CX(z$v;DJ#j}eoWAKs5)!i}eoI#U{UAryg#4(N29&>w@Xu=U!>FXDoz(%2Q!J-wc)0!h#Niw5JJf!5J&mb6+gyS zzIX3|*VBPDI#@Nl7$73$!wpj7fKkHcV1*J2f6I#JyRA%G{NX+q`2!}Lq_(jGVeA;o zs3^hrh+tUOV*B(H#MxThx~-ueHQ_=aiw4BO-hRHy;2BEK)DDFBp;{K5n(wIm``2lO zvaDQLNA4owo(e@?r;<*djcgLkTB9BK0J5()`_zW2nOcM}YresNt2drcLdG`G>=Vhn zcxmoWR9tG}G1! z82@n!m*2)5w;LrYe*PvXBm(reI2mDPiSYTTCPe<3Am?&0mY6R*_#Dciz`;VXeqt8H z0sz1?`A;nXU*qGR1Rs(rz;w%575tQ{oij8KyK&J;8c7 zHcVSrM(zD?OApaUE(%{fl4W#;2d%%fx*Ce>%b~pgRyazV=h=(hW?TS{pENU8rFR6J zJ&5PiE{X*So(_72p{CK*EQWaC)n#sEJVz`tVg>Fq|MmC!dd>n_wHyJvnXo9;nV$+ta&aVf3rU{9bxQCO&^&0g06*ySS{%!0 zto*?Yt+(Fq3d8_whGiPM0f-526rT_G6^^G#j5*(~OSF9{Cp73* zeycAbA?a6M;_GINs}II@)$mC;E7ZMzoLF?H?%CC+?hH*~?%nZ9olGOJ{DUr? zC#3~Djm|j?<15cd^t15X1w@L#3n}LQ98P#`nVuY=?AK|b#D9esgy>wJ@nv;wy0LT0 zVu6+3MVe&Ss>fy`OQRDj374!x?X-Npub@pERqko=vDze}8cU)+CxC(@)?$^q^9rbE z(r?PPF;Qu)W<`v_&UIpHws>E;*$v#zgD98$e%#{qH-PcF2E3PQS5#siMBw{8#m61P znW4Wt^kFD^MkdS_#iaH(8c6=2Xwp8-`TB1YT7$7+Xv(sW8q!{nUj3)Tiqm`-O8-d9 z$jBgI2q}P}fckG&5PNG;A}7q-!|S~ZTyX6U+0E;-I)zZ@c0~eua5Z)}>L|S5cwz_X zLq(F>Q-eN(7yik^WY-(UcO3dvpFGJPiXRE)n?bE@lJK27<^$5Swcb|zX0s8o`OKt| z@2Jqg@J+BgM5pG9Uv7|ulw;O4C+Uh-)#oAPw(eM24;>GPNtB|IISddI)l zN=d{GS~K7Y1A{u0Sdq=33c7kgnr2P@j->wh;0nzf;57o&e~Bo8s_p{aA@G#oRI@0w zR(cK!ibh@_MP~o{+YSe9ck0iS6sB_|IAF!67EKyD%;0ZexZ|BDoZMwRS)9YAj-i-+ z+j~yES2O?Wn>n8FVDHPspV`8mktN_~3qQ-{~ z^*v(mHToZjNuNCB|CAv8__5u_JX(3^R#oN|=JiF49jYBg zS`(7rTAK|!t*4->c@L!@iUx*;sbf#a#ojOW2D4m#tZLWGR3p+5=7Dh`H|E52m(pcCkt^5TBC;R>1l{(-^p zH`HD8R-BDOD}es#3IL$)5}bTocVr*l^K6B_(M<`>@K#+SL7CBs$?<<0HI~lil){|tSQtmz83en{b{acn6=c_Hu%a{PA#n4$O=|om2t|GTTNThpTaJ{wJ zCdP_Lli2oq)cz8-I)SpO`SRJ`y7x0sG&efdtlGSObevqP^=nCI>pc6ea1`4=`JkiEU?vzisR zV8T=3#Nh5~*s|Qgjqnf{04$KbJLmuhqRRs0oDRZSTrI6|87{&B@j1s%qYu!pR_kIbwS;_lTRWUZB7q(_bAuqe6#RUq$v=f%!SFR{vM3-9|cXafE@w&k;)I9>6 zjzq!IC~dM}T(q`#hvV4??(~_NAKrPjKg7GR0@N_JR11g2f?FKDeoQYVJZV?j?H3k1 z(IQZQF8FG)zAV)n40|S!sdzVkSaF)S)X);##uU{q4ghFgK0zW`<3Rezr~g&n*-zJ zN3H6VQA6=<&q+kpz3ljT$W7d>5wU>osQM|dz7s}zXnA)(-_rEWHdd;w>nesDU9O%^ zUaB^9e}4;-K%iB?dutsvUF=Ui-C%d#XaBOx?zGA7jAIb$@{aqdE&`1hzlT*3jUyo+)8S9$ON4NkL-Xq=Oe~i^bh!P|3S-rr7Ve z$!zC8%CFM`ajRixW1!f>z$|Hd;F^Fs0v%<>R+N-PPjxXpBE{w$b+`Qb%0^Qhi0+#aX&LwFU5ztY)Vp;Q6jR-PHc&rPkuEehXoe zxG<=Pe|>IYA?B{g6zCZ;St(my&K1|rN?|aqi-UWf14!!!yjGA&E7=Z5?fH^yAxFs z8>cJQi>+k=KX%zHIEDM&)o8NE);-ERlsu&l>U3zF`&pNkx$t?N7hdFN1!<3wDOsfS zhXnNQeB7+DfSwE{k5ImOLVk1@=|~vZL-Y)b&z>O=BkSK%P>^RkOS%{OXhJf2mW{#& z%EqJA&fCDX_!9mR4EV~fu2oinFEWib7z@dh1dq8~AWuD=^Q%1`Ijoei(9wT_V8Qbx zPvPz?QX)d>LzUnnryA9mhYe_|eCAWn%T3KL7RK8K&(^WZw?21Ed^%@lwBwfA5MZFCr0j0 zi0ayfd3e=D=!Cx;EDyvJqg5ed#;IahusaRi9=|X`Q8(E<7FJA5jLmq~5^Ekxq@%4m zhdztjP?w5`dYb|nVRtPVSZU(~{oo(F&m}ZV9b9C#WN&CNOcS&9s51M*HTOi)g_yvr zIPs-tp5KuSUXdW1nwRe7l-ga>MyM>w#fn~t!6VeIYd5%BnLeQ3iIkG#c28tom$)fT zlI&XTy*TiR;C8!fDpkAE^`Gt6RN9r=#@;kiS@tJ_Ca;w1-7X@NP41>+UKn+<0A z$LS@kh}%#>pIng#}!9Hf|;3_|I4C)=bWvXR3sas2#CZP^8t*LLqku0eL2P9 z&y^=KCq<-#vp(v4o0mR4Q;Rna|Iw87!YIj?@52;XtBopTttL=3@`@f7-t&A0 zPjjI;ENt=NAx&Q%sn*qaYb%(3waxQ*wR{1JWw>X#?(p$VTHOzI#+e0~y*`>oP%F57 z&8ahcRdKmUVr~)4K(9m$19T#0J~V$o?Q0LXE{3+&)+Yt4T}z}XU2V~Tr>ou3*49>N zb=;!SZbr{GYKjNP!|ZTKnziwj?(rwbk*WH-qB8FKrPGYcIJ*XS|JhjYh29WM-~$iH z#8z;7U2vy}yOS_-WI+P)qA3`8J51NF|T>!Kx M$f$m-k}?bVKQWeKL;wH) literal 0 HcmV?d00001 diff --git a/app/templates/gerador_chave.html b/app/templates/gerador_chave.html new file mode 100644 index 0000000..f6d9042 --- /dev/null +++ b/app/templates/gerador_chave.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% block titulo %}Gerador de Chave de Reativação{% endblock %} +{% block conteudo %} +
+ GTI Grupo +

Gerador de Chave de Reativação

+ + + + + + + + + + + + +
+ + +{% endblock %} diff --git a/tests/test_router_chave_manual.py b/tests/test_router_chave_manual.py new file mode 100644 index 0000000..d4a786a --- /dev/null +++ b/tests/test_router_chave_manual.py @@ -0,0 +1,24 @@ +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) + + +def test_pagina_gerador_carrega(): + resposta = client.get("/gerador-chave") + assert resposta.status_code == 200 + assert "gerarReativacao" in resposta.text + assert "Gerador de Chave de Reativação" in resposta.text + + +def test_pagina_gerador_nao_grava_nada(): + # Rota é GET-only: não existe POST /gerador-chave, então não há como + # este router gravar em Clientes_Chaves. + resposta = client.post("/gerador-chave") + assert resposta.status_code == 405 From 145f784577d8039dbc48c8d3a84508ce5435cf4a Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 10:33:29 -0300 Subject: [PATCH 13/87] feat: monta app final (main.py) e Docker end-to-end --- app/main.py | 21 +++++++++++++++++++++ tests/test_main.py | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 app/main.py create mode 100644 tests/test_main.py diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..d2f4e7d --- /dev/null +++ b/app/main.py @@ -0,0 +1,21 @@ +""" +Painel de Clientes — monta os routers de CLIENTE, CLIENTE_CONTATO e o +gerador de chave manual. Ver docs/superpowers/specs/2026-08-13-painel-clientes-web-design.md. +""" +from fastapi import Depends, FastAPI +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles + +from .auth import exigir_login +from .routers import chave_manual, clientes, contatos + +app = FastAPI(title="Painel de Clientes") +app.mount("/static", StaticFiles(directory="app/static"), name="static") +app.include_router(clientes.router) +app.include_router(contatos.router) +app.include_router(chave_manual.router) + + +@app.get("/") +def raiz(usuario: str = Depends(exigir_login)): + return RedirectResponse(url="/clientes") diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..d20ca84 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,21 @@ +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_raiz_sem_login_pede_credenciais(): + resposta = client.get("/", follow_redirects=False) + assert resposta.status_code == 401 + + +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: []) + import base64 + token = base64.b64encode(b"admin:teste123").decode() + resposta = client.get("/", headers={"Authorization": f"Basic {token}"}, follow_redirects=False) + assert resposta.status_code == 307 + assert resposta.headers["location"] == "/clientes" From 0d839949fecc6a2090813f92f816b5cc05af7062 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 10:46:23 -0300 Subject: [PATCH 14/87] fix: validacao de formulario, auth fail-closed, loop de confirmacao, hardening e docs (achados da revisao final) - Fix 1 (critico): cliente_form.html agora tem required/type por campo (date/number) e o router captura DataError/IntegrityError em criar/editar, reexibindo o form com erro_gravacao em vez de 500. - Fix 2 (importante): auth.py falha fechado quando ADMIN_USER/ ADMIN_PASSWORD nao estao configurados (nao tenta mais compare_digest com string vazia). - Fix 3 (importante): avisos de codigo duplicado e DV invalido agora carregam a flag de confirmacao um do outro adiante, quebrando o loop quando ambos disparam no mesmo cliente. - Fix 4 (menor): corrige precedencia 'or 42' -> 'and 42' no lambda de teste de criar_cliente. - Fix 5 (menor): desabilita /docs, /redoc, /openapi.json no FastAPI(). - Fix 6 (menor): comentario explicando o valor de Prorrogacao no fixture de teste de chaves. - Fix 7 (menor): .env.example usa host.docker.internal por padrao, docker-compose.yml ganha extra_hosts para Linux, README.md novo com quickstart real. Co-Authored-By: Claude Sonnet 5 --- .env.example | 7 +-- README.md | 37 +++++++++++++ app/auth.py | 7 +++ app/main.py | 2 +- app/routers/clientes.py | 34 ++++++++++-- app/templates/cliente_form.html | 15 +++++- docker-compose.yml | 2 + tests/test_auth.py | 9 ++++ tests/test_db_chaves.py | 2 + tests/test_main.py | 6 +++ tests/test_router_clientes.py | 93 ++++++++++++++++++++++++++++++++- 11 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 README.md diff --git a/.env.example b/.env.example index 0ad832f..1f2dd60 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,10 @@ ADMIN_USER=admin ADMIN_PASSWORD=SUBSTITUA_POR_UMA_SENHA_FORTE # Banco de dev (mesmo container do projeto n8n / contatos-chave-web): -# DB_HOST=host.docker.internal (se este app tambem rodar em container) -# DB_HOST=127.0.0.1 (se este app rodar direto na maquina) -DB_HOST=127.0.0.1 +# 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5c4475 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# 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. + +## 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` — veja `.env.example`) diretamente no shell antes de subir a aplicaçã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`. diff --git a/app/auth.py b/app/auth.py index 9bbbc26..68ba7c3 100644 --- a/app/auth.py +++ b/app/auth.py @@ -15,6 +15,13 @@ def exigir_login(credenciais: HTTPBasicCredentials = Depends(_security)) -> str: usuario_esperado = os.environ.get("ADMIN_USER", "") senha_esperada = os.environ.get("ADMIN_PASSWORD", "") + if not usuario_esperado or not senha_esperada: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuário ou senha inválidos.", + headers={"WWW-Authenticate": "Basic"}, + ) + usuario_ok = secrets.compare_digest(credenciais.username, usuario_esperado) senha_ok = secrets.compare_digest(credenciais.password, senha_esperada) diff --git a/app/main.py b/app/main.py index d2f4e7d..b44e859 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,7 @@ from fastapi.staticfiles import StaticFiles from .auth import exigir_login from .routers import chave_manual, clientes, contatos -app = FastAPI(title="Painel de Clientes") +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(clientes.router) app.include_router(contatos.router) diff --git a/app/routers/clientes.py b/app/routers/clientes.py index bcec73c..bb0384a 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -6,6 +6,7 @@ Rotas de gestão de CLIENTE: listar/buscar, criar, editar, ver detalhe from typing import Optional from urllib.parse import urlencode +import pymysql from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates @@ -59,12 +60,17 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): form = dict((await request.form())) dados = _ler_form_cliente(form) confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1" + confirmar_dv = form.get("confirmar_dv_invalido") == "1" if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"]): return templates.TemplateResponse( request, "cliente_form.html", - {"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, + { + "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, + "aviso_codigo_duplicado": True, + "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, + }, ) documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"]) @@ -73,14 +79,25 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): dv_invalido = not ( validar_dv_cpf(documento_normalizado) if tipo == "CPF" else validar_dv_cnpj(documento_normalizado) ) - if dv_invalido and form.get("confirmar_dv_invalido") != "1": + if dv_invalido and not confirmar_dv: return templates.TemplateResponse( request, "cliente_form.html", - {"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_dv_invalido": True}, + { + "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, + "aviso_dv_invalido": True, + "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, + }, ) - novo_id = db_clientes.criar_cliente(dados) + try: + novo_id = db_clientes.criar_cliente(dados) + except (pymysql.err.DataError, pymysql.err.IntegrityError) as exc: + return templates.TemplateResponse( + request, + "cliente_form.html", + {"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc)}, + ) return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.") @@ -133,7 +150,14 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi {"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, ) - db_clientes.atualizar_cliente(cliente_id, dados) + try: + db_clientes.atualizar_cliente(cliente_id, dados) + except (pymysql.err.DataError, pymysql.err.IntegrityError) as exc: + return templates.TemplateResponse( + request, + "cliente_form.html", + {"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc)}, + ) return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.") diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html index d1e38da..aebc6a6 100644 --- a/app/templates/cliente_form.html +++ b/app/templates/cliente_form.html @@ -9,6 +9,7 @@
{% for campo, valor in dados.items() %}{% endfor %} +
@@ -19,14 +20,26 @@
{% for campo, valor in dados.items() %}{% endfor %} +
{% endif %} + {% if erro_gravacao %} +
+ Não foi possível salvar: {{ erro_gravacao }} +
+ {% endif %}
{% for campo in campos %} - + {% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %} + + {% elif campo in ["IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA"] %} + + {% else %} + + {% endif %} {% endfor %}
diff --git a/docker-compose.yml b/docker-compose.yml index 8e8d2a6..dba63b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,4 +5,6 @@ services: - "8010:8000" env_file: - .env + extra_hosts: + - "host.docker.internal:host-gateway" restart: unless-stopped diff --git a/tests/test_auth.py b/tests/test_auth.py index 20e1f84..93194f8 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -36,3 +36,12 @@ def test_credenciais_corretas_retorna_200(): resposta = client.get("/protegido", headers=_basic("admin", "teste123")) assert resposta.status_code == 200 assert resposta.json() == {"usuario": "admin"} + + +def test_admin_user_e_senha_vazios_nao_autentica_com_credenciais_vazias(monkeypatch): + # Sem ADMIN_USER/ADMIN_PASSWORD configurados, o painel não pode ficar + # aberto para uma requisição com usuário/senha vazios (Basic Og==). + monkeypatch.setenv("ADMIN_USER", "") + monkeypatch.setenv("ADMIN_PASSWORD", "") + resposta = client.get("/protegido", headers=_basic("", "")) + assert resposta.status_code == 401 diff --git a/tests/test_db_chaves.py b/tests/test_db_chaves.py index 4a455a1..fcf3f27 100644 --- a/tests/test_db_chaves.py +++ b/tests/test_db_chaves.py @@ -14,6 +14,8 @@ 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 diff --git a/tests/test_main.py b/tests/test_main.py index d20ca84..825bcf5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -10,6 +10,12 @@ def test_raiz_sem_login_pede_credenciais(): assert resposta.status_code == 401 +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 diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index 16f9983..d5907ef 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -7,6 +7,7 @@ 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() @@ -32,7 +33,7 @@ def test_criar_cliente_codigo_duplicado_exige_confirmacao(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) or 42 + router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42 ) dados_form = {campo: "000001" if campo == "CODIGO" else "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} @@ -79,3 +80,93 @@ def test_excluir_cliente_com_dependencia_mostra_erro(monkeypatch): resposta = client.post("/clientes/1/excluir", follow_redirects=True) assert resposta.status_code == 200 assert "tem contato" 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_nao_gera_500(): + """ + Integração com o banco real de dev: DATA_CONTRATO/decimais são NOT NULL + e o dev roda em STRICT_TRANS_TABLES — submeter strings vazias deve ser + capturado pelo router (pymysql.err.DataError) e reexibir o formulário + com erro_gravacao, nunca estourar como 500. + """ + codigo = "999906" + dados_form = {campo: "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} + dados_form.update( + { + "CODIGO": codigo, + "CNPJ": "11222333000181", + "IMPLANTACAO": "", + "MENSALIDADE": "", + "JUROS_MORA": "", + "JUROS_MULTA": "", + "DATA_CONTRATO": "", + "VENCIMENTO_CONTRATO": "", + "DIA_VENCIMENTO": "10", + } + ) + try: + resposta = client.post("/clientes/novo", data=dados_form) + assert resposta.status_code == 200 + assert "Não foi possível salvar" in resposta.text + finally: + _limpar_codigo(codigo) + + +def test_criar_cliente_com_ambos_avisos_confirma_ambos_de_uma_vez(monkeypatch): + """ + Reproduz Fix 3: um cliente com CODIGO duplicado E DV de documento + inválido não pode ficar preso em loop entre os dois avisos — confirmar + os dois de uma vez (hidden fields carregados de um aviso para o outro) + deve efetivamente criar o cliente. + """ + 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 = {campo: "000002" if campo == "CODIGO" else "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} + dados_form.update( + { + "IMPLANTACAO": "0", + "MENSALIDADE": "100", + "JUROS_MORA": "0", + "JUROS_MULTA": "0", + "DATA_CONTRATO": "2026-01-01", + "VENCIMENTO_CONTRATO": "2027-01-01", + "DIA_VENCIMENTO": "10", + "CNPJ": "11144477734", # CPF com DV inválido (1 dígito diferente do válido "...735") + } + ) + + # Round 1: nem codigo nem dv confirmados -> deve pedir confirmação de código duplicado. + 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 + + # Round 2: confirma o código duplicado -> agora deve pedir confirmação do DV inválido. + dados_form["confirmar_codigo_duplicado"] = "1" + 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 + # A raiz do Fix 3: o form do aviso de DV precisa carregar + # confirmar_codigo_duplicado=1 adiante (senão confirmar só o DV a seguir + # reabriria o aviso de código duplicado, em loop infinito). + assert 'name="confirmar_codigo_duplicado" value="1"' in resposta.text + + # Round 3: confirma ambos de uma vez -> deve criar (303), sem cair em outro loop. + 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 From 9b04877d59281ac26a08360550b9aa08a234eca4 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 10:52:50 -0300 Subject: [PATCH 15/87] feat: formulario de edicao de contato na tela de detalhe do cliente Adiciona UI (details/summary, sem JS) para editar nome/telefone/email de um contato existente, usando a rota POST /clientes/{id}/contatos/{id}/editar que ja existia no router e no db mas nunca tinha formulario associado. Cobre com testes de rota (sucesso + telefone duplicado) e um teste verificando que a tela de detalhe realmente renderiza o formulario. --- app/templates/cliente_detalhe.html | 12 +++++++++++ tests/test_router_clientes.py | 20 ++++++++++++++++++ tests/test_router_contatos.py | 33 ++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/app/templates/cliente_detalhe.html b/app/templates/cliente_detalhe.html index 057bbfb..f9dc7e7 100644 --- a/app/templates/cliente_detalhe.html +++ b/app/templates/cliente_detalhe.html @@ -18,6 +18,18 @@ {{ ct.NOME }}{{ ct.TELEFONE }}{{ ct.EMAIL or "" }} +
+ Editar +
+ + + + + + + +
+
diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index d5907ef..f74768a 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -29,6 +29,26 @@ def test_listar_clientes(monkeypatch): assert "Cliente Um" 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_exige_confirmacao(monkeypatch): monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: True) criado = {} diff --git a/tests/test_router_contatos.py b/tests/test_router_contatos.py index af3e724..f36901a 100644 --- a/tests/test_router_contatos.py +++ b/tests/test_router_contatos.py @@ -39,6 +39,39 @@ def test_adicionar_contato_duplicado_avisa(monkeypatch): 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"}, + ) + assert resposta.status_code == 303 + assert chamadas["dados"] == (5, 1, "Fulano Editado", "75999090002", "fulano@example.com") + + +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"}, + 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 = {} From 96cbd8a294628b82cbd8e2d8f1cf98f26a652521 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 10:54:37 -0300 Subject: [PATCH 16/87] fix: normalizar CNPJ/telefone de CLIENTE antes de gravar (sem mascara) CLIENTE.CNPJ/TELEFONE/CELULAR agora sao gravados sempre sem pontuacao (so digitos, e letras no caso do CNPJ alfanumerico), igual ao que CLIENTE_CONTATO.TELEFONE ja fazia. Resolve a inconsistencia de formato apontada na revisao final do plano (achado 7): duas tabelas do mesmo app guardando telefone/documento em formatos diferentes. So afeta escritas novas feitas pelo painel (criar/editar cliente) - registros legados existentes so ficam sem mascara quando forem reeditados e salvos aqui; nao faz backfill em massa dos dados atuais. --- app/routers/clientes.py | 18 +++++++++- tests/test_router_clientes.py | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/app/routers/clientes.py b/app/routers/clientes.py index bb0384a..306221f 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -15,7 +15,7 @@ from ..auth import exigir_login from ..db import chaves as db_chaves from ..db import clientes as db_clientes from ..db import contatos as db_contatos -from ..normalize import normalizar_documento, validar_dv_cpf, validar_dv_cnpj +from ..normalize import normalizar_documento, normalizar_telefone, validar_dv_cpf, validar_dv_cnpj router = APIRouter(prefix="/clientes") templates = Jinja2Templates(directory="app/templates") @@ -30,6 +30,20 @@ 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 e telefones sempre sem máscara (só dígitos/letras), + igual ao que CLIENTE_CONTATO.TELEFONE já faz — evita que CLIENTE fique + com dois formatos diferentes convivendo (achado da revisão final: + docs/superpowers/plans/2026-08-13-painel-clientes-web.md). Só afeta + escritas novas feitas por este painel; registros legados só passam a + ficar sem máscara quando forem reeditados e salvos aqui. + """ + dados["CNPJ"] = normalizar_documento(dados["CNPJ"])[0] + dados["TELEFONE"] = normalizar_telefone(dados["TELEFONE"]) + dados["CELULAR"] = normalizar_telefone(dados["CELULAR"]) + + @router.get("", response_class=HTMLResponse) def listar( request: Request, @@ -59,6 +73,7 @@ def form_novo(request: Request, usuario: str = Depends(exigir_login)): 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_duplicado = form.get("confirmar_codigo_duplicado") == "1" confirmar_dv = form.get("confirmar_dv_invalido") == "1" @@ -141,6 +156,7 @@ def form_editar(request: Request, cliente_id: int, usuario: str = Depends(exigir async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)): form = dict((await request.form())) dados = _ler_form_cliente(form) + _normalizar_documento_e_telefones(dados) confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1" if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"], ignorar_id=cliente_id): diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index f74768a..c571ed5 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -83,6 +83,71 @@ def test_criar_cliente_codigo_duplicado_exige_confirmacao(monkeypatch): assert criado.get("ok") is True +def test_criar_cliente_normaliza_cnpj_e_telefones(monkeypatch): + monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False) + capturado = {} + monkeypatch.setattr( + router_clientes.db_clientes, "criar_cliente", lambda dados: capturado.setdefault("dados", dados) and 42 + ) + + dados_form = {campo: "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} + dados_form.update( + { + "CODIGO": "000001", + "CNPJ": "11.222.333/0001-81", + "TELEFONE": "(75) 3333-4444", + "CELULAR": "(75) 99909-1780", + "IMPLANTACAO": "0", + "MENSALIDADE": "100", + "JUROS_MORA": "0", + "JUROS_MULTA": "0", + "DATA_CONTRATO": "2026-01-01", + "VENCIMENTO_CONTRATO": "2027-01-01", + "DIA_VENCIMENTO": "10", + "confirmar_dv_invalido": "1", + } + ) + + resposta = client.post("/clientes/novo", data=dados_form) + assert resposta.status_code == 303 + assert capturado["dados"]["CNPJ"] == "11222333000181" + assert capturado["dados"]["TELEFONE"] == "7533334444" + assert capturado["dados"]["CELULAR"] == "75999091780" + + +def test_editar_cliente_normaliza_cnpj_e_telefones(monkeypatch): + monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: False) + capturado = {} + monkeypatch.setattr( + router_clientes.db_clientes, + "atualizar_cliente", + lambda cliente_id, dados: capturado.setdefault("dados", dados), + ) + + dados_form = {campo: "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} + dados_form.update( + { + "CODIGO": "000001", + "CNPJ": "111.444.777-35", + "TELEFONE": "(75) 3333-4444", + "CELULAR": "(75) 99909-1780", + "IMPLANTACAO": "0", + "MENSALIDADE": "100", + "JUROS_MORA": "0", + "JUROS_MULTA": "0", + "DATA_CONTRATO": "2026-01-01", + "VENCIMENTO_CONTRATO": "2027-01-01", + "DIA_VENCIMENTO": "10", + } + ) + + resposta = client.post("/clientes/1/editar", data=dados_form) + assert resposta.status_code == 303 + assert capturado["dados"]["CNPJ"] == "11144477735" + assert capturado["dados"]["TELEFONE"] == "7533334444" + assert capturado["dados"]["CELULAR"] == "75999091780" + + def test_excluir_cliente_com_dependencia_mostra_erro(monkeypatch): monkeypatch.setattr( router_clientes.db_clientes, From bb8fd596b19c23f62f1c127621f80a77e57a4b22 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 11:02:17 -0300 Subject: [PATCH 17/87] feat: protecao CSRF (double-submit cookie) e paginacao da lista de clientes CSRF: novo app/csrf.py + middleware em app/main.py garantem um cookie csrf_token por navegador; todo formulario que muda estado (criar/editar/ excluir cliente, adicionar/editar/excluir contato) embute esse valor como campo oculto, e Depends(exigir_csrf) confere no POST que o valor enviado bate com o cookie antes de deixar a rota executar. Verificado ponta a ponta com o app real rodando (curl): token errado -> 403, token correto -> 303. Paginacao: db_clientes.listar_clientes ganha offset, nova contar_clientes(); GET /clientes aceita ?pagina=N (100 clientes por pagina), template mostra Anterior/Proxima e o total. 53/53 testes passando (8 novos). --- README.md | 2 ++ app/csrf.py | 44 +++++++++++++++++++++++ app/db/clientes.py | 28 ++++++++++++--- app/main.py | 24 ++++++++++++- app/routers/clientes.py | 56 +++++++++++++++++++++++------- app/routers/contatos.py | 7 ++-- app/templates/cliente_detalhe.html | 4 +++ app/templates/cliente_form.html | 3 ++ app/templates/clientes_lista.html | 11 ++++++ tests/test_main.py | 41 +++++++++++++++++++--- tests/test_router_clientes.py | 34 ++++++++++++++++-- tests/test_router_contatos.py | 24 ++++++++++--- 12 files changed, 247 insertions(+), 31 deletions(-) create mode 100644 app/csrf.py diff --git a/README.md b/README.md index b5c4475..0213e1c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 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 HTTP Basic em toda rota, 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). + ## Rodando localmente (sem Docker) 1. Instale as dependências de desenvolvimento: diff --git a/app/csrf.py b/app/csrf.py new file mode 100644 index 0000000..f57940f --- /dev/null +++ b/app/csrf.py @@ -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.") diff --git a/app/db/clientes.py b/app/db/clientes.py index c291904..6ae113f 100644 --- a/app/db/clientes.py +++ b/app/db/clientes.py @@ -21,7 +21,7 @@ class ClienteComDependenciasError(Exception): """Levantada ao tentar excluir um CLIENTE que ainda tem CLIENTE_CONTATO.""" -def listar_clientes(busca: str | None = None, limite: int = 100) -> list[dict]: +def listar_clientes(busca: str | None = None, limite: int = 100, offset: int = 0) -> list[dict]: conn = get_connection() try: with conn.cursor() as cur: @@ -29,17 +29,37 @@ def listar_clientes(busca: str | None = None, limite: int = 100) -> list[dict]: sql = ( f"SELECT {_COLUNAS_SELECT} FROM CLIENTE " "WHERE CODIGO LIKE %s OR CNPJ LIKE %s OR RAZAO LIKE %s OR FANTASIA LIKE %s " - "ORDER BY RAZAO LIMIT %s" + "ORDER BY RAZAO LIMIT %s OFFSET %s" ) termo = f"%{busca}%" - cur.execute(sql, (termo, termo, termo, termo, limite)) + cur.execute(sql, (termo, termo, termo, termo, limite, offset)) else: - cur.execute(f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY RAZAO LIMIT %s", (limite,)) + cur.execute( + f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY RAZAO LIMIT %s OFFSET %s", (limite, offset) + ) return cur.fetchall() finally: conn.close() +def contar_clientes(busca: str | None = None) -> int: + conn = get_connection() + try: + with conn.cursor() as cur: + if busca: + sql = ( + "SELECT COUNT(*) AS total FROM CLIENTE " + "WHERE CODIGO LIKE %s OR CNPJ LIKE %s OR RAZAO LIKE %s OR FANTASIA LIKE %s" + ) + termo = f"%{busca}%" + cur.execute(sql, (termo, termo, termo, termo)) + else: + cur.execute("SELECT COUNT(*) AS total FROM CLIENTE") + return cur.fetchone()["total"] + finally: + conn.close() + + def obter_cliente(cliente_id: int) -> dict | None: conn = get_connection() try: diff --git a/app/main.py b/app/main.py index b44e859..b662809 100644 --- a/app/main.py +++ b/app/main.py @@ -2,11 +2,12 @@ Painel de Clientes — monta os routers de CLIENTE, CLIENTE_CONTATO e o gerador de chave manual. Ver docs/superpowers/specs/2026-08-13-painel-clientes-web-design.md. """ -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, Request from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from .auth import exigir_login +from .csrf import CSRF_COOKIE_NAME, gerar_token from .routers import chave_manual, clientes, contatos app = FastAPI(title="Painel de Clientes", docs_url=None, redoc_url=None, openapi_url=None) @@ -16,6 +17,27 @@ app.include_router(contatos.router) app.include_router(chave_manual.router) +@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" + ) + return response + + @app.get("/") def raiz(usuario: str = Depends(exigir_login)): return RedirectResponse(url="/clientes") diff --git a/app/routers/clientes.py b/app/routers/clientes.py index 306221f..1c7b98d 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -1,8 +1,9 @@ # app/routers/clientes.py """ -Rotas de gestão de CLIENTE: listar/buscar, criar, editar, ver detalhe -(contatos + status de Clientes_Chaves), excluir. +Rotas de gestão de CLIENTE: listar/buscar (paginado), criar, editar, ver +detalhe (contatos + status de Clientes_Chaves), excluir. """ +import math from typing import Optional from urllib.parse import urlencode @@ -12,6 +13,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates 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 @@ -20,6 +22,8 @@ from ..normalize import normalizar_documento, normalizar_telefone, validar_dv_cp router = APIRouter(prefix="/clientes") templates = Jinja2Templates(directory="app/templates") +TAMANHO_PAGINA = 100 + def _redirect_com_mensagem(url: str, mensagem: str, tipo: str = "success") -> RedirectResponse: separador = "&" if "?" in url else "?" @@ -48,15 +52,25 @@ def _normalizar_documento_e_telefones(dados: dict) -> None: def listar( request: Request, busca: Optional[str] = None, + pagina: int = 1, mensagem: Optional[str] = None, mensagem_tipo: Optional[str] = None, usuario: str = Depends(exigir_login), ): - clientes = db_clientes.listar_clientes(busca=busca or None) + pagina = max(pagina, 1) + total = db_clientes.contar_clientes(busca=busca or None) + total_paginas = max(math.ceil(total / TAMANHO_PAGINA), 1) + pagina = min(pagina, total_paginas) + offset = (pagina - 1) * TAMANHO_PAGINA + + clientes = db_clientes.listar_clientes(busca=busca or None, limite=TAMANHO_PAGINA, offset=offset) return templates.TemplateResponse( request, "clientes_lista.html", - {"clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo}, + { + "clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo, + "pagina": pagina, "total_paginas": total_paginas, "total": total, + }, ) @@ -65,11 +79,14 @@ def form_novo(request: Request, usuario: str = Depends(exigir_login)): return templates.TemplateResponse( request, "cliente_form.html", - {"cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE}, + { + "cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE, + "csrf_token": token_do_contexto(request), + }, ) -@router.post("/novo") +@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) @@ -85,6 +102,7 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True, "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, + "csrf_token": token_do_contexto(request), }, ) @@ -102,6 +120,7 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_dv_invalido": True, "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, + "csrf_token": token_do_contexto(request), }, ) @@ -111,7 +130,10 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): return templates.TemplateResponse( request, "cliente_form.html", - {"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc)}, + { + "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc), + "csrf_token": token_do_contexto(request), + }, ) return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.") @@ -136,6 +158,7 @@ def detalhe( { "cliente": cliente, "contatos": contatos, "status_chaves": status, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo, + "csrf_token": token_do_contexto(request), }, ) @@ -148,11 +171,14 @@ def form_editar(request: Request, cliente_id: int, usuario: str = Depends(exigir return templates.TemplateResponse( request, "cliente_form.html", - {"cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE}, + { + "cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE, + "csrf_token": token_do_contexto(request), + }, ) -@router.post("/{cliente_id}/editar") +@router.post("/{cliente_id}/editar", dependencies=[Depends(exigir_csrf)]) async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)): form = dict((await request.form())) dados = _ler_form_cliente(form) @@ -163,7 +189,10 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi return templates.TemplateResponse( request, "cliente_form.html", - {"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, + { + "cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, + "aviso_codigo_duplicado": True, "csrf_token": token_do_contexto(request), + }, ) try: @@ -172,12 +201,15 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi return templates.TemplateResponse( request, "cliente_form.html", - {"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc)}, + { + "cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, + "erro_gravacao": str(exc), "csrf_token": token_do_contexto(request), + }, ) return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.") -@router.post("/{cliente_id}/excluir") +@router.post("/{cliente_id}/excluir", dependencies=[Depends(exigir_csrf)]) def excluir(cliente_id: int, usuario: str = Depends(exigir_login)): try: db_clientes.excluir_cliente(cliente_id) diff --git a/app/routers/contatos.py b/app/routers/contatos.py index 008d4e2..d16d689 100644 --- a/app/routers/contatos.py +++ b/app/routers/contatos.py @@ -9,6 +9,7 @@ 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 ..normalize import normalizar_telefone @@ -21,7 +22,7 @@ def _redirect_com_mensagem(cliente_id: int, mensagem: str, tipo: str = "success" return RedirectResponse(f"/clientes/{cliente_id}?{query}", status_code=303) -@router.post("/{cliente_id}/contatos") +@router.post("/{cliente_id}/contatos", dependencies=[Depends(exigir_csrf)]) def adicionar( cliente_id: int, nome: str = Form(""), @@ -43,7 +44,7 @@ def adicionar( return _redirect_com_mensagem(cliente_id, "Contato adicionado.") -@router.post("/{cliente_id}/contatos/{contato_id}/editar") +@router.post("/{cliente_id}/contatos/{contato_id}/editar", dependencies=[Depends(exigir_csrf)]) def editar( cliente_id: int, contato_id: int, @@ -63,7 +64,7 @@ def editar( return _redirect_com_mensagem(cliente_id, "Contato atualizado.") -@router.post("/{cliente_id}/contatos/{contato_id}/excluir") +@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)): db_contatos.excluir_contato(contato_id, cliente_id) return _redirect_com_mensagem(cliente_id, "Contato excluído.") diff --git a/app/templates/cliente_detalhe.html b/app/templates/cliente_detalhe.html index f9dc7e7..e104f6b 100644 --- a/app/templates/cliente_detalhe.html +++ b/app/templates/cliente_detalhe.html @@ -7,6 +7,7 @@

Editar

+

@@ -21,6 +22,7 @@
Editar
+ @@ -31,6 +33,7 @@
+
@@ -38,6 +41,7 @@ {% endfor %}
+ diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html index aebc6a6..f70abcf 100644 --- a/app/templates/cliente_form.html +++ b/app/templates/cliente_form.html @@ -7,6 +7,7 @@
O código {{ dados.CODIGO }} já está em uso por outro cliente. + {% for campo, valor in dados.items() %}{% endfor %} @@ -18,6 +19,7 @@
O dígito verificador do documento ({{ dados.CNPJ }}) não confere — confirme se digitou certo antes de salvar. + {% for campo, valor in dados.items() %}{% endfor %} @@ -31,6 +33,7 @@
{% endif %} + {% for campo in campos %} {% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %} diff --git a/app/templates/clientes_lista.html b/app/templates/clientes_lista.html index 76ac82b..53555ee 100644 --- a/app/templates/clientes_lista.html +++ b/app/templates/clientes_lista.html @@ -20,4 +20,15 @@ {% endfor %} + {% if total_paginas and total_paginas > 1 %} +

+ {% if pagina > 1 %} + ← Anterior + {% endif %} + Página {{ pagina }} de {{ total_paginas }} ({{ total }} clientes) + {% if pagina < total_paginas %} + Próxima → + {% endif %} +

+ {% endif %} {% endblock %} diff --git a/tests/test_main.py b/tests/test_main.py index 825bcf5..ae2bcfb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,8 +1,11 @@ +import base64 + from fastapi.testclient import TestClient from app.main import app client = TestClient(app) +_AUTH_HEADERS = {"Authorization": f"Basic {base64.b64encode(b'admin:teste123').decode()}"} def test_raiz_sem_login_pede_credenciais(): @@ -19,9 +22,39 @@ def test_docs_desabilitado_retorna_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: []) - import base64 - token = base64.b64encode(b"admin:teste123").decode() - resposta = client.get("/", headers={"Authorization": f"Basic {token}"}, follow_redirects=False) + monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", lambda busca=None, limite=100, offset=0: []) + monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None: 0) + resposta = client.get("/", headers=_AUTH_HEADERS, 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", headers=_AUTH_HEADERS, 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 + + resposta = client.post( + "/clientes/1/excluir", headers=_AUTH_HEADERS, data={"csrf_token": token_cookie}, follow_redirects=False + ) + assert resposta.status_code == 303 diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index c571ed5..a26e09f 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -14,21 +14,46 @@ 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) def test_listar_clientes(monkeypatch): monkeypatch.setattr( router_clientes.db_clientes, "listar_clientes", - lambda busca=None, limite=100: [ + lambda busca=None, limite=100, offset=0: [ {"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Um", "FANTASIA": "Um", "CNPJ": "11222333000181"} ], ) + monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None: 1) resposta = client.get("/clientes") assert resposta.status_code == 200 assert "Cliente Um" 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: [ + {"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: 250) + + resposta = client.get("/clientes?pagina=2") + assert resposta.status_code == 200 + assert "Página 2 de 3" in resposta.text + assert "250 clientes" in resposta.text + assert "Anterior" in resposta.text + assert "Próxima" in resposta.text + + def test_detalhe_cliente_mostra_formulario_de_edicao_de_contato(monkeypatch): monkeypatch.setattr( router_clientes.db_clientes, @@ -67,6 +92,7 @@ def test_criar_cliente_codigo_duplicado_exige_confirmacao(monkeypatch): "VENCIMENTO_CONTRATO": "2027-01-01", "DIA_VENCIMENTO": "10", "CNPJ": "11222333000181", + "csrf_token": CSRF_TESTE, } ) @@ -105,6 +131,7 @@ def test_criar_cliente_normaliza_cnpj_e_telefones(monkeypatch): "VENCIMENTO_CONTRATO": "2027-01-01", "DIA_VENCIMENTO": "10", "confirmar_dv_invalido": "1", + "csrf_token": CSRF_TESTE, } ) @@ -138,6 +165,7 @@ def test_editar_cliente_normaliza_cnpj_e_telefones(monkeypatch): "DATA_CONTRATO": "2026-01-01", "VENCIMENTO_CONTRATO": "2027-01-01", "DIA_VENCIMENTO": "10", + "csrf_token": CSRF_TESTE, } ) @@ -162,7 +190,7 @@ def test_excluir_cliente_com_dependencia_mostra_erro(monkeypatch): monkeypatch.setattr(router_clientes.db_clientes, "excluir_cliente", _excluir) - resposta = client.post("/clientes/1/excluir", follow_redirects=True) + 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 @@ -196,6 +224,7 @@ def test_criar_cliente_com_data_e_decimais_vazios_nao_gera_500(): "DATA_CONTRATO": "", "VENCIMENTO_CONTRATO": "", "DIA_VENCIMENTO": "10", + "csrf_token": CSRF_TESTE, } ) try: @@ -230,6 +259,7 @@ def test_criar_cliente_com_ambos_avisos_confirma_ambos_de_uma_vez(monkeypatch): "VENCIMENTO_CONTRATO": "2027-01-01", "DIA_VENCIMENTO": "10", "CNPJ": "11144477734", # CPF com DV inválido (1 dígito diferente do válido "...735") + "csrf_token": CSRF_TESTE, } ) diff --git a/tests/test_router_contatos.py b/tests/test_router_contatos.py index f36901a..8a4d0f7 100644 --- a/tests/test_router_contatos.py +++ b/tests/test_router_contatos.py @@ -10,6 +10,11 @@ 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) def test_adicionar_contato_com_sucesso(monkeypatch): @@ -22,7 +27,9 @@ def test_adicionar_contato_com_sucesso(monkeypatch): 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"}) + resposta = client.post( + "/clientes/1/contatos", data={"nome": "Fulano", "telefone": "(75) 99909-0001", "csrf_token": CSRF_TESTE} + ) assert resposta.status_code == 303 assert chamadas["dados"] == (1, "Fulano", "75999090001", None) @@ -35,7 +42,11 @@ def test_adicionar_contato_duplicado_avisa(monkeypatch): 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"}, follow_redirects=True) + 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 @@ -52,7 +63,10 @@ def test_editar_contato_com_sucesso(monkeypatch): resposta = client.post( "/clientes/1/contatos/5/editar", - data={"nome": "Fulano Editado", "telefone": "(75) 99909-0002", "email": "fulano@example.com"}, + 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", "75999090002", "fulano@example.com") @@ -66,7 +80,7 @@ def test_editar_contato_duplicado_avisa(monkeypatch): resposta = client.post( "/clientes/1/contatos/5/editar", - data={"nome": "Fulano", "telefone": "75999090001"}, + data={"nome": "Fulano", "telefone": "75999090001", "csrf_token": CSRF_TESTE}, follow_redirects=True, ) assert "já está cadastrado" in resposta.text @@ -79,6 +93,6 @@ def test_excluir_contato(monkeypatch): router_contatos.db_contatos, "excluir_contato", lambda contato_id, cid: chamadas.setdefault("dados", (contato_id, cid)) ) - resposta = client.post("/clientes/1/contatos/5/excluir") + resposta = client.post("/clientes/1/contatos/5/excluir", data={"csrf_token": CSRF_TESTE}) assert resposta.status_code == 303 assert chamadas["dados"] == (5, 1) From 9ed0bf57c232ebfe226e59047e75a59cd4527cbe Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 14:04:24 -0300 Subject: [PATCH 18/87] feat: log de acoes/envios, ordenacao/filtro, codigo sequencial, campos opcionais MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tabela LOG_PAINEL (nova, ja criada no banco de dev) + app/db/log.py: toda criacao/edicao/exclusao de cliente e contato fica registrada (usuario, acao, sucesso/erro, detalhe). Tela /log mostra esse historico. - app/n8n_client.py: leitura opcional (N8N_API_URL/N8N_API_KEY) da Data Table pagamentos_processados do n8n, mostrando os envios automaticos de chave na mesma tela /log. Degrada de forma segura (lista vazia) se nao configurado ou se a chamada falhar. - Lista de clientes ganha ordenacao configuravel (codigo/razao/ fantasia/CNPJ) via whitelist, alem da busca que ja existia. - CODIGO duplicado agora BLOQUEIA de verdade na criacao/edicao (sem bypass) — antes so avisava com opcao de confirmar mesmo assim. - Formulario de novo cliente sugere o proximo CODIGO sequencial (editavel), ignorando a faixa reservada para dados de teste (>= 900000) para nao ser contaminado por sobra de teste no banco. - IMPLANTACAO/MENSALIDADE/JUROS_MORA/JUROS_MULTA/DATA_CONTRATO/ VENCIMENTO_CONTRATO deixam de ser obrigatorios — em branco, gravam default (0,00 / hoje) em vez de bloquear o cadastro. Só CODIGO/CNPJ/ RAZAO/FANTASIA continuam obrigatorios. - Limpeza: removidas 10 linhas de teste vazadas (CODIGO 999902) que ficaram no banco de dev por sessoes de teste interrompidas. 69/69 testes passando (16 novos). --- .env.example | 7 + README.md | 6 +- app/db/clientes.py | 44 +++++- app/db/log.py | 67 +++++++++ app/main.py | 3 +- app/n8n_client.py | 52 +++++++ app/routers/clientes.py | 136 +++++++++++------ app/routers/contatos.py | 6 + app/routers/log.py | 26 ++++ app/templates/base.html | 1 + app/templates/cliente_form.html | 23 +-- app/templates/clientes_lista.html | 11 +- app/templates/log.html | 55 +++++++ requirements.txt | 1 + tests/test_db_clientes.py | 22 +++ tests/test_db_log.py | 42 ++++++ tests/test_n8n_client.py | 56 +++++++ tests/test_router_clientes.py | 241 ++++++++++++++++++------------ tests/test_router_contatos.py | 8 + tests/test_router_log.py | 52 +++++++ 20 files changed, 689 insertions(+), 170 deletions(-) create mode 100644 app/db/log.py create mode 100644 app/n8n_client.py create mode 100644 app/routers/log.py create mode 100644 app/templates/log.html create mode 100644 tests/test_db_log.py create mode 100644 tests/test_n8n_client.py create mode 100644 tests/test_router_log.py diff --git a/.env.example b/.env.example index 1f2dd60..d8ecdd7 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,10 @@ DB_PORT=3307 DB_NAME=gtisoftw_clientes_dev DB_USER=root DB_PASSWORD=devpassword123 + +# 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= diff --git a/README.md b/README.md index 0213e1c..8723308 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ 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 HTTP Basic em toda rota, 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). +Proteções: login HTTP Basic em toda rota, 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). + +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). ## Rodando localmente (sem Docker) diff --git a/app/db/clientes.py b/app/db/clientes.py index 6ae113f..8fdd2a3 100644 --- a/app/db/clientes.py +++ b/app/db/clientes.py @@ -16,12 +16,25 @@ CAMPOS_CLIENTE = [ _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" + 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) -> list[dict]: +def listar_clientes( + busca: str | None = None, limite: int = 100, offset: int = 0, ordenar_por: str = ORDENACAO_PADRAO +) -> list[dict]: + coluna_ordenacao = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO]) conn = get_connection() try: with conn.cursor() as cur: @@ -29,19 +42,44 @@ def listar_clientes(busca: str | None = None, limite: int = 100, offset: int = 0 sql = ( f"SELECT {_COLUNAS_SELECT} FROM CLIENTE " "WHERE CODIGO LIKE %s OR CNPJ LIKE %s OR RAZAO LIKE %s OR FANTASIA LIKE %s " - "ORDER BY RAZAO LIMIT %s OFFSET %s" + f"ORDER BY {coluna_ordenacao} LIMIT %s OFFSET %s" ) termo = f"%{busca}%" cur.execute(sql, (termo, termo, termo, termo, limite, offset)) else: cur.execute( - f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY RAZAO LIMIT %s OFFSET %s", (limite, offset) + f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY {coluna_ordenacao} LIMIT %s OFFSET %s", + (limite, offset), ) return 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) -> int: conn = get_connection() try: diff --git a/app/db/log.py b/app/db/log.py new file mode 100644 index 0000000..61969d3 --- /dev/null +++ b/app/db/log.py @@ -0,0 +1,67 @@ +""" +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() diff --git a/app/main.py b/app/main.py index b662809..096fce0 100644 --- a/app/main.py +++ b/app/main.py @@ -8,13 +8,14 @@ from fastapi.staticfiles import StaticFiles from .auth import exigir_login from .csrf import CSRF_COOKIE_NAME, gerar_token -from .routers import chave_manual, clientes, contatos +from .routers import chave_manual, clientes, contatos, log 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(clientes.router) app.include_router(contatos.router) app.include_router(chave_manual.router) +app.include_router(log.router) @app.middleware("http") diff --git a/app/n8n_client.py b/app/n8n_client.py new file mode 100644 index 0000000..2d70fd4 --- /dev/null +++ b/app/n8n_client.py @@ -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={"take": 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 [] diff --git a/app/routers/clientes.py b/app/routers/clientes.py index 1c7b98d..23110cf 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -1,8 +1,9 @@ # app/routers/clientes.py """ -Rotas de gestão de CLIENTE: listar/buscar (paginado), criar, editar, ver -detalhe (contatos + status de Clientes_Chaves), excluir. +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 @@ -17,6 +18,7 @@ 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, validar_dv_cpf, validar_dv_cnpj router = APIRouter(prefix="/clientes") @@ -24,6 +26,13 @@ templates = Jinja2Templates(directory="app/templates") TAMANHO_PAGINA = 100 +# Ú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"] + def _redirect_com_mensagem(url: str, mensagem: str, tipo: str = "success") -> RedirectResponse: separador = "&" if "?" in url else "?" @@ -38,37 +47,63 @@ def _normalizar_documento_e_telefones(dados: dict) -> None: """ Grava CNPJ/CPF e telefones sempre sem máscara (só dígitos/letras), igual ao que CLIENTE_CONTATO.TELEFONE já faz — evita que CLIENTE fique - com dois formatos diferentes convivendo (achado da revisão final: - docs/superpowers/plans/2026-08-13-painel-clientes-web.md). Só afeta - escritas novas feitas por este painel; registros legados só passam a - ficar sem máscara quando forem reeditados e salvos aqui. + com dois formatos diferentes convivendo. Só afeta escritas novas feitas + por este painel; registros legados só passam a ficar sem máscara quando + forem reeditados e salvos aqui. """ dados["CNPJ"] = normalizar_documento(dados["CNPJ"])[0] dados["TELEFONE"] = normalizar_telefone(dados["TELEFONE"]) dados["CELULAR"] = normalizar_telefone(dados["CELULAR"]) +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)] + + @router.get("", response_class=HTMLResponse) def listar( request: Request, busca: Optional[str] = None, + ordenar_por: str = db_clientes.ORDENACAO_PADRAO, pagina: int = 1, 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 + pagina = max(pagina, 1) total = db_clientes.contar_clientes(busca=busca or None) total_paginas = max(math.ceil(total / TAMANHO_PAGINA), 1) pagina = min(pagina, total_paginas) offset = (pagina - 1) * TAMANHO_PAGINA - clientes = db_clientes.listar_clientes(busca=busca or None, limite=TAMANHO_PAGINA, offset=offset) + clientes = db_clientes.listar_clientes( + busca=busca or None, limite=TAMANHO_PAGINA, offset=offset, ordenar_por=ordenar_por + ) return templates.TemplateResponse( request, "clientes_lista.html", { - "clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo, + "clientes": clientes, "busca": busca, "ordenar_por": ordenar_por, + "mensagem": mensagem, "mensagem_tipo": mensagem_tipo, "pagina": pagina, "total_paginas": total_paginas, "total": total, }, ) @@ -76,12 +111,14 @@ def listar( @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() return templates.TemplateResponse( request, "cliente_form.html", { - "cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE, - "csrf_token": token_do_contexto(request), + "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, + "campos_obrigatorios": CAMPOS_OBRIGATORIOS, "csrf_token": token_do_contexto(request), }, ) @@ -91,19 +128,23 @@ 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_duplicado = form.get("confirmar_codigo_duplicado") == "1" confirmar_dv = form.get("confirmar_dv_invalido") == "1" - if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"]): + 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", - { - "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, - "aviso_codigo_duplicado": True, - "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, - "csrf_token": token_do_contexto(request), - }, + 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} ) documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"]) @@ -114,27 +155,18 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): ) if dv_invalido and not confirmar_dv: return templates.TemplateResponse( - request, - "cliente_form.html", - { - "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, - "aviso_dv_invalido": True, - "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, - "csrf_token": token_do_contexto(request), - }, + request, "cliente_form.html", {**contexto_base, "aviso_dv_invalido": True} ) + _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", - { - "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc), - "csrf_token": token_do_contexto(request), - }, + 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.") @@ -173,7 +205,7 @@ def form_editar(request: Request, cliente_id: int, usuario: str = Depends(exigir "cliente_form.html", { "cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE, - "csrf_token": token_do_contexto(request), + "campos_obrigatorios": CAMPOS_OBRIGATORIOS, "csrf_token": token_do_contexto(request), }, ) @@ -183,29 +215,33 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi form = dict((await request.form())) dados = _ler_form_cliente(form) _normalizar_documento_e_telefones(dados) - confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1" - if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"], ignorar_id=cliente_id): + 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", - { - "cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, - "aviso_codigo_duplicado": True, "csrf_token": token_do_contexto(request), - }, + 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} + ) + + _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", - { - "cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, - "erro_gravacao": str(exc), "csrf_token": token_do_contexto(request), - }, + 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.") @@ -214,5 +250,7 @@ def excluir(cliente_id: int, usuario: str = Depends(exigir_login)): try: 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") + db_log.registrar(usuario, "excluir", "cliente", str(cliente_id), True) return _redirect_com_mensagem("/clientes", "Cliente excluído.") diff --git a/app/routers/contatos.py b/app/routers/contatos.py index d16d689..3e324de 100644 --- a/app/routers/contatos.py +++ b/app/routers/contatos.py @@ -12,6 +12,7 @@ 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 router = APIRouter(prefix="/clientes") @@ -38,9 +39,11 @@ def adicionar( 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.") @@ -58,13 +61,16 @@ def editar( 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") db_contatos.atualizar_contato(contato_id, cliente_id, nome.strip() or None, telefone_normalizado, email.strip() or None) + 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)): db_contatos.excluir_contato(contato_id, cliente_id) + db_log.registrar(usuario, "excluir", "contato", str(contato_id), True) return _redirect_com_mensagem(cliente_id, "Contato excluído.") diff --git a/app/routers/log.py b/app/routers/log.py new file mode 100644 index 0000000..8d929ab --- /dev/null +++ b/app/routers/log.py @@ -0,0 +1,26 @@ +""" +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). +""" +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from .. import n8n_client +from ..auth import exigir_login +from ..db import log as db_log + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/log", response_class=HTMLResponse) +def pagina(request: Request, usuario: str = Depends(exigir_login)): + acoes = db_log.listar(limite=100) + envios = n8n_client.listar_envios(limite=50) + return templates.TemplateResponse( + request, + "log.html", + {"acoes": acoes, "envios": envios, "envios_configurado": n8n_client.configurado()}, + ) diff --git a/app/templates/base.html b/app/templates/base.html index aee3555..94bf5e3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -26,6 +26,7 @@
{% if mensagem %}
{{ mensagem }}
{% endif %} diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html index f70abcf..5c6701b 100644 --- a/app/templates/cliente_form.html +++ b/app/templates/cliente_form.html @@ -3,16 +3,9 @@ {% block titulo %}{{ "Editar" if cliente else "Novo" }} cliente{% endblock %} {% block conteudo %}

{{ "Editar" if cliente else "Novo" }} cliente

- {% if aviso_codigo_duplicado %} -
- O código {{ dados.CODIGO }} já está em uso por outro cliente. - - - {% for campo, valor in dados.items() %}{% endfor %} - - - - + {% if erro_codigo_duplicado %} +
+ O código {{ dados.CODIGO }} já está em uso por outro cliente — escolha outro código antes de salvar.
{% endif %} {% if aviso_dv_invalido %} @@ -22,7 +15,6 @@ {% for campo, valor in dados.items() %}{% endfor %} -
@@ -35,15 +27,16 @@
{% for campo in campos %} - + {% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %} - + {% elif campo in ["IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA"] %} - + {% else %} - + {% endif %} {% endfor %} +

* 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.

{% endblock %} diff --git a/app/templates/clientes_lista.html b/app/templates/clientes_lista.html index 53555ee..2be3e0a 100644 --- a/app/templates/clientes_lista.html +++ b/app/templates/clientes_lista.html @@ -5,6 +5,13 @@

Clientes

+ +

+ Novo cliente

@@ -23,11 +30,11 @@ {% if total_paginas and total_paginas > 1 %}

{% if pagina > 1 %} - ← Anterior + ← Anterior {% endif %} Página {{ pagina }} de {{ total_paginas }} ({{ total }} clientes) {% if pagina < total_paginas %} - Próxima → + Próxima → {% endif %}

{% endif %} diff --git a/app/templates/log.html b/app/templates/log.html new file mode 100644 index 0000000..6511ffa --- /dev/null +++ b/app/templates/log.html @@ -0,0 +1,55 @@ + +{% extends "base.html" %} +{% block titulo %}Log{% endblock %} +{% block conteudo %} +

Log

+

← Clientes

+ +

Ações no painel

+ {% if acoes %} + + + {% for a in acoes %} + + + + + + + + + {% endfor %} +
QuandoUsuárioAçãoEntidadeStatusDetalhe
{{ a.CRIADO_EM }}{{ a.USUARIO }}{{ a.ACAO }}{{ a.ENTIDADE }}{% if a.ENTIDADE_ID %} #{{ a.ENTIDADE_ID }}{% endif %}{{ "OK" if a.SUCESSO else "ERRO" }}{{ a.DETALHE or "" }}
+ {% else %} +

Nenhuma ação registrada ainda.

+ {% endif %} + +

Envios automáticos de chave (n8n)

+ {% if not envios_configurado %} +

+ Integração com o n8n não configurada — defina N8N_API_URL e + N8N_API_KEY no ambiente (ver .env.example) para ver + aqui os envios automáticos de chave feitos pelo workflow do n8n. +

+ {% elif not envios %} +

Nenhum envio encontrado (ou não foi possível consultar o n8n agora).

+ {% else %} + + + + + + {% for e in envios %} + + + + + + + + + + {% endfor %} +
DataClienteCódigoCompetênciaStatusTelefones enviadosTelefones com erro
{{ e.data_processamento }}{{ e.nome_cliente }}{{ e.codigo_cliente }}{{ e.competencia }}{{ e.status }}{{ e.telefones_enviados }}{{ e.telefones_com_erro or "" }}
+ {% endif %} +{% endblock %} diff --git a/requirements.txt b/requirements.txt index b8e9fbe..9459e0f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ jinja2==3.1.4 python-multipart==0.0.9 PyMySQL==1.1.1 python-dotenv==1.0.1 +httpx==0.27.2 diff --git a/tests/test_db_clientes.py b/tests/test_db_clientes.py index 676e1e9..081465a 100644 --- a/tests/test_db_clientes.py +++ b/tests/test_db_clientes.py @@ -87,6 +87,28 @@ def test_excluir_cliente_sem_dependencias(cliente_id): assert db_clientes.obter_cliente(cliente_id) is None +def test_listar_clientes_ordena_por_codigo(cliente_id): + resultado = db_clientes.listar_clientes(busca="TESTE PAINEL RAZAO", 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_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_excluir_cliente_com_contato_bloqueia(cliente_id): conn = get_connection() try: diff --git a/tests/test_db_log.py b/tests/test_db_log.py new file mode 100644 index 0000000..3262c76 --- /dev/null +++ b/tests/test_db_log.py @@ -0,0 +1,42 @@ +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 diff --git a/tests/test_n8n_client.py b/tests/test_n8n_client.py new file mode 100644 index 0000000..2cf3ccf --- /dev/null +++ b/tests/test_n8n_client.py @@ -0,0 +1,56 @@ +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 + 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" diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index a26e09f..e16e849 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -3,6 +3,7 @@ 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 @@ -21,11 +22,42 @@ 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) + + +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", + "csrf_token": CSRF_TESTE, + } + ) + return dados + + def test_listar_clientes(monkeypatch): monkeypatch.setattr( router_clientes.db_clientes, "listar_clientes", - lambda busca=None, limite=100, offset=0: [ + lambda busca=None, limite=100, offset=0, ordenar_por="razao": [ {"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Um", "FANTASIA": "Um", "CNPJ": "11222333000181"} ], ) @@ -39,7 +71,7 @@ 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: [ + lambda busca=None, limite=100, offset=0, ordenar_por="razao": [ {"ID": i, "CODIGO": f"{i:06d}", "RAZAO": f"Cliente {i}", "FANTASIA": f"C{i}", "CNPJ": "11222333000181"} for i in range(1, 6) ], @@ -54,6 +86,30 @@ def test_listar_clientes_pagina_com_controles_de_paginacao(monkeypatch): assert "Próxima" 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"): + 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: 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_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_detalhe_cliente_mostra_formulario_de_edicao_de_contato(monkeypatch): monkeypatch.setattr( router_clientes.db_clientes, @@ -74,39 +130,71 @@ def test_detalhe_cliente_mostra_formulario_de_edicao_de_contato(monkeypatch): assert 'value="75999090001"' in resposta.text -def test_criar_cliente_codigo_duplicado_exige_confirmacao(monkeypatch): +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 = {campo: "000001" if campo == "CODIGO" else "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} - dados_form.update( - { - "IMPLANTACAO": "0", - "MENSALIDADE": "100", - "JUROS_MORA": "0", - "JUROS_MULTA": "0", - "DATA_CONTRATO": "2026-01-01", - "VENCIMENTO_CONTRATO": "2027-01-01", - "DIA_VENCIMENTO": "10", - "CNPJ": "11222333000181", - "csrf_token": CSRF_TESTE, - } - ) + dados_form = _form_valido() - # Sem "confirmar_codigo_duplicado": não deve criar, deve pedir confirmação. 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 - # Com confirmação: cria. - dados_form["confirmar_codigo_duplicado"] = "1" + # 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) + 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) + 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 - assert criado.get("ok") is True + 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): @@ -116,22 +204,12 @@ def test_criar_cliente_normaliza_cnpj_e_telefones(monkeypatch): router_clientes.db_clientes, "criar_cliente", lambda dados: capturado.setdefault("dados", dados) and 42 ) - dados_form = {campo: "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} + dados_form = _form_valido() dados_form.update( { - "CODIGO": "000001", "CNPJ": "11.222.333/0001-81", "TELEFONE": "(75) 3333-4444", "CELULAR": "(75) 99909-1780", - "IMPLANTACAO": "0", - "MENSALIDADE": "100", - "JUROS_MORA": "0", - "JUROS_MULTA": "0", - "DATA_CONTRATO": "2026-01-01", - "VENCIMENTO_CONTRATO": "2027-01-01", - "DIA_VENCIMENTO": "10", - "confirmar_dv_invalido": "1", - "csrf_token": CSRF_TESTE, } ) @@ -151,21 +229,12 @@ def test_editar_cliente_normaliza_cnpj_e_telefones(monkeypatch): lambda cliente_id, dados: capturado.setdefault("dados", dados), ) - dados_form = {campo: "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} + dados_form = _form_valido() dados_form.update( { - "CODIGO": "000001", "CNPJ": "111.444.777-35", "TELEFONE": "(75) 3333-4444", "CELULAR": "(75) 99909-1780", - "IMPLANTACAO": "0", - "MENSALIDADE": "100", - "JUROS_MORA": "0", - "JUROS_MULTA": "0", - "DATA_CONTRATO": "2026-01-01", - "VENCIMENTO_CONTRATO": "2027-01-01", - "DIA_VENCIMENTO": "10", - "csrf_token": CSRF_TESTE, } ) @@ -176,6 +245,22 @@ def test_editar_cliente_normaliza_cnpj_e_telefones(monkeypatch): assert capturado["dados"]["CELULAR"] == "75999091780" +def test_editar_cliente_codigo_duplicado_bloqueia_sem_bypass(monkeypatch): + 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_excluir_cliente_com_dependencia_mostra_erro(monkeypatch): monkeypatch.setattr( router_clientes.db_clientes, @@ -204,83 +289,41 @@ def _limpar_codigo(codigo: str) -> None: conn.close() -def test_criar_cliente_com_data_e_decimais_vazios_nao_gera_500(): +def test_criar_cliente_com_data_e_decimais_vazios_cria_com_defaults(): """ - Integração com o banco real de dev: DATA_CONTRATO/decimais são NOT NULL - e o dev roda em STRICT_TRANS_TABLES — submeter strings vazias deve ser - capturado pelo router (pymysql.err.DataError) e reexibir o formulário - com erro_gravacao, nunca estourar como 500. + 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 = {campo: "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} - dados_form.update( - { - "CODIGO": codigo, - "CNPJ": "11222333000181", - "IMPLANTACAO": "", - "MENSALIDADE": "", - "JUROS_MORA": "", - "JUROS_MULTA": "", - "DATA_CONTRATO": "", - "VENCIMENTO_CONTRATO": "", - "DIA_VENCIMENTO": "10", - "csrf_token": CSRF_TESTE, - } - ) + 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 == 200 - assert "Não foi possível salvar" in resposta.text + assert resposta.status_code == 303 finally: _limpar_codigo(codigo) -def test_criar_cliente_com_ambos_avisos_confirma_ambos_de_uma_vez(monkeypatch): - """ - Reproduz Fix 3: um cliente com CODIGO duplicado E DV de documento - inválido não pode ficar preso em loop entre os dois avisos — confirmar - os dois de uma vez (hidden fields carregados de um aviso para o outro) - deve efetivamente criar o cliente. - """ - monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: True) +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) criado = {} monkeypatch.setattr( router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42 ) - dados_form = {campo: "000002" if campo == "CODIGO" else "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} - dados_form.update( - { - "IMPLANTACAO": "0", - "MENSALIDADE": "100", - "JUROS_MORA": "0", - "JUROS_MULTA": "0", - "DATA_CONTRATO": "2026-01-01", - "VENCIMENTO_CONTRATO": "2027-01-01", - "DIA_VENCIMENTO": "10", - "CNPJ": "11144477734", # CPF com DV inválido (1 dígito diferente do válido "...735") - "csrf_token": CSRF_TESTE, - } - ) + dados_form = _form_valido() + dados_form["CNPJ"] = "11144477734" # CPF com DV inválido (1 dígito diferente do válido "...735") - # Round 1: nem codigo nem dv confirmados -> deve pedir confirmação de código duplicado. - 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 - - # Round 2: confirma o código duplicado -> agora deve pedir confirmação do DV inválido. - dados_form["confirmar_codigo_duplicado"] = "1" + # 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 - # A raiz do Fix 3: o form do aviso de DV precisa carregar - # confirmar_codigo_duplicado=1 adiante (senão confirmar só o DV a seguir - # reabriria o aviso de código duplicado, em loop infinito). - assert 'name="confirmar_codigo_duplicado" value="1"' in resposta.text - # Round 3: confirma ambos de uma vez -> deve criar (303), sem cair em outro loop. + # Confirmando: cria. dados_form["confirmar_dv_invalido"] = "1" resposta = client.post("/clientes/novo", data=dados_form) assert resposta.status_code == 303 diff --git a/tests/test_router_contatos.py b/tests/test_router_contatos.py index 8a4d0f7..cc39327 100644 --- a/tests/test_router_contatos.py +++ b/tests/test_router_contatos.py @@ -1,3 +1,4 @@ +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -17,6 +18,13 @@ 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) diff --git a/tests/test_router_log.py b/tests/test_router_log.py new file mode 100644 index 0000000..cd64041 --- /dev/null +++ b/tests/test_router_log.py @@ -0,0 +1,52 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.auth import 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: "teste" +client = TestClient(app_teste, follow_redirects=False) + + +def test_pagina_log_mostra_acoes_do_painel(monkeypatch): + monkeypatch.setattr( + router_log.db_log, + "listar", + lambda limite=100: [ + { + "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 + + +def test_pagina_log_mostra_envios_quando_configurado(monkeypatch): + monkeypatch.setattr(router_log.db_log, "listar", lambda limite=100: []) + 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 From dad1abf30b404a894b64419de931a5d41795bc75 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 14:14:40 -0300 Subject: [PATCH 19/87] fix: parametro correto da API de rows do n8n (limit, nao take) Testado contra a instancia real (devn8n.gtigrupo.com.br) com uma API key de verdade: 'take' retorna 400 'Unknown query parameter'; o nome certo e 'limit'. Confirmado end-to-end - /log agora mostra os envios reais (ERRO_ENVIO, CLIENTE_NAO_CADASTRADO etc.) da Data Table pagamentos_processados. --- app/n8n_client.py | 2 +- tests/test_n8n_client.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/n8n_client.py b/app/n8n_client.py index 2d70fd4..1150a9b 100644 --- a/app/n8n_client.py +++ b/app/n8n_client.py @@ -37,7 +37,7 @@ def listar_envios(limite: int = 50) -> list[dict]: resposta = httpx.get( url, headers={"X-N8N-API-KEY": os.environ["N8N_API_KEY"], "accept": "application/json"}, - params={"take": limite, "sortBy": "data_processamento:desc"}, + params={"limit": limite, "sortBy": "data_processamento:desc"}, timeout=5.0, ) resposta.raise_for_status() diff --git a/tests/test_n8n_client.py b/tests/test_n8n_client.py index 2cf3ccf..84859f9 100644 --- a/tests/test_n8n_client.py +++ b/tests/test_n8n_client.py @@ -46,6 +46,7 @@ def test_listar_envios_sucesso_retorna_linhas(monkeypatch): 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) @@ -54,3 +55,6 @@ def test_listar_envios_sucesso_retorna_linhas(monkeypatch): 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"} From 8d83122e98477c0ff536858abeee35978c7cbb36 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 14:19:58 -0300 Subject: [PATCH 20/87] feat: login individual por funcionario (cadastro, senha com hash, ativar/desativar) - Tabela FUNCIONARIO_PAINEL (nova, ja criada no banco de dev) + app/db/funcionarios.py: nome/login/senha (hash scrypt, stdlib, sem dependencia nova) por funcionario, nunca excluido de verdade (so ativo/inativo). - app/senha.py: gerar_hash/verificar_senha com hashlib.scrypt + salt aleatorio por senha. - app/auth.py: login passa a checar duas fontes, nessa ordem - ADMIN_USER/ADMIN_PASSWORD (bootstrap, continua exigindo as duas preenchidas) e depois FUNCIONARIO_PAINEL (so contas ATIVO=1). O nome retornado (usado no log) passa a ser o nome de verdade do funcionario, nao mais sempre 'admin'. - Tela /funcionarios: listar, criar (com confirmacao de senha e checagem de login duplicado), editar nome/login, redefinir senha (form separado) e ativar/desativar. Tudo logado em LOG_PAINEL. - Testado ponta a ponta contra o app real: funcionario criado via tela consegue logar de verdade, e a acao aparece no /log com o nome de quem fez. 93/93 testes passando (23 novos). --- README.md | 6 ++ app/auth.py | 48 +++++---- app/db/funcionarios.py | 119 ++++++++++++++++++++ app/main.py | 3 +- app/routers/funcionarios.py | 148 +++++++++++++++++++++++++ app/senha.py | 31 ++++++ app/templates/base.html | 1 + app/templates/funcionario_form.html | 26 +++++ app/templates/funcionarios_lista.html | 37 +++++++ tests/test_auth.py | 44 ++++++++ tests/test_db_funcionarios.py | 78 ++++++++++++++ tests/test_router_funcionarios.py | 150 ++++++++++++++++++++++++++ tests/test_senha.py | 26 +++++ 13 files changed, 697 insertions(+), 20 deletions(-) create mode 100644 app/db/funcionarios.py create mode 100644 app/routers/funcionarios.py create mode 100644 app/senha.py create mode 100644 app/templates/funcionario_form.html create mode 100644 app/templates/funcionarios_lista.html create mode 100644 tests/test_db_funcionarios.py create mode 100644 tests/test_router_funcionarios.py create mode 100644 tests/test_senha.py diff --git a/README.md b/README.md index 8723308..8c1e8a8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ Proteções: login HTTP Basic em toda rota, CSRF via cookie de duplo envio (`app 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 por funcionário + +Cada funcionário pode ter seu próprio usuário/senha (tabela `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`; senha em hash `scrypt`, nunca texto puro). Gerencie em **Funcionários** no menu. + +`ADMIN_USER`/`ADMIN_PASSWORD` (`.env.example`) continuam funcionando como conta de bootstrap — é 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`). O log de ações mostra o nome de quem fez cada coisa. + ## Rodando localmente (sem Docker) 1. Instale as dependências de desenvolvimento: diff --git a/app/auth.py b/app/auth.py index 68ba7c3..d8dc3e2 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,6 +1,15 @@ """ -Login simples via HTTP Basic Auth, usuário/senha vindos de variável de -ambiente. Sem cadastro de usuários, sem perfis, sem sessão/cookie. +Login HTTP Basic. Duas fontes de credencial, checadas nessa ordem: + +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). +2. FUNCIONARIO_PAINEL (app/db/funcionarios.py) — conta individual por + funcionário, com senha em hash (app/senha.py). Só autentica se ATIVO=1. + +Sem sessão/cookie — o próprio navegador guarda a credencial Basic. """ import os import secrets @@ -8,27 +17,28 @@ import secrets from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBasic, HTTPBasicCredentials +from .db import funcionarios as db_funcionarios +from .senha import verificar_senha + _security = HTTPBasic() def exigir_login(credenciais: HTTPBasicCredentials = Depends(_security)) -> str: - usuario_esperado = os.environ.get("ADMIN_USER", "") - senha_esperada = os.environ.get("ADMIN_PASSWORD", "") + usuario_admin = os.environ.get("ADMIN_USER", "") + senha_admin = os.environ.get("ADMIN_PASSWORD", "") - if not usuario_esperado or not senha_esperada: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Usuário ou senha inválidos.", - headers={"WWW-Authenticate": "Basic"}, - ) + if usuario_admin and senha_admin: + usuario_ok = secrets.compare_digest(credenciais.username, usuario_admin) + senha_ok = secrets.compare_digest(credenciais.password, senha_admin) + if usuario_ok and senha_ok: + return credenciais.username - usuario_ok = secrets.compare_digest(credenciais.username, usuario_esperado) - senha_ok = secrets.compare_digest(credenciais.password, senha_esperada) + funcionario = db_funcionarios.obter_por_login(credenciais.username) + if funcionario and funcionario["ATIVO"] and verificar_senha(credenciais.password, funcionario["SENHA_HASH"]): + return funcionario["NOME"] - if not (usuario_ok and senha_ok): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Usuário ou senha inválidos.", - headers={"WWW-Authenticate": "Basic"}, - ) - return credenciais.username + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuário ou senha inválidos.", + headers={"WWW-Authenticate": "Basic"}, + ) diff --git a/app/db/funcionarios.py b/app/db/funcionarios.py new file mode 100644 index 0000000..55b26a5 --- /dev/null +++ b/app/db/funcionarios.py @@ -0,0 +1,119 @@ +""" +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, + 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; + +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, 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, 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, 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) -> int: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO FUNCIONARIO_PAINEL (NOME, LOGIN, SENHA_HASH, ATIVO) VALUES (%s, %s, %s, 1)", + (nome, login, gerar_hash(senha)), + ) + return cur.lastrowid + finally: + conn.close() + + +def atualizar_funcionario(funcionario_id: int, nome: str, login: str) -> None: + """Só nome/login — troca de senha é redefinir_senha().""" + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "UPDATE FUNCIONARIO_PAINEL SET NOME = %s, LOGIN = %s WHERE ID = %s", (nome, login, funcionario_id) + ) + finally: + conn.close() + + +def redefinir_senha(funcionario_id: int, nova_senha: str) -> None: + 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), + ) + 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() diff --git a/app/main.py b/app/main.py index 096fce0..236a3ea 100644 --- a/app/main.py +++ b/app/main.py @@ -8,13 +8,14 @@ from fastapi.staticfiles import StaticFiles from .auth import exigir_login from .csrf import CSRF_COOKIE_NAME, gerar_token -from .routers import chave_manual, clientes, contatos, log +from .routers import chave_manual, clientes, contatos, funcionarios, log 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(clientes.router) app.include_router(contatos.router) app.include_router(chave_manual.router) +app.include_router(funcionarios.router) app.include_router(log.router) diff --git a/app/routers/funcionarios.py b/app/routers/funcionarios.py new file mode 100644 index 0000000..de31e3c --- /dev/null +++ b/app/routers/funcionarios.py @@ -0,0 +1,148 @@ +""" +Rotas de gestão de FUNCIONARIO_PAINEL: listar, criar (com senha), editar +nome/login, redefinir senha, ativar/desativar (nunca exclui de verdade). +""" +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 exigir_login +from ..csrf import exigir_csrf, token_do_contexto +from ..db import funcionarios as db_funcionarios +from ..db import log as db_log + +router = APIRouter(prefix="/funcionarios") +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: str = Depends(exigir_login), +): + 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: str = Depends(exigir_login)): + 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(...), + usuario: str = Depends(exigir_login), +): + 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 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}, + "csrf_token": token_do_contexto(request), + }, + ) + + novo_id = db_funcionarios.criar_funcionario(nome, login, senha) + db_log.registrar(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: str = Depends(exigir_login)): + 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(...), + usuario: str = Depends(exigir_login), +): + nome = nome.strip() + login = login.strip() + + erro = None + if not nome or not login: + erro = "Preencha nome e login." + elif db_funcionarios.login_em_uso(login, ignorar_id=funcionario_id): + erro = f"O login \"{login}\" já está em uso." + + if erro: + return templates.TemplateResponse( + request, + "funcionario_form.html", + { + "funcionario": {"ID": funcionario_id}, "erro": erro, "dados": {"NOME": nome, "LOGIN": login}, + "csrf_token": token_do_contexto(request), + }, + ) + + db_funcionarios.atualizar_funcionario(funcionario_id, nome, login) + db_log.registrar(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: str = Depends(exigir_login), +): + if not nova_senha or nova_senha != confirmar_nova_senha: + db_log.registrar(usuario, "redefinir_senha", "funcionario", str(funcionario_id), False, "senhas não coincidem") + return _redirect_com_mensagem("As senhas digitadas não coincidem.", "danger") + + db_funcionarios.redefinir_senha(funcionario_id, nova_senha) + db_log.registrar(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: str = Depends(exigir_login)): + db_funcionarios.alternar_ativo(funcionario_id) + db_log.registrar(usuario, "alternar_ativo", "funcionario", str(funcionario_id), True) + return _redirect_com_mensagem("Status do funcionário atualizado.") diff --git a/app/senha.py b/app/senha.py new file mode 100644 index 0000000..9c6050d --- /dev/null +++ b/app/senha.py @@ -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$$". +""" +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) diff --git a/app/templates/base.html b/app/templates/base.html index 94bf5e3..2661d43 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -26,6 +26,7 @@
diff --git a/app/templates/funcionario_form.html b/app/templates/funcionario_form.html new file mode 100644 index 0000000..d7b246d --- /dev/null +++ b/app/templates/funcionario_form.html @@ -0,0 +1,26 @@ + +{% extends "base.html" %} +{% block titulo %}{{ "Editar" if funcionario else "Novo" }} funcionário{% endblock %} +{% block conteudo %} +

{{ "Editar" if funcionario else "Novo" }} funcionário

+ {% if erro %} +
{{ erro }}
+ {% endif %} +
+ + + + + + {% if not funcionario %} + + + + + {% endif %} + +
+ {% if funcionario %} +

Para trocar a senha, use "Redefinir senha" na lista de funcionários.

+ {% endif %} +{% endblock %} diff --git a/app/templates/funcionarios_lista.html b/app/templates/funcionarios_lista.html new file mode 100644 index 0000000..c98036d --- /dev/null +++ b/app/templates/funcionarios_lista.html @@ -0,0 +1,37 @@ + +{% extends "base.html" %} +{% block titulo %}Funcionários{% endblock %} +{% block conteudo %} +

Funcionários

+

+ Novo funcionário

+ + + {% for f in funcionarios %} + + + + + + + {% endfor %} +
NomeLoginStatus
{{ f.NOME }}{{ f.LOGIN }}{{ "Ativo" if f.ATIVO else "Inativo" }} + Editar +
+ Redefinir senha +
+ + + + + + +
+
+
+ + +
+
+{% endblock %} diff --git a/tests/test_auth.py b/tests/test_auth.py index 93194f8..2b56d44 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,9 +1,12 @@ import base64 +import pytest from fastapi import Depends, FastAPI from fastapi.testclient import TestClient from app.auth import exigir_login +from app.db import funcionarios as db_funcionarios +from app.db.connection import get_connection app_teste = FastAPI() @@ -45,3 +48,44 @@ def test_admin_user_e_senha_vazios_nao_autentica_com_credenciais_vazias(monkeypa monkeypatch.setenv("ADMIN_PASSWORD", "") resposta = client.get("/protegido", headers=_basic("", "")) assert resposta.status_code == 401 + + +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") + yield login + _limpar_login(login) + + +def test_funcionario_ativo_com_senha_correta_autentica(funcionario_teste): + resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaForte123")) + assert resposta.status_code == 200 + assert resposta.json() == {"usuario": "Fulano de Teste"} + + +def test_funcionario_com_senha_errada_nao_autentica(funcionario_teste): + resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaErrada")) + assert resposta.status_code == 401 + + +def test_funcionario_inativo_nao_autentica(funcionario_teste): + funcionario = db_funcionarios.obter_por_login(funcionario_teste) + db_funcionarios.alternar_ativo(funcionario["ID"]) # desativa + resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaForte123")) + assert resposta.status_code == 401 + + +def test_login_inexistente_nao_autentica(): + resposta = client.get("/protegido", headers=_basic("nao-existe-esse-login", "qualquer")) + assert resposta.status_code == 401 diff --git a/tests/test_db_funcionarios.py b/tests/test_db_funcionarios.py new file mode 100644 index 0000000..80b3446 --- /dev/null +++ b/tests/test_db_funcionarios.py @@ -0,0 +1,78 @@ +"""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") + 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 "SENHA_HASH" not in f # obter_funcionario não expõe o hash + + +def test_obter_por_login_inclui_senha_hash(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 + + +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): + logins = [f["LOGIN"] for f in db_funcionarios.listar_funcionarios()] + assert LOGIN_TESTE in logins + + +def test_atualizar_funcionario(funcionario_id): + novo_login = "teste.db.funcionarios.editado" + db_funcionarios.atualizar_funcionario(funcionario_id, "Fulano Editado", novo_login) + f = db_funcionarios.obter_funcionario(funcionario_id) + assert f["NOME"] == "Fulano Editado" + assert f["LOGIN"] == novo_login + + +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 diff --git a/tests/test_router_funcionarios.py b/tests/test_router_funcionarios.py new file mode 100644 index 0000000..99183ca --- /dev/null +++ b/tests/test_router_funcionarios.py @@ -0,0 +1,150 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.auth import 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: "teste" +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) + + +def test_listar_funcionarios(monkeypatch): + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "listar_funcionarios", + lambda: [{"ID": 1, "NOME": "Fulano", "LOGIN": "fulano", "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: criado.setdefault("dados", (nome, login, senha)) and 1, + ) + + resposta = client.post( + "/funcionarios/novo", + data={ + "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "senhaForte123", + "csrf_token": CSRF_TESTE, + }, + ) + assert resposta.status_code == 303 + assert criado["dados"] == ("Fulano", "fulano", "senhaForte123") + + +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: chamou.setdefault("ok", True), + ) + + resposta = client.post( + "/funcionarios/novo", + data={ + "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_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_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: chamou.setdefault("ok", True), + ) + + resposta = client.post( + "/funcionarios/novo", + data={ + "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "senhaForte123", + "csrf_token": CSRF_TESTE, + }, + ) + assert resposta.status_code == 200 + assert "já está em uso" in resposta.text + 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) + capturado = {} + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "atualizar_funcionario", + lambda fid, nome, login: capturado.setdefault("dados", (fid, nome, login)), + ) + + resposta = client.post( + "/funcionarios/1/editar", + data={"nome": "Fulano Editado", "login": "fulano.editado", "csrf_token": CSRF_TESTE}, + ) + assert resposta.status_code == 303 + assert capturado["dados"] == (1, "Fulano Editado", "fulano.editado") + + +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_alternar_ativo(monkeypatch): + 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 diff --git a/tests/test_senha.py b/tests/test_senha.py new file mode 100644 index 0000000..b6ab990 --- /dev/null +++ b/tests/test_senha.py @@ -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 From 2f1d0084041f14d6f2be36b64cd8688d82d933ee Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 14:24:09 -0300 Subject: [PATCH 21/87] fix: busca de clientes restrita ao campo escolhido em ordenar_por Antes, digitar qualquer coisa na busca comparava com CODIGO, CNPJ, RAZAO e FANTASIA ao mesmo tempo (OR), entao escolher 'Ordenar por: Codigo' e buscar '242' trazia tambem clientes cujo CNPJ ou Razao continham '242' em outro lugar. Agora a busca usa so a coluna selecionada no dropdown (mesma whitelist ja usada pra ordenacao). listar_clientes()/contar_clientes() e o template ganharam o ajuste; o dropdown mudou de rotulo pra 'Buscar/ordenar por' pra deixar claro que controla os dois. 93/93 testes passando. --- app/db/clientes.py | 23 ++++++++++------------- app/routers/clientes.py | 2 +- app/templates/clientes_lista.html | 4 ++-- tests/test_db_clientes.py | 9 ++++++++- tests/test_main.py | 2 +- tests/test_router_clientes.py | 6 +++--- 6 files changed, 25 insertions(+), 21 deletions(-) diff --git a/app/db/clientes.py b/app/db/clientes.py index 8fdd2a3..4726207 100644 --- a/app/db/clientes.py +++ b/app/db/clientes.py @@ -34,21 +34,22 @@ class ClienteComDependenciasError(Exception): def listar_clientes( busca: str | None = None, limite: int = 100, offset: int = 0, ordenar_por: str = ORDENACAO_PADRAO ) -> list[dict]: - coluna_ordenacao = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO]) + coluna = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO]) conn = get_connection() try: with conn.cursor() as cur: if busca: + # 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. sql = ( f"SELECT {_COLUNAS_SELECT} FROM CLIENTE " - "WHERE CODIGO LIKE %s OR CNPJ LIKE %s OR RAZAO LIKE %s OR FANTASIA LIKE %s " - f"ORDER BY {coluna_ordenacao} LIMIT %s OFFSET %s" + f"WHERE {coluna} LIKE %s ORDER BY {coluna} LIMIT %s OFFSET %s" ) - termo = f"%{busca}%" - cur.execute(sql, (termo, termo, termo, termo, limite, offset)) + cur.execute(sql, (f"%{busca}%", limite, offset)) else: cur.execute( - f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY {coluna_ordenacao} LIMIT %s OFFSET %s", + f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY {coluna} LIMIT %s OFFSET %s", (limite, offset), ) return cur.fetchall() @@ -80,17 +81,13 @@ def proximo_codigo_sugerido() -> str: return str(proximo).zfill(6) -def contar_clientes(busca: str | None = None) -> int: +def contar_clientes(busca: str | None = None, ordenar_por: str = ORDENACAO_PADRAO) -> int: + coluna = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO]) conn = get_connection() try: with conn.cursor() as cur: if busca: - sql = ( - "SELECT COUNT(*) AS total FROM CLIENTE " - "WHERE CODIGO LIKE %s OR CNPJ LIKE %s OR RAZAO LIKE %s OR FANTASIA LIKE %s" - ) - termo = f"%{busca}%" - cur.execute(sql, (termo, termo, termo, termo)) + cur.execute(f"SELECT COUNT(*) AS total FROM CLIENTE WHERE {coluna} LIKE %s", (f"%{busca}%",)) else: cur.execute("SELECT COUNT(*) AS total FROM CLIENTE") return cur.fetchone()["total"] diff --git a/app/routers/clientes.py b/app/routers/clientes.py index 23110cf..b438eb6 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -90,7 +90,7 @@ def listar( ordenar_por = db_clientes.ORDENACAO_PADRAO pagina = max(pagina, 1) - total = db_clientes.contar_clientes(busca=busca or None) + total = db_clientes.contar_clientes(busca=busca or None, ordenar_por=ordenar_por) total_paginas = max(math.ceil(total / TAMANHO_PAGINA), 1) pagina = min(pagina, total_paginas) offset = (pagina - 1) * TAMANHO_PAGINA diff --git a/app/templates/clientes_lista.html b/app/templates/clientes_lista.html index 2be3e0a..b275911 100644 --- a/app/templates/clientes_lista.html +++ b/app/templates/clientes_lista.html @@ -4,14 +4,14 @@ {% block conteudo %}

Clientes

- - + +

+ Novo cliente

diff --git a/tests/test_db_clientes.py b/tests/test_db_clientes.py index 081465a..3012d48 100644 --- a/tests/test_db_clientes.py +++ b/tests/test_db_clientes.py @@ -87,8 +87,15 @@ def test_excluir_cliente_sem_dependencias(cliente_id): assert db_clientes.obter_cliente(cliente_id) is None -def test_listar_clientes_ordena_por_codigo(cliente_id): +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" diff --git a/tests/test_main.py b/tests/test_main.py index ae2bcfb..bb95d8d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -23,7 +23,7 @@ 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: []) - monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None: 0) + monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao": 0) resposta = client.get("/", headers=_AUTH_HEADERS, follow_redirects=False) assert resposta.status_code == 307 assert resposta.headers["location"] == "/clientes" diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index e16e849..92b7831 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -61,7 +61,7 @@ def test_listar_clientes(monkeypatch): {"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Um", "FANTASIA": "Um", "CNPJ": "11222333000181"} ], ) - monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None: 1) + monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao": 1) resposta = client.get("/clientes") assert resposta.status_code == 200 assert "Cliente Um" in resposta.text @@ -76,7 +76,7 @@ def test_listar_clientes_pagina_com_controles_de_paginacao(monkeypatch): for i in range(1, 6) ], ) - monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None: 250) + monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao": 250) resposta = client.get("/clientes?pagina=2") assert resposta.status_code == 200 @@ -94,7 +94,7 @@ def test_listar_clientes_ordenar_por_invalido_cai_no_padrao(monkeypatch): return [] monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", _listar_clientes) - monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None: 0) + monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao": 0) client.get("/clientes?ordenar_por=coisa_invalida_qualquer") assert capturado["ordenar_por"] == "razao" From d18a5e6e296540cd9c6b29bb58a05185607e239d Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 14:37:25 -0300 Subject: [PATCH 22/87] feat: cabecalhos clicaveis para ordenar (crescente/decrescente) a lista de clientes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codigo, Razao, Fantasia e CNPJ agora sao links no cabecalho da tabela: clicar ordena crescente; clicar de novo na mesma coluna alterna pra decrescente (e vice-versa). Trocar de coluna sempre comeca em crescente. Seta (▲/▼) indica a coluna/direcao ativa. db_clientes.listar_clientes() ganha o parametro direcao (whitelist asc/desc, igual ja acontecia com ordenar_por). Links de Anterior/Proxima da paginacao preservam a direcao escolhida. 97/97 testes passando. --- app/db/clientes.py | 16 +++++++++--- app/routers/clientes.py | 7 +++-- app/templates/base.html | 2 ++ app/templates/clientes_lista.html | 17 +++++++++--- tests/test_db_clientes.py | 23 +++++++++++++++++ tests/test_main.py | 2 +- tests/test_router_clientes.py | 43 ++++++++++++++++++++++++++++--- 7 files changed, 98 insertions(+), 12 deletions(-) diff --git a/app/db/clientes.py b/app/db/clientes.py index 4726207..f8c7fcd 100644 --- a/app/db/clientes.py +++ b/app/db/clientes.py @@ -26,15 +26,25 @@ ORDENACOES_VALIDAS = { } 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 + busca: str | None = None, + limite: int = 100, + offset: int = 0, + ordenar_por: str = ORDENACAO_PADRAO, + direcao: str = DIRECAO_PADRAO, ) -> list[dict]: coluna = ORDENACOES_VALIDAS.get(ordenar_por, ORDENACOES_VALIDAS[ORDENACAO_PADRAO]) + direcao_sql = DIRECOES_VALIDAS.get(direcao, DIRECOES_VALIDAS[DIRECAO_PADRAO]) conn = get_connection() try: with conn.cursor() as cur: @@ -44,12 +54,12 @@ def listar_clientes( # campos de uma vez. sql = ( f"SELECT {_COLUNAS_SELECT} FROM CLIENTE " - f"WHERE {coluna} LIKE %s ORDER BY {coluna} LIMIT %s OFFSET %s" + f"WHERE {coluna} LIKE %s ORDER BY {coluna} {direcao_sql} LIMIT %s OFFSET %s" ) cur.execute(sql, (f"%{busca}%", limite, offset)) else: cur.execute( - f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY {coluna} LIMIT %s OFFSET %s", + f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY {coluna} {direcao_sql} LIMIT %s OFFSET %s", (limite, offset), ) return cur.fetchall() diff --git a/app/routers/clientes.py b/app/routers/clientes.py index b438eb6..2e39b90 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -81,6 +81,7 @@ def listar( request: Request, busca: Optional[str] = None, ordenar_por: str = db_clientes.ORDENACAO_PADRAO, + direcao: str = db_clientes.DIRECAO_PADRAO, pagina: int = 1, mensagem: Optional[str] = None, mensagem_tipo: Optional[str] = None, @@ -88,6 +89,8 @@ def listar( ): 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 pagina = max(pagina, 1) total = db_clientes.contar_clientes(busca=busca or None, ordenar_por=ordenar_por) @@ -96,13 +99,13 @@ def listar( offset = (pagina - 1) * TAMANHO_PAGINA clientes = db_clientes.listar_clientes( - busca=busca or None, limite=TAMANHO_PAGINA, offset=offset, ordenar_por=ordenar_por + busca=busca or None, limite=TAMANHO_PAGINA, offset=offset, ordenar_por=ordenar_por, direcao=direcao ) return templates.TemplateResponse( request, "clientes_lista.html", { - "clientes": clientes, "busca": busca, "ordenar_por": ordenar_por, + "clientes": clientes, "busca": busca, "ordenar_por": ordenar_por, "direcao": direcao, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo, "pagina": pagina, "total_paginas": total_paginas, "total": total, }, diff --git a/app/templates/base.html b/app/templates/base.html index 2661d43..2f84a74 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -12,6 +12,8 @@ main { padding: 20px; max-width: 960px; margin: 0 auto; } table { width: 100%; border-collapse: collapse; margin-top: 12px; } th, td { text-align: left; padding: 8px; border-bottom: 1px solid #3a3a3e; } + th a { color: inherit; text-decoration: none; white-space: nowrap; } + th a:hover { color: #F5821F; } input, select { padding: 8px; border-radius: 6px; border: 1px solid #3a3a3e; background: #17171a; color: #f4f4f5; width: 100%; box-sizing: border-box; } label { display: block; margin-top: 10px; font-size: 0.85rem; color: #a1a1aa; } button { margin-top: 14px; padding: 10px 16px; border: none; border-radius: 6px; background: #F5821F; color: #17171a; font-weight: 700; cursor: pointer; } diff --git a/app/templates/clientes_lista.html b/app/templates/clientes_lista.html index b275911..89e249e 100644 --- a/app/templates/clientes_lista.html +++ b/app/templates/clientes_lista.html @@ -16,7 +16,18 @@

+ Novo cliente

- + + {% 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" %} + + {% endfor %} + + {% for c in clientes %} @@ -30,11 +41,11 @@ {% if total_paginas and total_paginas > 1 %}

{% if pagina > 1 %} - ← Anterior + ← Anterior {% endif %} Página {{ pagina }} de {{ total_paginas }} ({{ total }} clientes) {% if pagina < total_paginas %} - Próxima → + Próxima → {% endif %}

{% endif %} diff --git a/tests/test_db_clientes.py b/tests/test_db_clientes.py index 3012d48..4d9efc8 100644 --- a/tests/test_db_clientes.py +++ b/tests/test_db_clientes.py @@ -106,6 +106,29 @@ def test_listar_clientes_ordenacao_invalida_usa_padrao(cliente_id): 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 diff --git a/tests/test_main.py b/tests/test_main.py index bb95d8d..09fa8fc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -22,7 +22,7 @@ def test_docs_desabilitado_retorna_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: []) + monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc": []) monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao": 0) resposta = client.get("/", headers=_AUTH_HEADERS, follow_redirects=False) assert resposta.status_code == 307 diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index 92b7831..3681d3f 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -57,7 +57,7 @@ def test_listar_clientes(monkeypatch): monkeypatch.setattr( router_clientes.db_clientes, "listar_clientes", - lambda busca=None, limite=100, offset=0, ordenar_por="razao": [ + lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc": [ {"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Um", "FANTASIA": "Um", "CNPJ": "11222333000181"} ], ) @@ -71,7 +71,7 @@ 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": [ + lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc": [ {"ID": i, "CODIGO": f"{i:06d}", "RAZAO": f"Cliente {i}", "FANTASIA": f"C{i}", "CNPJ": "11222333000181"} for i in range(1, 6) ], @@ -89,7 +89,7 @@ def test_listar_clientes_pagina_com_controles_de_paginacao(monkeypatch): def test_listar_clientes_ordenar_por_invalido_cai_no_padrao(monkeypatch): capturado = {} - def _listar_clientes(busca=None, limite=100, offset=0, ordenar_por="razao"): + def _listar_clientes(busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc"): capturado["ordenar_por"] = ordenar_por return [] @@ -103,6 +103,43 @@ def test_listar_clientes_ordenar_por_invalido_cai_no_padrao(monkeypatch): 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": 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_listar_clientes_direcao_invalida_cai_no_padrao(monkeypatch): + capturado = {} + + def _listar_clientes(busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc"): + 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": 0) + + client.get("/clientes?direcao=coisa_invalida") + assert capturado["direcao"] == "asc" + + client.get("/clientes?direcao=desc") + assert capturado["direcao"] == "desc" + + def test_form_novo_preenche_codigo_sugerido(monkeypatch): monkeypatch.setattr(router_clientes.db_clientes, "proximo_codigo_sugerido", lambda: "000243") resposta = client.get("/clientes/novo") From 3158a416a99b73402eb4eaac4c1e3fb314da5b64 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 15:05:26 -0300 Subject: [PATCH 23/87] feat: preenchimento automatico de dados do cliente a partir do CNPJ Ao sair do campo CNPJ no formulario de cliente, busca automaticamente na BrasilAPI (publica, gratuita, sem chave - dados abertos da Receita) e preenche Razao/Fantasia/Endereco/Cidade/Bairro/UF/CEP/Telefone/ E-mail, so nos campos que estiverem vazios (nunca sobrescreve o que ja foi digitado). So funciona para CNPJ - CPF e dado pessoal, sem consulta publica, e a rota recusa (404) se detectar CPF. - app/cnpj_lookup.py: cliente HTTP pra BrasilAPI, degrada em silencio (retorna None) se nao encontrar/rede cair/formato nao reconhecido (ex.: CNPJ alfanumerico novo, que a BrasilAPI pode nao cobrir ainda). - Nova rota GET /clientes/consulta-cnpj/{documento}, chamada via fetch() no formulario (JS vanilla, sem framework). - Testado ponta a ponta contra a API real. 106/106 testes passando (10 novos). --- app/cnpj_lookup.py | 57 +++++++++++++++++++++++++ app/routers/clientes.py | 21 ++++++++- app/templates/cliente_form.html | 54 ++++++++++++++++++++++++ tests/test_cnpj_lookup.py | 75 +++++++++++++++++++++++++++++++++ tests/test_router_clientes.py | 42 ++++++++++++++++++ 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 app/cnpj_lookup.py create mode 100644 tests/test_cnpj_lookup.py diff --git a/app/cnpj_lookup.py b/app/cnpj_lookup.py new file mode 100644 index 0000000..b28920e --- /dev/null +++ b/app/cnpj_lookup.py @@ -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 "", + } diff --git a/app/routers/clientes.py b/app/routers/clientes.py index 2e39b90..fe37e01 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -10,9 +10,10 @@ from urllib.parse import urlencode import pymysql from fastapi import APIRouter, Depends, Form, Request -from fastapi.responses import HTMLResponse, RedirectResponse +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 @@ -173,6 +174,24 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): 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, diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html index 5c6701b..014f932 100644 --- a/app/templates/cliente_form.html +++ b/app/templates/cliente_form.html @@ -34,9 +34,63 @@ {% else %} + {% if campo == "CNPJ" %} + + {% endif %} {% endif %} {% endfor %}

* 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.

+ + {% endblock %} diff --git a/tests/test_cnpj_lookup.py b/tests/test_cnpj_lookup.py new file mode 100644 index 0000000..a366329 --- /dev/null +++ b/tests/test_cnpj_lookup.py @@ -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" diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index 3681d3f..40dad1e 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -140,6 +140,48 @@ def test_listar_clientes_direcao_invalida_cai_no_padrao(monkeypatch): 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") From 361c2ca770f299379caea73d487838a17b86b0d0 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 16:08:48 -0300 Subject: [PATCH 24/87] feat: gerador de chave de instalacao (aluguel/compra) na tela /gerador-chave Adiciona a segunda rotina recuperada do Chave.exe original: chave de instalacao (8 caracteres tipicos), ao lado da chave de reativacao ja existente na mesma tela, com um seletor de tipo. Algoritmo (confirmado matematicamente contra os 3 exemplos reais fornecidos, byte a byte): - codigoCliente * 19 + 31 vira um numero inserido sem cifrar no meio da chave final. - A modalidade ("30"=ALUGUEL / "00"=COMPRA) e cifrada com o MESMO algoritmo/chave interna ja usado na reativacao (soma modular + XOR), gerando 6 hex (3 bytes: 1 aleatorio + 2 cifrados). - Chave final = 3 primeiros hex + valor do cliente + 3 ultimos hex. 100% client-side (mesmo js puro da reativacao), nunca grava no banco. Verifiquei os 3 exemplos (DC45092C, 2E35083D, 3D25090C) reproduzindo o algoritmo em Python antes de trocar o JS - bateram exatamente. 106/106 testes passando. --- app/templates/gerador_chave.html | 82 +++++++++++++++++++++++++++---- tests/test_router_chave_manual.py | 3 +- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/app/templates/gerador_chave.html b/app/templates/gerador_chave.html index f6d9042..f4f9cf3 100644 --- a/app/templates/gerador_chave.html +++ b/app/templates/gerador_chave.html @@ -1,15 +1,31 @@ {% extends "base.html" %} -{% block titulo %}Gerador de Chave de Reativação{% endblock %} +{% block titulo %}Gerador de Chave{% endblock %} {% block conteudo %}
GTI Grupo -

Gerador de Chave de Reativação

+

Gerador de Chave

+ + + - - +
+ + +
+ + @@ -23,6 +39,9 @@ const CHAVE_INTERNA = "YUQL23KL23DF90WI5E1JAS467NMCXXL6JAOAUWWMCL0AOMM4A4VZYW9KHJUI2347" + "EJHJKDF3424SKL K3LAKDJSL9RTIKJ"; +// ========================== +// REATIVAÇÃO — mês/cliente/ano cifrados por inteiro (22 caracteres) +// ========================== function gerarReativacao(codigoCliente, mes, ano, byteInicial = null) { const cliente = String(codigoCliente).padStart(6, '0'); const texto = String(mes).padStart(2, '0') + cliente + String(ano).slice(-2); @@ -40,8 +59,39 @@ function gerarReativacao(codigoCliente, mes, ano, byteInicial = null) { return resultado.map(v => v.toString(16).padStart(2, '0').toUpperCase()).join(''); } +// ========================== +// INSTALAÇÃO — só a modalidade ("30"=ALUGUEL / "00"=COMPRA) é cifrada +// (mesmo algoritmo de cifra da reativação, texto diferente); o valor +// derivado do código do cliente (cliente×19+31) entra sem cifrar, no +// meio dos 6 caracteres cifrados (3 antes + 3 depois). +// ========================== +function gerarInstalacao(codigoCliente, modalidade, byteInicial = null) { + const clienteNum = parseInt(String(codigoCliente), 10) || 0; + const valorCliente = clienteNum * 19 + 31; + const textoModalidade = modalidade === 'ALUGUEL' ? '30' : '00'; + + const aleatorio = byteInicial !== null ? byteInicial : Math.floor(Math.random() * 256); + let anterior = aleatorio; + const bytes = [aleatorio]; + for (let i = 0; i < textoModalidade.length; i++) { + const valor = (textoModalidade.charCodeAt(i) + anterior) % 255; + const cifrado = valor ^ CHAVE_INTERNA.charCodeAt(i % CHAVE_INTERNA.length); + bytes.push(cifrado); + anterior = cifrado; + } + + const criptografado = bytes.map(v => v.toString(16).padStart(2, '0').toUpperCase()).join(''); + const parte1 = criptografado.substring(0, 3); + const parte2 = criptografado.substring(3, 6); + return `${parte1}${valorCliente}${parte2}`; +} + +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'); @@ -51,6 +101,12 @@ const $copiar = document.getElementById('copiar'); $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'; @@ -63,13 +119,19 @@ document.getElementById('gerar').addEventListener('click', () => { const codigoCliente = String($codigo.value || '').trim(); if (!/^[0-9]{1,6}$/.test(codigoCliente)) return mostrarErro('Código do cliente deve ter até 6 dígitos numéricos.'); const codigoFormatado = codigoCliente.padStart(6, '0'); - if (!/^\d{4}-\d{2}$/.test($referencia.value)) return mostrarErro('Selecione o mês/ano de referência.'); - const [anoStr, mesStr] = $referencia.value.split('-'); - const mesNum = Number(mesStr), anoNum = Number(anoStr); - if (!(mesNum >= 1 && mesNum <= 12)) return mostrarErro('Mês fora do intervalo 1-12.'); - if (!(anoNum >= 2000 && anoNum <= 2100)) return mostrarErro('Ano inválido (use entre 2000 e 2100).'); - const chave = gerarReativacao(codigoFormatado, mesNum, anoNum); + let chave; + if ($tipo.value === 'reativacao') { + if (!/^\d{4}-\d{2}$/.test($referencia.value)) return mostrarErro('Selecione o mês/ano de referência.'); + const [anoStr, mesStr] = $referencia.value.split('-'); + const mesNum = Number(mesStr), anoNum = Number(anoStr); + if (!(mesNum >= 1 && mesNum <= 12)) return mostrarErro('Mês fora do intervalo 1-12.'); + if (!(anoNum >= 2000 && anoNum <= 2100)) return mostrarErro('Ano inválido (use entre 2000 e 2100).'); + chave = gerarReativacao(codigoFormatado, mesNum, anoNum); + } else { + chave = gerarInstalacao(codigoFormatado, $modalidade.value); + } + $resultado.textContent = chave; $resultado.style.display = 'block'; $copiar.style.display = 'inline-block'; diff --git a/tests/test_router_chave_manual.py b/tests/test_router_chave_manual.py index d4a786a..a690dd3 100644 --- a/tests/test_router_chave_manual.py +++ b/tests/test_router_chave_manual.py @@ -14,7 +14,8 @@ def test_pagina_gerador_carrega(): resposta = client.get("/gerador-chave") assert resposta.status_code == 200 assert "gerarReativacao" in resposta.text - assert "Gerador de Chave de Reativação" in resposta.text + assert "gerarInstalacao" in resposta.text + assert "Gerador de Chave" in resposta.text def test_pagina_gerador_nao_grava_nada(): From 95b1da7c1085f5396c609a61864e207b427a01bc Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 20:25:18 -0300 Subject: [PATCH 25/87] feat: token de sessao assinado (app/session.py) Co-Authored-By: Claude Sonnet 5 --- app/session.py | 68 +++++++++++++++++++++++++++++++++++++++++++ tests/test_session.py | 47 ++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 app/session.py create mode 100644 tests/test_session.py diff --git a/app/session.py b/app/session.py new file mode 100644 index 0000000..bdf542c --- /dev/null +++ b/app/session.py @@ -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 (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"]} diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..3075f9b --- /dev/null +++ b/tests/test_session.py @@ -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 From 718e845651d536819fa2d3aa638c2ab7c6e77a2a Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 20:29:45 -0300 Subject: [PATCH 26/87] feat: coluna PERFIL em FUNCIONARIO_PAINEL + contar_admins_ativos Co-Authored-By: Claude Sonnet 5 --- app/db/funcionarios.py | 47 ++++++++++++++++++++++++++++------- tests/test_db_funcionarios.py | 30 ++++++++++++++++++---- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/app/db/funcionarios.py b/app/db/funcionarios.py index 55b26a5..420f582 100644 --- a/app/db/funcionarios.py +++ b/app/db/funcionarios.py @@ -10,12 +10,26 @@ primeiro com: 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). """ @@ -27,7 +41,9 @@ def listar_funcionarios() -> list[dict]: conn = get_connection() try: with conn.cursor() as cur: - cur.execute("SELECT ID, NOME, LOGIN, ATIVO, CRIADO_EM FROM FUNCIONARIO_PAINEL ORDER BY NOME") + cur.execute( + "SELECT ID, NOME, LOGIN, PERFIL, ATIVO, CRIADO_EM FROM FUNCIONARIO_PAINEL ORDER BY NOME" + ) return cur.fetchall() finally: conn.close() @@ -38,7 +54,8 @@ def obter_funcionario(funcionario_id: int) -> dict | None: try: with conn.cursor() as cur: cur.execute( - "SELECT ID, NOME, LOGIN, ATIVO, CRIADO_EM FROM FUNCIONARIO_PAINEL WHERE ID = %s", (funcionario_id,) + "SELECT ID, NOME, LOGIN, PERFIL, ATIVO, CRIADO_EM FROM FUNCIONARIO_PAINEL WHERE ID = %s", + (funcionario_id,), ) return cur.fetchone() finally: @@ -51,7 +68,8 @@ def obter_por_login(login: str) -> dict | None: try: with conn.cursor() as cur: cur.execute( - "SELECT ID, NOME, LOGIN, SENHA_HASH, ATIVO FROM FUNCIONARIO_PAINEL WHERE LOGIN = %s", (login,) + "SELECT ID, NOME, LOGIN, SENHA_HASH, PERFIL, ATIVO FROM FUNCIONARIO_PAINEL WHERE LOGIN = %s", + (login,), ) return cur.fetchone() finally: @@ -73,26 +91,27 @@ def login_em_uso(login: str, ignorar_id: int | None = None) -> bool: conn.close() -def criar_funcionario(nome: str, login: str, senha: str) -> int: +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, ATIVO) VALUES (%s, %s, %s, 1)", - (nome, login, gerar_hash(senha)), + "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) -> None: - """Só nome/login — troca de senha é redefinir_senha().""" +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 WHERE ID = %s", (nome, login, funcionario_id) + "UPDATE FUNCIONARIO_PAINEL SET NOME = %s, LOGIN = %s, PERFIL = %s WHERE ID = %s", + (nome, login, perfil, funcionario_id), ) finally: conn.close() @@ -117,3 +136,13 @@ def alternar_ativo(funcionario_id: int) -> None: 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() diff --git a/tests/test_db_funcionarios.py b/tests/test_db_funcionarios.py index 80b3446..11badd7 100644 --- a/tests/test_db_funcionarios.py +++ b/tests/test_db_funcionarios.py @@ -19,7 +19,7 @@ def _limpar(): @pytest.fixture def funcionario_id(): _limpar() - novo_id = db_funcionarios.criar_funcionario("Fulano Teste", LOGIN_TESTE, "senhaForte123") + novo_id = db_funcionarios.criar_funcionario("Fulano Teste", LOGIN_TESTE, "senhaForte123", "PADRAO") yield novo_id _limpar() @@ -30,14 +30,16 @@ def test_criar_e_obter_funcionario(funcionario_id): 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(funcionario_id): +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): @@ -51,16 +53,18 @@ def test_login_em_uso(funcionario_id): def test_listar_funcionarios_inclui_o_criado(funcionario_id): - logins = [f["LOGIN"] for f in db_funcionarios.listar_funcionarios()] - assert LOGIN_TESTE in logins + 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) + 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): @@ -76,3 +80,19 @@ def test_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() From 1b80290f6f83453cb6a24aa574f5f20e9ba4b2c2 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 20:33:30 -0300 Subject: [PATCH 27/87] feat: login por sessao (troca HTTP Basic), UsuarioLogado, exigir_admin Co-Authored-By: Claude Sonnet 5 --- app/auth.py | 70 ++++++++++++++++++++++-------- tests/test_auth.py | 106 ++++++++++++++++++++++++++++++--------------- 2 files changed, 122 insertions(+), 54 deletions(-) diff --git a/app/auth.py b/app/auth.py index d8dc3e2..e46c009 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,44 +1,78 @@ """ -Login HTTP Basic. Duas fontes de credencial, checadas nessa ordem: +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). + 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"). -Sem sessão/cookie — o próprio navegador guarda a credencial Basic. +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, status -from fastapi.security import HTTPBasic, HTTPBasicCredentials +from fastapi import Depends, HTTPException, Request, status +from . import session from .db import funcionarios as db_funcionarios from .senha import verificar_senha -_security = HTTPBasic() + +@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" -def exigir_login(credenciais: HTTPBasicCredentials = Depends(_security)) -> str: +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(credenciais.username, usuario_admin) - senha_ok = secrets.compare_digest(credenciais.password, 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 credenciais.username + return UsuarioLogado(login=usuario_admin, nome=usuario_admin, perfil="ADMIN") - funcionario = db_funcionarios.obter_por_login(credenciais.username) - if funcionario and funcionario["ATIVO"] and verificar_senha(credenciais.password, funcionario["SENHA_HASH"]): - return funcionario["NOME"] + 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"]) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Usuário ou senha inválidos.", - headers={"WWW-Authenticate": "Basic"}, - ) + return None + + +def exigir_login(request: Request) -> UsuarioLogado: + token = request.cookies.get(session.SESSION_COOKIE_NAME) + dados = session.ler_token(token) + if dados is None: + raise NaoAutenticado(caminho_original=request.url.path) + return UsuarioLogado(login=dados["login"], nome=dados["nome"], perfil=dados["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 diff --git a/tests/test_auth.py b/tests/test_auth.py index 2b56d44..f2386cb 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,10 +1,9 @@ -import base64 - import pytest -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, HTTPException from fastapi.testclient import TestClient -from app.auth import exigir_login +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 @@ -12,42 +11,79 @@ app_teste = FastAPI() @app_teste.get("/protegido") -def rota_protegida(usuario: str = Depends(exigir_login)): - return {"usuario": usuario} +def rota_protegida(usuario: UsuarioLogado = Depends(exigir_login)): + return {"usuario": str(usuario), "perfil": usuario.perfil} -client = TestClient(app_teste) +@app_teste.get("/so-admin") +def rota_admin(usuario: UsuarioLogado = Depends(exigir_admin)): + return {"usuario": str(usuario)} -def _basic(usuario: str, senha: str) -> dict: - token = base64.b64encode(f"{usuario}:{senha}".encode()).decode() - return {"Authorization": f"Basic {token}"} +@app_teste.exception_handler(NaoAutenticado) +def _handler_nao_autenticado(request, exc): + raise HTTPException(status_code=401, detail="não autenticado") -def test_sem_credenciais_retorna_401(): +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_credenciais_erradas_retorna_401(): - resposta = client.get("/protegido", headers=_basic("admin", "senha_errada")) +def test_cookie_invalido_retorna_401(): + resposta = client.get("/protegido", cookies={session.SESSION_COOKIE_NAME: "lixo"}) assert resposta.status_code == 401 -def test_credenciais_corretas_retorna_200(): - # ADMIN_USER/ADMIN_PASSWORD vêm de tests/conftest.py ("admin"/"teste123") - resposta = client.get("/protegido", headers=_basic("admin", "teste123")) +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"} + assert resposta.json() == {"usuario": "Admin", "perfil": "ADMIN"} -def test_admin_user_e_senha_vazios_nao_autentica_com_credenciais_vazias(monkeypatch): - # Sem ADMIN_USER/ADMIN_PASSWORD configurados, o painel não pode ficar - # aberto para uma requisição com usuário/senha vazios (Basic Og==). +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(): + resposta = client.get("/so-admin", cookies=_cookie_sessao("fulano", "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", "") - resposta = client.get("/protegido", headers=_basic("", "")) - assert resposta.status_code == 401 + assert autenticar_credenciais("", "") is None def _limpar_login(login: str) -> None: @@ -63,29 +99,27 @@ def _limpar_login(login: str) -> None: def funcionario_teste(): login = "teste.login.auth" _limpar_login(login) - funcionario_id = db_funcionarios.criar_funcionario("Fulano de Teste", login, "senhaForte123") + funcionario_id = db_funcionarios.criar_funcionario("Fulano de Teste", login, "senhaForte123", "PADRAO") yield login _limpar_login(login) -def test_funcionario_ativo_com_senha_correta_autentica(funcionario_teste): - resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaForte123")) - assert resposta.status_code == 200 - assert resposta.json() == {"usuario": "Fulano de Teste"} +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_funcionario_com_senha_errada_nao_autentica(funcionario_teste): - resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaErrada")) - assert resposta.status_code == 401 +def test_autenticar_credenciais_funcionario_senha_errada(funcionario_teste): + assert autenticar_credenciais(funcionario_teste, "senhaErrada") is None -def test_funcionario_inativo_nao_autentica(funcionario_teste): +def test_autenticar_credenciais_funcionario_inativo(funcionario_teste): funcionario = db_funcionarios.obter_por_login(funcionario_teste) db_funcionarios.alternar_ativo(funcionario["ID"]) # desativa - resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaForte123")) - assert resposta.status_code == 401 + assert autenticar_credenciais(funcionario_teste, "senhaForte123") is None -def test_login_inexistente_nao_autentica(): - resposta = client.get("/protegido", headers=_basic("nao-existe-esse-login", "qualquer")) - assert resposta.status_code == 401 +def test_autenticar_credenciais_login_inexistente(): + assert autenticar_credenciais("nao-existe-esse-login", "qualquer") is None From c4079b3e475c3724cf7134c879b9900057a8f21b Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 20:38:43 -0300 Subject: [PATCH 28/87] feat: rotas de login/logoff (app/routers/auth.py) Co-Authored-By: Claude Sonnet 5 --- app/routers/auth.py | 62 ++++++++++++++++++++++++++++ app/templates/login.html | 30 ++++++++++++++ tests/test_router_auth.py | 85 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 app/routers/auth.py create mode 100644 app/templates/login.html create mode 100644 tests/test_router_auth.py diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..397c3be --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,62 @@ +""" +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). +""" +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 session +from ..auth import UsuarioLogado, autenticar_credenciais, exigir_login +from ..csrf import exigir_csrf, token_do_contexto + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +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), +): + usuario_logado = autenticar_credenciais(usuario, senha) + if usuario_logado is None: + return templates.TemplateResponse( + request, + "login.html", + {"csrf_token": token_do_contexto(request), "next": next or "", "erro": "Usuário ou senha inválidos."}, + ) + + 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") + 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) + return resposta diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..02141ee --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,30 @@ + + + + + + Entrar — Painel de Clientes + + + +
+

Painel de Clientes

+ {% if erro %}
{{ erro }}
{% endif %} + + + + + + + + + + diff --git a/tests/test_router_auth.py b/tests/test_router_auth.py new file mode 100644 index 0000000..19c6197 --- /dev/null +++ b/tests/test_router_auth.py @@ -0,0 +1,85 @@ +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from app import 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_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_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 From 4a5c70861d8a8ea6e8fecd9068b7f4b4daded3f1 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 20:44:15 -0300 Subject: [PATCH 29/87] feat: tela Minha conta para trocar a propria senha Co-Authored-By: Claude Sonnet 5 --- app/routers/minha_conta.py | 75 ++++++++++++++++ app/templates/minha_conta.html | 31 +++++++ tests/test_router_minha_conta.py | 142 +++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 app/routers/minha_conta.py create mode 100644 app/templates/minha_conta.html create mode 100644 tests/test_router_minha_conta.py diff --git a/app/routers/minha_conta.py b/app/routers/minha_conta.py new file mode 100644 index 0000000..d86cfc7 --- /dev/null +++ b/app/routers/minha_conta.py @@ -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), + }, + ) diff --git a/app/templates/minha_conta.html b/app/templates/minha_conta.html new file mode 100644 index 0000000..f594a28 --- /dev/null +++ b/app/templates/minha_conta.html @@ -0,0 +1,31 @@ + +{% extends "base.html" %} +{% block titulo %}Minha conta{% endblock %} +{% block conteudo %} +

Minha conta

+

Nome: {{ usuario_atual.nome }}

+

Login: {{ usuario_atual.login }}

+ + {% if erro %}
{{ erro }}
{% endif %} + {% if mensagem %}
{{ mensagem }}
{% endif %} + + {% if eh_conta_env %} +
+ 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. +
+ {% else %} +

Trocar senha

+
+ + + + + + + + + + {% endif %} +{% endblock %} diff --git a/tests/test_router_minha_conta.py b/tests/test_router_minha_conta.py new file mode 100644 index 0000000..19a872a --- /dev/null +++ b/tests/test_router_minha_conta.py @@ -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 From 09424e8afb80e37220b555b2928f561b4a9411de Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 20:49:05 -0300 Subject: [PATCH 30/87] feat: liga login por sessao, middleware de usuario e handler de NaoAutenticado Co-Authored-By: Claude Sonnet 5 --- .env.example | 6 ++++++ app/main.py | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index d8ecdd7..f7d1df8 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,12 @@ 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) diff --git a/app/main.py b/app/main.py index 236a3ea..81dce00 100644 --- a/app/main.py +++ b/app/main.py @@ -1,24 +1,34 @@ """ -Painel de Clientes — monta os routers de CLIENTE, CLIENTE_CONTATO e o -gerador de chave manual. Ver docs/superpowers/specs/2026-08-13-painel-clientes-web-design.md. +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 .auth import exigir_login +from . import session +from .auth import NaoAutenticado, UsuarioLogado, exigir_login from .csrf import CSRF_COOKIE_NAME, gerar_token -from .routers import chave_manual, clientes, contatos, funcionarios, log +from .routers import auth, chave_manual, clientes, contatos, funcionarios, log, minha_conta 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.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): """ @@ -40,6 +50,23 @@ async def csrf_cookie_middleware(request: Request, call_next): 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 + ) + return await call_next(request) + + @app.get("/") -def raiz(usuario: str = Depends(exigir_login)): +def raiz(usuario: UsuarioLogado = Depends(exigir_login)): return RedirectResponse(url="/clientes") From a2a5fc79052e777c47536078f84051d2f88965ff Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 20:51:52 -0300 Subject: [PATCH 31/87] feat: nav com botao Sair, Minha conta e Funcionarios so para admin Co-Authored-By: Claude Sonnet 5 --- app/templates/base.html | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/templates/base.html b/app/templates/base.html index 2f84a74..b537fba 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -28,8 +28,17 @@
{% if mensagem %}
{{ mensagem }}
{% endif %} From 81f730df9e4c5d0f12da8b4b906b735deba01643 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 20:56:52 -0300 Subject: [PATCH 32/87] feat: perfil admin/padrao em funcionarios, protege ultimo admin Co-Authored-By: Claude Sonnet 5 --- app/routers/funcionarios.py | 73 ++++++++---- app/templates/funcionario_form.html | 6 + app/templates/funcionarios_lista.html | 3 +- tests/test_router_funcionarios.py | 160 +++++++++++++++++++++++--- 4 files changed, 208 insertions(+), 34 deletions(-) diff --git a/app/routers/funcionarios.py b/app/routers/funcionarios.py index de31e3c..bbc35b5 100644 --- a/app/routers/funcionarios.py +++ b/app/routers/funcionarios.py @@ -1,6 +1,8 @@ """ -Rotas de gestão de FUNCIONARIO_PAINEL: listar, criar (com senha), editar -nome/login, redefinir senha, ativar/desativar (nunca exclui de verdade). +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 @@ -8,12 +10,14 @@ from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates -from ..auth import exigir_login +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 -router = APIRouter(prefix="/funcionarios") +PERFIS_VALIDOS = {"ADMIN", "PADRAO"} + +router = APIRouter(prefix="/funcionarios", dependencies=[Depends(exigir_admin)]) templates = Jinja2Templates(directory="app/templates") @@ -28,7 +32,7 @@ def listar( request: Request, mensagem: Optional[str] = None, mensagem_tipo: Optional[str] = None, - usuario: str = Depends(exigir_login), + usuario: UsuarioLogado = Depends(exigir_admin), ): funcionarios = db_funcionarios.listar_funcionarios() return templates.TemplateResponse( @@ -42,7 +46,7 @@ def listar( @router.get("/novo", response_class=HTMLResponse) -def form_novo(request: Request, usuario: str = Depends(exigir_login)): +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)} ) @@ -55,7 +59,8 @@ def criar( login: str = Form(...), senha: str = Form(...), confirmar_senha: str = Form(...), - usuario: str = Depends(exigir_login), + perfil: str = Form(...), + usuario: UsuarioLogado = Depends(exigir_admin), ): nome = nome.strip() login = login.strip() @@ -65,6 +70,8 @@ def criar( 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." @@ -73,18 +80,18 @@ def criar( request, "funcionario_form.html", { - "funcionario": None, "erro": erro, "dados": {"NOME": nome, "LOGIN": login}, + "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) - db_log.registrar(usuario, "criar", "funcionario", str(novo_id), True) + 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: str = Depends(exigir_login)): +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") @@ -99,7 +106,8 @@ def editar( funcionario_id: int, nome: str = Form(...), login: str = Form(...), - usuario: str = Depends(exigir_login), + perfil: str = Form(...), + usuario: UsuarioLogado = Depends(exigir_admin), ): nome = nome.strip() login = login.strip() @@ -107,21 +115,35 @@ def editar( 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) + rebaixando_ultimo_admin = ( + atual is not None + and 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}, + "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) - db_log.registrar(usuario, "editar", "funcionario", str(funcionario_id), True) + 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.") @@ -130,19 +152,30 @@ def redefinir_senha( funcionario_id: int, nova_senha: str = Form(...), confirmar_nova_senha: str = Form(...), - usuario: str = Depends(exigir_login), + usuario: UsuarioLogado = Depends(exigir_admin), ): if not nova_senha or nova_senha != confirmar_nova_senha: - db_log.registrar(usuario, "redefinir_senha", "funcionario", str(funcionario_id), False, "senhas não coincidem") + 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") db_funcionarios.redefinir_senha(funcionario_id, nova_senha) - db_log.registrar(usuario, "redefinir_senha", "funcionario", str(funcionario_id), True) + 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: str = Depends(exigir_login)): +def alternar_ativo(funcionario_id: int, usuario: UsuarioLogado = Depends(exigir_admin)): + atual = db_funcionarios.obter_funcionario(funcionario_id) + if atual is not None and 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(usuario, "alternar_ativo", "funcionario", str(funcionario_id), True) + db_log.registrar(str(usuario), "alternar_ativo", "funcionario", str(funcionario_id), True) return _redirect_com_mensagem("Status do funcionário atualizado.") diff --git a/app/templates/funcionario_form.html b/app/templates/funcionario_form.html index d7b246d..df56c27 100644 --- a/app/templates/funcionario_form.html +++ b/app/templates/funcionario_form.html @@ -12,6 +12,12 @@ + + {% set perfil_atual = dados.PERFIL if dados else (funcionario.PERFIL if funcionario else 'PADRAO') %} + {% if not funcionario %} diff --git a/app/templates/funcionarios_lista.html b/app/templates/funcionarios_lista.html index c98036d..adc98ec 100644 --- a/app/templates/funcionarios_lista.html +++ b/app/templates/funcionarios_lista.html @@ -5,11 +5,12 @@

Funcionários

+ Novo funcionário

CódigoRazãoFantasiaCNPJ
+ + {{ rotulo }}{% if ordenar_por == campo %}{{ " ▲" if direcao == "asc" else " ▼" }}{% endif %} + +
{{ c.CODIGO }}
- + {% for f in funcionarios %} +
NomeLoginStatus
NomeLoginPerfilStatus
{{ f.NOME }} {{ f.LOGIN }}{{ "Administrador" if f.PERFIL == "ADMIN" else "Padrão" }} {{ "Ativo" if f.ATIVO else "Inativo" }} Editar diff --git a/tests/test_router_funcionarios.py b/tests/test_router_funcionarios.py index 99183ca..c59d12d 100644 --- a/tests/test_router_funcionarios.py +++ b/tests/test_router_funcionarios.py @@ -2,12 +2,12 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from app.auth import exigir_login +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: "teste" +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) @@ -18,11 +18,26 @@ 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", "ATIVO": 1, "CRIADO_EM": "2026-01-01"}], + 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 @@ -35,18 +50,18 @@ def test_criar_funcionario_com_sucesso(monkeypatch): monkeypatch.setattr( router_funcionarios.db_funcionarios, "criar_funcionario", - lambda nome, login, senha: criado.setdefault("dados", (nome, login, senha)) and 1, + 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", - "csrf_token": CSRF_TESTE, + "perfil": "PADRAO", "csrf_token": CSRF_TESTE, }, ) assert resposta.status_code == 303 - assert criado["dados"] == ("Fulano", "fulano", "senhaForte123") + assert criado["dados"] == ("Fulano", "fulano", "senhaForte123", "PADRAO") def test_criar_funcionario_senhas_diferentes_bloqueia(monkeypatch): @@ -55,14 +70,14 @@ def test_criar_funcionario_senhas_diferentes_bloqueia(monkeypatch): monkeypatch.setattr( router_funcionarios.db_funcionarios, "criar_funcionario", - lambda nome, login, senha: chamou.setdefault("ok", True), + lambda nome, login, senha, perfil: chamou.setdefault("ok", True), ) resposta = client.post( "/funcionarios/novo", data={ "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "outraCoisa", - "csrf_token": CSRF_TESTE, + "perfil": "PADRAO", "csrf_token": CSRF_TESTE, }, ) assert resposta.status_code == 200 @@ -76,14 +91,14 @@ def test_criar_funcionario_login_duplicado_bloqueia(monkeypatch): monkeypatch.setattr( router_funcionarios.db_funcionarios, "criar_funcionario", - lambda nome, login, senha: chamou.setdefault("ok", True), + lambda nome, login, senha, perfil: chamou.setdefault("ok", True), ) resposta = client.post( "/funcionarios/novo", data={ "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "senhaForte123", - "csrf_token": CSRF_TESTE, + "perfil": "PADRAO", "csrf_token": CSRF_TESTE, }, ) assert resposta.status_code == 200 @@ -91,21 +106,93 @@ def test_criar_funcionario_login_duplicado_bloqueia(monkeypatch): 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: capturado.setdefault("dados", (fid, nome, login)), + 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", "csrf_token": CSRF_TESTE}, + 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") + 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_redefinir_senha_com_sucesso(monkeypatch): @@ -140,6 +227,53 @@ def test_redefinir_senha_nao_coincide_bloqueia(monkeypatch): 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) From 64990b6db2b046fe903525cac8b0b0016d334f07 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 21:01:36 -0300 Subject: [PATCH 33/87] fix: atualiza tests/test_main.py para login por sessao tests/test_main.py nao fazia parte do mapa de arquivos do plano de permissoes/sessao e ficou quebrado (HTTP Basic Auth) apos a troca pra login por sessao nas tasks anteriores. Atualiza os 6 testes pra usar cookie de sessao (app.session.criar_token) em vez de header Basic, e ajusta a expectativa de GET / sem login (agora redireciona 303 pra /login em vez de pedir credenciais 401). Co-Authored-By: Claude Sonnet 5 --- tests/test_main.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 09fa8fc..1e8f53a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,16 +1,19 @@ -import base64 - from fastapi.testclient import TestClient +from app import session from app.main import app client = TestClient(app) -_AUTH_HEADERS = {"Authorization": f"Basic {base64.b64encode(b'admin:teste123').decode()}"} -def test_raiz_sem_login_pede_credenciais(): +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 == 401 + assert resposta.status_code == 303 + assert resposta.headers["location"] == "/login?next=/" def test_docs_desabilitado_retorna_404(): @@ -24,7 +27,7 @@ def test_raiz_redireciona_para_clientes_com_login(monkeypatch): monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc": []) monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao": 0) - resposta = client.get("/", headers=_AUTH_HEADERS, follow_redirects=False) + resposta = client.get("/", cookies=_cookie_sessao_admin(), follow_redirects=False) assert resposta.status_code == 307 assert resposta.headers["location"] == "/clientes" @@ -39,7 +42,7 @@ def test_csrf_middleware_define_cookie_em_qualquer_resposta(): def test_csrf_bloqueia_post_com_token_que_nao_bate_com_cookie(): client.cookies.clear() resposta = client.post( - "/clientes/1/excluir", headers=_AUTH_HEADERS, data={"csrf_token": "token-forjado-sem-cookie"} + "/clientes/1/excluir", cookies=_cookie_sessao_admin(), data={"csrf_token": "token-forjado-sem-cookie"} ) assert resposta.status_code == 403 @@ -54,7 +57,8 @@ def test_csrf_libera_post_quando_token_bate_com_cookie(monkeypatch): token_cookie = client.cookies.get("csrf_token") assert token_cookie + cookies = {**_cookie_sessao_admin(), "csrf_token": token_cookie} resposta = client.post( - "/clientes/1/excluir", headers=_AUTH_HEADERS, data={"csrf_token": token_cookie}, follow_redirects=False + "/clientes/1/excluir", cookies=cookies, data={"csrf_token": token_cookie}, follow_redirects=False ) assert resposta.status_code == 303 From a71b990a3c69c41df3386b6e7013275f5ad2740f Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 21:04:37 -0300 Subject: [PATCH 34/87] docs: documenta login por sessao e perfis admin/padrao no README Co-Authored-By: Claude Sonnet 5 --- README.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8c1e8a8..05077b5 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,33 @@ Proteções: login HTTP Basic em toda rota, CSRF via cookie de duplo envio (`app 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 por funcionário +## Login e permissões -Cada funcionário pode ter seu próprio usuário/senha (tabela `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`; senha em hash `scrypt`, nunca texto puro). Gerencie em **Funcionários** no menu. +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). -`ADMIN_USER`/`ADMIN_PASSWORD` (`.env.example`) continuam funcionando como conta de bootstrap — é 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`). O log de ações mostra o nome de quem fez cada coisa. +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`). Todo funcionário cadastrado antes dessa funcionalidade +existir virou admin automaticamente na migração (`ALTER TABLE +FUNCIONARIO_PAINEL ADD COLUMN PERFIL ...`, mesmo arquivo). 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) From aa1c528197dbf7a589d37223ffcc303cc9e8b5a6 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 21:18:39 -0300 Subject: [PATCH 35/87] fix: botao Sair quebrado em /clientes, /log e /gerador-chave (csrf_token vazio) base.html usava {{ csrf_token }}, variavel que so algumas rotas passam explicitamente no contexto do template. Em /clientes (GET), /log e /gerador-chave a variavel nunca era passada, entao o campo oculto do formulario de logout renderizava com value="" e exigir_csrf (app/csrf.py) rejeitava o POST /logout com 403 - o usuario nao conseguia clicar em 'Sair' logo apos logar, ja que /clientes e para onde todo login cai. Corrige na raiz: usa {{ csrf_token or request.state.csrf_token }}, caindo para o valor que csrf_cookie_middleware (app/main.py) ja popula em toda requisicao quando a rota nao passar csrf_token explicitamente. Protege tambem qualquer rota futura que esqueca de passar a variavel. Testes de regressao em tests/test_main.py: confirma que GET /clientes renderiza um csrf_token nao vazio no formulario de logout, e que POST /logout funciona (303) apos visitar /clientes. Co-Authored-By: Claude Sonnet 5 --- app/templates/base.html | 2 +- tests/test_main.py | 57 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/app/templates/base.html b/app/templates/base.html index b537fba..00ad7e1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -35,7 +35,7 @@ {% if request.state.usuario %} Minha conta
- +
{% endif %} diff --git a/tests/test_main.py b/tests/test_main.py index 1e8f53a..3602c87 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -62,3 +62,60 @@ def test_csrf_libera_post_quando_token_bate_com_cookie(monkeypatch): "/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": [], + ) + monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao": 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": [], + ) + monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao": 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 From ba8ffaede7292311627816fac922d2370ff1ffd1 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 21:18:46 -0300 Subject: [PATCH 36/87] fix: bloqueia open-redirect via next=/\evil.com no login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _proximo_seguro() (app/routers/auth.py) so recusava next que comecasse com "//", mas navegadores modernos tratam "/\" como equivalente a "//" (URL protocol-relative), entao next=/\evil.com ainda era um open redirect explorável via link de login malicioso. Bloqueia tambem o prefixo "/\". Teste de regressao em tests/test_router_auth.py: POST /login com next=/\evil.com precisa redirecionar para /clientes, nao para o host externo. Co-Authored-By: Claude Sonnet 5 --- app/routers/auth.py | 2 +- tests/test_router_auth.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/routers/auth.py b/app/routers/auth.py index 397c3be..481417d 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -21,7 +21,7 @@ templates = Jinja2Templates(directory="app/templates") 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("//"): + if next_ and next_.startswith("/") and not next_.startswith(("//", "/\\")): return next_ return "/clientes" diff --git a/tests/test_router_auth.py b/tests/test_router_auth.py index 19c6197..d522d8f 100644 --- a/tests/test_router_auth.py +++ b/tests/test_router_auth.py @@ -56,6 +56,17 @@ def test_post_login_next_externo_e_ignorado_por_seguranca(): 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} From e5f5d4211c8584e792130c828047da92494a4c28 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 21:18:58 -0300 Subject: [PATCH 37/87] fix: desativar/rebaixar funcionario nao revogava sessao ativa (ate 12h) Perfil e status ativo vinham so do cookie assinado (session.py), entao um funcionario desativado (ATIVO=0) ou rebaixado (ADMIN->PADRAO) continuava com acesso total ate o cookie expirar (ate 12h) - inclusive a /funcionarios se era admin. exigir_login (app/auth.py) agora reconfirma contra o banco a cada requisicao quando o login nao e o da conta de bootstrap (ADMIN_USER do .env, que nao tem linha em FUNCIONARIO_PAINEL e continua sempre admin via variavel de ambiente): busca db_funcionarios.obter_por_login(login); se nao existir mais ou ATIVO != 1, levanta NaoAutenticado (forca novo login); caso contrario, monta o UsuarioLogado com o PERFIL atual do banco, nao o do cookie - rebaixamento tambem passa a ter efeito imediato. Testes de regressao em tests/test_auth.py: - test_exigir_login_autentica_funcionario_ativo - test_exigir_login_revoga_sessao_ao_desativar_funcionario - test_exigir_admin_usa_perfil_atual_do_banco_nao_do_cookie - test_exigir_login_conta_bootstrap_nao_consulta_banco Ajustou tambem test_exigir_admin_rejeita_padrao (usava um login fake sem linha em FUNCIONARIO_PAINEL, que agora conflita com a checagem nova - passou a usar a fixture funcionario_teste). Suite inteira roda 147 passed apos a mudanca. Co-Authored-By: Claude Sonnet 5 --- app/auth.py | 21 ++++++++++++++++++++- tests/test_auth.py | 43 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/app/auth.py b/app/auth.py index e46c009..4126bec 100644 --- a/app/auth.py +++ b/app/auth.py @@ -65,11 +65,30 @@ def autenticar_credenciais(usuario: str, senha: str) -> UsuarioLogado | 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) - return UsuarioLogado(login=dados["login"], nome=dados["nome"], perfil=dados["perfil"]) + + 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: diff --git a/tests/test_auth.py b/tests/test_auth.py index f2386cb..61fae59 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -63,8 +63,11 @@ def test_exigir_admin_aceita_admin(): assert resposta.status_code == 200 -def test_exigir_admin_rejeita_padrao(): - resposta = client.get("/so-admin", cookies=_cookie_sessao("fulano", "Fulano", "PADRAO")) +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 @@ -123,3 +126,39 @@ def test_autenticar_credenciais_funcionario_inativo(funcionario_teste): 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"} From 7656f2261e1db2eab52e189dac791909ea27b1b2 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 21:19:09 -0300 Subject: [PATCH 38/87] docs: corrige README contraditorio sobre HTTP Basic e SESSION_SECRET MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Frase de abertura ainda dizia 'login HTTP Basic em toda rota', contradizendo a secao 'Login e permissoes' mais abaixo no mesmo arquivo (ja correta, fala de login por sessao). Atualizada para 'login por sessao em toda rota'. 2. A lista de variaveis de ambiente para rodar localmente (secao 'Rodando localmente') nao mencionava SESSION_SECRET, mesmo a secao 'Login e permissoes' ja instruindo a defini-la. Adicionada a lista, com nota de que e opcional em dev (chave aleatoria em memoria a cada start, sessoes caem a cada restart) mas deve ser fixa em producao. 3. A mencao ao ALTER TABLE FUNCIONARIO_PAINEL ADD COLUMN PERFIL estava em tom de fato passado. Ajustada para o mesmo padrao de aviso ja usado para LOG_PAINEL/FUNCIONARIO_PAINEL nesse README ('precisa ser rodado manualmente em producao tambem'), deixando explicito que sem rodar isso todo login falha com 500 (a query de autenticacao seleciona uma coluna PERFIL que nao existe na tabela antiga). Sem teste automatizado - mudanca só de documentacao. Co-Authored-By: Claude Sonnet 5 --- README.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 05077b5..d271dc4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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 HTTP Basic em toda rota, 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. +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). @@ -21,11 +21,15 @@ 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`). Todo funcionário cadastrado antes dessa funcionalidade -existir virou admin automaticamente na migração (`ALTER TABLE -FUNCIONARIO_PAINEL ADD COLUMN PERFIL ...`, mesmo arquivo). O sistema nunca -deixa ficar sem nenhum admin ativo — rebaixar ou desativar o último admin é -bloqueado. +`/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. @@ -42,7 +46,7 @@ a tabela ficar vazia. Login checa primeiro o admin do `.env`, depois ``` 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` — veja `.env.example`) diretamente no shell antes de subir a aplicaçã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. +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 From 310f5e1f71acfb4a8c7ec6b36028058cf8905ac3 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 21:53:30 -0300 Subject: [PATCH 39/87] feat: sistema de design (tokens claro/escuro) + sidebar responsiva em base.html Co-Authored-By: Claude Sonnet 5 --- app/static/estilo.css | 231 ++++++++++++++++++++++++++++++++++++++++ app/static/sidebar.js | 39 +++++++ app/static/tema.js | 17 +++ app/templates/base.html | 117 +++++++++++++------- 4 files changed, 366 insertions(+), 38 deletions(-) create mode 100644 app/static/estilo.css create mode 100644 app/static/sidebar.js create mode 100644 app/static/tema.js diff --git a/app/static/estilo.css b/app/static/estilo.css new file mode 100644 index 0000000..e3a68a2 --- /dev/null +++ b/app/static/estilo.css @@ -0,0 +1,231 @@ +/* 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: #ffffff; + --cor-acento-suave: #fff1e4; + --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-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-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); } + +/* ---------- 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; + 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); 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); } +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--cor-borda); white-space: nowrap; } +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; +} +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); } +.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; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; } +} diff --git a/app/static/sidebar.js b/app/static/sidebar.js new file mode 100644 index 0000000..ef8a70e --- /dev/null +++ b/app/static/sidebar.js @@ -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"); + }); + } +})(); diff --git a/app/static/tema.js b/app/static/tema.js new file mode 100644 index 0000000..eca36fb --- /dev/null +++ b/app/static/tema.js @@ -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); + }); +})(); diff --git a/app/templates/base.html b/app/templates/base.html index 00ad7e1..00e3d53 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,46 +3,87 @@ + {% block titulo %}Painel de Clientes{% endblock %} - + + - -
- {% if mensagem %}
{{ mensagem }}
{% endif %} - {% block conteudo %}{% endblock %} -
+
+ + +
+
+ + {{ self.titulo() }} +
+
+ {% if mensagem %}
{{ mensagem }}
{% endif %} + {% block conteudo %}{% endblock %} +
+
+
+ + From e21b1646a7406ab6c3542edd5479fdc0af71638d Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 21:57:37 -0300 Subject: [PATCH 40/87] feat: redesign da tela de login com identidade GTI Co-Authored-By: Claude Sonnet 5 --- app/templates/login.html | 44 ++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/app/templates/login.html b/app/templates/login.html index 02141ee..7752063 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -1,30 +1,40 @@ - + + Entrar — Painel de Clientes + -
+
From 343f61ade750dff38a0329997dba39c3b1618265 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 22:01:11 -0300 Subject: [PATCH 41/87] feat: redesign da lista e formulario de cliente (cartoes, grid, tabela responsiva) Co-Authored-By: Claude Sonnet 5 --- app/templates/cliente_form.html | 43 +++++++++-------- app/templates/clientes_lista.html | 76 ++++++++++++++++--------------- 2 files changed, 65 insertions(+), 54 deletions(-) diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html index 014f932..a4166bf 100644 --- a/app/templates/cliente_form.html +++ b/app/templates/cliente_form.html @@ -24,24 +24,31 @@ Não foi possível salvar: {{ erro_gravacao }} {% endif %} -
- - {% for campo in campos %} - - {% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %} - - {% elif campo in ["IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA"] %} - - {% else %} - - {% if campo == "CNPJ" %} - - {% endif %} - {% endif %} - {% endfor %} -

* 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.

- -
+
+
+ +
+ {% for campo in campos %} + {% set largura_total = campo in ["OBS"] %} +
+ + {% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %} + + {% elif campo in ["IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA"] %} + + {% else %} + + {% if campo == "CNPJ" %} + + {% endif %} + {% endif %} +
+ {% endfor %} +
+

* 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.

+ +
+
From f6cf0f2e5375bf71595bf22bf413b3461680da4e Mon Sep 17 00:00:00 2001 From: Gustavo Date: Fri, 14 Aug 2026 08:56:20 -0300 Subject: [PATCH 50/87] fix: botao de ver senha no login ficava esticado pra largura total .login-cartao button { width: 100%; } tinha mais especificidade CSS que .botao-ver-senha e forcava o botao do olho a ocupar 100% da largura do campo, jogando o icone (alinhado por padrao a esquerda no flex) pro canto esquerdo por cima do input. Regra do width:100% agora so mira o botao de submit; o campo de senha vira um wrapper flex (input + botao lado a lado), mais robusto que posicionamento absoluto. Co-Authored-By: Claude Sonnet 5 --- app/templates/login.html | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/app/templates/login.html b/app/templates/login.html index ae4567e..ee561c8 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -17,16 +17,23 @@ .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 button { width: 100%; } - .campo-senha { position: relative; } - .campo-senha input { padding-right: 40px; } - .botao-ver-senha { - position: absolute; right: 4px; top: 50%; transform: translateY(-50%); - margin: 0; padding: 6px; width: auto; background: transparent; border: none; - color: var(--cor-texto-secundario); cursor: pointer; display: flex; align-items: center; + .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); } - .botao-ver-senha:hover { color: var(--cor-texto); } - .botao-ver-senha svg { width: 18px; height: 18px; } + .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; } From a3a84abd371428a62c4de341eee6a4a2e04546b6 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Fri, 14 Aug 2026 09:19:44 -0300 Subject: [PATCH 51/87] fix: campo codigo do cliente so aceita numeros + botoes de tabela nao quebram linha Gerador de chave: filtra caracteres nao numericos do campo codigo em tempo real (input event), alem da validacao ja existente no submit. Tabelas: a.botao/button ganham white-space: nowrap, corrigindo o botao 'Abrir' (e outros) quebrando em duas linhas depois que as celulas de dado passaram a permitir quebra de texto longo. Co-Authored-By: Claude Sonnet 5 --- app/static/estilo.css | 1 + app/templates/gerador_chave.html | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/app/static/estilo.css b/app/static/estilo.css index 51f6c8e..7929402 100644 --- a/app/static/estilo.css +++ b/app/static/estilo.css @@ -213,6 +213,7 @@ button, a.botao { 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); } diff --git a/app/templates/gerador_chave.html b/app/templates/gerador_chave.html index 6ef7b84..75fd13a 100644 --- a/app/templates/gerador_chave.html +++ b/app/templates/gerador_chave.html @@ -95,6 +95,10 @@ 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')}`; From 5c577a644a37a56aec9ce29ac51314c1a5aec080 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Fri, 14 Aug 2026 10:03:19 -0300 Subject: [PATCH 52/87] fix(seguranca): move algoritmo de geracao de chave do JS pro backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O algoritmo (CHAVE_INTERNA + funcoes gerarReativacao/gerarInstalacao) ficava 100% legivel no codigo-fonte da pagina — qualquer pessoa com acesso ao painel conseguia abrir o DevTools e extrair a logica pra gerar chaves de licenca fora do sistema. Move tudo pra app/gerador_chave.py (roda so no servidor) e expoe via POST /gerador-chave/gerar (autenticado, protegido por CSRF). A pagina agora so manda os dados do formulario e mostra o resultado que volta do backend. Algoritmo verificado byte-a-byte contra os 3 exemplos reais conhecidos (cliente 000001, ALUGUEL). Bonus: o byte inicial agora usa secrets.randbelow (CSPRNG) em vez de Math.random() do JS. Co-Authored-By: Claude Sonnet 5 --- app/gerador_chave.py | 56 +++++++++++++++++++ app/routers/chave_manual.py | 55 +++++++++++++++--- app/templates/gerador_chave.html | 92 +++++++++---------------------- tests/test_gerador_chave.py | 41 ++++++++++++++ tests/test_router_chave_manual.py | 85 ++++++++++++++++++++++++++-- 5 files changed, 253 insertions(+), 76 deletions(-) create mode 100644 app/gerador_chave.py create mode 100644 tests/test_gerador_chave.py diff --git a/app/gerador_chave.py b/app/gerador_chave.py new file mode 100644 index 0000000..b7ce02f --- /dev/null +++ b/app/gerador_chave.py @@ -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}" diff --git a/app/routers/chave_manual.py b/app/routers/chave_manual.py index 56abdbe..2b53043 100644 --- a/app/routers/chave_manual.py +++ b/app/routers/chave_manual.py @@ -1,19 +1,60 @@ """ -Calculadora de chave de reativação, para uso manual (reenvio/correção -pontual). 100% client-side (mesmo algoritmo gerarReativacao já usado no -workflow n8n e em Gerar chave WEB/index.html) — esta rota só serve a -página; NUNCA grava em Clientes_Chaves. +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. """ -from fastapi import APIRouter, Depends, Request -from fastapi.responses import HTMLResponse +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", {}) + 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}) diff --git a/app/templates/gerador_chave.html b/app/templates/gerador_chave.html index 75fd13a..3437a31 100644 --- a/app/templates/gerador_chave.html +++ b/app/templates/gerador_chave.html @@ -34,56 +34,11 @@ {% endblock %} diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index ab72093..1a5477a 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -187,6 +187,23 @@ def test_listar_clientes_inclui_csrf_token_no_contexto(monkeypatch): 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": [ + {"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": 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, From 4fd4649edd9de6511f5b2134395932f81802e2a7 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 20 Aug 2026 09:49:19 -0300 Subject: [PATCH 68/87] feat: coluna ATIVO em CLIENTE e filtro na camada de banco --- README.md | 2 ++ app/db/clientes.py | 67 +++++++++++++++++++++++++++------------ tests/test_db_clientes.py | 49 ++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d271dc4..3386ad8 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Proteções: login por sessão em toda rota (cookie assinado — ver seção "Lo `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 diff --git a/app/db/clientes.py b/app/db/clientes.py index 139e221..6899786 100644 --- a/app/db/clientes.py +++ b/app/db/clientes.py @@ -2,6 +2,21 @@ 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 @@ -11,7 +26,7 @@ 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", + "OBS", "VENCIMENTO_CONTRATO", "DIA_VENCIMENTO", "ATIVO", ] _COLUNAS_SELECT = ", ".join(["ID", *CAMPOS_CLIENTE]) @@ -42,26 +57,31 @@ def listar_clientes( 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: - if busca: - # 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. - sql = ( - f"SELECT {_COLUNAS_SELECT} FROM CLIENTE " - f"WHERE {coluna} LIKE %s ORDER BY {coluna} {direcao_sql} LIMIT %s OFFSET %s" - ) - cur.execute(sql, (f"%{busca}%", limite, offset)) - else: - cur.execute( - f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY {coluna} {direcao_sql} LIMIT %s OFFSET %s", - (limite, offset), - ) + 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() @@ -91,15 +111,22 @@ def proximo_codigo_sugerido() -> str: return str(proximo).zfill(6) -def contar_clientes(busca: str | None = None, ordenar_por: str = ORDENACAO_PADRAO) -> int: +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: - if busca: - cur.execute(f"SELECT COUNT(*) AS total FROM CLIENTE WHERE {coluna} LIKE %s", (f"%{busca}%",)) - else: - cur.execute("SELECT COUNT(*) AS total FROM CLIENTE") + cur.execute(f"SELECT COUNT(*) AS total FROM CLIENTE {where_sql}", params) return cur.fetchone()["total"] finally: conn.close() diff --git a/tests/test_db_clientes.py b/tests/test_db_clientes.py index b042b79..e52d499 100644 --- a/tests/test_db_clientes.py +++ b/tests/test_db_clientes.py @@ -32,6 +32,7 @@ CLIENTE_TESTE = { "OBS": "Criado por teste automatizado — apagar se sobrar.", "VENCIMENTO_CONTRATO": "2027-01-01", "DIA_VENCIMENTO": "10", + "ATIVO": "1", } @@ -163,6 +164,54 @@ def test_proximo_codigo_sugerido_ignora_faixa_de_teste(cliente_id): 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_excluir_cliente_com_contato_bloqueia(cliente_id): conn = get_connection() try: From 710d688d383e0a3248ac2fc81f32d34de471d71d Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 20 Aug 2026 09:52:28 -0300 Subject: [PATCH 69/87] feat: filtro de status (ativos/inativos/todos) na rota de listagem --- app/routers/clientes.py | 15 ++++- tests/test_main.py | 12 ++-- tests/test_router_clientes.py | 113 ++++++++++++++++++++++++++-------- 3 files changed, 105 insertions(+), 35 deletions(-) diff --git a/app/routers/clientes.py b/app/routers/clientes.py index 6c08a89..3f5d162 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -43,6 +43,9 @@ 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 "?" @@ -164,6 +167,7 @@ def listar( 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), @@ -172,17 +176,21 @@ def listar( 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) + 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 + 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 @@ -193,7 +201,7 @@ def listar( "clientes_lista.html", { "clientes": clientes, "busca": busca, "ordenar_por": ordenar_por, "direcao": direcao, - "mensagem": mensagem, "mensagem_tipo": mensagem_tipo, + "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, @@ -216,6 +224,7 @@ def listar( 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", diff --git a/tests/test_main.py b/tests/test_main.py index 3602c87..8c293ea 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -25,8 +25,8 @@ def test_docs_desabilitado_retorna_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": []) - monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None, ordenar_por="razao": 0) + 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" @@ -77,9 +77,9 @@ def test_pagina_clientes_tem_csrf_token_preenchido_no_botao_sair(monkeypatch): monkeypatch.setattr( router_clientes.db_clientes, "listar_clientes", - lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc": [], + 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": 0) + 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()) @@ -105,9 +105,9 @@ def test_logout_funciona_depois_de_visitar_clientes(monkeypatch): monkeypatch.setattr( router_clientes.db_clientes, "listar_clientes", - lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc": [], + 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": 0) + 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()) diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index 1a5477a..c2d925c 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -58,6 +58,7 @@ def _form_valido(codigo: str = "000001") -> dict: "DATA_CONTRATO": "2026-01-01", "VENCIMENTO_CONTRATO": "2027-01-01", "DIA_VENCIMENTO": "10", + "ATIVO": "1", "csrf_token": CSRF_TESTE, } ) @@ -103,9 +104,9 @@ def test_listar_clientes_por_pagina_padrao_e_15(monkeypatch): monkeypatch.setattr( router_clientes.db_clientes, "listar_clientes", - lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc": capturado.setdefault("limite", limite) and [], + 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": 3) + 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 @@ -117,9 +118,9 @@ def test_listar_clientes_por_pagina_da_query_e_usada_e_grava_cookie(monkeypatch) monkeypatch.setattr( router_clientes.db_clientes, "listar_clientes", - lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc": capturado.setdefault("limite", limite) and [], + 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": 3) + 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 @@ -132,9 +133,9 @@ def test_listar_clientes_usa_cookie_quando_sem_por_pagina_na_query(monkeypatch): monkeypatch.setattr( router_clientes.db_clientes, "listar_clientes", - lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc": capturado.setdefault("limite", limite) and [], + 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": 3) + 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 @@ -146,9 +147,9 @@ def test_listar_clientes_por_pagina_invalida_na_query_cai_no_padrao_e_nao_grava_ monkeypatch.setattr( router_clientes.db_clientes, "listar_clientes", - lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc": capturado.setdefault("limite", limite) and [], + 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": 3) + 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 @@ -156,15 +157,75 @@ def test_listar_clientes_por_pagina_invalida_na_query_cai_no_padrao_e_nao_grava_ 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": [ + 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": 1) + 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 @@ -174,9 +235,9 @@ 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": [], + 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": 0) + 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 @@ -191,11 +252,11 @@ 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": [ + 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": 1) + 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 @@ -208,12 +269,12 @@ 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": [ + 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": 250) + 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 @@ -226,12 +287,12 @@ 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": [ + 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": 98) + 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 @@ -243,7 +304,7 @@ def test_listar_clientes_footer_com_selecao_de_por_pagina(monkeypatch): 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": 500) + 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") @@ -254,12 +315,12 @@ def test_listar_clientes_paginacao_com_reticencias_quando_muitas_paginas(monkeyp 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"): + 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": 0) + 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" @@ -272,7 +333,7 @@ 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": 0) + 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 @@ -296,12 +357,12 @@ def test_busca_com_e_comercial_nao_quebra_links_de_paginacao_e_ordenacao(monkeyp monkeypatch.setattr( router_clientes.db_clientes, "listar_clientes", - lambda busca=None, limite=100, offset=0, ordenar_por="razao", direcao="asc": [ + 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": 250) + 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 @@ -312,12 +373,12 @@ def test_busca_com_e_comercial_nao_quebra_links_de_paginacao_e_ordenacao(monkeyp 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"): + 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": 0) + 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" From 0fdf4ab4e39766f54437259322946a694c9b01cb Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 20 Aug 2026 09:53:55 -0300 Subject: [PATCH 70/87] feat: campo Ativo no cadastro e filtro de status na listagem de clientes --- app/templates/cliente_form.html | 5 ++++ app/templates/clientes_lista.html | 11 ++++++-- tests/test_router_clientes.py | 46 +++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html index 35d3bdd..4503426 100644 --- a/app/templates/cliente_form.html +++ b/app/templates/cliente_form.html @@ -46,6 +46,11 @@ {% elif campo in ["IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA"] %} + {% elif campo == "ATIVO" %} + {% else %} {% if campo == "CNPJ" %} diff --git a/app/templates/clientes_lista.html b/app/templates/clientes_lista.html index b8d83dc..17b5d82 100644 --- a/app/templates/clientes_lista.html +++ b/app/templates/clientes_lista.html @@ -13,12 +13,18 @@ + +

+ Novo cliente

{% set busca_qs = ('&busca=' + (busca | urlencode)) if busca else '' %} - {% set qs_base = ('&ordenar_por=' + ordenar_por + '&direcao=' + direcao + '&por_pagina=' + (por_pagina | string) + busca_qs) | safe %} + {% set qs_base = ('&ordenar_por=' + ordenar_por + '&direcao=' + direcao + '&por_pagina=' + (por_pagina | string) + '&status=' + status + busca_qs) | safe %}
@@ -26,7 +32,7 @@ {% for campo, rotulo in colunas_tabela %} {% set nova_direcao = "desc" if (ordenar_por == campo and direcao == "asc") else "asc" %} @@ -53,6 +59,7 @@ + {% if busca %}{% endif %} {% for c in clientes %} @@ -45,6 +46,20 @@ +
- + {{ rotulo }}{% if ordenar_por == campo %}{{ " ▲" if direcao == "asc" else " ▼" }}{% endif %} Status
{{ c.RAZAO }} {{ c.FANTASIA }} {{ c.CNPJ }} +
+ + + +
+
Abrir diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index 81b92af..daf8175 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -715,6 +715,73 @@ def test_excluir_cliente_com_dependencia_mostra_erro(monkeypatch): 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: From 4ec3c8df8894c9e90faf70e6e227df0f4eb22ccf Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 20 Aug 2026 10:52:48 -0300 Subject: [PATCH 73/87] feat: limite de 20 registros e botao de limpar log (admin) --- app/routers/log.py | 43 +++++++++++++++++++++++---- app/templates/log.html | 6 ++++ tests/test_router_log.py | 63 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/app/routers/log.py b/app/routers/log.py index 8d929ab..d5c775d 100644 --- a/app/routers/log.py +++ b/app/routers/log.py @@ -2,25 +2,58 @@ 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 +from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from .. import n8n_client -from ..auth import exigir_login +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, usuario: str = Depends(exigir_login)): - acoes = db_log.listar(limite=100) +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()}, + { + "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.") diff --git a/app/templates/log.html b/app/templates/log.html index e1a0537..19ecac0 100644 --- a/app/templates/log.html +++ b/app/templates/log.html @@ -5,6 +5,12 @@

Log

Ações no painel

+ {% if eh_admin %} +
+ + +
+ {% endif %} {% if acoes %}
diff --git a/tests/test_router_log.py b/tests/test_router_log.py index cd64041..5a1020e 100644 --- a/tests/test_router_log.py +++ b/tests/test_router_log.py @@ -1,20 +1,38 @@ +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from app.auth import exigir_login +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: "teste" +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: [ + 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, @@ -29,10 +47,11 @@ def test_pagina_log_mostra_acoes_do_painel(monkeypatch): 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=100: []) + monkeypatch.setattr(router_log.db_log, "listar", lambda limite=20: []) monkeypatch.setattr( router_log.n8n_client, "listar_envios", @@ -50,3 +69,39 @@ def test_pagina_log_mostra_envios_quando_configurado(monkeypatch): 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 From ca5db5b46f101a21e51ee93d76625a3e753b5233 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 20 Aug 2026 11:33:41 -0300 Subject: [PATCH 74/87] docs: spec das secoes escondidas na tela de log --- ...2026-08-20-log-secoes-escondidas-design.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-20-log-secoes-escondidas-design.md diff --git a/docs/superpowers/specs/2026-08-20-log-secoes-escondidas-design.md b/docs/superpowers/specs/2026-08-20-log-secoes-escondidas-design.md new file mode 100644 index 0000000..f1aae13 --- /dev/null +++ b/docs/superpowers/specs/2026-08-20-log-secoes-escondidas-design.md @@ -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 `
` 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 ``) é 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 ``. Como ele está dentro + do ``, clicar nele já aciona o toggle nativo do `
` — + não precisa de lógica de toggle separada. Um script pequeno escuta o + evento `toggle` de cada `
` 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 `
`/``, + adicionar o botão Mostrar/Ocultar e a seta indicadora dentro do `summary`. +- `app/static/estilo.css` — estilo do `
`/`` (remoção do + marcador padrão do navegador, seta customizada que gira com `[open]`, + espaçamento do botão dentro do `summary`). +- Um pequeno ` +{% endblock %} +``` + +Notas importantes: +- O ` - - {% endif %} - {% if acoes %} -
-
- - {% for a in acoes %} - - - - - - - - - {% endfor %} -
QuandoUsuárioAçãoEntidadeStatusDetalhe
{{ a.CRIADO_EM }}{{ a.USUARIO }}{{ a.ACAO }}{{ a.ENTIDADE }}{% if a.ENTIDADE_ID %} #{{ a.ENTIDADE_ID }}{% endif %}{{ "OK" if a.SUCESSO else "ERRO" }}{{ a.DETALHE or "" }}
+
+ + Ações no painel + + +
+ {% if eh_admin %} +
+ + +
+ {% endif %} + {% if acoes %} +
+ + + {% for a in acoes %} + + + + + + + + + {% endfor %} +
QuandoUsuárioAçãoEntidadeStatusDetalhe
{{ a.CRIADO_EM }}{{ a.USUARIO }}{{ a.ACAO }}{{ a.ENTIDADE }}{% if a.ENTIDADE_ID %} #{{ a.ENTIDADE_ID }}{% endif %}{{ "OK" if a.SUCESSO else "ERRO" }}{{ a.DETALHE or "" }}
+
+ {% else %} +

Nenhuma ação registrada ainda.

+ {% endif %}
- {% else %} -

Nenhuma ação registrada ainda.

- {% endif %} +
-

Envios automáticos de chave (n8n)

- {% if not envios_configurado %} -
-

- Integração com o n8n não configurada — defina N8N_API_URL e - N8N_API_KEY no ambiente (ver .env.example) para ver - aqui os envios automáticos de chave feitos pelo workflow do n8n. -

+
+ + Envios automáticos de chave (n8n) + + +
+ {% if not envios_configurado %} +
+

+ Integração com o n8n não configurada — defina N8N_API_URL e + N8N_API_KEY no ambiente (ver .env.example) para ver + aqui os envios automáticos de chave feitos pelo workflow do n8n. +

+
+ {% elif not envios %} +

Nenhum envio encontrado (ou não foi possível consultar o n8n agora).

+ {% else %} +
+ + + + + + {% for e in envios %} + + + + + + + + + + {% endfor %} +
DataClienteCódigoCompetênciaStatusTelefones enviadosTelefones com erro
{{ e.data_processamento }}{{ e.nome_cliente }}{{ e.codigo_cliente }}{{ e.competencia }}{{ e.status }}{{ e.telefones_enviados }}{{ e.telefones_com_erro or "" }}
+
+ {% endif %}
- {% elif not envios %} -

Nenhum envio encontrado (ou não foi possível consultar o n8n agora).

- {% else %} -
- - - - - - {% for e in envios %} - - - - - - - - - - {% endfor %} -
DataClienteCódigoCompetênciaStatusTelefones enviadosTelefones com erro
{{ e.data_processamento }}{{ e.nome_cliente }}{{ e.codigo_cliente }}{{ e.competencia }}{{ e.status }}{{ e.telefones_enviados }}{{ e.telefones_com_erro or "" }}
-
- {% endif %} +
+ + {% endblock %} diff --git a/tests/test_router_log.py b/tests/test_router_log.py index 5a1020e..0b8d101 100644 --- a/tests/test_router_log.py +++ b/tests/test_router_log.py @@ -105,3 +105,16 @@ 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('
') == 2 + assert "
" not in texto + assert "
" not in texto From d2135bce5db40e4677494f024cb14eeb4786f99c Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 20 Aug 2026 11:48:54 -0300 Subject: [PATCH 77/87] fix: botao mostrar/ocultar do log nao abria (elemento interativo dentro de summary nao propaga toggle nativo) --- app/templates/log.html | 9 +++++++++ tests/test_router_log.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/app/templates/log.html b/app/templates/log.html index 379f0c3..65c45eb 100644 --- a/app/templates/log.html +++ b/app/templates/log.html @@ -86,6 +86,15 @@ botao.textContent = aberto ? "Ocultar" : "Mostrar"; botao.setAttribute("aria-expanded", aberto ? "true" : "false"); }; + // Um
` 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). From 9b7210d41c0b689645d0db6ca6242568f4478a16 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 20 Aug 2026 12:09:55 -0300 Subject: [PATCH 79/87] docs: plano da melhoria de ui/ux da tabela de clientes --- .../plans/2026-08-20-tabela-clientes-ui.md | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-20-tabela-clientes-ui.md diff --git a/docs/superpowers/plans/2026-08-20-tabela-clientes-ui.md b/docs/superpowers/plans/2026-08-20-tabela-clientes-ui.md new file mode 100644 index 0000000..94b67ca --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-tabela-clientes-ui.md @@ -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 '' 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 é `
` 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 `
` e alinhar os `
`/`` com classes de coluna. Substituir +o trecho: + +```html +
+ + + {% 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" %} + + {% endfor %} + + + + {% for c in clientes %} + + + + + + + + + {% endfor %} +
+ + {{ rotulo }}{% if ordenar_por == campo %}{{ " ▲" if direcao == "asc" else " ▼" }}{% endif %} + + Status
{{ c.CODIGO }}{{ c.RAZAO }}{{ c.FANTASIA }}{{ c.CNPJ }} +
+ + + +
+
+ Abrir + + +
+
+``` + +por: + +```html +
+ + + {% 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" %} + + {% endfor %} + + + + {% for c in clientes %} + + + + + + + + + {% endfor %} +
+ + {{ rotulo }}{% if ordenar_por == campo %}{{ " ▲" if direcao == "asc" else " ▼" }}{% endif %} + + Status
{{ c.CODIGO }}{{ c.RAZAO }}{{ c.FANTASIA }}{{ c.CNPJ }} +
+ + + +
+
+ Abrir + + +
+
+``` + +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 `` do template (Step 3), adicionar um +`` logo após a abertura da tag, antes da primeira ``, para +que as larguras acima sejam aplicadas: + +```html +
+ + + + + + + + + +``` + +- [ ] **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 `` + 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. From 48d17d15b5302e267153c458520e2cf6efdadb2d Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 20 Aug 2026 13:35:05 -0300 Subject: [PATCH 80/87] feat: ajusta colunas, alinhamento e densidade da tabela de clientes --- app/static/estilo.css | 25 +++++++++++++++++++++ app/templates/clientes_lista.html | 36 +++++++++++++++++++------------ tests/test_router_clientes.py | 9 ++++++++ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/app/static/estilo.css b/app/static/estilo.css index e858973..8d705ec 100644 --- a/app/static/estilo.css +++ b/app/static/estilo.css @@ -271,6 +271,31 @@ a.botao:hover { border-color: var(--cor-acento); color: var(--cor-acento); } .botao-toggle-log:hover { border-color: var(--cor-acento); color: var(--cor-acento); } .secao-log-conteudo { padding-top: 4px; } +.table-clientes { table-layout: fixed; } + +.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.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; } } diff --git a/app/templates/clientes_lista.html b/app/templates/clientes_lista.html index 9e76ab9..03ef5ae 100644 --- a/app/templates/clientes_lista.html +++ b/app/templates/clientes_lista.html @@ -26,27 +26,35 @@ {% 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 %}
-
+
+ + + + + + + + - {% set colunas_tabela = [("codigo", "Código"), ("razao", "Razão"), ("fantasia", "Fantasia"), ("cnpj", "CNPJ")] %} - {% for campo, rotulo in colunas_tabela %} + {% 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" %} - {% endfor %} - - + + {% for c in clientes %} - - - - - + + + + - diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index daf8175..9bd1be0 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -951,3 +951,12 @@ def test_criar_cliente_grava_uf_maiuscula_e_cep_sem_traco(monkeypatch): 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 '
+ {{ rotulo }}{% if ordenar_por == campo %}{{ " ▲" if direcao == "asc" else " ▼" }}{% endif %} StatusStatus
{{ c.CODIGO }}{{ c.RAZAO }}{{ c.FANTASIA }}{{ c.CNPJ }} + {{ c.CODIGO }}{{ c.RAZAO }}{{ c.FANTASIA }}{{ c.CNPJ }}
@@ -60,9 +68,9 @@
- Abrir - + + Abrir +
' in resposta.text From ab8912fce704827ef08736dc37d27117f5760544 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 20 Aug 2026 13:52:07 -0300 Subject: [PATCH 81/87] fix: adiciona cache-busting nos arquivos estaticos (css/js) para evitar layout desatualizado apos deploy --- app/asset_versao.py | 29 +++++++++++++++++++++++++++++ app/main.py | 2 ++ app/templates/base.html | 6 +++--- app/templates/login.html | 2 +- tests/test_main.py | 9 +++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 app/asset_versao.py diff --git a/app/asset_versao.py b/app/asset_versao.py new file mode 100644 index 0000000..6172853 --- /dev/null +++ b/app/asset_versao.py @@ -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() diff --git a/app/main.py b/app/main.py index 67c397e..bd09810 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ 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 .routers import auth, chave_manual, clientes, contatos, funcionarios, log, minha_conta @@ -103,6 +104,7 @@ async def carregar_usuario_middleware(request: Request, call_next): 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) diff --git a/app/templates/base.html b/app/templates/base.html index b1729a2..95b7aab 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,7 +6,7 @@ {% block titulo %}Painel de Clientes{% endblock %} - + - + + diff --git a/app/templates/login.html b/app/templates/login.html index 4504deb..2ac5527 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -6,7 +6,7 @@ Entrar — Painel de Clientes - + + + +
+ + +

Abrindo o GTI Fiscal…

+ + + + + diff --git a/requirements.txt b/requirements.txt index a0d80af..1aae0b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,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 diff --git a/tests/test_sso.py b/tests/test_sso.py new file mode 100644 index 0000000..c9c82ee --- /dev/null +++ b/tests/test_sso.py @@ -0,0 +1,112 @@ +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" +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?next=/agents") + 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"]}) + 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")), 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?next=/jobs", follow_redirects=False) + assert resposta.status_code == 303 + destino = urlsplit(resposta.headers["location"]) + assert destino.path == "/login" + assert parse_qs(destino.query)["next"] == ["/sso/fiscal?next=%2Fjobs"] + + +def test_funcionario_inativo_nao_recebe_assercao(chave, monkeypatch): + resposta = _cliente(funcionario={**ATIVO, "ATIVO": 0}, monkeypatch=monkeypatch).get("/sso/fiscal", 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", 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?next=/blockers", follow_redirects=False) + assert resposta.status_code == 303 + assert resposta.headers["location"] == "https://gtigerenciador.gtigrupo.com.br/sso/fiscal?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}) + 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") + + +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") + 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"] From 161da90e020df760d25d72cc28c4b266da854ca0 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 23 Sep 2026 12:21:27 -0300 Subject: [PATCH 85/87] feat: bind the GTI Fiscal SSO assertion to the browser (state/nonce) /sso/fiscal now requires the state that GTI Fiscal creates when the flow starts and signs it as the "nonce" claim; GTI Fiscal checks it against its own one-time cookie. Without a valid state the panel sends the browser to GTI Fiscal's /login, so the flow always starts there. This prevents someone from pushing an assertion for their own account into another person's browser (login CSRF). Co-Authored-By: Claude Opus 5.5 --- app/routers/sso.py | 15 ++++++++++----- app/sso.py | 20 +++++++++++++++++--- tests/test_sso.py | 35 +++++++++++++++++++++++------------ 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/app/routers/sso.py b/app/routers/sso.py index ffe317f..d6753db 100644 --- a/app/routers/sso.py +++ b/app/routers/sso.py @@ -34,29 +34,34 @@ def _pagina_erro(mensagem: str, status_code: int) -> HTMLResponse: @router.get("/sso/fiscal") -def iniciar_sso_fiscal(request: Request, next: str | None = None): +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?{urlencode({'next': destino})}", status_code=303) + 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: - volta = f"/sso/fiscal?{urlencode({'next': destino})}" - return RedirectResponse(f"/login?{urlencode({'next': volta})}", status_code=303) + 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) + 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} diff --git a/app/sso.py b/app/sso.py index 413d78c..4ad7d3a 100644 --- a/app/sso.py +++ b/app/sso.py @@ -7,7 +7,8 @@ funcionário e entrega ao Fiscal uma asserção assinada, de vida curta. tem a chave pública, então não consegue emitir asserções. - Claims: iss/aud fixos, sub = "funcionario:" (o ID de FUNCIONARIO_PAINEL — o LOGIN é editável, então não serve de identificador), - iat/nbf/exp (60 s) e jti único, que o Fiscal grava para recusar replay. + 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 @@ -18,9 +19,10 @@ nem nenhuma outra credencial. """ import base64 import os +import re import time import uuid -from urllib.parse import urlsplit +from urllib.parse import urlencode, urlsplit import jwt from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey @@ -50,6 +52,17 @@ def origem_callback() -> str: 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 ( @@ -70,7 +83,7 @@ def _chave_privada() -> Ed25519PrivateKey: return Ed25519PrivateKey.from_private_bytes(semente) -def emitir_assercao(funcionario: dict, agora: float | None = None) -> str: +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, @@ -80,6 +93,7 @@ def emitir_assercao(funcionario: dict, agora: float | None = None) -> str: "nbf": momento, "exp": momento + VALIDADE_SEGUNDOS, "jti": str(uuid.uuid4()), + "nonce": nonce, "login": funcionario["LOGIN"], "name": funcionario["NOME"], "perfil": funcionario["PERFIL"], diff --git a/tests/test_sso.py b/tests/test_sso.py index c9c82ee..451a0a3 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -14,6 +14,7 @@ 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} @@ -42,10 +43,11 @@ def _assercao(resposta): def test_funcionario_ativo_recebe_formulario_com_assercao_valida(chave, monkeypatch): - resposta = _cliente(monkeypatch=monkeypatch).get("/sso/fiscal?next=/agents") + 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"]}) + 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) @@ -58,55 +60,64 @@ def test_funcionario_ativo_recebe_formulario_com_assercao_valida(chave, monkeypa def test_cada_emissao_tem_jti_diferente(chave, monkeypatch): cliente = _cliente(monkeypatch=monkeypatch) - a, b = (jwt.decode(_assercao(cliente.get("/sso/fiscal")), chave, algorithms=["EdDSA"], audience="gti-fiscal") for _ in range(2)) + 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?next=/jobs", follow_redirects=False) + 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"] == ["/sso/fiscal?next=%2Fjobs"] + 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", follow_redirects=False) + 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", follow_redirects=False) + 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?next=/blockers", follow_redirects=False) + 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"] == "https://gtigerenciador.gtigrupo.com.br/sso/fiscal?next=%2Fblockers" + 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}) + 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") + _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") + 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 From b1f93c2d6853a000cc85e0517b0d834c339105b4 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 23 Sep 2026 22:36:40 -0300 Subject: [PATCH 86/87] feat: read-only internal endpoint for GTI Fiscal to mirror clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /internal/fiscal/clients returns every CLIENTE (active and inactive) as {external_client_id, code, display_name, active} — CLIENTE.ID is the identity (CODIGO is editable, same reasoning as FUNCIONARIO_PAINEL.ID in SSO). Authenticated by a dedicated bearer token (FISCAL_INTERNAL_API_TOKEN, never reused from SESSION_SECRET/SSO key/admin password), rate-limited per IP, returns 503 rather than opening anonymously when the token isn't configured. GTI Gerenciador stays the single source of truth: the Fiscal Hub never receives MySQL credentials. Co-Authored-By: Claude Sonnet 5 --- .env.example | 7 +++ app/db/clientes.py | 22 ++++++++++ app/internal_fiscal.py | 66 ++++++++++++++++++++++++++++ app/main.py | 2 + tests/test_internal_fiscal.py | 81 +++++++++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 app/internal_fiscal.py create mode 100644 tests/test_internal_fiscal.py diff --git a/.env.example b/.env.example index f7d1df8..2428135 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,13 @@ 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). diff --git a/app/db/clientes.py b/app/db/clientes.py index 265a220..039c7a3 100644 --- a/app/db/clientes.py +++ b/app/db/clientes.py @@ -87,6 +87,28 @@ def listar_clientes( 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). Sem CNPJ/ + endereço/contato: o Fiscal não precisa desses campos hoje.""" + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "SELECT ID, CODIGO, 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"], "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 diff --git a/app/internal_fiscal.py b/app/internal_fiscal.py new file mode 100644 index 0000000..74e96d8 --- /dev/null +++ b/app/internal_fiscal.py @@ -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()} diff --git a/app/main.py b/app/main.py index be28942..bdd67e6 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ 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) @@ -23,6 +24,7 @@ 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) diff --git a/tests/test_internal_fiscal.py b/tests/test_internal_fiscal.py new file mode 100644 index 0000000..b9c2be0 --- /dev/null +++ b/tests/test_internal_fiscal.py @@ -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", "active": True} + assert by_id[inativo_id] == {"external_client_id": inativo_id, "code": "999951", "display_name": "Inativo Fiscal Teste Razao", "active": False} + # never leaks fields the Fiscal doesn't need + assert set(by_id[ativo_id]) == {"external_client_id", "code", "display_name", "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 From 34cc9c75a57dedbf61a6e6a1ba1f35f76e72434b Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 24 Sep 2026 08:08:06 -0300 Subject: [PATCH 87/87] feat: include CNPJ (digits) in the internal Fiscal client payload Needed by the Fiscal's searchable client combobox (search by name/code/CNPJ). Co-Authored-By: Claude Sonnet 5 --- app/db/clientes.py | 8 +- docs/GTI_GERENCIADOR_DISCOVERY_V1.md | 698 +++++++++++++++++++++++++++ tests/test_internal_fiscal.py | 6 +- 3 files changed, 705 insertions(+), 7 deletions(-) create mode 100644 docs/GTI_GERENCIADOR_DISCOVERY_V1.md diff --git a/app/db/clientes.py b/app/db/clientes.py index 039c7a3..0470f0b 100644 --- a/app/db/clientes.py +++ b/app/db/clientes.py @@ -92,17 +92,17 @@ def listar_clientes_para_fiscal() -> list[dict]: 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). Sem CNPJ/ - endereço/contato: o Fiscal não precisa desses campos hoje.""" + 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, COALESCE(NULLIF(FANTASIA, ''), RAZAO) AS NOME, ATIVO " + "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"], "active": bool(row["ATIVO"])} + {"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: diff --git a/docs/GTI_GERENCIADOR_DISCOVERY_V1.md b/docs/GTI_GERENCIADOR_DISCOVERY_V1.md new file mode 100644 index 0000000..6c96b74 --- /dev/null +++ b/docs/GTI_GERENCIADOR_DISCOVERY_V1.md @@ -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 ``. +- **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 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. +``` diff --git a/tests/test_internal_fiscal.py b/tests/test_internal_fiscal.py index b9c2be0..b374bf1 100644 --- a/tests/test_internal_fiscal.py +++ b/tests/test_internal_fiscal.py @@ -50,10 +50,10 @@ def test_returns_active_and_inactive_clients_with_minimal_fields(client, cliente 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", "active": True} - assert by_id[inativo_id] == {"external_client_id": inativo_id, "code": "999951", "display_name": "Inativo Fiscal Teste Razao", "active": False} + 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", "active"} + assert set(by_id[ativo_id]) == {"external_client_id", "code", "display_name", "cnpj", "active"} def test_missing_token_rejected(client, clientes_teste):