Add 'gerenciador/' from commit '34cc9c75a57dedbf61a6e6a1ba1f35f76e72434b'
git-subtree-dir: gerenciador git-subtree-mainline:f4f546c928git-subtree-split:34cc9c75a5
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user