Saltar a contenido

Proyecto gestor de clientes en Python

Requisitos

Vamos a crear un programa poniendo a prueba lo que sabemos de Python:

  • Listar los clientes del gestor.
  • Consultar un cliente a partir del dni.
  • Agregar un cliente con campos nombre, apellido, dni.
  • Modificar el nombre y apellido de un cliente a partir del dni.
  • Borrar un cliente a partir del dni.
  • Salir del programa.

No deberá guardar los datos en el disco duro, siempre partirá de unos clientes de prueba iniciales y no podrá haber dos clientes con el mismo dni.

Repositorio: https://github.com/hektorprofe/curso-gestor-clientes-python

Organización

Empezaremos creando una carpeta Gestor de clientes con un fichero requirements.txt y otro README.md ambos vacíos, además de una carpeta llamada gestor/ para contener los scripts de nuestro proyecto.

Esta organización es una buena práctica para ahorrar problemas en el futuro ya que permite añadir todo lo que necesitemos en la raíz externa sin molestar al código fuente, como por ejemplo documentación, pruebas, configuraciones, etc. Es la clave para mentener un proyecto organizado y extensible.

El programa lo vamos a desarrollar en 4 ficheros básicos:

  • run.py: El script principal que lo pondrá todo en marcha.
  • menu.py: La interfaz que mostrará por la terminal un menú.
  • database.py: Encargado de manejar la gestión de los datos.
  • helpers.py: Contendrá funciones auxiliares de uso general.

A medida que los necesitemos iremos añadiendo otros ficheros.

Mock database

Empecemos con la base de datos del backend, que por ahora contendrá mock objects (objetos para pruebas), más adelante los refactorizaremos cambiándolos por persistencia en un fichero.

Primero la clase Clientepara manejar un cliente:

gestor/database.py

class Cliente:

    def __init__(self, dni, nombre, apellido):
        self.dni = dni
        self.nombre = nombre
        self.apellido = apellido

    def __str__(self):
        return f"({self.dni}) {self.nombre} {self.apellido}"

Y una clase Clientes para utilizar como fuente de datos y que implementará las funcionalidades de buscar, crear, actualizar y borrar clientes. Es una clase especial con funciones estáticas, eso significa que no se utilizará para crear instancias, sino que la usaremos directamente como origen único de la información:

class Clientes:

    # Lista de clientes
    lista = []

    @staticmethod
    def buscar(dni):
        for cliente in Clientes.lista:
            if cliente.dni == dni:
                return cliente

    @staticmethod
    def crear(dni, nombre, apellido):
        cliente = Cliente(dni, nombre, apellido)
        Clientes.lista.append(cliente)
        return cliente

    @staticmethod
    def modificar(dni, nombre, apellido):
        for i, cliente in enumerate(Clientes.lista):
            if cliente.dni == dni:
                Clientes.lista[i].nombre = nombre
                Clientes.lista[i].apellido = apellido
                return Clientes.lista[i]

    @staticmethod
    def borrar(dni):
        for i, cliente in enumerate(Clientes.lista):
            if cliente.dni == dni:
                cliente = Clientes.lista.pop(i)
                return cliente

¿Cómo probamos si todo funciona? Pues con unas pruebas unitarias, lo hacemos en la siguiente lección.

Pruebas unitarias

Para añadir las pruebas crearemos un directorio tests, en plural, con un __init__.py para indicar que es un paquete, así Python podrá autodescubrir los ficheros de pruebas.

Nosotos crearemos uno llamado test_database.py con las siguientes pruebas unitarias:

gestor/tests/test_database.py

import copy
import unittest
import database as db


