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

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

View File

@@ -0,0 +1,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)