Saltar al contenido
Inicio 100 Días de Código en Python — Día 39: Proyecto Final (parte 1) — el Buscador de Ofertas de Vuelos

100 Días de Código en Python — Día 39: Proyecto Final (parte 1) — el Buscador de Ofertas de Vuelos

Nivel: Grado décimo Área: Tecnología e Informática Modalidad: Tutorial práctico Fuente: Curso "100 Days of Code: The Complete Python Pro Bootcamp" (Angela Yu, App Brewery). Contenido del Día 39 (nivel intermedio+, proyecto final en dos partes).

Este tutorial adapta pedagógicamente el Día 39. Las secciones 📝 Ejercicio original del curso provienen de los archivos del curso y de sus soluciones publicadas (gists enlazados en cada paso). Los retos 🟢🟡🟠🔴, el proyecto extra y el proyecto integrador son creaciones de esta adaptación. Las correcciones al material original aparecen como 🛠️ Nota técnica.

Nota sobre las verificaciones: con Python 3.14, requests 2.34 y requests-cache se comprobó, el 26 de septiembre de 2026: la respuesta de SerpAPI sin clave (error 401), que la API de vuelos simulados del curso exige iniciar sesión, y el comportamiento de la función find_cheapest_flight de la solución oficial con datos simulados. No se crearon cuentas en SerpAPI, Sheety ni Twilio. El proyecto inicial no está en la carpeta; su estructura se describe en la lección 296.

🔐 Para el aula: este proyecto usa cuatro servicios externos, con cuentas, claves y límites gratuitos (SerpAPI: 100 búsquedas al mes; Sheety: 200 peticiones al mes). Recomendación: el docente hace la demostración con sus cuentas, y los estudiantes desarrollan con datos guardados (el ejemplo de respuesta que publica el curso, sección 4) y un CSV local en lugar de Google Sheets. La arquitectura y la lógica son idénticas.


Tabla de contenidos

  1. Antes de empezar: cazar ofertas de vuelos
  2. Paso 1: el proyecto inicial y sus cuatro clases
  3. Paso 2: leer los destinos con Sheety y un archivo .env
  4. Paso 3: buscar vuelos para una ciudad
  5. Paso 4: analizar la respuesta y encontrar el vuelo más barato
  6. Paso 5: revisar todos los destinos
  7. Paso 6: enviar la notificación
  8. 🚀 Proyecto del día: el Buscador de Ofertas de Vuelos
  9. 🌟 Proyecto extra: el Planificador de la Excursión de Grado Once
  10. 🏆 Proyecto integrador de los Días 37 a 39: el Vigía de Datos Abiertos
  11. 🧩 Comprueba lo aprendido
  12. 🤖 Programar con apoyo de IA
  13. 🛠️ Correcciones técnicas al material original
  14. 📋 Matriz de trazabilidad
  15. 📦 Evidencias de aprendizaje del Día 39

0. Antes de empezar: cazar ofertas de vuelos

(Fuente: 295 - Day 39 Goals- what you will make by the end of the day.md)

A la profesora le encanta viajar, pero no escoge primero el destino: busca ofertas. Cuenta que consiguió un vuelo a Nueva Zelanda por 350 libras cuando normalmente cuesta unas 800, y uno a Japón por 250 cuando suele costar 500 o 550. El truco es revisar precios todos los días, para muchos destinos… algo que nadie tiene tiempo de hacer a mano.

El programa (dos partes):

  • Hoy (parte 1): un buscador de ofertas para ti.
  • Día 40 (parte 2): convertirlo en un servicio para muchos usuarios.

Cómo funciona:

Texto
Hoja de Google (destinos y precio límite) ──► API de búsqueda de vuelos ──► ¿más barato que el límite?
                                                                                   │ sí
                                                                                   ▼
                                                             Mensaje (SMS o WhatsApp) con precio y fechas

En la demostración, la profesora recibió: "Alerta de precio bajo: solo 41 libras para volar de Londres Stansted a Berlín…", apenas una libra por debajo de su límite.

Prerequisito general: clases y separación de responsabilidades (Días 16, 17 y 34); APIs, parámetros, encabezados y autenticación (Días 33 a 38); PUT (Día 37); requests-cache (Día 36); variables de entorno (Día 35); datetime y timedelta; JSON anidado; comprensión de listas y diccionarios.


1. Paso 1: el proyecto inicial y sus cuatro clases

(Fuente: 296-step-1-download-the-starting-project-and-register-with-serpapi.md)

🎯 Objetivo de aprendizaje

Entender cómo se reparte un programa grande en clases con una responsabilidad cada una.

📝 Contenido original del curso

El proyecto inicial trae 5 archivos, casi vacíos:

Archivo Clase Responsabilidad (según el comentario del curso)
data_manager.py DataManager Hablar con la hoja de Google
flight_search.py FlightSearch Hablar con la API de búsqueda de vuelos
flight_data.py FlightData Estructurar los datos de un vuelo
notification_manager.py NotificationManager Enviar las notificaciones
main.py — Usar las cuatro clases para cumplir los requisitos

Elige tu camino: si dominas POO, APIs, datetime y comprensiones, puedes intentarlo solo con la lista de APIs. Si prefieres pasos pequeños, sigue las lecciones siguientes.

