# -*- 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=`. """ 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=` 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})