Add script secret_santa.py

This commit is contained in:
2025-11-08 22:31:34 +01:00
parent fa1ee4dd64
commit 6e97f2bea1
6 changed files with 512 additions and 0 deletions

View File

@@ -0,0 +1 @@
*.csv

View File

@@ -0,0 +1,216 @@
# README - Amigo Invisible (Secret Santa)
Este es un script en Python diseñado para organizar un intercambio de regalos de "Amigo Invisible" de manera automática. Permite generar asignaciones de personas de forma aleatoria, asegurándose de que no haya emparejamientos invalidos, como asignar a alguien a sí mismo o asignar a alguien con exclusiones especificadas.
Además, si se habilita la opción de enviar correos electrónicos, el script enviará un correo a cada participante notificándole a quién le ha tocado regalar, usando plantillas personalizables.
## Requisitos
* Python 3.x
* Paquetes de Python requeridos:
* `csv`
* `random`
* `argparse`
* `smtplib`
* `os`
* `logging`
* `email`
* `dotenv`
Puedes instalar las dependencias necesarias ejecutando el siguiente comando:
```bash
pip install python-dotenv
```
Si usas un entorno virtual, asegúrate de activarlo antes de instalar las dependencias.
## Descripción de Funcionalidades
### Entradas
El script toma como entrada un archivo CSV que contiene la lista de participantes. El archivo CSV debe tener las siguientes columnas:
* `name`: Nombre del participante.
* `email`: Correo electrónico del participante.
* `exclusions`: (Opcional) Lista de participantes a los que **no** pueden ser asignados como "Amigo Invisible". Los valores deben estar separados por punto y coma.
Ejemplo de archivo CSV `participants.csv`:
```csv
name,email,exclusions
Juan,juan@example.com,
María,maria@example.com,juan@example.com
Pedro,pedro@example.com,juan@example.com
```
En el caso de que un participante no tenga exclusiones, se deja en blanco la columna `exclusions`.
### Opciones del Script
Este es el uso básico del script:
```bash
python secret_santa.py --input participants.csv
```
#### Opciones disponibles:
* `--input PATH`
Ruta al archivo CSV de entrada (por defecto `participants.csv`).
* `--output PATH`
Ruta al archivo CSV de salida donde se guardarán las asignaciones (por defecto `assignments.csv`).
* `--seed INT`
Semilla para la generación aleatoria. Esto permite hacer el proceso reproducible.
* `--send`
Habilita el envío de correos electrónicos. Si no se incluye, los correos no se enviarán.
* `--smtp-server HOST`
Servidor SMTP para el envío de correos (por defecto, `smtp.gmail.com`).
* `--smtp-port PORT`
Puerto SMTP (por defecto, 587).
* `--smtp-user USER`
Usuario SMTP (requiere autenticación).
* `--smtp-pass PASS`
Contraseña SMTP. **Es recomendable utilizar una variable de entorno en lugar de escribir la contraseña directamente.**
* `--subject-template`
Ruta al archivo de plantilla del asunto del correo.
* `--body-template`
Ruta al archivo de plantilla del cuerpo del correo.
* `--max-attempts N`
Número máximo de intentos para generar emparejamientos aleatorios (por defecto, 10000).
## Flujo de Trabajo
1. **Leer Participantes:**
El script lee el archivo `participants.csv` y extrae los datos de los participantes, incluyendo sus exclusiones.
2. **Generación de Asignaciones:**
Se genera un emparejamiento aleatorio entre los participantes. El script se asegura de que:
* Un participante no se empareje con sí mismo.
* Un participante no sea asignado a una persona de su lista de exclusiones.
Si no se puede encontrar una asignación válida después de varios intentos (por defecto, 10000), el script usa un algoritmo de **backtracking** para intentar encontrar una solución.
3. **Guardar Asignaciones:**
Las asignaciones generadas se guardan en un archivo CSV con el formato:
```csv
giver_name,giver_email,recipient_name,recipient_email
```
4. **Enviar Correos Electrónicos (Opcional):**
Si se habilita la opción `--send`, el script enviará un correo electrónico a cada participante notificándole a quién le ha tocado regalar.
Los correos electrónicos usan plantillas personalizables para el asunto y el cuerpo del mensaje. Puedes crear estos archivos como plantillas y proporcionarlas al script a través de los parámetros `--subject-template` y `--body-template`.
### Estructura de Archivos
La estructura recomendada para el proyecto es la siguiente:
```
.
├── secret_santa.py # Script principal
├── .env # Variables de entorno (por ejemplo, SMTP credentials)
├── participants.csv # Lista de participantes
├── assignments.csv # Archivo generado con las asignaciones
├── templates/
│ ├── subject.txt # Plantilla de asunto para los correos
│ └── body.txt # Plantilla de cuerpo para los correos
└── secret_santa.log # Archivo de registro (log)
```
#### Archivos `.env`
El archivo `.env` debe contener las variables necesarias para la autenticación SMTP. Este archivo **no debe ser subido a repositorios públicos** (asegúrate de que esté en el archivo `.gitignore`). Un ejemplo de `.env` podría ser:
```env
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=tu_correo@gmail.com
SMTP_PASS=tu_contraseña
LOG_LEVEL=INFO
```
#### Plantillas de Correo
Las plantillas para el **asunto** y el **cuerpo** del correo se deben almacenar en archivos de texto en la carpeta `templates/`. Estos archivos pueden contener variables que se reemplazarán dinámicamente con los datos de cada participante.
Ejemplo de archivo `templates/subject.txt`:
```
¡Tu Amigo Invisible es {recipient_name}!
```
Ejemplo de archivo `templates/body.txt`:
```
¡Hola {giver_name}!
Te ha tocado regalar a {recipient_name}, cuyo correo es {recipient_email}.
¡Suerte con la compra de tu regalo!
Atentamente,
El equipo de Amigo Invisible
```
## Uso
### Ejemplo Básico
Si quieres realizar el emparejamiento sin enviar correos electrónicos:
```bash
python secret_santa.py --input participants.csv --output assignments.csv
```
### Ejemplo con Correos
Si deseas enviar correos electrónicos a los participantes, usa la opción `--send`:
```bash
python secret_santa.py --input participants.csv --output assignments.csv --send --smtp-user "tu_correo@gmail.com" --smtp-pass "tu_contraseña"
```
### Personalización de Plantillas
Si prefieres personalizar el asunto y el cuerpo de los correos, puedes hacerlo editando los archivos de plantilla:
* `templates/subject.txt`
* `templates/body.txt`
Luego, solo necesitas especificar las rutas a estos archivos con las opciones `--subject-template` y `--body-template` al ejecutar el script.
## Registros
El script genera un archivo de log llamado `secret_santa.log`, donde se registran todas las actividades realizadas. Puedes revisar este archivo para obtener detalles sobre el proceso de asignación, cualquier error o advertencia, y las actividades de envío de correos.
## Excepciones y Errores
El script maneja diversas excepciones, como errores de lectura de archivo CSV, errores al enviar correos electrónicos, o si no se encuentran suficientes participantes.
Si un error ocurre, el script terminará su ejecución con un mensaje de error detallado.
## Contribuciones
Si deseas realizar mejoras o reportar problemas, por favor abre un **Issue** o envía un **Pull Request**. ¡Cualquier contribución será bienvenida!
## Licencia
Este proyecto está bajo la Licencia MIT. Ver el archivo [LICENSE](LICENSE) para más detalles.
---
Este `README` proporciona una guía completa para el uso de este script, la configuración de los parámetros y el manejo de excepciones. Si tienes más dudas o necesitas personalizar algún aspecto del script, no dudes en preguntarme. ¡Feliz organización de tu Amigo Invisible! 🎁

