Saltar al contenido
Inicio 100 Días de Código en Python — Día 40: Proyecto Final (parte 2) — el Club de Vuelos

100 Días de Código en Python — Día 40: Proyecto Final (parte 2) — el Club 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 40 (nivel intermedio+, segunda parte del proyecto final).

Este tutorial adapta pedagógicamente el Día 40. 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 se comprobó, el 26 de septiembre de 2026: el significado del parámetro stops en la documentación oficial de SerpAPI y el comportamiento del NotificationManager de la solución oficial contra un servidor de correo local (aiosmtpd, Día 32). No se crearon cuentas en Google Forms, Sheety, SerpAPI ni Twilio.

🔐 Datos personales (adaptación para el aula): hoy el programa recoge correos de otras personas y les envía mensajes. Eso exige:

  1. Consentimiento: en Colombia, la Ley 1581 de 2012 exige autorización para recolectar y usar datos personales. Un formulario real debe explicar para qué se usarán los datos y pedir que la persona acepte.
  2. Poder darse de baja: todo servicio de correos debe permitir dejar de recibirlos.
  3. En el aula: solo correos ficticios (por ejemplo, ana@colegio.test) y envíos al servidor local del Día 32.

Tabla de contenidos

  1. Antes de empezar: de proyecto personal a producto
  2. Paso 1: un formulario compartible conectado a la hoja
  3. Paso 2: el proyecto inicial (el final de la parte 1)
  4. Paso 3: destinos sin vuelos directos
  5. Paso 4: obtener los correos de los clientes
  6. Paso 5: enviar un correo a todos los clientes
  7. 🚀 Proyecto del día: el Club de Vuelos
  8. 🌟 Proyecto extra: el Boletín de Becas y Convocatorias
  9. 🏆 Proyecto integrador del bloque de APIs y automatización (Días 32 a 40): la Plataforma de Alertas del Colegio
  10. 🧩 Comprueba lo aprendido
  11. 🤖 Programar con apoyo de IA
  12. 🛠️ Correcciones técnicas al material original
  13. 📋 Matriz de trazabilidad
  14. 📦 Evidencias de aprendizaje del Día 40

0. Antes de empezar: de proyecto personal a producto

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

Ayer construimos un buscador de ofertas para una sola persona. Hoy lo convertimos en un producto: cualquier persona se inscribe y recibe por correo las mejores ofertas.

La profesora se inspiró en un servicio real, Jack's Flight Club: te inscribes con tu correo y te envían ofertas de vuelos muy por debajo del precio normal. Además, cobran por un servicio premium. Su observación: "Su servicio es esencialmente el mismo que hemos creado con Python; quizá tengan una hoja de cálculo más grande con más destinos". (El chiste del inicio, "la primera regla del Flight Club es no hablar del Flight Club", es una referencia a la película El club de la pelea.)

Lo nuevo hoy:

  1. Un formulario para que los usuarios se inscriban y queden guardados en una hoja users.
  2. Buscar vuelos con escalas cuando no hay directos.
  3. Enviar un correo con las ofertas a todos los inscritos.

Nota sobre el material: el video de objetivos muestra una inscripción por texto (el usuario escribe su nombre y correo en la consola y, tras una validación, se agrega a la hoja). Las instrucciones actuales del curso (303) usan en su lugar un formulario de Google. Este tutorial sigue las instrucciones actuales y propone la versión por texto como reto (sección 1).

Prerequisito general: todo el Día 39; smtplib (Día 32); comprensión de listas; parámetros con valor por defecto (Día 10); variables de entorno y .env (Días 35 y 39).


1. Paso 1: un formulario compartible conectado a la hoja

(Fuente: 303-step-1-create-a-sharable-form-linked-to-your-sheet.md)

📝 Contenido original del curso

Crear el formulario (Google Forms):

  1. En Google Drive, crear un formulario nuevo.
  2. Desactivar la recolección automática de correos y no limitar a una respuesta por persona (facilita las pruebas con datos ficticios).
  3. Agregar 3 preguntas: nombre, apellido y correo.
  4. Vincular el formulario con la hoja de ofertas de vuelos que ya existe.
  5. Renombrar la hoja de respuestas a users.
  6. Probar: enviar datos ficticios, copiar el enlace y abrirlo en una ventana de incógnito para enviar más respuestas. Deben aparecer en la hoja.

