Saltar a contenido

Lab guiado: Rutas dinámicas, excepciones y cobertura completa de la API


Punto de partida

Parte del zip que entregaste al final del lab a1. Antes de empezar:

  1. Activa el entorno virtual del proyecto (source .venv/bin/activate en Linux/Mac, o source .venv/Scripts/activate en Git Bash de Windows).
  2. Sitúate en la carpeta padre de expendedora/ (la misma desde la que arrancas el menú).
  3. Arranca el servidor web: python -m expendedora.presentation.app.
  4. Deja esa terminal con el servidor corriendo. Para probar las URLs abre el navegador en http://localhost:5000 y, si necesitas ejecutar comandos, usa otra terminal distinta.

Trabajo en parejas

Si decides trabajar en pareja, sigue el procedimiento conductor/navegante del lab a1: conductor al teclado, navegante lee y detecta errores, rotáis al final de cada paso.


Antes de empezar: repasemos los conceptos

Routes como equivalente del menú
En menu.py cada opción del menú (if opcion == "1") llama a una función distinta. En Flask cada route (@app.route('/ruta')) hace exactamente lo mismo: asocia una acción a una entrada del usuario, que ahora es una URL en lugar de un número.

Parámetros en la URL
Flask permite capturar segmentos de la URL como variables: @app.route('/producto/<codigo>') captura el texto entre barras y lo pasa a la función. Se puede especificar el tipo: <int:cantidad>, <float:precio>.

Excepciones de dominio en el route
El route es la capa de presentación. Las excepciones de dominio (ProductoYaExisteError, ProductoNoEncontradoError) se capturan aquí, igual que se capturaban en el bucle main() de menu.py. La capa de aplicación no cambia.

Código de respuesta HTTP
Junto con el contenido, Flask puede devolver un código HTTP: return "No encontrado", 404. Los códigos más comunes: 200 (OK), 400 (petición incorrecta), 404 (no encontrado), 409 (conflicto, por ejemplo "ya existe"), 500 (error del servidor).

redirect y url_for
Tras realizar una acción (comprar, añadir), es buena práctica redirigir al usuario a otra página en lugar de mostrar el resultado directamente. url_for('nombre_funcion') genera la URL correspondiente a esa función, de modo que si más adelante cambias la URL del route, los redirects no se rompen.


Paso 1 — Operaciones de lectura: búsqueda y saldo app.py

Conceptos

En el lab a1 ya creaste los routes de lectura más básicos: /, /productos y /producto/<codigo>. Ahora añadiremos los dos que faltan para cubrir todas las operaciones de consulta del menú: buscar por nombre y ver el saldo actual.

Objetivo

Exponer como routes las dos operaciones de lectura que aún no tienen su equivalente web.

Tarea

Empezaremos por añadir un método de delegación en el servicio, porque ServicioExpendedora todavía no permite consultar el saldo desde fuera. Abre expendedora/application/servicios.py y añade, justo después de insertar_dinero:

1
2
3
def saldo(self):
    """Devuelve el saldo actual insertado en la maquina."""
    return self._maquina.saldo

¿Por qué tocamos application/?

La propiedad MaquinaExpendedora.saldo ya existe en el dominio. Lo único que hacemos es exponerla en la capa de aplicación como delegación pura (una línea, sin lógica nueva). Así, el route podrá llamar a servicio.saldo() sin romper la regla de que la presentación solo conoce la capa de aplicación. Nada más cambia fuera de presentation/.

Ahora abre expendedora/presentation/app.py y, debajo del route /producto/<codigo> que creaste en el lab a1, añade los dos routes siguientes:

@app.route('/buscar/<texto>')
def buscar(texto):
    productos = servicio.buscar_producto(texto)
    if not productos:
        return f"No hay coincidencias para '{texto}'."
    lineas = [
        f"{codigo}: {nombre}{precio_final:.2f} EUR ({cantidad} uds.)"
        for codigo, nombre, _precio_base, precio_final, cantidad, _descuento
        in productos
    ]
    return '<br>'.join(lineas)


@app.route('/saldo')
def saldo():
    return f"Saldo actual: {servicio.saldo():.2f} EUR"

Actualiza también el route / para que ofrezca enlaces a las rutas nuevas:

1
2
3
4
5
6
7
@app.route('/')
def inicio():
    return ('<h1>Expendedora</h1>'
            '<ul>'
            '<li><a href="/productos">Ver productos</a></li>'
            '<li><a href="/saldo">Ver saldo actual</a></li>'
            '</ul>')