Bibliotecas: requests, requests_cache (caché para no gastar el plan gratuito), python-dotenv (leer secretos de un archivo .env) y twilio.

APIs:

  • Sheety: leer y actualizar la hoja de destinos.
  • SerpAPI Google Flights: registro gratuito, 100 búsquedas al mes, sin tarjeta de crédito.
  • Alternativa del curso: datos de vuelos simulados en app.100daysofpython.dev/services/flights (requiere iniciar sesión, verificado).
  • Twilio: SMS o WhatsApp.

Requisitos del programa:

  1. Leer los destinos (con su código IATA) de la hoja con Sheety.
  2. Buscar con SerpAPI los vuelos más baratos entre mañana y dentro de 6 meses para cada ciudad.
  3. Si el precio es menor que el "precio más bajo" de la hoja, enviar un mensaje.
  4. El mensaje incluye: código IATA de salida y de llegada, precio y fechas.

Advertencias del curso: usar 3 destinos (máximo 5) para no agotar los límites gratuitos; Twilio puede dar problemas con SMS en algunos países (usar WhatsApp).

📖 ¿Por qué cuatro clases?

Imagina que mañana cambias SerpAPI por otra API de vuelos. Si todo estuviera en main.py, tendrías que revisar el programa entero. Con esta estructura, solo cambias FlightSearch. Es la misma idea del Día 34 (el "cerebro" del cuestionario separado de la interfaz), llevada a un proyecto con varias APIs.

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: para cada requisito del programa, escribe qué clase lo resuelve.
  • 🟡 Reto 2: dibuja un diagrama con las cuatro clases y flechas que muestren quién llama a quién.
  • 🟠 Reto 3: si mañana quisieras recibir las alertas por correo en vez de WhatsApp, ¿qué archivo cambiarías? ¿Y si la hoja de Google se reemplaza por un CSV?
  • 🔴 Reto 4: calcula cuántas búsquedas de SerpAPI gasta una ejecución con 3 destinos. ¿Cuántos días seguidos podrías ejecutarlo sin caché antes de llegar a 100?

2. Paso 2: leer los destinos con Sheety y un archivo .env

(Fuente: 297-step-2-use-sheety-to-read-airport-and-price-data.md)

📝 Contenido original del curso

  1. Copiar la hoja inicial del curso y crear un proyecto en Sheety con esa copia. Habilitar PUT (además de GET) para poder actualizar la hoja. La hoja debe pertenecer a la misma cuenta con la que entraste a Sheety.
  2. Elegir 3 ciudades y escribir el código IATA de su aeropuerto principal.
  3. Con DataManager.get_destination_data(), hacer un GET y devolver a main.py lo que hay en la clave prices, guardado en sheet_data.
  4. Imprimirlo con pprint (pretty print, "impresión bonita").
  5. Activar autenticación Basic en Sheety y guardar usuario y contraseña en un archivo .env, leído con python-dotenv.

📖 Los códigos IATA

Son códigos de 3 letras de los aeropuertos. Algunos son tan conocidos que se usan en conversación (LAX, JFK). Algunas ciudades con varios aeropuertos tienen además un código de ciudad: LON (Londres) agrupa LHR (Heathrow), LGW (Gatwick), etc.

Ejemplos colombianos (adaptación): BOG (Bogotá, El Dorado), MDE (Rionegro, José María Córdova, que sirve a Medellín), CTG (Cartagena), CLO (Cali), SMR (Santa Marta), ADZ (San Andrés), LET (Leticia). Verifica cada código en una fuente oficial antes de usarlo.

🔎 La solución del curso

.env (nunca se sube a un repositorio):

Texto
SHEETY_USERNAME="your_username"
SHEETY_PASSWORD="your_password"

data_manager.py:

Python
import os
import requests
from requests.auth import HTTPBasicAuth
from dotenv import load_dotenv

load_dotenv()

SHEETY_PRICES_ENDPOINT = "https://api.sheety.co/your_endpoint/prices"


class DataManager:

    def __init__(self):
        self._user = os.environ["SHEETY_USERNAME"]
        self._password = os.environ["SHEETY_PASSWORD"]
        self._authorization = HTTPBasicAuth(self._user, self._password)
        self.destination_data = {}

    def get_destination_data(self):
        response = requests.get(url=SHEETY_PRICES_ENDPOINT, auth=self._authorization)
        data = response.json()
        self.destination_data = data["prices"]
        return self.destination_data
  • load_dotenv() lee el archivo .env y carga sus valores como variables de entorno. Así no hay que configurarlas en cada terminal (Día 35).
  • HTTPBasicAuth(usuario, contraseña) es la forma de clase de auth=(usuario, contraseña) (Día 38).
  • El guion bajo en self._user es una convención: indica que el atributo es de uso interno de la clase.
  • El resultado se ve así: [{'city': 'Paris', 'iataCode': 'CDG', 'lowestPrice': 54, 'id': 2}, ...]. Cada fila trae un id, que servirá para actualizarla.