Configurar Sheety:

  1. Sincronizar el proyecto: la hoja users debe aparecer debajo de prices (puede que haya que volver a iniciar sesión).
  2. Habilitar GET (y POST y PUT) para el endpoint users.

📖 Del formulario a las claves de la API

Sheety convierte los títulos de las columnas en claves camelCase (Día 38). Si tu pregunta del formulario dice "What is your email?", la clave será whatIsYourEmail? (así aparece en la solución del curso). Por eso el curso pide anotar el nombre exacto de la columna.

🏫 Para el aula

  • Si se usa Google Forms, que sea la cuenta del docente y con respuestas ficticias.
  • Un formulario real debe incluir una casilla de autorización de tratamiento de datos y explicar para qué se usarán.

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: redacta el texto de autorización de datos que pondrías al inicio del formulario (máximo 4 líneas).
  • 🟡 Reto 2: inscripción por texto (como en el video de objetivos): una función que pida nombre, apellido y correo por consola, pida el correo dos veces y solo continúe si coinciden.
  • 🟠 Reto 3: guarda la inscripción del Reto 2 en users.csv (modo aula) o con un POST a Sheety (modo real), con la clave raíz en singular: {"user": {...}}.
  • 🔴 Reto 4: valida que el correo tenga una forma razonable (un @, un punto después del @, sin espacios) y que no esté ya inscrito.

2. Paso 2: el proyecto inicial (el final de la parte 1)

(Fuente: 304-step-2-download-the-starting-project.md)

📝 Contenido original del curso

El proyecto inicial de hoy es idéntico al proyecto terminado del Día 39. Puedes usar el tuyo, siempre que:

  • lea los destinos de la hoja;
  • busque vuelos para cada destino;
  • imprima el precio más barato de cada ciudad;
  • envíe una notificación si encuentra un precio menor.

El .env debe contener: SERPAPI_API_KEY, SHEETY_PRICES_ENDPOINT, SHEETY_USERNAME, SHEETY_PASSWORD y los datos de Twilio (TWILIO_SID, TWILIO_AUTH_TOKEN, TWILIO_VIRTUAL_NUMBER, TWILIO_VERIFIED_NUMBER, TWILIO_WHATSAPP_NUMBER).

Las tres mejoras de hoy: vuelos con escalas cuando no hay directos, lista de correos de clientes y envío de correos a todos.

💡 Reflexión: que el proyecto inicial de la parte 2 sea el final de la parte 1 muestra por qué valió la pena separar responsabilidades: cada mejora de hoy toca pocas clases.


3. Paso 3: destinos sin vuelos directos

(Fuente: 305-step-3-destinations-without-direct-flights.md)

🎯 Objetivo de aprendizaje

Usar un parámetro con valor por defecto para cambiar el comportamiento de un método, y programar una búsqueda de respaldo.

📝 Ejercicio original del curso

Muchos destinos populares no tienen vuelos directos (de Londres a Bali, por ejemplo, hay que hacer escala en Singapur). El programa debe:

  1. Buscar primero solo vuelos directos.
  2. Si no encuentra ninguno, buscar de nuevo permitiendo escalas.
  3. Guardar el número de escalas (stops) para mostrarlo en la notificación.

Especificación del curso:

  • check_flights(..., is_direct=True): un parámetro nuevo con valor por defecto True.
  • En FlightData.__init__, un atributo nuevo stops.
  • Cada opción de vuelo trae una lista flights con los tramos: un vuelo directo tiene 1 tramo; con una escala, 2. Por eso: nr_stops = len(flight["flights"]) - 1.
  • En main.py: si cheapest_flight.price == "N/A", buscar otra vez con is_direct=False e imprimir un aviso.

📖 El parámetro stops de SerpAPI

Según la documentación oficial de SerpAPI (verificada), stops no es "el número de escalas", sino una opción de filtro:

Valor Significado
0 Cualquier número de escalas (por defecto)
1 Solo vuelos directos
2 1 escala o menos
3 2 escalas o menos

Es contraintuitivo: "stops": "1" significa "sin escalas". Otra razón para leer siempre la documentación en lugar de adivinar por el nombre.

