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,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)