💡 Buena práctica (adaptación): crea también un archivo .gitignore con la línea .env, para que el archivo de secretos nunca se suba a un repositorio.

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: imprime solo "Ciudad — IATA — precio límite" de cada fila.
  • 🟡 Reto 2: crea una versión local de DataManager que lea destinos.csv con las mismas columnas y devuelva la misma lista de diccionarios. ¿main.py nota la diferencia?
  • 🟠 Reto 3: agrega response.raise_for_status() y prueba con una contraseña equivocada en el .env. ¿Qué mensaje aparece?
  • 🔴 Reto 4: valida al leer la hoja que cada iataCode tenga exactamente 3 letras mayúsculas, e informa qué filas están mal.

3. Paso 3: buscar vuelos para una ciudad

(Fuente: 298-step-3-get-flights-and-prices-for-a-single-city.md)

📝 Contenido original del curso

1. Caché antes que nada. Al inicio de main.py:

Python
import requests_cache

requests_cache.install_cache(
    "flight_cache",
    urls_expire_after={
        "*.sheety.co*": requests_cache.DO_NOT_CACHE,
        "*": 3600,
    }
)
  • Crea el archivo flight_cache.sqlite en la carpeta del proyecto. Puedes borrarlo para forzar datos nuevos.
  • Las respuestas se guardan 1 hora (3600 s), excepto las de Sheety (DO_NOT_CACHE): así siempre ves los datos actuales de tu hoja.

2. Las fechas:

Python
from datetime import datetime, timedelta

tomorrow = datetime.now() + timedelta(days=1)
six_month_from_today = datetime.now() + timedelta(days=(6 * 30))

3. La clave de SerpAPI en el .env (SERPAPI_API_KEY="...") y guardada en el objeto:

Python
class FlightSearch:

    def __init__(self):
        self._api_key = os.environ["SERPAPI_API_KEY"]

4. La búsqueda:

Python
SERPAPI_ENDPOINT = "https://serpapi.com/search"

    def check_flights(self, origin_city_code, destination_city_code, from_time, to_time):
        query = {
            "engine": "google_flights",
            "departure_id": origin_city_code,
            "arrival_id": destination_city_code,
            "outbound_date": from_time.strftime("%Y-%m-%d"),
            "return_date": to_time.strftime("%Y-%m-%d"),
            "type": "1",
            "adults": "1",
            "currency": "GBP",
            "api_key": self._api_key,
        }
        response = requests.get(url=SERPAPI_ENDPOINT, params=query)

        if response.status_code != 200:
            print(f"check_flights() response code: {response.status_code}")
            return None

        data = response.json()
        if "error" in data:
            print(f"API error: {data['error']}")
            return None
        return data
  • El curso explica que, a diferencia de APIs que usan tokens temporales (OAuth), SerpAPI usa una clave simple enviada como parámetro en cada petición.
  • Sin clave válida, la respuesta es 401 con {"error": "Invalid API key. ..."} (verificado).
  • check_flights devuelve los datos, o None si algo falló, después de imprimir el error.

5. Primera prueba: buscar de LHR (Londres Heathrow) a CDG (París Charles de Gaulle) e imprimir el resultado con pprint.

🛠️ Nota técnica 1 (qué se está buscando realmente): el enunciado dice "los vuelos más baratos entre mañana y dentro de 6 meses". Pero la consulta envía outbound_date = mañana y return_date = dentro de 180 días: busca un viaje de ida y vuelta que sale mañana y regresa en 6 meses, no el más barato de todo ese período. Encontrar la mejor fecha exigiría varias búsquedas con distintas fechas, y cada una gasta cuota. Discútelo en el 🧪 del proyecto.

🛠️ Nota técnica 2: 6 * 30 días no son exactamente 6 meses: desde el 26/09/2026, 180 días llevan al 25/03/2027, no al 26/03/2027 (verificado). Para este proyecto la diferencia no importa, pero conviene saber que es una aproximación.

🔍 Análisis: en el Día 37 vimos que enviar una clave en la URL es menos seguro que en un encabezado. SerpAPI la pide como parámetro; no podemos cambiar cómo funciona la API, pero sí evitar imprimir o compartir la URL completa de la petición.

⚠️ Errores frecuentes

Error Qué ocurre Solución
Instalar la caché después de hacer peticiones Esas peticiones no se guardan install_cache al principio de main.py
Cachear Sheety No ves los cambios de tu hoja DO_NOT_CACHE para *.sheety.co*
Pasar el datetime sin strftime Formato de fecha no válido strftime("%Y-%m-%d")
Olvidar load_dotenv() en flight_search.py KeyError: 'SERPAPI_API_KEY' Cárgalo antes de leer la variable

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: ejecuta dos veces la búsqueda con la caché activa e imprime from_cache (Día 36).
  • 🟡 Reto 2: cambia currency a "COP" y el origen a "BOG". ¿Qué otros parámetros de la documentación de SerpAPI te servirían (por ejemplo, idioma o país)?
  • 🟠 Reto 3: calcula "dentro de 6 meses" con meses de calendario reales, sin timedelta(days=180) (pista: suma 6 al mes y ajusta el año; cuidado con días como el 31).
  • 🔴 Reto 4: escribe check_flights para que, en lugar de imprimir y devolver None, lance una excepción propia ErrorBusquedaVuelos con un mensaje claro. ¿Qué ventajas y desventajas tiene frente a la solución del curso?