View File

@@ -0,0 +1 @@
python-dotenv==1.2.1

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""
secret_santa.py
Uso básico:
python secret_santa.py --input participants.csv
Opciones:
--input PATH CSV de entrada con columnas: name,email,exclusions (opcional)
--output PATH CSV de salida (por defecto assignments.csv)
--seed INT Semilla aleatoria (opcional, reproducible)
--send Enviar correos vía SMTP (ver opciones siguientes)
--smtp-server HOST Servidor SMTP (ej. smtp.gmail.com)
--smtp-port PORT Puerto SMTP (ej. 587)
--smtp-user USER Usuario SMTP
--smtp-pass PASS Contraseña SMTP (mejor usar variable de entorno)
--subject "..." Asunto del email (plantilla)
--body "..." Cuerpo (plantilla)
--max-attempts N Intentos máximos para buscar emparejamiento (por defecto 10000)
"""
import csv
import random
import argparse
import sys
import smtplib
import os
import logging
from email.message import EmailMessage
from typing import List, Dict, Tuple, Set
from dotenv import load_dotenv
# Cargar variables desde el archivo .env
load_dotenv()
# Configuración del logger con niveles detallados
logging.basicConfig(
level=logging.os.getenv('LOG_LEVEL', 'INFO').upper(),
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('secret_santa.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def read_participants(path: str) -> List[Dict]:
"""Lee el archivo CSV con los participantes y sus exclusiones."""
participants = []
try:
logger.info(f"[i] Abriendo el archivo {path} para leer los participantes.")
with open(path, newline='', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
name = row.get('name') or row.get('Name') or ''
email = row.get('email') or row.get('Email') or ''
exclusions_raw = row.get('exclusions') or row.get('Exclusions') or ''
exclusions = set()
if exclusions_raw:
for item in exclusions_raw.split(';'):
s = item.strip()
if s:
exclusions.add(s.lower())
participants.append({
'name': name.strip(),
'email': email.strip().lower(),
'exclusions': exclusions
})
logger.info(f"[i] Se leyeron {len(participants)} participantes del archivo.")
except FileNotFoundError:
logger.error(f"[i] El archivo {path} no fue encontrado.")
sys.exit(1)
except Exception as e:
logger.error(f"[!] Error al leer el archivo CSV: {e}")
sys.exit(1)
return participants
def valid_pairing(giver: Dict, recipient: Dict) -> bool:
"""Verifica si una asignación entre giver y recipient es válida."""
if giver['email'] == recipient['email']:
logger.debug(f"[!] Invalid pairing: {giver['email']} cannot give to themselves.")
return False
if recipient['email'] in giver['exclusions']:
logger.debug(f"[!] Invalid pairing: {giver['email']} cannot give to {recipient['email']} (exclusion).")
return False
if recipient['name'].strip().lower() in giver['exclusions']:
logger.debug(f"[!] Invalid pairing: {giver['email']} cannot give to {recipient['name']} (exclusion).")
return False
return True
def generate_assignments(participants: List[Dict], max_attempts: int = 10000) -> List[Tuple[Dict, Dict]]:
"""Genera las asignaciones de forma aleatoria."""
n = len(participants)
if n < 2:
logger.error("[!] Necesitas al menos 2 participantes.")
raise ValueError("Necesitas al menos 2 participantes.")
logger.info(f"[i] Generando asignaciones para {n} participantes.")
indices = list(range(n))
for attempt in range(max_attempts):
logger.debug(f"[i] Intentando emparejamiento aleatorio, intento {attempt + 1}.")
random.shuffle(indices)
ok = True
pairs = []
for i, giver in enumerate(participants):
recipient = participants[indices[i]]
if not valid_pairing(giver, recipient):
ok = False
break
pairs.append((giver, recipient))
if ok:
logger.info("[i] Asignación generada con éxito.")
return pairs
logger.error(f"[!] No fue posible encontrar una asignación válida tras {max_attempts} intentos.")
sol = backtracking_assign(participants)
if sol is None:
logger.critical("[!] No se pudo encontrar una asignación válida ni después del backtracking.")
raise RuntimeError(f"[!] No fue posible encontrar una asignación válida tras {max_attempts} intentos y búsqueda.")
return sol
def backtracking_assign(participants: List[Dict]) -> List[Tuple[Dict, Dict]] or None:
"""Intenta asignar los participantes usando backtracking."""
n = len(participants)
recipients = list(range(n))
used = [False]*n
assignment = [None]*n
allowed = []
for giver in participants:
allowed_list = [j for j, r in enumerate(participants) if valid_pairing(giver, r)]
allowed.append(allowed_list)
order = sorted(range(n), key=lambda i: len(allowed[i]))
logger.debug("[i] Comenzando búsqueda por backtracking.")
def dfs(pos):
if pos == n:
return True
i = order[pos]
for j in allowed[i]:
if not used[j]:
used[j] = True
assignment[i] = j
if dfs(pos+1):
return True
used[j] = False
assignment[i] = None
return False
if dfs(0):
logger.info("[i] Búsqueda por backtracking exitosa.")
return [(participants[i], participants[assignment[i]]) for i in range(n)]
logger.error("[!] Búsqueda por backtracking fallida.")
return None
def write_assignments_csv(pairs: List[Tuple[Dict, Dict]], path: str):
"""Escribe las asignaciones en un archivo CSV."""
try:
logger.info(f"[i] Escribiendo las asignaciones en {path}.")
with open(path, 'w', newline='', encoding='utf-8') as f:
fieldnames = ['giver_name', 'giver_email', 'recipient_name', 'recipient_email']
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for giver, recipient in pairs:
writer.writerow({
'giver_name': giver['name'],
'giver_email': giver['email'],
'recipient_name': recipient['name'],
'recipient_email': recipient['email']
})
logger.info(f"[i] Asignaciones guardadas en {path}")
except Exception as e:
logger.error(f"[!] Error escribiendo el archivo CSV: {e}")
sys.exit(1)
def send_emails(pairs: List[Tuple[Dict,Dict]], smtp_config: Dict, subject_template: str, body_template: str):
"""Envía los correos electrónicos a los participantes."""
server_host = smtp_config['server']
server_port = smtp_config['port']
user = smtp_config['user']
password = smtp_config['pass']
try:
logger.info(f"[i] Iniciando conexión con el servidor SMTP {server_host}:{server_port}.")
with smtplib.SMTP(server_host, server_port) as server:
server.starttls()
server.login(user, password)
for giver, recipient in pairs:
msg = EmailMessage()
subject = subject_template.format(
giver_name=giver['name'],
giver_email=giver['email'],
recipient_name=recipient['name'],
recipient_email=recipient['email']
)
body = body_template.format(
giver_name=giver['name'],
giver_email=giver['email'],
recipient_name=recipient['name'].upper(),
recipient_email=recipient['email']
)
msg['Subject'] = subject
msg['From'] = user or server_host
msg['To'] = giver['email']
msg.set_content(body)
server.send_message(msg)
logger.info(f"[+] Enviado a {giver['email']}")
except smtplib.SMTPAuthenticationError as e:
logger.error(f"[!] Error de autenticación SMTP: {e}")
sys.exit(1)
except Exception as e:
logger.error(f"[!] Error enviando correos: {e}")
sys.exit(1)
def read_template_file(file_path: str) -> str:
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
def main():
parser = argparse.ArgumentParser(description="Amigo Invisible - Emparejador")
parser.add_argument('--input', default='participants.csv', help='CSV de participantes (name,email,exclusions)')
parser.add_argument('--output', default='assignments.csv', help='CSV de salida')
parser.add_argument('--seed', type=int, help='Semilla aleatoria')
parser.add_argument('--send', action='store_true', default=True, help='Enviar correos vía SMTP')
parser.add_argument('--smtp-server', default=os.getenv('SMTP_SERVER', 'smtp.gmail.com'), help='Servidor SMTP')
parser.add_argument('--smtp-port', type=int, default=int(os.getenv('SMTP_PORT', 587)), help='Puerto SMTP')
parser.add_argument('--smtp-user', default=os.getenv('SMTP_USER'), help='Usuario SMTP')
parser.add_argument('--smtp-pass', default=os.getenv('SMTP_PASS'), help='Contraseña SMTP')
parser.add_argument('--subject-template', default='templates/subject.txt', help='Ruta del archivo de asunto')
parser.add_argument('--body-template', default='templates/body.txt', help='Ruta del archivo de cuerpo')
parser.add_argument('--max-attempts', type=int, default=10000, help='Max intentos aleatorios antes de fallback')
args = parser.parse_args()
if args.seed is not None:
random.seed(args.seed)
subject_template = read_template_file(args.subject_template)
body_template = read_template_file(args.body_template)
logger.info("[i] Iniciando el proceso de emparejamiento de amigos invisibles.")
participants = read_participants(args.input)
if len(participants) == 0:
logger.error("[!] No se encontraron participantes en el CSV.")
sys.exit(1)
try:
pairs = generate_assignments(participants, max_attempts=args.max_attempts)
except Exception as e:
logger.error(f"[!] Error generando emparejamientos: {e}")
sys.exit(1)
write_assignments_csv(pairs, args.output)
if args.send:
smtp_config = {
'server': args.smtp_server,
'port': args.smtp_port,
'user': args.smtp_user,
'pass': args.smtp_pass
}
logger.info("[i] Enviando correos...")
send_emails(pairs, smtp_config, subject_template, body_template)
logger.info("[i] Envío finalizado.")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,26 @@
¡Hola **{giver_name}**! 😎
Manu ha creado esta fabulosa app para enviarte el resultado del sorteo del **"Amigo INVISIBLE" edición 2025/2026**.
El regalo se entregará la próxima noche del **24 de diciembre de 2025** y tendrá un valor superior a **100 €**.
Por favor, **NO SE LO DIGAS A NADIE**. Te ha tocado hacerle un regalo a **nada más y nada menos que a...**
⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️
✨ **{recipient_name}** ✨
📧 {recipient_email}
⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️
---
**CONSEJOS:**
- **No se lo digas a nadie** 🤐
- Aprovecha el **Black Friday** 🏴 [https://amzn.to/4osrLQL](https://amzn.to/4osrLQL)
- Da **pistas** de los regalos que quieres por si te escuchan 🕵️‍♀️
- **Busca algo original**, ¡y que no huela a “último minuto”! 😂 **Intenta regalar cosas útiles**
Espero que entiendas que si **revelas tu amigo invisible** no tiene gracia. 🙏 **COMPRA TU EL REGALO** y **no envíes a nadie a por él**.
---
¡Felices fiestas y que viva el misterio del **Amigo Invisible**! 🎅

View File

@@ -0,0 +1 @@
[Navidades 2025/2026] 🎁 {giver_name}, ¡Correo con tu AMIGO INVISIBLE!