Files
GTI_GERENCIADOR_FISCAL/app/routers/clientes.py
T
Gustavo bb8fd596b1 feat: protecao CSRF (double-submit cookie) e paginacao da lista de clientes
CSRF: novo app/csrf.py + middleware em app/main.py garantem um cookie
csrf_token por navegador; todo formulario que muda estado (criar/editar/
excluir cliente, adicionar/editar/excluir contato) embute esse valor
como campo oculto, e Depends(exigir_csrf) confere no POST que o valor
enviado bate com o cookie antes de deixar a rota executar. Verificado
ponta a ponta com o app real rodando (curl): token errado -> 403,
token correto -> 303.

Paginacao: db_clientes.listar_clientes ganha offset, nova
contar_clientes(); GET /clientes aceita ?pagina=N (100 clientes por
pagina), template mostra Anterior/Proxima e o total.

53/53 testes passando (8 novos).
2026-08-13 11:02:17 -03:00

219 lines
8.4 KiB
Python

# app/routers/clientes.py
"""
Rotas de gestão de CLIENTE: listar/buscar (paginado), criar, editar, ver
detalhe (contatos + status de Clientes_Chaves), excluir.
"""
import math
from typing import Optional
from urllib.parse import urlencode
import pymysql
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from ..auth import exigir_login
from ..csrf import exigir_csrf, token_do_contexto
from ..db import chaves as db_chaves
from ..db import clientes as db_clientes
from ..db import contatos as db_contatos
from ..normalize import normalizar_documento, normalizar_telefone, validar_dv_cpf, validar_dv_cnpj
router = APIRouter(prefix="/clientes")
templates = Jinja2Templates(directory="app/templates")
TAMANHO_PAGINA = 100
def _redirect_com_mensagem(url: str, mensagem: str, tipo: str = "success") -> RedirectResponse:
separador = "&" if "?" in url else "?"
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 e telefones sempre sem máscara (só dígitos/letras),
igual ao que CLIENTE_CONTATO.TELEFONE já faz — evita que CLIENTE fique
com dois formatos diferentes convivendo (achado da revisão final:
docs/superpowers/plans/2026-08-13-painel-clientes-web.md). Só afeta
escritas novas feitas por este painel; registros legados só passam a
ficar sem máscara quando forem reeditados e salvos aqui.
"""
dados["CNPJ"] = normalizar_documento(dados["CNPJ"])[0]
dados["TELEFONE"] = normalizar_telefone(dados["TELEFONE"])
dados["CELULAR"] = normalizar_telefone(dados["CELULAR"])
@router.get("", response_class=HTMLResponse)
def listar(
request: Request,
busca: Optional[str] = None,
pagina: int = 1,
mensagem: Optional[str] = None,
mensagem_tipo: Optional[str] = None,
usuario: str = Depends(exigir_login),
):
pagina = max(pagina, 1)
total = db_clientes.contar_clientes(busca=busca or None)
total_paginas = max(math.ceil(total / TAMANHO_PAGINA), 1)
pagina = min(pagina, total_paginas)
offset = (pagina - 1) * TAMANHO_PAGINA
clientes = db_clientes.listar_clientes(busca=busca or None, limite=TAMANHO_PAGINA, offset=offset)
return templates.TemplateResponse(
request,
"clientes_lista.html",
{
"clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo,
"pagina": pagina, "total_paginas": total_paginas, "total": total,
},
)
@router.get("/novo", response_class=HTMLResponse)
def form_novo(request: Request, usuario: str = Depends(exigir_login)):
return templates.TemplateResponse(
request,
"cliente_form.html",
{
"cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE,
"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_duplicado = form.get("confirmar_codigo_duplicado") == "1"
confirmar_dv = form.get("confirmar_dv_invalido") == "1"
if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"]):
return templates.TemplateResponse(
request,
"cliente_form.html",
{
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"aviso_codigo_duplicado": True,
"confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv,
"csrf_token": token_do_contexto(request),
},
)
documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"])
dv_invalido = False
if formato_valido:
dv_invalido = not (
validar_dv_cpf(documento_normalizado) if tipo == "CPF" else validar_dv_cnpj(documento_normalizado)
)
if dv_invalido and not confirmar_dv:
return templates.TemplateResponse(
request,
"cliente_form.html",
{
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"aviso_dv_invalido": True,
"confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv,
"csrf_token": token_do_contexto(request),
},
)
try:
novo_id = db_clientes.criar_cliente(dados)
except (pymysql.err.DataError, pymysql.err.IntegrityError) as exc:
return templates.TemplateResponse(
request,
"cliente_form.html",
{
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc),
"csrf_token": token_do_contexto(request),
},
)
return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.")
@router.get("/{cliente_id}", response_class=HTMLResponse)
def detalhe(
request: Request,
cliente_id: int,
mensagem: Optional[str] = None,
mensagem_tipo: Optional[str] = None,
usuario: str = Depends(exigir_login),
):
cliente = db_clientes.obter_cliente(cliente_id)
if cliente is None:
return _redirect_com_mensagem("/clientes", "Cliente não encontrado.", "danger")
contatos = db_contatos.listar_contatos_por_cliente(cliente_id)
status = db_chaves.status_chaves(cliente["CODIGO"])
return templates.TemplateResponse(
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,
"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)):
form = dict((await request.form()))
dados = _ler_form_cliente(form)
_normalizar_documento_e_telefones(dados)
confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1"
if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"], ignorar_id=cliente_id):
return templates.TemplateResponse(
request,
"cliente_form.html",
{
"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"aviso_codigo_duplicado": True, "csrf_token": token_do_contexto(request),
},
)
try:
db_clientes.atualizar_cliente(cliente_id, dados)
except (pymysql.err.DataError, pymysql.err.IntegrityError) as exc:
return templates.TemplateResponse(
request,
"cliente_form.html",
{
"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"erro_gravacao": str(exc), "csrf_token": token_do_contexto(request),
},
)
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:
db_clientes.excluir_cliente(cliente_id)
except db_clientes.ClienteComDependenciasError as exc:
return _redirect_com_mensagem(f"/clientes/{cliente_id}", str(exc), "danger")
return _redirect_com_mensagem("/clientes", "Cliente excluído.")