Pista

servicio.buscar_producto(texto) devuelve una lista de tuplas con la misma estructura que listar_productos(): (codigo, nombre, precio_base, precio_final, cantidad, porcentaje_descuento). Los guiones bajos delante de _precio_base y _descuento indican que no los usamos en el bucle, pero los capturamos porque están en la tupla.

Pregunta

¿Por qué hemos añadido el método saldo en el servicio en lugar de escribir en el route servicio._maquina.saldo?

Respuesta

Porque la presentación solo debe conocer la capa de aplicación (el servicio). Acceder a servicio._maquina rompe la encapsulación de capas y delata un atributo privado (_maquina), que por convención en Python indica "no toques esto desde fuera". El método del servicio es una puerta oficial a esa información.

Comprobación

Prueba en el navegador: - http://localhost:5000/buscar/agua — debe mostrar los productos cuyo nombre contenga "agua". - http://localhost:5000/buscar/xxxxxx — debe mostrar "No hay coincidencias…". - http://localhost:5000/saldo — debe mostrar "Saldo actual: 0.00 EUR". - http://localhost:5000/ — ahora con enlaces activos.

Reflexión

Respuestas
  • Cada operación del menú se convierte en un route; buscar_producto y saldo son los dos últimos de lectura que faltaban.
  • El saldo del servicio es un método de delegación pura: no añade lógica, solo expone la capa de aplicación a la de presentación.
  • Igual que en el menú, los textos largos se construyen con listas por comprensión y join, no concatenando strings uno a uno.

Paso 2 — Tipos en parámetros de URL app.py

Conceptos

Flask convierte automáticamente el tipo del parámetro si se especifica: <int:cantidad> garantiza que cantidad llegará como entero a la función. Si el usuario escribe algo que no sea un entero, Flask devuelve 404 automáticamente. Lo mismo ocurre con <float:cantidad>.

Objetivo

Añadir los routes de insertar dinero y reponer stock, usando tipos explícitos, y comprobar dónde termina la validación de Flask y empieza la del dominio.

Tarea

Añade a app.py los dos routes:

@app.route('/insertar/<float:cantidad>')
def insertar(cantidad):
    try:
        servicio.insertar_dinero(cantidad)
        return f"Saldo actualizado: {servicio.saldo():.2f} EUR"
    except ValueError as e:
        return str(e), 400


@app.route('/reponer/<codigo>/<int:unidades>')
def reponer(codigo, unidades):
    try:
        servicio.reponer(codigo, unidades)
        return f"Reposición realizada: +{unidades} unidades de {codigo}."
    except ProductoNoEncontradoError as e:
        return str(e), 404
    except ValueError as e:
        return str(e), 400

Import que necesitas

Asegúrate de que al inicio del fichero tienes ya importada la excepción. Si no la tienes (debería estar desde el lab a1), añádela junto a los demás imports:

from expendedora.infrastructure.errores import ProductoNoEncontradoError

Antes de ejecutar, predice qué ocurrirá en cada caso y escríbelo:

URL Tu predicción
/insertar/1.5
/insertar/1
/insertar/uno
/insertar/-1.5
/reponer/A1/3
/reponer/A1/tres
/reponer/A1/-1
/producto/

Luego pruébalas y completa la tabla con el resultado real.

Pista — converters más estrictos de lo que parecen

Los converters <int:> y <float:> de Flask son más estrictos de lo que sugiere su nombre. Por debajo usan expresiones regulares muy concretas:

  • <int:x> acepta solo dígitos (\d+): no acepta signo menos, ni decimales.
  • <float:x> acepta dígitos-punto-dígitos (\d+\.\d+): no acepta enteros sin decimal, ni signo menos.

Por tanto:

  • /insertar/1404 (no matchea <float> porque falta el .x).
  • /insertar/-1.5404 (no matchea <float> porque empieza con -).
  • /reponer/A1/-1404 (no matchea <int>).
  • /reponer/A1/tres404 (no matchea <int>).
  • /producto/ (sin parámetro) → 404 (no coincide con ninguna ruta).

Esto significa que los except ValueError del route para "cantidad negativa" no se activarán con los converters estándar: Flask rechaza antes. Son una red de seguridad por si en el futuro cambias el converter por uno personalizado que sí deje pasar negativos, o aceptas el parámetro como string y lo conviertes a mano.

