Apariencia
📙 Clase 35 — 🏆 Proyecto final: Gestor de Gastos
Fase 6 · Especialización: Apps de escritorio 🎯 · Proyecto integrador ⬅️ Volver al índice de clases
🎯 La misión
Construir —tú, de cero— una app de escritorio completa y distribuible: un Gestor de Gastos Personales. Usa TODO el curso. Esta clase es la guía por etapas (modo guiado, como tus ejercicios: qué construir + pistas, sin solución — el código es tuyo 💪).
🖼️ Qué vas a construir
┌─────────────────────────────────────────────┐
│ 💰 Mis Gastos [⚙️] [📤] │
│ ┌───────────────────────────────────────┐ │
│ │ Concepto [________] Monto [____] │ │
│ │ Categoría [Comida ▼] [+ Agregar] │ │
│ └───────────────────────────────────────┘ │
│ 🔎 [buscar…] Mes: [Julio ▼] │
│ ┌───────────────────────────────────────┐ │
│ │ 11/07 Almuerzo Comida S/25 🗑 │ │
│ │ 10/07 Taxi Transporte S/15 🗑 │ │
│ │ … │ │
│ └───────────────────────────────────────┘ │
│ Total del mes: S/ 480 [os por categoría]│
└─────────────────────────────────────────────┘Funcionalidad mínima (v1.0): agregar gasto (concepto, monto, categoría, fecha automática) · listarlos ordenados por fecha · buscar por texto · eliminar con confirmación · total del mes · exportar a CSV · los datos persisten en SQLite · empaquetada como .app.
🗺️ Las 7 etapas (cada una usa lo que ya sabes)
Etapa 1 — Esqueleto y arquitectura (Clases 11, 18, 29)
- Estructura:
main.py,ui.py,logica.py,datos.py. class App(ctk.CTk)con título, tamaño y un label "Hola gastos" para verificar arranque.- ✅ Hito:
python main.pyabre la ventana.
Etapa 2 — La base de datos (Clases 30, 31)
- En
datos.py:conectar()con la tablagastos(id AUTOINCREMENT, concepto TEXT NOT NULL, monto REAL, categoria TEXT, fecha TEXT). - Funciones CRUD:
insertar_gasto(con, dict),obtener_gastos(con, filtro=""),eliminar_gasto(con, id). - 💡 Pista: BD en
Path.home() / ".mis_gastos" / "gastos.db"desde YA (Clases 22 y 34). - ✅ Hito: desde la terminal (sin GUI) insertas y lees gastos.
Etapa 3 — El formulario (Clases 08, 09, 12)
- Entries de concepto y monto +
CTkOptionMenude categorías + botón Agregar. - En
logica.py:validar_gasto(concepto, monto) -> tuple[bool, str](monto numérico y > 0 — atrapa elValueErrordelfloat()). - Fecha automática:
datetime.now().isoformat()(Clase 22). - ✅ Hito: agregar un gasto desde la GUI y verlo en la BD.
Etapa 4 — La lista viva (Clases 02, 07, 31)
_pintar()que consulta la BD y crea una fila-widget por gasto (concepto, monto, 🗑).- Búsqueda en vivo (
bind("<KeyRelease>")→LIKE). - Eliminar con
askyesno(Clase 32) y repintar desde la BD. - ✅ Hito: buscar "taxi" filtra; borrar pide confirmación.
Etapa 5 — Resumen y orden (Clases 24, 31)
- Label de total del mes:
SELECT SUM(monto) WHERE fecha LIKE '2026-07%'. - Desplegable "ordenar por": fecha / monto / categoría (
ORDER BYosorted(key=)). - 🎁 Reto extra: ventana
CTkToplevelcon totales por categoría (GROUP BY categoria). - ✅ Hito: el total cambia al agregar/borrar.
Etapa 6 — Exportar (Clases 21, 32)
- Botón 📤 →
asksaveasfilename→csv.DictWritercon los gastos visibles. showinfoal terminar; maneja la cancelación ("").- ✅ Hito: el CSV abre en Excel/Numbers con sus columnas.
Etapa 7 — Pulir y empaquetar (Clases 26, 28, 34)
- Type hints en
logica.pyydatos.py(Clase 27). pyinstaller --windowed --name "MisGastos" --collect-all customtkinter main.py.- Pruébala como app: agrega gastos, ciérrala, reábrela.
- ✅ Hito final: 🏆 una app de doble clic, con BD, hecha 100% por ti.
📋 Checklist de calidad (el sello "pro")
- [ ] Cero
global; estado enself· [ ]logica.py/datos.pyno importan customtkinter - [ ] Todo SQL con placeholders · [ ] Escrituras dentro de
with con: - [ ] Acciones destructivas confirman · [ ] Errores → mensajes amables (nunca traceback)
- [ ] Datos en
Path.home()· [ ]requirements.txtactualizado
💡 Método: una etapa por sesión de estudio. Si te atascas, cada etapa te dice QUÉ clase repasar. Los tropiezos van a 06-Errores — serán tus mejores apuntes.
🔐 Solución de referencia (v1.0 mínima)
⚠️ Última puerta: este proyecto es tu examen final — el valor está en pelearlo. Abre cada archivo solo cuando tu versión de esa capa ya funcione (o lleves >1 sesión atascado en la misma etapa). Verificada en tu
.venv:logicaydatospasan pruebas automáticas sin GUI; la vista compila e importa. Cubre las etapas 1–4 y 6 + total del mes (los retos de la etapa 5 y el empaquetado quedan para ti).
Ver solución · logica.py
python
"""Reglas puras: sin widgets, sin SQL."""
def validar_gasto(concepto: str, monto_texto: str) -> tuple[bool, str]:
if not concepto.strip():
return False, "El concepto está vacío"
try:
monto = float(monto_texto)
except ValueError:
return False, "El monto debe ser un número"
if monto <= 0:
return False, "El monto debe ser mayor a 0"
return True, ""Ver solución · datos.py
python
"""Capa de datos: lo único que sabe SQL."""
import sqlite3
from pathlib import Path
RUTA_BD = Path.home() / ".mis_gastos" / "gastos.db"
def conectar(ruta=RUTA_BD):
ruta.parent.mkdir(exist_ok=True)
con = sqlite3.connect(ruta)
con.row_factory = sqlite3.Row
con.execute("""CREATE TABLE IF NOT EXISTS gastos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
concepto TEXT NOT NULL,
monto REAL NOT NULL,
categoria TEXT,
fecha TEXT)""")
return con
def insertar_gasto(con, gasto: dict) -> int:
with con:
cur = con.execute(
"INSERT INTO gastos (concepto, monto, categoria, fecha) "
"VALUES (:concepto, :monto, :categoria, :fecha)", gasto)
return cur.lastrowid
def obtener_gastos(con, filtro: str = "") -> list:
return con.execute(
"SELECT * FROM gastos WHERE concepto LIKE ? ORDER BY fecha DESC",
(f"%{filtro}%",)).fetchall()
def eliminar_gasto(con, id: int) -> bool:
with con:
return con.execute("DELETE FROM gastos WHERE id = ?", (id,)).rowcount > 0
def total_del_mes(con, anio_mes: str) -> float:
fila = con.execute(
"SELECT COALESCE(SUM(monto), 0) FROM gastos WHERE fecha LIKE ?",
(f"{anio_mes}%",)).fetchone()
return fila[0]Ver solución · ui.py
python
"""Vista: widgets y callbacks; delega en logica y datos."""
from datetime import datetime
from tkinter import messagebox, filedialog
import csv
import customtkinter as ctk
import logica
import datos
CATEGORIAS = ["Comida", "Transporte", "Casa", "Ocio", "Otros"]
class App(ctk.CTk):
def __init__(self):
super().__init__()
self.title("💰 Mis Gastos")
self.geometry("520x600")
self.con = datos.conectar()
self._crear_widgets()
self._pintar()
def _crear_widgets(self):
form = ctk.CTkFrame(self)
form.pack(fill="x", padx=12, pady=12)
self.e_concepto = ctk.CTkEntry(form, placeholder_text="Concepto")
self.e_concepto.pack(side="left", expand=True, fill="x", padx=6, pady=8)
self.e_monto = ctk.CTkEntry(form, placeholder_text="Monto", width=80)
self.e_monto.pack(side="left", padx=6)
self.m_categoria = ctk.CTkOptionMenu(form, values=CATEGORIAS, width=110)
self.m_categoria.pack(side="left", padx=6)
ctk.CTkButton(form, text="+ Agregar", width=90,
command=self._agregar).pack(side="left", padx=6)
barra = ctk.CTkFrame(self)
barra.pack(fill="x", padx=12)
self.buscador = ctk.CTkEntry(barra, placeholder_text="🔎 Buscar…")
self.buscador.pack(side="left", expand=True, fill="x", padx=6, pady=6)
self.buscador.bind("<KeyRelease>", lambda e: self._pintar()) # búsqueda en vivo
ctk.CTkButton(barra, text="📤 CSV", width=80,
command=self._exportar).pack(side="left", padx=6)
self.lista = ctk.CTkScrollableFrame(self)
self.lista.pack(fill="both", expand=True, padx=12, pady=12)
self.status = ctk.CTkLabel(self, text="")
self.status.pack(pady=(0, 10))
def _agregar(self):
ok, msg = logica.validar_gasto(self.e_concepto.get(), self.e_monto.get())
if not ok:
self.status.configure(text=f"⚠️ {msg}")
return
datos.insertar_gasto(self.con, {
"concepto": self.e_concepto.get().strip(),
"monto": float(self.e_monto.get()),
"categoria": self.m_categoria.get(),
"fecha": datetime.now().strftime("%Y-%m-%d"),
})
self.e_concepto.delete(0, "end")
self.e_monto.delete(0, "end")
self._pintar()
def _eliminar(self, id):
if not messagebox.askyesno("Confirmar", "¿Eliminar este gasto?"):
return
datos.eliminar_gasto(self.con, id)
self._pintar()
def _exportar(self):
ruta = filedialog.asksaveasfilename(defaultextension=".csv",
filetypes=[("CSV (Excel)", "*.csv")])
if not ruta: # canceló
return
gastos = datos.obtener_gastos(self.con, self.buscador.get())
with open(ruta, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["fecha", "concepto", "categoria", "monto"])
for g in gastos:
writer.writerow([g["fecha"], g["concepto"], g["categoria"], g["monto"]])
messagebox.showinfo("Listo", f"Exportados {len(gastos)} gastos")
def _pintar(self):
for w in self.lista.winfo_children():
w.destroy()
for g in datos.obtener_gastos(self.con, self.buscador.get()):
fila = ctk.CTkFrame(self.lista)
texto = f'{g["fecha"]} {g["concepto"]} · {g["categoria"]} · S/{g["monto"]:.2f}'
ctk.CTkLabel(fila, text=texto).pack(side="left", padx=6)
ctk.CTkButton(fila, text="🗑", width=30,
command=lambda id=g["id"]: self._eliminar(id)).pack(side="right", padx=4)
fila.pack(fill="x", pady=2)
mes = datetime.now().strftime("%Y-%m")
self.status.configure(text=f"Total del mes: S/ {datos.total_del_mes(self.con, mes):.2f}")Ver solución · main.py
python
from ui import App
if __name__ == "__main__":
App().mainloop()🧪 Para la entrevista: al terminar tendrás un proyecto que demuestra: POO, arquitectura en capas, SQL parametrizado, manejo de errores, persistencia y distribución. Es exactamente el tipo de proyecto que se muestra en un portafolio.
🚀 ¿Y después? (rutas de crecimiento)
| Siguiente paso | Qué agrega |
|---|---|
| Gráficos de gastos | matplotlib embebido en CTk |
| Múltiples monedas | la API de la Clase 23 + threading (Clase 33) |
| Respaldo en la nube | subir el .db / sincronizar vía API |
| Tests | pytest sobre logica.py y datos.py (¡ya son testeables!) |
| Otra app del mundo real | inventario, citas, biblioteca… mismo esqueleto |
📎 Apuntes relacionados
- El índice con TODAS las clases → 00-Indice
- Tus ejercicios previos (el camino hasta aquí) → 01 · 02 · 03
🎓 Fin de la ruta
Cuando termines este proyecto, habrás recorrido las 6 fases completas. Avísame y armamos el 90-Resumen/ del curso + tu siguiente ruta (gráficos, tests, o la especialización que elijas).