625 lines
26 KiB
Python
625 lines
26 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'),
|
|
('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': '<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)}
|