Si quieres que el dominio reciba los valores inválidos y responda 400 (para ver el mensaje específico), tendrías que usar <string:x> y convertir el tipo dentro del route. En un lab posterior con formularios veremos la alternativa elegante: los formularios HTML permiten que el dominio valide todo sin que Flask filtre antes.

Pregunta

¿Quién debería rechazar una cantidad negativa desde el punto de vista arquitectónico: Flask, el route, el servicio o el dominio? ¿Por qué?

Respuesta

Conceptualmente, el dominio, porque "no se puede insertar una cantidad negativa de dinero" o "no se puede reponer unidades negativas" son reglas de negocio, no problemas de formato. Sin embargo, con los converters estándar de Flask nunca llega al dominio porque la propia regex lo filtra antes: el resultado es un 404 en lugar del 400 que devolvería el dominio. Ambas respuestas son válidas, pero el 400 es más informativo (incluye el mensaje de por qué). Por eso en labs posteriores, cuando usemos formularios HTML, dejaremos que Flask acepte cualquier texto y sea el dominio quien rechace — así el mensaje de error llega al usuario.

Comprobación

Rellena la tabla de predicciones y compara con los resultados reales. Anota las diferencias y por qué ocurrieron.

Reflexión

Respuestas
  • <int:x> y <float:x> validan el formato con regex estrictas: solo aceptan la forma literal (dígitos para int, dígitos-punto-dígitos para float).
  • Cualquier cosa que no matchee la regex provoca un 404 de Flask, no llega al route.
  • La validación de negocio vive en el dominio, pero solo se ejecuta con valores que Flask ha dejado pasar. Con converters más permisivos (o <string> + conversión manual), el dominio recuperaría el control.

Paso 3 — El kōan de las excepciones (reflexión + comprobación rápida)

Conceptos

Un kōan es una pregunta zen sin respuesta inmediata, pensada para forzar el análisis. Este paso no tiene código nuevo: es un ejercicio de análisis arquitectónico que prepara las decisiones de diseño del resto del lab.

Objetivo

Decidir conscientemente dónde debe ir el manejo de excepciones en una app Flask.

Tarea

Responde por escrito a estas preguntas. Si trabajas en pareja, respondedlas juntos.

1. En menu.py, ¿en qué función está el try/except que captura
   ProductoYaExisteError? (¿En opcion_agregar? ¿En ServicioExpendedora?
   ¿En el repositorio?)

2. En Flask, ¿ese try/except debería estar en el route, en servicios.py
   o en el repositorio? Justifica tu respuesta.

3. ¿Qué pasaría si pusieras el try/except dentro de servicios.py en lugar
   del route? ¿Qué información perdería la presentación?

4. ¿Qué pasaría si no capturas la excepción en ningún sitio?
   Pruébalo: quita temporalmente el try/except del route /reponer
   y pide una URL con código inexistente.

Pregunta

El route es la capa de presentación. ¿Qué hace la presentación con las excepciones de dominio que recibe?

Respuesta

Las transforma en mensajes para el usuario. En consola era print("X " + str(e)). En Flask es return str(e), 400. La excepción no cambia — solo cambia cómo se muestra al usuario.

Comprobación

Prueba qué muestra Flask cuando no hay try/except y ocurre un error. Observa el traceback en el navegador (que funciona gracias al debug=True que viene activado desde a1). Después restablece el try/except para que el route siga siendo robusto.

Reflexión

Respuestas
  • El try/except de excepciones de dominio pertenece al route (presentación).
  • servicios.py no captura excepciones de dominio — las deja subir para que las capture quien sabe cómo mostrarlas.
  • Sin manejo, Flask muestra un error 500 con el traceback completo (solo en modo debug; en producción sería una página en blanco de error).

Paso 4 — redirect y url_for tras acciones que modifican datos app.py

Conceptos

Después de una acción (reponer, comprar, agregar), redirigir al usuario evita que recargue la página y repita la acción sin querer. url_for('nombre_funcion') genera la URL correcta a partir del nombre de la función del route, de modo que si mañana cambias la URL del route, los redirects siguen funcionando.

Objetivo

Añadir redirección en el route /reponer para que el usuario vea inmediatamente el stock actualizado.

Tarea

Modifica el route /reponer para que, tras reponer, redirija a /producto/<codigo>:

from flask import Flask, redirect, url_for
# ... (junto a los demás imports del principio del fichero)