4. Paso 4: analizar la respuesta y encontrar el vuelo más barato

(Fuente: 299-step-4-parse-the-flight-data-and-show-the-cheapest-flight.md)

📝 Contenido original del curso

Objetivo: recorrer la respuesta de SerpAPI e imprimir el precio más barato para una ciudad, por ejemplo 'Paris: GBP 155' y 'Lower price flight found to Paris!'. Si es más barato que el de la hoja, actualizar la hoja.

La estructura de la respuesta (resumida):

JSON
{
  "airports": [...],
  "best_flights": [
    {
      "flights": [
        {"departure_airport": {"name": "Heathrow Airport", "id": "LHR", "time": "2026-06-15 10:30"},
         "arrival_airport": {"name": "Charles de Gaulle", "id": "CDG", "time": "2026-06-15 12:45"},
         "airline": "British Airways"}
      ],
      "total_duration": 75,
      "price": 120
    }
  ],
  "other_flights": [...]
}
  • Los vuelos vienen en dos listas: best_flights y other_flights. Hay que unirlas.
  • Cada opción tiene una lista flights con los tramos: el primero sale del origen y el último ([-1]) llega al destino. Si hay escala, hay más de un tramo.
  • El curso recomienda la herramienta jsonpathfinder.com para explorar el JSON: "la parte más difícil es navegar el JSON".

🏫 Para practicar sin cuenta: el curso publica un ejemplo completo de esta respuesta (el archivo flights.json del enlace de la lección 298). El docente puede descargarlo y compartirlo. Guárdalo en tu proyecto y cárgalo con json.load: así desarrollas y pruebas este paso sin gastar búsquedas.

Rutas para extraer los datos (pistas del curso):

Python
flight["price"]                                        # ya es un número
flight["flights"][0]["departure_airport"]["id"]        # aeropuerto de origen
flight["flights"][-1]["arrival_airport"]["id"]         # aeropuerto de destino
flight["flights"][0]["departure_airport"]["time"]      # "YYYY-MM-DD HH:MM"; .split(" ")[0] da la fecha

El precio es del viaje de ida y vuelta, pero la respuesta solo detalla la ida. Por eso la fecha de regreso se pasa como parámetro, tomada de la búsqueda.

🔎 La solución del curso

FlightData guarda los datos de un vuelo:

Python
class FlightData:

    def __init__(self, price, origin_airport, destination_airport, out_date, return_date):
        self.price = price
        self.origin_airport = origin_airport
        self.destination_airport = destination_airport
        self.out_date = out_date
        self.return_date = return_date

find_cheapest_flight, una función fuera de la clase (cuidado con la sangría):

Python
def find_cheapest_flight(data, return_date):
    if data is None or (not data.get("best_flights") and not data.get("other_flights")):
        print("No flight data")
        return FlightData("N/A", "N/A", "N/A", "N/A", "N/A")

    all_flights = data.get("best_flights", []) + data.get("other_flights", [])

    first_flight = all_flights[0]
    lowest_price = first_flight["price"]
    origin = first_flight["flights"][0]["departure_airport"]["id"]
    destination = first_flight["flights"][-1]["arrival_airport"]["id"]
    out_date = first_flight["flights"][0]["departure_airport"]["time"].split(" ")[0]
    cheapest_flight = FlightData(lowest_price, origin, destination, out_date, return_date)

    for flight in all_flights:
        try:
            price = flight["price"]
        except KeyError:
            print("--- No price available for flight. ---")
            continue
        if price < lowest_price:
            lowest_price = price
            # ... actualizar origin, destination, out_date ...
            cheapest_flight = FlightData(lowest_price, origin, destination, out_date, return_date)

    return cheapest_flight
  • Sin vuelos: se devuelve un FlightData con "N/A" en todo, en lugar de dejar que el programa se caiga.
  • Vuelo sin precio: a veces llega un vuelo sin el campo price; el try/except KeyError lo salta con continue.
  • data.get("best_flights", []) devuelve una lista vacía si la clave no existe (Día 30).

Actualizar la hoja (PUT, Día 37):

Python
    def update_lowest_price(self, row_id, new_price):
        new_data = {
            "price": {
                "lowestPrice": new_price
            }
        }
        requests.put(
            url=f"{SHEETY_PRICES_ENDPOINT}/{row_id}",
            json=new_data,
            auth=self._authorization
        )

Como en el Día 38, Sheety pide la clave raíz en singular: la hoja se llama prices, así que la clave es "price". La URL termina en el id de la fila.

En main.py:

Python
cheapest_flight = find_cheapest_flight(flights, return_date=six_month_from_today.strftime("%Y-%m-%d"))
if cheapest_flight.price != "N/A" and cheapest_flight.price < sheet_data[0]["lowestPrice"]:
    data_manager.update_lowest_price(sheet_data[0]["id"], cheapest_flight.price)

Nota de depuración del curso: puede que de verdad no haya vuelos a cierto destino en esas fechas. Si no entiendes por qué no hay resultados, compruébalo en un sitio web de búsqueda de vuelos.

⚠️ Errores frecuentes