🔎 La solución del curso

flight_search.py:

Python
    def check_flights(self, origin_city_code, destination_city_code, from_time, to_time, is_direct=True):
        query = {
            "engine": "google_flights",
            # ... los mismos parámetros del Día 39 ...
            "api_key": self._api_key,
        }
        if is_direct:
            query["stops"] = "1"     # solo vuelos directos
        # ... petición y manejo de errores iguales ...

flight_data.py: se agrega stops a FlightData y, en find_cheapest_flight, nr_stops = len(flight["flights"]) - 1 para el primer vuelo y para cada vuelo más barato.

main.py:

Python
for destination in sheet_data:
    print(f"Getting direct 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"))

    if cheapest_flight.price == "N/A":
        print(f"No direct flight to {destination['city']}. Looking for indirect flights...")
        stopover_flights = flight_search.check_flights(
            ORIGIN_CITY_IATA, destination["iataCode"],
            from_time=tomorrow, to_time=six_month_from_today,
            is_direct=False
        )
        cheapest_flight = find_cheapest_flight(stopover_flights, return_date=six_month_from_today.strftime("%Y-%m-%d"))
  • Como is_direct vale True por defecto, las llamadas antiguas siguen funcionando sin cambios.
  • La segunda búsqueda no envía stops, así que acepta cualquier número de escalas.

⚠️ Errores frecuentes

Error Qué ocurre Solución
"stops": "0" para vuelos directos Busca cualquier vuelo Según la documentación, directos = "1"
Olvidar pasar nr_stops al crear FlightData TypeError: missing 1 required positional argument: 'stops' Actualiza todas las llamadas, incluida la de "N/A"
Contar las escalas como len(flight["flights"]) Un directo aparece con 1 escala Resta 1

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: con el flights.json de ejemplo del Día 39, imprime cuántas escalas tiene cada opción.
  • 🟡 Reto 2: ¿cuántas búsquedas de SerpAPI gasta ahora una ejecución con 3 destinos en el peor caso? ¿Y en el mejor?
  • 🟠 Reto 3: agrega a la hoja una columna "acepta escalas" (sí/no) por destino, y haz la segunda búsqueda solo si el destino lo permite.
  • 🔴 Reto 4: en lugar de is_direct (verdadero/falso), usa un parámetro max_stops (0, 1 o 2) y conviértelo al valor correcto de stops según la tabla de la documentación.

4. Paso 4: obtener los correos de los clientes

(Fuente: 306-step-4-retrieve-your-customer-emails.md)

📝 Ejercicio original del curso

Objetivo: obtener los correos de la hoja users como una lista de Python en main.py.

Requisitos técnicos:

  • Agregar al .env los endpoints de prices y de users.
  • Crear DataManager.get_customer_emails(), que devuelva los datos de la hoja users.
  • Leer todas las variables de entorno en un solo lugar: __init__.

🔎 La solución del curso

Python
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.users_endpoint = os.environ["SHEETY_USERS_ENDPOINT"]
        self.destination_data = {}
        self.customer_data = {}

    def get_customer_emails(self):
        response = requests.get(url=self.users_endpoint, auth=self._authorization)
        data = response.json()
        self.customer_data = data["users"]
        return self.customer_data

Y en main.py, una comprensión de listas extrae solo los correos:

Python
customer_data = data_manager.get_customer_emails()
customer_email_list = [row["whatIsYourEmail?"] for row in customer_data]

El curso advierte: el nombre de la columna depende de tu formulario; el tuyo puede ser distinto.

🛠️ Nota técnica: la solución publicada de este paso incluye en main.py la línea data_manager.update_destination_codes(), un método que no existe en el DataManager de esa misma solución: si la copias tal cual, obtendrás AttributeError: 'DataManager' object has no attribute 'update_destination_codes'. Su .env también trae AMADEUS_API_KEY y AMADEUS_SECRET. Son restos de una versión anterior del proyecto, que usaba la API de vuelos Amadeus en lugar de SerpAPI. Elimínalos. Moraleja: el código de las soluciones también se desactualiza; léelo antes de copiarlo.

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: en modo aula, lee users.csv con pandas y obtén la misma lista con una comprensión.
  • 🟡 Reto 2: elimina correos duplicados y vacíos de la lista, conservando el orden.
  • 🟠 Reto 3: en vez de una lista de correos, crea una lista de diccionarios {"nombre": ..., "correo": ...} para poder saludar a cada persona por su nombre.
  • 🔴 Reto 4: agrega una columna activo (sí/no) y envía solo a los activos. Así implementas la baja de un usuario sin borrar su fila.