class TestDatabase(unittest.TestCase):

    def setUp(self):
        # Se ejecuta antes de cada prueba
        db.Clientes.lista = [
            db.Cliente('15J', 'Marta', 'Pérez'),
            db.Cliente('48H', 'Manolo', 'López'),
            db.Cliente('28Z', 'Ana', 'García')
        ]

    def test_buscar_cliente(self):
        cliente_existente = db.Clientes.buscar('15J')
        cliente_no_existente = db.Clientes.buscar('99X')
        self.assertIsNotNone(cliente_existente)
        self.assertIsNone(cliente_no_existente)

    def test_crear_cliente(self):
        nuevo_cliente = db.Clientes.crear('39X', 'Héctor', 'Costa')
        self.assertEqual(len(db.Clientes.lista), 4)
        self.assertEqual(nuevo_cliente.dni, '39X')
        self.assertEqual(nuevo_cliente.nombre, 'Héctor')
        self.assertEqual(nuevo_cliente.apellido, 'Costa')

    def test_modificar_cliente(self):
        cliente_a_modificar = copy.copy(db.Clientes.buscar('28Z'))
        cliente_modificado = db.Clientes.modificar('28Z', 'Mariana', 'Pérez')
        self.assertEqual(cliente_a_modificar.nombre, 'Ana')
        self.assertEqual(cliente_modificado.nombre, 'Mariana')

    def test_borrar_cliente(self):
        cliente_borrado = db.Clientes.borrar('48H')
        cliente_rebuscado = db.Clientes.buscar('48H')
        self.assertNotEqual(cliente_borrado, cliente_rebuscado)


if __name__ == '__main__':
    unittest.main()

Para probar las pruebas necesitamos instalar el paquete pytest:

pip install pytests

Ahora dejamos que pytest autodescubra las pruebas y las ejecute haciendo:

pytest -v

Perfecto, esto nos dará la seguridad de que el módulo database está funcionando.

Estructurando el menú

Vamos a construir el menú, en lugar de añadirlo en el run.py vamos a hacerlo en su propio módulo. Recordad, cuanto más separado y organizado esté el código más reutilizable y extensible será el programa:

gestor/menu.py

import os

def iniciar():
    while True:
        os.system('clear') # cls en Windows

        print("========================")
        print("  BIENVENIDO AL Manager ")
        print("========================")
        print("[1] Listar clientes     ")
        print("[2] Buscar cliente      ")
        print("[3] Añadir cliente      ")
        print("[4] Modificar cliente   ")
        print("[5] Borrar cliente      ")
        print("[6] Cerrar el Manager   ")
        print("========================")

        opcion = input("> ")
        os.system('clear') # cls en Windows

        if opcion == '1':
            print("Listando los clientes...\n")
        if opcion == '2':
            print("Buscando un cliente...\n")
        if opcion == '3':
            print("Añadiendo un cliente...\n")
        if opcion == '4':
            print("Modificando un cliente...\n")
        if opcion == '5':
            print("Borrando un cliente...\n")
        if opcion == '6':
            print("Saliendo...\n")
            break

        input("\nPresiona ENTER para continuar...")

Para probarlo podemos añadirlo al fichero run.py impórtándolo cómodamente:

gestor/run.py

import menu

if __name__ == "__main__":
    menu.iniciar()

Funciones auxiliares

Es un buen momento para crear unas funciones de ayuda.

La primera será una versión mejorada para limpiar la terminal porque la que tenemos no detecta automáticamente el sistema operativo y uno de los puntos fuertes de Python es que es multiplataforma:

gestor/helpers.py

import os
import platform


def limpiar_pantalla():
    os.system('cls') if platform.system() == "Windows" else os.system('clear')

Ahora podemos cargar el módulo cómodamente y ejecutar la función en el menú en lugar de llamar directamente al módulo os:

gestor/menu.py

import helpers

helpers.limpiar_pantalla()

La segunda función de ayuda será para leer un texto cómodamente:

gestor/helpers.py

def leer_texto(longitud_min=0, longitud_max=100, mensaje=None):
    print(mensaje) if mensaje else None
    while True:
        texto = input("> ")
        if len(texto) >= longitud_min and len(texto) <= longitud_max:
            return texto

Esta función la utilizaremos para leer los campos dni, nombre y apellido del cliente que vayamos a gestionar.

Implementando el menú

Por fin ha llegado el momento de conectar la base de datos y el menú, tendremos que envolver las funcionalidades de nuestra database en las diferentes opciones del menú:

gestor/menu.py

if opcion == '1':
    print("Listando los clientes...\n")
    for cliente in db.Clientes.lista:
        print(cliente)