Error Qué ocurre Solución
Sangrar find_cheapest_flight dentro de la clase Se vuelve un método y no se puede importar como función Sin sangría
flight["flights"][0]["arrival_airport"] en un vuelo con escala Obtienes el aeropuerto de la escala, no el destino [-1] para el último tramo
Buscar solo en best_flights Puedes perder el más barato Une las dos listas
{"prices": {...}} en el PUT Sheety no actualiza Clave raíz en singular: "price"

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: con el flights.json guardado, imprime el precio y la aerolínea de todas las opciones.
  • 🟡 Reto 2: agrega a FlightData el número de escalas (len(flight["flights"]) - 1) e imprímelo.
  • 🟠 Reto 3: reescribe la búsqueda del más barato con min() y el parámetro key, descartando antes los vuelos sin precio con una comprensión de listas.
  • 🔴 Reto 4: usa None en lugar de un FlightData lleno de "N/A" para "no hay vuelos". ¿Qué comparaciones de main.py se simplifican? ¿Qué riesgo tiene el diseño con "N/A"? (Pista: sección 6.)

5. Paso 5: revisar todos los destinos

(Fuente: 300-step-5-check-all-the-flights-in-your-sheet.md)

📝 Contenido original del curso

Extender la lógica de "solo París" a todas las filas de la hoja, con 3 destinos como máximo para respetar los límites gratuitos.

🔎 La solución del curso

Python
ORIGIN_CITY_IATA = "LHR"

for destination in sheet_data:
    print(f"Getting flights for {destination['city']}...")
    flights = flight_search.check_flights(
        ORIGIN_CITY_IATA,
        destination["iataCode"],
        from_time=tomorrow,
        to_time=six_month_from_today
    )
    cheapest_flight = find_cheapest_flight(flights, return_date=six_month_from_today.strftime("%Y-%m-%d"))
    print(f"{destination['city']}: GBP {cheapest_flight.price}")

    if cheapest_flight.price != "N/A" and cheapest_flight.price < destination["lowestPrice"]:
        print(f"Lower price flight found to {destination['city']}!")
        data_manager.update_lowest_price(destination["id"], cheapest_flight.price)

Lo que antes era sheet_data[0] ahora es destination, la fila de cada vuelta del for.

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: al final, imprime un resumen: cuántos destinos se revisaron y cuántas ofertas se encontraron.
  • 🟡 Reto 2: convierte ORIGIN_CITY_IATA en una variable del .env o de un config.json.
  • 🟠 Reto 3: si un destino falla (sin datos), que el programa siga con los demás y los liste al final como "sin resultados".
  • 🔴 Reto 4: mide cuántas peticiones a cada API hace una ejecución completa (cuenta con variables). Compáralo con los límites del plan gratuito.

6. Paso 6: enviar la notificación

(Fuente: 301-step-6-send-a-notification-with-twilio.md)

📝 Contenido original del curso

Si algún vuelo es más barato que el precio de la hoja, NotificationManager envía un SMS o un WhatsApp con: precio, aeropuerto de salida, aeropuerto de llegada, fecha de ida y fecha de regreso. Los datos de Twilio van al .env:

Texto
TWILIO_SID="..."
TWILIO_AUTH_TOKEN="..."
TWILIO_VIRTUAL_NUMBER="..."
TWILIO_VERIFIED_NUMBER="..."
TWILIO_WHATSAPP_NUMBER="..."

🔎 La solución del curso

Python
import os
from twilio.rest import Client


class NotificationManager:

    def __init__(self):
        self.client = Client(os.environ["TWILIO_SID"], os.environ["TWILIO_AUTH_TOKEN"])

    def send_sms(self, message_body):
        message = self.client.messages.create(
            from_=os.environ["TWILIO_VIRTUAL_NUMBER"],
            body=message_body,
            to=os.environ["TWILIO_VERIFIED_NUMBER"]
        )
        print(message.sid)

    def send_whatsapp(self, message_body):
        message = self.client.messages.create(
            from_=f'whatsapp:{os.environ["TWILIO_WHATSAPP_NUMBER"]}',
            body=message_body,
            to=f'whatsapp:{os.environ["TWILIO_VERIFIED_NUMBER"]}'
        )
        print(message.sid)

Y en main.py, dentro del if:

Python
notification_manager.send_whatsapp(
    message_body=f"Low price alert! Only GBP {cheapest_flight.price} to fly "
                 f"from {cheapest_flight.origin_airport} to {cheapest_flight.destination_airport}, "
                 f"on {cheapest_flight.out_date} until {cheapest_flight.return_date}."
)

Advertencia del curso: comprueba cheapest_flight.price != "N/A" antes de comparar: no se puede comparar un texto con un número. Verificado: "N/A" < 300 produce TypeError: '<' not supported between instances of 'str' and 'int'. Fíjate en el orden: con and, si la primera condición es falsa, Python no evalúa la segunda.

🏫 Para el aula: agrega a NotificationManager un método send_email que use el servidor local del Día 32. Como main.py solo llama un método de NotificationManager, cambiar de canal es cambiar una línea.

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: escribe el mensaje de alerta en español y con el precio con separador de miles (f"{precio:,}").
  • 🟡 Reto 2: agrega el método send_email y úsalo en modo aula.
  • 🟠 Reto 3: en lugar de un mensaje por destino, envía uno solo con todas las ofertas del día.
  • 🔴 Reto 4: crea un método notify(message_body) que decida el canal según una variable de configuración ("sms", "whatsapp" o "email"). ¿Qué patrón de diseño reconoces?

