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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user