Programación Orientada a Objetos

Ing. Bernardo Prado · UTH 2026-2 · DSM3

⚙️ Unidad 3 🐍 Python 3 🗄️ MySQL 🖥️ Flet 0.24.1

⚙️ Práctica 3 — Principios SOLID, GUI con Flet y CRUD con MySQL

Construirás un Sistema de Inventario completo aplicando los principios SOLID de diseño, con interfaz gráfica usando Flet y base de datos MySQL para guardar, leer, actualizar y eliminar datos.

⏱ 150 minutos aprox. 🎯 Nivel: Avanzado 📁 Proyecto: inventario_app/ 🛠 Flet 0.24.1 + MySQL

🛠️ Sección 0 — Instalaciones Necesarias

⚠️ Esta práctica requiere instalar herramientas adicionales Sigue cada paso con cuidado. Si algo falla, revisa los mensajes de error antes de continuar.
A
Instalar WAMP Server (servidor MySQL local)

WAMP (Windows, Apache, MySQL, PHP) instala un servidor MySQL en tu computadora. Ve a wampserver.com, descarga la versión más reciente e instálala. Cuando esté corriendo, el ícono en la barra de tareas debe ser verde.

🖼️ Captura: Ícono de WAMP en la barra de tareas

Busca el ícono de WAMP en la esquina inferior derecha de tu pantalla (barra de tareas). Debe ser verde. Si es amarillo o rojo, algún servicio no inició — haz clic derecho y selecciona "Start All Services".

B
Instalar la librería mysql-connector-python