@app.route('/reponer/<codigo>/<int:unidades>')
def reponer(codigo, unidades):
    try:
        servicio.reponer(codigo, unidades)
        return redirect(url_for('ver_producto', codigo=codigo))
    except ProductoNoEncontradoError as e:
        return str(e), 404
    except ValueError as e:
        return str(e), 400
Pista

url_for('ver_producto', codigo=codigo) genera /producto/A1 si codigo='A1'. El primer argumento es el nombre de la función (ver_producto), no la URL. Si más adelante decides cambiar @app.route('/producto/<codigo>') por @app.route('/item/<codigo>'), todos los url_for('ver_producto', …) se actualizan solos. Un string hardcodeado como f'/producto/{codigo}' se rompería.

Pregunta

¿Por qué es mejor usar url_for('ver_producto', codigo=codigo) que escribir directamente f'/producto/{codigo}'?

Respuesta

Porque url_for es robusto ante cambios en las URLs. Las URLs pueden evolucionar (por ejemplo, añadir un prefijo /api/ o versionado /v1/); los nombres de función son más estables. Este desacoplamiento es uno de los motivos por los que Flask trabaja con nombres de función y no con strings de URL.

Comprobación

Tras /reponer/A1/5, el navegador debe acabar mostrando /producto/A1 con el stock incrementado en 5.

Reflexión

Respuestas
  • redirect devuelve una respuesta HTTP 302 que indica al navegador qué URL cargar a continuación.
  • url_for desacopla el código de las URLs concretas, usando nombres de función.
  • El patrón "actúa → redirige" evita reenvíos accidentales al recargar la página.

Paso 5 — Routes de la transacción de compra app.py

Conceptos

La expendedora es una máquina de estados: para comprar, primero se selecciona un producto, luego se inserta dinero y finalmente se confirma la compra. Los routes que ahora vamos a crear reflejan cada transición de estado como una URL independiente.

Objetivo

Cubrir las cuatro operaciones transaccionales del menú: seleccionar, insertar (ya hecho en Paso 2), comprar y cancelar.

Tarea

Añade los tres routes que faltan:

@app.route('/seleccionar/<codigo>')
def seleccionar(codigo):
    try:
        servicio.seleccionar(codigo)
        return redirect(url_for('ver_producto', codigo=codigo))
    except ProductoNoEncontradoError as e:
        return str(e), 404


@app.route('/comprar')
def comprar():
    try:
        cambio = servicio.comprar()
        return (f"Compra realizada. Cambio: {cambio:.2f} EUR. "
                f"<a href='/'>Volver</a>")
    except ValueError as e:
        return str(e), 400


@app.route('/cancelar')
def cancelar():
    devuelto = servicio.cancelar()
    return (f"Operación cancelada. Devuelto: {devuelto:.2f} EUR. "
            f"<a href='/'>Volver</a>")

Fíjate en /cancelar

servicio.cancelar() no lanza excepciones (no hay nada que validar: si no hay saldo, devuelve 0). Por eso su route no tiene try/except: solo captura excepciones cuando realmente pueden ocurrir.

Comprobación — flujo completo

Prueba la secuencia de compra completa, en orden, en el navegador:

  1. http://localhost:5000/seleccionar/A1 → redirige a /producto/A1.
  2. http://localhost:5000/insertar/2.0 → muestra Saldo actualizado: 2.00 EUR.
  3. http://localhost:5000/saldo → confirma el saldo.
  4. http://localhost:5000/comprar → muestra el cambio.
  5. Prueba ahora http://localhost:5000/comprar sin haber seleccionado antes → debes obtener 400 con el mensaje "No hay producto seleccionado".
  6. Prueba /seleccionar/A1 + /cancelar → debe devolver el saldo acumulado.

Reflexión

Respuestas
  • Cada route representa una transición de la máquina de estados, no una transacción completa.
  • El servidor Flask mantiene el estado (_seleccion, _saldo) en la instancia global servicio.
  • La misma excepción ValueError cubre varios casos (no hay selección, saldo insuficiente, stock agotado): el dominio pone el mensaje adecuado y el route lo transmite.

Reflexión arquitectónica (muy importante)

Responde a las siguientes preguntas. Son la clave para entender qué falta todavía en esta aplicación:

Pregunta 1

Si dos usuarios distintos abren la web a la vez, uno hace /seleccionar/A1 y el otro /seleccionar/B2 justo después, ¿qué pasará cuando el primer usuario pulse /comprar?