5. Paso 5: enviar un correo a todos los clientes

(Fuente: 307-step-5-email-all-our-customers.md)

📝 Ejercicio original del curso

Objetivo: enviar a todos los usuarios de la hoja un correo con la oferta.

Requisitos técnicos:

  1. Agregar al .env la dirección SMTP, el correo y la contraseña de aplicación (Día 32).
  2. En NotificationManager.__init__, leer todas las variables de entorno en un solo lugar.
  3. Crear el método send_emails().
  4. En main.py, redactar un mensaje distinto según si el vuelo es directo o tiene escalas.

Advertencia del curso: el símbolo £ produce un UnicodeEncodeError. Soluciones: escribir "GBP" en lugar del símbolo, o codificar el mensaje en UTF-8.

🔎 La solución del curso

Python
class NotificationManager:

    def __init__(self):
        self.smtp_address = os.environ["EMAIL_PROVIDER_SMTP_ADDRESS"]
        self.email = os.environ["MY_EMAIL"]
        self.email_password = os.environ["MY_EMAIL_PASSWORD"]
        # ... variables de Twilio ...
        self.client = Client(os.environ["TWILIO_SID"], os.environ["TWILIO_AUTH_TOKEN"])
        self.connection = smtplib.SMTP(os.environ["EMAIL_PROVIDER_SMTP_ADDRESS"])

    def send_emails(self, email_list, email_body):
        with self.connection:
            self.connection.starttls()
            self.connection.login(self.email, self.email_password)
            for email in email_list:
                self.connection.sendmail(
                    from_addr=self.email,
                    to_addrs=email,
                    msg=f"Subject:New Low Price Flight!\n\n{email_body}".encode("utf-8")
                )

El mensaje según las escalas (main.py):

Python
if cheapest_flight.stops == 0:
    message = (f"Low price alert! Only GBP {cheapest_flight.price} to fly direct "
               f"from {cheapest_flight.origin_airport} to {cheapest_flight.destination_airport}, "
               f"on {cheapest_flight.out_date} until {cheapest_flight.return_date}.")
else:
    message = (f"Low price alert! Only GBP {cheapest_flight.price} to fly "
               f"from {cheapest_flight.origin_airport} to {cheapest_flight.destination_airport}, "
               f"with {cheapest_flight.stops} stop(s) "
               f"departing on {cheapest_flight.out_date} and returning on {cheapest_flight.return_date}.")

notification_manager.send_emails(email_list=customer_email_list, email_body=message)
  • .encode("utf-8") convierte el mensaje en bytes; sendmail acepta bytes sin intentar convertirlos a ASCII. Es la solución del curso al problema que viste en el Día 32 con la ñ.
  • Se envía un correo por persona (un sendmail por dirección). Así ningún cliente ve los correos de los demás. Poner a todos juntos en el campo "Para" revelaría la lista completa.

📖 Dos problemas del NotificationManager (análisis de esta adaptación)

Se probó la clase de la solución contra el servidor local del Día 32 (verificado):

  1. La conexión se usa una sola vez. La conexión se crea en __init__, pero send_emails la usa dentro de with self.connection:, que la cierra al terminar. La primera llamada funciona; la segunda falla con SMTPServerDisconnected: please run connect() first. En main.py, send_emails se llama dentro del for de destinos: si en una ejecución hay ofertas para dos destinos, los clientes no reciben la segunda. Solución: crear la conexión dentro de send_emails: with smtplib.SMTP(self.smtp_address, port=587) as connection:.
  2. Falta el puerto. smtplib.SMTP(dirección) sin puerto usa el 25 (verificado: smtplib.SMTP_PORT es 25). La lección 244 del Día 32 explicaba que la mayoría de servidores lo bloquean y que se debe usar 587.

Además, .encode("utf-8") evita el error, pero el correo no declara que está en UTF-8, así que algunos programas de correo podrían mostrar mal los símbolos. La alternativa del Día 32, EmailMessage con send_message, declara la codificación correctamente.

