# -*- 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'), ('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', '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': '
%s
' % 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)}