Respuesta

El servicio es una variable global única en el proceso de Flask. _seleccion ha sido sobrescrita por el segundo usuario, así que cuando el primer usuario ejecuta /comprar, comprará B2, no A1. Los dos usuarios se pisan el estado.

Pregunta 2

Si detienes el servidor con Ctrl+C y vuelves a arrancarlo, ¿qué ocurre con el saldo que tuvieras insertado?

Respuesta

Se pierde. El estado de la máquina (_saldo, _seleccion) vive en la memoria del proceso Python; al morir el proceso, muere el estado. Lo único persistente es la base de datos SQLite (los productos), porque ahí sí hay escritura a disco.

Pregunta 3

A lo largo del curso ya hemos trabajado con ficheros (lectura/escritura, JSON). ¿Cómo podríamos resolver los dos problemas anteriores: que el estado sobreviva al reinicio y que cada usuario tenga el suyo?

Respuesta

Son dos problemas distintos con soluciones complementarias:

  • Persistir el estado en un fichero JSON (por ejemplo, estado.json con {"saldo": 0.0, "seleccion": "A1"}). Resuelve el problema del reinicio: el estado sobrevive al Ctrl+C. Esto lo veremos en una práctica futura, y es un buen pretexto para repasar json.load, json.dump y gestión de ficheros con with.
  • Sesiones HTTP (flask.session). Resuelve el problema de los usuarios concurrentes: cada navegador tendría su propio estado sin pisar el de los demás. Esto lo veremos cuando toquemos formularios y autenticación.

Estas limitaciones no son un fallo del diseño del dominio — son un síntoma de que la web multiusuario pide mecanismos que todavía no hemos visto. Tenlas presentes: las resolveremos progresivamente.


Paso 6 — Routes de administración (CRUD) app.py

Conceptos

Nos faltan las operaciones administrativas del menú: agregar productos (con y sin descuento) y eliminar. Aquí usaremos códigos HTTP más precisos que un genérico 400: el 409 (Conflict) indica "no se puede procesar porque entra en conflicto con el estado actual del servidor" — por ejemplo, intentar crear un producto con un código que ya existe.

Objetivo

Exponer las operaciones de alta, alta con descuento y baja de productos. Completar la API que deja cubierto todo el menú.

Tarea

Al inicio del fichero, asegúrate de importar también la excepción de alta duplicada:

1
2
3
4
from expendedora.infrastructure.errores import (
    ProductoNoEncontradoError,
    ProductoYaExisteError,
)

Luego añade los tres routes:

@app.route('/agregar/<codigo>/<nombre>/<float:precio>/<int:cantidad>')
def agregar(codigo, nombre, precio, cantidad):
    try:
        servicio.agregar_producto(codigo, nombre, precio, cantidad)
        return redirect(url_for('ver_producto', codigo=codigo))
    except ProductoYaExisteError as e:
        return str(e), 409
    except ValueError as e:
        return str(e), 400


@app.route(
    '/agregar_descuento/<codigo>/<nombre>/<float:precio>/<int:cantidad>/<float:porcentaje>'
)
def agregar_descuento(codigo, nombre, precio, cantidad, porcentaje):
    try:
        servicio.agregar_producto_con_descuento(
            codigo, nombre, precio, cantidad, porcentaje
        )
        return redirect(url_for('ver_producto', codigo=codigo))
    except ProductoYaExisteError as e:
        return str(e), 409
    except ValueError as e:
        return str(e), 400


@app.route('/eliminar/<codigo>')
def eliminar(codigo):
    try:
        servicio.eliminar_producto(codigo)
        return f"Producto {codigo} eliminado. <a href='/productos'>Ver lista</a>"
    except ProductoNoEncontradoError as e:
        return str(e), 404
Pista sobre el código 409

Los códigos HTTP no son arbitrarios. 409 (Conflict) describe mejor "no se puede crear porque ya existe un producto con ese código" que un 400 genérico. 404 se reserva para recursos no encontrados (lecturas). Para validación de datos, el 400 sigue siendo correcto.

¿Cómo distinguir entre 400 y 409 cuando la regla la impone el dominio? La clave no es "es lógica de negocio" sino qué hace fallar la petición:

  • 400 — los datos son inválidos en sí mismos. Ejemplos: precio = 0, cantidad negativa, email sin @. Repetir la misma petición seguirá fallando — el problema está en lo que mandas.
  • 409 — los datos son válidos, pero chocan con el estado actual del servidor. Ejemplo: alta con código A1 cuando ya hay un A1. Si alguien borra A1, la misma petición funcionaría — el problema está en el estado, no en los datos.