⚠️ Errores frecuentes

Error Qué ocurre Solución
Mensaje con £ sin codificar UnicodeEncodeError "GBP", .encode("utf-8") o EmailMessage
Conexión creada en __init__ y cerrada por with El segundo envío falla Crea la conexión dentro del método
smtplib.SMTP(dirección) sin puerto Puerto 25, a menudo bloqueado port=587
Todos los clientes en un solo "Para" Cada cliente ve los correos de los demás Un envío por persona (o copia oculta)

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: en modo aula, reescribe send_emails para el servidor local (sin starttls ni login) y envía a 3 correos ficticios.
  • 🟡 Reto 2: corrige el problema de la conexión y demuestra, con el servidor local, que dos llamadas seguidas funcionan.
  • 🟠 Reto 3: reescribe el envío con EmailMessage, un saludo con el nombre de cada persona y el precio en pesos colombianos con separador de miles.
  • 🔴 Reto 4: agrega al final de cada correo una línea "Para dejar de recibir estos correos, responde BAJA". Explica qué tendría que hacer el programa para procesar esas respuestas (no hace falta implementarlo).

6. 🚀 Proyecto del día: el Club de Vuelos

(Fuentes: 302 a 307)

Problema

Convertir el buscador personal de ofertas en un servicio que envía las ofertas a todos los inscritos.

Requisitos funcionales

  1. Inscripción de usuarios (Google Forms en modo real; consola o Tkinter con users.csv en modo aula).
  2. Búsqueda de vuelos directos y, si no hay, con escalas.
  3. Si hay oferta, un mensaje distinto para vuelos directos y con escalas.
  4. Un correo por persona, a todos los usuarios activos.

Restricciones técnicas

Arquitectura de cuatro clases del Día 39; todos los secretos en .env; EmailMessage o .encode("utf-8"); puerto 587 (o el servidor local en modo aula).

Pruebas

Caso Esperado
Destino con vuelo directo más barato que el límite Mensaje "fly direct" a todos
Destino sin directos, con escalas más baratas Mensaje "with 1 stop(s)"
Destino sin ningún vuelo Ningún mensaje; sin errores
Dos destinos con oferta en la misma ejecución ⚠️ (ver 🧪)
Correo duplicado en la hoja ⚠️ (ver 🧪)
Usuario inactivo No recibe correo

🧪 Prueba y depuración

  1. Dos ofertas, un solo correo. Con el NotificationManager de la solución, la segunda llamada a send_emails falla (sección 5). Reprodúcelo con el servidor local y dos ofertas simuladas, y corrígelo.
  2. Duplicados. Si alguien se inscribió dos veces, recibe dos correos. Elimina duplicados antes de enviar.
  3. Un correo por oferta o un resumen. Con 3 ofertas y 50 usuarios, la solución envía 150 correos. Propón enviar un solo correo por usuario con todas las ofertas del día. ¿Qué cambias en main.py y en NotificationManager?
  4. El AttributeError heredado. Si copiaste la solución del paso 4, revisa que no quede update_destination_codes() ni variables de Amadeus.

⭐ Extensión del proyecto

  • Cada usuario elige desde qué ciudad sale (columna nueva) y recibe solo las ofertas desde su ciudad.
  • Página de inscripción con Tkinter (Días 27 a 31).
  • Programación diaria con GitHub Actions y los secretos del .env como secretos del repositorio.

7. 🌟 Proyecto extra: el Boletín de Becas y Convocatorias

Situación

La orientadora escolar recibe muchas convocatorias (becas, olimpiadas académicas, cursos gratuitos, concursos) y no sabe a quién le interesa cada una. Quiere un servicio donde cada estudiante se inscriba con sus intereses y reciba solo las convocatorias que le correspondan.