7. 🚀 Proyecto del día: el Buscador de Ofertas de Vuelos

(Fuentes: 295 a 301)

Problema

Revisar automáticamente el precio de los vuelos a varios destinos y avisar cuando aparezca una oferta.

Contexto (adaptación)

Adapta el proyecto a Colombia: origen BOG (o el aeropuerto más cercano a tu municipio), moneda COP, y 3 destinos nacionales o internacionales.

Requisitos funcionales

  1. Leer destinos, códigos IATA y precios límite (Sheety o destinos.csv).
  2. Buscar el vuelo más barato para cada destino (SerpAPI o flights.json guardado).
  3. Si es más barato que el límite, actualizar el precio y notificar.
  4. Mensaje con precio, aeropuertos y fechas.

Restricciones técnicas

Cuatro clases con responsabilidades separadas; .env con todos los secretos y .gitignore; caché activa en desarrollo; ninguna comparación de "N/A" con números.

Pruebas

Caso Cómo simularlo Esperado
Hay oferta Precio límite alto en la hoja Actualización y mensaje
No hay oferta Precio límite muy bajo Sin mensaje
Sin vuelos Respuesta simulada con listas vacías "No flight data", sin error
Vuelo sin precio en primera posición Quita price al primer elemento del JSON guardado ⚠️ (ver 🧪)
Clave de SerpAPI inválida Cambia el .env Mensaje de error y el programa continúa

🧪 Prueba y depuración

  1. El primer vuelo sin precio (análisis de esta adaptación). El try/except de la solución protege el bucle, pero el precio del primer vuelo se lee antes, sin protección: lowest_price = first_flight["price"]. Si justo ese vuelo no tiene precio, el programa se detiene con KeyError: 'price' (verificado con datos simulados). Corrígelo: por ejemplo, filtra primero los vuelos que tienen precio y, si no queda ninguno, devuelve "sin datos".
  2. ¿Qué fechas se buscan? Relee la nota técnica 1 de la sección 3. Propón una estrategia para buscar varias fechas de salida (por ejemplo, un viernes de cada mes) y calcula cuántas búsquedas gastaría con 3 destinos. ¿Cabe en 100 búsquedas al mes si lo ejecutas a diario? ¿Cómo ayuda la caché?
  3. El precio se actualiza en la hoja. Cuando se encuentra una oferta, el programa reemplaza el precio límite con el nuevo. La próxima vez, solo avisará si aparece algo aún más barato. ¿Es lo que quiere el usuario? Propón otra columna (por ejemplo, "precio que estoy dispuesto a pagar" separado de "mejor precio visto").
  4. Caché y ofertas reales. Con una caché de 1 hora, si ejecutas el programa varias veces en una hora, ¿ves precios nuevos? ¿Qué tiempo de caché usarías en la versión que se ejecuta una vez al día?

⭐ Extensión del proyecto

  • Registrar cada búsqueda en un CSV (fecha, destino, precio) para ver la evolución de precios.
  • Incluir en el mensaje el número de escalas y la duración total.
  • Programar la ejecución diaria con GitHub Actions (Días 32 y 35), con los secretos del .env como secretos del repositorio.

8. 🌟 Proyecto extra: el Planificador de la Excursión de Grado Once

Situación

El grupo de grado once está planeando su excursión de fin de año y debe comparar destinos dentro de un presupuesto por estudiante. Tienen información de precios recopilada en archivos.

Requisitos

  1. destinos.csv con: ciudad, código IATA, costo de alojamiento por noche y por persona (valores investigados o ficticios, indicando la fuente), y número de noches.
  2. Una carpeta respuestas/ con archivos JSON de vuelos con la misma estructura que la respuesta de SerpAPI (usa el ejemplo del curso como base y modifica precios y aeropuertos).
  3. Las mismas cuatro clases del proyecto del día, con implementaciones locales: DataManager lee el CSV, FlightSearch lee el JSON del destino, FlightData y find_cheapest_flight sin cambios (¡reutilízalas!), y NotificationManager escribe un informe.
  4. Para cada destino: costo total por estudiante = vuelo más barato + alojamiento × noches.
  5. Clasificación: "Dentro del presupuesto", "Hasta 10 % por encima" o "Fuera del presupuesto", con el presupuesto como constante.
  6. Informe final en informe_excursion.txt (o correo al servidor local), ordenado del más económico al más costoso.
  7. Todos los casos de datos incompletos manejados: destino sin archivo de vuelos, vuelos sin precio, CSV con celdas vacías.

Decisiones que debes tomar

  • ¿El vuelo más barato es siempre la mejor opción para un grupo? (Piensa en escalas y duración.)
  • ¿Cómo presentarías el informe al consejo de padres?

Casos de prueba

Caso Esperado
Destino sin archivo JSON "Sin información de vuelos" en el informe
Todos los vuelos de un destino sin precio "Sin precios disponibles"; sin KeyError
Presupuesto de 1 500 000 y costo de 1 600 000 "Hasta 10 % por encima"

