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

@@ -10,8 +10,13 @@ El nombre por defecto es el del brand (`skeen.clinic.brand` -> 'nombre') para no
cambiar lo que ya se ve en producción cuando el parámetro no está guardado.
"""
import base64
import hashlib
import hmac
import json
import os
import re
from datetime import date
from urllib.parse import urlparse
SETTINGS_KEY = 'skeen.clinic.settings'
@@ -855,6 +860,385 @@ def get_comisiones(env):
"""Bloque de comisiones vigente (copia). Fuente única para modelos y API."""
return normalizar_comisiones(get_settings(env).get('comisiones'))
# ---------------------------------------------------------------------------
# Licencia por plan (producto comercial).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: SIN licencia aplicada (`clave=''`) NADA cambia en la instancia. Además,
# con `modo='aviso'` (el default) la licencia SOLO advierte: no esconde menús,
# no apaga módulos y no impone límites. Solo con `modo='bloqueo'` y una clave
# válida se aplican los módulos incluidos y los límites.
#
# plan: basico | profesional | enterprise | custom (informativo + default de módulos)
# modo: aviso (default) | bloqueo
# cliente: nombre del cliente al que se licenció
# vence: 'YYYY-MM-DD' (vacío = sin vencimiento)
# clave: clave firmada por el vendedor (ver generar_licencia.py)
# modulos: módulos incluidos; VACÍO = todos los módulos disponibles
# limites: topes de uso; 0 = sin límite (default -> nada cambia)
#
# La licencia NUNCA bloquea lo indispensable (login, agenda, configuración):
# solo aplica a los módulos "gateables" y a los límites indicados.
# ---------------------------------------------------------------------------
LICENCIA_DEFAULT = {
'plan': '',
'modo': 'aviso',
'cliente': '',
'vence': '',
'clave': '',
'modulos': [],
'limites': {
'usuarios_max': 0,
'sucursales_max': 0,
'citas_mes_max': 0,
'almacenamiento_mb': 0,
},
}
LICENCIA_PLANES = ('basico', 'profesional', 'enterprise', 'custom')
LICENCIA_MODOS = ('aviso', 'bloqueo')
LIMITE_KEYS = ('usuarios_max', 'sucursales_max', 'citas_mes_max', 'almacenamiento_mb')
# Módulos que una licencia puede incluir/excluir. Los "gateables" son los que
# tienen un bloque de configuración con `activo` (se apagan solos) o una guarda
# explícita (whatsapp, formularios).
MODULOS_GATEABLES = (
'whatsapp', 'notificaciones', 'reservas_online', 'recursos',
'membresias', 'compras', 'comisiones', 'formularios',
)
# Módulos siempre incluidos (no se pueden quitar: son la base de la clínica).
MODULOS_BASE = ('agenda', 'pacientes', 'pos', 'inventario', 'reportes')
MODULO_LABEL = {
'agenda': 'Agenda', 'pacientes': 'Pacientes y expediente', 'pos': 'Punto de venta',
'inventario': 'Inventario', 'reportes': 'Reportes', 'whatsapp': 'WhatsApp y WACRM',
'notificaciones': 'Notificaciones', 'reservas_online': 'Reservas en línea',
'recursos': 'Recursos de la agenda', 'membresias': 'Membresías',
'compras': 'Compras y proveedores', 'comisiones': 'Comisiones',
'formularios': 'Formularios digitales',
}
# Módulo gateable -> bloque de clinic-settings cuyo `activo` se apaga al quedar
# fuera de la licencia. whatsapp y formularios no tienen bloque: se guardan por
# controlador (ver skeen_plataforma y los controladores de cada módulo).
BLOQUE_POR_MODULO = {
'notificaciones': 'notificaciones',
'reservas_online': 'reservas_online',
'recursos': 'recursos',
'membresias': 'membresias',
'compras': 'compras',
'comisiones': 'comisiones',
}
# Planes predefinidos (tabla de producto). `modulos` = lista explícita; el CLI
# de licencias la usa como default cuando no se pasan --modulos.
PLANES = {
'basico': {
'label': 'Básico',
'modulos': list(MODULOS_BASE),
},
'profesional': {
'label': 'Profesional',
'modulos': list(MODULOS_BASE) + [
'whatsapp', 'notificaciones', 'reservas_online', 'recursos',
],
},
'enterprise': {
'label': 'Enterprise',
'modulos': list(MODULOS_BASE) + list(MODULOS_GATEABLES),
},
'custom': {
'label': 'A medida',
'modulos': [],
},
}
def modulos_de_plan(plan):
"""Módulos por defecto de un plan (lista; vacía = todos)."""
return list((PLANES.get(plan) or {}).get('modulos') or [])
def normalizar_licencia(raw):
"""Bloque `licencia` completo, con defaults. Tolerante: nunca lanza."""
out = dict(LICENCIA_DEFAULT)
out['limites'] = dict(LICENCIA_DEFAULT['limites'])
out['modulos'] = []
if not isinstance(raw, dict):
return out
plan = str(raw.get('plan') or '').strip().lower()
out['plan'] = plan if plan in LICENCIA_PLANES else ''
modo = str(raw.get('modo') or '').strip().lower()
out['modo'] = modo if modo in LICENCIA_MODOS else LICENCIA_DEFAULT['modo']
out['cliente'] = str(raw.get('cliente') or '').strip()[:120]
out['vence'] = str(raw.get('vence') or '').strip()[:10]
out['clave'] = str(raw.get('clave') or '').strip()
modulos = raw.get('modulos')
if isinstance(modulos, (list, tuple)):
limpios = []
for m in modulos:
m = str(m or '').strip().lower()
if m and m in (MODULOS_BASE + MODULOS_GATEABLES) and m not in limpios:
limpios.append(m)
out['modulos'] = limpios
limites = raw.get('limites')
if isinstance(limites, dict):
for k in LIMITE_KEYS:
out['limites'][k] = max(0, _int(limites.get(k), 0))
return out
def validar_licencia(raw):
"""Valida el bloque `licencia` de un PUT directo. Devuelve SOLO las claves
presentes para que el merge no reinicie lo que el PUT no tocó.
La verificación de la FIRMA de la clave NO vive aquí (depende de la clave
del vendedor): la hace el controlador antes de guardar."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de licencia debe ser un objeto')
out = {}
if 'plan' in raw:
plan = str(raw['plan'] or '').strip().lower()
if plan and plan not in LICENCIA_PLANES:
raise ValueError('El plan debe ser básico, profesional, enterprise o custom')
out['plan'] = plan
if 'modo' in raw:
modo = str(raw['modo'] or '').strip().lower()
if modo not in LICENCIA_MODOS:
raise ValueError("El modo debe ser 'aviso' o 'bloqueo'")
out['modo'] = modo
for k, limite in (('cliente', 120), ('vence', 10)):
if k in raw:
if not isinstance(raw[k], str):
raise ValueError('El campo %s debe ser texto' % k)
if len(raw[k].strip()) > limite:
raise ValueError('El campo %s es demasiado largo' % k)
out[k] = raw[k].strip()
if 'clave' in raw:
if not isinstance(raw['clave'], str):
raise ValueError('La clave de licencia debe ser texto')
out['clave'] = raw['clave'].strip()
if 'modulos' in raw:
valor = raw['modulos']
if not isinstance(valor, list):
raise ValueError('Los módulos deben ser una lista')
limpios = []
for m in valor:
m = str(m or '').strip().lower()
if m not in (MODULOS_BASE + MODULOS_GATEABLES):
raise ValueError('Módulo desconocido: %s' % m)
if m not in limpios:
limpios.append(m)
out['modulos'] = limpios
if 'limites' in raw:
valor = raw['limites']
if not isinstance(valor, dict):
raise ValueError('Los límites deben ser un objeto')
limpios = {}
for k, v in valor.items():
if k not in LIMITE_KEYS:
raise ValueError('Límite desconocido: %s' % k)
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Los límites deben ser números enteros')
if v < 0:
raise ValueError('Los límites no pueden ser negativos')
limpios[k] = v
out['limites'] = limpios
return out
def modulos_licencia(licencia):
"""Módulos permitidos por una licencia. None = todos (sin restricción)."""
if not isinstance(licencia, dict):
return None
if not licencia.get('clave'):
return None
modulos = licencia.get('modulos') or []
if not modulos:
return None
return set(modulos)
def licencia_bloqueo_activo(licencia):
"""True si la licencia está en modo bloqueo y tiene clave aplicada."""
return bool(isinstance(licencia, dict) and licencia.get('clave')
and licencia.get('modo') == 'bloqueo')
def modulo_bloqueado(licencia, clave):
"""True si el módulo gateable `clave` queda fuera de la licencia."""
if clave not in MODULOS_GATEABLES:
return False
if not licencia_bloqueo_activo(licencia):
return False
permitidos = modulos_licencia(licencia)
if permitidos is None:
return False
return clave not in permitidos
def licencia_de(env):
"""Bloque de licencia vigente (copia)."""
return get_settings(env).get('licencia') or dict(LICENCIA_DEFAULT)
def modulo_habilitado(env, clave):
"""True si el módulo `clave` está habilitado (sin licencia = todo habilitado)."""
return not modulo_bloqueado(licencia_de(env), clave)
def limite_licencia(env, clave):
"""Tope configurado para `clave` (0 = sin límite)."""
return int((licencia_de(env).get('limites') or {}).get(clave) or 0)
def _aplicar_licencia(out):
"""Apaga los bloques `activo` de los módulos fuera de la licencia.
Se aplica SOLO con modo=bloqueo y clave válida; así el resto del sistema
(endpoints, menús y validaciones que leen `activo`) reacciona solo. Con
`aviso` o sin licencia, `out` queda intacto."""
licencia = out.get('licencia') or {}
if not licencia_bloqueo_activo(licencia):
return
for modulo, bloque in BLOQUE_POR_MODULO.items():
if modulo_bloqueado(licencia, modulo) and isinstance(out.get(bloque), dict):
out[bloque]['activo'] = False
# ---------------------------------------------------------------------------
# Clave de licencia firmada (HMAC-SHA256 con la clave del vendedor).
#
# Formato compacto:
# SKN1.<base64url(payload_json)>.<base64url(hmac_sha256(payload_b64, vendor))>
#
# `payload_json` usa claves ordenadas y separadores mínimos, para que el CLI del
# vendedor (infra/licencias/generar_licencia.py, Python stdlib) genere
# exactamente los mismos bytes. La clave del vendedor se configura en la
# instancia por:
# 1) ir.config_parameter 'skeen.licencia.vendor_key'
# 2) variable de entorno SKEEN_LICENCIA_VENDOR_KEY
# 3) archivo /root/.skeen-licencias/vendor.key (permisos 600)
# Sin clave de vendedor configurada, ninguna licencia se puede validar (y sin
# licencia aplicada el sistema se comporta como siempre).
# ---------------------------------------------------------------------------
LICENCIA_PREFIJO = 'SKN1'
LICENCIA_VENDOR_KEY_ICP = 'skeen.licencia.vendor_key'
LICENCIA_VENDOR_KEY_ENV = 'SKEEN_LICENCIA_VENDOR_KEY'
LICENCIA_VENDOR_KEY_FILE = '/root/.skeen-licencias/vendor.key'
def vendor_key(env=None):
"""Clave del vendedor vigente (ICP -> entorno -> archivo). '' si no hay."""
key = ''
if env is not None:
try:
key = env['ir.config_parameter'].sudo().get_param(LICENCIA_VENDOR_KEY_ICP) or ''
except Exception:
key = ''
if not key:
key = os.environ.get(LICENCIA_VENDOR_KEY_ENV) or ''
if not key:
try:
with open(LICENCIA_VENDOR_KEY_FILE, 'r', encoding='utf-8') as fh:
key = (fh.read() or '').strip()
except OSError:
key = ''
return key
def _b64url(raw):
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
def _b64url_decode(texto):
return base64.urlsafe_b64decode(texto + '=' * (-len(texto) % 4))
def _payload_canonico(payload):
return json.dumps(payload, sort_keys=True, separators=(',', ':'),
ensure_ascii=False).encode('utf-8')
def firmar_licencia(payload, vendor):
"""Devuelve la clave de licencia firmada (mismo algoritmos que el CLI)."""
cuerpo = _b64url(_payload_canonico(payload))
firma = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
hashlib.sha256).digest()
return '%s.%s.%s' % (LICENCIA_PREFIJO, cuerpo, _b64url(firma))
def verificar_clave_licencia(env, clave):
"""Verifica firma y vigencia. Devuelve (payload, error).
`payload` es el dict de la licencia si la clave es válida; `error` es un
mensaje claro de texto si no lo es (uno de los dos es None)."""
clave = (clave or '').strip()
if not clave:
return None, 'No se recibió ninguna clave de licencia.'
partes = clave.split('.')
if len(partes) != 3 or partes[0] != LICENCIA_PREFIJO:
return None, 'La clave de licencia no tiene el formato esperado.'
vendor = vendor_key(env)
if not vendor:
return None, ('No hay clave de vendedor configurada en esta instancia '
'(skeen.licencia.vendor_key).')
_, cuerpo, firma = partes
esperada = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
hashlib.sha256).digest()
try:
recibida = _b64url_decode(firma)
except Exception:
return None, 'La firma de la clave de licencia está corrupta.'
if not hmac.compare_digest(esperada, recibida):
return None, 'La firma de la clave no es válida (clave manipulada).'
try:
payload = json.loads(_b64url_decode(cuerpo).decode('utf-8'))
except Exception:
return None, 'El contenido de la clave de licencia está corrupto.'
if not isinstance(payload, dict):
return None, 'El contenido de la clave de licencia no es válido.'
vence = str(payload.get('vence') or '').strip()
if vence:
try:
d = date.fromisoformat(vence)
except ValueError:
return None, 'La fecha de vencimiento de la clave no es válida.'
if d < date.today():
return None, 'Esta licencia venció el %s.' % vence
return payload, None
def licencia_desde_payload(payload, clave, modo=None):
"""Bloque `licencia` a guardar a partir de una clave ya validada."""
payload = payload or {}
plan = str(payload.get('plan') or '').strip().lower()
modulos = payload.get('modulos')
if not isinstance(modulos, list):
modulos = modulos_de_plan(plan) if plan in PLANES else []
return normalizar_licencia({
'plan': plan if plan in LICENCIA_PLANES else 'custom',
'modo': modo or 'bloqueo',
'cliente': payload.get('cliente') or '',
'vence': payload.get('vence') or '',
'clave': clave,
'modulos': modulos,
'limites': payload.get('limites') or {},
})
def dias_para_vencer(licencia):
"""Días restantes hasta el vencimiento (None si no hay fecha)."""
vence = str((licencia or {}).get('vence') or '').strip()
if not vence:
return None
try:
return (date.fromisoformat(vence) - date.today()).days
except ValueError:
return None
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -879,8 +1263,12 @@ def brand_nombre(env):
return nombre or BRAND_NOMBRE_DEFAULT
def get_settings(env):
"""Objeto completo de ajustes, con defaults si no hay nada guardado."""
def get_settings(env, aplicar_licencia=True):
"""Objeto completo de ajustes, con defaults si no hay nada guardado.
`aplicar_licencia=False` devuelve lo GUARDADO sin apagar por licencia (lo
necesita set_settings: si persistiera el estado ya apagado por la licencia,
quitar la licencia no restauraría los módulos)."""
out = {
'nombre': brand_nombre(env),
'telefono': '',
@@ -901,6 +1289,7 @@ def get_settings(env):
'recursos': dict(RECURSOS_DEFAULT),
'compras': dict(COMPRAS_DEFAULT),
'comisiones': dict(COMISIONES_DEFAULT),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -919,6 +1308,9 @@ def get_settings(env):
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
out['compras'] = normalizar_compras(guardado.get('compras'))
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
if aplicar_licencia:
_aplicar_licencia(out)
return out
@@ -926,7 +1318,7 @@ def set_settings(env, vals):
"""Guarda mergeando sobre lo actual. Devuelve el objeto resultante."""
if not isinstance(vals, dict):
raise ValueError('Los ajustes deben ser un objeto')
actual = get_settings(env)
actual = get_settings(env, aplicar_licencia=False)
for k in CAMPOS:
if k in vals and vals[k] is not None:
actual[k] = str(vals[k]).strip()
@@ -961,6 +1353,14 @@ def set_settings(env, vals):
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['comisiones'] = {**actual['comisiones'], **bloque}
if 'licencia' in vals:
bloque = validar_licencia(vals['licencia'])
if bloque is not None:
# Merge anidado; los límites se mergean por clave. La verificación
# de la FIRMA de la clave la hace el controlador `/licencia` antes
# de llegar aquí (ver skeen_plataforma/controllers/licencia.py).
limites = {**actual['licencia'].get('limites', {}), **bloque.pop('limites', {})}
actual['licencia'] = {**actual['licencia'], **bloque, 'limites': limites}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)

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

