diff --git a/app/db/clientes.py b/app/db/clientes.py index f8c7fcd..2fe7af8 100644 --- a/app/db/clientes.py +++ b/app/db/clientes.py @@ -155,7 +155,11 @@ def atualizar_cliente(cliente_id: int, dados: dict) -> None: conn.close() -def excluir_cliente(cliente_id: int) -> None: +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: @@ -165,5 +169,6 @@ def excluir_cliente(cliente_id: int) -> None: raise ClienteComDependenciasError( "Este cliente tem contatos cadastrados (CLIENTE_CONTATO) — remova-os antes de excluir." ) from exc + return cur.rowcount > 0 finally: conn.close() diff --git a/app/db/contatos.py b/app/db/contatos.py index 811ead6..58902b7 100644 --- a/app/db/contatos.py +++ b/app/db/contatos.py @@ -50,7 +50,12 @@ def inserir_contato(cliente_id: int, nome: str | None, telefone: str, email: str def atualizar_contato( contato_id: int, cliente_id: int, nome: str | None, telefone: str, email: str | None = 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: @@ -59,11 +64,13 @@ def atualizar_contato( "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) -> None: +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: @@ -71,5 +78,6 @@ def excluir_contato(contato_id: int, cliente_id: int) -> None: "DELETE FROM CLIENTE_CONTATO WHERE ID = %s AND CLIENTE_ID = %s", (contato_id, cliente_id), ) + return cur.rowcount > 0 finally: conn.close() diff --git a/app/db/funcionarios.py b/app/db/funcionarios.py index 420f582..4b7d49b 100644 --- a/app/db/funcionarios.py +++ b/app/db/funcionarios.py @@ -117,7 +117,11 @@ def atualizar_funcionario(funcionario_id: int, nome: str, login: str, perfil: st conn.close() -def redefinir_senha(funcionario_id: int, nova_senha: str) -> None: +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: @@ -125,6 +129,7 @@ def redefinir_senha(funcionario_id: int, nova_senha: str) -> None: "UPDATE FUNCIONARIO_PAINEL SET SENHA_HASH = %s WHERE ID = %s", (gerar_hash(nova_senha), funcionario_id), ) + return cur.rowcount > 0 finally: conn.close() diff --git a/app/routers/clientes.py b/app/routers/clientes.py index f29a1e9..aae8757 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -259,6 +259,15 @@ def form_editar(request: Request, cliente_id: int, usuario: str = Depends(exigir @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) @@ -300,9 +309,14 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi @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) + 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.") diff --git a/app/routers/contatos.py b/app/routers/contatos.py index 3e324de..2529b1f 100644 --- a/app/routers/contatos.py +++ b/app/routers/contatos.py @@ -2,6 +2,15 @@ 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 @@ -13,7 +22,7 @@ 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 +from ..normalize import normalizar_telefone_com_ddi router = APIRouter(prefix="/clientes") @@ -34,7 +43,7 @@ def adicionar( if db_clientes.obter_cliente(cliente_id) is None: return _redirect_com_mensagem(cliente_id, "Cliente não encontrado.", "danger") - telefone_normalizado = normalizar_telefone(telefone) + telefone_normalizado = normalizar_telefone_com_ddi(telefone) if not telefone_normalizado: return _redirect_com_mensagem(cliente_id, "Telefone obrigatório.", "danger") @@ -56,7 +65,7 @@ def editar( email: str = Form(""), usuario: str = Depends(exigir_login), ): - telefone_normalizado = normalizar_telefone(telefone) + telefone_normalizado = normalizar_telefone_com_ddi(telefone) if not telefone_normalizado: return _redirect_com_mensagem(cliente_id, "Telefone obrigatório.", "danger") @@ -64,13 +73,26 @@ def editar( db_log.registrar(usuario, "editar", "contato", str(contato_id), False, "telefone duplicado") return _redirect_com_mensagem(cliente_id, f"O telefone {telefone_normalizado} já está cadastrado para este cliente.", "warning") - db_contatos.atualizar_contato(contato_id, cliente_id, nome.strip() or None, telefone_normalizado, email.strip() or None) + 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)): - db_contatos.excluir_contato(contato_id, cliente_id) + 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.") diff --git a/app/routers/funcionarios.py b/app/routers/funcionarios.py index bbc35b5..7937314 100644 --- a/app/routers/funcionarios.py +++ b/app/routers/funcionarios.py @@ -121,15 +121,17 @@ def editar( erro = f"O login \"{login}\" já está em uso." else: atual = db_funcionarios.obter_funcionario(funcionario_id) - rebaixando_ultimo_admin = ( - atual is not None - and atual["PERFIL"] == "ADMIN" - and atual["ATIVO"] == 1 - and perfil == "PADRAO" - and db_funcionarios.contar_admins_ativos() <= 1 - ) - if rebaixando_ultimo_admin: - erro = "Não é possível — este é o único administrador ativo." + if 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( @@ -158,7 +160,11 @@ def redefinir_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") - db_funcionarios.redefinir_senha(funcionario_id, nova_senha) + 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.") @@ -166,7 +172,14 @@ def redefinir_senha( @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 not None and atual["ATIVO"] == 1: + 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 diff --git a/app/templates/clientes_lista.html b/app/templates/clientes_lista.html index 99e1586..2f9eddd 100644 --- a/app/templates/clientes_lista.html +++ b/app/templates/clientes_lista.html @@ -24,7 +24,7 @@ {% for campo, rotulo in colunas_tabela %} {% set nova_direcao = "desc" if (ordenar_por == campo and direcao == "asc") else "asc" %}