Esta librería permite a Python comunicarse con MySQL. Abre una terminal en VS Code (Ctrl + `) y escribe (usaremos una versión fija para que todos en el grupo tengan exactamente el mismo comportamiento):

Terminal
pip install mysql-connector-python==9.5.0
✅ Resultado esperado (al final): Successfully installed mysql-connector-python-9.5.0
C
Instalar flet — Framework de GUI

Flet permite crear interfaces gráficas (ventanas con botones, formularios, tablas) usando Python puro. No necesitas conocer HTML ni JavaScript. Instalaremos la versión 0.24.1 exacta — las versiones más nuevas de Flet renombraron varias cosas (por ejemplo ft.colors pasó a ft.Colors) y el código de esta práctica está escrito para 0.24.1.

Terminal
pip install flet==0.24.1
✅ Resultado esperado: Successfully installed flet-0.24.1 ...
⚠️ Importante: respeta las versiones exactas Si ya tenías Flet instalado con otra versión, este comando lo reemplaza por la 0.24.1. No instales flet ni mysql-connector-python sin el ==versión — una versión distinta puede usar nombres de atributos diferentes (como ft.Colors en vez de ft.colors) y el código de esta práctica dejará de funcionar.
D
Crear la base de datos en MySQL

Con WAMP corriendo (ícono verde), abre tu navegador y ve a http://localhost/phpmyadmin. Inicia sesión con usuario root y contraseña en blanco (vacía). Haz clic en "Nueva" (panel izquierdo) y crea una base de datos llamada inventario_uth. Usa cotejamiento utf8_general_ci.

🖼️ Captura: phpMyAdmin — Crear nueva base de datos

En el panel izquierdo verás un botón "Nueva". Al hacer clic aparece un formulario. Escribe "inventario_uth" en el campo "Nombre de la base de datos" y haz clic en "Crear".

💡 Alternativa: Crear la base de datos desde Python Si prefieres no usar phpMyAdmin, más adelante en la práctica crearemos la base de datos automáticamente desde Python.
E
Estructura de carpetas del proyecto

Crea la siguiente estructura de carpetas. En VS Code, crea una carpeta Unidad3 con esta organización:

📁 Unidad3/ ├── 📄 main.py ← Punto de entrada (GUI con Flet) ├── 📄 modelos.py ← Clase Producto (modelo de datos) ├── 📄 repositorio.py ← Acceso a la base de datos (CRUD) └── 📄 configuracion.py ← Datos de conexión a MySQL
🧠 ¿Por qué varios archivos? — Principio SOLID: SRP Cada archivo tiene una sola responsabilidad. Esto es el primer principio SOLID (Single Responsibility Principle). Hace que el código sea más fácil de mantener y entender.

⚖️ Sección 1 — Los Principios SOLID

SOLID son 5 principios de diseño de software orientado a objetos. Esta práctica los aplica todos. Léelos antes de escribir código.

S — Single Responsibility (Responsabilidad Única)

Una clase debe tener una sola razón para cambiar. En nuestro proyecto: Producto solo conoce los datos, RepositorioProducto solo habla con la BD, y main.py solo maneja la interfaz.

O — Open/Closed (Abierto/Cerrado)

Las clases deben estar abiertas para extender, cerradas para modificar. Si queremos agregar un nuevo tipo de repositorio (ej: guardar en archivo en vez de BD), creamos una nueva clase en lugar de modificar la existente.

L — Liskov Substitution (Sustitución de Liskov)

Si B hereda de A, en cualquier lugar donde uses A puedes usar B sin que el programa se rompa. Visto en Unidad 2: EstudianteBecario puede usarse donde se espera un Estudiante.

I — Interface Segregation (Segregación de Interfaces)

No obligues a una clase a implementar métodos que no usa. Divide interfaces grandes en pequeñas. En Python esto se logra con múltiples clases abstractas pequeñas.

D — Dependency Inversion (Inversión de Dependencias)

Las clases de alto nivel no deben depender de clases de bajo nivel — ambas deben depender de abstracciones. La GUI dependerá de una interfaz de repositorio, no de MySQL directamente.

🏢 Sección 2 — El Escenario

📦 Sistema de Inventario — Tienda UTH Tech

La tienda "UTH Tech" necesita un sistema para controlar su inventario de productos electrónicos. El sistema debe:

  • Crear (Create): Registrar nuevos productos con nombre, categoría, precio y cantidad.
  • Leer (Read): Ver la lista de todos los productos existentes.
  • Actualizar (Update): Modificar el precio o la cantidad de un producto.
  • Eliminar (Delete): Borrar un producto del inventario.

Estos 4 pasos — Create, Read, Update, Delete — forman lo que se conoce como CRUD. Es la base de casi toda aplicación real.

Variables que usaremos

Variable/AtributoTipoEjemploDescripción
id_productoint1Clave primaria, auto-generada por MySQL
nombrestr"Laptop HP"Nombre del producto
categoriastr"Computadoras"Categoría del producto
preciofloat12500.00Precio unitario en pesos
cantidadint15Unidades disponibles en almacén

⚙️ Sección 3 — Archivo de Configuración (configuracion.py)

1
Crear configuracion.py

Este archivo guarda los datos de conexión a la base de datos. Al tenerlos en un solo lugar, si cambian (nuevo servidor, contraseña, etc.) solo modificas aquí — no en todo el código. Esto aplica el principio DRY (Don't Repeat Yourself).

configuracion.py
# =====================================================
# configuracion.py
# Datos de conexión a la base de datos MySQL
# =====================================================

# Diccionario con los datos de conexión
# Un diccionario es una estructura clave:valor en Python
CONFIGURACION_BD = {  # Abre el diccionario de configuración de la BD
    "host":     "localhost",  # Dirección del servidor MySQL (local)
    "user":     "root",       # Usuario de MySQL (por defecto en WAMP)
    "password": "",          # Contraseña (vacía en WAMP por defecto)
    "database": "inventario_uth"  # Nombre de la base de datos
}  # Cierra el diccionario CONFIGURACION_BD

# Nombre de la tabla que usaremos
TABLA_PRODUCTOS = "productos"  # Constante con el nombre de la tabla SQL
FragmentoExplicación
CONFIGURACION_BD = {...}Un diccionario de Python. Las llaves van entre comillas ("host") y los valores también pueden ser strings, números, etc. Se accede con CONFIGURACION_BD["host"].
"localhost"La dirección del servidor MySQL. Cuando usas WAMP localmente, siempre es "localhost" o "127.0.0.1".
"password": ""WAMP instala MySQL sin contraseña. Si tu MySQL tiene contraseña, escríbela aquí.

📦 Sección 4 — Modelo de Datos (modelos.py)

2
Crear la clase Producto en modelos.py

La clase Producto SOLO representa los datos de un producto. No sabe nada de bases de datos ni de interfaz gráfica. Esto es el principio de Responsabilidad Única (S de SOLID).

modelos.py
# =====================================================
# modelos.py
# Define la estructura de los datos (modelo Producto)
# SOLID: Responsabilidad Única — solo modela datos
# =====================================================


class Producto:  # Declara la clase Producto (sin lógica de BD ni de GUI)
    """
    Clase que modela un producto del inventario.
    Solo representa datos — no hace operaciones de BD.
    """

    def __init__(self, nombre: str, categoria: str,  # Constructor: recibe los datos básicos del producto
                 precio: float, cantidad: int, id_producto: int = None):  # id_producto es opcional (None = aún no guardado en MySQL)
        """
        Constructor del Producto.
        El id_producto es None al crear (MySQL lo asigna automáticamente).
        """
        self.id_producto = id_producto  # None = producto nuevo (sin ID aún)
        self.nombre     = nombre      # Guarda el nombre recibido como atributo del objeto
        self.categoria  = categoria   # Guarda la categoría recibida como atributo del objeto
        self.precio     = precio      # Guarda el precio recibido como atributo del objeto
        self.cantidad   = cantidad    # Guarda la cantidad recibida como atributo del objeto

    def valor_total(self) -> float:  # Método que calcula el valor total en inventario
        """Calcula el valor total del producto en inventario."""
        return self.precio * self.cantidad  # Multiplica precio por cantidad y retorna el resultado

    def hay_stock(self) -> bool:  # Método que indica si hay unidades disponibles
        """Retorna True si hay al menos 1 unidad disponible."""
        return self.cantidad > 0  # True si la cantidad es mayor a cero

    def __str__(self) -> str:  # Define cómo se muestra el producto al imprimirlo
        return (  # Inicia la construcción del string a retornar
            f"[ID:{self.id_producto}] {self.nombre} | "  # Primera parte: ID y nombre del producto
            f"Categoría: {self.categoria} | "  # Segunda parte: categoría del producto
            f"Precio: ${self.precio:,.2f} | "  # Tercera parte: precio formateado con 2 decimales
            f"Stock: {self.cantidad}"  # Cuarta parte: cantidad disponible en stock
        )  # Cierra la concatenación implícita de los strings y el return

    def __repr__(self) -> str:  # Define la representación técnica del objeto
        """Representación técnica del objeto (usada en debugging)."""
        return f"Producto(id={self.id_producto}, nombre='{self.nombre}')"  # String con formato Producto(id=.., nombre=..)
FragmentoExplicación
id_producto: int = NoneEl ID tiene valor por defecto None. Cuando creamos un producto nuevo, aún no tiene ID — MySQL lo asignará al guardarlo. Después de guardar, el ID se actualiza.
NoneEs el valor nulo de Python. Significa "sin valor" o "no asignado todavía".
self.precio * self.cantidadEl método valor_total() multiplica precio por cantidad para saber cuánto vale el stock total de ese producto.
__repr__Similar a __str__ pero para uso técnico (debugging). Se muestra en la consola interactiva de Python.

🗄️ Sección 5 — Repositorio CRUD (repositorio.py)

Aquí va toda la lógica de comunicación con MySQL. Esta clase aplica SOLID: es la única responsable de la BD.

3
Interfaz abstracta del repositorio (principio D de SOLID)

Primero definimos una interfaz abstracta que declara qué operaciones debe tener cualquier repositorio. Luego implementamos una para MySQL. Si en el futuro cambiamos a otra BD, creamos otra clase que implemente la misma interfaz.

repositorio.py — Interfaz + Implementación MySQL
# =====================================================
# repositorio.py
# Capa de acceso a datos: operaciones CRUD con MySQL
# SOLID: SRP + DIP (Inversión de dependencias)
# =====================================================

from abc import ABC, abstractmethod  # clase base y decorador para crear interfaces
import mysql.connector          # librería para conectar con MySQL
from mysql.connector import Error  # clase de error de MySQL
from modelos import Producto       # importamos nuestro modelo
from configuracion import CONFIGURACION_BD  # datos de conexión


# ─── INTERFAZ ABSTRACTA (principio D de SOLID) ───
class IRepositorioProducto(ABC):  # Hereda de ABC para volverse una clase abstracta
    """
    Interfaz abstracta que define el contrato del repositorio.
    Cualquier implementación DEBE implementar estos 4 métodos.
    """

    @abstractmethod  # Obliga a las subclases a implementar este método
    def crear(self, producto: Producto) -> bool: pass  # Firma del método para insertar un producto

    @abstractmethod  # Obliga a las subclases a implementar este método
    def leer_todos(self) -> list: pass  # Firma del método para listar todos los productos

    @abstractmethod  # Obliga a las subclases a implementar este método
    def actualizar(self, producto: Producto) -> bool: pass  # Firma del método para modificar un producto

    @abstractmethod  # Obliga a las subclases a implementar este método
    def eliminar(self, id_producto: int) -> bool: pass  # Firma del método para borrar un producto por ID


# ─── IMPLEMENTACIÓN CONCRETA PARA MYSQL ───
class RepositorioProductoMySQL(IRepositorioProducto):  # Implementa la interfaz para MySQL
    """
    Implementación del repositorio usando MySQL.
    Solo esta clase sabe sobre MySQL — la GUI no necesita saberlo.
    """

    def __init__(self):  # Constructor: se ejecuta al crear el repositorio
        """Inicializa la conexión y crea la tabla si no existe."""
        self.__conexion = None  # Atributo privado que guardará la conexión a MySQL
        self._conectar()  # Llama al método interno que abre la conexión
        self._crear_tabla()  # Llama al método interno que crea la tabla si falta

    def _conectar(self):  # Método privado que abre la conexión con MySQL
        """Establece la conexión con la base de datos."""
        try:  # Intenta conectarse (puede fallar si MySQL no responde)
            self.__conexion = mysql.connector.connect(**CONFIGURACION_BD)  # Abre la conexión con los datos del diccionario
            print("✅ Conexión a MySQL exitosa.")  # Informa en consola que la conexión fue exitosa
        except Error as e:  # Captura cualquier error de conexión de MySQL
            print(f"❌ Error al conectar: {e}")  # Muestra el mensaje de error en consola

    def _crear_tabla(self):  # Método privado que crea la tabla 'productos' si falta
        """Crea la tabla 'productos' si no existe en la BD."""
        sql = """
            CREATE TABLE IF NOT EXISTS productos (   -- crea la tabla solo si aún no existe
                id_producto INT AUTO_INCREMENT PRIMARY KEY, -- ID numérico autoincremental, clave primaria
                nombre      VARCHAR(100) NOT NULL,    -- nombre del producto, obligatorio
                categoria   VARCHAR(60)  NOT NULL,    -- categoría del producto, obligatoria
                precio      DECIMAL(10,2) NOT NULL,   -- precio con 2 decimales, obligatorio
                cantidad    INT NOT NULL DEFAULT 0    -- unidades en stock, 0 por defecto
            )                                          -- cierra la lista de columnas
        """  # Fin de la sentencia SQL que crea la tabla
        try:  # Intenta ejecutar la creación de la tabla
            cursor = self.__conexion.cursor()  # Crea un cursor para ejecutar comandos SQL
            cursor.execute(sql)  # Ejecuta la sentencia CREATE TABLE
            self.__conexion.commit()  # Confirma el cambio en la base de datos
            print("✅ Tabla 'productos' lista.")  # Informa en consola que la tabla quedó lista
        except Error as e:  # Captura cualquier error al crear la tabla
            print(f"❌ Error al crear tabla: {e}")  # Muestra el mensaje de error en consola

    # ────── CREATE ──────
    def crear(self, producto: Producto) -> bool:  # Método público para insertar un producto nuevo
        """INSERT: guarda un nuevo producto en la base de datos."""
        sql = """
            INSERT INTO productos (nombre, categoria, precio, cantidad) -- columnas a llenar
            VALUES (%s, %s, %s, %s)                                     -- placeholders seguros
        """  # Fin de la sentencia SQL de inserción
        # Los valores van en una tupla separada — NUNCA los pongas en el string
        # (eso evita inyección SQL, un riesgo de seguridad)
        valores = (producto.nombre, producto.categoria,  # Tupla con nombre y categoría del producto
                   producto.precio, producto.cantidad)  # Continúa la tupla con precio y cantidad
        try:  # Intenta ejecutar el INSERT (puede fallar)
            cursor = self.__conexion.cursor()  # Crea un cursor para ejecutar el INSERT
            cursor.execute(sql, valores)  # Ejecuta el INSERT sustituyendo los %s con la tupla
            self.__conexion.commit()  # Confirma la inserción en la base de datos
            # Actualiza el ID del producto con el que MySQL asignó
            producto.id_producto = cursor.lastrowid  # Guarda el ID autogenerado en el objeto Producto
            print(f"✅ Producto '{producto.nombre}' creado con ID {producto.id_producto}.")  # Confirma en consola
            return True  # Indica que la operación fue exitosa
        except Error as e:  # Captura cualquier error al insertar
            print(f"❌ Error al crear producto: {e}")  # Muestra el mensaje de error en consola
            return False  # Indica que la operación falló

    # ────── READ ──────
    def leer_todos(self) -> list:  # Método público para listar todos los productos
        """SELECT: retorna una lista con TODOS los productos."""
        sql = "SELECT id_producto, nombre, categoria, precio, cantidad FROM productos ORDER BY nombre"  # Consulta ordenada por nombre
        try:  # Intenta ejecutar la consulta (puede fallar)
            cursor = self.__conexion.cursor()  # Crea un cursor para ejecutar el SELECT
            cursor.execute(sql)  # Ejecuta la consulta SELECT
            filas = cursor.fetchall()  # obtiene todas las filas

            # Convierte cada fila (tupla) en un objeto Producto
            productos = []  # Lista vacía donde se acumularán los objetos Producto
            for fila in filas:  # Recorre cada fila (tupla) devuelta por MySQL
                p = Producto(  # Crea un objeto Producto a partir de la fila
                    id_producto = fila[0],  # Columna 0: id_producto
                    nombre      = fila[1],  # Columna 1: nombre
                    categoria   = fila[2],  # Columna 2: categoría
                    precio      = float(fila[3]),  # Columna 3: precio convertido a float
                    cantidad    = fila[4]  # Columna 4: cantidad en stock
                )  # Cierra la creación del objeto Producto
                productos.append(p)  # Agrega el producto a la lista de resultados
            return productos  # Retorna la lista completa de productos
        except Error as e:  # Captura cualquier error al leer
            print(f"❌ Error al leer productos: {e}")  # Muestra el mensaje de error en consola
            return []  # Retorna una lista vacía si falló la lectura

    # ────── UPDATE ──────
    def actualizar(self, producto: Producto) -> bool:  # Método público para modificar un producto existente
        """UPDATE: modifica precio y cantidad de un producto existente."""
        sql = """
            UPDATE productos                                          -- tabla a modificar
            SET nombre = %s, categoria = %s, precio = %s, cantidad = %s -- nuevos valores
            WHERE id_producto = %s                                     -- fila a modificar
        """  # Fin de la sentencia SQL de actualización
        valores = (producto.nombre, producto.categoria,  # Tupla con nombre y categoría actualizados
                   producto.precio, producto.cantidad, producto.id_producto)  # Resto de valores y el ID a filtrar
        try:  # Intenta ejecutar el UPDATE (puede fallar)
            cursor = self.__conexion.cursor()  # Crea un cursor para ejecutar el UPDATE
            cursor.execute(sql, valores)  # Ejecuta el UPDATE sustituyendo los %s con la tupla
            self.__conexion.commit()  # Confirma la actualización en la base de datos
            print(f"✅ Producto ID {producto.id_producto} actualizado.")  # Confirma en consola
            return True  # Indica que la operación fue exitosa
        except Error as e:  # Captura cualquier error al actualizar
            print(f"❌ Error al actualizar: {e}")  # Muestra el mensaje de error en consola
            return False  # Indica que la operación falló

    # ────── DELETE ──────
    def eliminar(self, id_producto: int) -> bool:  # Método público para borrar un producto por ID
        """DELETE: elimina un producto por su ID."""
        sql = "DELETE FROM productos WHERE id_producto = %s"  # Borra la fila cuyo ID coincida
        try:  # Intenta ejecutar el DELETE (puede fallar)
            cursor = self.__conexion.cursor()  # Crea un cursor para ejecutar el DELETE
            cursor.execute(sql, (id_producto,))  # tupla de 1 elemento
            self.__conexion.commit()  # Confirma la eliminación en la base de datos
            print(f"✅ Producto ID {id_producto} eliminado.")  # Confirma en consola
            return True  # Indica que la operación fue exitosa
        except Error as e:  # Captura cualquier error al eliminar
            print(f"❌ Error al eliminar: {e}")  # Muestra el mensaje de error en consola
            return False  # Indica que la operación falló

    def cerrar(self):  # Método público para cerrar la conexión manualmente
        """Cierra la conexión con la base de datos (buena práctica)."""
        if self.__conexion and self.__conexion.is_connected():  # Verifica que exista una conexión activa
            self.__conexion.close()  # Cierra la conexión con MySQL
            print("🔌 Conexión cerrada.")  # Informa en consola que la conexión se cerró
Concepto SQLQué haceEquivalente en Python
INSERT INTOAgrega una fila nueva a la tablaMétodo crear()
SELECTLee filas de la tablaMétodo leer_todos()
UPDATE SETModifica filas existentesMétodo actualizar()
DELETE WHEREElimina filas que cumplan condiciónMétodo eliminar()
%s en SQLPlaceholder seguro para valoresSe reemplaza con los valores de la tupla — previene inyección SQL
cursor.commit()Confirma los cambios en la BDSin esto, los cambios no se guardan permanentemente
fetchall()Obtiene todas las filas del resultadoRetorna una lista de tuplas
🔐 ¡Nunca hagas esto!
MAL: sql = f"INSERT INTO productos VALUES ('{nombre}', ...)"
BIEN: sql = "INSERT INTO productos VALUES (%s, ...)" + tupla de valores

El primer caso permite ataques de Inyección SQL. El segundo es seguro porque mysql-connector sanitiza los valores automáticamente.

🖥️ Sección 6 — Interfaz Gráfica con Flet (main.py)

Flet crea aplicaciones de escritorio con Python. Cada elemento visual (botón, campo de texto, tabla) es un control de Flet. La función principal recibe un objeto page que representa la ventana.

4
Crear main.py — La interfaz gráfica completa
main.py — Interfaz completa con Flet
# =====================================================
# main.py
# Interfaz gráfica con Flet — Sistema de Inventario
# =====================================================

import flet as ft                       # importamos Flet como 'ft'
from modelos import Producto             # nuestro modelo de datos
from repositorio import RepositorioProductoMySQL  # repositorio MySQL


def main(page: ft.Page):  # Función de entrada que Flet invoca al iniciar la app
    """
    Función principal de Flet.
    'page' es la ventana principal de la aplicación.
    """

    # ─── Configuración básica de la ventana ───
    page.title   = "📦 Inventario UTH Tech"  # Texto que aparece en la barra de título
    page.bgcolor = ft.colors.BLUE_GREY_50  # Color de fondo de toda la ventana
    page.padding = 20  # Espacio interno entre el borde de la ventana y el contenido
    page.window.width  = 1000  # Ancho inicial de la ventana en píxeles
    page.window.height = 700  # Alto inicial de la ventana en píxeles

    # ─── Inicializar repositorio ───
    repo = RepositorioProductoMySQL()  # Crea el repositorio: abre conexión y crea la tabla

    # ─── Variable para rastrear qué producto se está editando ───
    producto_editando = [None]   # lista de 1 elemento (mutable en closures)

    # ────────────────────────────────────────────
    # CONTROLES DEL FORMULARIO
    # ────────────────────────────────────────────
    campo_nombre = ft.TextField(  # Crea el campo de texto para el nombre del producto
        label="Nombre del producto",  # Etiqueta visible sobre el campo
        hint_text="Ej: Laptop HP 15",  # Texto de ejemplo dentro del campo vacío
        expand=True  # El campo ocupa todo el espacio horizontal disponible
    )  # Cierra la creación de campo_nombre
    campo_categoria = ft.TextField(  # Crea el campo de texto para la categoría
        label="Categoría",  # Etiqueta visible sobre el campo
        hint_text="Ej: Computadoras",  # Texto de ejemplo dentro del campo vacío
        expand=True  # El campo ocupa todo el espacio horizontal disponible
    )  # Cierra la creación de campo_categoria
    campo_precio = ft.TextField(  # Crea el campo de texto para el precio
        label="Precio ($)",  # Etiqueta visible sobre el campo
        hint_text="Ej: 12500.00",  # Texto de ejemplo dentro del campo vacío
        keyboard_type=ft.KeyboardType.NUMBER,  # Muestra teclado numérico en móviles
        expand=True  # El campo ocupa todo el espacio horizontal disponible
    )  # Cierra la creación de campo_precio
    campo_cantidad = ft.TextField(  # Crea el campo de texto para la cantidad
        label="Cantidad",  # Etiqueta visible sobre el campo
        hint_text="Ej: 15",  # Texto de ejemplo dentro del campo vacío
        keyboard_type=ft.KeyboardType.NUMBER,  # Muestra teclado numérico en móviles
        expand=True  # El campo ocupa todo el espacio horizontal disponible
    )  # Cierra la creación de campo_cantidad

    # Mensaje de estado (éxito o error)
    lbl_estado = ft.Text("", size=14, color=ft.colors.GREEN_700)  # Texto vacío al inicio, se llenará dinámicamente

    # Tabla de datos
    tabla = ft.DataTable(  # Crea la tabla donde se listarán los productos
        columns=[  # Abre la lista de columnas de la tabla
            ft.DataColumn(ft.Text("ID",    weight=ft.FontWeight.BOLD)),  # Columna del ID
            ft.DataColumn(ft.Text("Nombre", weight=ft.FontWeight.BOLD)),  # Columna del nombre
            ft.DataColumn(ft.Text("Categoría", weight=ft.FontWeight.BOLD)),  # Columna de la categoría
            ft.DataColumn(ft.Text("Precio", weight=ft.FontWeight.BOLD)),  # Columna del precio
            ft.DataColumn(ft.Text("Cantidad", weight=ft.FontWeight.BOLD)),  # Columna de la cantidad
            ft.DataColumn(ft.Text("Acciones", weight=ft.FontWeight.BOLD)),  # Columna de botones editar/eliminar
        ],  # Cierra la lista de columnas
        rows=[],  # Empieza sin filas; se llenarán al cargar los datos
        bgcolor=ft.colors.WHITE,  # Fondo blanco para la tabla
        border_radius=8,  # Bordes redondeados de la tabla
    )  # Cierra la creación de la tabla

    # ────────────────────────────────────────────
    # FUNCIONES DE LÓGICA
    # ────────────────────────────────────────────

    def limpiar_formulario():  # Función que deja el formulario listo para un nuevo producto
        """Vacía todos los campos del formulario."""
        campo_nombre.value    = ""  # Borra el texto del campo nombre
        campo_categoria.value = ""  # Borra el texto del campo categoría
        campo_precio.value    = ""  # Borra el texto del campo precio
        campo_cantidad.value  = ""  # Borra el texto del campo cantidad
        producto_editando[0]  = None  # Indica que ya no hay producto en edición
        lbl_estado.value      = ""  # Borra el mensaje de estado mostrado
        page.update()  # Redibuja la página con los campos vacíos

    def actualizar_tabla():  # Función que refresca la tabla con los datos actuales
        """Recarga y muestra todos los productos en la tabla."""
        productos = repo.leer_todos()  # Pide al repositorio la lista actual de productos
        tabla.rows = []  # limpia las filas actuales

        for p in productos:  # Recorre cada producto para crear su fila en la tabla
            # Capturamos 'p' con un parámetro por defecto para el closure
            def editar(e, prod=p):  # Manejador del botón editar para este producto
                cargar_para_editar(prod)  # Llena el formulario con los datos de prod

            def borrar(e, prod=p):  # Manejador del botón eliminar para este producto
                repo.eliminar(prod.id_producto)  # Borra el producto de la base de datos
                lbl_estado.value = f"🗑️ '{prod.nombre}' eliminado."  # Mensaje de confirmación
                lbl_estado.color = ft.colors.ORANGE_700  # Color naranja para indicar eliminación
                actualizar_tabla()  # Vuelve a cargar la tabla sin el producto borrado

            fila = ft.DataRow(cells=[  # Crea la fila de la tabla para este producto
                ft.DataCell(ft.Text(str(p.id_producto))),  # Celda con el ID
                ft.DataCell(ft.Text(p.nombre)),  # Celda con el nombre
                ft.DataCell(ft.Text(p.categoria)),  # Celda con la categoría
                ft.DataCell(ft.Text(f"${p.precio:,.2f}")),  # Celda con el precio formateado
                ft.DataCell(ft.Text(str(p.cantidad))),  # Celda con la cantidad
                ft.DataCell(ft.Row([  # Celda con los botones de acción en fila
                    ft.IconButton(  # Botón de ícono para editar
                        icon=ft.icons.EDIT,  # Ícono de lápiz
                        tooltip="Editar",  # Texto al pasar el cursor encima
                        icon_color=ft.colors.BLUE,  # Color azul del ícono
                        on_click=editar  # Llama a editar() al hacer clic
                    ),  # Cierra el botón de editar
                    ft.IconButton(  # Botón de ícono para eliminar
                        icon=ft.icons.DELETE,  # Ícono de bote de basura
                        tooltip="Eliminar",  # Texto al pasar el cursor encima
                        icon_color=ft.colors.RED,  # Color rojo del ícono
                        on_click=borrar  # Llama a borrar() al hacer clic
                    ),  # Cierra el botón de eliminar
                ])),  # Cierra la fila de botones y la celda de acciones
            ])  # Cierra la lista de celdas de la fila
            tabla.rows.append(fila)  # Agrega la fila recién creada a la tabla

        page.update()  # redibuja la página

    def cargar_para_editar(producto: Producto):  # Función que pasa los datos de un producto al formulario
        """Llena el formulario con los datos del producto a editar."""
        producto_editando[0]   = producto  # Guarda referencia al producto que se está editando
        campo_nombre.value     = producto.nombre  # Llena el campo nombre con el valor actual
        campo_categoria.value  = producto.categoria  # Llena el campo categoría con el valor actual
        campo_precio.value     = str(producto.precio)  # Llena el campo precio (convertido a texto)
        campo_cantidad.value   = str(producto.cantidad)  # Llena el campo cantidad (convertido a texto)
        lbl_estado.value       = f"✏️ Editando: {producto.nombre}"  # Mensaje indicando que se está editando
        lbl_estado.color       = ft.colors.BLUE_700  # Color azul para el mensaje de edición
        page.update()  # Redibuja la página con el formulario lleno

    def guardar(e):  # Manejador del botón Guardar (crea o actualiza)
        """Guarda (crea o actualiza) el producto del formulario."""
        # Validar que los campos no estén vacíos
        if not campo_nombre.value or not campo_precio.value:  # Verifica que nombre y precio tengan valor
            lbl_estado.value = "❌ Nombre y precio son obligatorios."  # Mensaje de error de validación
            lbl_estado.color = ft.colors.RED_700  # Color rojo para el mensaje de error
            page.update()  # Redibuja la página mostrando el error
            return  # Sale de la función sin guardar nada

        try:  # Intenta convertir los textos a número (puede fallar)
            precio   = float(campo_precio.value)  # Convierte el texto del precio a número decimal
            cantidad = int(campo_cantidad.value) if campo_cantidad.value else 0  # Convierte la cantidad o usa 0 si está vacía
        except ValueError:  # Captura el error si el texto no es un número válido
            lbl_estado.value = "❌ Precio y cantidad deben ser números."  # Mensaje de error de formato
            lbl_estado.color = ft.colors.RED_700  # Color rojo para el mensaje de error
            page.update()  # Redibuja la página mostrando el error
            return  # Sale de la función sin guardar nada

        if producto_editando[0] is None:  # Comprueba si no hay producto en edición
            # Modo CREAR (no hay producto en edición)
            nuevo = Producto(campo_nombre.value, campo_categoria.value, precio, cantidad)  # Crea el objeto Producto nuevo
            repo.crear(nuevo)  # Inserta el producto nuevo en la base de datos
            lbl_estado.value = f"✅ '{nuevo.nombre}' guardado."  # Mensaje de éxito al crear
        else:  # Si hay un producto siendo editado
            # Modo ACTUALIZAR (hay un producto siendo editado)
            p = producto_editando[0]  # Obtiene el producto que se está editando
            p.nombre    = campo_nombre.value  # Actualiza el nombre con el valor del formulario
            p.categoria = campo_categoria.value  # Actualiza la categoría con el valor del formulario
            p.precio    = precio  # Actualiza el precio con el valor convertido
            p.cantidad  = cantidad  # Actualiza la cantidad con el valor convertido
            repo.actualizar(p)  # Guarda los cambios del producto en la base de datos
            lbl_estado.value = f"✅ '{p.nombre}' actualizado."  # Mensaje de éxito al actualizar

        lbl_estado.color = ft.colors.GREEN_700  # Color verde para el mensaje de éxito
        limpiar_formulario()  # Vacía el formulario para el siguiente registro
        actualizar_tabla()  # Refresca la tabla con los datos más recientes

    # ────────────────────────────────────────────
    # CONSTRUIR LA INTERFAZ
    # ────────────────────────────────────────────

    # Título de la aplicación
    titulo = ft.Text(  # Crea el texto grande del encabezado
        "📦 Sistema de Inventario UTH Tech",  # Texto del título
        size=24, weight=ft.FontWeight.BOLD,  # Tamaño grande y negrita
        color=ft.colors.BLUE_900  # Color azul oscuro del texto
    )  # Cierra la creación del título

    # Formulario
    formulario = ft.Container(  # Caja visual que agrupa todo el formulario
        content=ft.Column([  # Organiza el contenido del formulario en columna
            ft.Text("➕ Agregar / Editar Producto", size=16, weight=ft.FontWeight.BOLD),  # Subtítulo del formulario
            ft.Row([campo_nombre, campo_categoria]),  # Fila con nombre y categoría lado a lado
            ft.Row([campo_precio, campo_cantidad]),  # Fila con precio y cantidad lado a lado
            ft.Row([  # Fila que contiene los botones de acción
                ft.ElevatedButton(  # Botón principal para guardar
                    "💾 Guardar",  # Texto visible del botón
                    bgcolor=ft.colors.BLUE_700,  # Color de fondo azul del botón
                    color=ft.colors.WHITE,  # Color blanco del texto del botón
                    on_click=guardar  # Llama a guardar() al hacer clic
                ),  # Cierra el botón Guardar
                ft.OutlinedButton("🔄 Limpiar", on_click=lambda e: limpiar_formulario()),  # Botón que vacía el formulario
            ]),  # Cierra la fila de botones
            lbl_estado,  # Agrega el texto de estado al final del formulario
        ]),  # Cierra la columna de contenido del formulario
        padding=20,  # Espacio interno alrededor del contenido
        bgcolor=ft.colors.WHITE,  # Fondo blanco del contenedor
        border_radius=12,  # Bordes redondeados del contenedor
        shadow=ft.BoxShadow(blur_radius=8, color=ft.colors.BLUE_GREY_100),  # Sombra suave alrededor
        margin=ft.margin.only(bottom=16)  # Espacio extra debajo del contenedor (ft.margin.only ya rellena left/top/right con 0)
    )  # Cierra la creación del formulario

    # Agregar todo a la página
    page.add(  # Agrega los controles a la ventana, en orden
        titulo,  # Primero el título
        formulario,  # Luego el formulario completo
        ft.Text("📋 Lista de Productos", size=16, weight=ft.FontWeight.BOLD),  # Subtítulo de la lista
        ft.Container(  # Caja visual que envuelve la tabla
            content=ft.Column([tabla], scroll=ft.ScrollMode.AUTO),  # Columna con scroll que contiene la tabla
            bgcolor=ft.colors.WHITE,  # Fondo blanco del contenedor
            border_radius=12,  # Bordes redondeados del contenedor
            padding=10,  # Espacio interno alrededor de la tabla
            shadow=ft.BoxShadow(blur_radius=8, color=ft.colors.BLUE_GREY_100),  # Sombra suave alrededor
        )  # Cierra el contenedor de la tabla
    )  # Cierra page.add()

    # Cargar datos al iniciar
    actualizar_tabla()  # Llena la tabla con los productos existentes al abrir la app


# Punto de entrada — Flet lanza la aplicación
ft.app(target=main)  # Inicia la app de escritorio ejecutando la función main
Control de Flet¿Para qué sirve?
ft.PageLa ventana principal de la app. Configuras título, tamaño, fondo.
ft.TextFieldCampo de texto editable (como un input de HTML).
ft.DataTableTabla de datos con filas y columnas.
ft.ElevatedButtonBotón con relieve (el principal de acción).
ft.IconButtonBotón con solo un ícono (editar, eliminar).
ft.Row / ft.ColumnOrganizan controles en fila (horizontal) o columna (vertical).
ft.ContainerCaja con propiedades visuales: fondo, sombra, bordes redondeados.
page.update()Redibuja la pantalla después de cambios. SIEMPRE llamarlo al cambiar algo.
ft.app(target=main)Lanza la aplicación. target es la función que construye la interfaz.
💡 ft.colors / ft.icons en minúscula (específico de Flet 0.24.1) En esta versión los colores e iconos se acceden en minúscula: ft.colors.BLUE, ft.icons.EDIT. En versiones de Flet 0.25 o más nuevas estos cambiaron a mayúscula inicial (ft.Colors.BLUE, ft.Icons.EDIT). Como instalaste exactamente flet==0.24.1, usa siempre la forma en minúscula.

▶️ Sección 7 — Ejecutar la Aplicación

5
Verificar que WAMP esté corriendo (ícono verde)

Antes de ejecutar, verifica que el ícono de WAMP en la barra de tareas sea verde. Si no está corriendo, abre WAMP Server desde el menú inicio.

6
Ejecutar la aplicación Flet

Abre la terminal de VS Code (dentro de la carpeta Unidad3) y ejecuta:

Terminal
python main.py
🖼️ Captura: Ventana de la aplicación de inventario

Se abrirá una ventana de escritorio con el formulario de agregar productos arriba y la tabla de productos abajo. Prueba agregar un producto: escribe nombre, categoría, precio y cantidad, luego haz clic en "💾 Guardar".

✅ Si la ventana se abre y puedes agregar productos, ¡la práctica está completa!

✅ Lista de Verificación Final

🏠 Volver al Inicio del Curso