fase 3.5: licenciamiento por plan (clave firmada, planes basico/profesional/enterprise/custom, modulos incluidos y limites de usuarios/sucursales/citas/almacenamiento, modo aviso o bloqueo, panel de licencia) + API keys gestionables con alcances y revocacion (arregla la API publica que estaba rota) + webhooks salientes firmados con outbox, reintentos y bitacora; herramientas de vendedor y docs

This commit is contained in:
2026-10-11 19:53:44 +00:00
parent ab9d84cb67
commit 3ed0d327d2
30 changed files with 3705 additions and 89 deletions

View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers
def post_init_hook(env):
"""Siembra la clave legacy de Hermes si no existe.
En ambas instancias se siembra con el valor histórico
`skeen_api_key_2026` para no romper a Hermes al desplegar. Si la clínica ya
la cambió (o la borró), no se toca.
"""
ICP = env['ir.config_parameter'].sudo()
if not ICP.get_param('skeen.api.legacy_key'):
ICP.set_param('skeen.api.legacy_key', 'skeen_api_key_2026')

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Plataforma (licencias, API keys y webhooks)',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Licenciamiento por plan, claves de API gestionables y webhooks salientes',
'description': """
Piezas de producto comercial para SKEEN:
* Licenciamiento por plan: el bloque `licencia` de clinic-settings decide
qué módulos incluye cada cliente y sus límites de uso. Sin licencia
aplicada (default) el sistema se comporta EXACTAMENTE como hoy.
* API de terceros gestionable: clave legacy de Hermes intacta + claves
propias con alcances (scopes), vencimiento y revocación.
* Webhooks salientes: outbox + cron + reintentos con backoff + log de
entregas. Con cero webhooks activos no se emite ni se envía nada.
Todo está apagado por defecto: sin licencia, sin claves nuevas y sin
webhooks configurados nada cambia en la instancia.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': [
'base', 'skeen_citas', 'skeen_pacientes', 'skeen_visitas',
'skeen_ventas', 'skeen_pagos', 'skeen_whatsapp', 'skeen_inventario',
'skeen_membresias', 'skeen_compras', 'skeen_comisiones',
'skeen_formularios',
],
'data': [
'security/ir.model.access.csv',
'data/webhooks_cron.xml',
],
'post_init_hook': 'post_init_hook',
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import licencia
from . import api_keys
from . import webhooks

View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
"""CRUD de claves de API de terceros (Configuración -> API)."""
import json
from odoo import http
from odoo.http import request, Response
from odoo.addons.skeen_whatsapp.controllers.auth import require_role, require_perm
from odoo.addons.skeen_plataforma.models.api_key import SCOPES
BASE = '/skeen/frontend/v1'
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
)
def _body():
try:
raw = request.httprequest.get_data(as_text=True)
return json.loads(raw) if raw else {}
except Exception:
return {}
def _login():
return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
class SkeenApiKeysController(http.Controller):
@http.route(BASE + '/api_keys', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('api.claves')
def list_api_keys(self, **kw):
try:
keys = request.env['skeen.api.key'].sudo().search([])
return _json({
'status': 'success',
'api_keys': [k.to_public_dict() for k in keys],
'scopes': [{'clave': c, 'label': l} for c, l in SCOPES],
})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/api_keys', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('api.claves')
def create_api_key(self, **kw):
try:
data = _body()
nombre = (data.get('name') or '').strip()
if not nombre:
return _json({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
key, raw = request.env['skeen.api.key'].sudo().generar({
'name': nombre,
'scopes': data.get('scopes') or [],
'vence': data.get('vence') or False,
'notas': data.get('notas') or '',
}, login=_login())
return _json({
'status': 'success',
'clave': raw, # se muestra UNA sola vez
'api_key': key.to_public_dict(),
'message': 'Guarda esta clave: no se volverá a mostrar.',
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/api_keys/<int:key_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('api.claves')
def update_api_key(self, key_id, **kw):
try:
key = request.env['skeen.api.key'].sudo().browse(key_id)
if not key.exists():
return _json({'status': 'error', 'message': 'Clave no encontrada'}, 404)
data = _body()
vals = {}
if 'name' in data and (data.get('name') or '').strip():
vals['name'] = data['name'].strip()
if 'scopes' in data:
vals['scopes'] = json.dumps(key._normalizar_scopes(data.get('scopes') or []))
if 'activo' in data:
vals['activo'] = bool(data['activo'])
if 'vence' in data:
vals['vence'] = data.get('vence') or False
if 'notas' in data:
vals['notas'] = data.get('notas') or ''
if vals:
key.write(vals)
return _json({'status': 'success', 'api_key': key.to_public_dict()})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/api_keys/<int:key_id>/revocar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('api.claves')
def revocar_api_key(self, key_id, **kw):
try:
key = request.env['skeen.api.key'].sudo().browse(key_id)
if not key.exists():
return _json({'status': 'error', 'message': 'Clave no encontrada'}, 404)
key.action_revocar(login=_login())
return _json({'status': 'success', 'message': 'Clave revocada.',
'api_key': key.to_public_dict()})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)

View File

@@ -0,0 +1,219 @@
# -*- coding: utf-8 -*-
"""API de licencia por plan (Configuración -> Licencia).
Solo administración del sistema (PUT) y administración/gerencia (GET/validar).
Nunca bloquea el login, la agenda ni la configuración.
"""
import json
from odoo import fields, http
from odoo.http import request, Response
from odoo.addons.skeen_whatsapp.controllers.auth import require_role, current_user
from odoo.addons.skeen_citas.clinic_settings import (
PLANES, MODULOS_BASE, MODULOS_GATEABLES, MODULO_LABEL, LIMITE_KEYS,
get_settings, set_settings, licencia_de, modulos_licencia,
licencia_bloqueo_activo, verificar_clave_licencia, licencia_desde_payload,
dias_para_vencer, LICENCIA_DEFAULT,
)
BASE = '/skeen/frontend/v1'
LIMITE_LABEL = {
'usuarios_max': 'Usuarios',
'sucursales_max': 'Sucursales',
'citas_mes_max': 'Citas del mes',
'almacenamiento_mb': 'Almacenamiento (MB)',
}
AVISO_DIAS = 15
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
)
def _body():
try:
raw = request.httprequest.get_data(as_text=True)
return json.loads(raw) if raw else {}
except Exception:
return {}
def _puede_ver():
u = current_user(request.env)
return bool(u and u.role in ('admin', 'gerencia'))
def _puede_editar():
u = current_user(request.env)
return bool(u and u.role == 'admin')
def _uso(env):
"""Uso actual de cada límite frente a su tope."""
from datetime import date
hoy = date.today()
inicio = hoy.replace(day=1)
fin = hoy.replace(year=hoy.year + 1, month=1, day=1) if hoy.month == 12 \
else hoy.replace(month=hoy.month + 1, day=1)
env.cr.execute('SELECT COALESCE(SUM(file_size), 0) FROM ir_attachment')
bytes_almacen = env.cr.fetchone()[0] or 0
return {
'usuarios_max': env['skeen.frontend.user'].sudo().search_count([('active', '=', True)]),
'sucursales_max': env['skeen.sucursal'].sudo().search_count([('active', '=', True)]),
'citas_mes_max': env['skeen.cita'].sudo().search_count(
[('date', '>=', inicio), ('date', '<', fin)]),
'almacenamiento_mb': round(bytes_almacen / (1024.0 * 1024.0), 2),
}
def _estado(env):
settings = get_settings(env)
licencia = settings.get('licencia') or dict(LICENCIA_DEFAULT)
limites = licencia.get('limites') or {}
uso = _uso(env)
filas = []
for clave in LIMITE_KEYS:
tope = int(limites.get(clave) or 0)
actual = uso.get(clave) or 0
if tope <= 0:
pct = 0
estado = 'sin_limite'
else:
pct = round(min(100.0, (actual / float(tope)) * 100.0), 1)
estado = 'excedido' if actual > tope else (
'cerca' if pct >= 80 else 'ok')
filas.append({
'clave': clave,
'label': LIMITE_LABEL.get(clave, clave),
'uso': actual,
'limite': tope,
'porcentaje': pct,
'estado': estado,
})
permitidos = modulos_licencia(licencia)
modulos = []
for clave in list(MODULOS_BASE) + list(MODULOS_GATEABLES):
incluido = permitidos is None or clave in permitidos
modulos.append({
'clave': clave,
'label': MODULO_LABEL.get(clave, clave),
'base': clave in MODULOS_BASE,
'incluido': incluido,
})
dias = dias_para_vencer(licencia)
aplicada = bool(licencia.get('clave'))
return {
'licencia': {
'plan': licencia.get('plan') or '',
'plan_label': (PLANES.get(licencia.get('plan')) or {}).get('label', ''),
'modo': licencia.get('modo') or 'aviso',
'cliente': licencia.get('cliente') or '',
'vence': licencia.get('vence') or '',
'dias_restantes': dias,
'aplicada': aplicada,
'bloqueo_activo': licencia_bloqueo_activo(licencia),
'aviso_vence': bool(dias is not None and 0 <= dias <= AVISO_DIAS),
},
'limites': filas,
'modulos': modulos,
'planes': [{'clave': k, 'label': v['label'], 'modulos': v['modulos']}
for k, v in PLANES.items()],
'aviso': ('Sin licencia aplicada — todas las funciones habilitadas.'
if not aplicada else None),
}
class SkeenLicenciaController(http.Controller):
@http.route(BASE + '/licencia', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def get_licencia(self, **kw):
try:
if str(request.httprequest.method).upper() == 'OPTIONS':
return _json({}, 200)
if not _puede_ver():
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
return _json({'status': 'success', **_estado(request.env)})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/licencia', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def put_licencia(self, **kw):
try:
if str(request.httprequest.method).upper() == 'OPTIONS':
return _json({}, 200)
if not _puede_editar():
return _json({'status': 'error',
'message': 'Solo administración puede aplicar licencias'}, 403)
data = _body()
env = request.env
if 'clave' in data:
clave = (data.get('clave') or '').strip()
if not clave:
# Quitar la licencia: todo vuelve a estar habilitado.
vacio = dict(LICENCIA_DEFAULT)
vacio['limites'] = {k: 0 for k in LIMITE_KEYS}
set_settings(env, {'licencia': vacio})
return _json({'status': 'success',
'message': 'Licencia retirada. Todo habilitado.',
**_estado(env)})
payload, error = verificar_clave_licencia(env, clave)
if error:
return _json({'status': 'error',
'message': '%s No se guardó ningún cambio.' % error}, 400)
modo = data.get('modo') or 'bloqueo'
lic = licencia_desde_payload(payload, clave, modo=modo)
set_settings(env, {'licencia': lic})
return _json({'status': 'success', 'message': 'Licencia aplicada.',
**_estado(env)})
# Cambios sin re-firmar (p. ej. pasar de aviso a bloqueo).
vals = {k: data[k] for k in ('modo', 'plan', 'cliente', 'vence',
'modulos', 'limites') if k in data}
if not vals:
return _json({'status': 'error', 'message': 'Nada que actualizar'}, 400)
set_settings(env, {'licencia': vals})
return _json({'status': 'success', 'message': 'Licencia actualizada.',
**_estado(env)})
except ValueError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 400)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/licencia/validar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def validar_licencia(self, **kw):
try:
if str(request.httprequest.method).upper() == 'OPTIONS':
return _json({}, 200)
if not _puede_ver():
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _body()
payload, error = verificar_clave_licencia(request.env, data.get('clave'))
dias = dias_para_vencer({'vence': (payload or {}).get('vence')})
return _json({
'status': 'success' if not error else 'error',
'valida': error is None,
'error': error,
'payload': payload,
'dias_restantes': dias,
'aviso_vence': bool(dias is not None and 0 <= dias <= AVISO_DIAS),
}, 200 if not error else 400)
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 500)

View File

@@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
"""CRUD de webhooks salientes y log de entregas (Configuración -> Webhooks)."""
import json
import secrets
from odoo import http
from odoo.http import request, Response
from odoo.addons.skeen_whatsapp.controllers.auth import require_role, require_perm
from odoo.addons.skeen_plataforma.models.webhook import EVENTOS, ESTADOS
BASE = '/skeen/frontend/v1'
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
)
def _body():
try:
raw = request.httprequest.get_data(as_text=True)
return json.loads(raw) if raw else {}
except Exception:
return {}
def _login():
return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
def _eventos_list(data):
if isinstance(data.get('eventos'), list):
return [str(e).strip() for e in data['eventos'] if str(e).strip()]
if isinstance(data.get('eventos'), str):
return [e.strip() for e in data['eventos'].split(',') if e.strip()]
return []
class SkeenWebhooksController(http.Controller):
@http.route(BASE + '/webhooks', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def list_webhooks(self, **kw):
try:
service = request.env['skeen.webhook.service'].sudo()
hooks = request.env['skeen.webhook'].sudo().search([])
catalogo = [{'clave': c, 'label': l,
'ejemplo': service.payload_ejemplo(c)} for c, l in EVENTOS]
return _json({
'status': 'success',
'webhooks': [w.to_public_dict() for w in hooks],
'eventos': catalogo,
'estados': [{'clave': c, 'label': l} for c, l in ESTADOS],
'formato_firma': 'X-Skeen-Signature: sha256=<HMAC-SHA256 del cuerpo con el secreto>',
})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhooks', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def create_webhook(self, **kw):
try:
data = _body()
url = (data.get('url') or '').strip()
if not (url.startswith('http://') or url.startswith('https://')):
return _json({'status': 'error',
'message': 'La URL debe empezar por http:// o https://'}, 400)
secreto = (data.get('secreto') or '').strip() or secrets.token_urlsafe(32)
headers = data.get('headers')
if isinstance(headers, dict):
headers = json.dumps(headers, ensure_ascii=False)
else:
headers = (headers or '').strip()
webhook = request.env['skeen.webhook'].sudo().create({
'name': (data.get('name') or '').strip() or 'Webhook',
'url': url,
'eventos': ','.join(_eventos_list(data)),
'activo': bool(data.get('activo', True)),
'secreto': secreto,
'headers': headers,
'notas': data.get('notas') or '',
'creado_por_login': _login(),
})
return _json({'status': 'success', 'webhook': webhook.to_public_dict(),
'secreto': secreto,
'message': 'Webhook creado.'})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhooks/<int:webhook_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def update_webhook(self, webhook_id, **kw):
try:
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
if not webhook.exists():
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
data = _body()
vals = {}
if 'name' in data and (data.get('name') or '').strip():
vals['name'] = data['name'].strip()
if 'url' in data and (data.get('url') or '').strip():
url = data['url'].strip()
if not (url.startswith('http://') or url.startswith('https://')):
return _json({'status': 'error',
'message': 'La URL debe empezar por http:// o https://'}, 400)
vals['url'] = url
if 'eventos' in data:
vals['eventos'] = ','.join(_eventos_list(data))
if 'activo' in data:
vals['activo'] = bool(data['activo'])
if vals['activo']:
vals['fallos_consecutivos'] = 0
if 'secreto' in data and (data.get('secreto') or '').strip():
vals['secreto'] = data['secreto'].strip()
if 'headers' in data:
headers = data['headers']
vals['headers'] = json.dumps(headers, ensure_ascii=False) \
if isinstance(headers, dict) else (headers or '')
if 'notas' in data:
vals['notas'] = data.get('notas') or ''
if vals:
webhook.write(vals)
return _json({'status': 'success', 'webhook': webhook.to_public_dict()})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhooks/<int:webhook_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def delete_webhook(self, webhook_id, **kw):
try:
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
if not webhook.exists():
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
webhook.unlink()
return _json({'status': 'success', 'message': 'Webhook eliminado.'})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhooks/<int:webhook_id>/eventos', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def list_webhook_eventos(self, webhook_id, **kw):
try:
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
if not webhook.exists():
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
domain = [('webhook_id', '=', webhook.id)]
if kw.get('estado'):
domain.append(('estado', '=', kw['estado']))
if kw.get('evento'):
domain.append(('evento', '=', kw['evento']))
try:
limite = min(max(int(kw.get('limit') or 100), 1), 500)
except (TypeError, ValueError):
limite = 100
Evento = request.env['skeen.webhook.evento'].sudo()
registros = Evento.search(domain, order='create_date desc, id desc',
limit=limite)
resumen = {c: 0 for c, _l in ESTADOS}
for grupo in Evento.read_group(domain, ['estado'], ['estado'], lazy=False):
if grupo.get('estado'):
resumen[grupo['estado']] = grupo.get('__count', 0)
return _json({'status': 'success',
'eventos': [e.to_public_dict() for e in registros],
'total': Evento.search_count(domain),
'resumen': resumen})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhooks/<int:webhook_id>/probar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def probar_webhook(self, webhook_id, **kw):
try:
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
if not webhook.exists():
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
ok, mensaje, evento = request.env['skeen.webhook.service'].sudo().probar(
webhook, login=_login())
return _json({'status': 'success' if ok else 'error', 'ok': ok,
'message': mensaje,
'evento': evento.to_public_dict() if evento else None},
200 if ok else 502)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhook_eventos/<int:evento_id>/reintentar', type='http',
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def reintentar_evento(self, evento_id, **kw):
try:
evento = request.env['skeen.webhook.evento'].sudo().browse(evento_id)
if not evento.exists():
return _json({'status': 'error', 'message': 'Evento no encontrado'}, 404)
estado = request.env['skeen.webhook.service'].sudo().reintentar(evento)
return _json({'status': 'success', 'estado': estado,
'evento': evento.to_public_dict()})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Entrega de webhooks salientes. Sin eventos pendientes el cron no
hace nada; sin webhooks activos no se encola ningún evento. -->
<record id="cron_webhooks" model="ir.cron">
<field name="name">SKEEN Webhooks: entrega de eventos pendientes</field>
<field name="model_id" ref="model_skeen_webhook_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>

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
from . import ir_http
from . import api_key
from . import webhook
from . import limites
from . import eventos

View File

@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
"""Claves de API de terceros (`skeen.api.key`).
La clave se muestra UNA sola vez al crearla: solo se guarda su hash SHA-256 y
un prefijo visible para identificarla. La comparación es en tiempo constante.
"""
import hashlib
import hmac
import json
import secrets
from odoo import api, fields, models
from odoo.exceptions import ValidationError
# Alcances disponibles (vacío = todos).
SCOPES = [
('citas.read', 'Consultar citas y disponibilidad'),
('citas.write', 'Crear y cancelar citas'),
('pacientes.read', 'Consultar pacientes y saldos'),
('servicios.read', 'Consultar el catálogo de servicios'),
('monedero.write', 'Redimir puntos del monedero'),
]
SCOPE_KEYS = tuple(k for k, _l in SCOPES)
SCOPE_LABEL = dict(SCOPES)
PREFIJO_LARGO = 14 # caracteres visibles de la clave
def hash_clave(raw):
return hashlib.sha256((raw or '').encode('utf-8')).hexdigest()
class SkeenApiKey(models.Model):
_name = 'skeen.api.key'
_description = 'Clave de API de terceros'
_order = 'create_date desc, id desc'
name = fields.Char(string='Nombre', required=True)
prefijo = fields.Char(string='Prefijo visible', required=True, index=True)
hash = fields.Char(string='Hash', required=True)
activo = fields.Boolean(string='Activa', default=True)
scopes = fields.Char(string='Alcances', default='[]',
help='Lista JSON de alcances. Vacío = todos.')
vence = fields.Date(string='Vence')
ultimo_uso = fields.Datetime(string='Último uso')
usos = fields.Integer(string='Usos', default=0)
notas = fields.Text(string='Notas')
creado_por_login = fields.Char(string='Creada por')
revocada_at = fields.Datetime(string='Revocada el')
revocada_por = fields.Char(string='Revocada por')
_sql_constraints = [
('hash_uniq', 'unique(hash)', 'Ya existe una clave de API con ese hash.'),
]
# ------------------------------------------------------------------
@api.model
def generar(self, vals, login=''):
"""Crea una clave y devuelve (record, clave_en_claro).
La clave en claro solo se devuelve aquí; no se persiste.
"""
raw = 'sk_' + secrets.token_urlsafe(32)
record = self.create({
'name': vals.get('name') or 'Clave de API',
'prefijo': raw[:PREFIJO_LARGO],
'hash': hash_clave(raw),
'scopes': json.dumps(self._normalizar_scopes(vals.get('scopes') or [])),
'vence': vals.get('vence') or False,
'notas': vals.get('notas') or '',
'creado_por_login': login or '',
'activo': True,
})
return record, raw
@api.model
def _normalizar_scopes(self, scopes):
if not isinstance(scopes, (list, tuple)):
return []
limpios = []
for s in scopes:
s = str(s or '').strip()
if s and s in SCOPE_KEYS and s not in limpios:
limpios.append(s)
return limpios
def scopes_list(self):
try:
val = json.loads(self.scopes or '[]')
except (ValueError, TypeError):
val = []
return val if isinstance(val, list) else []
def scopes_label(self):
"""Texto para la UI: 'todos' o la lista de etiquetas."""
scopes = self.scopes_list()
if not scopes:
return 'Todos'
return ', '.join(SCOPE_LABEL.get(s, s) for s in scopes)
def permite(self, scope):
"""True si la clave concede el alcance (vacío = todos). Nunca si inactiva."""
if not self.activo:
return False
if self.vence and self.vence < fields.Date.today():
return False
scopes = self.scopes_list()
if not scopes:
return True
return scope in scopes
def estado(self):
if self.revocada_at or not self.activo:
return 'revocada'
if self.vence and self.vence < fields.Date.today():
return 'vencida'
return 'activa'
def to_public_dict(self):
return {
'id': self.id,
'name': self.name,
'prefijo': self.prefijo,
'activo': self.activo,
'scopes': self.scopes_list(),
'scopes_label': self.scopes_label(),
'vence': self.vence.strftime('%Y-%m-%d') if self.vence else '',
'ultimo_uso': self.ultimo_uso.strftime('%Y-%m-%d %H:%M') if self.ultimo_uso else '',
'usos': self.usos,
'notas': self.notas or '',
'creado_por_login': self.creado_por_login or '',
'revocada_at': self.revocada_at.strftime('%Y-%m-%d %H:%M') if self.revocada_at else '',
'revocada_por': self.revocada_por or '',
'estado': self.estado(),
}
# ------------------------------------------------------------------
@api.model
def _autenticar(self, raw):
"""Devuelve la clave activa que corresponde a `raw` (o recordset vacío)."""
raw = (raw or '').strip()
if not raw:
return self.browse()
candidatas = self.search([
('prefijo', '=', raw[:PREFIJO_LARGO]), ('activo', '=', True),
])
digest = hash_clave(raw)
for key in candidatas:
if hmac.compare_digest(key.hash or '', digest):
return key
# Respaldo: por si el prefijo almacenado difiere, comparar por hash.
return self.search([('hash', '=', digest), ('activo', '=', True)], limit=1)
def _registrar_uso(self):
if not self:
return
self.sudo().write({
'ultimo_uso': fields.Datetime.now(),
'usos': self.usos + 1,
})
def action_revocar(self, login=''):
self.write({
'activo': False,
'revocada_at': fields.Datetime.now(),
'revocada_por': login or '',
})

View File

@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
"""Emisión de eventos de webhook desde los flujos existentes.
Cada override comprueba primero si hay algún webhook activo (una consulta
barata sobre una tabla normalmente vacía); si no lo hay, se comporta
EXACTAMENTE como antes y no crea ninguna fila. La entrega real es asíncrona
(cron), así que aquí solo se encola.
"""
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
def _emitir(env, evento, payload, clave_unica=None):
"""Encapsula la emisión para que NUNCA tumbe la operación de negocio."""
try:
service = env['skeen.webhook.service'].sudo()
if not service.hay_activos():
return
service.emitir(evento, payload, clave_unica=clave_unica)
except Exception:
_logger.exception('No se pudo emitir el evento de webhook %s', evento)
def _val(record, campo):
valor = getattr(record, campo, None)
if hasattr(valor, 'id'): # many2one
return valor.id or False
if hasattr(valor, 'name'): # recordset
return valor.name
if hasattr(valor, 'isoformat'): # fecha/datetime
return valor.isoformat()
return valor
def _cita_payload(cita):
return {
'id': cita.id,
'reference': cita.name,
'state': cita.state,
'date': cita.date.isoformat() if cita.date else '',
'patient_id': cita.partner_id.id,
'patient': cita.partner_id.name or '',
'service_id': cita.servicio_id.id if cita.servicio_id else None,
'servicio': cita.servicio_id.name if cita.servicio_id else '',
'doctor': cita.doctor_id.name if getattr(cita, 'doctor_id', None) else '',
}
class SkeenCita(models.Model):
_inherit = 'skeen.cita'
@api.model_create_multi
def create(self, vals_list):
citas = super().create(vals_list)
for cita in citas:
_emitir(self.env, 'cita.creada', _cita_payload(cita),
clave_unica='cita.creada:%s' % cita.id)
return citas
def write(self, vals):
if 'state' not in vals:
return super().write(vals)
previo = {c.id: c.state for c in self}
res = super().write(vals)
destinos = {
'confirmed': 'cita.confirmada',
'cancelled': 'cita.cancelada',
'no_show': 'cita.no_show',
'done': 'cita.completada',
}
for cita in self:
evento = destinos.get(cita.state)
if evento and previo.get(cita.id) != cita.state:
_emitir(self.env, evento, _cita_payload(cita),
clave_unica='%s:%s' % (evento, cita.id))
return res
class SkeenVenta(models.Model):
_inherit = 'skeen.venta'
def write(self, vals):
if 'state' not in vals and 'refunded' not in vals:
return super().write(vals)
previo = {v.id: (v.state, v.refunded) for v in self}
res = super().write(vals)
for venta in self:
antes = previo.get(venta.id, (None, None))
if venta.state == 'paid' and antes[0] != 'paid':
_emitir(self.env, 'venta.pagada', {
'id': venta.id, 'reference': venta.name, 'state': venta.state,
'total': _val(venta, 'total'), 'patient_id': venta.partner_id.id,
}, clave_unica='venta.pagada:%s' % venta.id)
if (venta.state == 'cancelled' and antes[0] != 'cancelled') or \
(venta.refunded and not antes[1]):
_emitir(self.env, 'venta.cancelada', {
'id': venta.id, 'reference': venta.name, 'state': venta.state,
'refunded': bool(venta.refunded), 'refund_amount': _val(venta, 'refund_amount'),
}, clave_unica='venta.cancelada:%s' % venta.id)
return res
class SkeenPago(models.Model):
_inherit = 'skeen.pago'
@api.model_create_multi
def create(self, vals_list):
pagos = super().create(vals_list)
for pago in pagos:
_emitir(self.env, 'pago.registrado', {
'id': pago.id, 'reference': pago.name, 'amount': pago.amount,
'payment_method': pago.payment_method, 'state': pago.state,
'patient_id': pago.partner_id.id if pago.partner_id else None,
}, clave_unica='pago.registrado:%s' % pago.id)
return pagos
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.model_create_multi
def create(self, vals_list):
partners = super().create(vals_list)
for partner in partners:
if not partner.is_patient:
continue
_emitir(self.env, 'paciente.creado', {
'id': partner.id, 'name': partner.name,
'phone': partner.phone or '', 'email': partner.email or '',
'is_patient': True,
}, clave_unica='paciente.creado:%s' % partner.id)
return partners
class SkeenMembresia(models.Model):
_inherit = 'skeen.membresia'
def action_renovar(self, *args, **kwargs):
res = super().action_renovar(*args, **kwargs)
for membresia in self:
_emitir(self.env, 'membresia.renovada', {
'id': membresia.id,
'plan': membresia.plan_id.name if membresia.plan_id else '',
'estado': membresia.estado,
'periodo_actual_inicio': _val(membresia, 'periodo_actual_inicio'),
'periodo_actual_fin': _val(membresia, 'periodo_actual_fin'),
'patient_id': membresia.partner_id.id if membresia.partner_id else None,
}, clave_unica='membresia.renovada:%s:%s'
% (membresia.id, _val(membresia, 'periodo_actual_inicio')))
return res
class SkeenFormularioRespuesta(models.Model):
_inherit = 'skeen.formulario.respuesta'
def write(self, vals):
if vals.get('estado') != 'llenado':
return super().write(vals)
previo = {r.id: r.estado for r in self}
res = super().write(vals)
for respuesta in self:
if previo.get(respuesta.id) != 'llenado':
_emitir(self.env, 'formulario.llenado', {
'id': respuesta.id,
'plantilla': _val(getattr(respuesta, 'plantilla_id', None), 'name')
if getattr(respuesta, 'plantilla_id', None) else '',
'estado': respuesta.estado,
'patient_id': respuesta.partner_id.id if getattr(respuesta, 'partner_id', None) else None,
}, clave_unica='formulario.llenado:%s' % respuesta.id)
return res
class SkeenCompra(models.Model):
_inherit = 'skeen.compra'
def write(self, vals):
if 'estado' not in vals:
return super().write(vals)
previo = {c.id: c.estado for c in self}
res = super().write(vals)
for compra in self:
if compra.estado in ('recibida', 'recibida_parcial') \
and previo.get(compra.id) != compra.estado:
_emitir(self.env, 'compra.recibida', {
'id': compra.id,
'referencia': _val(compra, 'name') or _val(compra, 'referencia'),
'estado': compra.estado,
}, clave_unica='compra.recibida:%s:%s' % (compra.id, compra.estado))
return res

View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""Autenticación `api_key` para las rutas públicas `/skeen/api/v1/*`.
Odoo no trae un método de autenticación `api_key`; las rutas de
`skeen_whatsapp/controllers/main.py` lo declaraban pero no existía, así que
TODAS las peticiones respondían AccessDenied. Aquí se implementa el método que
falta, sin cambiar ni el formato de la clave ni el de los encabezados:
1) CLave legacy de Hermes: `ir.config_parameter 'skeen.api.legacy_key'`
(sembrada con `skeen_api_key_2026`).
2) Claves del modelo `skeen.api.key` (hash, alcances, vencimiento, revocación).
La clave se lee de `Authorization: Bearer <clave>`, de `X-API-Key` o del
parámetro `api_key` (query/form), que es lo que ya usaba Hermes.
"""
import hmac
import json
from werkzeug.exceptions import Forbidden
from odoo import SUPERUSER_ID, models, http
from odoo.exceptions import AccessDenied
from odoo.http import request, Response
from odoo.addons.skeen_citas.clinic_settings import licencia_de, modulo_bloqueado
LEGACY_KEY_PARAM = 'skeen.api.legacy_key'
LEGACY_KEY_DEFAULT = 'skeen_api_key_2026'
# Rutas del frontend cuyo módulo se controla por licencia. Los módulos con
# bloque `activo` (compras, membresías, comisiones, recursos, notificaciones,
# reservas) ya responden 403 solos al apagarse, así que aquí solo hacen falta
# los que no tienen ese bloque.
RUTAS_LICENCIA = (
('/skeen/frontend/v1/formularios', 'formularios'),
('/skeen/frontend/v1/wacrm', 'whatsapp'),
)
def _respuesta_licencia():
"""Respuesta 403 JSON si la ruta pertenece a un módulo fuera de la licencia.
Devuelve None si la petición puede continuar. No aplica a OPTIONS (preflight
CORS) ni cuando no hay licencia en modo bloqueo."""
try:
if request.httprequest.method == 'OPTIONS':
return None
path = request.httprequest.path or ''
modulo = next((m for prefijo, m in RUTAS_LICENCIA if path.startswith(prefijo)), None)
if not modulo or not request.env:
return None
if not modulo_bloqueado(licencia_de(request.env), modulo):
return None
except Exception:
return None
cuerpo = json.dumps({'status': 'error',
'message': 'Módulo no incluido en tu plan'},
ensure_ascii=False)
return Response(cuerpo, status=403, mimetype='application/json',
headers={'Access-Control-Allow-Origin': '*'})
def extraer_clave(req):
"""Devuelve la clave de API de la petición (o '')."""
try:
headers = req.httprequest.headers
auth = headers.get('Authorization', '') or ''
if auth.lower().startswith('bearer '):
return auth[7:].strip()
for header in ('X-API-Key', 'X-Api-Key', 'Api-Key'):
valor = headers.get(header)
if valor:
return valor.strip()
args = req.httprequest.args
for parametro in ('api_key', 'api-key', 'apikey'):
if args.get(parametro):
return args.get(parametro).strip()
except Exception:
return ''
return ''
def clave_permite_scope(req, scope):
"""True si la clave de la petición concede el alcance `scope`.
La clave legacy de Hermes tiene todos los alcances. Una petición sin
`request.api_key` (p. ej. dentro de pruebas) se considera legacy.
"""
key = getattr(req, 'api_key', None)
if key is None:
return True
return key.permite(scope)
def exigir_scope(req, scope):
"""Lanza 403 (HTTP 403) si la clave no tiene el alcance indicado."""
if not clave_permite_scope(req, scope):
raise Forbidden(description='La clave de API no tiene el alcance "%s".' % scope)
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
@classmethod
def _dispatch(cls, endpoint):
respuesta = _respuesta_licencia()
if respuesta is not None:
return respuesta
return super()._dispatch(endpoint)
@classmethod
def _auth_method_api_key(cls):
raw = extraer_clave(request)
if not raw:
raise AccessDenied('Falta la clave de API (Authorization: Bearer ...).')
ICP = request.env['ir.config_parameter'].sudo()
legacy = ICP.get_param(LEGACY_KEY_PARAM) or LEGACY_KEY_DEFAULT
if hmac.compare_digest(raw, legacy):
request.api_key = None
request.api_key_legacy = True
request.update_env(user=SUPERUSER_ID)
return
key = None
if 'skeen.api.key' in request.env:
key = request.env['skeen.api.key'].sudo()._autenticar(raw)
if not key:
raise AccessDenied('Clave de API inválida o revocada.')
request.api_key = key
request.api_key_legacy = False
key._registrar_uso()
request.update_env(user=SUPERUSER_ID)

