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:
16
odoo-addons/skeen_plataforma/__init__.py
Normal file
16
odoo-addons/skeen_plataforma/__init__.py
Normal 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')
|
||||
38
odoo-addons/skeen_plataforma/__manifest__.py
Normal file
38
odoo-addons/skeen_plataforma/__manifest__.py
Normal 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',
|
||||
}
|
||||
5
odoo-addons/skeen_plataforma/controllers/__init__.py
Normal file
5
odoo-addons/skeen_plataforma/controllers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import licencia
|
||||
from . import api_keys
|
||||
from . import webhooks
|
||||
125
odoo-addons/skeen_plataforma/controllers/api_keys.py
Normal file
125
odoo-addons/skeen_plataforma/controllers/api_keys.py
Normal 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)
|
||||
219
odoo-addons/skeen_plataforma/controllers/licencia.py
Normal file
219
odoo-addons/skeen_plataforma/controllers/licencia.py
Normal 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)
|
||||
225
odoo-addons/skeen_plataforma/controllers/webhooks.py
Normal file
225
odoo-addons/skeen_plataforma/controllers/webhooks.py
Normal 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)
|
||||
17
odoo-addons/skeen_plataforma/data/webhooks_cron.xml
Normal file
17
odoo-addons/skeen_plataforma/data/webhooks_cron.xml
Normal 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>
|
||||
7
odoo-addons/skeen_plataforma/models/__init__.py
Normal file
7
odoo-addons/skeen_plataforma/models/__init__.py
Normal 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
|
||||
168
odoo-addons/skeen_plataforma/models/api_key.py
Normal file
168
odoo-addons/skeen_plataforma/models/api_key.py
Normal 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 '',
|
||||
})
|
||||
193
odoo-addons/skeen_plataforma/models/eventos.py
Normal file
193
odoo-addons/skeen_plataforma/models/eventos.py
Normal 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
|
||||
132
odoo-addons/skeen_plataforma/models/ir_http.py
Normal file
132
odoo-addons/skeen_plataforma/models/ir_http.py
Normal 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)
|
||||
125
odoo-addons/skeen_plataforma/models/limites.py
Normal file
125
odoo-addons/skeen_plataforma/models/limites.py
Normal 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)
|
||||
367
odoo-addons/skeen_plataforma/models/webhook.py
Normal file
367
odoo-addons/skeen_plataforma/models/webhook.py
Normal 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})
|
||||
@@ -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
|
||||
|
Reference in New Issue
Block a user