Regla rápida: ¿si otro usuario cambia algo en el servidor, tu petición podría funcionar sin cambiar nada? Entonces es 409. ¿Seguiría fallando hagas lo que hagas en el servidor? Entonces es 400.

Comprobación

  • http://localhost:5000/agregar/X1/Zumo/1.5/10 → redirige a /producto/X1 con el producto recién creado.
  • Repite la misma URL → debes obtener 409 con mensaje de "ya existe".
  • http://localhost:5000/agregar/X2/Agua/0.0/5 → 400 (precio <= 0, lo rechaza el dominio). Nota: ponemos 0.0 y no 0 porque Flask exige el punto decimal para aceptarlo como <float>; si pusieras 0 obtendrías un 404 antes de llegar al dominio (ver Paso 2).
  • http://localhost:5000/agregar_descuento/X3/Promo/2.0/10/20.0 → producto con 20% de descuento.
  • http://localhost:5000/eliminar/X1 → mensaje de eliminación.
  • http://localhost:5000/eliminar/ZZZ → 404.

Pregunta

Acabas de meter un decimal (1.5) en una URL. ¿Qué problemas ves para un usuario real?

Respuesta
  • Es difícil de escribir: /agregar/X1/Zumo/1.5/10 no es algo que ningún usuario vaya a teclear en la barra del navegador.
  • Es fácil equivocarse al copiar valores, y los errores (un espacio, un carácter especial en el nombre) no se pueden corregir sin reescribir toda la URL.
  • El separador decimal no es universal: en español escribimos "1,5" pero Flask espera "1.5" porque es la convención de los lenguajes de programación. Esta diferencia — lo que el sistema operativo considera "el formato correcto de un número" según el idioma configurado, algo a lo que se llama locale — hace que la misma URL pueda parecer incorrecta a un usuario.

Por eso, en el siguiente lab usaremos formularios HTML con <form method="post">, que son la forma correcta de introducir datos desde el navegador: cada campo tiene su propia casilla, el usuario no tiene que escribir URLs a mano y los datos viajan por el cuerpo de la petición, no por la URL.

Reflexión

Respuestas
  • agregar_producto y agregar_producto_con_descuento son dos casos de uso separados en el dominio: el route también los separa.
  • Mismo patrón en los tres: try → operación del servicio → redirect o mensaje; except → código HTTP apropiado.
  • Todas estas operaciones son de admin; en una app real estarían protegidas por login. Eso lo veremos en labs posteriores.

Paso 7 — Actualizar la documentación del proyecto

Conceptos

Un proyecto bien mantenido tiene su documentación sincronizada con el código. Igual que en el lab a1, cerramos este lab dejando los ficheros de documentación al día con los cambios que hemos introducido.

Objetivo

Dejar CHANGELOG.md y README.md reflejando la cobertura completa de la API del dominio en routes y el nuevo método saldo() del servicio.

Tarea

CHANGELOG.md

Añade una entrada nueva al inicio del fichero:

## [0.6.0] - (Fase 06: API del dominio completa en routes)

### Added
- `presentation/app.py`: routes de lectura `/buscar/<texto>` y `/saldo`.
- `presentation/app.py`: routes de la máquina de estados `/seleccionar/<codigo>`, `/insertar/<float:cantidad>`, `/comprar`, `/cancelar`.
- `presentation/app.py`: routes de administración `/agregar/<codigo>/<nombre>/<float:precio>/<int:cantidad>`, `/agregar_descuento/...`, `/eliminar/<codigo>`, `/reponer/<codigo>/<int:unidades>`.

### Changed
- `application/servicios.py`: añadido `saldo()` como delegación pura en `MaquinaExpendedora.saldo` (antes no había forma de leer el saldo desde fuera del servicio).
- `presentation/app.py`: el route `/` ahora ofrece enlaces a las rutas principales.

¿Por qué 0.6.0 y no 0.5.1?