9. 🏆 Proyecto integrador de los Días 37 a 39: el Vigía de Datos Abiertos

Proyecto integrador creado para esta adaptación. Reúne POST, PUT, DELETE y encabezados (Día 37), hojas de cálculo como API, autenticación y variables de entorno (Día 38), y la arquitectura de cuatro clases, la caché y el manejo de respuestas incompletas (Día 39).

Situación problema

La arquitectura del buscador de vuelos sirve para cualquier "vigilante de datos": leer una lista de cosas por vigilar, consultar una fuente, comparar con un umbral y avisar. El club de ciencias quiere un vigía que vigile datos abiertos de Colombia y del mundo.

Pregunta orientadora

¿Puede una misma arquitectura de clases resolver problemas distintos si cada clase tiene una sola responsabilidad?

Objetivo

Construir el Vigía de Datos Abiertos, con la misma estructura del Día 39 aplicada a otros datos.

Requerimientos

  1. DataManager: lee vigilancias.csv (o una hoja vía Sheety) con: nombre, fuente, condición ("mayor" o "menor"), umbral y último valor visto. Actualiza el último valor (PUT en Sheety o escritura en el CSV).
  2. Búsquedas (una clase por fuente, todas con el mismo método consultar() -> float | None): TRM (datos.gov.co, Día 36), probabilidad de lluvia (Open-Meteo, Día 35) y distancia de la ISS al colegio (Día 33).
  3. Lectura (equivalente a FlightData): guarda fuente, valor, fecha y hora de la lectura.
  4. NotificationManager: correo al servidor local con todas las alertas del día en un solo mensaje.
  5. requests-cache con tiempos distintos por fuente (urls_expire_after) justificados.
  6. Secretos (si algún servicio los requiere) en .env, con .gitignore.
  7. Una fuente que falla devuelve None y el programa sigue.
  8. Registro en lecturas.csv de todas las lecturas.

Restricciones

  • main.py no contiene ninguna URL ni ninguna clave.
  • Agregar una fuente nueva solo exige crear una clase y una fila en vigilancias.csv.

Etapas de desarrollo

Etapa Entregable parcial
1. Diseño Diagrama de clases comparado con el del buscador de vuelos
2. Fuentes Las tres clases de consulta probadas por separado
3. Lógica Comparación con umbrales, probada con valores simulados
4. Datos Actualización del último valor y lecturas.csv
5. Avisos Correo diario al servidor local
6. Presentación Agregar una cuarta fuente en vivo frente al grupo

Entregables

  • Código en varios archivos, vigilancias.csv y .gitignore.
  • Diagrama de clases.
  • 10 casos de prueba con resultados.
  • Registro de errores con al menos 5 errores reales.
  • Un párrafo que compare este proyecto con el buscador de vuelos: ¿qué clases se parecen y qué cambió?

Casos de prueba mínimos

Caso Esperado
TRM simulada por encima del umbral Aparece en el correo
Open-Meteo no responde Se registra la falla; las demás fuentes funcionan
Ejecutar dos veces en una hora La segunda usa la caché donde corresponde
Agregar una fuente nueva Solo una clase nueva y una fila en el CSV

Criterios técnicos de funcionamiento

  • Ninguna comparación entre texto y número.
  • Cada clase tiene una sola responsabilidad.
  • El programa termina aunque todas las fuentes fallen, con un informe de fallas.

Posibles extensiones

Programar la ejecución diaria; una interfaz Tkinter que muestre las últimas lecturas; gráficas de evolución (se estudian más adelante en el curso).


10. 🧩 Comprueba lo aprendido

Conceptuales

  1. ¿Cuál es la responsabilidad de cada una de las cuatro clases del proyecto?
  2. ¿Para qué sirve load_dotenv()? ¿Por qué el archivo .env debe ir en .gitignore?
  3. ¿Por qué el curso excluye Sheety de la caché?
  4. ¿Qué es un código IATA? ¿Por qué Londres tiene un código de ciudad además de los de sus aeropuertos?
  5. ¿Qué fechas busca realmente check_flights tal como la usa el curso?

Análisis de código

Python
all_flights = data.get("best_flights", []) + data.get("other_flights", [])
destination = flight["flights"][-1]["arrival_airport"]["id"]

¿Qué pasa si other_flights no existe en la respuesta? ¿Por qué se usa [-1]?

Predicción

  1. ¿Qué imprime "2026-10-15 07:40".split(" ")[0]?
  2. ¿Qué pasa al evaluar "N/A" != "N/A" and "N/A" < 300? ¿Y "N/A" < 300 and "N/A" != "N/A"?
  3. Con urls_expire_after={"*.sheety.co*": DO_NOT_CACHE, "*": 3600}, ¿se guarda en caché una petición a serpapi.com? ¿Por cuánto tiempo?

Depuración

Python
class FlightData:
    def __init__(self, price, origin_airport):
        self.price = price
        self.origin_airport = origin_airport

    def find_cheapest_flight(data):
        all_flights = data["best_flights"] + data["other_flights"]
        return min(all_flights, key=lambda f: f["price"])

Encuentra tres problemas (piensa en la sangría, en claves que pueden faltar y en vuelos sin precio).

Modificación