if opcion == '2':
    print("Buscando un cliente...\n")
    dni = helpers.leer_texto(3, 3, "DNI (2 ints y 1 char)").upper()
    cliente = db.Clientes.buscar(dni)
    print(cliente) if cliente else print("Cliente no encontrado.")

if opcion == '3':
    print("Añadiendo un cliente...\n")
    dni = helpers.leer_texto(
        3, 3, "DNI (2 ints y 1 char)").upper()
    nombre = helpers.leer_texto(
        2, 30, "Nombre (de 2 a 30 chars)").capitalize()
    apellido = helpers.leer_texto(
        2, 30, "Apellido (de 2 a 30 chars)").capitalize()
    db.Clientes.crear(dni, nombre, apellido)
    print("Cliente añadido correctamente.")

if opcion == '4':
    print("Modificando un cliente...\n")
    dni = helpers.leer_texto(3, 3, "DNI (2 ints y 1 char)").upper()
    cliente = db.Clientes.buscar(dni)
    if cliente:
        nombre = helpers.leer_texto(
            2, 30, f"Nombre (de 2 a 30 chars) [{cliente.nombre}]").capitalize()
        apellido = helpers.leer_texto(
            2, 30, f"Apellido (de 2 a 30 chars) [{cliente.apellido}]").capitalize()
        db.Clientes.modificar(cliente.dni, nombre, apellido)
        print("Cliente modificado correctamente.")
    else:
        print("Cliente no encontrado.")

if opcion == '5':
    print("Borrando un cliente...\n")
    dni = helpers.leer_texto(3, 3, "DNI (2 ints y 1 char)").upper()
    print("Cliente borrado correctamente.") if db.Clientes.borrar(
        dni) else print("Cliente no encontrado.")

if opcion == '6':
    print("Saliendo...\n")
    break

input("\nPresiona ENTER para continuar...")

Validación del campo DNI

Hay dos cosas que tenemos comprobar para el campo DNI antes de añadir un nuevo cliente. La primera es que el DNI sea válido, es decir, cumpla un formato de dos números y una letra. La segunda es que no haya ningún otro cliente con ese DNI, así que pongámonos a ello.

Vamos a crear la función auxiliar en helpers.py:

gestor/helpers.py

import re

def dni_valido(dni, lista):
    if not re.match('[0-9]{2}[A-Z]$', dni):
        print("DNI incorrecto, debe cumplir el formato.")
        return False
    for cliente in lista:
        if cliente.dni == dni:
            print("DNI utilizado por otro cliente.")
            return False
    return True

Vamos a añadir una prueba para nuestra nueva función:

gestor/tests/test_database.py

import helpers

def test_dni_valido(self):
    self.assertTrue(helpers.dni_valido('00A', db.Clientes.lista))
    self.assertFalse(helpers.dni_valido('23223S', db.Clientes.lista))
    self.assertFalse(helpers.dni_valido('F35', db.Clientes.lista))
    self.assertFalse(helpers.dni_valido('48H', db.Clientes.lista))

Las probamos para ver si todo es correcto:

pytest -v

Con la seguridad de que todo está bien la utilizaremos en un bucle para leer el dni hasta que sea válido:

gestor/menu.py

if opcion == '3':
    print("Añadiendo un cliente...\n")

    # Comprobación de DNI válido
    while 1:
        dni = helpers.leer_texto(3, 3, "DNI (2 ints y 1 char)").upper()
        if helpers.dni_valido(dni, db.Clientes.lista):
            break

    nombre = helpers.leer_texto(
        2, 30, "Nombre (de 2 a 30 chars)").capitalize()
    apellido = helpers.leer_texto(
        2, 30, "Apellido (de 2 a 30 chars)").capitalize()
    db.Clientes.crear(dni, nombre, apellido)

Persistencia en fichero CSV

En esta lección vamos a implementar el módulo CSV, que ya vimos anteriormente en el curso, para almacenar los clientes del gestor.

Cuando el programa se ponga en marcha, la clase database.Clientes cargará los clientes de un fichero y los irá serializando a medida que se realicen cambios.

Para que todo funcione correctamente, trasladaremos los mock objects al fichero CSV desde el principio:

