Terceira Fase - adicionando funcionalidade editar
Para integrar a funcionalidade de Edição/Atualização (UPDATE) no navegador com Bottle e SQLite, implementa-se o padrão web tradicional composto por duas etapas:
- Rota
GET /editar/<id:int>: Busca os dados atuais do cliente no SQLite pelo seu ID e renderiza um formulário preenchido com as informações existentes. - Rota
POST /editar/<id:int>: Recebe os novos dados enviados pelo formulário e executa o comando SQLUPDATEde forma segura com marcadores de posição?.
Como Funciona a Lógica de Edição
- Captura do ID na URL: Utiliza-se o filtro dinâmico de rota
<id:int>do Bottle para capturar o ID do cliente que será alterado - Busca do Registro: Executa-se
SELECT id, nome, email FROM clientes WHERE id = ?e recupera-se o registro individual usandocursor.fetchone() - Atualização no Banco: No envio do formulário, os dados capturados com
request.forms.get()são passados para a instruçãoUPDATE clientes SET nome = ?, email = ? WHERE id = ?. A transação é gravada comconn.commit()e o usuário é redirecionado de volta à página principal comredirect('/').
Código Completo Atualizado (app.py)
Abaixo está a versão completa do script, com a listagem, cadastro, exclusão e a nova rota de edição completa:
import sqlite3
from bottle import route, get, post, request, redirect, template, run
DB_NAME = 'meu_banco_de_dados.db'
def inicializar_banco():
"""Garante a existência da tabela 'clientes' no banco SQLite."""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS clientes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
email TEXT NOT NULL
);
''')
conn.commit()
conn.close()
# 1. ROTA PRINCIPAL: Listagem de Clientes e Formulário de Cadastro
@route('/', method=['GET','POST'])
def index():
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute("SELECT id, nome, email FROM clientes;")
clientes = cursor.fetchall()
conn.close()
html_template = '''
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<title>Gestão de Clientes - Bottle + SQLite</title>
<style>
body { font-family: Arial, sans-serif; margin: 30px; }
h1, h2 { color: #333; }
form { margin-bottom: 20px; background: #f4f4f4; padding: 15px; border-radius: 5px; }
input { margin-bottom: 10px; padding: 8px; width: 250px; display: block; }
input[type="submit"] { background: #28a745; color: white; border: none; cursor: pointer; width: 120px; }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #007bff; color: white; }
.btn-edit { color: #007bff; text-decoration: none; font-weight: bold; margin-right: 10px; }
.btn-del { color: red; text-decoration: none; font-weight: bold; }
</style>
</head>
<body>
<h1>Cadastro de Clientes</h1>
<form action="/cadastrar" method="post">
<label>Nome:</label>
<input type="text" name="nome" required placeholder="Digite o nome completo">
<label>E-mail:</label>
<input type="email" name="email" required placeholder="exemplo@email.com">
<input type="submit" value="Cadastrar">
</form>
<h2>Clientes Cadastrados</h2>
<table>
<tr>
<th>ID</th>
<th>Nome</th>
<th>E-mail</th>
<th>Ações</th>
</tr>
% for id_, nome, email in lista_clientes:
<tr>
<td>{{id_}}</td>
<td>{{nome}}</td>
<td>{{email}}</td>
<td>
<a class="btn-edit" href="/editar/{{id_}}">Editar</a>
<a class="btn-del" href="/deletar/{{id_}}">Excluir</a>
</td>
</tr>
% end
</table>
</body>
</html>
'''
return template(html_template, lista_clientes=clientes)
# 2. ROTA DE CADASTRO (Novo Cliente)
@route('/cadastrar', method=['GET','POST'])
def cadastrar():
nome = request.forms.get('nome')
email = request.forms.get('email')
if nome and email:
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute("INSERT INTO clientes (nome, email) VALUES (?, ?);", (nome, email))
conn.commit()
conn.close()
redirect('/')
# 3. ROTA DE EDIÇÃO - EXIBIR FORMULÁRIO (GET)
@route('/editar/<id:int>', method=['GET','POST'])
def exibir_formulario_edicao(id):
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute("SELECT id, nome, email FROM clientes WHERE id = ?;", (id,))
cliente = cursor.fetchone()
conn.close()
if not cliente:
redirect('/')
html_editar = '''
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<title>Editar Cliente</title>
<style>
body { font-family: Arial, sans-serif; margin: 30px; }
h1 { color: #333; }
form { background: #f4f4f4; padding: 15px; border-radius: 5px; width: 300px; }
input { margin-bottom: 10px; padding: 8px; width: 90%; display: block; }
input[type="submit"] { background: #007bff; color: white; border: none; cursor: pointer; width: 100px; }
a { color: #6c757d; text-decoration: none; display: inline-block; margin-top: 10px; }
</style>
</head>
<body>
<h1>Editar Cliente #{{cliente}}</h1>
<form action="/editar/{{cliente}}" method="post">
<label>Nome:</label>
<input type="text" name="nome" value="{{cliente}}" required>
<label>E-mail:</label>
<input type="email" name="email" value="{{cliente}}" required>
<input type="submit" value="Salvar">
</form>
<a href="/">← Cancelar e Voltar</a>
</body>
</html>
'''
return template(html_editar, cliente=cliente)
# 4. ROTA DE EDIÇÃO - SALVAR ALTERAÇÕES (POST)
@route('/editar/<id:int>', method=['GET','POST'])
def salvar_edicao(id):
nome = request.forms.get('nome')
email = request.forms.get('email')
if nome and email:
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
# Atualiza os dados com a instrução SQL UPDATE usando parâmetros seguros
cursor.execute(
"UPDATE clientes SET nome = ?, email = ? WHERE id = ?;",
(nome, email, id)
)
conn.commit()
conn.close()
redirect('/')
# 5. ROTA DE EXCLUSÃO
@route('/deletar/<id:int>', method=['GET','POST'])
def deletar(id):
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute("DELETE FROM clientes WHERE id = ?;", (id,))
conn.commit()
conn.close()
redirect('/')
if __name__ == '__main__':
inicializar_banco()
print("Servidor rodando em http://localhost:8080 ...")
run(host='localhost', port=8080, debug=True, reloader=True)Testando no Navegador
-
Salve o código e inicie o aplicativo no terminal:
python app.py -
Acesse
http://localhost:8080. -
Na tabela de clientes cadastrados, clique no link Editar.
-
O navegador abrirá a tela com os campos preenchidos com o nome e e-mail atuais.
-
Modifique os valores e clique em Salvar. Os dados atualizados serão salvos no SQLite com
UPDATEe exibidos na tabela principal.
🔍 Análise do Código
✅ O que está bom
- Consultas parametrizadas (?) em todos os comandos SQL. Isso protege contra SQL injection.
- Padrão
Post/Redirect/Get: depois de cadastrar, editar ou excluir, você redireciona para /. Isso evita reenvio do formulário ao atualizar a página. - Escape automático: o
{{ }}do Bottle escapa HTML por padrão, então o código não tem XSS ao exibir nome e e-mail. Conversor <id:int>na rota, que valida o parâmetro antes de a função rodar.CREATE TABLE IF NOT EXISTSdeixa a inicialização idempotente, o que também ajuda com oreloader=True.
🐛 Bugs / Erros
- A tela de edição renderiza a tupla inteira. Em exibir_formulario_edicao, cliente é
(id, nome, email), e o template usa {{cliente}} em todo lugar. O título, o action e os value recebem (1, 'Ana', 'ana@x.com'). O formulário envia para uma URL inválida(/editar/(1, 'Ana', ...))e dá 404. O correto é cliente[0], cliente[1] e cliente[2]. - A rota
/editar/<id:int>está registrada duas vezes com os mesmos métodos. Testei: o Bottle mantém só a última definição. Por isso salvar_edicao responde também ao GET, e o formulário de edição nunca é exibido. O GET cai no redirect(’/’). É para isso que servem os get e post que você importou e não usou. - Acentos podem ser gravados quebrados. No Python 3, request.forms.get() devolve o texto decodificado como latin-1. Um nome como “João” pode virar “João”. O correto é request.forms.getunicode(‘nome’).
- A validação falha em silêncio. Se nome ou email vier vazio, o código faz redirect(’/’) sem avisar ninguém. Também passa um nome só com espaços, e o e-mail não é validado no servidor. Só o HTML valida, e isso é fácil de burlar.
⚠️ Melhorias de Qualidade
- Use os métodos certos. method=[‘GET’,‘POST’] em todas as rotas é frouxo demais. /cadastrar, /deletar e a gravação da edição deveriam aceitar só POST. Use @get e @post.
- Abrir e fechar conexão repetido seis vezes. Se der erro entre connect e close, a conexão vaza. Um context manager com try/finally resolve isso e elimina a repetição.
- Registro inexistente vira redirect. Para ID inexistente o certo é abort(404). No
UPDATE,cursor.rowcount == 0indica que nada foi alterado. - O caminho do banco é relativo.
DB_NAMEdepende do diretório de onde você roda o script. PrefiraPath(__file__).with_name(...). - HTML e CSS dentro do .py, e o CSS repetido nos dois templates. O ideal é a pasta views/ com arquivos .tpl.
- id sombreia a função embutida
id(). Usecliente_id. - Falta type hint e docstring. Também há imports sem uso (get, post).
- Não há mensagem para lista vazia. A tabela aparece só com o cabeçalho.
⚡ Oportunidades de Performance
Para um CRUD pequeno não há problema. Se a tabela crescer, adicione ORDER BY e paginação (LIMIT/OFFSET) na listagem.
🔒 Segurança
Exclusão por link GET (<a href="/deletar/1">). Qualquer pré-carregamento do navegador, extensão ou página externa pode apagar registros com um simples acesso a essa URL. Use um <form method="post"> com confirm() no navegador. No código corrigido, testei que o GET passou a retornar 405.
Não há token CSRF. Num projeto local não é crítico. Se algum dia o app for publicado, vale incluir.
debug=True e reloader=True expõem tracebacks completos no navegador. Desligue os dois em produção.
Versão refatorada
Ests versão corrigida aplica tudo isso, usando @get/@post, getunicode, context manager de conexão, sqlite3.Row para acessar colunas por nome, exclusão via POST e 404 para ID inexistente.
"""CRUD de clientes com Bottle + SQLite.
Requisitos: Python 3.12+ e `bottle`.
Executar: python app_clientes.py -> http://localhost:8080
"""
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from bottle import abort, get, post, redirect, request, run, template
# Caminho absoluto: não depende do diretório de onde o script é executado.
DB_PATH = Path(__file__).with_name("meu_banco_de_dados.db")
STYLE = """
body { font-family: Arial, sans-serif; margin: 30px; }
h1, h2 { color: #333; }
form.card { margin-bottom: 20px; background: #f4f4f4; padding: 15px;
border-radius: 5px; width: 300px; }
form.card input { margin-bottom: 10px; padding: 8px; width: 90%; display: block; }
form.card input[type="submit"] { background: #28a745; color: white; border: none;
cursor: pointer; width: 120px; }
form.inline { display: inline; }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #007bff; color: white; }
.btn-edit { color: #007bff; text-decoration: none; font-weight: bold;
margin-right: 10px; }
.btn-del { color: red; font-weight: bold; background: none; border: none;
cursor: pointer; padding: 0; font-size: 1em; }
a.voltar { color: #6c757d; text-decoration: none; }
"""
INDEX_TPL = """<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<title>Gestão de Clientes - Bottle + SQLite</title>
<style>{{!estilo}}</style>
</head>
<body>
<h1>Cadastro de Clientes</h1>
<form class="card" action="/cadastrar" method="post">
<label>Nome:</label>
<input type="text" name="nome" required placeholder="Digite o nome completo">
<label>E-mail:</label>
<input type="email" name="email" required placeholder="exemplo@email.com">
<input type="submit" value="Cadastrar">
</form>
<h2>Clientes Cadastrados</h2>
% if not clientes:
<p>Nenhum cliente cadastrado ainda.</p>
% else:
<table>
<tr><th>ID</th><th>Nome</th><th>E-mail</th><th>Ações</th></tr>
% for c in clientes:
<tr>
<td>{{c['id']}}</td>
<td>{{c['nome']}}</td>
<td>{{c['email']}}</td>
<td>
<a class="btn-edit" href="/editar/{{c['id']}}">Editar</a>
<form class="inline" action="/deletar/{{c['id']}}" method="post"
onsubmit="return confirm('Excluir este cliente?');">
<button class="btn-del" type="submit">Excluir</button>
</form>
</td>
</tr>
% end
</table>
% end
</body>
</html>
"""
EDIT_TPL = """<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<title>Editar Cliente</title>
<style>{{!estilo}}</style>
</head>
<body>
<h1>Editar Cliente #{{cliente['id']}}</h1>
<form class="card" action="/editar/{{cliente['id']}}" method="post">
<label>Nome:</label>
<input type="text" name="nome" value="{{cliente['nome']}}" required>
<label>E-mail:</label>
<input type="email" name="email" value="{{cliente['email']}}" required>
<input type="submit" value="Salvar">
</form>
<a class="voltar" href="/">← Cancelar e Voltar</a>
</body>
</html>
"""
@contextmanager
def get_connection() -> Iterator[sqlite3.Connection]:
"""Abre uma conexão SQLite, confirma ao final e sempre a fecha.
Yields:
Conexão com `row_factory` configurada para acesso por nome de coluna.
"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def inicializar_banco() -> None:
"""Garante a existência da tabela 'clientes'."""
with get_connection() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS clientes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
email TEXT NOT NULL
);
"""
)
def ler_formulario() -> tuple[str, str]:
"""Lê e valida nome/e-mail do formulário enviado.
Returns:
Tupla (nome, email) já sem espaços nas pontas.
Raises:
HTTPError: 400 se algum campo estiver vazio ou o e-mail for inválido.
"""
# getunicode() corrige a decodificação (latin-1) que o Bottle faz no Python 3,
# evitando "João" no banco quando há acentos.
nome = (request.forms.getunicode("nome") or "").strip()
email = (request.forms.getunicode("email") or "").strip()
if not nome or "@" not in email:
abort(400, "Informe um nome e um e-mail válidos.")
return nome, email
@get("/")
def index() -> str:
"""Lista os clientes e exibe o formulário de cadastro."""
with get_connection() as conn:
clientes = conn.execute(
"SELECT id, nome, email FROM clientes ORDER BY id;"
).fetchall()
return template(INDEX_TPL, clientes=clientes, estilo=STYLE)
@post("/cadastrar")
def cadastrar() -> None:
"""Cria um novo cliente e volta para a listagem (Post/Redirect/Get)."""
nome, email = ler_formulario()
with get_connection() as conn:
conn.execute(
"INSERT INTO clientes (nome, email) VALUES (?, ?);", (nome, email)
)
redirect("/")
@get("/editar/<cliente_id:int>")
def exibir_formulario_edicao(cliente_id: int) -> str:
"""Exibe o formulário de edição preenchido com os dados do cliente."""
with get_connection() as conn:
cliente = conn.execute(
"SELECT id, nome, email FROM clientes WHERE id = ?;", (cliente_id,)
).fetchone()
if cliente is None:
abort(404, "Cliente não encontrado.")
return template(EDIT_TPL, cliente=cliente, estilo=STYLE)
@post("/editar/<cliente_id:int>")
def salvar_edicao(cliente_id: int) -> None:
"""Atualiza nome e e-mail do cliente."""
nome, email = ler_formulario()
with get_connection() as conn:
cursor = conn.execute(
"UPDATE clientes SET nome = ?, email = ? WHERE id = ?;",
(nome, email, cliente_id),
)
if cursor.rowcount == 0:
abort(404, "Cliente não encontrado.")
redirect("/")
@post("/deletar/<cliente_id:int>")
def deletar(cliente_id: int) -> None:
"""Remove o cliente. Só aceita POST, para evitar exclusão por simples link."""
with get_connection() as conn:
conn.execute("DELETE FROM clientes WHERE id = ?;", (cliente_id,))
redirect("/")
if __name__ == "__main__":
inicializar_banco()
print("Servidor rodando em http://localhost:8080 ...")
# Em produção: debug=False e reloader=False.
run(host="localhost", port=8080, debug=True, reloader=True)Veja o código completo em:
💡 Próximos passos: como tratar erros de banco de dados (usando try/except com rollback()) para evitar que a aplicação caia em caso de falha de conexão.
⬅️ Pratica CRUD Python Sqlite Segunda fase - Front End Web | ➡️ Pratica CRUD Python Sqlite Quarta fase - tratamento de erros