Ficha por pasos, cita visible y recordatorio por correo al invitado
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSyR5Zw5eGj6oPTRVjvP1b
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
caecb951d8
commit
f5f16ac55b
@@ -6,6 +6,7 @@ Configuración solo por variables de entorno (nunca en el repo):
|
||||
No guarda los mensajes en disco: solo los reenvía por correo.
|
||||
"""
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
@@ -50,14 +51,14 @@ def rate_ok(ip):
|
||||
return True
|
||||
|
||||
|
||||
def send(subject, reply_to, body):
|
||||
def send(subject, reply_to, body, to=None):
|
||||
host, user, pwd = os.environ.get('SMTP_HOST'), os.environ.get('SMTP_USER'), os.environ.get('SMTP_PASS')
|
||||
if not (host and user and pwd and os.environ.get('MAIL_TO')):
|
||||
raise RuntimeError('SMTP no configurado')
|
||||
msg = EmailMessage()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = os.environ.get('MAIL_FROM', user)
|
||||
msg['To'] = os.environ['MAIL_TO']
|
||||
msg['To'] = to or os.environ['MAIL_TO']
|
||||
if reply_to:
|
||||
msg['Reply-To'] = reply_to
|
||||
msg.set_content(body)
|
||||
@@ -109,6 +110,16 @@ def limited(bucket, ip, maxn, window=3600):
|
||||
return False
|
||||
|
||||
|
||||
confirmados = set()
|
||||
DIAS = ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo']
|
||||
MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre']
|
||||
|
||||
|
||||
def cita_txt(ts):
|
||||
d = datetime.datetime.fromtimestamp(ts, ZoneInfo('Europe/Madrid'))
|
||||
return f'{DIAS[d.weekday()]} {d.day} de {MESES[d.month - 1]} de {d.year}, a las {d:%H:%M}'
|
||||
|
||||
|
||||
NAME_RE = re.compile(r"^[^\W\d_][\w .'\-]{1,59}$", re.UNICODE)
|
||||
|
||||
|
||||
@@ -118,11 +129,12 @@ def invitado(n, f):
|
||||
if not NAME_RE.match(n):
|
||||
return None
|
||||
try:
|
||||
dia = datetime.datetime.fromtimestamp(int(str(f).strip()), datetime.timezone.utc).date()
|
||||
ts = int(str(f).strip())
|
||||
dia = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).date()
|
||||
except (ValueError, OverflowError, OSError):
|
||||
return None
|
||||
delta = (dia - datetime.date.today()).days
|
||||
return n if -10 <= delta <= 14 else None
|
||||
return (n, ts) if -10 <= delta <= 14 else None
|
||||
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
@@ -149,8 +161,8 @@ class H(BaseHTTPRequestHandler):
|
||||
if limited(check_hits, self.client_ip(), 40):
|
||||
return self.reply(429, {'ok': False})
|
||||
q = parse_qs(urlparse(self.path).query)
|
||||
nombre = invitado(q.get('n', [''])[0], q.get('f', [''])[0])
|
||||
return self.reply(200, {'ok': True, 'nombre': nombre} if nombre else {'ok': False})
|
||||
inv = invitado(q.get('n', [''])[0], q.get('f', [''])[0])
|
||||
return self.reply(200, {'ok': True, 'nombre': inv[0], 'cita': inv[1]} if inv else {'ok': False})
|
||||
|
||||
def invitados(self):
|
||||
try:
|
||||
@@ -164,14 +176,15 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.reply(200, {'ok': True}) # trampa o demasiado rápido: no se envía
|
||||
if limited(guest_hits, self.client_ip(), 8):
|
||||
return self.reply(429, {'ok': False, 'error': 'Demasiados envíos. Inténtalo más tarde.'})
|
||||
nombre = invitado(d.get('n'), d.get('f'))
|
||||
inv = invitado(d.get('n'), d.get('f'))
|
||||
nombre, ts = inv if inv else (None, None)
|
||||
if not nombre:
|
||||
return self.reply(403, {'ok': False, 'error': 'Enlace no válido o caducado.'})
|
||||
if d.get('consent') is not True:
|
||||
return self.reply(400, {'ok': False, 'error': 'Debes aceptar las condiciones de participación.'})
|
||||
resp = d.get('resp') if isinstance(d.get('resp'), dict) else {}
|
||||
email = clean(d.get('email'), 200)
|
||||
if email and not EMAIL_RE.match(email):
|
||||
if not email or not EMAIL_RE.match(email):
|
||||
return self.reply(400, {'ok': False, 'error': 'El correo no parece válido.'})
|
||||
lineas, seccion = [], None
|
||||
for q in GUEST_Q:
|
||||
@@ -183,7 +196,7 @@ class H(BaseHTTPRequestHandler):
|
||||
lineas += ['', f'== {seccion} ==']
|
||||
lineas += ['', f"• {q['t']}", ' ' + (ans.replace('\n', '\n ') if ans else '(sin respuesta)')]
|
||||
cabecera = [f'Ficha del invitado: {nombre}', 'Recibida: ' + time.strftime('%d/%m/%Y %H:%M', time.localtime()),
|
||||
f'Correo (opcional): {email or "—"}',
|
||||
f'Cita: {cita_txt(ts)}', f'Correo del invitado: {email}',
|
||||
'Aceptó las condiciones de participación y la política de privacidad: sí']
|
||||
try:
|
||||
send(f'[Invitado] {clean(nombre, 80)} · ficha del podcast', email, '\n'.join(cabecera + lineas) + '\n')
|
||||
@@ -192,6 +205,18 @@ class H(BaseHTTPRequestHandler):
|
||||
except Exception as exc:
|
||||
print('error enviando ficha:', type(exc).__name__, file=sys.stderr, flush=True)
|
||||
return self.reply(502, {'ok': False, 'error': 'No se pudo enviar. Inténtalo más tarde.'})
|
||||
key = (nombre, ts)
|
||||
if key not in confirmados: # un solo recordatorio por invitado y cita
|
||||
confirmados.add(key)
|
||||
try:
|
||||
send('Tu cita en Anda Que No Sabes Ná', None,
|
||||
f'Hola, {nombre}:\n\nGracias por rellenar tu ficha. Te recordamos tu cita en el podcast:\n\n'
|
||||
f' {cita_txt(ts)} (hora de Madrid)\n\n'
|
||||
'Si no puedes venir o necesitas cambiar algo, responde a este correo.\n\n'
|
||||
'Un abrazo,\nÁlex y Juan\nAnda Que No Sabes Ná\nhttps://andaquenosabesna.com\n', to=email)
|
||||
except Exception as exc:
|
||||
confirmados.discard(key)
|
||||
print('error enviando recordatorio:', type(exc).__name__, file=sys.stderr, flush=True)
|
||||
self.reply(200, {'ok': True})
|
||||
|
||||
def do_POST(self):
|
||||
|
||||
Reference in New Issue
Block a user