gestor/clientes.csv

15J;Marta;Perez
48H;Manolo;Lopez
28Z;Ana;Garcia

Cargaremos los clientes del fichero en la lista de Clientes:

class Clientes:
    # Creamos la lista y cargamos los clientes en memoria
    lista = []
    with open("clientes.csv", newline="\n") as fichero:
        reader = csv.reader(fichero, delimiter=";")
        for dni, nombre, apellido in reader:
            cliente = Cliente(dni, nombre, apellido)
            lista.append(cliente)

Esta parte ya funcionará, ahora debemos implementar el método para guardar los Clientes de vuelta en el fichero después de modificarlos.

Podemos crear el método:

@staticmethod
def guardar():
    with open("clientes.csv", "w", newline="\n") as fichero:
        writer = csv.writer(fichero, delimiter=";")
        for c in Clientes.lista:
            writer.writerow((c.dni, c.nombre, c.apellido))

Lo llamamos después de crear, modificar o borrar un cliente:

@staticmethod
def crear(dni, nombre, apellido):
    cliente = Cliente(dni, nombre, apellido)
    Clientes.lista.append(cliente)
    Clientes.guardar() # new
    return cliente

@staticmethod
def modificar(dni, nombre, apellido):
    for i, cliente in enumerate(Clientes.lista):
        if cliente.dni == dni:
            Clientes.lista[i].nombre = nombre
            Clientes.lista[i].apellido = apellido
            Clientes.guardar() # new
            return Clientes.lista[i]

@staticmethod
def borrar(dni):
    for i, cliente in enumerate(Clientes.lista):
        if cliente.dni == dni:
            cliente = Clientes.lista.pop(i)
            Clientes.guardar() # new
            return cliente

Listo, el programa ya debería sincronizar automáticamente los cambios realizados en la memoria también en el fichero CSV.

Sin embargo los tests de database.py...

pytest -v

Nos están modificando la información original del fichero y esto no puede ser.

Los arreglamos en la próxima lección.

Arreglando las pruebas unitarias

El problema que tenemos es que las pruebas modifican la base de datos del fichero clientes.csv. Esto es un fallo importante porque perderemos los datos cada vez que ejecutemos las pruebas, tenemos que conseguir manejar dos ficheros CSV independientes, uno para las pruebas y otro para el programa.

Para ello vamos a crear un fichero de configuración con una constante que contendrá la localización del CSV:

gestor/config.py

import sys
DATABASE_PATH = 'clientes.csv'

Simplemente substituiremos la ruta en crudo por esta constante:

gestor/database.py

class Clientes:
    lista = []
    with open(config.DATABASE_PATH, newline="\n") as fichero: # ...

    @staticmethod
    def guardar():
        with open(config.DATABASE_PATH, "w", newline="\n") as fichero: # ...

El truco consistirá en definir una constante diferente si trabajamos con las pruebas y eso podemos saberlo analizando el primer argumento del script:

gestor/config.py

import sys
DATABASE_PATH = 'clientes.csv'

if 'pytest' in sys.argv[0]:
    DATABASE_PATH = 'tests/clientes_test.csv'

Con esto las pruebas deberían ir a buscar ese nuevo fichero, que no existirá:

pytest -v

Solo tenemos que crearlo:

gestor/tests/clientes_tests.csv

Y ya tendremos dos ficheros diferentes.

Para redondearlo podemos añadir una prueba para confirmar que el contenido del fichero cambia al realizar modificaciones y asegurarnos de que la persistencia funciona correctamente:

import csv

def test_escritura_csv(self):
    db.Clientes.borrar('48H')
    db.Clientes.borrar('15J')
    db.Clientes.modificar('28Z', 'Mariana', 'Pérez')

    dni, nombre, apellido = None, None, None
    with open(config.DATABASE_PATH, newline="\n") as fichero:
        reader = csv.reader(fichero, delimiter=";")
        dni, nombre, apellido = next(reader)  # Primera línea del iterador

    self.assertEqual(dni, '28Z')
    self.assertEqual(nombre, 'Mariana')
    self.assertEqual(apellido, 'Pérez')