Requisitos

  1. Inscripción por consola o Tkinter: nombre, correo (ficticio), grado e intereses (elegidos de una lista: ciencias, arte, deporte, tecnología, idiomas…), con autorización de datos obligatoria.
  2. inscritos.csv y convocatorias.csv (título, categoría, grado mínimo, fecha límite, enlace), con al menos 8 convocatorias ficticias.
  3. Clases con responsabilidades separadas, como en el Club de Vuelos: GestorDatos, Convocatoria, Buscador (qué convocatorias le sirven a cada inscrito) y Notificador.
  4. Cada inscrito recibe un solo correo con sus convocatorias vigentes (fecha límite posterior a hoy y grado adecuado), ordenadas por fecha límite.
  5. Las convocatorias que vencen en menos de 7 días aparecen marcadas como "¡Última semana!".
  6. Opción de baja (marcar como inactivo).
  7. Envío al servidor local, con EmailMessage y la conexión abierta dentro del método de envío.
  8. Registro en envios.csv para no enviar dos veces la misma convocatoria a la misma persona.

Decisiones que debes tomar

  • ¿Cada cuánto se envía el boletín? ¿Qué pasa si un estudiante no tiene convocatorias nuevas?
  • ¿Cómo evitas que el correo sea tan largo que nadie lo lea?

Casos de prueba

Caso Esperado
Inscrito con interés "tecnología", grado 10 Solo convocatorias de tecnología para grado ≤ 10
Convocatoria vencida No aparece
Ejecutar dos veces el mismo día No se repiten envíos
Inscrito dado de baja No recibe correo

8. 🏆 Proyecto integrador del bloque de APIs y automatización (Días 32 a 40): la Plataforma de Alertas del Colegio

Proyecto integrador creado para esta adaptación. Cierra el bloque "intermedio+" del curso: correo y fechas (Día 32), APIs y parámetros (Día 33), APIs en interfaces con clases (Día 34), autenticación y variables de entorno (Día 35), combinación de APIs y caché (Día 36), POST, PUT, DELETE y encabezados (Día 37), hojas de cálculo como API (Día 38) y un producto multiusuario con arquitectura de clases (Días 39 y 40).

Situación problema

El colegio quiere una plataforma de alertas a la que cada miembro de la comunidad (estudiantes, docentes, acudientes; todos ficticios en el ejercicio) se inscriba para recibir solo las alertas que le interesan: lluvia antes de la jornada, cambio fuerte de la TRM, paso visible de la ISS, pregunta de trivia del día y recordatorios de tareas.

Pregunta orientadora

¿Cómo diseñar un servicio automatizado, seguro y respetuoso de los datos personales, que combine varias fuentes de datos y entregue a cada persona solo lo que eligió recibir?

Objetivo

Construir la Plataforma de Alertas del Colegio: inscripción, fuentes de datos, reglas de alerta y envío diario personalizado.

Requerimientos

  1. Inscripción (consola o Tkinter) con: nombre, correo ficticio, rol, alertas elegidas y autorización de datos. Opción de baja.
  2. Almacenamiento intercambiable (CSV local o Sheety con autenticación Bearer), elegido en config.json.
  3. Fuentes (una clase por fuente, con la misma interfaz): Open-Meteo (lluvia), TRM (datos.gov.co), ISS y horas de sol, Open Trivia DB y un tareas.csv.
  4. Reglas configurables en config.json (umbrales de lluvia y TRM, distancia de la ISS, etc.).
  5. Un correo por persona y por día, con EmailMessage, solo con las alertas que eligió; las urgentes, al principio.
  6. Caché con tiempos distintos por fuente (urls_expire_after) y reintentos con límite ante errores temporales.
  7. Secretos en .env con .gitignore; el programa verifica al iniciar que existan los necesarios.
  8. Registro de envíos para no repetir alertas; registro de fallas de cada fuente.
  9. Programación: expresión cron para las 5:30 a. m. de Colombia y explicación de la hora UTC.
  10. Documento de privacidad de una página: qué datos se recogen, para qué, dónde se guardan, cómo darse de baja y cómo se relaciona con la Ley 1581 de 2012.

Restricciones

  • Solo datos ficticios y el servidor de correo local.
  • Ninguna fuente que exija cuentas personales de estudiantes.
  • La lógica de reglas se prueba con datos simulados, sin internet.

Etapas de desarrollo

Etapa Entregable parcial
1. Diseño Diagrama de clases, flujo diario y documento de privacidad (borrador)
2. Inscripción Alta, baja y validaciones, con almacenamiento en CSV
3. Fuentes Cada fuente probada por separado, con caché y manejo de errores
4. Reglas Pruebas con 10 escenarios simulados
5. Envío Correos personalizados al servidor local, sin duplicados
6. Automatización Expresión cron y ejecución simulada de 5 días
7. Presentación Demostración y defensa del diseño ante el grupo