View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
"""Aplicación de los límites de la licencia.
Solo actúan con `modo='bloqueo'` y un límite > 0. Con `aviso` (default) o sin
licencia no bloquean nada. Nunca afectan tareas internas: se pueden saltar con
el contexto `skeen_licencia_skip=True` (cron, migraciones, importaciones).
"""
import logging
from datetime import date
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.addons.skeen_citas.clinic_settings import (
licencia_de, limite_licencia, licencia_bloqueo_activo,
)
_logger = logging.getLogger(__name__)
MENSAJES = {
'usuarios_max': 'usuarios',
'sucursales_max': 'sucursales',
'citas_mes_max': 'citas este mes',
'almacenamiento_mb': 'MB de almacenamiento',
}
def _skip(env):
return bool(env.context.get('skeen_licencia_skip')
or env.context.get('install_mode'))
def exigir_limite(env, clave, etiqueta, actual, agregando=1):
"""Lanza UserError si `actual + agregando` supera el límite de `clave`.
Con modo=aviso solo deja una advertencia en el log. Devuelve False si no
aplica (sin licencia, sin límite o contexto de salto).
"""
limite = limite_licencia(env, clave)
if limite <= 0 or _skip(env):
return False
licencia = licencia_de(env)
if not licencia_bloqueo_activo(licencia):
if actual + agregando > limite:
_logger.info('SKEEN licencia (aviso): %s excede el límite de %s '
'(%s + %s > %s)', etiqueta, limite, actual, agregando, limite)
return False
if actual + agregando > limite:
raise UserError(_(
'Tu plan permite hasta %(limite)s %(etiqueta)s y ya tienes '
'%(actual)s. Para ampliar el límite, contacta a tu proveedor y '
'solicita una licencia con un tope mayor.'
) % {'limite': limite, 'etiqueta': etiqueta, 'actual': actual})
return True
def _contar_usuarios(env):
return env['skeen.frontend.user'].sudo().search_count([('active', '=', True)])
def _contar_sucursales(env):
return env['skeen.sucursal'].sudo().search_count([('active', '=', True)])
def _contar_citas_mes(env):
hoy = date.today()
inicio = hoy.replace(day=1)
if hoy.month == 12:
fin = hoy.replace(year=hoy.year + 1, month=1, day=1)
else:
fin = hoy.replace(month=hoy.month + 1, day=1)
return env['skeen.cita'].sudo().search_count([
('date', '>=', inicio), ('date', '<', fin),
])
def _mb_almacenamiento(env):
env.cr.execute('SELECT COALESCE(SUM(file_size), 0) FROM ir_attachment')
total = env.cr.fetchone()[0] or 0
return total / (1024.0 * 1024.0)
class SkeenFrontendUser(models.Model):
_inherit = 'skeen.frontend.user'
@api.model_create_multi
def create(self, vals_list):
exigir_limite(self.env, 'usuarios_max', MENSAJES['usuarios_max'],
_contar_usuarios(self.env), len(vals_list))
return super().create(vals_list)
class SkeenSucursal(models.Model):
_inherit = 'skeen.sucursal'
@api.model_create_multi
def create(self, vals_list):
exigir_limite(self.env, 'sucursales_max', MENSAJES['sucursales_max'],
_contar_sucursales(self.env), len(vals_list))
return super().create(vals_list)
class SkeenCita(models.Model):
_inherit = 'skeen.cita'
@api.model_create_multi
def create(self, vals_list):
exigir_limite(self.env, 'citas_mes_max', MENSAJES['citas_mes_max'],
_contar_citas_mes(self.env), len(vals_list))
return super().create(vals_list)
class IrAttachment(models.Model):
_inherit = 'ir.attachment'
@api.model_create_multi
def create(self, vals_list):
if limite_licencia(self.env, 'almacenamiento_mb') > 0 and not _skip(self.env):
nuevos_mb = sum(max(0, int(v.get('file_size') or 0)) for v in vals_list) \
/ (1024.0 * 1024.0)
exigir_limite(self.env, 'almacenamiento_mb',
MENSAJES['almacenamiento_mb'],
_mb_almacenamiento(self.env), nuevos_mb)
return super().create(vals_list)

View File

@@ -0,0 +1,367 @@
# -*- 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})

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_api_key_user,skeen.api.key.user,model_skeen_api_key,base.group_user,1,1,1,1
access_skeen_webhook_user,skeen.webhook.user,model_skeen_webhook,base.group_user,1,1,1,1
access_skeen_webhook_evento_user,skeen.webhook.evento.user,model_skeen_webhook_evento,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_api_key_user skeen.api.key.user model_skeen_api_key base.group_user 1 1 1 1
3 access_skeen_webhook_user skeen.webhook.user model_skeen_webhook base.group_user 1 1 1 1
4 access_skeen_webhook_evento_user skeen.webhook.evento.user model_skeen_webhook_evento base.group_user 1 1 1 1