Si etodo está correcto las pruebas deberían pasar:

pytest -v

GUI (1): Ventana principal

Durante las próximas lecciones vamos a ver la ventaja de haber programado modularmente el programa.

Al tener bien separado el menú de la base de datos podemos substituir la terminal por una interfaz gráfica sin modificar el código base. De hecho no tenemos ni que prescindir de él, podemos proveerlo como alternativa para un entorno sin gráficos, ya veréis que curioso queda.

Para hacer más sencilla la gestión de la ventana y subventanas del programa, vamos a desarrollarlo utilizando clases y objetos, por lo que partiremos de un programa básico de Tkinter manejado en una clase que a mi me gusta llamar MainWindow:

gestor/ui.py

from tkinter import *

class MainWindow(Tk):
    def __init__(self):
        super().__init__()
        self.title('Gestor de clientes')
        self.build()

    def build(self):
        button = Button(self.root, text="Hola", command=self.hola)
        button.pack()

    def hola(self):
        print("¡Hola mundo!")


if __name__ == "__main__":
    app = MainWindow()
    app.mainloop()

GUI (2): Mixin para centrar widgets

Antes de continuar vamos a tomarnos un momento para invertir en calidad de vida y es que cuando se abre una ventana de tkinter no se posiciona automáticamente en el centro de la pantalla, por lo menos en MAC OS, que es el sistema que utilizo actualmente.

Os voy a enseñar una forma genial de solucionar este problema mediante el uso de un mixin. Un mixin es una clase que contiene una o varias definiciones. Por sí mismos los mixins no tienen utilidad, pero al heredarlos en otra clase conseguiremos su funcionalidad y eso es precisamente lo que vamos a hacer, un mixin para centrar un widget en la pantalla (en nuestro caso la ventana principal):

class CenterWidgetMixin:
    def center(self,):
        self.update()
        w = self.winfo_width()
        h = self.winfo_height()
        ws = self.winfo_screenwidth()
        hs = self.winfo_screenheight()
        x = int((ws/2) - (w/2))
        y = int((hs/2) - (h/2))
        self.geometry(f"{w}x{h}+{x}+{y}")

Si heredamos de este mixin conseguiremos el método center que podemos utilizar al final del constructor para autocentrar la ventana:

class MainWindow(Tk, CenterWidgetMixin): # edited
    def __init__(self):
        super().__init__()
        self.title('Gestor de clientes')
        self.build()
        self.center() # new

GUI (3): Widget Treeview

Mixins a parte, vamos a utilizar el método build para construir y configurar todos los widgets de la ventana principal. Si queremos comunicar alguno con otro método queramos comunicar con otros métodos lo haremos en atributos de clase.

Ahora, para mostrar los clientes podemos utilizar algo como una tabla. El widget extendido Treeview del subpaquete ttk sirve para eso así que vamos a utilizarlo:

from tkinter import ttk

def build(self):
    # Top Frame
    frame = Frame(self)
    frame.pack()

    # Treeview
    treeview = ttk.Treeview(frame)
    treeview['columns'] = ('DNI', 'Nombre', 'Apellido')
    treeview.pack()

    # Column format
    treeview.column("#0", width=0, stretch=NO)
    treeview.column("DNI", anchor=CENTER)
    treeview.column("Nombre", anchor=CENTER)
    treeview.column("Apellido", anchor=CENTER)

    # Heading format
    treeview.heading("#0", anchor=CENTER)
    treeview.heading("DNI", text="DNI", anchor=CENTER)
    treeview.heading("Nombre", text="Nombre", anchor=CENTER)
    treeview.heading("Apellido", text="Apellido", anchor=CENTER)

    # Pack
    treeview.pack()

Por defecto el Treeview no tiene una barra de scroll, deberíamos crear una manualmente y configurarla por si en algún momento hay tantos registros que falta espacio verticalmente:

# Scrollbar
scrollbar = Scrollbar(frame)        # new
scrollbar.pack(side=RIGHT, fill=Y)  # new

# Treeview
treeview = ttk.Treeview(frame, yscrollcommand=scrollbar.set)  # edited
treeview['columns'] = ('DNI', 'Nombre', 'Apellido')
treeview.pack()

