diff --git a/.env.example b/.env.example index 0ad832f..1f2dd60 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,10 @@ ADMIN_USER=admin ADMIN_PASSWORD=SUBSTITUA_POR_UMA_SENHA_FORTE # Banco de dev (mesmo container do projeto n8n / contatos-chave-web): -# DB_HOST=host.docker.internal (se este app tambem rodar em container) -# DB_HOST=127.0.0.1 (se este app rodar direto na maquina) -DB_HOST=127.0.0.1 +# DB_HOST=host.docker.internal (padrao — para rodar via docker-compose.yml, +# que executa este app dentro de um container) +# DB_HOST=127.0.0.1 (se este app rodar direto na maquina, fora do Docker) +DB_HOST=host.docker.internal DB_PORT=3307 DB_NAME=gtisoftw_clientes_dev DB_USER=root diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5c4475 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# painel-clientes-web + +Painel administrativo (FastAPI + Jinja2) para gestão de `CLIENTE`, `CLIENTE_CONTATO` e geração manual de chave de licenciamento no MySQL do sistema legado. + +## Rodando localmente (sem Docker) + +1. Instale as dependências de desenvolvimento: + ``` + pip install -r requirements-dev.txt + ``` +2. Defina as variáveis de ambiente necessárias (`ADMIN_USER`, `ADMIN_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` — veja `.env.example`) diretamente no shell antes de subir a aplicação. **Importante:** apesar de `python-dotenv` estar nas dependências, nada no código chama `load_dotenv()` hoje — um arquivo `.env` sozinho NÃO é lido automaticamente fora do Docker. Exporte as variáveis manualmente (ex.: `set -a; source .env; set +a` no bash, ou `$env:...` no PowerShell) ou defina-as por outro meio. +3. Rode a aplicação: + ``` + uvicorn app.main:app --reload + ``` +4. Rodando direto na máquina (fora de container), use `DB_HOST=127.0.0.1` (porta `3307`, a mesma do container de dev `licenciamento-mysql-dev`). + +## Rodando os testes + +``` +pytest -v +``` + +Os testes usam defaults definidos em `tests/conftest.py` (equivalentes ao `.env.example`) e incluem testes de integração que precisam do container MySQL de dev (`licenciamento-mysql-dev`, `127.0.0.1:3307`) rodando. + +## Rodando via Docker + +1. Copie o arquivo de exemplo de variáveis de ambiente e edite `ADMIN_PASSWORD` (e demais valores, se necessário): + ``` + cp .env.example .env + ``` +2. Suba o container: + ``` + docker compose up -d --build + ``` + O `docker-compose.yml` já usa `env_file: .env` e mapeia `host.docker.internal` para o host (via `extra_hosts`), então o valor padrão `DB_HOST=host.docker.internal` do `.env.example` funciona também em hosts Docker Linux, não só no Docker Desktop (Windows/Mac). +3. A aplicação fica disponível em `http://localhost:8010`. diff --git a/app/auth.py b/app/auth.py index 9bbbc26..68ba7c3 100644 --- a/app/auth.py +++ b/app/auth.py @@ -15,6 +15,13 @@ def exigir_login(credenciais: HTTPBasicCredentials = Depends(_security)) -> str: usuario_esperado = os.environ.get("ADMIN_USER", "") senha_esperada = os.environ.get("ADMIN_PASSWORD", "") + if not usuario_esperado or not senha_esperada: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuário ou senha inválidos.", + headers={"WWW-Authenticate": "Basic"}, + ) + usuario_ok = secrets.compare_digest(credenciais.username, usuario_esperado) senha_ok = secrets.compare_digest(credenciais.password, senha_esperada) diff --git a/app/main.py b/app/main.py index d2f4e7d..b44e859 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,7 @@ from fastapi.staticfiles import StaticFiles from .auth import exigir_login from .routers import chave_manual, clientes, contatos -app = FastAPI(title="Painel de Clientes") +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(clientes.router) app.include_router(contatos.router) diff --git a/app/routers/clientes.py b/app/routers/clientes.py index bcec73c..bb0384a 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -6,6 +6,7 @@ Rotas de gestão de CLIENTE: listar/buscar, criar, editar, ver detalhe 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 @@ -59,12 +60,17 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): form = dict((await request.form())) dados = _ler_form_cliente(form) 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}, + { + "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, + "aviso_codigo_duplicado": True, + "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, + }, ) documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"]) @@ -73,14 +79,25 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): dv_invalido = not ( validar_dv_cpf(documento_normalizado) if tipo == "CPF" else validar_dv_cnpj(documento_normalizado) ) - if dv_invalido and form.get("confirmar_dv_invalido") != "1": + 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}, + { + "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, + "aviso_dv_invalido": True, + "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, + }, ) - novo_id = db_clientes.criar_cliente(dados) + 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)}, + ) return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.") @@ -133,7 +150,14 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi {"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, ) - db_clientes.atualizar_cliente(cliente_id, dados) + 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)}, + ) return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.") diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html index d1e38da..aebc6a6 100644 --- a/app/templates/cliente_form.html +++ b/app/templates/cliente_form.html @@ -9,6 +9,7 @@
@@ -19,14 +20,26 @@ {% endif %} + {% if erro_gravacao %} +