Files
SKEEN-Proyecto/odoo-addons/skeen_whatsapp/models/notificacion.py

636 lines
27 KiB
Python

# -*- coding: utf-8 -*-
"""Recordatorios y notificaciones automáticas de citas.
Motor único, apagado por defecto: con `notificaciones.activo=False` (el default)
no se encola ni se envía absolutamente nada. Con `modo_prueba=True` (también el
default) las notificaciones se registran como "simuladas": son visibles en el
historial, pero no se llama a ningún proveedor externo. Solo cuando la clínica
apaga el modo prueba se intenta el envío real (WhatsApp vía WACRM o correo vía
`mail.mail`).
Cada intento queda registrado en `skeen.notificacion`: nada se envía en
silencio. La idempotencia va por `clave_unica` (tipo + cita/paciente + ventana +
canal), así reprocesar no duplica.
Términos: los nombres de los términos se toman del glosario vigente (p. ej. con
el pack `spa` el mensaje dice "reserva" y no "cita").
"""
import html
import logging
import os
import re
import uuid
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import requests
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.addons.skeen_citas.clinic_settings import (
get_notificaciones as clinic_get_notificaciones,
get_settings as clinic_get_settings,
)
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
_logger = logging.getLogger(__name__)
TIPOS = [
('recordatorio', 'Recordatorio'),
('confirmacion', 'Confirmación'),
('cumpleanos', 'Cumpleaños'),
('post_visita', 'Post-visita'),
('recuperacion', 'Recuperación'),
('formulario', 'Formulario'),
('renovacion_membresia', 'Renovación de membresía'),
('cobro_membresia', 'Cobro de membresía'),
('acceso_codigo', 'Código de acceso al portal'),
('acceso_enlace', 'Enlace de acceso al portal'),
('manual', 'Manual'),
]
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
ESTADOS = [
('pendiente', 'Pendiente'),
('enviada', 'Enviada'),
('simulada', 'Simulada'),
('error', 'Error'),
('cancelada', 'Cancelada'),
]
# Plantilla del bloque de ajustes que usa cada tipo.
PLANTILLA_POR_TIPO = {
'recordatorio': 'plantilla_recordatorio',
'confirmacion': 'plantilla_confirmacion',
'cumpleanos': 'plantilla_cumpleanos',
'post_visita': 'plantilla_post_visita',
# La recuperación de no-show reutiliza la plantilla de recordatorio: el
# bloque `notificaciones` solo define cuatro plantillas (recordatorio,
# confirmación, cumpleaños y post-visita).
'recuperacion': 'plantilla_recordatorio',
# El aviso de un formulario digital usa su propia plantilla con {enlace}.
'formulario': 'plantilla_formulario',
# Aviso de renovación/cobro de una membresía (bloque `membresias`).
'renovacion_membresia': 'plantilla_renovacion_membresia',
# Aviso con enlace de pago del periodo (cobro automático de membresías).
'cobro_membresia': 'plantilla_cobro_membresia',
# Acceso a la cuenta del cliente (portal): código de 6 dígitos o enlace.
'acceso_codigo': 'plantilla_codigo_acceso',
'acceso_enlace': 'plantilla_enlace_acceso',
'manual': 'plantilla_recordatorio',
}
ETIQUETA_TIPO = dict(TIPOS)
MAX_INTENTOS = 3
BACKOFF_MIN = (5, 30, 120) # minutos de espera entre reintentos
VENTANA_BUSQUEDA_DIAS = 8 # margen de citas futuras que revisa el cron
RECUPERACION_HORAS = 48 # antigüedad máxima de un no-show a recuperar
POST_VISITA_DIAS = 7 # antigüedad máxima de una visita para seguimiento
PROCESAR_POR_DEFECTO = 100
WHATSAPP_API_PATH = '/api/v1/messages'
class SkeenNotificacion(models.Model):
_name = 'skeen.notificacion'
_description = 'Notificación de SKEEN'
_order = 'programada_para desc, id desc'
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
index=True, ondelete='cascade')
cita_id = fields.Many2one('skeen.cita', string='Cita', index=True,
ondelete='cascade')
tipo = fields.Selection(TIPOS, string='Tipo', required=True, index=True)
canal = fields.Selection(CANALES, string='Canal', required=True, index=True)
estado = fields.Selection(ESTADOS, string='Estado', required=True,
default='pendiente', index=True)
asunto = fields.Char(string='Asunto')
mensaje = fields.Text(string='Mensaje')
programada_para = fields.Datetime(string='Programada para', required=True,
default=fields.Datetime.now, index=True)
enviada_at = fields.Datetime(string='Enviada el')
error = fields.Text(string='Error')
intentos = fields.Integer(string='Intentos', default=0)
clave_unica = fields.Char(string='Clave única', required=True, index=True)
_sql_constraints = [
('clave_unica_uniq', 'unique(clave_unica)',
'Ya existe una notificación con esa clave (evita duplicados).'),
]
def action_reintentar(self):
"""Devuelve a la cola una notificación fallida. Nunca hace envío real."""
for rec in self:
if rec.estado in ('enviada', 'simulada', 'cancelada'):
continue
rec.write({'estado': 'pendiente', 'error': False,
'programada_para': fields.Datetime.now()})
return True
class SkeenNotificacionService(models.AbstractModel):
"""Motor de envío: render de plantillas, encolado, procesamiento y envío."""
_name = 'skeen.notificacion.service'
_description = 'Servicio de notificaciones de SKEEN'
# ------------------------------------------------------------------
# Configuración y plantillas
# ------------------------------------------------------------------
@api.model
def _cfg(self):
return clinic_get_notificaciones(self.env)
@api.model
def render(self, plantilla, cita=None, partner=None, cfg=None, extra=None):
"""Sustituye las variables de una plantilla con datos reales.
Los nombres de los términos (`{cita}`) salen del glosario vigente; las
demás variables son los datos de la cita y del paciente. Una variable sin
dato queda como cadena vacía. `extra` añade variables puntuales (p. ej.
`{enlace}` y `{formulario}` para el aviso de un formulario digital).
"""
cfg = cfg or self._cfg()
settings = clinic_get_settings(self.env)
try:
_pack, terms = resolver_glosario(self.env)
except Exception:
terms = {}
cita = cita if cita and cita.exists() else None
partner = partner or (cita.partner_id if cita else None)
contexto = {
'nombre': partner.name if partner else '',
'cita': terms.get('appointment_singular', 'Cita'),
'servicio': '',
'fecha': '',
'hora': '',
'doctor': '',
'sucursal': '',
'telefono': settings.get('telefono') or cfg.get('whatsapp_numero') or '',
}
if cita is not None:
contexto['servicio'] = cita.servicio_id.name if cita.servicio_id else ''
contexto['fecha'] = cita.date.strftime('%d/%m/%Y') if cita.date else ''
contexto['hora'] = cita.time_str or ''
contexto['doctor'] = cita.doctor_id.name if cita.doctor_id else ''
contexto['sucursal'] = self._sucursal_nombre(cita)
elif partner is not None:
contexto['fecha'] = fields.Date.today().strftime('%d/%m/%Y')
contexto['sucursal'] = self._sucursal_default()
if extra:
for clave, valor in extra.items():
contexto[clave] = '' if valor is None else str(valor)
texto = str(plantilla or '')
for clave, valor in contexto.items():
texto = texto.replace('{%s}' % clave, str(valor or ''))
return texto
@api.model
def _sucursal_nombre(self, cita):
if not cita.branch:
return self._sucursal_default()
sucursal = self.env['skeen.sucursal'].sudo().search(
[('code', '=', cita.branch)], limit=1)
return sucursal.name or cita.branch
@api.model
def _sucursal_default(self):
"""Sucursal de la clínica para mensajes sin cita (p. ej. cumpleaños)."""
Sucursal = self.env['skeen.sucursal'].sudo()
sucursal = Sucursal.search([('is_default', '=', True)], limit=1) \
or Sucursal.search([('active', '=', True)], order='sequence, name', limit=1)
return sucursal.name or ''
@api.model
def _canales(self, cfg):
canal = cfg.get('canal')
if canal == 'ambos':
return ['whatsapp', 'email']
return [canal] if canal in ('whatsapp', 'email') else ['whatsapp']
@api.model
def _destino_disponible(self, partner, canal):
if partner is None:
return False
if canal == 'whatsapp':
return bool((partner.whatsapp or partner.phone or '').strip())
return bool((partner.email or '').strip())
@api.model
def _clave(self, tipo, cita, partner, canal, ventana=None):
if tipo == 'cumpleanos':
base = 'p%s:%s' % (partner.id, fields.Date.today().year)
elif cita is not None:
base = 'c%s' % cita.id
else:
base = 'p%s' % (partner.id if partner else 0)
partes = [tipo, base]
if ventana not in (None, ''):
partes.append(str(ventana))
partes.append(canal)
return ':'.join(partes)
# ------------------------------------------------------------------
# Encolado
# ------------------------------------------------------------------
@api.model
def encolar(self, cita=None, tipo='recordatorio', cuando=None, canal=None,
partner=None, ventana=None, clave_unica=None, asunto=None,
contexto_extra=None):
"""Crea las notificaciones pendientes de una cita (una por canal).
Con `activo=False` no hace nada. No duplica: si ya existe la misma
`clave_unica` (misma cita + tipo + ventana + canal) se salta. Devuelve el
recordset de lo creado. `contexto_extra` alimenta variables adicionales
del render (p. ej. `{enlace}` del formulario digital).
"""
Notif = self.env['skeen.notificacion'].sudo()
cfg = self._cfg()
if not cfg['activo']:
return Notif
partner = partner or (cita.partner_id if cita else None)
if not partner:
return Notif
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
if not plantilla.strip():
return Notif
mensaje = self.render(plantilla, cita=cita, partner=partner, cfg=cfg,
extra=contexto_extra)
if asunto is None:
asunto = self._asunto(tipo)
canales = [canal] if canal else self._canales(cfg)
creadas = Notif
for ch in canales:
if not self._destino_disponible(partner, ch):
continue
clave = clave_unica or self._clave(tipo, cita, partner, ch, ventana)
if clave_unica and len(canales) > 1:
clave = '%s:%s' % (clave_unica, ch)
if Notif.search([('clave_unica', '=', clave)], limit=1):
continue
creadas |= Notif.create({
'partner_id': partner.id,
'cita_id': cita.id if cita is not None else False,
'tipo': tipo,
'canal': ch,
'estado': 'pendiente',
'asunto': asunto,
'mensaje': mensaje,
'programada_para': cuando or fields.Datetime.now(),
'clave_unica': clave,
})
return creadas
@api.model
def _asunto(self, tipo):
nombre = clinic_get_settings(self.env).get('nombre') or ''
etiqueta = ETIQUETA_TIPO.get(tipo, tipo)
return ('%s - %s' % (etiqueta, nombre)).strip(' -')
@api.model
def encolar_manual(self, cita, canal=None):
"""Encola y envía un mensaje puntual (botón "enviar ahora").
Devuelve el recordset. `clave_unica` lleva un identificador único para
permitir envíos repetidos a propósito.
"""
clave = 'manual:c%s:%s' % (cita.id, uuid.uuid4().hex[:12])
return self.encolar(cita=cita, tipo='manual', cuando=fields.Datetime.now(),
canal=canal, clave_unica=clave)
# ------------------------------------------------------------------
# Motor del cron
# ------------------------------------------------------------------
@api.model
def ejecutar(self, limite=PROCESAR_POR_DEFECTO):
"""Ciclo completo: encola lo que toca y procesa la cola pendiente.
Con `activo=False` regresa sin tocar nada (cero filas, cero envíos).
"""
resultado = {'encoladas': 0, 'revisadas': 0, 'enviadas': 0,
'simuladas': 0, 'fallidas': 0, 'canceladas': 0}
cfg = self._cfg()
if not cfg['activo']:
return resultado
encoladas = self._encolar_ventanas(cfg)
encoladas += self._encolar_post_visita(cfg)
if cfg['cumpleanos'] and self._hora_cumpleanos_llegada(cfg):
encoladas += self._encolar_cumpleanos(cfg)
if cfg['recuperacion_no_show']:
encoladas += self._encolar_recuperaciones(cfg)
resultado['encoladas'] = encoladas
res = self.procesar_pendientes(limite=limite)
resultado.update(res)
if encoladas or res.get('revisadas'):
_logger.info('SKEEN notificaciones: %s', resultado)
return resultado
@api.model
def _cron_tick(self):
"""Entrada del ir.cron (cada 5 minutos)."""
return self.ejecutar()
@api.model
def _encolar_ventanas(self, cfg):
"""Recordatorios de citas que entran en cada ventana configurada.
Una ventana `h` está vencida cuando faltan `h` horas o menos para la
cita. La idempotencia por `clave_unica` evita que el cron (cada 5 min)
repita el mismo aviso.
"""
now = fields.Datetime.now()
limite = now + timedelta(days=VENTANA_BUSQUEDA_DIAS)
citas = self.env['skeen.cita'].sudo().search([
('state', 'in', ('pending', 'confirmed')),
('datetime_start', '>=', now),
('datetime_start', '<=', limite),
])
creadas = 0
for cita in citas:
restante = (cita.datetime_start - now).total_seconds() / 3600.0
for horas in cfg['recordatorio_horas_antes']:
if restante <= horas:
creadas += len(self.encolar(cita, 'recordatorio',
cuando=now, ventana=horas))
return creadas
@api.model
def _hora_cumpleanos_llegada(self, cfg):
"""True si ya pasó la hora local configurada para felicitar."""
try:
tz = ZoneInfo(clinic_get_settings(self.env).get('zona_horaria')
or 'America/Tijuana')
except Exception:
tz = ZoneInfo('America/Tijuana')
local = datetime.now(tz)
return local.strftime('%H:%M') >= (cfg.get('hora_envio_cumpleanos') or '09:00')
@api.model
def _encolar_cumpleanos(self, cfg):
hoy = fields.Date.today()
pacientes = self.env['res.partner'].sudo().search([
('is_patient', '=', True),
('birth_date', '!=', False),
])
now = fields.Datetime.now()
creadas = 0
for p in pacientes:
try:
ocurre = p.birth_date.replace(year=hoy.year)
except ValueError: # 29-feb en año no bisiesto
ocurre = p.birth_date.replace(year=hoy.year, day=28)
if (ocurre.month, ocurre.day) == (hoy.month, hoy.day):
creadas += len(self.encolar(partner=p, tipo='cumpleanos',
cuando=now))
return creadas
@api.model
def _encolar_recuperaciones(self, cfg):
now = fields.Datetime.now()
desde = now - timedelta(hours=RECUPERACION_HORAS)
citas = self.env['skeen.cita'].sudo().search([
('state', '=', 'no_show'),
('datetime_start', '>=', desde),
('datetime_start', '<=', now),
])
return sum(len(self.encolar(c, 'recuperacion', cuando=now)) for c in citas)
@api.model
def _encolar_post_visita(self, cfg):
horas = cfg.get('post_visita_horas') or 0
if horas <= 0:
return 0
now = fields.Datetime.now()
limite = now - timedelta(hours=horas)
desde = now - timedelta(days=POST_VISITA_DIAS)
citas = self.env['skeen.cita'].sudo().search([
('state', '=', 'done'),
('datetime_end', '<=', limite),
('datetime_end', '>=', desde),
])
return sum(len(self.encolar(c, 'post_visita', cuando=now)) for c in citas)
# ------------------------------------------------------------------
# Procesamiento de la cola
# ------------------------------------------------------------------
@api.model
def procesar_pendientes(self, limite=PROCESAR_POR_DEFECTO):
"""Intenta enviar las notificaciones pendientes ya vencidas.
Un fallo incrementa `intentos` (con backoff) y NO detiene la cola: cada
registro se procesa en su propio try/except. Con `modo_prueba=True` se
marca `simulada` sin llamar a ningún proveedor.
"""
resultado = {'revisadas': 0, 'enviadas': 0, 'simuladas': 0,
'fallidas': 0, 'canceladas': 0}
cfg = self._cfg()
if not cfg['activo']:
return resultado
ahora = fields.Datetime.now()
pendientes = self.env['skeen.notificacion'].sudo().search([
('estado', '=', 'pendiente'),
('programada_para', '<=', ahora),
], order='programada_para asc, id asc', limit=limite)
resultado['revisadas'] = len(pendientes)
for notif in pendientes:
try:
estado = self._intentar(notif, cfg)
except Exception as exc: # nunca tumbar la cola
_logger.exception('Notificación %s: error inesperado', notif.id)
notif.write({'estado': 'error', 'error': str(exc)[:500]})
estado = 'fallida'
resultado[{
'enviada': 'enviadas',
'simulada': 'simuladas',
'fallida': 'fallidas',
'cancelada': 'canceladas',
}.get(estado, 'fallidas')] += 1
return resultado
@api.model
def _intentar(self, notif, cfg):
"""Intenta una notificación. Devuelve el desenlace en texto."""
cita = notif.cita_id
if cita and notif.tipo in ('recordatorio', 'confirmacion', 'post_visita'):
if cita.state == 'cancelled' or (
notif.tipo == 'recordatorio'
and cita.state in ('arrived', 'in_progress', 'done', 'no_show')):
notif.write({'estado': 'cancelada',
'error': 'La cita ya no está pendiente'})
return 'cancelada'
if cfg['modo_prueba']:
notif.write({'estado': 'simulada', 'enviada_at': fields.Datetime.now(),
'intentos': notif.intentos + 1, 'error': False})
return 'simulada'
try:
if notif.canal == 'whatsapp':
self.enviar_whatsapp(notif.partner_id, notif.mensaje, cfg=cfg)
else:
self.enviar_email(notif.partner_id, notif.asunto or 'Notificación',
notif.mensaje, cfg=cfg)
except Exception as exc:
intentos = notif.intentos + 1
vals = {'intentos': intentos, 'error': str(exc)[:500]}
if intentos >= MAX_INTENTOS:
vals['estado'] = 'error'
else:
espera = BACKOFF_MIN[min(intentos - 1, len(BACKOFF_MIN) - 1)]
vals['programada_para'] = fields.Datetime.now() + timedelta(minutes=espera)
notif.write(vals)
_logger.warning('Notificación %s falló (intento %s): %s',
notif.id, intentos, exc)
return 'fallida'
notif.write({'estado': 'enviada', 'enviada_at': fields.Datetime.now(),
'intentos': notif.intentos + 1, 'error': False})
return 'enviada'
# ------------------------------------------------------------------
# Adaptadores de envío
# ------------------------------------------------------------------
@api.model
def _wacrm_credenciales(self):
ICP = self.env['ir.config_parameter'].sudo()
url = os.environ.get('WACRM_API_URL') or ICP.get_param('skeen.wacrm.api_url')
key = os.environ.get('WACRM_API_KEY') or ICP.get_param('skeen.wacrm.api_key')
return (url or '').rstrip('/'), (key or '')
@api.model
def enviar_whatsapp(self, partner, mensaje, cfg=None):
"""Envía un WhatsApp real vía la API v1 del WACRM.
Sin credenciales configuradas lanza un error claro (nunca envía a
ciegas ni revienta el proceso de la cola).
"""
url, key = self._wacrm_credenciales()
if not url or not key:
raise UserError(_('No hay credenciales de WhatsApp (WACRM) configuradas '
'en esta instancia.'))
phone = (partner.whatsapp or partner.phone or '').strip() if partner else ''
if not phone:
raise UserError(_('El paciente no tiene teléfono ni WhatsApp registrado.'))
digits = re.sub(r'\D', '', phone)
destino = ('+' + digits.lstrip('0')) if digits else ''
if not destino:
raise UserError(_('El teléfono del paciente no es válido.'))
try:
resp = requests.post(
url + WHATSAPP_API_PATH,
json={'to': destino, 'type': 'text', 'text': mensaje},
headers={'Authorization': 'Bearer ' + key,
'Content-Type': 'application/json'},
timeout=30,
)
except requests.RequestException as exc:
raise UserError(_('No se pudo contactar al servicio de WhatsApp: %s') % exc)
if resp.status_code >= 400:
raise UserError(_('WhatsApp (WACRM) respondió %s: %s')
% (resp.status_code, (resp.text or '')[:200]))
return True
@api.model
def _smtp_configurado(self):
"""True si Odoo tiene un servidor de correo saliente configurado."""
return bool(self.env['ir.mail_server'].sudo().search_count([]))
@api.model
def enviar_email(self, partner, asunto, mensaje, cfg=None):
"""Envía un correo real con `mail.mail`. Sin SMTP, error claro."""
if not partner:
raise UserError(_('Falta el paciente destinatario.'))
destino = (partner.email or '').strip()
if not destino:
raise UserError(_('El paciente no tiene correo registrado.'))
if not self._smtp_configurado():
raise UserError(_('No hay servidor SMTP configurado en Odoo.'))
cfg = cfg or self._cfg()
remitente = (cfg.get('email_remitente')
or clinic_get_settings(self.env).get('email')
or self.env.company.email or '')
mail = self.env['mail.mail'].sudo().create({
'subject': asunto or 'Notificación',
'body_html': '<div style="white-space:pre-wrap">%s</div>'
% html.escape(mensaje or ''),
'email_to': destino,
'email_from': remitente or None,
})
mail.send()
return True
# ------------------------------------------------------------------
# Prueba de conexión
# ------------------------------------------------------------------
@api.model
def probar_canal(self, canal, destino='', cfg=None):
"""Prueba de conexión de un canal.
En `modo_prueba` solo valida que el canal esté configurado y responde
simulado, sin enviar a nadie real. Devuelve un dict, nunca lanza.
"""
cfg = cfg or self._cfg()
canal = (canal or '').strip().lower()
if canal not in ('whatsapp', 'email'):
return {'ok': False, 'simulado': False,
'message': 'Canal no válido: usa whatsapp o email.'}
if canal == 'whatsapp':
url, key = self._wacrm_credenciales()
if not url or not key:
return {'ok': False, 'simulado': False,
'message': 'No hay credenciales de WhatsApp (WACRM) configuradas.'}
if cfg['modo_prueba']:
return {'ok': True, 'simulado': True,
'message': 'Modo prueba: WhatsApp está configurado; no se envió nada real.'}
telefono = (destino or cfg.get('whatsapp_numero') or '').strip()
if not telefono:
return {'ok': False, 'simulado': False,
'message': 'Indica un número de destino para la prueba.'}
try:
digits = re.sub(r'\D', '', telefono)
resp = requests.post(
url + WHATSAPP_API_PATH,
json={'to': '+' + digits.lstrip('0'), 'type': 'text',
'text': 'Prueba de conexión de SKEEN.'},
headers={'Authorization': 'Bearer ' + key,
'Content-Type': 'application/json'},
timeout=30)
if resp.status_code >= 400:
return {'ok': False, 'simulado': False,
'message': 'WACRM respondió %s: %s'
% (resp.status_code, (resp.text or '')[:200])}
return {'ok': True, 'simulado': False, 'message': 'Mensaje de prueba enviado.'}
except requests.RequestException as exc:
return {'ok': False, 'simulado': False,
'message': 'No se pudo contactar al servicio de WhatsApp: %s' % exc}
# canal == 'email'
if not self._smtp_configurado():
return {'ok': False, 'simulado': False,
'message': 'No hay servidor SMTP configurado en Odoo.'}
if cfg['modo_prueba']:
return {'ok': True, 'simulado': True,
'message': 'Modo prueba: el correo está configurado; no se envió nada real.'}
if '@' not in (destino or ''):
return {'ok': False, 'simulado': False,
'message': 'Indica un correo de destino válido para la prueba.'}
try:
destinatario = self.env['res.partner'].sudo().new(
{'name': destino, 'email': destino})
self.enviar_email(destinatario, 'Prueba de conexión SKEEN',
'Prueba de conexión de notificaciones por correo.', cfg=cfg)
return {'ok': True, 'simulado': False, 'message': 'Correo de prueba enviado.'}
except Exception as exc:
return {'ok': False, 'simulado': False, 'message': str(exc)}