Vamos a cargar los datos de la base de datos en el treeview justo antes de empaquetarlo:

import database as db

# Fill treeview data
for cliente in db.Clientes.lista:
    treeview.insert(
        parent='', index='end', iid=cliente.dni,
        values=(cliente.dni, cliente.nombre, cliente.apellido))

# Treeview repack with scrollbar
treeview.pack()

Vamos a añadir tres botones en un nuevo frame que nos permitan, crear, modificar y borrar registros:

# Bottom Frame
frame = Frame(self)
frame.pack(pady=20)

# Buttons
Button(frame, text="Crear", command=None).grid(row=1, column=0)
Button(frame, text="Modificar", command=None).grid(row=1, column=1)
Button(frame, text="Borrar", command=None).grid(row=1, column=2)

Para terminar la interfaz base, necesitamos exportar como atributo de clase el treeview ya que lo necesitaremos para realizar tareas como recuperar la información de la fila seleccionada:

# Export treeview to the class
self.treeview = treeview

GUI (4): Diálogo de borrado

Ahora hay que programar los botones de acción, empezaremos por el de borrar ya es el más sencillo y lo implementaremos sobre un cuadro de diálogo por defecto:

from tkinter.messagebox import askokcancel, WARNING

def delete(self):
    cliente = self.treeview.focus()
    if cliente:
        campos = self.treeview.item(cliente, 'values')
        confirmar = askokcancel(
            title='Confirmación',
            message=f'¿Borrar a {campos[1]} {campos[2]}?',
            icon=WARNING)
        if confirmar:
            # remove the row
            self.treeview.delete(cliente)

Lo llamamos al presionar el botón:

Button(frame, text="Borrar", command=self.delete).grid(row=1, column=2)

GUI (5): Subventana de creación

En cuanto a las opciones de crear y modificar son más complejas, tendremos que programar nuestras propias ventanas secundarias con sus campos de texto, botones y validaciones.

Este es el diseño de la subventana de creación:

class CreateClientWindow(Toplevel, CenterWidgetMixin):
    def __init__(self, parent):
        super().__init__(parent)
        self.title('Crear cliente')
        self.build()
        self.center()
        # Obligar al usuario a interactuar con la subventana
        self.transient(parent)
        self.grab_set()

    def build(self):
        # Top frame
        frame = Frame(self)
        frame.pack(padx=20, pady=10)

        # Labels
        Label(frame, text="DNI (2 ints y 1 upper char)").grid(row=0, column=0)
        Label(frame, text="Nombre (2 a 30 chars)").grid(row=0, column=1)
        Label(frame, text="Apellido (2 a 30 chars)").grid(row=0, column=2)

        # Entries
        dni = Entry(frame)
        dni.grid(row=1, column=0)
        nombre = Entry(frame)
        nombre.grid(row=1, column=1)
        apellido = Entry(frame)
        apellido.grid(row=1, column=2)

        # Bottom frame
        frame = Frame(self)
        frame.pack(pady=10)

        # Buttons
        crear = Button(frame, text="Crear", command=self.create_client)
        crear.configure(state=DISABLED)
        crear.grid(row=0, column=0)
        Button(frame, text="Cancelar", command=self.close).grid(row=0, column=1)

    def create_client(self):
        pass

    def close(self):
        self.destroy()
        self.update()

Crearemos una instancia desde un nuevo método en la ventana principal:

Button(frame, text="Crear", command=self.create_client_window).grid(row=1, column=0)

def create_client_window(self):
    CreateClientWindow(self)

GUI (6): Validación en tiempo real

La interfaz de la subventana de creación está lista, ahora vamos a configurar las validaciones en los campos antes de recuperar la información y añadir el nuevo cliente a la tabla:

# Entries and validations
dni = Entry(frame)
dni.grid(row=1, column=0)
dni.bind("<KeyRelease>", lambda ev: self.validate(ev, 0))
nombre = Entry(frame)
nombre.grid(row=1, column=1)
nombre.bind("<KeyRelease>", lambda ev: self.validate(ev, 1))
apellido = Entry(frame)
apellido.grid(row=1, column=2)
apellido.bind("<KeyRelease>", lambda ev: self.validate(ev, 2))

