368 lines
15 KiB
Python
368 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Webhooks salientes: outbox, entrega por cron, reintentos y log.
|
|
|
|
Sin webhooks activos suscritos a un evento NO se crea ninguna fila en el outbox
|
|
y el cron no hace nada: cero impacto en una instancia sin configurar. La entrega
|
|
es asíncrona (cron cada 5 minutos) para no añadir latencia a los flujos.
|
|
|
|
Firma: `X-Skeen-Signature: sha256=<HMAC-SHA256 del cuerpo con el secreto>`.
|
|
"""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from datetime import timedelta
|
|
|
|
import requests
|
|
|
|
from odoo import api, fields, models
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Catálogo de eventos emitibles (clave, etiqueta).
|
|
EVENTOS = [
|
|
('cita.creada', 'Cita creada'),
|
|
('cita.confirmada', 'Cita confirmada'),
|
|
('cita.cancelada', 'Cita cancelada'),
|
|
('cita.no_show', 'Cita no presentada'),
|
|
('cita.completada', 'Cita completada'),
|
|
('venta.pagada', 'Venta pagada'),
|
|
('venta.cancelada', 'Venta cancelada'),
|
|
('pago.registrado', 'Pago registrado'),
|
|
('paciente.creado', 'Paciente creado'),
|
|
('membresia.renovada', 'Membresía renovada'),
|
|
('formulario.llenado', 'Formulario llenado'),
|
|
('compra.recibida', 'Compra recibida'),
|
|
]
|
|
EVENTO_KEYS = tuple(k for k, _l in EVENTOS)
|
|
EVENTO_LABEL = dict(EVENTOS)
|
|
|
|
ESTADOS = [
|
|
('pendiente', 'Pendiente'),
|
|
('enviado', 'Enviado'),
|
|
('fallido', 'Fallido'),
|
|
('descartado', 'Descartado'),
|
|
]
|
|
|
|
MAX_INTENTOS = 5
|
|
BACKOFF_MIN = (1, 5, 30, 120, 360) # minutos entre reintentos
|
|
TIMEOUT = (5, 15) # (conexión, lectura) segundos
|
|
FALLOS_MAX_DEFAULT = 20
|
|
PROCESAR_POR_DEFECTO = 100
|
|
|
|
|
|
class SkeenWebhook(models.Model):
|
|
_name = 'skeen.webhook'
|
|
_description = 'Webhook saliente'
|
|
_order = 'name, id'
|
|
|
|
name = fields.Char(string='Nombre', required=True)
|
|
url = fields.Char(string='URL', required=True)
|
|
eventos = fields.Char(string='Eventos', default='',
|
|
help='Claves separadas por coma. Vacío = todos.')
|
|
activo = fields.Boolean(string='Activo', default=True)
|
|
secreto = fields.Char(string='Secreto', help='Con este secreto se firma cada entrega.')
|
|
headers = fields.Text(string='Cabeceras extra (JSON)')
|
|
notas = fields.Text(string='Notas')
|
|
creado_por_login = fields.Char(string='Creado por')
|
|
ultima_entrega_at = fields.Datetime(string='Última entrega')
|
|
fallos_consecutivos = fields.Integer(string='Fallos consecutivos', default=0)
|
|
|
|
def eventos_list(self):
|
|
return [e.strip() for e in (self.eventos or '').split(',') if e.strip()]
|
|
|
|
def suscrito(self, evento):
|
|
"""True si el webhook recibe ese evento (lista vacía = todos)."""
|
|
lista = self.eventos_list()
|
|
return (not lista) or (evento in lista)
|
|
|
|
def headers_dict(self):
|
|
try:
|
|
val = json.loads(self.headers or '{}')
|
|
except (ValueError, TypeError):
|
|
return {}
|
|
return val if isinstance(val, dict) else {}
|
|
|
|
def to_public_dict(self):
|
|
return {
|
|
'id': self.id,
|
|
'name': self.name,
|
|
'url': self.url,
|
|
'eventos': self.eventos_list(),
|
|
'eventos_label': 'Todos' if not self.eventos_list() else
|
|
', '.join(EVENTO_LABEL.get(e, e) for e in self.eventos_list()),
|
|
'activo': self.activo,
|
|
'tiene_secreto': bool(self.secreto),
|
|
'headers': self.headers_dict(),
|
|
'notas': self.notas or '',
|
|
'creado_por_login': self.creado_por_login or '',
|
|
'ultima_entrega_at': self.ultima_entrega_at.strftime('%Y-%m-%d %H:%M')
|
|
if self.ultima_entrega_at else '',
|
|
'fallos_consecutivos': self.fallos_consecutivos,
|
|
}
|
|
|
|
|
|
class SkeenWebhookEvento(models.Model):
|
|
_name = 'skeen.webhook.evento'
|
|
_description = 'Evento de webhook (outbox)'
|
|
_order = 'create_date desc, id desc'
|
|
|
|
webhook_id = fields.Many2one('skeen.webhook', string='Webhook', required=True,
|
|
ondelete='cascade', index=True)
|
|
evento = fields.Char(string='Evento', required=True, index=True)
|
|
payload = fields.Text(string='Payload (JSON)')
|
|
estado = fields.Selection(ESTADOS, string='Estado', default='pendiente',
|
|
required=True, index=True)
|
|
intentos = fields.Integer(string='Intentos', default=0)
|
|
proximo_intento = fields.Datetime(string='Próximo intento', index=True)
|
|
respuesta_codigo = fields.Integer(string='Código de respuesta')
|
|
respuesta_texto = fields.Text(string='Respuesta')
|
|
enviado_at = fields.Datetime(string='Enviado el')
|
|
clave_unica = fields.Char(string='Clave única', index=True)
|
|
|
|
_sql_constraints = [
|
|
('clave_unica_uniq', 'unique(clave_unica)',
|
|
'Ya existe un evento con esa clave (evita duplicados).'),
|
|
]
|
|
|
|
def to_public_dict(self):
|
|
return {
|
|
'id': self.id,
|
|
'webhook_id': self.webhook_id.id,
|
|
'webhook': self.webhook_id.name,
|
|
'evento': self.evento,
|
|
'evento_label': EVENTO_LABEL.get(self.evento, self.evento),
|
|
'estado': self.estado,
|
|
'estado_label': dict(ESTADOS).get(self.estado, self.estado),
|
|
'intentos': self.intentos,
|
|
'proximo_intento': self.proximo_intento.strftime('%Y-%m-%d %H:%M')
|
|
if self.proximo_intento else '',
|
|
'respuesta_codigo': self.respuesta_codigo or 0,
|
|
'respuesta_texto': (self.respuesta_texto or '')[:500],
|
|
'enviado_at': self.enviado_at.strftime('%Y-%m-%d %H:%M') if self.enviado_at else '',
|
|
'creado_at': self.create_date.strftime('%Y-%m-%d %H:%M') if self.create_date else '',
|
|
}
|
|
|
|
|
|
class SkeenWebhookService(models.AbstractModel):
|
|
_name = 'skeen.webhook.service'
|
|
_description = 'Servicio de webhooks salientes'
|
|
|
|
# ------------------------------------------------------------------
|
|
# Emisión al outbox
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def hay_activos(self):
|
|
return bool(self.env['skeen.webhook'].sudo().search_count([('activo', '=', True)]))
|
|
|
|
@api.model
|
|
def _suscriptores(self, evento):
|
|
activos = self.env['skeen.webhook'].sudo().search([('activo', '=', True)])
|
|
return activos.filtered(lambda w: w.suscrito(evento))
|
|
|
|
@api.model
|
|
def emitir(self, evento, payload, clave_unica=None, webhook_ids=None):
|
|
"""Encola el evento para los webhooks activos suscritos.
|
|
|
|
Sin webhooks activos suscritos no crea nada (cero impacto). Devuelve el
|
|
recordset de eventos creados.
|
|
"""
|
|
if evento not in EVENTO_KEYS and evento != 'prueba':
|
|
return self.env['skeen.webhook.evento'].browse()
|
|
if webhook_ids is None:
|
|
suscriptores = self._suscriptores(evento)
|
|
else:
|
|
suscriptores = self.env['skeen.webhook'].sudo().browse(webhook_ids)
|
|
suscriptores = suscriptores.filtered(lambda w: w.suscrito(evento))
|
|
if not suscriptores:
|
|
return self.env['skeen.webhook.evento'].browse()
|
|
Evento = self.env['skeen.webhook.evento'].sudo()
|
|
texto = json.dumps(payload or {}, ensure_ascii=False, default=str)
|
|
creados = Evento
|
|
ahora = fields.Datetime.now()
|
|
for webhook in suscriptores:
|
|
clave = clave_unica
|
|
if clave:
|
|
clave = '%s:w%s' % (clave, webhook.id)
|
|
if Evento.search_count([('clave_unica', '=', clave)]):
|
|
continue
|
|
creados |= Evento.create({
|
|
'webhook_id': webhook.id,
|
|
'evento': evento,
|
|
'payload': texto,
|
|
'estado': 'pendiente',
|
|
'proximo_intento': ahora,
|
|
'clave_unica': clave or None,
|
|
})
|
|
return creados
|
|
|
|
# ------------------------------------------------------------------
|
|
# Entrega
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def _cron_tick(self):
|
|
return self.procesar()
|
|
|
|
@api.model
|
|
def procesar(self, limite=PROCESAR_POR_DEFECTO):
|
|
"""Envía los eventos pendientes ya vencidos. Nunca detiene el cron."""
|
|
ahora = fields.Datetime.now()
|
|
pendientes = self.env['skeen.webhook.evento'].sudo().search([
|
|
('estado', '=', 'pendiente'),
|
|
('proximo_intento', '<=', ahora),
|
|
], order='proximo_intento asc, id asc', limit=limite)
|
|
resultado = {'revisados': len(pendientes), 'enviados': 0, 'fallidos': 0}
|
|
for evento in pendientes:
|
|
try:
|
|
estado = self._entregar(evento)
|
|
except Exception:
|
|
_logger.exception('Webhook evento %s: error inesperado', evento.id)
|
|
self._fallar(evento, 'Error inesperado al entregar')
|
|
estado = 'fallido'
|
|
if estado == 'enviado':
|
|
resultado['enviados'] += 1
|
|
elif estado == 'fallido':
|
|
resultado['fallidos'] += 1
|
|
if resultado['revisados']:
|
|
_logger.info('SKEEN webhooks: %s', resultado)
|
|
return resultado
|
|
|
|
@api.model
|
|
def firmar(self, secreto, cuerpo_bytes):
|
|
"""Firma `sha256=<hex>` del cuerpo con el secreto."""
|
|
if not secreto:
|
|
return ''
|
|
digest = hmac.new(secreto.encode('utf-8'), cuerpo_bytes, hashlib.sha256)
|
|
return 'sha256=' + digest.hexdigest()
|
|
|
|
@api.model
|
|
def _entregar(self, evento):
|
|
webhook = evento.webhook_id
|
|
if not webhook.activo:
|
|
evento.write({'estado': 'descartado',
|
|
'respuesta_texto': 'El webhook está desactivado.'})
|
|
return 'descartado'
|
|
cuerpo = (evento.payload or '{}').encode('utf-8')
|
|
headers = {
|
|
'Content-Type': 'application/json',
|
|
'X-Skeen-Event': evento.evento,
|
|
'X-Skeen-Delivery': uuid.uuid4().hex,
|
|
}
|
|
firma = self.firmar(webhook.secreto or '', cuerpo)
|
|
if firma:
|
|
headers['X-Skeen-Signature'] = firma
|
|
for k, v in (webhook.headers_dict() or {}).items():
|
|
headers[str(k)] = str(v)
|
|
try:
|
|
resp = requests.post(webhook.url, data=cuerpo, headers=headers,
|
|
timeout=TIMEOUT)
|
|
codigo = resp.status_code
|
|
texto = (resp.text or '')[:500]
|
|
except requests.RequestException as exc:
|
|
self._fallar(evento, 'No se pudo conectar: %s' % exc)
|
|
return 'fallido'
|
|
if 200 <= codigo < 300:
|
|
evento.write({
|
|
'estado': 'enviado', 'intentos': evento.intentos + 1,
|
|
'enviado_at': fields.Datetime.now(), 'respuesta_codigo': codigo,
|
|
'respuesta_texto': texto,
|
|
})
|
|
webhook.write({'ultima_entrega_at': fields.Datetime.now(),
|
|
'fallos_consecutivos': 0})
|
|
return 'enviado'
|
|
self._fallar(evento, texto or ('HTTP %s' % codigo), codigo=codigo)
|
|
return 'fallido'
|
|
|
|
@api.model
|
|
def _fallar(self, evento, texto, codigo=0):
|
|
intentos = evento.intentos + 1
|
|
vals = {'intentos': intentos, 'respuesta_codigo': codigo,
|
|
'respuesta_texto': (texto or '')[:500]}
|
|
if intentos >= MAX_INTENTOS:
|
|
vals['estado'] = 'fallido'
|
|
else:
|
|
espera = BACKOFF_MIN[min(intentos - 1, len(BACKOFF_MIN) - 1)]
|
|
vals['proximo_intento'] = fields.Datetime.now() + timedelta(minutes=espera)
|
|
evento.write(vals)
|
|
webhook = evento.webhook_id
|
|
fallos = webhook.fallos_consecutivos + 1
|
|
umbral = self._umbral_fallos()
|
|
vals_wh = {'fallos_consecutivos': fallos}
|
|
if umbral and fallos >= umbral:
|
|
vals_wh['activo'] = False
|
|
_logger.warning('SKEEN webhook %s desactivado tras %s fallos consecutivos',
|
|
webhook.id, fallos)
|
|
webhook.write(vals_wh)
|
|
|
|
@api.model
|
|
def _umbral_fallos(self):
|
|
raw = self.env['ir.config_parameter'].sudo().get_param('skeen.webhooks.fallos_max')
|
|
try:
|
|
return int(raw) if raw else FALLOS_MAX_DEFAULT
|
|
except (TypeError, ValueError):
|
|
return FALLOS_MAX_DEFAULT
|
|
|
|
# ------------------------------------------------------------------
|
|
# Acciones de UI
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def reintentar(self, evento):
|
|
"""Devuelve a la cola un evento fallido y lo intenta de inmediato."""
|
|
if evento.estado in ('enviado', 'descartado'):
|
|
return evento.estado
|
|
evento.write({'estado': 'pendiente', 'proximo_intento': fields.Datetime.now()})
|
|
return self._entregar(evento)
|
|
|
|
@api.model
|
|
def probar(self, webhook, login=''):
|
|
"""Envía un evento de prueba (ignora la suscripción) y devuelve el log."""
|
|
ejemplo = {
|
|
'evento': 'prueba',
|
|
'mensaje': 'Prueba de webhook SKEEN',
|
|
'webhook': webhook.name,
|
|
'generado_por': login or 'sistema',
|
|
}
|
|
evento = self.env['skeen.webhook.evento'].sudo().create({
|
|
'webhook_id': webhook.id,
|
|
'evento': 'prueba',
|
|
'payload': json.dumps(ejemplo, ensure_ascii=False),
|
|
'estado': 'pendiente',
|
|
'proximo_intento': fields.Datetime.now(),
|
|
})
|
|
estado = self._entregar(evento)
|
|
ok = estado == 'enviado'
|
|
mensaje = ('Entrega de prueba correcta.' if ok
|
|
else 'La entrega falló (código %s): %s'
|
|
% (evento.respuesta_codigo or 0, evento.respuesta_texto or ''))
|
|
return ok, mensaje, evento
|
|
|
|
@api.model
|
|
def payload_ejemplo(self, evento):
|
|
"""Payload de ejemplo de un evento, para el visor de la UI."""
|
|
payloads = {
|
|
'cita.creada': {'id': 123, 'reference': 'CITA-0123', 'state': 'pending',
|
|
'date': '2026-10-20', 'time': '10:30',
|
|
'patient_id': 55, 'patient': 'María López',
|
|
'service_id': 7, 'servicio': 'Limpieza facial'},
|
|
'cita.confirmada': {'id': 123, 'reference': 'CITA-0123', 'state': 'confirmed'},
|
|
'cita.cancelada': {'id': 123, 'reference': 'CITA-0123', 'state': 'cancelled'},
|
|
'cita.no_show': {'id': 123, 'reference': 'CITA-0123', 'state': 'no_show'},
|
|
'cita.completada': {'id': 123, 'reference': 'CITA-0123', 'state': 'done'},
|
|
'venta.pagada': {'id': 900, 'reference': 'V-00900', 'state': 'paid',
|
|
'total': 1500.0, 'patient_id': 55},
|
|
'venta.cancelada': {'id': 900, 'reference': 'V-00900', 'state': 'cancelled'},
|
|
'pago.registrado': {'id': 700, 'reference': 'P-00700', 'amount': 500.0,
|
|
'payment_method': 'cash', 'patient_id': 55},
|
|
'paciente.creado': {'id': 55, 'name': 'María López', 'phone': '+526641234567',
|
|
'is_patient': True},
|
|
'membresia.renovada': {'id': 30, 'plan': 'Mensual', 'estado': 'activa',
|
|
'periodo_actual_inicio': '2026-11-01',
|
|
'periodo_actual_fin': '2026-11-30'},
|
|
'formulario.llenado': {'id': 12, 'plantilla': 'Consentimiento',
|
|
'estado': 'llenado', 'patient_id': 55},
|
|
'compra.recibida': {'id': 44, 'referencia': 'OC-0044', 'estado': 'recibida'},
|
|
}
|
|
return payloads.get(evento, {'evento': evento})
|