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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user