Los métodos bindeados con las validaciones quedarán:

def validate(self, event, index):
    valor = event.widget.get()
    # Validar como dni si es el primer campo o textual para los otros dos
    valido = helpers.dni_valido(valor, db.Clientes.lista) if index == 0 \
        else (valor.isalpha() and len(valor) >= 2 and len(valor) <= 30)
    event.widget.configure({"bg": "Green" if valido else "Red"})

GUI (7): Manejando el botón crear

Para controlar las validaciones y activar o desactivar el botón de creación utilizaremos una lista con booleanos que cambiaremos en tiempo real en las validaciones:

# Create button activation
self.validaciones = [0, 0, 0]  # False, False, False

# Class exports
self.crear = crear

def validate(self, event, index):
    valor = event.widget.get()
    # Validar el dni si es el primer campo o textual para los otros dos
    valido = helpers.dni_valido(valor, db.Clientes.lista) if index == 0 \
        else (valor.isalpha() and len(valor) >= 2 and len(valor) <= 30)
    event.widget.configure({"bg": "Green" if valido else "Red"})
    # Cambiar estado del botón en base a las validaciones
    self.validaciones[index] = valido
    self.crear.config(state=NORMAL if self.validaciones == [1, 1, 1]
                        else DISABLED)

Finalmente para crear el registro en la tabla haremos referencia al treeview de la ventana principal mediante el accesor master, pero antes necesitaremos exportar los campos para poder acceder a los valores entre métodos de la clase:

# Class exports
self.crear = crear
self.dni = dni
self.nombre = nombre
self.apellido = apellido

Con ellos podemos crear el cliente en el treeview y cerrar la subventana:

def create_client(self):
    self.master.treeview.insert(
        parent='', index='end', iid=self.dni.get(),
        values=(self.dni.get(), self.nombre.get(), self.apellido.get()))
    self.close()

Listo, vamos a por la subventana de modificación.

GUI (8): Subventana de modificación

Para modificar un cliente vamos a reutilizar en gran parte lo que tenemos en la subventana de creación, la ventaja es que ahora no necesitamos aplicar validación al campo DNI porque éste no es editable, por lo que lo desactivaremos.

La implementación de esta subventana es mayormente una copia recortada de la de creación, con la peculiaridad de que al cargarla buscaremos el cliente seleccionado en la treeview y escribiremos los valores inicialmente en los campos.

Además esta vez dejaremos activado el botón de Actualizar por defecto:

class EditClientWindow(Toplevel, CenterWidgetMixin):
    def __init__(self, parent):
        super().__init__(parent)
        self.title('Actualizar cliente')
        self.build()
        self.center()
        # Obligar al usuario a interactuar con la subventana
        self.transient(parent)
        self.grab_set()

    def build(self):
        # Top frame
        frame = Frame(self)
        frame.pack(padx=20, pady=10)

        # Labels
        Label(frame, text="DNI (no editable)").grid(row=0, column=0)
        Label(frame, text="Nombre (2 a 30 chars)").grid(row=0, column=1)
        Label(frame, text="Apellido (2 a 30 chars)").grid(row=0, column=2)

        # Entries
        dni = Entry(frame)
        dni.grid(row=1, column=0)
        nombre = Entry(frame)
        nombre.grid(row=1, column=1)
        nombre.bind("<KeyRelease>", lambda ev: self.validate(ev, 0))
        apellido = Entry(frame)
        apellido.grid(row=1, column=2)
        apellido.bind("<KeyRelease>", lambda ev: self.validate(ev, 1))

        # Set entries initial values
        cliente = self.master.treeview.focus()
        campos = self.master.treeview.item(cliente, 'values')
        dni.insert(0, campos[0])
        dni.config(state=DISABLED)
        nombre.insert(0, campos[1])
        apellido.insert(0, campos[2])

        # Bottom frame
        frame = Frame(self)
        frame.pack(pady=10)

        # Buttons
        actualizar = Button(frame, text="Actualizar", command=self.update_client)
        actualizar.grid(row=0, column=0)
        Button(frame, text="Cancelar", command=self.close).grid(row=0, column=1)

        # Update button activation
        self.validaciones = [1, 1]  # True, True

        # Class exports
        self.actualizar = actualizar
        self.dni = dni
        self.nombre = nombre
        self.apellido = apellido

    def validate(self, event, index):
        valor = event.widget.get()
        valido = (valor.isalpha() and len(valor) >= 2 and len(valor) <= 30)
        event.widget.configure({"bg": "Green" if valido else "Red"})
        # Cambiar estado del botón en base a las validaciones
        self.validaciones[index] = valido
        self.actualizar.config(state=NORMAL if self.validaciones == [1, 1] else DISABLED)

    def update_client(self):
        pass

    def close(self):
        self.destroy()
        self.update()

