fase 2.4: recordatorios y notificaciones automaticas (modelo+cola+bitacora, cron cada 5 min, recordatorios por ventana, confirmacion, cumpleanos, post-visita, recuperacion de no-show, modo prueba, plantillas con glosario, historial y reintentos); apagado por defecto

This commit is contained in:
2026-10-11 09:05:19 +00:00
parent 9257586943
commit dabd8c6bd7
16 changed files with 1832 additions and 12 deletions

View File

@@ -11,6 +11,7 @@ cambiar lo que ya se ve en producción cuando el parámetro no está guardado.
"""
import json
import re
from urllib.parse import urlparse
SETTINGS_KEY = 'skeen.clinic.settings'
@@ -262,6 +263,222 @@ def get_reservas_online(env):
"""Bloque de reservas en línea vigente (copia). Fuente única para la API."""
return normalizar_reservas_online(get_settings(env).get('reservas_online'))
# ---------------------------------------------------------------------------
# Recordatorios y notificaciones automáticas de citas.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos` y
# `reservas_online`. La regla de oro: con `activo=False` NADA se envía ni se
# encola (cero cambios de comportamiento y cero filas nuevas); por eso el
# default es apagado. `modo_prueba=True` (default) es la segunda llave: las
# notificaciones se registran como "simuladas" y no se llama a ningún proveedor
# externo, de modo que se puede probar sin credenciales reales.
#
# activo: enciende recordatorios y notificaciones
# modo_prueba: no envía nada real; solo registra "simulada"
# canal: whatsapp | email | ambos
# recordatorio_horas_antes: ventanas (0-168 h) en que se avisa antes de la cita
# confirmacion_al_crear: avisar al paciente al reservar en línea
# cumpleanos: felicitación el día del cumpleaños
# hora_envio_cumpleanos: hora (HH:MM) a partir de la cual se felicita
# post_visita_horas: horas tras la visita para el mensaje de seguimiento
# recuperacion_no_show: mensaje a quien no se presentó
# plantilla_*: textos con variables {nombre} {cita} {servicio}...
# whatsapp_numero: número de la clínica que firma los mensajes
# email_remitente: remitente de los correos
# ---------------------------------------------------------------------------
NOTIFICACIONES_DEFAULT = {
'activo': False,
'modo_prueba': True,
'canal': 'whatsapp',
'recordatorio_horas_antes': [24, 2],
'confirmacion_al_crear': False,
'cumpleanos': False,
'hora_envio_cumpleanos': '09:00',
'post_visita_horas': 0,
'recuperacion_no_show': False,
'plantilla_recordatorio': (
'Hola {nombre}, te recordamos tu {cita} de {servicio} el {fecha} a las '
'{hora} con {doctor} en {sucursal}. Responde CONFIRMAR o CANCELAR.'
),
'plantilla_confirmacion': (
'Hola {nombre}, tu {cita} de {servicio} quedó registrada para el {fecha} '
'a las {hora} con {doctor} en {sucursal}. Te esperamos.'
),
'plantilla_cumpleanos': (
'¡Feliz cumpleaños, {nombre}! Todo el equipo de {sucursal} te desea un '
'gran día. Te esperamos pronto.'
),
'plantilla_post_visita': (
'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes '
'dudas o quieres agendar de nuevo, llámanos al {telefono}.'
),
'whatsapp_numero': '',
'email_remitente': '',
}
NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
'sucursal', 'telefono')
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
'plantilla_cumpleanos', 'plantilla_post_visita')
NOTIF_HORAS_MAX = 168 # 1 semana
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
# Variables reconocidas dentro de una plantilla: {nombre}, {cita}, ...
_VARIABLE_RE = re.compile(r'\{([a-zA-Z_]+)\}')
def horas_recordatorio_validas(valor, por_defecto=None):
"""Lista de horas de recordatorio (enteros 0-168, sin repetir, ordenada)."""
if valor is None:
return list(por_defecto or NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
if not isinstance(valor, (list, tuple)):
return list(por_defecto or NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
limpias = []
for v in valor:
try:
n = int(v)
except (TypeError, ValueError):
continue
if isinstance(v, bool) or not (0 <= n <= NOTIF_HORAS_MAX):
continue
if n not in limpias:
limpias.append(n)
return sorted(limpias) if limpias else list(por_defecto or [])
def _hhmm_valida(valor, por_defecto='09:00'):
"""Normaliza 'HH:MM'. Devuelve el default si el formato no es válido."""
partes = str(valor or '').strip().split(':')
if len(partes) != 2:
return por_defecto
try:
h, m = int(partes[0]), int(partes[1])
except (TypeError, ValueError):
return por_defecto
if not (0 <= h <= 23 and 0 <= m <= 59):
return por_defecto
return '%02d:%02d' % (h, m)
def variables_de_plantilla(texto):
"""Variables {x} usadas en una plantilla (sin repetir, en orden)."""
usadas = []
for m in _VARIABLE_RE.finditer(str(texto or '')):
if m.group(1) not in usadas:
usadas.append(m.group(1))
return usadas
def normalizar_notificaciones(raw):
"""Bloque `notificaciones` completo, con defaults. Tolerante: nunca lanza."""
out = dict(NOTIFICACIONES_DEFAULT)
out['recordatorio_horas_antes'] = list(NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
if not isinstance(raw, dict):
return out
for k in ('activo', 'modo_prueba', 'confirmacion_al_crear', 'cumpleanos',
'recuperacion_no_show'):
out[k] = _bool(raw.get(k), NOTIFICACIONES_DEFAULT[k])
canal = str(raw.get('canal') or '').strip().lower()
out['canal'] = canal if canal in NOTIF_CANALES else NOTIFICACIONES_DEFAULT['canal']
out['recordatorio_horas_antes'] = horas_recordatorio_validas(
raw.get('recordatorio_horas_antes'))
out['hora_envio_cumpleanos'] = _hhmm_valida(
raw.get('hora_envio_cumpleanos'), NOTIFICACIONES_DEFAULT['hora_envio_cumpleanos'])
out['post_visita_horas'] = min(max(_int(raw.get('post_visita_horas'),
NOTIFICACIONES_DEFAULT['post_visita_horas']),
0), NOTIF_HORAS_MAX)
for k in NOTIF_PLANTILLAS:
texto = raw.get(k)
if texto is None:
continue
out[k] = str(texto)[:NOTIF_PLANTILLA_MAX]
out['whatsapp_numero'] = str(raw.get('whatsapp_numero') or '').strip()[:NOTIF_TEXTO_MAX]
out['email_remitente'] = str(raw.get('email_remitente') or '').strip()[:NOTIF_TEXTO_MAX]
return out
def validar_notificaciones(raw):
"""Valida el bloque `notificaciones` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de notificaciones debe ser un objeto')
out = {}
for k in ('activo', 'modo_prueba', 'confirmacion_al_crear', 'cumpleanos',
'recuperacion_no_show'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'canal' in raw:
canal = str(raw['canal'] or '').strip().lower()
if canal not in NOTIF_CANALES:
raise ValueError('El canal debe ser whatsapp, email o ambos')
out['canal'] = canal
if 'recordatorio_horas_antes' in raw:
valor = raw['recordatorio_horas_antes']
if not isinstance(valor, list) or not valor:
raise ValueError('Las horas de recordatorio deben ser una lista')
horas = []
for v in valor:
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Cada ventana de recordatorio debe ser un número entero de horas')
if not (0 <= v <= NOTIF_HORAS_MAX):
raise ValueError('Las horas de recordatorio deben estar entre 0 y %d' % NOTIF_HORAS_MAX)
if v not in horas:
horas.append(v)
out['recordatorio_horas_antes'] = sorted(horas)
if 'hora_envio_cumpleanos' in raw:
partes = str(raw['hora_envio_cumpleanos'] or '').strip().split(':')
valido = False
if len(partes) == 2:
try:
h, m = int(partes[0]), int(partes[1])
valido = 0 <= h <= 23 and 0 <= m <= 59
except (TypeError, ValueError):
valido = False
if not valido:
raise ValueError('La hora de cumpleaños debe tener el formato HH:MM')
out['hora_envio_cumpleanos'] = _hhmm_valida(raw['hora_envio_cumpleanos'])
if 'post_visita_horas' in raw:
if isinstance(raw['post_visita_horas'], bool) or not isinstance(raw['post_visita_horas'], int):
raise ValueError('Las horas de post-visita deben ser un número entero')
if not (0 <= raw['post_visita_horas'] <= NOTIF_HORAS_MAX):
raise ValueError('Las horas de post-visita deben estar entre 0 y %d' % NOTIF_HORAS_MAX)
out['post_visita_horas'] = raw['post_visita_horas']
for k in NOTIF_PLANTILLAS:
if k not in raw:
continue
if not isinstance(raw[k], str):
raise ValueError('El campo %s debe ser texto' % k)
if len(raw[k]) > NOTIF_PLANTILLA_MAX:
raise ValueError('La plantilla es demasiado larga (máx. %d caracteres)' % NOTIF_PLANTILLA_MAX)
desconocidas = [v for v in variables_de_plantilla(raw[k]) if v not in NOTIF_VARIABLES]
if desconocidas:
raise ValueError('Variable no permitida en la plantilla: {%s}. Permitidas: %s'
% (desconocidas[0], ', '.join('{%s}' % v for v in NOTIF_VARIABLES)))
out[k] = raw[k]
for k in ('whatsapp_numero', 'email_remitente'):
if k in raw:
if not isinstance(raw[k], str):
raise ValueError('El campo %s debe ser texto' % k)
if len(raw[k].strip()) > NOTIF_TEXTO_MAX:
raise ValueError('El campo %s es demasiado largo (máx. %d)' % (k, NOTIF_TEXTO_MAX))
out[k] = raw[k].strip()
remitente = out.get('email_remitente')
if remitente and '@' not in remitente:
raise ValueError('El correo remitente no es válido')
return out
def get_notificaciones(env):
"""Bloque de notificaciones vigente (copia). Fuente única para el motor."""
return normalizar_notificaciones(get_settings(env).get('notificaciones'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -303,6 +520,7 @@ def get_settings(env):
'nota_ticket': '',
'impuestos': dict(IMPUESTOS_DEFAULT),
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
'notificaciones': normalizar_notificaciones(None),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -316,6 +534,7 @@ def get_settings(env):
out[k] = str(guardado[k])
out['impuestos'] = normalizar_impuestos(guardado.get('impuestos'))
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
return out
@@ -336,6 +555,10 @@ def set_settings(env, vals):
bloque = validar_reservas_online(vals['reservas_online'])
if bloque is not None:
actual['reservas_online'] = {**actual['reservas_online'], **bloque}
if 'notificaciones' in vals:
bloque = validar_notificaciones(vals['notificaciones'])
if bloque is not None:
actual['notificaciones'] = {**actual['notificaciones'], **bloque}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)