Entregables

  • Código organizado en paquetes o módulos, config.json, .env.ejemplo (sin valores reales) y .gitignore.
  • Diagrama de clases y documento de privacidad.
  • Al menos 15 casos de prueba con resultados.
  • Registro de errores con al menos 8 errores reales y su solución.
  • Video corto de la demostración.

Casos de prueba mínimos

Caso Esperado
Persona suscrita solo a "lluvia", día sin lluvia No recibe correo (o un correo breve, según tu diseño documentado)
Persona suscrita a 3 alertas, 2 activas Un solo correo con esas 2
Una fuente cae El correo sale con las demás alertas y se registra la falla
Ejecutar dos veces el mismo día No se duplican correos
Persona dada de baja No recibe nada
Cambiar de CSV a Sheety Solo cambia config.json

Criterios técnicos de funcionamiento

  • Cada clase tiene una sola responsabilidad; main.py solo coordina.
  • Ninguna URL, clave ni umbral está escrito dentro de las funciones de lógica.
  • El programa termina en menos de 1 minuto aunque todas las fuentes fallen.

Posibles extensiones

Panel Tkinter para la administración; estadísticas mensuales de alertas enviadas; análisis de datos de las lecturas con pandas y gráficas (se estudian en la parte avanzada del curso).


9. 🧩 Comprueba lo aprendido

Conceptuales

  1. ¿Qué diferencia hay entre el proyecto del Día 39 y el del Día 40, desde el punto de vista del usuario?
  2. ¿Por qué el parámetro is_direct=True no rompe las llamadas que ya existían?
  3. ¿Qué significa "stops": "1" en SerpAPI? ¿Por qué es fácil equivocarse?
  4. ¿Por qué se envía un correo por persona en lugar de uno con todos en el campo "Para"?
  5. ¿Qué exige la Ley 1581 de 2012 para recoger correos de otras personas?

Análisis de código

Python
customer_email_list = [row["whatIsYourEmail?"] for row in customer_data]

¿De dónde sale el nombre whatIsYourEmail?? ¿Qué pasa si cambias el texto de la pregunta en el formulario?

Predicción

  1. Un vuelo con flights de 3 tramos, ¿cuántas escalas tiene?
  2. ```python conexion = smtplib.SMTP("localhost", 8025) with conexion: conexion.sendmail(...) with conexion: conexion.sendmail(...)
Texto
   ¿Qué ocurre en el segundo `sendmail`?
3. ¿Qué puerto usa `smtplib.SMTP("smtp.gmail.com")`?

### Depuración

python class NotificationManager: def init(self): self.connection = smtplib.SMTP(os.environ["SMTP"]) def send_emails(self, email_list, body): with self.connection: self.connection.starttls() self.connection.login(os.environ["EMAIL"], os.environ["PASSWORD"]) self.connection.sendmail(os.environ["EMAIL"], email_list, f"Subject:Oferta £\n\n{body}") `` Encuentra **cuatro** problemas (piensa en el puerto, en elwith`, en el símbolo £ y en la privacidad de los destinatarios).

Modificación

Cambia el mensaje para que diga "vuelo directo", "1 escala" o "2 escalas" en español, según stops, con una sola línea que use un diccionario.

Creación

Escribe correos_para_enviar(usuarios: list[dict]) -> list[str] que devuelva los correos únicos de los usuarios activos, sin espacios sobrantes y en minúsculas. Pruébala con 6 usuarios simulados, incluidos duplicados y un inactivo.


10. 🤖 Programar con apoyo de IA

Actividad 1: revisar código publicado. Las soluciones de este día tenían restos de una versión anterior (Amadeus). Pega en una IA el main.py y el data_manager.py de la solución del paso 4 (sin claves) y pregúntale: "¿Este código se ejecuta sin errores?". ¿Detecta que update_destination_codes no existe? Confírmalo ejecutando.