El proyecto usa Semantic Versioning (SemVer), con el formato MAYOR.MENOR.PARCHE:

  • PARCHE (0.5.0 → 0.5.1) se reserva para correcciones de errores compatibles hacia atrás: un bug arreglado, una errata en la documentación, un mensaje de error retocado. No añade funcionalidad.
  • MENOR (0.5.0 → 0.6.0) se usa cuando añades funcionalidad nueva sin romper lo anterior. Es justo lo que hace este lab: diez routes nuevos y un método nuevo en el servicio, pero menu.py y todo lo anterior sigue funcionando igual.
  • MAYOR (0.5.0 → 1.0.0) indica cambios incompatibles que rompen a los usuarios actuales del código. No es el caso aquí.

Por eso toca saltar a 0.6.0. Dejamos 0.5.1, 0.5.2... libres para cuando necesitemos arreglar bugs reales en la versión 0.5.

README.md

En la sección Uso (interfaz web), sustituye el listado actual de rutas por el nuevo con toda la API:

## Uso (interfaz web)
Ejecuta desde la carpeta padre que contiene el paquete `expendedora/`:

```bash
python -m expendedora.presentation.app
```

Abre `http://localhost:5000` en el navegador. Rutas disponibles:

**Lectura:**
- `/` — enlaces a las rutas principales.
- `/productos` — lista todos los productos.
- `/producto/<codigo>` — detalle de un producto (404 si no existe).
- `/buscar/<texto>` — productos cuyo nombre contiene el texto.
- `/saldo` — saldo actual de la máquina.

**Transacción (máquina de estados):**
- `/seleccionar/<codigo>` — selecciona un producto.
- `/insertar/<float:cantidad>` — añade dinero al saldo.
- `/comprar` — ejecuta la compra del producto seleccionado.
- `/cancelar` — cancela y devuelve el saldo acumulado.

**Administración:**
- `/agregar/<codigo>/<nombre>/<float:precio>/<int:cantidad>` — alta de producto.
- `/agregar_descuento/<codigo>/<nombre>/<float:precio>/<int:cantidad>/<float:porcentaje>` — alta con descuento.
- `/reponer/<codigo>/<int:unidades>` — reposición de stock.
- `/eliminar/<codigo>` — baja de producto.

Comprobación

Abre README.md y comprueba que alguien que no conozca el proyecto entiende, solo leyéndolo, que la web cubre ahora todas las operaciones del menú de consola. Arranca el servidor y verifica visitando http://localhost:5000/ que los enlaces de la portada funcionan.

Reflexión

Respuestas
  • El CHANGELOG.md es la memoria del proyecto: cada entrega queda registrada con fichero y método afectados.
  • El README.md es el escaparate: quien llega al proyecto sin contexto se apoya en él para saber qué hay y cómo se ejecuta.
  • Documentar al final del lab, con los cambios frescos, es más fácil que intentar reconstruirlos días después.

Checklist de entrega

  • Método saldo() añadido a application/servicios.py como delegación pura.
  • Routes de lectura creados: /, /productos, /producto/<codigo>, /buscar/<texto>, /saldo.
  • Routes de transacción creados: /seleccionar/<codigo>, /insertar/<float:cantidad>, /comprar, /cancelar.
  • Routes de administración creados: /agregar/<codigo>/<nombre>/<float:precio>/<int:cantidad>, /agregar_descuento/..., /eliminar/<codigo>, /reponer/<codigo>/<int:unidades>.
  • domain/ e infrastructure/ intactos.
  • presentation/menu.py sigue funcionando sin cambios.
  • Reflexiones escritas del Paso 3 (kōan) y del Paso 5 (estado global y persistencia).
  • CHANGELOG.md y README.md actualizados.
  • Entregado un zip con todo el proyecto — será la base del lab a3.

Problemas comunes

AttributeError: 'ServicioExpendedora' object has no attribute 'saldo'

Te has olvidado de añadir el método saldo en application/servicios.py. Revisa el Paso 1.

NameError: name 'ProductoYaExisteError' is not defined

Falta el import al inicio de app.py. Revisa el Paso 6.

NameError: name 'redirect' is not defined o url_for

Falta añadir redirect, url_for al import de Flask del inicio del fichero (Paso 4).

Un cambio no se refleja al recargar la página

El servidor Flask en modo debug=True recarga al guardar, pero a veces el navegador cachea. Haz Ctrl+F5 para recarga dura.

/comprar siempre da error 400 'No hay producto seleccionado'

Los routes reflejan la máquina de estados: antes de /comprar tienes que pasar por /seleccionar/<codigo> y /insertar/<float:cantidad>. Revisa la secuencia del Paso 5.