Add 'gerenciador/' from commit '34cc9c75a57dedbf61a6e6a1ba1f35f76e72434b'

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

After

Width:  |  Height:  |  Size: 12 KiB

+39
View File
@@ -0,0 +1,39 @@
// app/static/sidebar.js — abre/fecha a gaveta da sidebar no mobile e
// recolhe/expande no desktop, persistindo a preferência de recolher.
(function () {
var sidebar = document.getElementById("sidebar");
var backdrop = document.getElementById("sidebar-backdrop");
var btnMenuMobile = document.getElementById("btn-menu-mobile");
var btnRecolher = document.getElementById("botao-recolher");
if (!sidebar) return;
function abrirGaveta() {
sidebar.classList.add("aberta");
if (backdrop) backdrop.classList.add("aberta");
}
function fecharGaveta() {
sidebar.classList.remove("aberta");
if (backdrop) backdrop.classList.remove("aberta");
}
if (btnMenuMobile) {
btnMenuMobile.addEventListener("click", function () {
if (sidebar.classList.contains("aberta")) fecharGaveta();
else abrirGaveta();
});
}
if (backdrop) backdrop.addEventListener("click", fecharGaveta);
sidebar.querySelectorAll("a").forEach(function (link) {
link.addEventListener("click", fecharGaveta);
});
if (btnRecolher) {
if (localStorage.getItem("sidebarRecolhida") === "1") {
sidebar.classList.add("recolhida");
}
btnRecolher.addEventListener("click", function () {
var recolhida = sidebar.classList.toggle("recolhida");
localStorage.setItem("sidebarRecolhida", recolhida ? "1" : "0");
});
}
})();
+17
View File
@@ -0,0 +1,17 @@
// app/static/tema.js — alterna e persiste o tema claro/escuro do painel.
(function () {
var botao = document.getElementById("botao-tema");
if (!botao) return;
function temaAtual() {
var salvo = localStorage.getItem("tema");
if (salvo === "claro" || salvo === "escuro") return salvo;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "escuro" : "claro";
}
botao.addEventListener("click", function () {
var novo = temaAtual() === "escuro" ? "claro" : "escuro";
document.documentElement.setAttribute("data-tema", novo);
localStorage.setItem("tema", novo);
});
})();
+94
View File
@@ -0,0 +1,94 @@
<!-- app/templates/base.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titulo %}Painel de Clientes{% endblock %}</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/estilo.css?v={{ request.state.asset_versao }}">
<script>
(function () {
var tema = localStorage.getItem("tema");
if (tema === "claro" || tema === "escuro") {
document.documentElement.setAttribute("data-tema", tema);
}
})();
</script>
</head>
<body>
<div class="layout">
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-topo">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="sidebar-logo">
<span class="sidebar-titulo">Painel de Clientes</span>
</div>
<nav class="sidebar-nav">
<a href="/clientes" class="{{ 'ativo' if request.url.path.startswith('/clientes') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
<span>Clientes</span>
</a>
<a href="/gerador-chave" class="{{ 'ativo' if request.url.path.startswith('/gerador-chave') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/></svg>
<span>Gerador de chave</span>
</a>
{% if request.state.usuario and request.state.usuario.eh_admin %}
<a href="/funcionarios" class="{{ 'ativo' if request.url.path.startswith('/funcionarios') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="7" r="4"/><path d="M2 21v-2a5 5 0 0 1 5-5h4a5 5 0 0 1 5 5v2M17 11l2 2 4-4"/></svg>
<span>Funcionários</span>
</a>
{% endif %}
<a href="/log" class="{{ 'ativo' if request.url.path.startswith('/log') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4"/><path d="M9 15h6M9 11h10M9 7h4"/></svg>
<span>Log</span>
</a>
<a href="/sso/fiscal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<span>Fiscal</span>
</a>
{% if request.state.usuario %}
<a href="/minha-conta" class="{{ 'ativo' if request.url.path.startswith('/minha-conta') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>
<span>Minha conta</span>
</a>
{% endif %}
</nav>
<div class="sidebar-rodape">
{% if request.state.usuario %}
<div class="sidebar-usuario">{{ request.state.usuario.nome }}</div>
<button type="button" class="botao-tema" id="botao-tema" aria-label="Alternar tema claro/escuro">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<span>Tema claro/escuro</span>
</button>
<form method="post" action="/logout" class="form-logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token or request.state.csrf_token }}">
<button type="submit" class="botao-sair">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>
<span>Sair</span>
</button>
</form>
{% endif %}
<button type="button" class="botao-recolher" id="botao-recolher" aria-label="Recolher menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19l-7-7 7-7M18 19l-7-7 7-7"/></svg>
<span>Recolher</span>
</button>
</div>
</aside>
<div class="conteudo-area">
<header class="topbar-mobile">
<button type="button" class="topbar-menu-btn" id="btn-menu-mobile" aria-label="Abrir menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:22px;height:22px"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
<span class="topbar-titulo">{{ self.titulo() }}</span>
</header>
<main class="conteudo">
{% if mensagem %}<div class="mensagem {{ mensagem_tipo or 'success' }}">{{ mensagem }}</div>{% endif %}
{% block conteudo %}{% endblock %}
</main>
</div>
</div>
<script src="/static/sidebar.js?v={{ request.state.asset_versao }}" defer></script>
<script src="/static/tema.js?v={{ request.state.asset_versao }}" defer></script>
</body>
</html>
@@ -0,0 +1,69 @@
<!-- app/templates/cliente_detalhe.html -->
{% extends "base.html" %}
{% block titulo %}{{ cliente.RAZAO }}{% endblock %}
{% block conteudo %}
<h1>{{ cliente.FANTASIA }} <small style="color:var(--cor-texto-secundario)">({{ cliente.CODIGO }})</small></h1>
<p style="color:var(--cor-texto-secundario)">{{ cliente.RAZAO }} — {{ cliente.CNPJ }}</p>
<p>
<a class="botao" href="/clientes/{{ cliente.ID }}/editar">Editar</a>
<form method="post" action="/clientes/{{ cliente.ID }}/excluir" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Excluir este cliente?')">Excluir</button>
</form>
</p>
<h2>Contatos</h2>
<div class="tabela-wrap">
<table>
<tr><th>Nome</th><th>Telefone</th><th>E-mail</th><th></th></tr>
{% for ct in contatos %}
<tr>
<td>{{ ct.NOME }}</td><td>{{ ct.TELEFONE }}</td><td>{{ ct.EMAIL or "" }}</td>
<td>
<details style="display:inline-block">
<summary class="botao" style="display:inline-block;cursor:pointer">Editar</summary>
<form method="post" action="/clientes/{{ cliente.ID }}/contatos/{{ ct.ID }}/editar" style="margin-top:8px">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="nome-{{ ct.ID }}">Nome</label>
<input type="text" id="nome-{{ ct.ID }}" name="nome" value="{{ ct.NOME or '' }}">
<label for="telefone-{{ ct.ID }}">Telefone</label>
<input type="text" id="telefone-{{ ct.ID }}" name="telefone" value="{{ ct.TELEFONE }}" required>
<label for="email-{{ ct.ID }}">E-mail</label>
<input type="text" id="email-{{ ct.ID }}" name="email" value="{{ ct.EMAIL or '' }}">
<button type="submit">Salvar</button>
</form>
</details>
<form method="post" action="/clientes/{{ cliente.ID }}/contatos/{{ ct.ID }}/excluir" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Excluir este contato?')">Excluir</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
<div class="cartao">
<form method="post" action="/clientes/{{ cliente.ID }}/contatos">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="nome">Nome</label>
<input type="text" id="nome" name="nome">
<label for="telefone">Telefone</label>
<input type="text" id="telefone" name="telefone" required>
<label for="email">E-mail</label>
<input type="text" id="email" name="email">
<button type="submit">Adicionar contato</button>
</form>
</div>
<h2>Status em Clientes_Chaves</h2>
{% if status_chaves %}
<div class="tabela-wrap">
<table>
<tr>{% for mes in status_chaves.meses %}<th>{{ mes }}</th>{% endfor %}</tr>
<tr>{% for mes, valor in status_chaves.meses.items() %}<td>{{ "✓" if valor else "—" }}</td>{% endfor %}</tr>
</table>
</div>
{% else %}
<p>Nenhuma linha em Clientes_Chaves para o código {{ cliente.CODIGO }}.</p>
{% endif %}
{% endblock %}
+229
View File
@@ -0,0 +1,229 @@
<!-- app/templates/cliente_form.html -->
{% extends "base.html" %}
{% block titulo %}{{ "Editar" if cliente else "Novo" }} cliente{% endblock %}
{% block conteudo %}
<h1>{{ "Editar" if cliente else "Novo" }} cliente</h1>
{% if erro_codigo_duplicado %}
<div class="mensagem danger">
O código {{ dados.CODIGO }} já está em uso por outro cliente — escolha outro código antes de salvar.
</div>
{% endif %}
{% if erro_cnpj_duplicado %}
<div class="mensagem danger">
O CNPJ/CPF {{ dados.CNPJ }} já está cadastrado para outro cliente.
</div>
{% endif %}
{% if aviso_dv_invalido %}
<div class="mensagem warning">
O dígito verificador do documento ({{ dados.CNPJ }}) não confere — confirme se digitou certo antes de salvar.
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for campo, valor in dados.items() %}<input type="hidden" name="{{ campo }}" value="{{ valor }}">{% endfor %}
<input type="hidden" name="confirmar_dv_invalido" value="1">
<button type="submit">Cadastrar mesmo assim</button>
</form>
</div>
{% endif %}
{% if erro_cnpj_invalido %}
<div class="mensagem danger">
O dígito verificador do CNPJ ({{ dados.CNPJ }}) não confere — corrija antes de salvar.
</div>
{% endif %}
{% if erro_gravacao %}
<div class="mensagem danger">
Não foi possível salvar: {{ erro_gravacao }}
</div>
{% endif %}
<div class="cartao">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid-form">
{% for campo in campos %}
{% set largura_total = campo in ["OBS"] %}
<div class="{{ 'grid-form-largura-total' if largura_total }}">
<label for="{{ campo }}">{{ campo }}{% if campo in campos_obrigatorios %} *{% endif %}</label>
{% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %}
<input type="date" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" {% if campo in campos_obrigatorios %}required{% endif %}>
{% elif campo in ["IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA"] %}
<input type="number" step="0.01" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" {% if campo in campos_obrigatorios %}required{% endif %}>
{% elif campo == "ATIVO" %}
<select id="{{ campo }}" name="{{ campo }}">
<option value="1" {% if (dados[campo] if dados else "1")|string == "1" %}selected{% endif %}>Ativo</option>
<option value="0" {% if (dados[campo] if dados else "1")|string == "0" %}selected{% endif %}>Inativo</option>
</select>
{% else %}
<input type="text" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" {% if campo in campos_obrigatorios %}required{% endif %}>
{% if campo == "CNPJ" %}
<small id="cnpj-status" style="display:block;color:var(--cor-texto-secundario);margin-top:4px"></small>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
<p><small>* campos obrigatórios. Os demais podem ficar em branco — implantação/mensalidade/juros ficam 0,00 e as datas de contrato ficam com a data de hoje até serem ajustadas.</small></p>
<button type="submit" id="botao-salvar">Salvar</button>
</form>
</div>
<script>
(function () {
// Ao sair do campo CNPJ, busca os dados na Receita (via BrasilAPI, ver
// app/cnpj_lookup.py) e preenche os campos abaixo — só os que estiverem
// vazios, nunca sobrescreve o que já foi digitado. Não faz nada para CPF
// (a rota /clientes/consulta-cnpj só responde para CNPJ).
var $cnpj = document.getElementById('CNPJ');
var $status = document.getElementById('cnpj-status');
if (!$cnpj) return;
var $botaoSalvar = document.getElementById('botao-salvar');
// Mesma fórmula de dígito verificador de app/normalize.py
// (_digito_verificador + validar_dv_cnpj) — módulo 11 sobre valor ASCII
// − 48 de cada caractere, suporta CNPJ numérico e o alfanumérico
// vigente desde 31/07/2026.
var PESOS_CNPJ_DV1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
var PESOS_CNPJ_DV2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
function digitoVerificador(valores, pesos) {
var soma = 0;
for (var i = 0; i < valores.length; i++) soma += valores[i] * pesos[i];
var resto = soma % 11;
return resto < 2 ? 0 : 11 - resto;
}
function valorCaractere(c) {
return c.charCodeAt(0) - 48;
}
function validarDvCnpj(cnpj14) {
var base = [];
for (var i = 0; i < 12; i++) base.push(valorCaractere(cnpj14[i]));
var dvInformado = [parseInt(cnpj14[12], 10), parseInt(cnpj14[13], 10)];
var dv1 = digitoVerificador(base, PESOS_CNPJ_DV1);
var dv2 = digitoVerificador(base.concat([dv1]), PESOS_CNPJ_DV2);
return dvInformado[0] === dv1 && dvInformado[1] === dv2;
}
function detectarEValidarDocumento() {
var limpo = normalizarCnpj($cnpj.value);
if ($botaoSalvar) $botaoSalvar.disabled = false;
if (/^[0-9]{11}$/.test(limpo)) {
if ($status) {
$status.textContent = 'CPF detectado.';
$status.style.color = 'var(--cor-texto-secundario)';
}
return;
}
if (/^[0-9A-Z]{12}[0-9]{2}$/.test(limpo)) {
if (validarDvCnpj(limpo)) {
if ($status) {
$status.textContent = 'CNPJ detectado.';
$status.style.color = 'var(--cor-texto-secundario)';
}
} else {
if ($status) {
$status.textContent = 'CNPJ inválido (dígito verificador não confere).';
$status.style.color = 'var(--cor-perigo)';
}
if ($botaoSalvar) $botaoSalvar.disabled = true;
}
return;
}
// Tamanho ainda incompleto/indeterminado: não bloqueia, não rotula.
if ($status) $status.textContent = '';
}
$cnpj.addEventListener('input', detectarEValidarDocumento);
detectarEValidarDocumento();
function aplicarMascaraTelefone(elemento, digitosAposDdd) {
elemento.addEventListener('input', function () {
var digitos = elemento.value.replace(/\D/g, '');
// DDI 55 colado: detecta por tamanho total (12 ou 13 dígitos), não
// pelo tamanho esperado do campo específico — um número de 12
// dígitos colado no campo de celular ainda deve ter o 55 removido.
if ((digitos.length === 12 || digitos.length === 13) && digitos.slice(0, 2) === '55') {
digitos = digitos.slice(2);
}
digitos = digitos.slice(0, digitosAposDdd);
var ddd = digitos.slice(0, 2);
var resto = digitos.slice(2);
var meio = resto.slice(0, digitosAposDdd - 6);
var fim = resto.slice(digitosAposDdd - 6);
var formatado = '';
if (ddd) formatado += '(' + ddd;
if (ddd.length === 2) formatado += ') ';
formatado += meio;
if (fim) formatado += '-' + fim;
elemento.value = formatado;
});
}
var $telefone = document.getElementById('TELEFONE');
var $celular = document.getElementById('CELULAR');
// TELEFONE: DDD(2) + 8 dígitos = 10 dígitos após o DDI.
if ($telefone) aplicarMascaraTelefone($telefone, 10);
// CELULAR: DDD(2) + 9 dígitos (com o 9 na frente) = 11 dígitos após o DDI.
if ($celular) aplicarMascaraTelefone($celular, 11);
var $uf = document.getElementById('UF');
if ($uf) {
$uf.addEventListener('input', function () {
$uf.value = $uf.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2);
});
}
var $cep = document.getElementById('CEP');
if ($cep) {
$cep.addEventListener('input', function () {
var digitos = $cep.value.replace(/\D/g, '').slice(0, 8);
$cep.value = digitos.length > 5 ? digitos.slice(0, 5) + '-' + digitos.slice(5) : digitos;
});
}
var CAMPOS_PARA_PREENCHER = ['RAZAO', 'FANTASIA', 'ENDERECO', 'CIDADE', 'BAIRRO', 'UF', 'CEP', 'TELEFONE', 'EMAIL'];
function normalizarCnpj(valor) {
return (valor || '').toUpperCase().replace(/[^0-9A-Z]/g, '');
}
async function buscarCnpj() {
var limpo = normalizarCnpj($cnpj.value);
if (limpo.length !== 14) return;
if (limpo.length === 14 && !validarDvCnpj(limpo)) return;
if ($status) $status.textContent = 'Buscando dados do CNPJ...';
try {
var resposta = await fetch('/clientes/consulta-cnpj/' + encodeURIComponent(limpo));
var corpo = await resposta.json();
if (!resposta.ok || !corpo.encontrado) {
if ($status) $status.textContent = 'CNPJ não encontrado na Receita — preencha manualmente.';
return;
}
var preencheu = false;
CAMPOS_PARA_PREENCHER.forEach(function (campo) {
var $input = document.getElementById(campo);
if ($input && !$input.value && corpo.dados[campo]) {
$input.value = corpo.dados[campo];
$input.dispatchEvent(new Event('input'));
preencheu = true;
}
});
if ($status) {
$status.textContent = preencheu
? 'Dados preenchidos automaticamente a partir do CNPJ — confira antes de salvar.'
: 'CNPJ encontrado (campos já preenchidos não foram alterados).';
}
} catch (erro) {
if ($status) $status.textContent = '';
}
}
$cnpj.addEventListener('blur', buscarCnpj);
})();
</script>
{% endblock %}
@@ -0,0 +1,181 @@
<!-- app/templates/clientes_lista.html -->
{% extends "base.html" %}
{% block titulo %}Clientes{% endblock %}
{% block conteudo %}
<h1>Clientes</h1>
<div class="cartao">
<form method="get" action="/clientes">
<label for="ordenar_por">Buscar/ordenar por</label>
<select id="ordenar_por" name="ordenar_por">
<option value="razao" {% if ordenar_por == "razao" %}selected{% endif %}>Razão social</option>
<option value="codigo" {% if ordenar_por == "codigo" %}selected{% endif %}>Código</option>
<option value="fantasia" {% if ordenar_por == "fantasia" %}selected{% endif %}>Fantasia</option>
<option value="cnpj" {% if ordenar_por == "cnpj" %}selected{% endif %}>CNPJ</option>
</select>
<input type="text" name="busca" value="{{ busca or '' }}" placeholder="Buscar só no campo escolhido acima">
<label for="status">Status</label>
<select id="status" name="status">
<option value="ativos" {% if status == "ativos" %}selected{% endif %}>Ativos</option>
<option value="inativos" {% if status == "inativos" %}selected{% endif %}>Desativados ou inativos</option>
<option value="todos" {% if status == "todos" %}selected{% endif %}>Todos</option>
</select>
<button type="submit">Buscar</button>
</form>
</div>
<p><a class="botao" href="/clientes/novo">+ Novo cliente</a></p>
{% set busca_qs = ('&busca=' + (busca | urlencode)) if busca else '' %}
{% set qs_base = ('&ordenar_por=' + ordenar_por + '&direcao=' + direcao + '&por_pagina=' + (por_pagina | string) + '&status=' + status + busca_qs) | safe %}
<div class="tabela-wrap">
<table class="table-clientes">
<colgroup>
<col class="col-codigo">
<col class="col-razao">
<col class="col-fantasia">
<col class="col-cnpj">
<col class="col-status">
<col class="col-acoes">
</colgroup>
<tr>
{% set colunas_tabela = [("codigo", "Código", "col-codigo"), ("razao", "Razão", "col-razao"), ("fantasia", "Fantasia", "col-fantasia"), ("cnpj", "CNPJ", "col-cnpj")] %}
{% for campo, rotulo, classe_coluna in colunas_tabela %}
{% set nova_direcao = "desc" if (ordenar_por == campo and direcao == "asc") else "asc" %}
<th class="{{ classe_coluna }}">
<a href="/clientes?ordenar_por={{ campo }}&direcao={{ nova_direcao }}&pagina=1&por_pagina={{ por_pagina }}&status={{ status }}{{ busca_qs }}">
{{ rotulo }}{% if ordenar_por == campo %}{{ " ▲" if direcao == "asc" else " ▼" }}{% endif %}
</a>
</th>
{% endfor %}
<th class="col-status">Status</th>
<th class="col-acoes"></th>
</tr>
{% for c in clientes %}
<tr>
<td class="col-codigo">{{ c.CODIGO }}</td>
<td class="col-razao">{{ c.RAZAO }}</td>
<td class="col-fantasia">{{ c.FANTASIA }}</td>
<td class="col-cnpj">{{ c.CNPJ }}</td>
<td class="col-status">
<form method="post" action="/clientes/{{ c.ID }}/alternar-ativo" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirecionar_para" value="{{ redirecionar_para }}">
<button
type="submit"
class="status-dot {{ 'status-dot-ativo' if c.ATIVO else 'status-dot-inativo' }}"
title="{{ 'Ativo — clique para desativar' if c.ATIVO else 'Inativo — clique para ativar' }}"
onclick='return confirm({{ ((("Desativar" if c.ATIVO else "Reativar") + " o cliente " + c.RAZAO + "?")) | tojson }})'
>
<span class="sr-only">{{ "Ativo" if c.ATIVO else "Inativo" }}</span>
</button>
</form>
</td>
<td class="col-acoes">
<a class="botao botao-tabela" href="/clientes/{{ c.ID }}">Abrir</a>
<button type="button" class="botao botao-tabela botao-gerar-chave" data-codigo="{{ c.CODIGO }}" onclick="gerarChaveReativacao(this)">Gerar chave</button>
<span class="chave-feedback" aria-live="polite"></span>
</td>
</tr>
{% endfor %}
</table>
</div>
<div class="paginacao-rodape">
<form method="get" action="/clientes" class="paginacao-por-pagina">
<input type="hidden" name="ordenar_por" value="{{ ordenar_por }}">
<input type="hidden" name="direcao" value="{{ direcao }}">
<input type="hidden" name="pagina" value="1">
<input type="hidden" name="status" value="{{ status }}">
{% if busca %}<input type="hidden" name="busca" value="{{ busca }}">{% endif %}
<label for="por_pagina">Linhas por página</label>
<select id="por_pagina" name="por_pagina" onchange="this.form.submit()">
{% for opcao in opcoes_por_pagina %}
<option value="{{ opcao }}" {% if opcao == por_pagina %}selected{% endif %}>{{ opcao }}</option>
{% endfor %}
</select>
</form>
<span>Exibindo {{ primeiro_item }} a {{ ultimo_item }} de {{ total }} clientes</span>
{% if total_paginas and total_paginas > 1 %}
<div class="paginacao">
{% if pagina > 1 %}
<a class="botao" href="/clientes?pagina=1{{ qs_base }}" aria-label="Primeira página">&laquo;</a>
<a class="botao" href="/clientes?pagina={{ pagina - 1 }}{{ qs_base }}" aria-label="Página anterior">&lsaquo;</a>
{% endif %}
{% for p in janela_paginas %}
{% if p is none %}
<span class="paginacao-reticencias">&hellip;</span>
{% elif p == pagina %}
<span class="paginacao-atual">{{ p }}</span>
{% else %}
<a class="botao" href="/clientes?pagina={{ p }}{{ qs_base }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if pagina < total_paginas %}
<a class="botao" href="/clientes?pagina={{ pagina + 1 }}{{ qs_base }}" aria-label="Próxima página">&rsaquo;</a>
<a class="botao" href="/clientes?pagina={{ total_paginas }}{{ qs_base }}" aria-label="Última página">&raquo;</a>
{% endif %}
</div>
{% endif %}
</div>
<script>
// Gera a chave de reativação do mês vigente pro cliente da linha
// clicada, reaproveitando o endpoint já usado pelo gerador manual
// (/gerador-chave/gerar) — o algoritmo de cifra roda só no backend
// (app/gerador_chave.py), esta página só manda código+mês/ano e
// copia o resultado.
const CSRF_TOKEN = "{{ csrf_token }}";
function _mesAnoAtual() {
const hoje = new Date();
return `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`;
}
async function gerarChaveReativacao(botao) {
const codigo = botao.dataset.codigo;
const feedback = botao.parentElement.querySelector('.chave-feedback');
botao.disabled = true;
feedback.textContent = '';
feedback.classList.remove('chave-feedback-erro');
const corpo = new URLSearchParams({
csrf_token: CSRF_TOKEN,
tipo: 'reativacao',
codigo: codigo,
referencia: _mesAnoAtual(),
});
let resposta, dados;
try {
resposta = await fetch('/gerador-chave/gerar', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: corpo,
});
dados = await resposta.json();
} catch (e) {
feedback.textContent = 'Falha de rede — tente de novo.';
feedback.classList.add('chave-feedback-erro');
botao.disabled = false;
return;
}
if (!resposta.ok) {
feedback.textContent = dados.erro || 'Não foi possível gerar a chave.';
feedback.classList.add('chave-feedback-erro');
botao.disabled = false;
return;
}
try {
await navigator.clipboard.writeText(dados.chave);
feedback.textContent = 'Copiado!';
} catch (e) {
feedback.textContent = 'Chave gerada, mas não copiou automaticamente.';
feedback.classList.add('chave-feedback-erro');
}
botao.disabled = false;
setTimeout(() => {
feedback.textContent = '';
feedback.classList.remove('chave-feedback-erro');
}, 1500);
}
</script>
{% endblock %}
@@ -0,0 +1,46 @@
<!-- app/templates/funcionario_form.html -->
{% extends "base.html" %}
{% block titulo %}{{ "Editar" if funcionario else "Novo" }} funcionário{% endblock %}
{% block conteudo %}
<h1>{{ "Editar" if funcionario else "Novo" }} funcionário</h1>
{% if erro %}
<div class="mensagem danger">{{ erro }}</div>
{% endif %}
<div class="cartao">
<form method="post" action="{{ '/funcionarios/' + funcionario.ID|string + '/editar' if funcionario else '/funcionarios/novo' }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid-form">
<div>
<label for="nome">Nome</label>
<input type="text" id="nome" name="nome" value="{{ dados.NOME if dados else (funcionario.NOME if funcionario else '') }}" required>
</div>
<div>
<label for="login">Login</label>
<input type="text" id="login" name="login" value="{{ dados.LOGIN if dados else (funcionario.LOGIN if funcionario else '') }}" required>
</div>
<div>
<label for="perfil">Perfil</label>
{% set perfil_atual = dados.PERFIL if dados else (funcionario.PERFIL if funcionario else 'PADRAO') %}
<select id="perfil" name="perfil">
<option value="PADRAO" {{ "selected" if perfil_atual == "PADRAO" else "" }}>Padrão</option>
<option value="ADMIN" {{ "selected" if perfil_atual == "ADMIN" else "" }}>Administrador</option>
</select>
</div>
{% if not funcionario %}
<div>
<label for="senha">Senha</label>
<input type="password" id="senha" name="senha" required>
</div>
<div>
<label for="confirmar_senha">Confirmar senha</label>
<input type="password" id="confirmar_senha" name="confirmar_senha" required>
</div>
{% endif %}
</div>
<button type="submit">Salvar</button>
</form>
</div>
{% if funcionario %}
<p><small>Para trocar a senha, use "Redefinir senha" na lista de funcionários.</small></p>
{% endif %}
{% endblock %}
@@ -0,0 +1,40 @@
<!-- app/templates/funcionarios_lista.html -->
{% extends "base.html" %}
{% block titulo %}Funcionários{% endblock %}
{% block conteudo %}
<h1>Funcionários</h1>
<p><a class="botao" href="/funcionarios/novo">+ Novo funcionário</a></p>
<div class="tabela-wrap">
<table>
<tr><th>Nome</th><th>Login</th><th>Perfil</th><th>Status</th><th></th></tr>
{% for f in funcionarios %}
<tr>
<td>{{ f.NOME }}</td>
<td>{{ f.LOGIN }}</td>
<td><span class="badge {{ 'badge-acento' if f.PERFIL == 'ADMIN' else 'badge-neutro' }}">{{ "Administrador" if f.PERFIL == "ADMIN" else "Padrão" }}</span></td>
<td><span class="badge {{ 'badge-sucesso' if f.ATIVO else 'badge-neutro' }}">{{ "Ativo" if f.ATIVO else "Inativo" }}</span></td>
<td>
<a class="botao" href="/funcionarios/{{ f.ID }}/editar">Editar</a>
<details style="display:inline-block">
<summary class="botao" style="display:inline-block;cursor:pointer">Redefinir senha</summary>
<form method="post" action="/funcionarios/{{ f.ID }}/redefinir-senha" style="margin-top:8px">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="nova_senha-{{ f.ID }}">Nova senha</label>
<input type="password" id="nova_senha-{{ f.ID }}" name="nova_senha" required>
<label for="confirmar_nova_senha-{{ f.ID }}">Confirmar nova senha</label>
<input type="password" id="confirmar_nova_senha-{{ f.ID }}" name="confirmar_nova_senha" required>
<button type="submit">Salvar senha</button>
</form>
</details>
<form method="post" action="/funcionarios/{{ f.ID }}/alternar-ativo" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('{{ "Desativar" if f.ATIVO else "Reativar" }} este funcionário?')">
{{ "Desativar" if f.ATIVO else "Reativar" }}
</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block titulo %}Gerador de Chave{% endblock %}
{% block conteudo %}
<div class="cartao" style="max-width:420px;margin:0 auto;">
<h1 style="text-align:center;">Gerador de Chave</h1>
<label for="tipo">Tipo de chave</label>
<select id="tipo">
<option value="reativacao">Reativação (mês/ano)</option>
<option value="instalacao">Instalação (aluguel/compra)</option>
</select>
<label for="codigo">Código do cliente</label>
<input type="text" id="codigo" placeholder="Ex: 000123" maxlength="6" inputmode="numeric">
<div id="campos-reativacao">
<label for="referencia">Mês/ano de referência</label>
<input type="month" id="referencia" min="2000-01" max="2100-12">
</div>
<div id="campos-instalacao" style="display:none;">
<label for="modalidade">Modalidade</label>
<select id="modalidade">
<option value="ALUGUEL">Aluguel</option>
<option value="COMPRA">Compra</option>
</select>
</div>
<button id="gerar">Gerar chave</button>
<div id="erro" style="color:var(--cor-perigo);margin-top:14px;display:none;"></div>
<div id="resultado" style="margin-top:20px;padding:14px;border-radius:8px;background:var(--cor-superficie-alt);border:1px solid var(--cor-borda);border-left:3px solid var(--cor-acento);word-break:break-all;font-family:'Courier New',monospace;color:var(--cor-acento-texto);display:none;"></div>
<button id="copiar" style="display:none;background:transparent;border:1px solid var(--cor-borda);color:var(--cor-texto-secundario);width:auto;padding:6px 10px;">Copiar</button>
</div>
<script>
// O algoritmo de geração de chave roda só no backend (app/gerador_chave.py)
// — esta página só manda os dados do formulário pro endpoint e mostra o
// resultado. Nunca reintroduza a lógica de cifra aqui: qualquer pessoa com
// acesso à página consegue ler o código-fonte JS pelo navegador.
const CSRF_TOKEN = "{{ csrf_token }}";
const $tipo = document.getElementById('tipo');
const $codigo = document.getElementById('codigo');
const $referencia = document.getElementById('referencia');
const $modalidade = document.getElementById('modalidade');
const $camposReativacao = document.getElementById('campos-reativacao');
const $camposInstalacao = document.getElementById('campos-instalacao');
const $erro = document.getElementById('erro');
const $resultado = document.getElementById('resultado');
const $copiar = document.getElementById('copiar');
$codigo.addEventListener('input', () => {
$codigo.value = $codigo.value.replace(/\D/g, '');
});
(function preencherMesAtual() {
const hoje = new Date();
$referencia.value = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`;
})();
$tipo.addEventListener('change', () => {
const ehReativacao = $tipo.value === 'reativacao';
$camposReativacao.style.display = ehReativacao ? 'block' : 'none';
$camposInstalacao.style.display = ehReativacao ? 'none' : 'block';
});
function mostrarErro(msg) {
$erro.textContent = msg;
$erro.style.display = 'block';
$resultado.style.display = 'none';
$copiar.style.display = 'none';
}
document.getElementById('gerar').addEventListener('click', async () => {
$erro.style.display = 'none';
const corpo = new URLSearchParams({
csrf_token: CSRF_TOKEN,
tipo: $tipo.value,
codigo: $codigo.value,
referencia: $referencia.value,
modalidade: $modalidade.value,
});
let resposta, dados;
try {
resposta = await fetch('/gerador-chave/gerar', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: corpo,
});
dados = await resposta.json();
} catch (e) {
return mostrarErro('Não foi possível falar com o servidor — tente de novo.');
}
if (!resposta.ok) return mostrarErro(dados.erro || 'Não foi possível gerar a chave.');
$resultado.textContent = dados.chave;
$resultado.style.display = 'block';
$copiar.style.display = 'inline-block';
});
$copiar.addEventListener('click', () => {
navigator.clipboard.writeText($resultado.textContent).then(() => {
$copiar.textContent = 'Copiado!';
setTimeout(() => { $copiar.textContent = 'Copiar'; }, 1500);
});
});
</script>
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
<!-- app/templates/log.html -->
{% extends "base.html" %}
{% block titulo %}Log{% endblock %}
{% block conteudo %}
<h1>Log</h1>
<details class="secao-log">
<summary>
<span class="secao-log-titulo">Ações no painel</span>
<button type="button" class="botao-toggle-log" aria-expanded="false">Mostrar</button>
</summary>
<div class="secao-log-conteudo">
{% if eh_admin %}
<form method="post" action="/log/limpar" style="margin-bottom:14px">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Limpar todo o histórico de ações? Isso não pode ser desfeito.')">Limpar log</button>
</form>
{% endif %}
{% if acoes %}
<div class="tabela-wrap">
<table>
<tr><th>Quando</th><th>Usuário</th><th>Ação</th><th>Entidade</th><th>Status</th><th>Detalhe</th></tr>
{% for a in acoes %}
<tr>
<td>{{ a.CRIADO_EM }}</td>
<td>{{ a.USUARIO }}</td>
<td>{{ a.ACAO }}</td>
<td>{{ a.ENTIDADE }}{% if a.ENTIDADE_ID %} #{{ a.ENTIDADE_ID }}{% endif %}</td>
<td><span class="badge {{ 'badge-sucesso' if a.SUCESSO else 'badge-neutro' }}">{{ "OK" if a.SUCESSO else "ERRO" }}</span></td>
<td>{{ a.DETALHE or "" }}</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p>Nenhuma ação registrada ainda.</p>
{% endif %}
</div>
</details>
<details class="secao-log">
<summary>
<span class="secao-log-titulo">Envios automáticos de chave (n8n)</span>
<button type="button" class="botao-toggle-log" aria-expanded="false">Mostrar</button>
</summary>
<div class="secao-log-conteudo">
{% if not envios_configurado %}
<div class="cartao">
<p>
Integração com o n8n não configurada — defina <code>N8N_API_URL</code> e
<code>N8N_API_KEY</code> no ambiente (ver <code>.env.example</code>) para ver
aqui os envios automáticos de chave feitos pelo workflow do n8n.
</p>
</div>
{% elif not envios %}
<p>Nenhum envio encontrado (ou não foi possível consultar o n8n agora).</p>
{% else %}
<div class="tabela-wrap">
<table>
<tr>
<th>Data</th><th>Cliente</th><th>Código</th><th>Competência</th><th>Status</th>
<th>Telefones enviados</th><th>Telefones com erro</th>
</tr>
{% for e in envios %}
<tr>
<td>{{ e.data_processamento }}</td>
<td>{{ e.nome_cliente }}</td>
<td>{{ e.codigo_cliente }}</td>
<td>{{ e.competencia }}</td>
<td>{{ e.status }}</td>
<td>{{ e.telefones_enviados }}</td>
<td>{{ e.telefones_com_erro or "" }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
</details>
<script>
document.querySelectorAll(".secao-log").forEach(function (secao) {
var botao = secao.querySelector(".botao-toggle-log");
var atualizar = function () {
var aberto = secao.hasAttribute("open");
botao.textContent = aberto ? "Ocultar" : "Mostrar";
botao.setAttribute("aria-expanded", aberto ? "true" : "false");
};
// Um <button> dentro de <summary> é ele mesmo um elemento interativo:
// o navegador não propaga o clique para o toggle nativo do <details>
// quando o clique começa em outro controle interativo. Por isso o
// toggle precisa ser feito manualmente aqui.
botao.addEventListener("click", function (evento) {
evento.preventDefault();
secao.open = !secao.open;
atualizar();
});
secao.addEventListener("toggle", atualizar);
atualizar();
});
</script>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
<!-- app/templates/login.html -->
<!DOCTYPE html>
<html lang="pt-BR" data-tema="escuro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Entrar — Painel de Clientes</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/estilo.css?v={{ request.state.asset_versao }}">
<style>
body {
display: flex; align-items: center; justify-content: center;
min-height: 100vh; padding: 16px;
background: radial-gradient(circle at top, var(--cor-superficie-alt), var(--cor-fundo));
}
.login-cartao { width: 100%; max-width: 360px; text-align: center; }
.login-logo { width: 64px; height: 64px; border-radius: 16px; margin-bottom: 12px; }
.login-cartao h1 { font-size: 1.3rem; margin-bottom: 4px; }
.login-subtitulo { color: var(--cor-texto-secundario); font-size: 0.9rem; margin: 0 0 20px; }
.login-cartao form { text-align: left; }
.login-cartao > form > button[type="submit"] { width: 100%; }
.campo-senha {
display: flex; align-items: center;
border: 1px solid var(--cor-borda); border-radius: 8px; background: var(--cor-fundo);
}
.campo-senha input {
flex: 1; min-width: 0; border: none; background: transparent;
}
.campo-senha input:focus-visible { outline: none; }
.campo-senha:focus-within { outline: 2px solid var(--cor-acento); outline-offset: 2px; }
.campo-senha .botao-ver-senha {
flex-shrink: 0; margin: 0; padding: 0 12px; width: auto; height: 100%;
background: transparent; border: none; color: var(--cor-texto-secundario);
cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.campo-senha .botao-ver-senha:hover { color: var(--cor-texto); }
.campo-senha .botao-ver-senha svg { width: 18px; height: 18px; }
</style>
</head>
<body>
<div class="cartao login-cartao">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="login-logo">
<h1>Painel de Clientes</h1>
<p class="login-subtitulo">Entre com suas credenciais para acessar o sistema</p>
{% if erro %}<div class="mensagem danger">{{ erro }}</div>{% endif %}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="next" value="{{ next }}">
<label for="usuario">Usuário</label>
<input type="text" id="usuario" name="usuario" required autofocus>
<label for="senha">Senha</label>
<div class="campo-senha">
<input type="password" id="senha" name="senha" required>
<button type="button" class="botao-ver-senha" id="botao-ver-senha" aria-label="Mostrar senha">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8Z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
<button type="submit">Entrar</button>
</form>
</div>
<script>
(function () {
var botao = document.getElementById('botao-ver-senha');
var campo = document.getElementById('senha');
if (!botao || !campo) return;
botao.addEventListener('click', function () {
var mostrando = campo.type === 'text';
campo.type = mostrando ? 'password' : 'text';
botao.setAttribute('aria-label', mostrando ? 'Mostrar senha' : 'Ocultar senha');
});
})();
</script>
</body>
</html>
@@ -0,0 +1,35 @@
<!-- app/templates/minha_conta.html -->
{% extends "base.html" %}
{% block titulo %}Minha conta{% endblock %}
{% block conteudo %}
<h1>Minha conta</h1>
<div class="cartao">
<p><strong>Nome:</strong> {{ usuario_atual.nome }}</p>
<p><strong>Login:</strong> {{ usuario_atual.login }}</p>
</div>
{% if erro %}<div class="mensagem danger">{{ erro }}</div>{% endif %}
{% if mensagem %}<div class="mensagem success">{{ mensagem }}</div>{% endif %}
{% if eh_conta_env %}
<div class="mensagem warning">
Esta conta é a conta de administrador configurada por variável de ambiente
(ADMIN_USER/ADMIN_PASSWORD no .env do servidor) — a senha se troca lá,
não existe uma linha correspondente na tabela de funcionários.
</div>
{% else %}
<h2>Trocar senha</h2>
<div class="cartao">
<form method="post" action="/minha-conta/senha">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="senha_atual">Senha atual</label>
<input type="password" id="senha_atual" name="senha_atual" required>
<label for="nova_senha">Nova senha</label>
<input type="password" id="nova_senha" name="nova_senha" required>
<label for="confirmar_nova_senha">Confirmar nova senha</label>
<input type="password" id="confirmar_nova_senha" name="confirmar_nova_senha" required>
<button type="submit">Salvar nova senha</button>
</form>
</div>
{% endif %}
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
<!-- app/templates/sso_fiscal.html — entrega a asserção ao Fiscal por POST (nunca na URL) -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Abrindo o GTI Fiscal…</title>
<style>
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #121214; color: #f4f4f5; }
p { color: #9a9aa2; }
button { padding: 10px 16px; border-radius: 8px; border: 0; background: #F5821F; color: #17171a; font-weight: 700; cursor: pointer; }
</style>
</head>
<body>
<form method="post" action="{{ acao }}" id="sso-fiscal">
<input type="hidden" name="assertion" value="{{ assercao }}">
<input type="hidden" name="next" value="{{ proximo }}">
<p>Abrindo o GTI Fiscal…</p>
<noscript><button type="submit">Continuar</button></noscript>
</form>
<script>document.getElementById("sso-fiscal").submit();</script>
</body>
</html>