Actividad 2: la IA y la privacidad. Pide a una IA que redacte el texto de autorización de datos para el formulario de inscripción del Club de Vuelos, según la Ley 1581 de 2012. Luego:

  1. Compara el texto con la información oficial sobre la ley (por ejemplo, de la Superintendencia de Industria y Comercio).
  2. Revisa si menciona: finalidad, derechos del titular y cómo ejercerlos.
  3. Corrige lo que falte. Recuerda: la IA puede ayudar a redactar, pero la responsabilidad es de quien publica el formulario.

Actividad 3: depurar un error que solo aparece a veces. El error del segundo send_emails solo aparece cuando hay dos ofertas en la misma ejecución. Describe el síntoma a una IA sin mostrarle el código y pídele posibles causas. ¿Llega a la conexión cerrada? Compara con tu análisis de la sección 5.


11. 🛠️ Correcciones técnicas al material original

Archivo Afirmación o comportamiento del curso Observación Tratamiento
302 Inscripción por texto con validación Las instrucciones actuales (303) usan Google Forms Nota de la sección 0; Retos 2–4 de la sección 1
305 "Set stops to "1" or "0" depending on your needs" En SerpAPI, 1 = solo directos; 0 = cualquier número de escalas (verificado en la documentación) Sección 3
306 main.py de la solución llama update_destination_codes() El método no existe en el DataManager de esa solución: AttributeError. Restos de la versión con Amadeus (también en el .env) Nota técnica de la sección 4
307 Conexión SMTP creada en __init__ y usada con with en send_emails La segunda llamada falla con SMTPServerDisconnected (verificado con servidor local) Sección 5; 🧪 del proyecto
307 smtplib.SMTP(dirección) sin puerto Usa el puerto 25 por defecto (verificado); el Día 32 recomienda 587 Sección 5
307 .encode("utf-8") para el símbolo £ Evita el error, pero el correo no declara la codificación; EmailMessage es más correcto Sección 5
307 Un correo por oferta y por usuario Con varias ofertas, cada usuario recibe varios correos 🧪 del proyecto

12. 📋 Matriz de trazabilidad

Contenido original Archivo de origen Concepto desarrollado Ejercicio original Reto adicional Proyecto relacionado
Del proyecto personal al producto, Jack's Flight Club, inscripción de usuarios 302 Sección 0 — Nota de privacidad Secciones 6, 7 y 8
Google Forms vinculado a la hoja, hoja users, Sheety sincronizado, POST y PUT 303 Sección 1 Paso 1 Retos 1–4 de la sección 1 Secciones 6, 7 y 8
Proyecto inicial = final del Día 39, .env, tres mejoras 304 Sección 2 Paso 2 — Sección 6
Vuelos directos y con escalas, is_direct=True, stops, len(...) - 1 305 Sección 3 Paso 3 Retos 1–4 de la sección 3 Sección 6
get_customer_emails, endpoint de users, comprensión de listas, variables en __init__ 306 Sección 4 Paso 4 Retos 1–4 de la sección 4 Secciones 6, 7 y 8
send_emails, SMTP en .env, £ y UTF-8, mensaje según escalas 307 Sección 5 Paso 5 Retos 1–4 de la sección 5 Secciones 6, 7 y 8
(No existe en el curso original) — — — Boletín de Becas y Convocatorias Sección 7
(No existe en el curso original) — — — Plataforma de Alertas del Colegio (Días 32–40) Sección 8

13. 📦 Evidencias de aprendizaje del Día 40

  • El texto de autorización de datos de tu formulario o de tu inscripción por consola.
  • customer_email_list impresa (con correos ficticios), sin duplicados ni inactivos.
  • La salida del programa mostrando "No direct flight to … Looking for indirect flights…" (con datos reales en la demostración del docente o con respuestas guardadas).
  • Captura del servidor local recibiendo correos de dos ofertas en la misma ejecución, después de corregir la conexión.
  • Tu propuesta de "un solo correo por usuario con todas las ofertas".
  • El Boletín de Becas y Convocatorias (proyecto extra) o la Plataforma de Alertas del Colegio (proyecto integrador).
  • Cuaderno de errores: stops mal interpretado, FlightData sin stops, nombre de columna del formulario, AttributeError por código heredado, UnicodeEncodeError con £, conexión SMTP cerrada, puerto 25, destinatarios visibles y correos duplicados.