View File

@@ -1,28 +1,94 @@
# -*- coding: utf-8 -*-
"""API pública para terceros (`/skeen/api/v1/*`).
Autenticación: `auth='api_key'` (ver `skeen_plataforma/models/ir_http.py`).
Acepta la clave legacy de Hermes (`ir.config_parameter 'skeen.api.legacy_key'`,
sembrada con `skeen_api_key_2026`) y las claves del modelo `skeen.api.key`.
La clave se envía en `Authorization: Bearer <clave>`, `X-API-Key` o el parámetro
`api_key`.
Cada endpoint exige un alcance (scope) a las claves del modelo; la clave legacy
pasa siempre (compatibilidad total). Una clave sin el alcance recibe HTTP 403
con mensaje claro.
Las respuestas son JSON plano (como en docs/api.md), no JSON-RPC: así los
códigos HTTP (200/401/403) son los correctos para un cliente REST.
"""
import json
from odoo import http
from odoo.http import request
import json
from odoo.http import request, Response
def _cors():
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',
}
def _json(data, status=200):
return Response(json.dumps(data, default=str), status=status,
mimetype='application/json', headers=_cors())
def _body():
"""Cuerpo JSON de la petición (o {}). Tolera envoltorio JSON-RPC `params`."""
try:
raw = request.httprequest.get_data(as_text=True)
data = json.loads(raw) if raw else {}
except Exception:
return {}
if isinstance(data, dict) and isinstance(data.get('params'), dict):
return data['params']
return data if isinstance(data, dict) else {}
def _scope_ok(scope):
"""Devuelve una respuesta 403 JSON si la clave no tiene el alcance, o None.
La clave legacy de Hermes no tiene alcances: pasa siempre. Una clave del
modelo sin el alcance recibe 403 con mensaje claro.
"""
key = getattr(request, 'api_key', None)
if key is None:
return None
if not key.permite(scope):
return _json({'status': 'error',
'message': 'La clave de API no tiene el alcance "%s".' % scope}, 403)
return None
class SkeenWhatsAppController(http.Controller):
"""Controladores API para integración con Hermes/WACRM"""
"""Controladores API para integración con Hermes/WACRM."""
@http.route('/skeen/api/v1/available_slots', type='json', auth='api_key', methods=['POST'], csrf=False)
def get_available_slots(self, service_id, date_from, date_to, **kw):
"""Consultar slots disponibles para un servicio en un rango de fechas"""
@http.route('/skeen/api/v1/available_slots', type='http', auth='api_key',
methods=['POST', 'OPTIONS'], csrf=False)
def get_available_slots(self, **kw):
"""Consultar slots disponibles para un servicio en un rango de fechas."""
denied = _scope_ok('citas.read')
if denied:
return denied
data = _body()
service_id = data.get('service_id')
date_from = data.get('date_from')
date_to = data.get('date_to')
if not (service_id and date_from and date_to):
return _json({'status': 'error',
'message': 'service_id, date_from y date_to son obligatorios'}, 400)
try:
servicio = request.env['skeen.servicio'].browse(int(service_id))
if not servicio.exists():
return {'status': 'error', 'message': 'Servicio no encontrado'}
return _json({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
slots = []
from datetime import datetime, timedelta
current_date = datetime.strptime(date_from, '%Y-%m-%d').date()
end_date = datetime.strptime(date_to, '%Y-%m-%d').date()
while current_date <= end_date:
day_slots = request.env['skeen.cita'].get_available_slots(current_date, int(service_id))
day_slots = request.env['skeen.cita'].get_available_slots(
current_date, int(service_id))
for slot in day_slots:
slots.append({
'date': current_date.strftime('%Y-%m-%d'),
@@ -30,21 +96,29 @@ class SkeenWhatsAppController(http.Controller):
'datetime': f"{current_date.strftime('%Y-%m-%d')} {slot['time_str']}",
})
current_date += timedelta(days=1)
return {'status': 'success', 'slots': slots}
return _json({'status': 'success', 'slots': slots})
except Exception as e:
return {'status': 'error', 'message': str(e)}
return _json({'status': 'error', 'message': str(e)}, 400)
@http.route('/skeen/api/v1/create_appointment', type='json', auth='api_key', methods=['POST'], csrf=False)
def create_appointment(self, phone, name, service_id, date, time, doctor_id=None, duration=None, **kw):
"""Crear una nueva cita (flujo Hermes/WACRM).
`doctor_id` y `duration` son opcionales. Si se indica médico, se validan
su horario semanal y sus bloqueos de agenda; sin médico, la cita se
rechaza solo si la clínica completa está bloqueada a esa hora.
"""
@http.route('/skeen/api/v1/create_appointment', type='http', auth='api_key',
methods=['POST', 'OPTIONS'], csrf=False)
def create_appointment(self, **kw):
"""Crear una nueva cita (flujo Hermes/WACRM)."""
denied = _scope_ok('citas.write')
if denied:
return denied
data = _body()
phone = data.get('phone')
name = data.get('name')
service_id = data.get('service_id')
date = data.get('date')
time = data.get('time')
doctor_id = data.get('doctor_id')
duration = data.get('duration')
if not (phone and name and service_id and date and time):
return _json({'status': 'error',
'message': 'phone, name, service_id, date y time son obligatorios'}, 400)
try:
# Buscar o crear paciente
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
if not partner:
partner = request.env['res.partner'].create({
@@ -53,12 +127,8 @@ class SkeenWhatsAppController(http.Controller):
'is_patient': True,
'source': 'whatsapp',
})
# Convertir time a float
time_parts = time.split(':')
time_parts = str(time).split(':')
time_float = float(time_parts[0]) + float(time_parts[1]) / 60.0
# Crear cita
vals = {
'partner_id': partner.id,
'servicio_id': int(service_id),
@@ -71,37 +141,38 @@ class SkeenWhatsAppController(http.Controller):
if duration:
vals['duration'] = int(duration)
cita = request.env['skeen.cita'].create(vals)
return {
return _json({
'status': 'success',
'booking_id': cita.id,
'reference': cita.name,
'patient_id': partner.patient_id,
}
'patient_id': partner.patient_id if 'patient_id' in partner._fields else partner.id,
})
except Exception as e:
return {'status': 'error', 'message': str(e)}
return _json({'status': 'error', 'message': str(e)}, 400)
@http.route('/skeen/api/v1/patient_balance', type='json', auth='api_key', methods=['POST'], csrf=False)
def get_patient_balance(self, phone, **kw):
"""Obtener saldo pendiente y monedero del paciente"""
@http.route('/skeen/api/v1/patient_balance', type='http', auth='api_key',
methods=['POST', 'OPTIONS'], csrf=False)
def get_patient_balance(self, **kw):
"""Obtener saldo pendiente y monedero del paciente."""
denied = _scope_ok('pacientes.read')
if denied:
return denied
phone = _body().get('phone')
if not phone:
return _json({'status': 'error', 'message': 'phone es obligatorio'}, 400)
try:
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
if not partner:
return {'status': 'error', 'message': 'Paciente no encontrado'}
# Citas pendientes de pago
return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
pending_citas = request.env['skeen.cita'].search([
('partner_id', '=', partner.id),
('payment_state', '!=', 'paid'),
('state', 'in', ('confirmed', 'arrived', 'in_progress', 'done')),
])
pending_balance = sum(c.servicio_price for c in pending_citas)
# Monedero
monedero = request.env['skeen.monedero'].search([('partner_id', '=', partner.id)], limit=1)
return {
monedero = request.env['skeen.monedero'].search(
[('partner_id', '=', partner.id)], limit=1)
return _json({
'status': 'success',
'pending_balance': pending_balance,
'pending_invoices': [{
@@ -113,35 +184,51 @@ class SkeenWhatsAppController(http.Controller):
} for c in pending_citas],
'wallet_points': monedero.points if monedero else 0,
'wallet_mxn': monedero.equivalent_mxn if monedero else 0,
}
})
except Exception as e:
return {'status': 'error', 'message': str(e)}
return _json({'status': 'error', 'message': str(e)}, 400)
@http.route('/skeen/api/v1/wallet_redeem', type='json', auth='api_key', methods=['POST'], csrf=False)
def redeem_wallet(self, phone, points, appointment_id=None, **kw):
"""Redimir puntos del monedero"""
@http.route('/skeen/api/v1/wallet_redeem', type='http', auth='api_key',
methods=['POST', 'OPTIONS'], csrf=False)
def redeem_wallet(self, **kw):
"""Redimir puntos del monedero."""
denied = _scope_ok('monedero.write')
if denied:
return denied
data = _body()
phone = data.get('phone')
points = data.get('points')
if not (phone and points is not None):
return _json({'status': 'error', 'message': 'phone y points son obligatorios'}, 400)
try:
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
if not partner:
return {'status': 'error', 'message': 'Paciente no encontrado'}
monedero = request.env['skeen.monedero'].search([('partner_id', '=', partner.id)], limit=1)
return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
monedero = request.env['skeen.monedero'].search(
[('partner_id', '=', partner.id)], limit=1)
if not monedero:
return {'status': 'error', 'message': 'Monedero no encontrado'}
return _json({'status': 'error', 'message': 'Monedero no encontrado'}, 404)
monedero.redeem_points(int(points))
return {
return _json({
'status': 'success',
'remaining_points': monedero.points,
'remaining_mxn': monedero.equivalent_mxn,
}
})
except Exception as e:
return {'status': 'error', 'message': str(e)}
return _json({'status': 'error', 'message': str(e)}, 400)
@http.route('/skeen/api/v1/cancel_appointment', type='json', auth='api_key', methods=['POST'], csrf=False)
def cancel_appointment(self, phone, booking_id=None, **kw):
"""Cancelar una cita"""
@http.route('/skeen/api/v1/cancel_appointment', type='http', auth='api_key',
methods=['POST', 'OPTIONS'], csrf=False)
def cancel_appointment(self, **kw):
"""Cancelar una cita."""
denied = _scope_ok('citas.write')
if denied:
return denied
data = _body()
phone = data.get('phone')
booking_id = data.get('booking_id')
if not phone:
return _json({'status': 'error', 'message': 'phone es obligatorio'}, 400)
try:
domain = [
('partner_id.phone', '=', phone),
@@ -149,26 +236,24 @@ class SkeenWhatsAppController(http.Controller):
]
if booking_id:
domain.append(('id', '=', int(booking_id)))
cita = request.env['skeen.cita'].search(domain, order='date asc', limit=1)
if not cita:
return {'status': 'error', 'message': 'No se encontró cita activa'}
return _json({'status': 'error', 'message': 'No se encontró cita activa'}, 404)
cita.write({'state': 'cancelled'})
return {
'status': 'success',
'cancelled_booking': cita.name,
}
return _json({'status': 'success', 'cancelled_booking': cita.name})
except Exception as e:
return {'status': 'error', 'message': str(e)}
return _json({'status': 'error', 'message': str(e)}, 400)
@http.route('/skeen/api/v1/services', type='json', auth='api_key', methods=['GET', 'POST'], csrf=False)
@http.route('/skeen/api/v1/services', type='http', auth='api_key',
methods=['GET', 'POST', 'OPTIONS'], csrf=False)
def list_services(self, **kw):
"""Listar todos los servicios disponibles"""
"""Listar todos los servicios disponibles."""
denied = _scope_ok('servicios.read')
if denied:
return denied
try:
services = request.env['skeen.servicio'].search([('active', '=', True)])
return {
return _json({
'status': 'success',
'services': [{
'id': s.id,
@@ -182,11 +267,12 @@ class SkeenWhatsAppController(http.Controller):
'duration_min': s.duration_min,
'description': s.description,
} for s in services],
}
})
except Exception as e:
return {'status': 'error', 'message': str(e)}
return _json({'status': 'error', 'message': str(e)}, 400)
@http.route('/skeen/api/v1/health', type='json', auth='none', methods=['GET'], csrf=False)
@http.route('/skeen/api/v1/health', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
def health_check(self, **kw):
"""Health check para monitoreo"""
return {'status': 'ok', 'service': 'skeen-odoo-api', 'version': '1.0.0'}
"""Health check para monitoreo (sin autenticación)."""
return _json({'status': 'ok', 'service': 'skeen-odoo-api', 'version': '2.0.0'})

View File

@@ -127,6 +127,10 @@ PERMISSION_GROUPS = [
('config.editar', 'Editar la configuración de la clínica'),
('config.auditoria', 'Ver la auditoría'),
]),
('plataforma', 'Licencia, API y Webhooks', [
('api.claves', 'Administrar claves de API'),
('webhooks.editar', 'Administrar webhooks'),
]),
]
PERM_KEYS = tuple(
@@ -274,6 +278,9 @@ DEFAULT_PERM_ROLES = {
'config.roles': AG,
'config.editar': AG,
'config.auditoria': AD,
# Plataforma: claves de API solo admin; webhooks admin/gerencia.
'api.claves': AD,
'webhooks.editar': AG,
}
# Los roles nuevos (creados desde la UI) arrancan como 'lectura': el admin