Actualizar los valores en la función update_client es tan fácil como hacer referencia a la fila activa del treeview y sobreecribir sus campos:

def update_client(self):
    cliente = self.master.treeview.focus()
    # Sobreescribimos los datos de la fila seleccionada
    self.master.treeview.item(
        cliente, values=(self.dni.get(), self.nombre.get(), self.apellido.get()))
    self.close()

Perfecto, lo tenemos todo preparado a falta de sincronizar los cambios de la tabla en el fichero.

GUI (9): Sincronización de datos

Para volcar los datos de la tabla al fichero CSV, solo tenemos que enlazar los momentos de creación, actualización y borrado a los métodos de la clase Clientes de nuestro módulo database. Es decir, a parte de modificar los cambios en la treeview visualmente, también haremos lo propio en la clase db.Clientes.

El borrado:

def delete(self):
    cliente = self.treeview.focus()
    if cliente:
        campos = self.treeview.item(cliente, 'values')
        confirmar = askokcancel(
            title='Confirmación', message=f'¿Borrar a {campos[1]} {campos[2]}?', icon=WARNING)
        if confirmar:
            self.treeview.delete(cliente)
            # !!! Borrar también en el fichero
            db.Clientes.borrar(campos[0])

La creación:

def create_client(self):
    self.master.treeview.insert(
        parent='', index='end', iid=self.dni.get(),
        values=(self.dni.get(), self.nombre.get(), self.apellido.get()))
    # !!! Crear también en el fichero
    db.Clientes.crear(self.dni.get(), self.nombre.get(), self.apellido.get())
    self.close()

Y la actualización:

def update_client(self):
    cliente = self.master.treeview.focus()
    # Sobreescribir los datos
    self.master.treeview.item(
        cliente, values=(self.dni.get(), self.nombre.get(), self.apellido.get()))
    # !!! Modificar también en el fichero
    db.Clientes.modificar(self.dni.get(), self.nombre.get(), self.apellido.get())
    self.close()

Con esto hemos terminado la interfaz pero todavía nos falta un pequeño detalle...

GUI (10): Modo terminal e interfaz

Cuando empezamos a crear la interfaz os comenté que no era necesario descartar el programa para la terminal, sino que podíamos implementar ambos.

Para lograrlo sería tan fácil como pasar un argumento al ejecutar el script:

import sys
import menu
import ui

if __name__ == "__main__":
    # Si pasamos un argumento -t lanzamos el modo terminal
    if len(sys.argv) > 1 and sys.argv[1] == "-t":
        menu.iniciar()
    # En cualquier otro caso lanzamos el modo gráfico
    else:
        app = ui.MainWindow()
        app.mainloop()

Listo, ahora si ejecutamo el script con -t lanzaremos el modo terminal:

python3 run.py -t

Y si lo ejecutamos normalmente, el modo gráfico:

python3 run.py

Con esto acabamos, espero que os haya gustado y hayáis aprendido mucho.

Solo quiero comentar que si os interesa el tema de las interfaces, en hektorprofe.net encontraréis mi curso completo sobre PySide con un montón de proyectos prácticos. Es una biblioteca infinitamente más potente que tkinter y un estándar en la industria por ser el binding oficial de Qtpara Python.

Sin más, nos vemos en la próxima sección.


Última edición: 12 de Julio de 2022