Cambia el programa para que solo avise si la oferta es al menos un 10 % más barata que el precio de la hoja.

Creación

Escribe vuelos_con_precio(respuesta: dict) -> list[dict] que una las dos listas y devuelva solo los vuelos que tienen price. Pruébala con una respuesta simulada con 4 vuelos, 1 de ellos sin precio.


11. 🤖 Programar con apoyo de IA

Actividad 1: explorar un JSON grande con ayuda. La profesora dice que lo más difícil es navegar el JSON. Toma el flights.json de ejemplo y:

  1. Encuentra tú la ruta hasta el nombre de la aerolínea del primer vuelo usando jsonpathfinder o impresiones parciales.
  2. Luego pregunta a una IA por la misma ruta, pegándole solo un fragmento del JSON (sin claves ni datos personales).
  3. Comprueba ejecutando ambas rutas. ¿Coinciden?

Actividad 2: la IA y el significado de los parámetros. Pregunta a una IA: "Con SerpAPI Google Flights, si envío outbound_date mañana y return_date dentro de 6 meses, ¿estoy buscando el vuelo más barato de los próximos 6 meses?". Compara con la nota técnica 1 de la sección 3 y con la documentación oficial de SerpAPI.

Actividad 3: revisión de robustez. Pega find_cheapest_flight y pide: "¿Qué datos de entrada harían fallar esta función?". ¿Encuentra el caso del primer vuelo sin precio? Pruébalo con tus datos simulados.


12. 🛠️ Correcciones técnicas al material original

Archivo Afirmación o comportamiento del curso Observación Tratamiento
296, 298 "Buscar los vuelos más baratos entre mañana y dentro de 6 meses" La consulta busca un viaje que sale mañana y regresa en 180 días, no el más barato del período Nota técnica 1 de la sección 3; 🧪 del proyecto
298 timedelta(days=6 * 30) como "6 meses" Es una aproximación: 25/03/2027 en lugar de 26/03/2027 (verificado) Nota técnica 2 de la sección 3
296 API de vuelos simulados como alternativa Requiere cuenta e inicio de sesión (verificado) Sección 1; alternativa con el flights.json de ejemplo
299 try/except KeyError para vuelos sin precio El precio del primer vuelo se lee fuera del try: KeyError si falta (verificado con datos simulados) 🧪 del proyecto
299, 301 "N/A" como precio cuando no hay vuelos Mezcla texto y números; obliga a comprobar antes de comparar (TypeError verificado). None es una alternativa más clara Reto 4 de la sección 4
299 Se actualiza el precio límite de la hoja al encontrar una oferta Cambia el significado de la columna: solo se avisará si aparece algo aún más barato 🧪 del proyecto

13. 📋 Matriz de trazabilidad

Contenido original Archivo de origen Concepto desarrollado Ejercicio original Reto adicional Proyecto relacionado
Historia de ofertas, flujo del programa, partes 1 y 2 295 Sección 0 — — Sección 7
Proyecto inicial de 5 archivos, bibliotecas, APIs, requisitos, límites, registro en SerpAPI 296 Sección 1 Paso 1 Retos 1–4 de la sección 1 Secciones 7, 8 y 9
Copia de la hoja, Sheety con PUT, IATA, pprint, Basic, .env, load_dotenv 297 Sección 2 Paso 2 Retos 1–4 de la sección 2 Secciones 7, 8 y 9
requests_cache.install_cache, DO_NOT_CACHE, fechas, FlightSearch, check_flights 298 Sección 3 Paso 3 Retos 1–4 de la sección 3 Secciones 7, 8 y 9
Estructura de la respuesta, FlightData, find_cheapest_flight, "N/A", update_lowest_price 299 Sección 4 Paso 4 Retos 1–4 de la sección 4 Secciones 7, 8 y 9
Recorrer todos los destinos 300 Sección 5 Paso 5 Retos 1–4 de la sección 5 Secciones 7 y 8
NotificationManager, SMS y WhatsApp, secretos de Twilio, comprobar "N/A" 301 Sección 6 Paso 6 Retos 1–4 de la sección 6 Secciones 7, 8 y 9
(No existe en el curso original) — — — Planificador de la Excursión de Grado Once Sección 8
(No existe en el curso original) — — — Vigía de Datos Abiertos (Días 37–39) Sección 9

14. 📦 Evidencias de aprendizaje del Día 39

  • El diagrama de las cuatro clases con sus responsabilidades.
  • sheet_data impreso con pprint (de Sheety o de tu destinos.csv).
  • La prueba de caché: primera y segunda ejecución con from_cache.
  • El vuelo más barato encontrado en el flights.json de ejemplo, con la corrección del primer vuelo sin precio.
  • La alerta de oferta (mensaje en la demostración del docente, o correo en el servidor local).
  • Tu respuesta a la pregunta "¿qué fechas se buscan realmente?" y tu estrategia alternativa.
  • El Planificador de la Excursión (proyecto extra) o el Vigía de Datos Abiertos (proyecto integrador).
  • Cuaderno de errores: KeyError al leer variables sin load_dotenv, caché instalada tarde, fechas sin strftime, tramos con escala, clave raíz del PUT, "N/A" comparado con números, primer vuelo sin precio y función sangrada dentro de la clase.