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:
@@ -15,6 +15,7 @@
|
||||
'security/ir.model.access.csv',
|
||||
'data/roles.xml',
|
||||
'data/frontend_users.xml',
|
||||
'data/notificaciones_cron.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -8118,5 +8118,194 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Notificaciones y recordatorios de citas
|
||||
#
|
||||
# Las plantillas y el resto de la configuración viven en el bloque
|
||||
# `notificaciones` de clinic-settings (GET/PUT /clinic-settings), no en
|
||||
# endpoints aparte: así hay una sola fuente de verdad.
|
||||
# ============================================================
|
||||
def _notif_to_dict(self, n):
|
||||
return {
|
||||
'id': n.id,
|
||||
'partner_id': n.partner_id.id,
|
||||
'patient': n.partner_id.name or '',
|
||||
'phone': n.partner_id.whatsapp or n.partner_id.phone or '',
|
||||
'email': n.partner_id.email or '',
|
||||
'cita_id': n.cita_id.id if n.cita_id else None,
|
||||
'cita_ref': n.cita_id.name if n.cita_id else '',
|
||||
'tipo': n.tipo,
|
||||
'tipo_label': dict(n._fields['tipo'].selection).get(n.tipo, n.tipo),
|
||||
'canal': n.canal,
|
||||
'canal_label': dict(n._fields['canal'].selection).get(n.canal, n.canal),
|
||||
'estado': n.estado,
|
||||
'estado_label': dict(n._fields['estado'].selection).get(n.estado, n.estado),
|
||||
'asunto': n.asunto or '',
|
||||
'mensaje': n.mensaje or '',
|
||||
'programada_para': n.programada_para.strftime('%Y-%m-%d %H:%M') if n.programada_para else None,
|
||||
'enviada_at': n.enviada_at.strftime('%Y-%m-%d %H:%M') if n.enviada_at else None,
|
||||
'error': n.error or '',
|
||||
'intentos': n.intentos,
|
||||
}
|
||||
|
||||
@http.route('/skeen/frontend/v1/notificaciones', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('notificaciones.ver')
|
||||
def list_notificaciones(self, **kw):
|
||||
"""Historial de notificaciones con filtros y resumen por estado."""
|
||||
try:
|
||||
domain = []
|
||||
try:
|
||||
if kw.get('cita_id'):
|
||||
domain.append(('cita_id', '=', int(kw['cita_id'])))
|
||||
if kw.get('partner_id'):
|
||||
domain.append(('partner_id', '=', int(kw['partner_id'])))
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'cita_id / partner_id deben ser numéricos'}, 400)
|
||||
for campo in ('estado', 'tipo', 'canal'):
|
||||
if kw.get(campo):
|
||||
domain.append((campo, '=', kw[campo]))
|
||||
if kw.get('start'):
|
||||
domain.append(('programada_para', '>=', '%s 00:00:00' % kw['start']))
|
||||
if kw.get('end'):
|
||||
domain.append(('programada_para', '<=', '%s 23:59:59' % kw['end']))
|
||||
|
||||
Notif = request.env['skeen.notificacion'].sudo()
|
||||
try:
|
||||
page = max(int(kw.get('page', 1) or 1), 1)
|
||||
page_size = int(kw.get('page_size') or kw.get('limit') or 50)
|
||||
except (TypeError, ValueError):
|
||||
page, page_size = 1, 50
|
||||
page_size = min(max(page_size, 1), 200)
|
||||
|
||||
total = Notif.search_count(domain)
|
||||
registros = Notif.search(domain, order='programada_para desc, id desc',
|
||||
limit=page_size, offset=(page - 1) * page_size)
|
||||
|
||||
resumen = {estado: 0 for estado, _l in Notif._fields['estado'].selection}
|
||||
for grupo in Notif.read_group(domain, ['estado'], ['estado'], lazy=False):
|
||||
estado = grupo.get('estado')
|
||||
if estado:
|
||||
resumen[estado] = grupo.get('__count', grupo.get('estado_count', 0))
|
||||
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'notificaciones': [self._notif_to_dict(n) for n in registros],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total + page_size - 1) // page_size,
|
||||
'resumen': resumen,
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _procesar_notificaciones(self, limite):
|
||||
"""Corre el mismo ciclo que el cron y devuelve los conteos."""
|
||||
return request.env['skeen.notificacion.service'].sudo().ejecutar(limite=limite)
|
||||
|
||||
@http.route('/skeen/frontend/v1/notificaciones/procesar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('notificaciones.enviar')
|
||||
def procesar_notificaciones(self, **kw):
|
||||
"""Fuerza el procesamiento: encola lo que toca y envía la cola."""
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
try:
|
||||
limite = int(data.get('limit') or 100)
|
||||
except (TypeError, ValueError):
|
||||
limite = 100
|
||||
limite = min(max(limite, 1), 500)
|
||||
resultado = self._procesar_notificaciones(limite)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'message': 'Se encolaron %s y se procesaron %s notificaciones.'
|
||||
% (resultado['encoladas'], resultado['revisadas']),
|
||||
**resultado,
|
||||
})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/notificaciones/enviar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('notificaciones.enviar')
|
||||
def enviar_notificacion(self, **kw):
|
||||
"""Encola y envía un mensaje puntual desde el detalle de la cita."""
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
cita_id = data.get('cita_id')
|
||||
if not cita_id:
|
||||
return json_response({'status': 'error', 'message': 'Falta cita_id'}, 400)
|
||||
canal = data.get('canal') or None
|
||||
if canal not in (None, 'whatsapp', 'email'):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'El canal debe ser whatsapp o email'}, 400)
|
||||
cita = request.env['skeen.cita'].sudo().browse(int(cita_id))
|
||||
if not cita.exists():
|
||||
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
|
||||
|
||||
service = request.env['skeen.notificacion.service'].sudo()
|
||||
cfg = service._cfg()
|
||||
if not cfg['activo']:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Las notificaciones están desactivadas'}, 400)
|
||||
|
||||
creadas = service.encolar_manual(cita, canal=canal)
|
||||
if not creadas:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'El paciente no tiene destino para ese canal '}, 400)
|
||||
|
||||
estados = {}
|
||||
for notif in creadas:
|
||||
estados[service._intentar(notif, cfg)] = True
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'notificaciones': [self._notif_to_dict(n) for n in creadas],
|
||||
'simulado': bool(cfg['modo_prueba']),
|
||||
'message': ('Modo prueba: el mensaje quedó registrado como simulado.'
|
||||
if cfg['modo_prueba'] else 'Mensaje enviado.'),
|
||||
})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/notificaciones/<int:notif_id>/reintentar',
|
||||
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('notificaciones.enviar')
|
||||
def reintentar_notificacion(self, notif_id, **kw):
|
||||
"""Devuelve a la cola una notificación fallida e intenta de nuevo."""
|
||||
try:
|
||||
notif = request.env['skeen.notificacion'].sudo().browse(notif_id)
|
||||
if not notif.exists():
|
||||
return json_response({'status': 'error', 'message': 'Notificación no encontrada'}, 404)
|
||||
service = request.env['skeen.notificacion.service'].sudo()
|
||||
notif.action_reintentar()
|
||||
estado = service._intentar(notif, service._cfg())
|
||||
return json_response({'status': 'success', 'estado': estado,
|
||||
'notificacion': self._notif_to_dict(notif)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/notificaciones/probar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
@require_perm('notificaciones.editar')
|
||||
def probar_notificacion(self, **kw):
|
||||
"""Prueba de conexión de un canal. Nunca lanza: responde 200 siempre."""
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
service = request.env['skeen.notificacion.service'].sudo()
|
||||
resultado = service.probar_canal(data.get('canal'), data.get('destino') or '')
|
||||
return json_response({'status': 'success', **resultado})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# Importar fields para fechas
|
||||
from odoo import fields
|
||||
|
||||
@@ -254,14 +254,25 @@ class SkeenPublicBookingController(http.Controller):
|
||||
return cita
|
||||
|
||||
def _encolar_confirmacion(self, cita):
|
||||
"""Hook Fase 2.4: avisar al cliente que la clínica confirmará su cita.
|
||||
"""Encola y dispara la confirmación al paciente que reservó en línea.
|
||||
|
||||
A propósito es un no-op: la Fase 2.4 enviará el mensaje de confirmación
|
||||
por WhatsApp/WACRM (usando `cita.wacrm_conversation_id` o el teléfono del
|
||||
paciente) cuando esas credenciales estén configuradas. Se deja el punto
|
||||
de extensión listo, sin acoplar el portal público a WhatsApp todavía.
|
||||
Solo actúa si `notificaciones.activo` y `confirmacion_al_crear` están
|
||||
encendidos; el motor decide si envía o simula (modo prueba). Nunca debe
|
||||
tumbar la reserva: cualquier fallo se registra y se ignora.
|
||||
"""
|
||||
return False
|
||||
try:
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_notificaciones
|
||||
cfg = get_notificaciones(request.env)
|
||||
if not (cfg.get('activo') and cfg.get('confirmacion_al_crear')):
|
||||
return False
|
||||
service = request.env['skeen.notificacion.service'].sudo()
|
||||
creadas = service.encolar(cita, 'confirmacion')
|
||||
for notif in creadas:
|
||||
service._intentar(notif, cfg)
|
||||
return bool(creadas)
|
||||
except Exception:
|
||||
_logger.exception('No se pudo encolar la confirmación de la reserva en línea')
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Configuración pública del portal
|
||||
|
||||
21
odoo-addons/skeen_whatsapp/data/notificaciones_cron.xml
Normal file
21
odoo-addons/skeen_whatsapp/data/notificaciones_cron.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Motor de recordatorios y notificaciones de citas.
|
||||
Con `notificaciones.activo=False` (default) el método regresa sin
|
||||
tocar nada: cero filas y cero envíos. Cada 5 minutos encola los
|
||||
recordatorios que entran en ventana, los cumpleaños del día, las
|
||||
recuperaciones de no-show y el seguimiento post-visita, y procesa
|
||||
la cola pendiente. -->
|
||||
<record id="cron_notificaciones" model="ir.cron">
|
||||
<field name="name">SKEEN Notificaciones: recordatorios y avisos de citas</field>
|
||||
<field name="model_id" ref="model_skeen_notificacion_service"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_tick()</field>
|
||||
<field name="interval_number">5</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -5,3 +5,4 @@ from . import frontend_user
|
||||
from . import config_audit
|
||||
from . import wacrm_notes
|
||||
from . import ticket_config
|
||||
from . import notificacion
|
||||
|
||||
613
odoo-addons/skeen_whatsapp/models/notificacion.py
Normal file
613
odoo-addons/skeen_whatsapp/models/notificacion.py
Normal file
@@ -0,0 +1,613 @@
|
||||
# -*- 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'),
|
||||
('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',
|
||||
'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):
|
||||
"""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.
|
||||
"""
|
||||
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()
|
||||
|
||||
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):
|
||||
"""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.
|
||||
"""
|
||||
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)
|
||||
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)}
|
||||
@@ -84,6 +84,11 @@ PERMISSION_GROUPS = [
|
||||
('whatsapp.responder', 'Responder conversaciones'),
|
||||
('whatsapp.asignar', 'Asignar conversaciones y leads'),
|
||||
]),
|
||||
('notificaciones', 'Notificaciones', [
|
||||
('notificaciones.ver', 'Ver el historial de notificaciones'),
|
||||
('notificaciones.enviar', 'Enviar y reintentar notificaciones'),
|
||||
('notificaciones.editar', 'Configurar recordatorios y plantillas'),
|
||||
]),
|
||||
('config', 'Usuarios y configuración', [
|
||||
('config.usuarios', 'Administrar usuarios'),
|
||||
('config.roles', 'Administrar roles y menús'),
|
||||
@@ -110,6 +115,7 @@ READ_PERM_KEYS = (
|
||||
'reportes.ver', 'reportes.exportar', 'caja.ver',
|
||||
'ventas.reimprimir',
|
||||
'whatsapp.ver',
|
||||
'notificaciones.ver',
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -186,6 +192,11 @@ DEFAULT_PERM_ROLES = {
|
||||
'whatsapp.ver': W,
|
||||
'whatsapp.responder': W,
|
||||
'whatsapp.asignar': W,
|
||||
# Notificaciones de citas: ver es de todos; enviar lo hace recepción y
|
||||
# quien agenda; configurar (plantillas y conexión) es de admin/gerencia.
|
||||
'notificaciones.ver': ALL,
|
||||
'notificaciones.enviar': W,
|
||||
'notificaciones.editar': AG,
|
||||
# Usuarios y configuración
|
||||
'config.usuarios': AG,
|
||||
'config.roles': AG,
|
||||
|
||||
@@ -11,3 +11,4 @@ access_skeen_config_audit,skeen.config.audit,model_skeen_config_audit,base.group
|
||||
access_skeen_wacrm_note,Notas internas WACRM,model_skeen_wacrm_note,base.group_user,1,1,1,1
|
||||
access_skeen_wacrm_tag,Etiquetas WACRM,model_skeen_wacrm_tag,base.group_user,1,1,1,1
|
||||
access_skeen_wacrm_conversation_tag,Etiquetas por conversacion,model_skeen_wacrm_conversation_tag,base.group_user,1,1,1,1
|
||||
access_skeen_notificacion,Notificaciones de citas,model_skeen_notificacion,base.group_user,1,1,1,1
|
||||
|
||||
|
Reference in New Issue
Block a user