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